diff --git a/.env b/.env new file mode 100644 index 0000000..1a71bfc --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +NEXT_PUBLIC_URL = "https://canopy-iiif.vercel.app" +NEXT_PUBLIC_BASE_PATH = "" \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..04e763f --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,28 @@ +{ + "plugins": ["@typescript-eslint", "testing-library"], + "extends": [ + "plugin:@typescript-eslint/recommended", + "next", + "next/core-web-vitals", + "prettier" + ], + "overrides": [ + // Only uses Testing Library lint rules in test files + { + "files": [ + "**/__tests__/**/*.[jt]s?(x)", + "**/?(*.)+(spec|test).[jt]s?(x)" + ], + "extends": ["plugin:testing-library/react"] + } + ], + "rules": { + // "sort-keys": "error", + // "sort-imports": "error", + "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/no-unused-vars": "error", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/sort-type-union-intersection-members": "error", + "@typescript-eslint/ban-ts-comment": "off" + } +} diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000..4ac104c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,27 @@ +--- +name: Bug +about: Report an issue +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature-enhancement.md b/.github/ISSUE_TEMPLATE/feature-enhancement.md new file mode 100644 index 0000000..c57d228 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-enhancement.md @@ -0,0 +1,14 @@ +--- +name: Feature/Enhancement +about: New feature or enhancement +title: '' +labels: enhancement +assignees: '' + +--- + +## Description + +## Done Looks Like +- [ ] to do 1 +- [ ] to do 2 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..90442f6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +# What does this do? + +_Please include a summary of the changes and the related issue. Please also include relevant motivation and context._ + +## What type of change is this? + +- [ ] ๐Ÿ› **Bug fix** (non-breaking change addressing an issue) +- [ ] โœจ **New feature or enhancement** (non-breaking change which adds functionality) +- [ ] ๐Ÿงจ **Breaking change** (fix or feature that would cause existing functionality to not work as expected) +- [ ] ๐Ÿšง **Maintenance or refinement of codebase structur**e (ex: dependency updates) +- [ ] ๐Ÿ“˜ **Documentation update** + +## Additional Notes + +_Please include any extra notes here._ diff --git a/.github/workflows/aggregate.yml b/.github/workflows/aggregate.yml new file mode 100644 index 0000000..9eaaf5b --- /dev/null +++ b/.github/workflows/aggregate.yml @@ -0,0 +1,17 @@ +name: Test IIIF Collection Aggregation/Build +on: push +jobs: + aggregate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install dependencies + run: npm install + - name: IIIF Presentation API 2.x Collection (Aggregation) + run: npm run prebuild -- --path=./config/.fixtures/canopy.presentation-2.json + - name: IIIF Presentation API 2.x Collection (Build) + run: npm run test-build + - name: IIIF Presentation API 3.0 Collection (Aggregation) + run: npm run prebuild -- --path=./config/.fixtures/canopy.presentation-3.json + - name: IIIF Presentation API 3.0 Collection (Build) + run: npm run test-build diff --git a/.github/workflows/gh-pages.deploy.yml b/.github/workflows/gh-pages.deploy.yml new file mode 100644 index 0000000..cbb009b --- /dev/null +++ b/.github/workflows/gh-pages.deploy.yml @@ -0,0 +1,36 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + + env: + NEXT_PUBLIC_URL: https://canopy-iiif.github.io + NEXT_PUBLIC_BASE_PATH: /canopy-iiif + + strategy: + matrix: + node-version: [14.x] + + steps: + - name: Get files + uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + - name: Install packages + run: npm ci + - name: Export static files + run: npm run build:static + - name: Add .nojekyll file + run: touch ./out/.nojekyll + - name: Deploy + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: out diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7205ab1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +.canopy +.vscode + +public/api +public/robots.txt +public/sitemap* \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..e69de29 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aaeadbc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Mat Jordan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f139436 --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# Canopy IIIF + +A purely IIIF sourced static site generator using Next.js. Canopy is an application that will build a browseable and searchable digital collections style static site entirely from a IIIF Collection and the items it contains. + +- [Demo (Vercel)](https://canopy-iiif.vercel.app/) +- [Demo (Static)](https://canopy-iiif.github.io/canopy-iiif/) +- [Documentation](https://canopy-iiif.vercel.app/about) + +> **Warning** +> Canopy is a work in progress and being built in public. + +## Examples + +### [Nez Perce](https://canopy-iiif-git-nez-perce-iiif.vercel.app/) + +- IIIF Presentation API 3.0 +- 119 items +- Provided by Northwestern University Libraries + +### [William Cox Cochran Photographic Collection](https://canopy-iiif.vercel.app/) + +- IIIF Presentation API 3.0 +- 90 items +- Provided by University of Tennessee Libraries +- `navPlace` + Map + +### [The Botanical photography of Alan S. Heilman](https://canopy-iiif-git-heilman-mathewjordan.vercel.app/) + +- IIIF Presentation API 3.0 +- 1120 items +- Provided by University of Tennessee Libraries + +### [The Chimney Tops 2 Wildfires In Memory And Art](https://canopy-iiif-git-rfta-artists-mathewjordan.vercel.app/) + +- IIIF Presentation API 3.0 w/ Video Canvases +- 43 items +- Provided by University of Tennessee Libraries + +### [Canonici](https://canopy-iiif-git-canonici-mathewjordan.vercel.app/) + +- IIIF Presentation API 2.0 +- 529 items +- Provided by Bodleian Libraries, University of Oxford + +## Roadmap + +### Content + +- [x] Manifest as a `/work/` page +- [ ] Collection as a `/collection/` page +- [ ] Documentation for best-practice incorporating non-IIIF front matter +- [x] Homepage metadata sliders + +### Data Aggregration + +- [x] Collection of Manifests (i.e. `depth === 1`) +- [ ] Collection of Collections (i.e. `depth > 1`) +- [x] Harvesting of curated metadata labels +- [ ] Leveraging BCP 47 and internationalization + +### Search + +- [x] Basic search on label(s) +- [ ] No results language +- [x] Search on summary and metadata entries +- [x] Search page facets on curated metadata +- [x] `next/link` routing from Work metadata to search page facets + +### User Interface & Experience + +- [x] Fully Responsive +- [x] Continuous scroll & lazy load of search results +- [ ] Custom theme support + +### Configuration + +- [x] Localization preferences +- [x] Site title label override +- [ ] Slug pattern options + +## Setup + +### Install Dependencies + +```shell +# installation +npm i +``` + +### Running in Development + +```shell +# development +npm run dev +``` + +### Building in Production + +```shell +# build +npm run build +``` + +## Configuration + +Canopy IIIF uses a default configuration `config/.default/canopy.default.json` for demonstration purposes if a custom one is not set. The build process will read from a custom configuration file at `config/canopy.json` if it exists. Please review [configuration documentation](https://canopy-iiif.vercel.app/about) for customization of Canopy IIIF. + +## License + +This project is [licensed](https://github.com/canopy-iiif/canopy-iiif/blob/main/LICENSE) under the MIT License. diff --git a/canopy.js b/canopy.js new file mode 100644 index 0000000..80860f7 --- /dev/null +++ b/canopy.js @@ -0,0 +1,44 @@ +require("dotenv").config(); +const aggregate = require("./services/build/aggregate"); +const { + getConfig, + getOptions, + getNavigation, +} = require("./services/build/config"); +const args = process.argv; + +(() => { + const path = args + .find((value) => value.includes("--path=")) + ?.split("=") + ?.pop(); + + const config = getConfig(path); + const options = getOptions(); + const navigation = getNavigation(); + const { prod, dev } = config; + + config.environment = args.includes("dev") ? dev : prod; + config.options = options; + + const url = args.includes("dev") + ? `http://localhost:5001` + : process.env.NEXT_PUBLIC_URL; + const basePath = process.env.NEXT_PUBLIC_BASE_PATH; + const baseUrl = basePath ? `${url}${basePath}` : url; + + const env = { + CANOPY_CONFIG: { + ...config.environment, + navigation: navigation, + ...config.options, + url, + basePath, + baseUrl, + }, + }; + + aggregate.build(env.CANOPY_CONFIG); +})(); + +module.exports = { getConfig }; diff --git a/components/Card/Card.styled.ts b/components/Card/Card.styled.ts new file mode 100644 index 0000000..7d40390 --- /dev/null +++ b/components/Card/Card.styled.ts @@ -0,0 +1,57 @@ +import { styled } from "@/stitches"; + +const Content = styled("div", { + padding: "$gr3 0 0", + + h4: { + margin: "0", + fontWeight: "400", + fontSize: "$gr4", + fontFamily: "$bookTight", + textDecoration: "none !important", + }, + + span: { + display: "block", + margin: "0.25rem 0 0", + fontWeight: "300", + fontSize: "0.8333rem", + color: "$slate10", + }, +}); + +const Placeholder = styled("div", { + backgroundColor: "$slate6", + width: "100%", + height: "100%", + overflowY: "hidden", + borderRadius: "3px", + transition: "$canopyAll", +}); + +const Wrapper = styled("div", { + display: "flex", + width: "100%", + maxWidth: "240px", + position: "relative", + + a: { + display: "flex", + flexDirection: "column", + width: "100%", + color: "$slate12", + textDecoration: "none !important", + transition: "$canopyAll", + + [`&:hover, &:focus`]: { + color: "$indigo10", + + [`${Placeholder}`]: { + transform: "scale3d(1.02, 1.02, 1.02)", + boxShadow: "3px 3px 8px #0002", + }, + }, + }, +}); + +export { Content, Placeholder, Wrapper }; diff --git a/components/Card/Card.tsx b/components/Card/Card.tsx new file mode 100644 index 0000000..1123a91 --- /dev/null +++ b/components/Card/Card.tsx @@ -0,0 +1,64 @@ +import { Content, Placeholder, Wrapper } from "@/components/Card/Card.styled"; +import Link from "next/link"; +import Figure from "@/components/Figure/Figure"; +import * as AspectRatio from "@radix-ui/react-aspect-ratio"; +import { getJsonByURI } from "@/services/utils"; +import { getPresentation3 } from "@/services/iiif/context"; +import { useInView } from "react-intersection-observer"; +import { m, LazyMotion, domAnimation, MotionConfig } from "framer-motion"; +import { useEffect, useState } from "react"; +import { Label } from "@samvera/nectar-iiif"; + +interface CardProps { + resource: any; +} + +const Card: React.FC = ({ resource }) => { + const [aspectRatio, setAspectRatio] = useState(); + const { label, homepage, thumbnail } = resource; + + useEffect(() => { + /** + * temporary aspect ratio calculation for demo collection + */ + getJsonByURI(resource.id).then((json) => { + const manifest = getPresentation3(json); + const { width, height } = manifest.items + ? manifest.items[0].items[0].items[0].body + : undefined; + setAspectRatio(width / height); + }); + + // if (Array.isArray(thumbnail) && thumbnail[0]) + // setAspectRatio(thumbnail[0].width / thumbnail[0].height); + }, [thumbnail]); + + const { ref, inView } = useInView(); + + if (!aspectRatio) return <>; + + return ( + + + + + + {inView && resource && ( + + +
+ + + )} + + + + + + + + ); +}; + +export default Card; diff --git a/components/Embed/Card.tsx b/components/Embed/Card.tsx new file mode 100644 index 0000000..8488b4a --- /dev/null +++ b/components/Embed/Card.tsx @@ -0,0 +1,33 @@ +import { useEffect, useState } from "react"; +import Card from "@/components/Card/Card"; +import { canopyManifests } from "@/services/constants/canopy"; + +const EmbedCard = ({ id }: { id: string }) => { + const [resource, setResource] = useState(); + const manifests = canopyManifests(); + + useEffect(() => { + const item = manifests.find((manifest) => manifest.id === id); + + if (item) + setResource({ + id: id, + type: "Manifest", + label: item.label, + thumbnail: item.thumbnail, + homepage: [ + { + id: `/works/${item.slug}`, + label: item.label, + type: "Text", + }, + ], + }); + }, [id]); + + if (!resource) return null; + + return ; +}; + +export default EmbedCard; diff --git a/components/Embed/Slider.tsx b/components/Embed/Slider.tsx new file mode 100644 index 0000000..44b3e7f --- /dev/null +++ b/components/Embed/Slider.tsx @@ -0,0 +1,7 @@ +import Slider from "@/components/Viewer/Slider"; + +const EmbedSlider = ({ id }: { id: string }) => { + return ; +}; + +export default EmbedSlider; diff --git a/components/Embed/Viewer.tsx b/components/Embed/Viewer.tsx new file mode 100644 index 0000000..1f4f9a3 --- /dev/null +++ b/components/Embed/Viewer.tsx @@ -0,0 +1,17 @@ +import Viewer from "@/components/Viewer/Viewer"; + +const EmbedViewer = ({ id }: { id: string }) => { + return ( + + ); +}; + +export default EmbedViewer; diff --git a/components/Facets/Activate.styled.ts b/components/Facets/Activate.styled.ts new file mode 100644 index 0000000..eaa3484 --- /dev/null +++ b/components/Facets/Activate.styled.ts @@ -0,0 +1,26 @@ +import { styled } from "@/stitches"; +import * as Dialog from "@radix-ui/react-dialog"; + +const FacetsActivateIndicator = styled("span", { + position: "absolute", + display: "flex", + justifyContent: "center", + alignItems: "center", + top: "-$gr1", + right: "$gr2", + width: "$gr3", + height: "$gr3", + fontSize: "$gr1", + color: "$indigo1", + backgroundColor: "$indigo12", + borderRadius: "50%", +}); + +const FacetsActivateStyled = styled(Dialog.Trigger, { + position: "relative", + right: "0", + transition: "$canopySlideIn", + boxShadow: "none", +}); + +export { FacetsActivateIndicator, FacetsActivateStyled }; diff --git a/components/Facets/Activate.tsx b/components/Facets/Activate.tsx new file mode 100644 index 0000000..98c5c67 --- /dev/null +++ b/components/Facets/Activate.tsx @@ -0,0 +1,31 @@ +import { useFacetsState } from "@/context/facets"; +import { MixerHorizontalIcon } from "@radix-ui/react-icons"; +import React from "react"; +import { ButtonStyled } from "../Shared/Button/Button.styled"; +import { + FacetsActivateIndicator, + FacetsActivateStyled, +} from "./Activate.styled"; +import { LocaleString } from "@/hooks/useLocale"; + +const FacetsActivate: React.FC = () => { + const { facetsState } = useFacetsState(); + const { facetsActive } = facetsState; + + const { length } = Array.from(facetsActive.keys()).filter( + (key) => key !== "q" + ); + + return ( + + + {LocaleString("searchFilter")} + {length > 0 && ( + {length} + )} + + + ); +}; + +export default FacetsActivate; diff --git a/components/Facets/Facet.styled.ts b/components/Facets/Facet.styled.ts new file mode 100644 index 0000000..9c1e140 --- /dev/null +++ b/components/Facets/Facet.styled.ts @@ -0,0 +1,55 @@ +import { styled } from "@/stitches"; +import * as Accordion from "@radix-ui/react-accordion"; + +const FacetsFacetActivate = styled(Accordion.Trigger, { + display: "flex", + justifyContent: "space-between", + width: "100%", + padding: "$gr2 0", + backgroundColor: "transparent", + border: "none", + fontFamily: "$bookTight", + fontSize: "$gr4", + cursor: "pointer", + borderTop: "1px solid $slate4", + + "&:hover, &:focus": { + color: "$indigo11", + }, + svg: { + transition: "$canopyAll", + transform: "rotate(-90deg)", + color: "$slate10", + }, + + "&[aria-expanded='true']": { + color: "$slate12 !important", + fontWeight: "800", + + svg: { + color: "$slate12 !important", + transform: "rotate(0deg)", + }, + }, +}); + +const FacetsFacetContent = styled(Accordion.Content, { + padding: "$gr1 0 $gr4", +}); + +const FacetsFacetHeader = styled(Accordion.Header, {}); + +const FacetsFacetStyled = styled(Accordion.Item, { + "&:first-child": { + [`${FacetsFacetActivate}`]: { + border: "none", + }, + }, +}); + +export { + FacetsFacetActivate, + FacetsFacetContent, + FacetsFacetHeader, + FacetsFacetStyled, +}; diff --git a/components/Facets/Facet.tsx b/components/Facets/Facet.tsx new file mode 100644 index 0000000..c0fe8cf --- /dev/null +++ b/components/Facets/Facet.tsx @@ -0,0 +1,82 @@ +import { useFacetsState } from "@/context/facets"; +import { ChevronDownIcon } from "@radix-ui/react-icons"; +import React, { useEffect, useState } from "react"; +import { + FacetsFacetActivate, + FacetsFacetContent, + FacetsFacetHeader, + FacetsFacetStyled, +} from "./Facet.styled"; +import FacetsOption from "./Option"; +import { LocaleString } from "@/hooks/useLocale"; + +interface FacetsFacetProps { + label: string; + slug: string; + values: any; +} + +export const FacetsFacet: React.FC = ({ + label, + slug, + values, +}) => { + const { facetsState } = useFacetsState(); + const { facetsActive } = facetsState; + + /** + * + */ + const params = facetsActive?.toString(); + const defaultValue = { + slug: "", + value: LocaleString("searchFilterAny"), + }; + + const [active, setActive] = useState({ + slug: "", + value: LocaleString("searchFilterAny"), + }); + + useEffect(() => { + const activeSlug = facetsActive?.get(slug); + setActive( + activeSlug + ? { + slug: activeSlug, + value: values.find((entry: any) => entry.slug === activeSlug) + ?.value, + } + : defaultValue + ); + }, [facetsActive, params, slug, values]); + + return ( + + + + + {label} + + {active.value} + + + + {values.map((option: any, index: number) => { + const identifier = `${slug}-${option.slug}-${index}`; + return ( + + ); + })} + + + ); +}; + +export default FacetsFacet; diff --git a/components/Facets/Facets.styled.ts b/components/Facets/Facets.styled.ts new file mode 100644 index 0000000..3813968 --- /dev/null +++ b/components/Facets/Facets.styled.ts @@ -0,0 +1,6 @@ +import { styled } from "@/stitches"; +import * as Dialog from "@radix-ui/react-dialog"; + +const FacetsStyled = styled(Dialog.Root, {}); + +export { FacetsStyled }; diff --git a/components/Facets/Facets.tsx b/components/Facets/Facets.tsx new file mode 100644 index 0000000..e4b93d9 --- /dev/null +++ b/components/Facets/Facets.tsx @@ -0,0 +1,55 @@ +import { FacetsStyled } from "./Facets.styled"; +import React, { useEffect, useState } from "react"; +import FacetsModal from "./Modal"; +import FacetsActivate from "./Activate"; +import { FacetsProvider, useFacetsState } from "@/context/facets"; +import { useCanopyState } from "@/context/canopy"; +import { useRouter } from "next/router"; + +const Facets = () => { + const { asPath } = useRouter(); + const [isModalOpen, setIsModalOpen] = useState(false); + const { facetsDispatch } = useFacetsState(); + const { canopyDispatch, canopyState } = useCanopyState(); + const { headerVisible, searchParams } = canopyState; + + useEffect(() => { + facetsDispatch({ + type: "updateFacetsActive", + facetsActive: searchParams, + }); + }, [searchParams, facetsDispatch]); + + const handleDialogChange = () => { + setIsModalOpen(!isModalOpen); + canopyDispatch({ + type: "updateHeaderVisible", + headerVisible: !headerVisible, + }); + }; + + useEffect(() => { + setIsModalOpen(false); + canopyDispatch({ + type: "updateHeaderVisible", + headerVisible: true, + }); + }, [asPath, canopyDispatch]); + + return ( + + + + + ); +}; + +const FacetsWrapper = () => { + return ( + + + + ); +}; + +export default FacetsWrapper; diff --git a/components/Facets/Modal.styled.ts b/components/Facets/Modal.styled.ts new file mode 100644 index 0000000..cdeafb8 --- /dev/null +++ b/components/Facets/Modal.styled.ts @@ -0,0 +1,143 @@ +import { styled } from "@/stitches"; +import * as Dialog from "@radix-ui/react-dialog"; +import { slateA } from "@radix-ui/colors"; +import { maxWidths } from "@/styles/theme/containers"; + +const FacetsModalContent = styled(Dialog.Content, { + width: `calc(100% - $gr5 * 2)`, + maxWidth: maxWidths.default, + maxHeight: `calc(100% - $gr5 * 2)`, + background: "$slate2", + position: "fixed", + top: `$gr5`, + left: `50%`, + overflowY: "auto", + zIndex: "10", + borderRadius: "3px", + boxShadow: `5px 5px 13px ${slateA.slateA7}`, + borderTop: "1px solid $slateA1", + borderBottom: "1px solid $slateA4", + transform: "translateX(-50%)", + overflow: "clip", + display: "flex", + + "@lg": { + width: `calc(100% - $gr4 * 2)`, + maxHeight: `calc(100% - $gr4 * 2)`, + top: `$gr4`, + }, + + "@sm": { + width: `calc(100% - $gr3 * 2)`, + maxHeight: `calc(100% - $gr3 * 2)`, + top: `$gr3`, + }, +}); + +const FacetsModalContentInner = styled("div", { + display: "flex", + flexDirection: "column", + flexWrap: "nowrap", + width: "100%", + overflow: "scroll", +}); + +const FacetsModalContentHeader = styled("header", { + display: "flex", + flexGrow: "0", + justifyContent: "space-between", + padding: "$gr3 $gr4", + color: "$slate9", + fontSize: "$gr3", + fontFamily: "$bookTight", + fontWeight: "300", + alignItems: "center", + + "@sm": { + padding: "$gr2 $gr3", + }, +}); + +const FacetsModalContentFooter = styled("footer", { + display: "flex", + flexGrow: "0", + justifyContent: "space-between", + padding: "$gr3 $gr4", + + "@sm": { + padding: "$gr2 $gr3", + }, +}); + +const FacetsModalContentBody = styled("div", { + display: "flex", + flexDirection: "column", + flexGrow: "1", + borderTop: "1px solid $slate4", + borderBottom: "1px solid $slate4", + overflowY: "scroll !important", + padding: "$gr3 $gr4", + + "@sm": { + padding: "$gr2 $gr3", + }, +}); + +const FacetsModalTitle = styled(Dialog.Title, {}); + +const FacetsModalClose = styled(Dialog.Close, { + display: "flex", + justifyContent: "center", + alignItems: "center", + border: "none", + borderRadius: "50%", + color: "$slate10", + background: "transparent", + cursor: "pointer", + width: "$gr4", + height: "$gr4", + transition: "$canopyAll", +}); + +const FacetsModalOverlay = styled(Dialog.Overlay, { + backgroundColor: "$slateA8", + position: "fixed", + top: 0, + left: 0, + right: 0, + bottom: 0, + display: "grid", + placeItems: "center", + overflowY: "auto", + zIndex: "10", + transition: "$canopyAll", + + "&:hover": { + backgroundColor: "$slateA9", + }, + + "&::after": { + position: "fixed", + zIndex: "11", + background: "linear-gradient(180deg, $slate2, #0000)", + width: "100%", + height: "$gr9", + left: "0", + top: "0", + content: "", + }, +}); + +const FacetsModalPortal = styled(Dialog.Portal, {}); + +export { + FacetsModalClose, + FacetsModalContent, + FacetsModalContentFooter, + FacetsModalContentInner, + FacetsModalContentHeader, + FacetsModalContentBody, + FacetsModalOverlay, + FacetsModalPortal, + FacetsModalTitle, +}; diff --git a/components/Facets/Modal.tsx b/components/Facets/Modal.tsx new file mode 100644 index 0000000..818f23e --- /dev/null +++ b/components/Facets/Modal.tsx @@ -0,0 +1,78 @@ +import * as Accordion from "@radix-ui/react-accordion"; +import { + FacetsModalClose as ContentClose, + FacetsModalContent as Content, + FacetsModalContentBody as ContentBody, + FacetsModalContentFooter as ContentFooter, + FacetsModalContentHeader as ContentHeader, + FacetsModalContentInner as ContentInner, + FacetsModalOverlay as Overlay, + FacetsModalPortal as Portal, + FacetsModalTitle as ContentTitle, +} from "./Modal.styled"; +import Facet from "./Facet"; +import FACETS from "@/.canopy/facets"; +import React from "react"; +import { Cross2Icon } from "@radix-ui/react-icons"; +import { ButtonStyled } from "../Shared/Button/Button.styled"; +import { useFacetsState } from "@/context/facets"; +import { useRouter } from "next/router"; +import { LocaleString } from "@/hooks/useLocale"; + +interface FacetsModalProps { + handleSubmit: () => void; +} + +const FacetsModal: React.FC = ({ handleSubmit }) => { + const { facetsState, facetsDispatch } = useFacetsState(); + const { facetsActive } = facetsState; + const router = useRouter(); + + const handleClearAll = () => { + FACETS.forEach((facet: any) => facetsActive.delete(facet.slug)); + facetsDispatch({ + type: "updateFacetsActive", + facetsActive: facetsActive, + }); + }; + + const handleViewResults = () => { + router.push({ pathname: "/search", query: facetsActive.toString() }); + handleSubmit(); + }; + + return ( + + + + + + + {LocaleString("searchFilter")} + + + + + + + + {FACETS.map((facet: any) => ( + + ))} + + + + + {LocaleString("searchFilterClear")} + + + {LocaleString("searchFilterSubmit")} + + + + + + ); +}; + +export default FacetsModal; diff --git a/components/Facets/Option.styled.ts b/components/Facets/Option.styled.ts new file mode 100644 index 0000000..33b2f7c --- /dev/null +++ b/components/Facets/Option.styled.ts @@ -0,0 +1,86 @@ +import { styled } from "@/stitches"; +import { indigoA } from "@radix-ui/colors"; +import * as Checkbox from "@radix-ui/react-checkbox"; + +const OptionLabel = styled("label", { + transition: "$canopyAll", + cursor: "pointer", + fontWeight: "500", + + variants: { + isChecked: { + true: { + color: "$slate12 !important", + fontWeight: "800", + }, + }, + }, + + "&:hover, &:focus": { + color: "$indigo11", + }, + + span: { + color: "$slate11 !important", + fontSize: "$gr2", + }, +}); + +const OptionCheckbox = styled(Checkbox.Root, { + position: "relative", + zIndex: "0", + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + width: "$gr3", + height: "$gr3", + margin: "0 $gr1 0 0", + background: "$slate4", + boxShadow: "inset 1px 1px 2px #0002", + border: "none", + borderRadius: "50%", // 3px for checkbox + cursor: "pointer", + transition: "$canopyAll", + flexShrink: "0", + + "&::before": { + position: "absolute", + width: "100%", + height: "100%", + content: "", + background: "linear-gradient(-45deg, $indigo11, $indigo8)", + opacity: "0", + borderRadius: "50%", // 3px for checkbox + transition: "$canopyAll", + zIndex: "0", + }, + + "&[aria-checked='true']": { + boxShadow: "1px 1px 2px ${indigoA.indigoA4}", + + "&::before": { + opacity: "1", + }, + }, +}); + +const OptionCheckboxIndicator = styled(Checkbox.Indicator, { + position: "absolute", + zIndex: "1", + color: "$indigo1", + textShadow: `1px 1px 2px ${indigoA.indigoA12}`, +}); + +const OptionStyled = styled("span", { + display: "flex", + margin: "0 0 $gr1", + fontSize: "$gr3", + color: "$slate11", + + "&:last-child": { + margin: "0", + }, +}); + +export { OptionCheckbox, OptionCheckboxIndicator, OptionLabel, OptionStyled }; diff --git a/components/Facets/Option.tsx b/components/Facets/Option.tsx new file mode 100644 index 0000000..7a98ecf --- /dev/null +++ b/components/Facets/Option.tsx @@ -0,0 +1,56 @@ +import { + OptionCheckbox, + OptionCheckboxIndicator, + OptionLabel, + OptionStyled, +} from "./Option.styled"; +import { CheckIcon } from "@radix-ui/react-icons"; +import React from "react"; +import { useFacetsState } from "@/context/facets"; + +interface FacetsOptionProps { + active: boolean; + facet: string; + identifier: string; + option: any; +} + +export const FacetsOption: React.FC = ({ + active, + facet, + identifier, + option, +}) => { + const { facetsDispatch, facetsState } = useFacetsState(); + const { facetsActive } = facetsState; + + const handleCheckedChange = (checked: boolean) => { + facetsActive.delete(facet); + checked && facetsActive.append(facet, option.slug); + + facetsDispatch({ + type: "updateFacetsActive", + facetsActive: facetsActive, + }); + }; + + return ( + + + + + + + + {option.value} ({option.doc_count}) + + + ); +}; + +export default FacetsOption; diff --git a/components/Figure/Figure.styled.ts b/components/Figure/Figure.styled.ts new file mode 100644 index 0000000..33c5317 --- /dev/null +++ b/components/Figure/Figure.styled.ts @@ -0,0 +1,32 @@ +import { styled } from "@/stitches"; + +const Image = styled("img", { + position: "relative", + zIndex: "1", + width: "100%", + height: "100%", + objectFit: "contain", + transition: "$canopyAll", + opacity: 0, + + [`&.loaded`]: { + opacity: 1, + }, +}); + +const Wrapper = styled("figure", { + backgroundColor: "$slate6", + display: "flex", + width: "100%", + height: "100%", + padding: "0", + margin: "0", + position: "relative", + overflow: "hidden", + zIndex: "0", + borderRadius: "3px", + boxShadow: "2px 2px 5px #0001", + transition: "$canopyAll", +}); + +export { Image, Wrapper }; diff --git a/components/Figure/Figure.tsx b/components/Figure/Figure.tsx new file mode 100644 index 0000000..3a6e2fa --- /dev/null +++ b/components/Figure/Figure.tsx @@ -0,0 +1,53 @@ +import React, { useState, useEffect, useRef } from "react"; +import { Image, Wrapper } from "@/components/Figure/Figure.styled"; +import clsx from "clsx"; +import { getResourceImage } from "@/hooks/getResourceImage"; + +interface FigureProps { + resource: any; + region?: string; + size?: string; + isCover?: boolean; +} + +const Figure: React.FC = ({ + resource, + region = "full", + size = "400,", + isCover = false, +}) => { + const [image, setImage] = useState(); + const [loaded, setLoaded] = useState(false); + const imgRef = useRef(null); + + useEffect(() => { + setImage(getResourceImage(resource, size, region)); + + // @ts-ignore + if (imgRef?.current && imgRef?.current?.complete) setLoaded(true); + }, []); + + return ( + + setLoaded(true)} + className={clsx("source", loaded && "loaded")} + /> + + ); +}; + +export default Figure; diff --git a/components/Footer/Footer.styled.ts b/components/Footer/Footer.styled.ts new file mode 100644 index 0000000..3437048 --- /dev/null +++ b/components/Footer/Footer.styled.ts @@ -0,0 +1,32 @@ +import { styled } from "@/stitches"; +import { slateA } from "@radix-ui/colors"; + +const FooterContent = styled("div", { + display: "flex", + justifyContent: "space-between", + fontSize: "$gr3", + fontWeight: "500", +}); + +const FooterStyled = styled("footer", { + boxShadow: `inset 1px 2px 2px ${slateA.slateA3}`, + padding: "$gr3 0", + marginTop: "$gr6", +}); + +const CollectionLink = styled("a", { + color: "$indigo11", + fill: "$indigo11", + display: "flex", + alignItems: "center", + textDecoration: "none", + + svg: { + marginRight: "$gr2", + height: "$gr3", + color: "inherit", + fill: "inherit", + }, +}); + +export { FooterContent, FooterStyled, CollectionLink }; diff --git a/components/Footer/Footer.tsx b/components/Footer/Footer.tsx new file mode 100644 index 0000000..2119af0 --- /dev/null +++ b/components/Footer/Footer.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import IIIF from "@/components/SVG/IIIF"; +import ThemeMode from "./ThemeMode"; +import { CollectionLink, FooterContent, FooterStyled } from "./Footer.styled"; +import Container from "../Shared/Container"; +import { LocaleString } from "@/hooks/useLocale"; + +const { collection } = process.env.CANOPY_CONFIG as any; + +const Footer = () => { + return ( + + + + + + {LocaleString("footerSourceCollection")} + + + + + + ); +}; + +export default Footer; diff --git a/components/Footer/ThemeMode.tsx b/components/Footer/ThemeMode.tsx new file mode 100644 index 0000000..2392cf6 --- /dev/null +++ b/components/Footer/ThemeMode.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; +import { ButtonStyled } from "../Shared/Button/Button.styled"; +import { LocaleString } from "@/hooks/useLocale"; + +const ThemeMode = () => { + const [mounted, setMounted] = useState(false); + const { theme, setTheme } = useTheme(); + + const toggleTheme = LocaleString("footerToggleTheme"); + + useEffect(() => setMounted(true), []); + + const handleTheme = (currentTheme: string) => { + switch (currentTheme) { + case "light": + setTheme("dark"); + break; + case "dark": + setTheme("light"); + break; + } + }; + + if (!mounted) return <>; + + return ( + handleTheme(theme as string)} + buttonSize="small" + > + {toggleTheme} + + ); +}; + +export default ThemeMode; diff --git a/components/Grid/Grid.styled.ts b/components/Grid/Grid.styled.ts new file mode 100644 index 0000000..becca79 --- /dev/null +++ b/components/Grid/Grid.styled.ts @@ -0,0 +1,57 @@ +import { styled } from "@/stitches"; +import Masonry from "react-masonry-css"; + +const GridItem = styled("div", { + paddingBottom: "$gr5", + zIndex: "1", + + "@xxs": { + paddingBottom: "$gr3", + }, + + "@xs": { + paddingBottom: "$gr3", + }, + + "@sm": { + paddingBottom: "$gr4", + }, + + "@md": { + paddingBottom: "$gr4", + }, +}); + +const GridStyled = styled(Masonry, { + display: "flex", + width: "auto", + position: "relative", + padding: "$gr2 0", + zIndex: "1", + + ".canopy-grid-column": { + marginLeft: "$gr5", + + "@xxs": { + marginLeft: "$gr3", + }, + + "@xs": { + marginLeft: "$gr3", + }, + + "@sm": { + marginLeft: "$gr4", + }, + + "@md": { + marginLeft: "$gr4", + }, + + "&:first-child": { + marginLeft: "0", + }, + }, +}); + +export { GridItem, GridStyled }; diff --git a/components/Grid/Grid.tsx b/components/Grid/Grid.tsx new file mode 100644 index 0000000..72bd1d1 --- /dev/null +++ b/components/Grid/Grid.tsx @@ -0,0 +1,37 @@ +import React, { ReactNode } from "react"; +import GridItem, { GridItemProps } from "@/components/Grid/Item"; +import { GridStyled } from "@/components/Grid/Grid.styled"; +import { width } from "@/styles/theme/media"; + +interface GridProps { + children: ReactNode | ReactNode[]; +} + +interface GridComposition { + Item: React.FC; +} + +const Grid: GridComposition & React.FC = ({ children }) => { + const columns = { + default: 6, + [width.xl]: 5, + [width.lg]: 4, + [width.md]: 4, + [width.sm]: 3, + [width.xs]: 2, + }; + + return ( + + {children} + + ); +}; + +Grid.Item = GridItem; + +export default Grid; diff --git a/components/Grid/Item.tsx b/components/Grid/Item.tsx new file mode 100644 index 0000000..525ae4b --- /dev/null +++ b/components/Grid/Item.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import Card from "@/components/Card/Card"; +import { GridItem as ItemStyled } from "@/components/Grid/Grid.styled"; + +export interface GridItemProps { + item: any; +} + +const GridItem: React.FC = ({ item }) => { + if (!item) return <>; + + return ( + + + + ); +}; + +export default GridItem; diff --git a/components/Header/Header.styled.tsx b/components/Header/Header.styled.tsx new file mode 100644 index 0000000..3cb206c --- /dev/null +++ b/components/Header/Header.styled.tsx @@ -0,0 +1,131 @@ +import { styled } from "@/stitches"; +import { slateA } from "@radix-ui/colors"; + +const Title = styled("span", { + display: "flex", + marginRight: "$gr2", + fontFamily: "$bookTight", + fontSize: "$gr4", + fontWeight: "800", + + "@sm": { + marginRight: "0", + }, +}); + +const ResponsiveActions = styled("div", { + flexGrow: "1", + display: "none", + justifyContent: "flex-end", + + button: { + display: "flex", + flexDirection: "column", + justifyContent: "center", + background: "transparent", + border: "none", + fontSize: "$gr3", + height: "calc(($gr1 * 2) + $gr4 + 1px)", + cursor: "pointer", + }, + + "@sm": { + display: "flex", + }, +}); + +const Actions = styled("div", { + flexGrow: "1", + display: "flex", + justifyContent: "flex-end", + + "@sm": { + flexDirection: "column-reverse", + backgroundColor: "$slate1", + position: "absolute", + width: "100%", + padding: "$gr1 0 $gr3", + overflow: "hidden", + left: "0", + top: "-10000px", + boxShadow: `1px 2px 2px ${slateA.slateA4}`, + }, + + variants: { + showNav: { + true: { + top: "calc($gr4 + ($gr2 * 2))", + }, + }, + }, +}); + +const Content = styled("div", { + width: "100%", + padding: "$gr1 $gr5", + backgroundColor: "$slate1", + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + color: "$slate1", + fontSize: "1rem", + lineHeight: "1.5rem", + alignItems: "center", + zIndex: "1", + boxShadow: `1px 1px 3px ${slateA.slateA6}`, + boxSizing: "border-box", + + "@xl": { + padding: "$gr1 $gr4", + }, + + "@lg": { + padding: "$gr1 $gr4", + }, + + "@md": { + padding: "$gr1 $gr4", + }, + + "@sm": { + padding: "$gr1 $gr4", + }, + + "@xs": { + padding: "$gr1 $gr3", + }, + + "@xxs": { + padding: "$gr1 $gr3", + }, + + [`& ${Title} a`]: { + textDecoration: "none", + color: "$slate12", + + "&:hover, &:focus": { + color: "$indigo10", + }, + }, +}); + +const Wrapper = styled("header", { + position: "fixed", + width: "100%", + zIndex: "10", + top: "0", + display: "flex", + flexDirection: "column", + transition: "$canopyOpacity", + opacity: "1", + + variants: { + isVisible: { + false: { + opacity: "0", + }, + }, + }, +}); + +export { Actions, Content, ResponsiveActions, Title, Wrapper }; diff --git a/components/Header/Header.tsx b/components/Header/Header.tsx new file mode 100644 index 0000000..f98e228 --- /dev/null +++ b/components/Header/Header.tsx @@ -0,0 +1,51 @@ +import React, { useEffect, useState } from "react"; +import Link from "next/link"; +import Locale from "@/components/Shared/Locale/Locale"; +import Nav from "@/components/Nav/Nav"; +import Search from "@/components/Search/Search"; +import { Content, Title, Wrapper } from "@/components/Header/Header.styled"; +import collections from "@/.canopy/collections.json"; +import { Label } from "@samvera/nectar-iiif"; +import { Actions, ResponsiveActions } from "./Header.styled"; +import { HamburgerMenuIcon } from "@radix-ui/react-icons"; +import { useRouter } from "next/router"; +import { useCanopyState } from "@/context/canopy"; + +// @ts-ignore +const navItems = process.env.CANOPY_CONFIG.navigation.primary; + +const Header = () => { + const [showNav, setShowNav] = useState(false); + const router = useRouter(); + const { pathname, query } = router; + const { canopyState } = useCanopyState(); + const { headerVisible } = canopyState; + + useEffect(() => setShowNav(false), [pathname, query]); + + const handleShowNav = () => setShowNav(!showNav); + + return ( + + + + <Link href="/"> + <Label label={collections[0].label} as="span" /> + </Link> + + + + + + +