From 56b526a2e432d2bae1ca6c3c181abec575b9d518 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski <janne@kuschku.de> Date: Thu, 5 Aug 2021 16:40:52 +0200 Subject: [PATCH] Implement routing --- ui/package-lock.json | 76 +++++++++++++ ui/package.json | 14 ++- ui/src/App.tsx | 50 +++++++- ui/src/components/ImageList.tsx | 20 ---- ui/src/components/ImageListViewProps.tsx | 74 ++++++++++++ ui/src/components/ImageView.tsx | 97 ---------------- ui/src/pages/ImageDetailPage.tsx | 138 +++++++++++++++++++++++ ui/src/pages/ImageListPage.tsx | 32 ++++++ 8 files changed, 376 insertions(+), 125 deletions(-) delete mode 100644 ui/src/components/ImageList.tsx create mode 100644 ui/src/components/ImageListViewProps.tsx delete mode 100644 ui/src/components/ImageView.tsx create mode 100644 ui/src/pages/ImageDetailPage.tsx create mode 100644 ui/src/pages/ImageListPage.tsx diff --git a/ui/package-lock.json b/ui/package-lock.json index eb0ce5c..1979148 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -16,14 +16,20 @@ "@types/node": "^12.20.17", "@types/react": "^17.0.15", "@types/react-dom": "^17.0.9", + "axios": "^0.21.1", "mdi-material-ui": "^6.22.1", "react": "^17.0.2", "react-dom": "^17.0.2", "react-query": "^3.19.1", + "react-router": "^5.2.0", "react-router-dom": "^5.2.0", "react-scripts": "4.0.3", "typescript": "^4.3.5", "web-vitals": "^1.1.2" + }, + "devDependencies": { + "@types/react-router": "^5.1.16", + "@types/react-router-dom": "^5.1.8" } }, "node_modules/@babel/code-frame": { @@ -3661,6 +3667,12 @@ "@types/node": "*" } }, + "node_modules/@types/history": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.9.tgz", + "integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==", + "dev": true + }, "node_modules/@types/html-minifier-terser": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz", @@ -3754,6 +3766,27 @@ "@types/react": "*" } }, + "node_modules/@types/react-router": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.16.tgz", + "integrity": "sha512-8d7nR/fNSqlTFGHti0R3F9WwIertOaaA1UEB8/jr5l5mDMOs4CidEgvvYMw4ivqrBK+vtVLxyTj2P+Pr/dtgzg==", + "dev": true, + "dependencies": { + "@types/history": "*", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.8.tgz", + "integrity": "sha512-03xHyncBzG0PmDmf8pf3rehtjY0NpUj7TIN46FrT5n1ZWHPZvXz32gUyNboJ+xsL8cpg8bQVLcllptcQHvocrw==", + "dev": true, + "dependencies": { + "@types/history": "*", + "@types/react": "*", + "@types/react-router": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.2.tgz", @@ -4683,6 +4716,14 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "dependencies": { + "follow-redirects": "^1.10.0" + } + }, "node_modules/axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", @@ -24680,6 +24721,12 @@ "@types/node": "*" } }, + "@types/history": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.9.tgz", + "integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==", + "dev": true + }, "@types/html-minifier-terser": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz", @@ -24773,6 +24820,27 @@ "@types/react": "*" } }, + "@types/react-router": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.16.tgz", + "integrity": "sha512-8d7nR/fNSqlTFGHti0R3F9WwIertOaaA1UEB8/jr5l5mDMOs4CidEgvvYMw4ivqrBK+vtVLxyTj2P+Pr/dtgzg==", + "dev": true, + "requires": { + "@types/history": "*", + "@types/react": "*" + } + }, + "@types/react-router-dom": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.8.tgz", + "integrity": "sha512-03xHyncBzG0PmDmf8pf3rehtjY0NpUj7TIN46FrT5n1ZWHPZvXz32gUyNboJ+xsL8cpg8bQVLcllptcQHvocrw==", + "dev": true, + "requires": { + "@types/history": "*", + "@types/react": "*", + "@types/react-router": "*" + } + }, "@types/react-transition-group": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.2.tgz", @@ -25496,6 +25564,14 @@ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.3.2.tgz", "integrity": "sha512-5LMaDRWm8ZFPAEdzTYmgjjEdj1YnQcpfrVajO/sn/LhbpGp0Y0H64c2hLZI1gRMxfA+w1S71Uc/nHaOXgcCvGg==" }, + "axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, "axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", diff --git a/ui/package.json b/ui/package.json index efbbf5d..2a79810 100644 --- a/ui/package.json +++ b/ui/package.json @@ -8,14 +8,12 @@ "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^11.2.7", "@testing-library/user-event": "^12.8.3", - "@types/jest": "^26.0.24", - "@types/node": "^12.20.17", - "@types/react": "^17.0.15", - "@types/react-dom": "^17.0.9", + "axios": "^0.21.1", "mdi-material-ui": "^6.22.1", "react": "^17.0.2", "react-dom": "^17.0.2", "react-query": "^3.19.1", + "react-router": "^5.2.0", "react-router-dom": "^5.2.0", "react-scripts": "4.0.3", "typescript": "^4.3.5", @@ -44,5 +42,13 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "@types/jest": "^26.0.24", + "@types/node": "^12.20.17", + "@types/react": "^17.0.15", + "@types/react-dom": "^17.0.9", + "@types/react-router": "^5.1.16", + "@types/react-router-dom": "^5.1.8" } } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b89a5bb..d588c5b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,18 +1,60 @@ -import React from 'react'; import './App.css'; -import ImageList from "./components/ImageList"; import {BaseUrlProvider} from './api/baseUrlContext'; import {QueryClient, QueryClientProvider} from "react-query"; import UploadView from "./components/UploadView"; +import {BrowserRouter, Link, Route} from "react-router-dom"; +import ImageListPage from "./pages/ImageListPage"; +import {Redirect, Switch} from "react-router"; +import ImageDetailPage from "./pages/ImageDetailPage"; +import {AppBar, Button, Toolbar, Typography} from "@material-ui/core"; +import {useErrorDisplay} from "./components/ErrorContext"; const queryClient = new QueryClient(); function App() { + const [ErrorDisplay, ErrorWrapper] = useErrorDisplay(); + return ( <BaseUrlProvider value="http://localhost:8080/"> <QueryClientProvider client={queryClient}> - <UploadView /> - <ImageList/> + <BrowserRouter> + <AppBar position="static"> + <Toolbar> + <Typography variant="h6"> + i.k8r.eu + </Typography> + <Button + color="inherit" + component={Link} + to="/upload" + > + Upload + </Button> + <Button + color="inherit" + component={Link} + to="/images" + > + Images + </Button> + </Toolbar> + </AppBar> + <ErrorDisplay/> + <ErrorWrapper> + <Switch> + <Route path="/upload"> + <UploadView/> + </Route> + <Route path="/images"> + <ImageListPage/> + </Route> + <Route path="/i/:imageId"> + <ImageDetailPage/> + </Route> + <Redirect from="/" to="/images"/> + </Switch> + </ErrorWrapper> + </BrowserRouter> </QueryClientProvider> </BaseUrlProvider> ); diff --git a/ui/src/components/ImageList.tsx b/ui/src/components/ImageList.tsx deleted file mode 100644 index f46895f..0000000 --- a/ui/src/components/ImageList.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import '../App.css'; -import {useListImages} from "../api/useListImages"; -import ImageView from "./ImageView"; - -export default function ImageList() { - const {status, data, error} = useListImages(); - return ( - <div> - <p>{status}</p> - <p>{error as string}</p> - {data?.map(image => ( - <ImageView - key={image.id} - image={image} - /> - ))} - </div> - ); -} diff --git a/ui/src/components/ImageListViewProps.tsx b/ui/src/components/ImageListViewProps.tsx new file mode 100644 index 0000000..0f7132b --- /dev/null +++ b/ui/src/components/ImageListViewProps.tsx @@ -0,0 +1,74 @@ +import {Fragment} from "react"; +import {Link} from "react-router-dom"; +import {Image} from "../api/model/Image"; +import {parseRatio} from "../metadata/Ratio"; + +export interface ImageListViewProps { + images: Image[], +} + +export function ImageListView({images}: ImageListViewProps) { + const rows: (Image | null)[][] = []; + if (images) { + let row = []; + for (let i = 0; i < images.length; i++) { + if (i % 4 === 0 && row.length !== 0) { + rows.push(row); + row = []; + } + row.push(images[i]); + } + rows.push([...row, ...([null, null, null].slice(0, 4 - row.length))]); + } + + return ( + <Fragment> + <style> + {`img { + height: 100%; + max-width: 100%; + margin: 2px; + } + + a:first-child img { + margin-left: -8px; + } + + a:last-child img { + margin-right: -8px; + }`} + </style> + <div style={{padding: "0 8px", fontSize: 0}}> + {rows.map(row => { + let ratios = 0; + for (const image of row) { + const [w, h] = image ? determineAspectRatio(image) : [1, 1]; + ratios += w / h; + } + + return ( + <div style={{aspectRatio: "" + ratios + "/" + 1}}> + {row.map(image => image && ( + <Link to={`/i/${image.id}`}> + <img src={image.url} alt=""/> + </Link> + ))} + </div> + ) + })} + </div> + </Fragment> + ) +} + +function determineAspectRatio(image: Image): [number, number] { + const aspectRatio = parseRatio(image.metadata["AspectRatio"]); + if (aspectRatio === undefined) { + console.log("Did not find aspect ratio: ", image.id); + return [1, 1]; + } else { + console.log("Found aspect ratio: ", image.id, aspectRatio.numerator, aspectRatio.denominator); + return [aspectRatio.numerator, aspectRatio.denominator]; + } +} + diff --git a/ui/src/components/ImageView.tsx b/ui/src/components/ImageView.tsx deleted file mode 100644 index a8bc740..0000000 --- a/ui/src/components/ImageView.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import {Image} from "../api/model/Image"; -import React, {useMemo, useState} from "react"; -import {useUpdateImage} from "../api/useUpdateImage"; -import {useDeleteImage} from "../api/useDeleteImage"; -import {parseMetadata} from "../metadata/ImageMetadata"; -import ImageMetadataView from "./ImageMetadataView"; -import {Button, List, ListItem, ListItemIcon, ListItemText, TextField} from "@material-ui/core"; -import {Event, Info} from "@material-ui/icons"; -import {File, Tag} from "mdi-material-ui"; - -export interface ImageProps { - image: Image -} - -export default function ImageView({image}: ImageProps) { - const {mutate: update, error: updateError, isLoading: updateLoading} = useUpdateImage(); - const {mutate: remove, error: removeError, isLoading: removeLoading} = useDeleteImage(); - const [title, setTitle] = useState<string>(image.title); - const [description, setDescription] = useState<string>(image.description); - - const metadata = useMemo(() => - parseMetadata(image.metadata), - [image]); - - return ( - <div> - <p>UpdateError: {JSON.stringify(updateError, null, 2)}</p> - <p>RemoveError: {JSON.stringify(removeError, null, 2)}</p> - <p>UpdateLoading: {JSON.stringify(updateLoading, null, 2)}</p> - <p>RemoveLoading: {JSON.stringify(removeLoading, null, 2)}</p> - <img src={image.url + "l"} alt=""/> - <List dense> - <ListItem dense> - <ListItemIcon><Info/></ListItemIcon> - <ListItemText primary="Id" secondary={image.id}/> - </ListItem> - <ListItem dense> - <ListItemText inset primary="Owner" secondary={image.owner}/> - </ListItem> - <ListItem dense> - <ListItemText inset primary="State" secondary={image.state}/> - </ListItem> - <ListItem dense> - <ListItemIcon><Tag/></ListItemIcon> - {/* TODO: Fix this ugly nesting */} - <ListItemText - primary="Title" - secondary={<TextField - fullWidth - value={title} - onChange={({target: {value}}) => - setTitle(value)} - />} - /> - </ListItem> - <ListItem dense> - {/* TODO: Fix this ugly nesting */} - <ListItemText - inset - primary="Description" - secondary={<TextField - fullWidth - value={description} - onChange={({target: {value}}) => - setDescription(value)} - />} - /> - </ListItem> - <ListItem dense> - <ListItemIcon><File/></ListItemIcon> - <ListItemText primary="Filename" secondary={image.original_name}/> - </ListItem> - <ListItem dense> - <ListItemText inset primary="MIME Type" secondary={image.mime_type}/> - </ListItem> - <ListItem dense> - <ListItemIcon><Event/></ListItemIcon> - <ListItemText primary="Uploaded At" secondary={image.created_at}/> - </ListItem> - <ListItem dense> - <ListItemText inset primary="Modified At" secondary={image.updated_at}/> - </ListItem> - <ImageMetadataView metadata={metadata}/> - </List> - <Button - onClick={() => update({ - ...image, - title, - description, - })} - >Save</Button> - <Button - onClick={() => remove(image)} - >Delete</Button> - </div> - ) -} diff --git a/ui/src/pages/ImageDetailPage.tsx b/ui/src/pages/ImageDetailPage.tsx new file mode 100644 index 0000000..931187d --- /dev/null +++ b/ui/src/pages/ImageDetailPage.tsx @@ -0,0 +1,138 @@ +import {useUpdateImage} from "../api/useUpdateImage"; +import {useDeleteImage} from "../api/useDeleteImage"; +import {useEffect, useMemo, useState} from "react"; +import {parseMetadata} from "../metadata/ImageMetadata"; +import { + Button, + CircularProgress, + Grid, + LinearProgress, + List, + ListItem, + ListItemIcon, + ListItemText, + TextField +} from "@material-ui/core"; +import {Delete, Event, Info, Save} from "@material-ui/icons"; +import {File, Tag} from "mdi-material-ui"; +import ImageMetadataView from "../components/ImageMetadataView"; +import {useGetImage} from "../api/useGetImage"; +import {useParams} from "react-router"; +import {ErrorPortal} from "../components/ErrorContext"; +import {ErrorAlert} from "../components/ErrorAlert"; + +export interface ImageDetailPageParams { + imageId: string +} + +export default function ImageDetailPage() { + const {imageId} = useParams<ImageDetailPageParams>(); + const {data: image, error: imageError, isLoading: imageLoading} = useGetImage(imageId); + const {mutate: update, error: updateError, isLoading: updateLoading} = useUpdateImage(); + const {mutate: remove, error: removeError, isLoading: removeLoading} = useDeleteImage(); + const [title, setTitle] = useState<string>(image?.title || ""); + const [description, setDescription] = useState<string>(image?.description || ""); + useEffect(() => setTitle(image?.title || ""), [image?.title]); + useEffect(() => setDescription(image?.description || ""), [image?.description]); + + const metadata = useMemo(() => parseMetadata(image?.metadata), [image]); + + if (image === undefined || metadata === undefined) { + return ( + <div>Error: 404</div> + ); + } + + return ( + <div> + {imageLoading && ( + <LinearProgress/> + )} + <ErrorPortal> + <ErrorAlert severity="error" error={imageError}/> + <ErrorAlert severity="error" error={updateError}/> + <ErrorAlert severity="error" error={removeError}/> + </ErrorPortal> + <Grid container> + <Grid item xs={12} md={6}> + <img src={image.url + "l"} alt={image.title} style={{width: "100%"}}/> + <List dense> + <ListItem dense> + <ListItemIcon><Info/></ListItemIcon> + <ListItemText primary="Id" secondary={image.id}/> + </ListItem> + <ListItem dense> + <ListItemText inset primary="Owner" secondary={image.owner}/> + </ListItem> + <ListItem dense> + <ListItemText inset primary="State" secondary={image.state}/> + </ListItem> + <ListItem dense> + <ListItemIcon><Tag/></ListItemIcon> + {/* TODO: Fix this ugly nesting */} + <ListItemText + primary="Title" + secondary={<TextField + fullWidth + value={title} + onChange={({target: {value}}) => + setTitle(value)} + />} + /> + </ListItem> + <ListItem dense> + {/* TODO: Fix this ugly nesting */} + <ListItemText + inset + primary="Description" + secondary={<TextField + fullWidth + value={description} + onChange={({target: {value}}) => + setDescription(value)} + />} + /> + </ListItem> + </List> + <Button + variant="contained" + color="primary" + disabled={updateLoading} + startIcon={updateLoading ? <CircularProgress style={{color: "#fff"}} size="1em"/> : <Save/>} + onClick={() => update({ + ...image, + title, + description, + })} + >Save</Button> + <Button + variant="contained" + color="secondary" + disabled={removeLoading} + startIcon={removeLoading ? <CircularProgress style={{color: "#fff"}} size="1em"/> : <Delete/>} + onClick={() => remove(image)} + >Delete</Button> + </Grid> + <Grid item xs={12} md={6}> + <List dense> + <ListItem dense> + <ListItemIcon><File/></ListItemIcon> + <ListItemText primary="Filename" secondary={image.original_name}/> + </ListItem> + <ListItem dense> + <ListItemText inset primary="MIME Type" secondary={image.mime_type}/> + </ListItem> + <ListItem dense> + <ListItemIcon><Event/></ListItemIcon> + <ListItemText primary="Uploaded At" secondary={image.created_at}/> + </ListItem> + <ListItem dense> + <ListItemText inset primary="Modified At" secondary={image.updated_at}/> + </ListItem> + <ImageMetadataView metadata={metadata}/> + </List> + </Grid> + </Grid> + </div> + ) +} diff --git a/ui/src/pages/ImageListPage.tsx b/ui/src/pages/ImageListPage.tsx new file mode 100644 index 0000000..7649d58 --- /dev/null +++ b/ui/src/pages/ImageListPage.tsx @@ -0,0 +1,32 @@ +import {Fragment} from "react"; +import {useListImages} from "../api/useListImages"; +import {ImageList, ImageListItem, ImageListItemBar, LinearProgress} from "@material-ui/core"; +import {Link} from "react-router-dom"; +import {ErrorPortal} from "../components/ErrorContext"; +import {ErrorAlert} from "../components/ErrorAlert"; + +export default function ImageListPage() { + const {data: images, error, isLoading} = useListImages(); + + return ( + <Fragment> + {isLoading && ( + <LinearProgress/> + )} + <ErrorPortal> + <ErrorAlert severity="error" error={error}/> + </ErrorPortal> + <ImageList cols={5}> + {images?.map(image => ( + <ImageListItem component={Link} to={`/i/${image.id}`}> + <img src={image.url} alt={image.title}/> + <ImageListItemBar + title={image.title} + subtitle={image.original_name} + /> + </ImageListItem> + ))} + </ImageList> + </Fragment> + ); +} -- GitLab