From 8368c593c910acefbc3fffe6ca52178537ba45f0 Mon Sep 17 00:00:00 2001 From: Mat Jordan Date: Tue, 17 Sep 2024 09:51:21 -0400 Subject: [PATCH 1/3] Revise playwright tests to match AI chat updates. --- .github/workflows/playwright.yml | 8 +++---- tests/404.spec.ts | 22 ++++++++++++++++++ tests/fixtures/work-page.ts | 8 ++++++- tests/search.spec.ts | 32 +++++++++++++++++++-------- tests/work.spec.ts | 38 ++++++++++++++++++++++++-------- 5 files changed, 85 insertions(+), 23 deletions(-) create mode 100644 tests/404.spec.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index f866ac5a..9524ef57 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -1,9 +1,9 @@ name: Playwright Tests on: -# push: -# branches: [] -# pull_request: -# branches: [main, deploy/staging] + push: + branches: [] + pull_request: + branches: [main, deploy/staging] workflow_dispatch: jobs: test: diff --git a/tests/404.spec.ts b/tests/404.spec.ts new file mode 100644 index 00000000..531095f6 --- /dev/null +++ b/tests/404.spec.ts @@ -0,0 +1,22 @@ +import { test as base, expect } from "@playwright/test"; + +const WORK_404_ID = "00000000-0000-0000-0000-000000000000"; + +const test = base.extend({}); + +test.describe("404 page component", async () => { + test.beforeEach(async ({ page }) => { + await page.goto(`/items/${WORK_404_ID}`); + }); + + test("renders the 404 page", async ({ page }) => { + await expect(page).toHaveURL(`/items/${WORK_404_ID}`); + + const figure = await page.locator("main .swiper figure"); + + await expect(figure.locator(".slide-label")).toHaveText("Page Not Found"); + await expect(figure.locator(".slide-summary")).toHaveText( + "Sorry the page you are looking for does not exist. It's possible the resource, work, or collection is no longer available. If you think you reached this page in error, please contact us.", + ); + }); +}); diff --git a/tests/fixtures/work-page.ts b/tests/fixtures/work-page.ts index 72308ca3..11e72e8b 100644 --- a/tests/fixtures/work-page.ts +++ b/tests/fixtures/work-page.ts @@ -1,7 +1,13 @@ import { type Page } from "@playwright/test"; +export const CANARY_WORK_ID = "cb8a19a7-3dec-47f3-80c0-12872ae61f8f"; + export class WorkPage { - readonly route: string = "/items"; + readonly route: string = `/items/${CANARY_WORK_ID}`; constructor(public readonly page: Page) {} + + async goto() { + await this.page.goto(this.route); + } } diff --git a/tests/search.spec.ts b/tests/search.spec.ts index 25e776ed..c2fe2127 100644 --- a/tests/search.spec.ts +++ b/tests/search.spec.ts @@ -19,6 +19,7 @@ const test = base.extend({ await openGraphPage.goto(); await use(openGraphPage); }, + // A fixture to help with the Search Page shared functionality searchPage: async ({ page }, use) => { const searchPage = new SearchPage(page); @@ -91,6 +92,7 @@ test.describe("Search page component", () => { await searchInput.fill(searches[1].term); await searchBtn.click(); + await page.waitForLoadState("domcontentloaded"); await expect(page).toHaveURL(`/search?q=${searches[1].term}`); await searchPage.verifyTopResultsCount(searches[1].expectedResultCount); await expect(searchInput).toHaveValue(searches[1].term); @@ -99,6 +101,7 @@ test.describe("Search page component", () => { searches[1].expectedResultCount, ); + await page.waitForLoadState("domcontentloaded"); await searchPage.verifyTopResultsCount(searches[1].expectedResultCount); await searchPage.verifyTotalsResultDisplay({ count: search2, @@ -110,6 +113,7 @@ test.describe("Search page component", () => { await expect(page).toHaveURL(/\/search/); // Verify original counts are back in place + await page.waitForLoadState("domcontentloaded"); await searchPage.verifyTopResultsCount(TOTAL_RESULTS); await searchPage.verifyGridItemCount(TOTAL_RESULTS); }); @@ -124,10 +128,10 @@ test.describe("Search page component", () => { const audioBtn = facetInlineComponent.getByRole("radio", { name: "Audio" }); const videoBtn = facetInlineComponent.getByRole("radio", { name: "Video" }); const clearAllBtn = page.getByRole("button", { - name: "Clear All", + name: "Reset", }); const publicWorksToggle = page.getByRole("switch", { - name: "Public works only", + name: "Public only", }); const facetUserComponent = page .getByTestId("facet-user-component") @@ -139,18 +143,21 @@ test.describe("Search page component", () => { const PUBLIC_WORKS_COUNT = 179; // Work Type facet button checks + await page.waitForLoadState("domcontentloaded"); await expect(allBtn).toHaveAttribute("aria-checked", "true"); await expect(imageBtn).toHaveAttribute("aria-checked", "false"); // Select Image facet - // await imageBtn.click(); - // await expect(imageBtn).toHaveAttribute("aria-checked", "true"); - // await expect(allBtn).toHaveAttribute("aria-checked", "false"); - // await searchPage.verifyTopResultsCount(IMAGE_COUNT); - // await searchPage.verifyGridItemCount(IMAGE_COUNT); + await imageBtn.click(); + await page.waitForLoadState("domcontentloaded"); + await expect(imageBtn).toHaveAttribute("aria-checked", "true"); + await expect(allBtn).toHaveAttribute("aria-checked", "false"); + await searchPage.verifyTopResultsCount(IMAGE_COUNT); + await searchPage.verifyGridItemCount(IMAGE_COUNT); // Select Audio facet await audioBtn.click(); + await page.waitForLoadState("domcontentloaded"); await expect(audioBtn).toHaveAttribute("aria-checked", "true"); await expect(imageBtn).toHaveAttribute("aria-checked", "false"); await searchPage.verifyTopResultsCount(AUDIO_COUNT); @@ -158,19 +165,23 @@ test.describe("Search page component", () => { // Select Video facet await videoBtn.click(); + await page.waitForLoadState("domcontentloaded"); await expect(videoBtn).toHaveAttribute("aria-checked", "true"); await expect(audioBtn).toHaveAttribute("aria-checked", "false"); await searchPage.verifyTopResultsCount(VIDEO_COUNT); await searchPage.verifyGridItemCount(VIDEO_COUNT); - // Toggle Public Works + // Select All (work types) facet await allBtn.click(); await page.waitForLoadState("domcontentloaded"); + await searchPage.verifyTopResultsCount(TOTAL_RESULTS); + + // Toggle Public Works await publicWorksToggle.click(); await page.waitForLoadState("domcontentloaded"); - await searchPage.verifyTopResultsCount(PUBLIC_WORKS_COUNT); await searchPage.verifyGridItemCount(PUBLIC_WORKS_COUNT); + await expect(facetUserComponent).toContainText("1"); // Test Filter Facet Toggle UI await clearAllBtn.click(); @@ -179,9 +190,12 @@ test.describe("Search page component", () => { await imageBtn.click(); await page.waitForLoadState("domcontentloaded"); + await publicWorksToggle.click(); + await page.waitForLoadState("domcontentloaded"); await expect(facetUserComponent).toContainText("1"); await publicWorksToggle.click(); + await page.waitForLoadState("domcontentloaded"); await expect(facetUserComponent).toContainText("2"); }); }); diff --git a/tests/work.spec.ts b/tests/work.spec.ts index 7a3ff096..85912bea 100644 --- a/tests/work.spec.ts +++ b/tests/work.spec.ts @@ -1,5 +1,6 @@ import { test as base, expect } from "@playwright/test"; +import { DC_URL } from "@/lib/constants/endpoints"; import { OpenGraphPage } from "@/tests/fixtures/open-graph"; import { WorkPage } from "@/tests/fixtures/work-page"; import { canaryWork } from "@/tests/fixtures/works/canary-work"; @@ -18,6 +19,7 @@ const test = base.extend({ await openGraphPage.goto(); await use(openGraphPage); }, + // A fixture to help with the Search Page shared functionality workPage: async ({ page }, use) => { const workPage = new WorkPage(page); @@ -30,6 +32,11 @@ test.describe("Work page component", async () => { await page.goto(`/items/${CANARY_WORK_ID}`); }); + /** + * this test is skipped due to timeouts in github CI actions + */ + test.skip(); + test("renders Open Graph data and meta title and description", async ({ openGraphPage, }) => { @@ -45,17 +52,19 @@ test.describe("Work page component", async () => { ); }); - test("renders the Work top level metadata", async ({ page }) => { - const metadataEl = page.getByTestId("metadata"); - - await page.getByRole("button", { name: "Dismiss" }).click(); + test("renders the Work", async ({ page, workPage }) => { + await expect(page).toHaveURL(`/items/${CANARY_WORK_ID}`); + }); + test("renders the Work top level metadata", async ({ page, workPage }) => { await expect(page.getByTestId("title")).toContainText( canaryWork.title || "", ); + await expect(page.getByTestId("summary")).toContainText( canaryWork.description.join(", ") || "", ); + const metadataEl = page.getByTestId("metadata"); await expect(metadataEl.getByText("Alternate Title")).toBeVisible(); await expect( @@ -199,14 +208,23 @@ test.describe("Work page component", async () => { .filter({ hasText: "TEST Canary Records" }), ).toHaveAttribute( "href", - "https://dc.library.northwestern.edu/search?q=collection.id%3A%22820fc328-a333-430b-a974-ac6218a1ffcd%22", + `${DC_URL}/search?collection=TEST+Canary+Records`, ); - // View all button - await expect(page.getByLabel("TEST Canary Records").nth(1)).toHaveAttribute( + // View all buttons + const viewAllButtons = page + .getByTestId("related-items") + .getByRole("link", { name: "View All" }); + const similarPattern = new RegExp(`${DC_URL}/search\\?similar=.*`); + const subjectPattern = new RegExp(`${DC_URL}/search\\?subject=.*`); + + await expect(viewAllButtons.first()).toHaveAttribute( "href", - "https://dc.library.northwestern.edu/search?q=collection.id%3A%22820fc328-a333-430b-a974-ac6218a1ffcd%22", + `${DC_URL}/search?collection=TEST+Canary+Records`, ); + await expect(viewAllButtons.nth(1)).toHaveAttribute("href", similarPattern); + await expect(viewAllButtons.nth(2)).toHaveAttribute("href", subjectPattern); + await expect(viewAllButtons.nth(3)).toHaveAttribute("href", subjectPattern); // Test the Collection carousel const collectionsSliderItems = relatedItems @@ -239,7 +257,7 @@ test.describe("Work page component", async () => { .filter({ hasText: "More Like This" }), ).toHaveAttribute( "href", - "https://dc.library.northwestern.edu/search?similar=cb8a19a7-3dec-47f3-80c0-12872ae61f8f", + `${DC_URL}/search?similar=cb8a19a7-3dec-47f3-80c0-12872ae61f8f`, ); // TODO: Something is wrong with the More Like This slider @@ -275,6 +293,8 @@ test.describe("Work page component", async () => { await expect(subject1SliderItems).toBeVisible(); await expect(subject2SliderItems).toBeVisible(); + + console.log("renders the Explore Further section Clover sliders (end)"); }); test("renders the Find this item and Cite this item modal windows", async ({ From d9c1bfa6c11cd1f623c330f07130c3130f5ef91f Mon Sep 17 00:00:00 2001 From: Mat Jordan Date: Fri, 8 Nov 2024 09:10:34 -0500 Subject: [PATCH 2/3] 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 = ({ }; return ( - - {manifestId && ( - - )} - {isWorkRestricted && userAuth?.user?.isReadingRoom && ( - - - -

