From 0604d587cf7d07435c786061d4204b243cde52bd Mon Sep 17 00:00:00 2001 From: Kenneth Yang <82800265+kjy5@users.noreply.github.com> Date: Sun, 25 Dec 2022 14:36:05 -0800 Subject: [PATCH] 106 feature new artifact page (#118) * Remove redundant PR type spec * Implemented routes, added pages * Comments * Link to artifact pages, use artifact id * Artifact page basic components * Artifact page responsive layout * Extract props and fixed prop names * Rich Link basic in place, fine-tuning sass * Rich links with style * Tweak margins, fixed back button styling * Installed photoswipe... * Added style and is rendering, but opening in new tab * Fixed gallery not opening * Set thumbnail width * Extra comments --- .eslintrc.cjs | 2 +- .github/workflows/build.yml | 1 - .github/workflows/reformat-and-lint.yml | 1 - package-lock.json | 108 ++++++++++++++++++- package.json | 7 +- src/components/ArtifactCard.tsx | 22 ++-- src/components/Gallery.d.ts | 3 + src/components/Gallery.jsx | 93 ++++++++++++++++ src/components/Quarter.tsx | 13 +-- src/components/RichLink.tsx | 33 ++++++ src/components/Year.tsx | 13 +-- src/main.tsx | 47 +++++---- src/routes/ArtifactPage.tsx | 135 ++++++++++++++++++++++++ src/routes/ErrorPage.tsx | 32 ++++++ src/routes/Root.tsx | 36 +++++++ src/scripts/interfaces.ts | 15 +-- src/scripts/parse-data.ts | 23 ++-- src/styles/ArtifactCard.sass | 2 + src/styles/ArtifactPage.sass | 32 ++++++ src/styles/Gallery.sass | 11 ++ src/styles/Quarter.sass | 2 +- src/styles/RichLink.sass | 48 +++++++++ tsconfig.json | 2 +- 23 files changed, 615 insertions(+), 66 deletions(-) create mode 100644 src/components/Gallery.d.ts create mode 100644 src/components/Gallery.jsx create mode 100644 src/components/RichLink.tsx create mode 100644 src/routes/ArtifactPage.tsx create mode 100644 src/routes/ErrorPage.tsx create mode 100644 src/routes/Root.tsx create mode 100644 src/styles/ArtifactPage.sass create mode 100644 src/styles/Gallery.sass create mode 100644 src/styles/RichLink.sass diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 66dabfa..fe55ffb 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -12,7 +12,7 @@ module.exports = { }, plugins: ["@typescript-eslint"], root: true, - ignorePatterns: ["dist", "vite.config.ts"], + ignorePatterns: ["dist", "vite.config.ts", "src/components/Gallery.jsx"], rules: { "sort-imports": "error", }, diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ba0f9d2..0b152a5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,7 +3,6 @@ name: Build Website # Controls when the workflow will run on: pull_request: - types: [opened, synchronize, reopened] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/.github/workflows/reformat-and-lint.yml b/.github/workflows/reformat-and-lint.yml index 8e36eec..de3ad89 100644 --- a/.github/workflows/reformat-and-lint.yml +++ b/.github/workflows/reformat-and-lint.yml @@ -2,7 +2,6 @@ name: Reformat and Lint on: pull_request: - types: [opened, synchronize, reopened] workflow_dispatch: diff --git a/package-lock.json b/package-lock.json index 35ea858..3984db8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,17 @@ "name": "honors-portfolio", "version": "0.0.0", "dependencies": { + "photoswipe": "^5.3.4", + "photoswipe-dynamic-caption-plugin": "^1.2.7", + "prop-types": "^15.8.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^6.6.1" }, "devDependencies": { "@types/react": "^18.0.26", "@types/react-dom": "^18.0.9", + "@types/react-router-dom": "^5.3.3", "@typescript-eslint/eslint-plugin": "^5.47.0", "@typescript-eslint/parser": "^5.47.0", "@vitejs/plugin-legacy": "^3.0.1", @@ -537,6 +542,14 @@ "node": ">= 8" } }, + "node_modules/@remix-run/router": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.2.1.tgz", + "integrity": "sha512-XiY0IsyHR+DXYS5vBxpoBe/8veTeoRpMHP+vDosLZxL5bnpetzI0igkxkLZS235ldLzyfkxF+2divEwWHP3vMQ==", + "engines": { + "node": ">=14" + } + }, "node_modules/@swc/core": { "version": "1.3.23", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.23.tgz", @@ -726,6 +739,12 @@ "node": ">=10" } }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -758,6 +777,27 @@ "@types/react": "*" } }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", @@ -2068,6 +2108,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2178,6 +2226,19 @@ "node": ">=8" } }, + "node_modules/photoswipe": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/photoswipe/-/photoswipe-5.3.4.tgz", + "integrity": "sha512-SN+RWHqxJvdwzXJsh8KrG+ajjPpdTX5HpKglEd0k9o6o5fW+QHPkW8//Bo11MB+NQwTa/hFw8BDv2EdxiDXjNw==", + "engines": { + "node": ">= 0.12.0" + } + }, + "node_modules/photoswipe-dynamic-caption-plugin": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/photoswipe-dynamic-caption-plugin/-/photoswipe-dynamic-caption-plugin-1.2.7.tgz", + "integrity": "sha512-5XXdXLf2381nwe7KqQvcyStiUBi9TitYXppUQTrzPwYAi4lZsmWNnNKMclM7I4QGlX6fXo42v3bgb6rlK9pY1Q==" + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -2244,6 +2305,16 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -2296,6 +2367,41 @@ "react": "^18.2.0" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-router": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.6.1.tgz", + "integrity": "sha512-YkvlYRusnI/IN0kDtosUCgxqHeulN5je+ew8W+iA1VvFhf86kA+JEI/X/8NqYcr11hCDDp906S+SGMpBheNeYQ==", + "dependencies": { + "@remix-run/router": "1.2.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.6.1.tgz", + "integrity": "sha512-u+8BKUtelStKbZD5UcY0NY90WOzktrkJJhyhNg7L0APn9t1qJNLowzrM9CHdpB6+rcPt6qQrlkIXsTvhuXP68g==", + "dependencies": { + "@remix-run/router": "1.2.1", + "react-router": "6.6.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", diff --git a/package.json b/package.json index 349a668..3bae7f0 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,17 @@ "relint": "prettier --write . && eslint --fix ." }, "dependencies": { + "photoswipe": "^5.3.4", + "photoswipe-dynamic-caption-plugin": "^1.2.7", + "prop-types": "^15.8.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^6.6.1" }, "devDependencies": { "@types/react": "^18.0.26", "@types/react-dom": "^18.0.9", + "@types/react-router-dom": "^5.3.3", "@typescript-eslint/eslint-plugin": "^5.47.0", "@typescript-eslint/parser": "^5.47.0", "@vitejs/plugin-legacy": "^3.0.1", diff --git a/src/components/ArtifactCard.tsx b/src/components/ArtifactCard.tsx index b858378..1dd630c 100644 --- a/src/components/ArtifactCard.tsx +++ b/src/components/ArtifactCard.tsx @@ -1,20 +1,26 @@ import "../styles/ArtifactCard.sass"; -import { Artifact } from "../scripts/interfaces"; +import { ArtifactData } from "../scripts/interfaces"; +import { NavLink } from "react-router-dom"; /** - * Artifact card component. Displays high-level information about an artifact (title, subtitle, 3D graphic). - * @param props {Artifact} artifact - Artifact object + * ArtifactPage card component. Displays high-level information about an artifact (title, subtitle, 3D graphic). + * @param {{artifact: ArtifactData}} props - Artifact to display * @constructor * @return {JSX.Element} */ export default function ArtifactCard(props: { - artifact: Artifact; + artifact: ArtifactData; }): JSX.Element { + // Extract props + const { id, title, subtitle } = props.artifact; + + // Render return ( -
+ /* skipcq: JS-0394 */ +
-

{props.artifact.title}

-

{props.artifact.subtitle}

-
+

{title}

+

{subtitle}

+
); } diff --git a/src/components/Gallery.d.ts b/src/components/Gallery.d.ts new file mode 100644 index 0000000..caf5ba3 --- /dev/null +++ b/src/components/Gallery.d.ts @@ -0,0 +1,3 @@ +import { ImageData } from "../scripts/interfaces"; + +export default function Gallery(props: { images: ImageData[] }): JSX.Element; diff --git a/src/components/Gallery.jsx b/src/components/Gallery.jsx new file mode 100644 index 0000000..5428f8d --- /dev/null +++ b/src/components/Gallery.jsx @@ -0,0 +1,93 @@ +// noinspection JSUnusedGlobalSymbols + +import "../styles/Gallery.sass"; +import { useEffect } from "react"; +import PhotoSwipeLightbox from "photoswipe/lightbox"; +import "photoswipe/style.css"; +import "photoswipe-dynamic-caption-plugin/photoswipe-dynamic-caption-plugin.css"; +import PropTypes from "prop-types"; +import PhotoSwipeDynamicCaption from "photoswipe-dynamic-caption-plugin"; + +/** + * Gallery component, displays a gallery of images + * @param {object} props - Images to display + * @constructor + * @returns {JSX.Element} + */ +export default function Gallery(props) { + // Extract prop data + const { images } = props; + const galleryID = images[0].artifact.replaceAll(" ", "-").toLowerCase(); + + // Initialize the gallery + useEffect(() => { + // Create lightbox + let lightbox = new PhotoSwipeLightbox({ + gallery: `#${galleryID}`, + children: "a", + padding: { top: 30, right: 70, bottom: 30, left: 70 }, + preloaderDelay: 0, + pswpModule: () => import("photoswipe"), + }); + + // Add dynamic caption plugin + // noinspection JSUnusedLocalSymbols + const _photoSwipeDynamicCaption = new PhotoSwipeDynamicCaption(lightbox, { + type: "auto", + captionContent: ".pswp-caption-content", + }); + + // Start the lightbox + lightbox.init(); + + // Cleanup + return () => { + lightbox.destroy(); + lightbox = null; + }; + }, []); + + // Render + return ( +
+ {images.map((image) => ( + + {/* Thumbnail */} + {image.name} + + {/* Caption */} + +

{image.name}

+
+ {image.description} +
+
+ ))} +
+ ); +} + +Gallery.propTypes = { + images: PropTypes.arrayOf( + PropTypes.shape({ + artifact: PropTypes.string.isRequired, + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + description: PropTypes.string, + thumbnail: PropTypes.string.isRequired, + image: PropTypes.string.isRequired, + }) + ), +}; diff --git a/src/components/Quarter.tsx b/src/components/Quarter.tsx index d30e979..512f431 100644 --- a/src/components/Quarter.tsx +++ b/src/components/Quarter.tsx @@ -1,19 +1,19 @@ import "../styles/Quarter.sass"; -import { Artifact } from "../scripts/interfaces"; import ArtifactCard from "./ArtifactCard"; +import { ArtifactData } from "../scripts/interfaces"; /** * Quarter component. Groups a quarter heading with a grid of artifacts. - * @param props {Artifact[]} filterArtifacts - Artifacts filtered by quarter + * @param {{filteredArtifacts: ArtifactData[]}} props - filterArtifacts - Artifacts filtered by quarter * @constructor * @return {JSX.Element} */ export default function Quarter(props: { - filterArtifacts: Artifact[]; + filteredArtifacts: ArtifactData[]; }): JSX.Element { // Compute quarter string let quarter = "Fall"; - switch (props.filterArtifacts[0].quarter) { + switch (props.filteredArtifacts[0].quarter) { case 1: quarter = "Winter"; break; @@ -27,12 +27,13 @@ export default function Quarter(props: { break; } + // Render return (

{quarter}

- {props.filterArtifacts.map((artifact: Artifact) => ( - + {props.filteredArtifacts.map((artifact: ArtifactData) => ( + ))}
diff --git a/src/components/RichLink.tsx b/src/components/RichLink.tsx new file mode 100644 index 0000000..8ee7af2 --- /dev/null +++ b/src/components/RichLink.tsx @@ -0,0 +1,33 @@ +import "../styles/RichLink.sass"; +import { LinkData } from "../scripts/interfaces"; + +/** + * RichLink component. Displays a link with a title, description, and thumbnail. + * @param {{linkData: LinkData}} props - Information about the link + * @constructor + * @return {JSX.Element} + */ +export default function RichLink(props: { linkData: LinkData }): JSX.Element { + // Extract props + const { url, image, title, description } = props.linkData; + + // Render + return ( + +
+
+

{title}

+

{description}

+

{url}

+
+
+ ); +} diff --git a/src/components/Year.tsx b/src/components/Year.tsx index bfbae39..0167d82 100644 --- a/src/components/Year.tsx +++ b/src/components/Year.tsx @@ -1,15 +1,15 @@ import "../styles/Year.sass"; -import { Artifact } from "../scripts/interfaces"; +import { ArtifactData } from "../scripts/interfaces"; import Quarter from "./Quarter"; /** * Year component. Groups a year heading with a list of quarters. - * @param props {Artifact[]} filteredArtifacts - Artifacts filtered by year + * @param {{filteredArtifacts: ArtifactData[]}} props - Artifacts filtered by year * @constructor * @return {JSX.Element} */ export default function Year(props: { - filteredArtifacts: Artifact[]; + filteredArtifacts: ArtifactData[]; }): JSX.Element { // Compute year string let year = "Freshman 2021 - 2022"; @@ -29,18 +29,19 @@ export default function Year(props: { // Compute quarter set (get unique quarters) const quarters: Set = new Set(); - props.filteredArtifacts.forEach((artifact: Artifact) => { + props.filteredArtifacts.forEach((artifact: ArtifactData) => { quarters.add(artifact.quarter); }); + // Render return (

{year}

{Array.from(quarters).map((quarter: number) => ( artifact.quarter === quarter + filteredArtifacts={props.filteredArtifacts.filter( + (artifact: ArtifactData) => artifact.quarter === quarter )} /> ))} diff --git a/src/main.tsx b/src/main.tsx index 0c9ab67..0fd519c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,32 +1,37 @@ import "./styles/index.sass"; -import { Artifact } from "./scripts/interfaces"; -import ComingSoon from "./components/ComingSoon"; +import { ArtifactData } from "./scripts/interfaces"; +import ArtifactPage from "./routes/ArtifactPage"; +import ErrorPage from "./routes/ErrorPage"; import ParseData from "./scripts/parse-data"; import React from "react"; import ReactDOM from "react-dom/client"; -import Year from "./components/Year"; +import Root from "./routes/Root"; +// eslint-disable-next-line sort-imports +import { createBrowserRouter, RouterProvider } from "react-router-dom"; -// Parse data and return as an array of Artifact objects -const artifacts: Artifact[] = ParseData(); +// Parse data and return as an array of ArtifactPage objects +const artifacts: ArtifactData[] = ParseData(); -// Compute year set (get unique years) -const years: Set = new Set(); -artifacts.forEach((artifact: Artifact) => { - years.add(artifact.year); -}); +// Create router +const router = createBrowserRouter([ + { + path: "/honors-portfolio/", + element: , + errorElement: , + loader: () => artifacts, + }, + { + path: "/honors-portfolio/:id", + element: , + errorElement: , + loader: ({ params }) => + artifacts.find((artifact: ArtifactData) => artifact.id === params.id), + }, +]); +// Render ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - {/* Artifacts */} - {Array.from(years).map((year: number) => ( - artifact.year === year - )} - /> - ))} + ); diff --git a/src/routes/ArtifactPage.tsx b/src/routes/ArtifactPage.tsx new file mode 100644 index 0000000..baef03d --- /dev/null +++ b/src/routes/ArtifactPage.tsx @@ -0,0 +1,135 @@ +import "../styles/ArtifactPage.sass"; +import { ArtifactData, EmbedData, LinkData } from "../scripts/interfaces"; +import { NavLink, useLoaderData } from "react-router-dom"; +import React, { Fragment } from "react"; +import Gallery from "../components/Gallery"; +import RichLink from "../components/RichLink"; + +/** + * Artifact header, displays artifact title, subtitle, date, and links + * @param {{artifact: ArtifactData}} props - Artifact data + * @constructor + * @returns {JSX.Element} + */ +function ArtifactHeader(props: { artifact: ArtifactData }): JSX.Element { + const { year, quarter, title, subtitle, links } = props.artifact; + + /** + * Convert quarter number encoding to readable string + */ + const quarterToString = (): string => { + switch (quarter) { + case 0: + return "Fall"; + case 1: + return "Winter"; + case 2: + return "Spring"; + default: + return "Summer"; + } + }; + + /** + * Convert year number encoding to readable string + */ + const yearToString = (): string => { + switch (year) { + case 0: + return "Freshman"; + case 1: + return "Sophomore"; + case 2: + return "Junior"; + default: + return "Senior"; + } + }; + + return ( +
+ {/* Title and subtitle */} +

{title}

+

{subtitle}

+ + {/* Year and quarter */} +

+ Quarter: {yearToString()} {quarterToString()} +

+ + {/* External Link */} + {links && + links.map((link: LinkData) => ( + + ))} +
+ ); +} + +/** + * Artifact page, displays artifact information including text, images, and embedded media + * @constructor + * @returns {JSX.Element} + */ +export default function ArtifactPage(): JSX.Element { + // Get and extract data + const artifact = useLoaderData() as ArtifactData; + + // Force scroll to top + window.scroll(0, 0); + + // Render + return ( +
+ {/* Back button */} + {/* skipcq: JS-0394 */} + + ← Return to Kenneth's Honors Portfolio + + + {/* 3D graphics element */} +
+ + {/* Artifact content */} +
+ + {/* Text */} +

{artifact.text}

+
+ + {/* Images */} + {artifact.images && } + + {/* Embedded items */} + {artifact.embeds && ( +
+ {artifact.embeds.map((embed: EmbedData) => ( + + + Click here if there is a problem viewing the embedded item + +