From d9c1bfa6c11cd1f623c330f07130c3130f5ef91f Mon Sep 17 00:00:00 2001 From: Mat Jordan <mat@northwestern.edu> Date: Fri, 8 Nov 2024 09:10:34 -0500 Subject: [PATCH] Add common IIIF share component. --- components/Clover/ViewerWrapper.styled.ts | 10 + components/Clover/ViewerWrapper.tsx | 39 ++-- components/Collection/Collection.styled.ts | 28 ++- components/Collection/NavTabs.styled.ts | 2 +- components/Figure/Figure.styled.ts | 1 + components/Heading/Heading.styled.ts | 3 +- components/Hero/Hero.styled.ts | 9 +- components/Hero/Hero.tsx | 2 +- components/Homepage/Collections.styled.ts | 3 - components/Homepage/Overview.styled.ts | 17 +- components/Search/Results.tsx | 16 +- components/Search/Search.styled.ts | 10 +- components/Shared/CopyText.styled.ts | 17 +- components/Shared/DefinitionList.styled.ts | 6 +- components/Shared/Expand/Expand.styled.ts | 6 +- components/Shared/Expand/Expand.tsx | 7 +- components/Shared/IIIF/Share.test.tsx | 56 +++++ components/Shared/IIIF/Share.tsx | 204 ++++++++++++++++++ components/Shared/IIIF/ViewerLink.test.tsx | 36 ++++ components/Shared/IIIF/ViewerLink.tsx | 26 +++ components/Shared/Icon.tsx | 31 ++- components/Shared/SVG/Icons.tsx | 8 + .../Shared/WorkCount/WorkCount.styled.ts | 1 - .../DownloadAndShare/IIIFManifest.tsx | 23 +- components/Work/TopInfo.styled.ts | 50 +++-- components/Work/TopInfo.tsx | 20 +- hooks/useCopyToClipboard.ts | 24 ++- lib/dc-api.test.ts | 46 ++++ lib/dc-api.ts | 29 +++ mocks/sample-collection1.ts | 2 + package-lock.json | 8 +- package.json | 2 +- pages/collections/[id].tsx | 19 +- 33 files changed, 654 insertions(+), 107 deletions(-) create mode 100644 components/Shared/IIIF/Share.test.tsx create mode 100644 components/Shared/IIIF/Share.tsx create mode 100644 components/Shared/IIIF/ViewerLink.test.tsx create mode 100644 components/Shared/IIIF/ViewerLink.tsx create mode 100644 lib/dc-api.test.ts diff --git a/components/Clover/ViewerWrapper.styled.ts b/components/Clover/ViewerWrapper.styled.ts index 2d580346..f1d1c560 100644 --- a/components/Clover/ViewerWrapper.styled.ts +++ b/components/Clover/ViewerWrapper.styled.ts @@ -10,6 +10,16 @@ const ViewerWrapperStyled = styled("section", { background: "#f0f0f0", }, + ".clover-viewer-header": { + display: "none", + }, + + ".clover-viewer-media-wrapper": { + "div[role='radiogroup']": { + paddingBottom: "0", + }, + }, + "& label[for='information-toggle']": { boxShadow: "none", }, diff --git a/components/Clover/ViewerWrapper.tsx b/components/Clover/ViewerWrapper.tsx index 24076213..4a6cf573 100644 --- a/components/Clover/ViewerWrapper.tsx +++ b/components/Clover/ViewerWrapper.tsx @@ -8,6 +8,7 @@ import type { } from "@samvera/clover-iiif"; import Announcement from "@/components/Shared/Announcement"; +import Container from "../Shared/Container"; import { IconInfo } from "@/components/Shared/SVG/Icons"; import React from "react"; import { UserContext } from "@/context/user-context"; @@ -76,24 +77,26 @@ const WorkViewerWrapper: React.FC<WrapperProps> = ({ }; return ( - <ViewerWrapperStyled data-testid="work-viewer-wrapper"> - {manifestId && ( - <CloverViewer - // @ts-ignore - customTheme={customTheme} - iiifContent={manifestId} - options={options} - /> - )} - {isWorkRestricted && userAuth?.user?.isReadingRoom && ( - <Announcement> - <AnnouncementContent> - <IconInfo /> - <p>You have access to Work because you are in the reading room</p> - </AnnouncementContent> - </Announcement> - )} - </ViewerWrapperStyled> + <Container containerType="wide"> + <ViewerWrapperStyled data-testid="work-viewer-wrapper"> + {manifestId && ( + <CloverViewer + // @ts-ignore + customTheme={customTheme} + iiifContent={manifestId} + options={options} + /> + )} + {isWorkRestricted && userAuth?.user?.isReadingRoom && ( + <Announcement> + <AnnouncementContent> + <IconInfo /> + <p>You have access to Work because you are in the reading room</p> + </AnnouncementContent> + </Announcement> + )} + </ViewerWrapperStyled> + </Container> ); }; diff --git a/components/Collection/Collection.styled.ts b/components/Collection/Collection.styled.ts index 9423ef76..0328651b 100644 --- a/components/Collection/Collection.styled.ts +++ b/components/Collection/Collection.styled.ts @@ -2,12 +2,32 @@ import { styled } from "@/stitches.config"; /* eslint sort-keys: 0 */ +const CollectionHeader = styled("div", { + display: "flex", + justifyContent: "space-between", + marginTop: "1.5em", + gap: "$gr4", + + "div:last-child": { + flexShrink: 0, + flexGrow: 0, + marginBottom: "$gr2", + }, + + "@sm": { + flexDirection: "column", + gap: "0", + }, +}); + const Description = styled("p", { - fontSize: "$gr5", - fontFamily: "$northwesternSansLight", lineHeight: "1.55em", - margin: "$gr2 0 $gr5", + margin: "$gr2 0 $gr6", color: "$black50", + + "@sm": { + fontSize: "$gr3", + }, }); const HeroWrapper = styled("div", { @@ -22,4 +42,4 @@ const Interstitial = styled("div", { padding: "$gr3 0", }); -export { Description, Interstitial, HeroWrapper }; +export { CollectionHeader, Description, Interstitial, HeroWrapper }; diff --git a/components/Collection/NavTabs.styled.ts b/components/Collection/NavTabs.styled.ts index 941ee844..eac53e3d 100644 --- a/components/Collection/NavTabs.styled.ts +++ b/components/Collection/NavTabs.styled.ts @@ -1,4 +1,5 @@ import * as TabsPrimitive from "@radix-ui/react-tabs"; + import { styled } from "@/stitches.config"; /* eslint sort-keys: 0 */ @@ -63,7 +64,6 @@ const StyledContent = styled(TabsPrimitive.Content, { borderBottomLeftRadius: 6, borderBottomRightRadius: 6, outline: "none", - "&:focus": { boxShadow: `0 0 0 2px #f0f0f0` }, "& a": { color: "$purple", diff --git a/components/Figure/Figure.styled.ts b/components/Figure/Figure.styled.ts index 1d2e8c54..7c11f9fd 100644 --- a/components/Figure/Figure.styled.ts +++ b/components/Figure/Figure.styled.ts @@ -93,6 +93,7 @@ const FigureTitle = styled("span", { color: "$purple", display: "flex", alignItems: "flex-start", + lineHeight: "1.25em", }); const FigureText = styled("div", { diff --git a/components/Heading/Heading.styled.ts b/components/Heading/Heading.styled.ts index 2cac8d43..123e4b7c 100644 --- a/components/Heading/Heading.styled.ts +++ b/components/Heading/Heading.styled.ts @@ -35,9 +35,10 @@ const StyledHeading = styled("h2", { }, "&[data-level=h2]": { - color: "$purple", fontFamily: "$northwesternDisplayBold", + color: "$black80", fontSize: "$gr7", + letterSpacing: "-0.015em", fontWeight: "400", marginBottom: "$gr5", }, diff --git a/components/Hero/Hero.styled.ts b/components/Hero/Hero.styled.ts index 4eb41ff1..dafc85fa 100644 --- a/components/Hero/Hero.styled.ts +++ b/components/Hero/Hero.styled.ts @@ -37,6 +37,7 @@ const HeroStyled = styled("div", { width: "100%", height: "100%", position: "relative", + zIndex: "1", ".swiper-wrapper": { "&::before": { @@ -115,7 +116,7 @@ const HeroStyled = styled("div", { display: "flex", flexDirection: "column", alignItems: "flex-start", - textShadow: "2px 2px 2px #000", + textShadow: "3px 3px 8px #0006", maxWidth: "$gr11", textAlign: "left", @@ -135,10 +136,12 @@ const HeroStyled = styled("div", { ".slide-label": { fontFamily: "$northwesternDisplayBold", - fontSize: "$gr8", + fontWeight: "400", + fontSize: "$gr7", display: "block", margin: "0 0 $gr2", lineHeight: "1em", + letterSpacing: "-0.015em", "@sm": { fontSize: "$gr7", @@ -146,7 +149,7 @@ const HeroStyled = styled("div", { }, ".slide-summary": { - fontFamily: "$northwesternSansLightItalic", + fontFamily: "$northwesternSansRegular", fontSize: "$gr4", display: "block", color: "$black20", diff --git a/components/Hero/Hero.tsx b/components/Hero/Hero.tsx index a1bacaea..029bad32 100644 --- a/components/Hero/Hero.tsx +++ b/components/Hero/Hero.tsx @@ -30,7 +30,7 @@ const Hero: React.FC<HeroProps> = ({ collection }) => { clickable: true, }} slidesPerView={1} - speed={1000} + speed={300} > {collection.items.map((item) => ( <SwiperSlide key={item.id}> diff --git a/components/Homepage/Collections.styled.ts b/components/Homepage/Collections.styled.ts index f82e32bf..c60e9229 100644 --- a/components/Homepage/Collections.styled.ts +++ b/components/Homepage/Collections.styled.ts @@ -4,9 +4,6 @@ import { FigureTitle } from "@/components/Figure/Figure.styled"; import { styled } from "@/stitches.config"; const HomepageCollectionsStyled = styled("section", { - backgroundColor: "$purple10", - padding: "$gr5 0", - [`${FigureTitle}`]: { lineHeight: "1.25em", fontSize: "$gr4", diff --git a/components/Homepage/Overview.styled.ts b/components/Homepage/Overview.styled.ts index 02d0e450..8771ae48 100644 --- a/components/Homepage/Overview.styled.ts +++ b/components/Homepage/Overview.styled.ts @@ -13,18 +13,20 @@ const Content = styled("div", { h2: { fontFamily: "$northwesternDisplayBold", - margin: "0 0 $gr2", + margin: "0 0 $gr3", fontSize: "$gr6", fontWeight: "400", lineHeight: "1.15", + letterSpacing: "-0.015em", }, p: { - fontFamily: "$northwesternSansLight", - fontSize: "$4", + fontFamily: "$northwesternSansRegular", + fontSize: "$gr4", lineHeight: "1.55em", margin: "0 0 $gr4", padding: "0", + color: "$black50", }, }); @@ -34,11 +36,9 @@ const Images = styled("div", { gridTemplateColumns: "1fr 2fr 3fr 3fr 3fr 1fr", gridTemplateRows: "repeat(6,auto)", gridGap: "$gr2", - marginLeft: "$gr5", "@sm": { - width: "100%", - marginLeft: "0", + width: "300px", }, "img, video": { @@ -82,13 +82,14 @@ const Images = styled("div", { const Inner = styled("div", { display: "flex", - padding: "$gr6", + padding: "$gr5", width: "auto", alignItems: "center", + gap: "$gr5", "@sm": { flexDirection: "column-reverse", - padding: "$gr4 $gr3", + gap: "$gr3", }, }); diff --git a/components/Search/Results.tsx b/components/Search/Results.tsx index f3753075..11624703 100644 --- a/components/Search/Results.tsx +++ b/components/Search/Results.tsx @@ -2,13 +2,18 @@ import { NoResultsMessage, ResultsMessage, ResultsWrapper, + ResultsWrapperHeader, } from "@/components/Search/Search.styled"; import Grid from "@/components/Grid/Grid"; +import IIIFShare from "../Shared/IIIF/Share"; import PaginationAltCounts from "@/components/Search/PaginationAltCounts"; +import { SEARCH_RESULTS_PER_PAGE } from "@/lib/constants/common"; import { SearchResultsState } from "@/types/components/search"; +import { iiifSearchUri } from "@/lib/dc-api"; import { pluralize } from "@/lib/utils/count-helpers"; import useGenerativeAISearchToggle from "@/hooks/useGenerativeAISearchToggle"; +import { useRouter } from "next/router"; const SearchResults: React.FC<SearchResultsState> = ({ data, @@ -16,7 +21,9 @@ const SearchResults: React.FC<SearchResultsState> = ({ loading, }) => { const { isChecked: isAI } = useGenerativeAISearchToggle(); + const router = useRouter(); + const iiifCollection = iiifSearchUri(router.query, SEARCH_RESULTS_PER_PAGE); const totalResults = data?.pagination?.total_hits; return ( @@ -27,9 +34,12 @@ const SearchResults: React.FC<SearchResultsState> = ({ <> {!isAI && (totalResults ? ( - <ResultsMessage data-testid="results-count"> - {pluralize("result", totalResults)} - </ResultsMessage> + <ResultsWrapperHeader> + <ResultsMessage data-testid="results-count"> + {pluralize("result", totalResults)} + </ResultsMessage> + <IIIFShare uri={iiifCollection} /> + </ResultsWrapperHeader> ) : ( <NoResultsMessage> <strong>Your search did not match any results.</strong> Please diff --git a/components/Search/Search.styled.ts b/components/Search/Search.styled.ts index 113bbd14..12187f81 100644 --- a/components/Search/Search.styled.ts +++ b/components/Search/Search.styled.ts @@ -87,7 +87,6 @@ const Button = styled("button", { const ResultsMessage = styled("span", { color: "$black50", - padding: "0 $gr4 $gr4", fontSize: "$gr3", "@lg": { @@ -126,6 +125,14 @@ const ResultsWrapper = styled("div", { minHeight: "80vh", }); +const ResultsWrapperHeader = styled("header", { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: "0 $gr4 $gr4", +}); + const StyledResponseWrapper = styled("div", { padding: "0 0 $gr6", }); @@ -135,6 +142,7 @@ export { NoResultsMessage, ResultsMessage, ResultsWrapper, + ResultsWrapperHeader, SearchStyled, StyledResponseWrapper, }; diff --git a/components/Shared/CopyText.styled.ts b/components/Shared/CopyText.styled.ts index d4aee8aa..64237fca 100644 --- a/components/Shared/CopyText.styled.ts +++ b/components/Shared/CopyText.styled.ts @@ -6,13 +6,12 @@ const StyledStatus = styled("span", { display: "flex", alignContent: "center", alignItems: "center", - padding: "0 $gr1", - marginLeft: "$gr1", - backgroundColor: "$darkBlueA", - color: "$white", + color: "$darkBlueA", borderRadius: "3px", fontSize: "$gr1", textTransform: "uppercase", + position: "absolute", + right: "-1.25em", }); const StyledCopyText = styled("button", { @@ -26,15 +25,17 @@ const StyledCopyText = styled("button", { fontFamily: "$northwesternSans", fontSize: "$gr3", whiteSpace: "nowrap", + textDecoration: "underline", + textDecorationThickness: "min(2px,max(1px,.05em))", + textUnderlineOffset: "calc(.05em + 2px)", + textDecorationColor: "$purple10", + position: "relative", + zIndex: "0", svg: { height: "calc($gr3 - 3px)", marginRight: "$gr1", }, - - "&:hover": { - textDecoration: "underline", - }, }); export { StyledCopyText, StyledStatus }; diff --git a/components/Shared/DefinitionList.styled.ts b/components/Shared/DefinitionList.styled.ts index 808b8923..0dfa6b36 100644 --- a/components/Shared/DefinitionList.styled.ts +++ b/components/Shared/DefinitionList.styled.ts @@ -4,15 +4,15 @@ import { styled } from "@/stitches.config"; const DefinitionListWrapper = styled("div", { lineHeight: "1.47em", + fontSize: "$gr3", "& dt": { - fontSize: "$gr3", color: "$black", - fontFamily: "$northwesternDisplayBold", + fontFamily: "$northwesternSansBold", }, "& dd": { marginInlineStart: "0", - paddingBottom: "$gr2", + paddingBottom: "$gr3", }, }); diff --git a/components/Shared/Expand/Expand.styled.ts b/components/Shared/Expand/Expand.styled.ts index 7890055c..c4ae989c 100644 --- a/components/Shared/Expand/Expand.styled.ts +++ b/components/Shared/Expand/Expand.styled.ts @@ -1,10 +1,10 @@ import { VariantProps, styled } from "@/stitches.config"; + import { Button } from "@nulib/design-system"; /* eslint sort-keys: 0 */ const ExpandButton = styled(Button, { - margin: "0 auto", opacity: "1", transition: "$dcAll", }); @@ -14,9 +14,9 @@ const ExpandEdge = styled("div", { width: "100%", bottom: "0", display: "flex", - justifyContent: "center", - padding: "$4 0 0", backgroundColor: "$white", + justifyContent: "flex-start", + paddingTop: "$gr7", background: "linear-gradient(to bottom, #fff0 0%, #fff 61.8%)", transition: "$dcAll", overflow: "hidden", diff --git a/components/Shared/Expand/Expand.tsx b/components/Shared/Expand/Expand.tsx index 4f852f5e..0b8b1d4e 100644 --- a/components/Shared/Expand/Expand.tsx +++ b/components/Shared/Expand/Expand.tsx @@ -38,7 +38,12 @@ const Expand: React.FC<ExpandProps & ExpandVariants> = ({ > <ExpandContent ref={contentRef}>{children}</ExpandContent> <ExpandEdge> - <ExpandButton onClick={handleExpand} isLowercase disabled={isExpanded}> + <ExpandButton + onClick={handleExpand} + isLowercase + isPrimary + disabled={isExpanded} + > {buttonText} </ExpandButton> </ExpandEdge> diff --git a/components/Shared/IIIF/Share.test.tsx b/components/Shared/IIIF/Share.test.tsx new file mode 100644 index 00000000..830c2fff --- /dev/null +++ b/components/Shared/IIIF/Share.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from "@/test-utils"; + +import IIIFShare from "@/components/Shared/IIIF/Share"; +import React from "react"; + +describe("IIIFShare", () => { + const uri = + "https://iiif.io/api/cookbook/recipe/0001-mvm-image/manifest.json"; + + it("renders a dropdown with IIIF viewers", async () => { + render(<IIIFShare uri={uri} />); + + // Verify that initial elements are present + expect(screen.getByTestId("iiif-share")).toBeInTheDocument(); + + const trigger = screen.getByTestId("iiif-share-trigger"); + expect(trigger).toHaveTextContent("View as IIIF"); + }); + + it("renders dropdown content with expected items", async () => { + render(<IIIFShare uri={uri} dropdownMenuProps={{ open: true }} />); + + const content = screen.getByTestId("iiif-share-content"); + expect(screen.getByText("View in...")).toBeInTheDocument(); + + const links = Array.from(content.querySelectorAll("a")); + const expectedLinks = { + "Clover IIIF": + "https://samvera-labs.github.io/clover-iiif/docs/viewer/demo?iiif-content=https%3A%2F%2Fiiif.io%2Fapi%2Fcookbook%2Frecipe%2F0001-mvm-image%2Fmanifest.json", + Mirador: + "https://projectmirador.org/embed?iiif-content=https%3A%2F%2Fiiif.io%2Fapi%2Fcookbook%2Frecipe%2F0001-mvm-image%2Fmanifest.json", + Theseus: + "https://theseusviewer.org/?iiif-content=https%3A%2F%2Fiiif.io%2Fapi%2Fcookbook%2Frecipe%2F0001-mvm-image%2Fmanifest.json", + "View Raw JSON": uri, + "What is IIIF?": "https://iiif.io/get-started/why-iiif/", + }; + + // verify that the links have the expected hrefs + expect(links.length).toEqual(Object.keys(expectedLinks).length); + links.forEach((link) => { + expect(link).toBeInTheDocument(); + expect(link).toBeInstanceOf(HTMLAnchorElement); + expect(link).toHaveAttribute("target", "_blank"); + const key = link.textContent as keyof typeof expectedLinks; + const expectedHref = expectedLinks[key]; + if (expectedHref) { + expect(link).toHaveAttribute("href", expectedHref); + } + }); + + // verify that the copy button is present + const copyText = screen.getByText("Copy IIIF JSON"); + expect(copyText).toBeInTheDocument(); + expect(copyText).toBeInstanceOf(HTMLButtonElement); + }); +}); diff --git a/components/Shared/IIIF/Share.tsx b/components/Shared/IIIF/Share.tsx new file mode 100644 index 00000000..e58720cd --- /dev/null +++ b/components/Shared/IIIF/Share.tsx @@ -0,0 +1,204 @@ +import * as Dropdown from "@radix-ui/react-dropdown-menu"; + +import { IconChevronDown, IconExternalLink } from "../SVG/Icons"; + +import CopyText from "../CopyText"; +import IIIFLogo from "../SVG/IIIF"; +import IIIFViewerLink from "./ViewerLink"; +import Icon from "../Icon"; +import Link from "next/link"; +import { styled } from "@/stitches.config"; + +const IIIFShare = ({ + uri, + dropdownMenuProps, +}: { + uri: string; + dropdownMenuProps?: Dropdown.DropdownMenuProps; +}) => { + return ( + <StyledIIIFShare data-iiif-uri={uri} data-testid="iiif-share"> + <Dropdown.Root {...dropdownMenuProps}> + <Dropdown.Trigger data-testid="iiif-share-trigger"> + <Icon> + <IIIFLogo /> + </Icon> + <em>View as IIIF</em> + <Icon> + <IconChevronDown /> + </Icon> + </Dropdown.Trigger> + <StyledIIIFShareContent + data-testid="iiif-share-content" + side="bottom" + sideOffset={3} + collisionPadding={19} + > + <StyledDropdownLabel>View in...</StyledDropdownLabel> + <Dropdown.Item> + <IIIFViewerLink + viewer={{ + label: "Clover IIIF", + href: "https://samvera-labs.github.io/clover-iiif/docs/viewer/demo", + }} + uri={uri} + /> + </Dropdown.Item> + <Dropdown.Item> + <IIIFViewerLink + viewer={{ + label: "Mirador", + href: "https://projectmirador.org/embed", + }} + uri={uri} + /> + </Dropdown.Item> + <Dropdown.Item> + <IIIFViewerLink + viewer={{ + label: "Theseus", + href: "https://theseusviewer.org", + }} + uri={uri} + /> + </Dropdown.Item> + <StyledDropdownSeparator /> + <Dropdown.Item> + <Link href={uri} target="_blank" rel="noreferrer"> + View Raw JSON + </Link> + </Dropdown.Item> + <Dropdown.Item> + <CopyText textPrompt="Copy IIIF JSON" textToCopy={uri} /> + </Dropdown.Item> + <StyledDropdownSeparator /> + <Dropdown.Item> + <Link + href="https://iiif.io/get-started/why-iiif/" + target="_blank" + rel="noreferrer" + data-id="what-is-iiif" + > + What is IIIF? + <Icon + style={{ + display: "inline-flex", + width: "12px", + height: "12px", + color: "$black50", + fill: "$black50", + marginLeft: "0.25em", + }} + hasSVGPadding={false} + > + <IconExternalLink /> + </Icon> + </Link> + </Dropdown.Item> + </StyledIIIFShareContent> + </Dropdown.Root> + </StyledIIIFShare> + ); +}; + +const StyledIIIFShare = styled("div", { + position: "relative", + zIndex: 1, + + "> button": { + backgroundColor: "transparent", + color: "$black50", + fontFamily: "$northwesternSansRegular", + fontSize: "$gr3", + borderRadius: "38px", + border: "none", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + cursor: "pointer", + gap: "$gr1", + padding: "0 $gr1", + margin: "0", + + "> span": { + svg: { + padding: "7px", + path: { + fill: "$purple !important", + }, + }, + + "&:last-child": { + display: "inline-flex", + alignItems: "center", + gap: "$gr1", + + "svg path": { + stroke: "$black50 !important", + fill: "none !important", + }, + }, + }, + + em: { + fontStyle: "normal", + display: "inline-flex", + marginBottom: "-3px", + }, + + "&:hover, &:active ": { + color: "$purple", + fill: "$black", + backgroundColor: "$purple10", + + "> span:last-child svg path": { + stroke: "$purple !important", + }, + }, + }, +}); + +const StyledIIIFShareContent = styled(Dropdown.Content, { + zIndex: 1, + backgroundColor: "$white", + padding: "$gr3", + borderRadius: "3px", + boxShadow: "5px 5px 19px 0 #0002", + display: "flex", + flexDirection: "column", + fontSize: "$gr2 ", + minWidth: "160px", + gap: "$gr2", + + a: { + color: "$purple", + display: "flex", + + svg: { + color: "$purple", + fill: "$purple", + }, + }, + + button: { + fontSize: "$gr2", + margin: "0 !important", + padding: "0 !important", + fontWeight: "400", + lineHeight: "inherit !important", + textDecoration: "none", + color: "$purple", + }, +}); + +const StyledDropdownLabel = styled(Dropdown.Separator, { + fontSize: "$gr2 ", + color: "$black50", +}); + +const StyledDropdownSeparator = styled(Dropdown.Separator, { + height: "1px", + backgroundColor: "$gray6", +}); + +export default IIIFShare; diff --git a/components/Shared/IIIF/ViewerLink.test.tsx b/components/Shared/IIIF/ViewerLink.test.tsx new file mode 100644 index 00000000..f2d5c777 --- /dev/null +++ b/components/Shared/IIIF/ViewerLink.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react"; + +import React from "react"; +import ViewerLink from "./ViewerLink"; + +describe("ViewerLink", () => { + const uri = + "https://iiif.io/api/cookbook/recipe/0001-mvm-image/manifest.json"; + const viewer = { + label: "IIIF Viewer", + href: "https://example.org/iiif-viewer", + }; + + it("renders an `<a>` element to IIIF Viewer", () => { + render(<ViewerLink viewer={viewer} uri={uri} />); + expect(screen.getByText(viewer.label)).toBeInTheDocument(); + expect(screen.getByText(viewer.label).closest("a")).toHaveAttribute( + "href", + `${viewer.href}?iiif-content=${encodeURIComponent(uri)}`, + ); + }); + + it("renders an `<a>` element to IIIF Viewer with custom iiif-content param", () => { + render( + <ViewerLink + viewer={{ ...viewer, iiifContentParam: "manifest" }} + uri={uri} + />, + ); + expect(screen.getByText(viewer.label)).toBeInTheDocument(); + expect(screen.getByText(viewer.label).closest("a")).toHaveAttribute( + "href", + `${viewer.href}?manifest=${encodeURIComponent(uri)}`, + ); + }); +}); diff --git a/components/Shared/IIIF/ViewerLink.tsx b/components/Shared/IIIF/ViewerLink.tsx new file mode 100644 index 00000000..18417799 --- /dev/null +++ b/components/Shared/IIIF/ViewerLink.tsx @@ -0,0 +1,26 @@ +import Link from "next/link"; + +interface IIIFViewerLinkProps { + viewer: { + label: string; + href: string; + iiifContentParam?: string; + }; + uri: string; +} + +const IIIFViewerLink: React.FC<IIIFViewerLinkProps> = ({ viewer, uri }) => { + const iiifContent = new URL(viewer.href); + iiifContent.searchParams.set( + viewer.iiifContentParam ? viewer.iiifContentParam : "iiif-content", + uri, + ); + + return ( + <Link href={iiifContent.toString()} target="_blank" rel="noreferrer"> + {viewer.label} + </Link> + ); +}; + +export default IIIFViewerLink; diff --git a/components/Shared/Icon.tsx b/components/Shared/Icon.tsx index fbd94278..794f8a61 100644 --- a/components/Shared/Icon.tsx +++ b/components/Shared/Icon.tsx @@ -1,12 +1,23 @@ +import { CSS } from "@stitches/react"; import { ReactNode } from "react"; import { styled } from "@/stitches.config"; interface IconProps { children: ReactNode; + style?: CSS; + hasSVGPadding?: boolean; } -const Icon: React.FC<IconProps> = ({ children }) => { - return <IconStyled>{children}</IconStyled>; +const Icon: React.FC<IconProps> = ({ + children, + style, + hasSVGPadding = true, +}) => { + return ( + <IconStyled css={{ ...style }} hasSVGPadding={hasSVGPadding}> + {children} + </IconStyled> + ); }; /* eslint sort-keys: 0 */ @@ -32,7 +43,21 @@ export const IconStyled = styled("span", { color: "inherit", fill: "inherit", stroke: "inherit", - padding: "8px", + }, + + variants: { + hasSVGPadding: { + true: { + svg: { + padding: "0.5em", + }, + }, + false: { + svg: { + padding: "0", + }, + }, + }, }, }); diff --git a/components/Shared/SVG/Icons.tsx b/components/Shared/SVG/Icons.tsx index 7e5344b6..0df72d2e 100644 --- a/components/Shared/SVG/Icons.tsx +++ b/components/Shared/SVG/Icons.tsx @@ -79,6 +79,13 @@ const IconClear: React.FC = () => ( </svg> ); +const IconExternalLink: React.FC = () => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> + <path d="M224 304a16 16 0 01-11.31-27.31l157.94-157.94A55.7 55.7 0 00344 112H104a56.06 56.06 0 00-56 56v240a56.06 56.06 0 0056 56h240a56.06 56.06 0 0056-56V168a55.7 55.7 0 00-6.75-26.63L235.31 299.31A15.92 15.92 0 01224 304z" /> + <path d="M448 48H336a16 16 0 000 32h73.37l-38.74 38.75a56.35 56.35 0 0122.62 22.62L432 102.63V176a16 16 0 0032 0V64a16 16 0 00-16-16z" /> + </svg> +); + const IconFilter: React.FC = () => ( <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> <title>Filter</title> @@ -228,6 +235,7 @@ export { IconCheck, IconChevronDown, IconClear, + IconExternalLink, IconFilter, IconImage, IconInfo, diff --git a/components/Shared/WorkCount/WorkCount.styled.ts b/components/Shared/WorkCount/WorkCount.styled.ts index 345f8690..94c05943 100644 --- a/components/Shared/WorkCount/WorkCount.styled.ts +++ b/components/Shared/WorkCount/WorkCount.styled.ts @@ -4,7 +4,6 @@ import { styled } from "@/stitches.config"; const WorkCountStyled = styled("div", { display: "inline-flex", - backgroundColor: "$purple10", color: "$white", fontSize: "$gr1", borderRadius: "1rem", diff --git a/components/Work/ActionsDialog/DownloadAndShare/IIIFManifest.tsx b/components/Work/ActionsDialog/DownloadAndShare/IIIFManifest.tsx index cd84a852..ad8a9bcc 100644 --- a/components/Work/ActionsDialog/DownloadAndShare/IIIFManifest.tsx +++ b/components/Work/ActionsDialog/DownloadAndShare/IIIFManifest.tsx @@ -48,18 +48,19 @@ const IIIFManifest: React.FC<IIIFManifestProps> = ({ manifest, work }) => { manifestId={manifest.id} /> </ShareURLActions> - {(isWorkInstitution || isWorkPrivate) && ( - <Announcement - css={{ - marginTop: "1rem", - }} - data-testid="mirador-announcement" - > - Opening in external tools like Mirador is not supported for works - that require authentication. - </Announcement> - )} </ShareURL> + + {(isWorkInstitution || isWorkPrivate) && ( + <Announcement + css={{ + marginTop: "1rem", + }} + data-testid="mirador-announcement" + > + Opening in external tools like Mirador is not supported for works that + require authentication. + </Announcement> + )} </> ); }; diff --git a/components/Work/TopInfo.styled.ts b/components/Work/TopInfo.styled.ts index 44586359..0b0cabdc 100644 --- a/components/Work/TopInfo.styled.ts +++ b/components/Work/TopInfo.styled.ts @@ -11,7 +11,6 @@ const ActionButtons = styled("div", { button: { marginRight: "$gr3", - fontFamily: "$northwesternSansLight", paddingTop: "$gr3", "&:last-child": { @@ -45,7 +44,7 @@ const TopInfoContent = styled("div", { }); const TopInfoWrapper = styled("section", { - margin: "$gr5 0", + margin: "$gr5 0 $gr6", [`> header`]: { display: "flex", @@ -53,25 +52,42 @@ const TopInfoWrapper = styled("section", { h1: { lineHeight: "1em", + fontWeight: "400", fontFamily: "$northwesternDisplayBold", - fontSize: "$8", - letterSpacing: "-0.015em", + fontSize: "$gr7", + letterSpacing: "-0.025em", margin: "0", - - "@sm": { - fontSize: "$gr7", - }, }, p: { - fontSize: "$gr5", + fontSize: "$gr4", color: "$black50", - fontFamily: "$northwesternSansLight", lineHeight: "1.47em", + }, + }, +}); - "@sm": { - fontSize: "$gr4", - }, +const TopInfoHeaderContent = styled("div", { + display: "flex", + justifyContent: "space-between", + padding: "0 0 $gr3", + gap: "$gr2", + + "@sm": { + flexDirection: "column", + gap: "0", + }, + + "> div:last-child": { + flexShrink: 0, + display: "flex", + alignItems: "flex-start", + justifyContent: "flex-end", + width: "38.2%", + + "@sm": { + justifyContent: "center", + width: "100%", }, }, }); @@ -86,4 +102,10 @@ const TopInfoCollection = styled("div", { }, }); -export { ActionButtons, TopInfoCollection, TopInfoContent, TopInfoWrapper }; +export { + ActionButtons, + TopInfoCollection, + TopInfoContent, + TopInfoHeaderContent, + TopInfoWrapper, +}; diff --git a/components/Work/TopInfo.tsx b/components/Work/TopInfo.tsx index e4962656..04a5ad24 100644 --- a/components/Work/TopInfo.tsx +++ b/components/Work/TopInfo.tsx @@ -2,6 +2,7 @@ import { ActionButtons, TopInfoCollection, TopInfoContent, + TopInfoHeaderContent, TopInfoWrapper, } from "@/components//Work/TopInfo.styled"; import { @@ -15,6 +16,7 @@ import { Button } from "@nulib/design-system"; import Card from "@/components/Shared/Card"; import { DefinitionListWrapper } from "@/components/Shared/DefinitionList.styled"; import Expand from "@/components/Shared/Expand/Expand"; +import IIIFShare from "../Shared/IIIF/Share"; import { Manifest } from "@iiif/presentation-3"; import type { Work } from "@nulib/dcapi-types"; import WorkActionsDialog from "@/components/Work/ActionsDialog/ActionsDialog"; @@ -71,10 +73,20 @@ const WorkTopInfo: React.FC<TopInfoProps> = ({ return ( <TopInfoWrapper> <header> - <Label label={manifest.label} as="h1" data-testid="title" /> - {manifest?.summary && ( - <Summary summary={manifest.summary} as="p" data-testid="summary" /> - )} + <TopInfoHeaderContent> + <div> + <Label label={manifest.label} as="h1" data-testid="title" /> + {manifest?.summary && ( + <Summary + summary={manifest.summary} + as="p" + data-testid="summary" + /> + )} + </div> + <IIIFShare uri={manifest.id} /> + </TopInfoHeaderContent> + <ActionButtons> <Button name="find" diff --git a/hooks/useCopyToClipboard.ts b/hooks/useCopyToClipboard.ts index 6660a88d..eddfd308 100644 --- a/hooks/useCopyToClipboard.ts +++ b/hooks/useCopyToClipboard.ts @@ -1,18 +1,22 @@ -import { useCallback, useEffect, useState } from "react"; +import { MouseEvent, useCallback, useEffect, useState } from "react"; -export type CopyStatus = "copied" | "failed" | undefined; +export type CopyStatus = "✔" | "✗" | undefined; export const useCopyToClipboard = ( text: string, - notifyTimeout = 2500, -): [CopyStatus, () => void] => { + notifyTimeout = 5000, +): [CopyStatus, (event: MouseEvent) => void] => { const [copyStatus, setCopyStatus] = useState<CopyStatus>(); - const copy = useCallback(() => { - navigator.clipboard.writeText(text).then( - () => setCopyStatus("copied"), - () => setCopyStatus("failed"), - ); - }, [text]); + const copy = useCallback( + (event: MouseEvent) => { + event?.preventDefault(); + navigator.clipboard.writeText(text).then( + () => setCopyStatus("✔"), + () => setCopyStatus("✗"), + ); + }, + [text], + ); useEffect(() => { if (!copyStatus) { diff --git a/lib/dc-api.test.ts b/lib/dc-api.test.ts new file mode 100644 index 00000000..fef1c12e --- /dev/null +++ b/lib/dc-api.test.ts @@ -0,0 +1,46 @@ +import { DC_API_SEARCH_URL } from "@/lib/constants/endpoints"; +import { URLSearchParams } from "url"; +import { iiifSearchUri } from "@/lib/dc-api"; + +describe("iiifSearchUri", () => { + it("returns the expected uri", () => { + const query = { q: "Joan Baez" }; + const uri = new URL(iiifSearchUri(query)); + const params = new URLSearchParams(uri.search); + + // check that the URI has the expected origin and path + expect(DC_API_SEARCH_URL).toContain(uri.origin); + expect(DC_API_SEARCH_URL).toContain(uri.pathname); + + // check that the query string has the expected params + expect(params.get("query")).toEqual("Joan Baez"); + expect(params.get("as")).toEqual("iiif"); + }); + + it("returns the expected uri with a custom size", () => { + const query = { q: "John Fahey" }; + const uri = new URL(iiifSearchUri(query, 100)); + const params = new URLSearchParams(uri.search); + + // check that the size param is set to 100 + expect(params.get("query")).toEqual("John Fahey"); + expect(params.get("as")).toEqual("iiif"); + expect(params.get("size")).toEqual("100"); + }); + + it("returns the expected uri with appended facets as params", () => { + const query = { + q: "Muddy Waters", + workType: "Image", + genre: "photographs", + }; + const uri = new URL(iiifSearchUri(query)); + const params = new URLSearchParams(uri.search); + + // check that the facets are appended as params + expect(params.get("query")).toEqual("Muddy Waters"); + expect(params.get("as")).toEqual("iiif"); + expect(params.get("workType")).toEqual("Image"); + expect(params.get("genre")).toEqual("photographs"); + }); +}); diff --git a/lib/dc-api.ts b/lib/dc-api.ts index afe93d79..ac45b992 100644 --- a/lib/dc-api.ts +++ b/lib/dc-api.ts @@ -1,6 +1,8 @@ +import { DCAPI_ENDPOINT, DC_API_SEARCH_URL } from "./constants/endpoints"; import axios, { AxiosError, RawAxiosRequestHeaders } from "axios"; import type { ApiSearchRequestBody } from "@/types/api/request"; +import { NextRouter } from "next/router"; interface ApiGetRequestParams { url: string; @@ -72,6 +74,31 @@ async function getIIIFResource<R>( } } +function iiifSearchUri(query: NextRouter["query"], size?: number): string { + const url = new URL(DC_API_SEARCH_URL); + Object.keys(query).forEach((key) => { + url.searchParams.append(key, query[key] as string); + url.searchParams.delete("q"); + query.q ? url.searchParams.append("query", query.q as string) : null; + }); + + if (size) url.searchParams.append("size", size.toString()); + url.searchParams.append("as", "iiif"); + + return url.toString(); +} + +function iiifCollectionUri(id?: string, size?: number): string | undefined { + if (!id) return; + + const url = new URL(`${DCAPI_ENDPOINT}/collections/${id}`); + + if (size) url.searchParams.append("size", size.toString()); + url.searchParams.append("as", "iiif"); + + return url.toString(); +} + function handleError(err: unknown) { const error = err as AxiosError; if (error.response) { @@ -96,4 +123,6 @@ export { apiPostRequest, getIIIFResource, handleError, + iiifCollectionUri, + iiifSearchUri, }; diff --git a/mocks/sample-collection1.ts b/mocks/sample-collection1.ts index 6f724e8f..8f690ba1 100644 --- a/mocks/sample-collection1.ts +++ b/mocks/sample-collection1.ts @@ -10,6 +10,8 @@ export const sampleCollection1 = { featured: true, finding_aid_url: null, id: "04c17199-1b74-4f9f-853c-1c069f1c4f2e", + iiif_collection: + "https://api.dc.library.northwestern.edu/api/v2/works/04c17199-1b74-4f9f-853c-1c069f1c4f2e?as=iiif", indexed_at: "what goes here", keywords: [], modified_date: "2022-02-24T23:51:15.854797Z", diff --git a/package-lock.json b/package-lock.json index 2bcc6a76..bd55ff88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@next/bundle-analyzer": "^14.0.3", "@next/font": "^14.0.3", "@next/third-parties": "^14.2.3", - "@nulib/dcapi-types": "^2.5.0", + "@nulib/dcapi-types": "^2.6.0", "@nulib/design-system": "^1.6.2", "@nulib/use-markdown": "^0.2.1", "@radix-ui/colors": "^3.0.0", @@ -2198,9 +2198,9 @@ } }, "node_modules/@nulib/dcapi-types": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@nulib/dcapi-types/-/dcapi-types-2.5.0.tgz", - "integrity": "sha512-Ai1AJioCC4y4lpGdvzziC7zDcrzUbIislM0InmRx9nuj4I/0Old9eJJyBrfiDv9uGdWwFGqBaleAUtZk6pQcTQ==" + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@nulib/dcapi-types/-/dcapi-types-2.6.0.tgz", + "integrity": "sha512-TaOhHfbTXnlHzurpgrCVCQNU13oCmzR8C51yKZ3iKBtf6mzVzXsnCDhIUbQ69iuXWeeuHGcEO6oK3XnVeUqb2A==" }, "node_modules/@nulib/design-system": { "version": "1.6.2", diff --git a/package.json b/package.json index d8d7df2b..9554fe01 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@next/bundle-analyzer": "^14.0.3", "@next/font": "^14.0.3", "@next/third-parties": "^14.2.3", - "@nulib/dcapi-types": "^2.5.0", + "@nulib/dcapi-types": "^2.6.0", "@nulib/design-system": "^1.6.2", "@nulib/use-markdown": "^0.2.1", "@radix-ui/colors": "^3.0.0", diff --git a/pages/collections/[id].tsx b/pages/collections/[id].tsx index 026981c7..8b0a0e73 100644 --- a/pages/collections/[id].tsx +++ b/pages/collections/[id].tsx @@ -1,4 +1,5 @@ import { + CollectionHeader, Description, HeroWrapper, Interstitial, @@ -32,11 +33,14 @@ import { Collection as CollectionType } from "@nulib/dcapi-types"; import Container from "@/components/Shared/Container"; import Facts from "@/components/Shared/Facts"; import Head from "next/head"; +import Heading from "@/components/Heading/Heading"; import Hero from "@/components/Hero/Hero"; +import IIIFShare from "@/components/Shared/IIIF/Share"; import Layout from "components/layout"; import ReadMore from "@/components/Shared/ReadMore"; import { buildDataLayer } from "@/lib/ga/data-layer"; import { getHeroCollection } from "@/lib/iiif/collection-helpers"; +import { iiifCollectionUri } from "@/lib/dc-api"; import { loadCollectionStructuredData } from "@/lib/json-ld"; import useGenerativeAISearchToggle from "@/hooks/useGenerativeAISearchToggle"; import { useRouter } from "next/router"; @@ -57,6 +61,8 @@ const Collection: NextPage = () => { const description = collection?.description; + const iiifResource = iiifCollectionUri(collection?.id); + /** Get the Collection */ useEffect(() => { async function getData() { @@ -182,9 +188,20 @@ const Collection: NextPage = () => { <TabsTrigger value="metadata">All Subjects</TabsTrigger> </TabsList> <TabsContent value="explore"> + <CollectionHeader> + <Heading + as="h2" + css={{ + margin: "0 0 $gr2 !important", + }} + > + {collection?.title} + </Heading>{" "} + <IIIFShare uri={String(collection.iiif_collection)} /> + </CollectionHeader> {description && ( <Description data-testid="description"> - <ReadMore text={description} words={55} /> + {<ReadMore text={description} words={55} />} </Description> )} {topMetadata.length > 0 && (