Add reviews page, switch between pages
This commit is contained in:
parent
a3de9ba924
commit
6fe9fbb3cb
|
@ -15,8 +15,10 @@
|
|||
"@fontsource/roboto": "^5.0.14",
|
||||
"@mui/icons-material": "^6.0.0",
|
||||
"@mui/material": "^6.0.0",
|
||||
"icons": "link:@mui/types/material/icons",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"yup": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.0",
|
||||
|
|
|
@ -23,12 +23,18 @@ importers:
|
|||
'@mui/material':
|
||||
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)
|
||||
icons:
|
||||
specifier: link:@mui/types/material/icons
|
||||
version: link:@mui/types/material/icons
|
||||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1(react@18.3.1)
|
||||
yup:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.9.0
|
||||
|
@ -1172,6 +1178,9 @@ packages:
|
|||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
property-expr@2.0.6:
|
||||
resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
@ -1294,6 +1303,9 @@ packages:
|
|||
text-table@0.2.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
|
||||
engines: {node: '>=4'}
|
||||
|
@ -1302,6 +1314,9 @@ packages:
|
|||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
toposort@2.0.2:
|
||||
resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==}
|
||||
|
||||
ts-api-utils@1.3.0:
|
||||
resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==}
|
||||
engines: {node: '>=16'}
|
||||
|
@ -1312,6 +1327,10 @@ packages:
|
|||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
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:
|
||||
resolution: {integrity: sha512-EvWjwWLwwKDIJuBjk2I6UkV8KEQcwZ0VM10nR1rIunRDIP67QJTZAHBXTX0HW/oI1H10YESF8yWie8fRQxjvFA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
@ -1386,6 +1405,9 @@ packages:
|
|||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
yup@1.4.0:
|
||||
resolution: {integrity: sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
|
@ -2474,6 +2496,8 @@ snapshots:
|
|||
object-assign: 4.1.1
|
||||
react-is: 16.13.1
|
||||
|
||||
property-expr@2.0.6: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
@ -2591,12 +2615,16 @@ snapshots:
|
|||
|
||||
text-table@0.2.0: {}
|
||||
|
||||
tiny-case@1.0.3: {}
|
||||
|
||||
to-fast-properties@2.0.0: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
toposort@2.0.2: {}
|
||||
|
||||
ts-api-utils@1.3.0(typescript@5.5.4):
|
||||
dependencies:
|
||||
typescript: 5.5.4
|
||||
|
@ -2605,6 +2633,8 @@ snapshots:
|
|||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
type-fest@2.19.0: {}
|
||||
|
||||
typescript-eslint@8.3.0(eslint@9.9.1)(typescript@5.5.4):
|
||||
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)
|
||||
|
@ -2647,3 +2677,10 @@ snapshots:
|
|||
yaml@1.10.2: {}
|
||||
|
||||
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
|
||||
|
|
33
src/App.tsx
33
src/App.tsx
|
@ -2,12 +2,41 @@ import "./App.css";
|
|||
import { Route, Routes } from "react-router-dom";
|
||||
import Home from "./pages/Home";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import ReviewsPage from "./pages/ReviewsPage";
|
||||
import { useState } from "react";
|
||||
import { ServerSideReview } from "./types";
|
||||
|
||||
function App() {
|
||||
const [endpoint, setEndpoint] = useState("");
|
||||
const [secure, setSecure] = useState(false);
|
||||
const [currentReviews, setCurrentReviews] = useState<ServerSideReview[]>([]);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
<Route
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -14,11 +14,13 @@ import React, { useState } from "react";
|
|||
import { CheckBox } from "@mui/icons-material";
|
||||
|
||||
export interface EndpointDialogProps {
|
||||
endpoint: [string, React.Dispatch<React.SetStateAction<string>>];
|
||||
secure: [boolean, React.Dispatch<React.SetStateAction<boolean>>];
|
||||
endpoint: string;
|
||||
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 [error, setError] = useState(false);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
|
@ -49,11 +51,11 @@ const EndpointDialog = ({ endpoint, secure }: EndpointDialogProps) => {
|
|||
}
|
||||
|
||||
const isValidEndpoint = () => {
|
||||
return !(endpoint[0].startsWith("http://") || endpoint[0].startsWith("https://"));
|
||||
return !(endpoint.startsWith("http://") || endpoint.startsWith("https://"));
|
||||
}
|
||||
|
||||
const saveEndpoint = () => {
|
||||
localStorage.setItem("apiEndpoint", JSON.stringify(endpoint[0]));
|
||||
localStorage.setItem("apiEndpoint", JSON.stringify(endpoint));
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -72,9 +74,9 @@ const EndpointDialog = ({ endpoint, secure }: EndpointDialogProps) => {
|
|||
required
|
||||
name="endpointBox"
|
||||
type="endpointBox"
|
||||
value={endpoint[0]}
|
||||
value={endpoint}
|
||||
onChange={({ target }) => {
|
||||
endpoint[1](target.value);
|
||||
setEndpoint(target.value);
|
||||
|
||||
isValidEndpoint()
|
||||
? closeTheError()
|
||||
|
@ -90,8 +92,8 @@ const EndpointDialog = ({ endpoint, secure }: EndpointDialogProps) => {
|
|||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={secure[0]}
|
||||
onClick={() => secure[1](!secure[0])}
|
||||
checked={secure}
|
||||
onClick={() => setSecure(!secure)}
|
||||
/>
|
||||
}
|
||||
label="HTTPS"
|
||||
|
@ -107,7 +109,7 @@ const EndpointDialog = ({ endpoint, secure }: EndpointDialogProps) => {
|
|||
closeTheError();
|
||||
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`);
|
||||
} else {
|
||||
|
|
53
src/components/PageSwitcher.tsx
Normal file
53
src/components/PageSwitcher.tsx
Normal 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;
|
87
src/components/ShowReviews.tsx
Normal file
87
src/components/ShowReviews.tsx
Normal 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;
|
|
@ -4,13 +4,20 @@ import ButtonRow, { ActionProps } from "../components/ButtonRow";
|
|||
import ReviewField, { ReviewFieldProps } from "../components/ReviewField";
|
||||
import { useState } from "react";
|
||||
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 [modal, showModal] = useState(false);
|
||||
const [showAlert, changeAlert] = useState(false);
|
||||
const [alertText, changeAlertText] = useState("");
|
||||
const [secure, setSecure] = useState(false);
|
||||
|
||||
const [showInfo, changeInfo] = useState(false);
|
||||
const [infoText, changeInfoText] = useState("");
|
||||
|
@ -132,13 +139,17 @@ function Home() {
|
|||
const info = <Alert severity="info">{infoText}</Alert>;
|
||||
|
||||
const alert = <Alert severity="error">{alertText}</Alert>;
|
||||
const [endpoint, setEndpoint] = useState(JSON.parse(localStorage.getItem("apiEndpoint")!) || "");
|
||||
setEndpoint(JSON.parse(localStorage.getItem("apiEndpoint")!) || "");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="settings">
|
||||
<EndpointDialog endpoint={[endpoint, setEndpoint]} secure={[secure, setSecure]}/>
|
||||
</div>
|
||||
<PageSwitcher />
|
||||
<EndpointDialog
|
||||
endpoint={endpoint}
|
||||
setEndpoint={setEndpoint}
|
||||
secure={secure}
|
||||
setSecure={setSecure}
|
||||
/>
|
||||
|
||||
<div id="app">
|
||||
<Typography variant="h3">Simple Review Client</Typography>
|
||||
|
|
87
src/pages/ReviewsPage.tsx
Normal file
87
src/pages/ReviewsPage.tsx
Normal 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
27
src/types.ts
Normal 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>;
|
Loading…
Reference in a new issue