Add reviews page, switch between pages

This commit is contained in:
powermaker450 2024-09-11 23:26:34 -04:00
parent a3de9ba924
commit 6fe9fbb3cb
9 changed files with 354 additions and 19 deletions

View file

@ -15,8 +15,10 @@
"@fontsource/roboto": "^5.0.14", "@fontsource/roboto": "^5.0.14",
"@mui/icons-material": "^6.0.0", "@mui/icons-material": "^6.0.0",
"@mui/material": "^6.0.0", "@mui/material": "^6.0.0",
"icons": "link:@mui/types/material/icons",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"yup": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.9.0", "@eslint/js": "^9.9.0",

View file

@ -23,12 +23,18 @@ importers:
'@mui/material': '@mui/material':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0(@emotion/react@11.13.3(@types/react@18.3.4)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.3(@types/react@18.3.4)(react@18.3.1))(@types/react@18.3.4)(react@18.3.1))(@types/react@18.3.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 6.0.0(@emotion/react@11.13.3(@types/react@18.3.4)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.3(@types/react@18.3.4)(react@18.3.1))(@types/react@18.3.4)(react@18.3.1))(@types/react@18.3.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
icons:
specifier: link:@mui/types/material/icons
version: link:@mui/types/material/icons
react: react:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1 version: 18.3.1
react-dom: react-dom:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
yup:
specifier: ^1.4.0
version: 1.4.0
devDependencies: devDependencies:
'@eslint/js': '@eslint/js':
specifier: ^9.9.0 specifier: ^9.9.0
@ -1172,6 +1178,9 @@ packages:
prop-types@15.8.1: prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
property-expr@2.0.6:
resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
punycode@2.3.1: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -1294,6 +1303,9 @@ packages:
text-table@0.2.0: text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
tiny-case@1.0.3:
resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==}
to-fast-properties@2.0.0: to-fast-properties@2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -1302,6 +1314,9 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
toposort@2.0.2:
resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==}
ts-api-utils@1.3.0: ts-api-utils@1.3.0:
resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
@ -1312,6 +1327,10 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
type-fest@2.19.0:
resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
engines: {node: '>=12.20'}
typescript-eslint@8.3.0: typescript-eslint@8.3.0:
resolution: {integrity: sha512-EvWjwWLwwKDIJuBjk2I6UkV8KEQcwZ0VM10nR1rIunRDIP67QJTZAHBXTX0HW/oI1H10YESF8yWie8fRQxjvFA==} resolution: {integrity: sha512-EvWjwWLwwKDIJuBjk2I6UkV8KEQcwZ0VM10nR1rIunRDIP67QJTZAHBXTX0HW/oI1H10YESF8yWie8fRQxjvFA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -1386,6 +1405,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
yup@1.4.0:
resolution: {integrity: sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==}
snapshots: snapshots:
'@ampproject/remapping@2.3.0': '@ampproject/remapping@2.3.0':
@ -2474,6 +2496,8 @@ snapshots:
object-assign: 4.1.1 object-assign: 4.1.1
react-is: 16.13.1 react-is: 16.13.1
property-expr@2.0.6: {}
punycode@2.3.1: {} punycode@2.3.1: {}
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
@ -2591,12 +2615,16 @@ snapshots:
text-table@0.2.0: {} text-table@0.2.0: {}
tiny-case@1.0.3: {}
to-fast-properties@2.0.0: {} to-fast-properties@2.0.0: {}
to-regex-range@5.0.1: to-regex-range@5.0.1:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
toposort@2.0.2: {}
ts-api-utils@1.3.0(typescript@5.5.4): ts-api-utils@1.3.0(typescript@5.5.4):
dependencies: dependencies:
typescript: 5.5.4 typescript: 5.5.4
@ -2605,6 +2633,8 @@ snapshots:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-fest@2.19.0: {}
typescript-eslint@8.3.0(eslint@9.9.1)(typescript@5.5.4): typescript-eslint@8.3.0(eslint@9.9.1)(typescript@5.5.4):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.3.0(@typescript-eslint/parser@8.3.0(eslint@9.9.1)(typescript@5.5.4))(eslint@9.9.1)(typescript@5.5.4) '@typescript-eslint/eslint-plugin': 8.3.0(@typescript-eslint/parser@8.3.0(eslint@9.9.1)(typescript@5.5.4))(eslint@9.9.1)(typescript@5.5.4)
@ -2647,3 +2677,10 @@ snapshots:
yaml@1.10.2: {} yaml@1.10.2: {}
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
yup@1.4.0:
dependencies:
property-expr: 2.0.6
tiny-case: 1.0.3
toposort: 2.0.2
type-fest: 2.19.0

View file

@ -2,12 +2,41 @@ import "./App.css";
import { Route, Routes } from "react-router-dom"; import { Route, Routes } from "react-router-dom";
import Home from "./pages/Home"; import Home from "./pages/Home";
import NotFound from "./pages/NotFound"; import NotFound from "./pages/NotFound";
import ReviewsPage from "./pages/ReviewsPage";
import { useState } from "react";
import { ServerSideReview } from "./types";
function App() { function App() {
const [endpoint, setEndpoint] = useState("");
const [secure, setSecure] = useState(false);
const [currentReviews, setCurrentReviews] = useState<ServerSideReview[]>([]);
return ( return (
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route
<Route path="*" element={<NotFound />} /> path="/"
element={
<Home
endpoint={endpoint}
setEndpoint={setEndpoint}
secure={secure}
setSecure={setSecure}
/>
}
/>
<Route
path="/reviews"
element={
<ReviewsPage
endpoint={endpoint}
secure={secure}
/>
}
/>
<Route
path="*"
element={<NotFound />}
/>
</Routes> </Routes>
); );
} }

View file

@ -14,11 +14,13 @@ import React, { useState } from "react";
import { CheckBox } from "@mui/icons-material"; import { CheckBox } from "@mui/icons-material";
export interface EndpointDialogProps { export interface EndpointDialogProps {
endpoint: [string, React.Dispatch<React.SetStateAction<string>>]; endpoint: string;
secure: [boolean, React.Dispatch<React.SetStateAction<boolean>>]; setEndpoint: React.Dispatch<React.SetStateAction<string>>;
secure: boolean;
setSecure: React.Dispatch<React.SetStateAction<boolean>>;
} }
const EndpointDialog = ({ endpoint, secure }: EndpointDialogProps) => { const EndpointDialog = ({ endpoint, setEndpoint, secure, setSecure }: EndpointDialogProps) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [errorText, setErrorText] = useState(""); const [errorText, setErrorText] = useState("");
@ -49,11 +51,11 @@ const EndpointDialog = ({ endpoint, secure }: EndpointDialogProps) => {
} }
const isValidEndpoint = () => { const isValidEndpoint = () => {
return !(endpoint[0].startsWith("http://") || endpoint[0].startsWith("https://")); return !(endpoint.startsWith("http://") || endpoint.startsWith("https://"));
} }
const saveEndpoint = () => { const saveEndpoint = () => {
localStorage.setItem("apiEndpoint", JSON.stringify(endpoint[0])); localStorage.setItem("apiEndpoint", JSON.stringify(endpoint));
} }
return ( return (
@ -72,9 +74,9 @@ const EndpointDialog = ({ endpoint, secure }: EndpointDialogProps) => {
required required
name="endpointBox" name="endpointBox"
type="endpointBox" type="endpointBox"
value={endpoint[0]} value={endpoint}
onChange={({ target }) => { onChange={({ target }) => {
endpoint[1](target.value); setEndpoint(target.value);
isValidEndpoint() isValidEndpoint()
? closeTheError() ? closeTheError()
@ -90,8 +92,8 @@ const EndpointDialog = ({ endpoint, secure }: EndpointDialogProps) => {
<FormControlLabel <FormControlLabel
control={ control={
<Checkbox <Checkbox
checked={secure[0]} checked={secure}
onClick={() => secure[1](!secure[0])} onClick={() => setSecure(!secure)}
/> />
} }
label="HTTPS" label="HTTPS"
@ -107,7 +109,7 @@ const EndpointDialog = ({ endpoint, secure }: EndpointDialogProps) => {
closeTheError(); closeTheError();
saveEndpoint(); saveEndpoint();
const fullUri = `${secure[0] ? "https" : "http"}://${endpoint[0]}`; const fullUri = `${secure ? "https" : "http"}://${endpoint}`;
console.log(`Endpoint set to ${fullUri}.\n\nPOST URI: ${fullUri}/post\nGET URI: ${fullUri}/reviews`); console.log(`Endpoint set to ${fullUri}.\n\nPOST URI: ${fullUri}/post\nGET URI: ${fullUri}/reviews`);
} else { } else {

View file

@ -0,0 +1,53 @@
import { Button, ButtonGroup, Tooltip, Zoom } from '@mui/material';
import React, { useEffect, useState } from 'react'
import { Link } from 'react-router-dom';
function PageSwitcher() {
const [selected, changeSelected] = useState(0);
useEffect(() => {
changeSelected(document.location.pathname === "/" ? 0 : 1);
})
const postReviewsButton = (
<Tooltip
title="Post a review"
placement="top"
TransitionComponent={Zoom}
>
<Button
variant={selected === 0 ? "contained" : "outlined"}
onClick={() => changeSelected(0)}
component={Link}
to="/"
>
Post
</Button>
</Tooltip>
);
const seeReviewsButton = (
<Tooltip
title="See other's reviews"
placement="top"
TransitionComponent={Zoom}
>
<Button
variant={selected === 1 ? "contained" : "outlined"}
onClick={() => changeSelected(1)}
component={Link}
to="/reviews"
>
Reviews
</Button>
</Tooltip>
);
return (
<ButtonGroup aria-label="page-switcher" style={{margin: "20px 5px"}}>
{postReviewsButton}
{seeReviewsButton}
</ButtonGroup>
);
}
export default PageSwitcher;

View file

@ -0,0 +1,87 @@
import { List, ListItem, ListItemText, Paper, Rating, Slide, Typography } from "@mui/material";
import { ServerSideReview } from "../types";
import React from 'react'
export interface ShowReviewsProps {
reviews: ServerSideReview[];
}
function ShowReviews({ reviews }: ShowReviewsProps) {
if (!reviews.length) {
return (
<>
<Typography
variant="body2"
sx={{color: "rgb(140, 140, 140)", margin: "10px 0"}}
>
No reviews yet...
</Typography>
</>
);
}
return (
<List sx={{ width: "100%", maxWidth: 460}}>
{reviews.map((review, index) => {
return (
<Slide
direction="up"
in
del
mountOnEnter
key={review.id}
>
<Paper elevation={2} sx={{width: "100%", maxWidth: 460}}>
<ListItem sx={{margin: "10px"}}>
<ListItemText
primary={
<Typography
variant="h5"
sx={{display: "block", fontWeight: "bold"}}
>
{review.username}
</Typography>
}
secondary={
<>
<Typography
component="span"
variant="caption"
sx={{display: "block"}}
>
{new Date(review.timestamp).toLocaleString()}
</Typography>
<Typography
component="span"
variant="body1"
sx={{display: "block", margin: "2px 0", fontWeight: "bold"}}
>
{review.title}
</Typography>
<Typography
component="span"
variant="body2"
sx={{display: "block", maxWidth: 350, margin: "5px 0"}}
>
{review.content}
</Typography>
<Rating
value={review.rating}
readOnly
/>
</>
}
/>
</ListItem>
</Paper>
</Slide>
);
})}
</List>
);
}
export default ShowReviews;

View file

@ -4,13 +4,20 @@ import ButtonRow, { ActionProps } from "../components/ButtonRow";
import ReviewField, { ReviewFieldProps } from "../components/ReviewField"; import ReviewField, { ReviewFieldProps } from "../components/ReviewField";
import { useState } from "react"; import { useState } from "react";
import EndpointDialog from "../components/EndpointDialong"; import EndpointDialog from "../components/EndpointDialong";
import PageSwitcher from "../components/PageSwitcher";
function Home() { interface HomeProps {
endpoint: string;
setEndpoint: React.Dispatch<React.SetStateAction<string>>;
secure: boolean;
setSecure: React.Dispatch<React.SetStateAction<boolean>>;
}
function Home({ endpoint, setEndpoint, secure, setSecure }: HomeProps) {
const [rating, setNewRating] = useState<number | null>(0); const [rating, setNewRating] = useState<number | null>(0);
const [modal, showModal] = useState(false); const [modal, showModal] = useState(false);
const [showAlert, changeAlert] = useState(false); const [showAlert, changeAlert] = useState(false);
const [alertText, changeAlertText] = useState(""); const [alertText, changeAlertText] = useState("");
const [secure, setSecure] = useState(false);
const [showInfo, changeInfo] = useState(false); const [showInfo, changeInfo] = useState(false);
const [infoText, changeInfoText] = useState(""); const [infoText, changeInfoText] = useState("");
@ -132,13 +139,17 @@ function Home() {
const info = <Alert severity="info">{infoText}</Alert>; const info = <Alert severity="info">{infoText}</Alert>;
const alert = <Alert severity="error">{alertText}</Alert>; const alert = <Alert severity="error">{alertText}</Alert>;
const [endpoint, setEndpoint] = useState(JSON.parse(localStorage.getItem("apiEndpoint")!) || ""); setEndpoint(JSON.parse(localStorage.getItem("apiEndpoint")!) || "");
return ( return (
<> <>
<div id="settings"> <PageSwitcher />
<EndpointDialog endpoint={[endpoint, setEndpoint]} secure={[secure, setSecure]}/> <EndpointDialog
</div> endpoint={endpoint}
setEndpoint={setEndpoint}
secure={secure}
setSecure={setSecure}
/>
<div id="app"> <div id="app">
<Typography variant="h3">Simple Review Client</Typography> <Typography variant="h3">Simple Review Client</Typography>

87
src/pages/ReviewsPage.tsx Normal file
View file

@ -0,0 +1,87 @@
import { CircularProgress, IconButton, Tooltip, Typography, Zoom } from '@mui/material';
import RefreshIcon from "@mui/icons-material/Refresh";
import PageSwitcher from '../components/PageSwitcher';
import "../index.css";
import ShowReviews from '../components/ShowReviews';
import { ServerSideReview } from '../types';
import { useEffect, useState } from 'react';
interface ReviewsPageProps {
endpoint: string;
secure: boolean;
}
function ReviewsPage({ endpoint, secure }: ReviewsPageProps) {
const [loading, setLoading] = useState(true);
const [statusText, setStatusText] = useState("");
const [statusTextColor, setStatusTextColor] = useState("rgb(140, 140, 140)");
const [currentReviews, setCurrentReviews] = useState<ServerSideReview[]>([]);
const minutes = (time: number): number => time * 60000;
const loadReviews = async () => {
setLoading(true);
setStatusText("Loading...");
setStatusTextColor("rgb(140, 140, 140)");
await fetch(endpoint ? `${secure ? "https" : "http"}://${endpoint}/reviews` : "http://localhost:8080/reviews")
.then(async r => {
const response: ServerSideReview[] = await r.json();
setCurrentReviews(response.reverse());
setStatusText("");
setLoading(false);
})
.catch(err => {
setStatusText(err.toString());
setStatusTextColor("rgb(250, 20, 0)");
});
};
const refreshButton = (
<Tooltip
title="Refresh"
placement="top"
TransitionComponent={Zoom}
>
<IconButton
aria-label="refresh"
onClick={loadReviews}
>
<RefreshIcon />
</IconButton>
</Tooltip>
);
useEffect(() => {
loadReviews();
}, [])
return (
<>
<PageSwitcher />
{loading
? <CircularProgress />
: refreshButton
}
<div id="app">
<Typography variant="h3">
Simple Review Client
</Typography>
{loading
? <Typography
variant="body1"
sx={{color: statusTextColor, margin: "10px 0"}}
>
{statusText}
</Typography>
: <ShowReviews reviews={currentReviews} />}
</div>
</>
);
}
export default ReviewsPage;

27
src/types.ts Normal file
View file

@ -0,0 +1,27 @@
import { object, string, number, InferType } from "yup";
const rating = number()
.positive()
.max(5)
.test(
"maxDigitsAfterDecimal",
"Rating can only have at most one integer at half intervals (.0 or .5)",
(number) => (number! * 10) % 5 === 0,
)
.required();
export const userReviewSchema = object({
username: string().min(2).max(30).required(),
rating: rating,
title: string().max(50).notRequired(),
content: string().max(2000).notRequired(),
});
export const serverReviewSchema = userReviewSchema.shape({
id: string().length(6).required(),
timestamp: string().required(),
});
export type UserSideReview = InferType<typeof userReviewSchema>;
export type ServerSideReview = InferType<typeof serverReviewSchema>;
export type userRating = InferType<typeof rating>;