diff --git a/package.json b/package.json index dedbc63..433057b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cb7150..16e5566 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/App.tsx b/src/App.tsx index e47c684..1e440ab 100644 --- a/src/App.tsx +++ b/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([]); + return ( - } /> - } /> + + } + /> + + } + /> + } + /> ); } diff --git a/src/components/EndpointDialong.tsx b/src/components/EndpointDialong.tsx index f551a45..d05868a 100644 --- a/src/components/EndpointDialong.tsx +++ b/src/components/EndpointDialong.tsx @@ -14,11 +14,13 @@ import React, { useState } from "react"; import { CheckBox } from "@mui/icons-material"; export interface EndpointDialogProps { - endpoint: [string, React.Dispatch>]; - secure: [boolean, React.Dispatch>]; + endpoint: string; + setEndpoint: React.Dispatch>; + secure: boolean; + setSecure: React.Dispatch>; } -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) => { 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 { diff --git a/src/components/PageSwitcher.tsx b/src/components/PageSwitcher.tsx new file mode 100644 index 0000000..e13af79 --- /dev/null +++ b/src/components/PageSwitcher.tsx @@ -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 = ( + + + + ); + + const seeReviewsButton = ( + + + + ); + + return ( + + {postReviewsButton} + {seeReviewsButton} + + ); +} + +export default PageSwitcher; diff --git a/src/components/ShowReviews.tsx b/src/components/ShowReviews.tsx new file mode 100644 index 0000000..3ee6334 --- /dev/null +++ b/src/components/ShowReviews.tsx @@ -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 ( + <> + + No reviews yet... + + + ); + } + + return ( + + {reviews.map((review, index) => { + return ( + + + + + {review.username} + + } + secondary={ + <> + + {new Date(review.timestamp).toLocaleString()} + + + + {review.title} + + + + {review.content} + + + + + } + /> + + + + ); + })} + + ); +} + +export default ShowReviews; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 89c719e..f391701 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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>; + secure: boolean; + setSecure: React.Dispatch>; +} + +function Home({ endpoint, setEndpoint, secure, setSecure }: HomeProps) { const [rating, setNewRating] = useState(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 = {infoText}; const alert = {alertText}; - const [endpoint, setEndpoint] = useState(JSON.parse(localStorage.getItem("apiEndpoint")!) || ""); + setEndpoint(JSON.parse(localStorage.getItem("apiEndpoint")!) || ""); return ( <> -
- -
+ +
Simple Review Client diff --git a/src/pages/ReviewsPage.tsx b/src/pages/ReviewsPage.tsx new file mode 100644 index 0000000..2be8f25 --- /dev/null +++ b/src/pages/ReviewsPage.tsx @@ -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([]); + + 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 = ( + + + + + + ); + + useEffect(() => { + loadReviews(); + }, []) + + return ( + <> + + {loading + ? + : refreshButton + } + +
+ + Simple Review Client + + + {loading + ? + {statusText} + + : } +
+ + ); +} + +export default ReviewsPage; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..0cff051 --- /dev/null +++ b/src/types.ts @@ -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; +export type ServerSideReview = InferType; +export type userRating = InferType;