You have access to Work because you are in the reading room

-
-
- )} -
+ + + {manifestId && ( + + )} + {isWorkRestricted && userAuth?.user?.isReadingRoom && ( + + + +

You have access to Work because you are in the reading room

+
+
+ )} +
+
); }; 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 = ({ collection }) => { clickable: true, }} slidesPerView={1} - speed={1000} + speed={300} > {collection.items.map((item) => ( 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 = ({ data, @@ -16,7 +21,9 @@ const SearchResults: React.FC = ({ 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 = ({ <> {!isAI && (totalResults ? ( - - {pluralize("result", totalResults)} - + + + {pluralize("result", totalResults)} + + + ) : ( Your search did not match any results. 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 = ({ > {children} - + {buttonText} 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(); + + // 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(); + + 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 ( + + + + + + + View as IIIF + + + + + + View in... + + + + + + + + + + + + + View Raw JSON + + + + + + + + + What is IIIF? + + + + + + + + + ); +}; + +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 `` element to IIIF Viewer", () => { + render(); + expect(screen.getByText(viewer.label)).toBeInTheDocument(); + expect(screen.getByText(viewer.label).closest("a")).toHaveAttribute( + "href", + `${viewer.href}?iiif-content=${encodeURIComponent(uri)}`, + ); + }); + + it("renders an `` element to IIIF Viewer with custom iiif-content param", () => { + render( + , + ); + 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 = ({ viewer, uri }) => { + const iiifContent = new URL(viewer.href); + iiifContent.searchParams.set( + viewer.iiifContentParam ? viewer.iiifContentParam : "iiif-content", + uri, + ); + + return ( + + {viewer.label} + + ); +}; + +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 = ({ children }) => { - return {children}; +const Icon: React.FC = ({ + children, + style, + hasSVGPadding = true, +}) => { + return ( + + {children} + + ); }; /* 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 = () => ( ); +const IconExternalLink: React.FC = () => ( + + + + +); + const IconFilter: React.FC = () => ( Filter @@ -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 = ({ manifest, work }) => { manifestId={manifest.id} /> - {(isWorkInstitution || isWorkPrivate) && ( - - Opening in external tools like Mirador is not supported for works - that require authentication. - - )} + + {(isWorkInstitution || isWorkPrivate) && ( + + Opening in external tools like Mirador is not supported for works that + require authentication. + + )} ); }; 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 = ({ return (
- - {manifest?.summary && ( - - )} + +
+
+ + + )} - diff --git a/hooks/useGenerativeAISearchToggle.ts b/hooks/useGenerativeAISearchToggle.ts index 0c4f4445..59e7b480 100644 --- a/hooks/useGenerativeAISearchToggle.ts +++ b/hooks/useGenerativeAISearchToggle.ts @@ -5,7 +5,12 @@ import { UserContext } from "@/context/user-context"; import useLocalStorage from "@/hooks/useLocalStorage"; import { useRouter } from "next/router"; -const defaultModalState = { +export const defaultAIState = { + enabled: "false", + expires: undefined, +}; + +export const defaultModalState = { isOpen: false, title: "Use Generative AI", }; @@ -13,12 +18,13 @@ const defaultModalState = { export default function useGenerativeAISearchToggle() { const router = useRouter(); - const [ai, setAI] = useLocalStorage("ai", "false"); + const [ai, setAI] = useLocalStorage("ai", defaultAIState); const { user } = React.useContext(UserContext); const [dialog, setDialog] = useState(defaultModalState); - const isAIPreference = ai === "true"; + const expires = Date.now() + 1000 * 60 * 60; + const isAIPreference = ai.enabled === "true"; const isChecked = isAIPreference && user?.isLoggedIn; const loginUrl = `${DCAPI_ENDPOINT}/auth/login?goto=${goToLocation()}`; @@ -36,7 +42,7 @@ export default function useGenerativeAISearchToggle() { if (router.isReady) { const { query } = router; if (query.ai === "true") { - setAI("true"); + setAI({ enabled: "true", expires }); } } }, [router.asPath]); @@ -61,7 +67,10 @@ export default function useGenerativeAISearchToggle() { if (!user?.isLoggedIn) { setDialog({ ...dialog, isOpen: checked }); } else { - setAI(checked ? "true" : "false"); + setAI({ + enabled: checked ? "true" : "false", + expires: checked ? expires : undefined, + }); } } diff --git a/hooks/useLocalStorage.ts b/hooks/useLocalStorage.ts index 5488a5b8..a4f79b10 100644 --- a/hooks/useLocalStorage.ts +++ b/hooks/useLocalStorage.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; -function useLocalStorage(key: string, initialValue: string) { +function useLocalStorage(key: string, initialValue: any) { // Get the initial value from localStorage or use the provided initialValue const [storedValue, setStoredValue] = useState(() => { if (typeof window !== "undefined") { diff --git a/pages/_app.tsx b/pages/_app.tsx index e2bba277..ef8f731d 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -16,6 +16,7 @@ import React from "react"; import { SearchProvider } from "@/context/search-context"; import { User } from "@/types/context/user"; import { UserProvider } from "@/context/user-context"; +import { defaultAIState } from "@/hooks/useGenerativeAISearchToggle"; import { defaultOpenGraphData } from "@/lib/open-graph"; import { getUser } from "@/lib/user-helpers"; import globalStyles from "@/styles/global"; @@ -37,8 +38,8 @@ function MyApp({ Component, pageProps }: MyAppProps) { const [mounted, setMounted] = React.useState(false); const [user, setUser] = React.useState(); - const [ai] = useLocalStorage("ai", "false"); - const isUsingAI = ai === "true"; + const [ai, setAI] = useLocalStorage("ai", defaultAIState); + const isUsingAI = ai?.enabled === "true"; React.useEffect(() => { async function getData() { @@ -47,6 +48,9 @@ function MyApp({ Component, pageProps }: MyAppProps) { setMounted(true); } getData(); + + // Check if AI is enabled and if it has expired + if (ai?.expires && ai.expires < Date.now()) setAI(defaultAIState); }, []); React.useEffect(() => {