diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e7be7c5..e16dfc0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,9 +8,9 @@ "name": "frontend", "version": "0.0.0", "dependencies": { - "@apollo/client": "^4.3.3", + "@apollo/client": "^4.3.8", "@tailwindcss/vite": "^4.3.3", - "graphql": "^16.14.2", + "graphql": "^16.15.0", "lucide-react": "^1.57.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -53,9 +53,9 @@ "license": "MIT" }, "node_modules/@apollo/client": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.3.tgz", - "integrity": "sha512-wMfOUaFKLgNsu0OMCg50gfhEHTV9/GyO7DxQvut7iSBUoePy0kvll3oWeY8KVPRfnVhYEzcwPJ/Jwz4uSe3fQA==", + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.8.tgz", + "integrity": "sha512-g04oCNYjbbjpecZmcUOE0fZLt3vMFwoZJ+o4iu0N3bDDhMR95/ZVzWL9v3mM7WDSPxI0++P2348yAbSWceLoZA==", "license": "MIT", "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", @@ -3243,9 +3243,9 @@ "license": "ISC" }, "node_modules/graphql": { - "version": "16.14.2", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", - "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "version": "16.15.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.15.0.tgz", + "integrity": "sha512-32kz9FecohDiC/tgcViwnRXM7HFBVTPiW7pHO+DikKOI5KjhU/jNULOReXsrFqt2WAYbE9xFXsTffJw6iTO8qw==", "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" diff --git a/frontend/package.json b/frontend/package.json index 22d6202..e01915f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,9 +11,9 @@ "test": "vitest run" }, "dependencies": { - "@apollo/client": "^4.3.3", + "@apollo/client": "^4.3.8", "@tailwindcss/vite": "^4.3.3", - "graphql": "^16.14.2", + "graphql": "^16.15.0", "lucide-react": "^1.57.0", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/frontend/src/components/CatalogueControls.tsx b/frontend/src/components/CatalogueControls.tsx index 52774ba..e440300 100644 --- a/frontend/src/components/CatalogueControls.tsx +++ b/frontend/src/components/CatalogueControls.tsx @@ -1,7 +1,16 @@ -import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type SubmitEvent } from "react"; +import { + useEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type SubmitEvent, +} from "react"; import { CircleUserRound, Search, SlidersHorizontal } from "lucide-react"; import { Link } from "react-router-dom"; -import { useArtworkFacets, type ArtworkFacetOption } from "../hooks/useArtworkFacets"; +import { + useArtworkFacets, + type ArtworkFacetOption, +} from "../hooks/artwork/useArtworkFacets"; import { useThemeStore } from "../store/themeStore"; import { useAuthStore } from "../store/authStore"; import ThemeToggleButton from "./ThemeToggleButton"; @@ -38,20 +47,21 @@ type CatalogueControlsProps = { galleryVisible: boolean; }; -type FilterSection = "sort" | "artists" | "period" | "country" | "culture" | "artPeriod"; +type FilterSection = + "sort" | "artists" | "period" | "country" | "culture" | "artPeriod"; const sortOptions: { value: ArtworkSort | ""; label: string; }[] = [ - { value: "", label: "Default order" }, - { value: "TITLE_ASC", label: "Title: A-Z" }, - { value: "TITLE_DESC", label: "Title: Z-A" }, - { value: "ARTIST_ASC", label: "Artist: A-Z" }, - { value: "ARTIST_DESC", label: "Artist: Z-A" }, - { value: "YEAR_ASC", label: "Year: oldest first" }, - { value: "YEAR_DESC", label: "Year: newest first" }, - ]; + { value: "", label: "Default order" }, + { value: "TITLE_ASC", label: "Title: A-Z" }, + { value: "TITLE_DESC", label: "Title: Z-A" }, + { value: "ARTIST_ASC", label: "Artist: A-Z" }, + { value: "ARTIST_DESC", label: "Artist: Z-A" }, + { value: "YEAR_ASC", label: "Year: oldest first" }, + { value: "YEAR_DESC", label: "Year: newest first" }, +]; const periodOptions: { value: ArtworkPeriod | ""; label: string }[] = [ { value: "", label: "Any time" }, @@ -62,7 +72,10 @@ const periodOptions: { value: ArtworkPeriod | ""; label: string }[] = [ ]; function toFacetOptions(options: ArtworkFacetOption[]) { - return options.map(({ value, count }) => ({ value, label: `${value} (${count})` })); + return options.map(({ value, count }) => ({ + value, + label: `${value} (${count})`, + })); } function selectedFacetOptions(values: string[], options: ArtworkFacetOption[]) { @@ -128,14 +141,31 @@ export default function CatalogueControls({ const isLoggedIn = useAuthStore((state) => Boolean(state.token)); const [searchInput, setSearchInput] = useState(search); const [mobileSearchOpen, setMobileSearchOpen] = useState(false); - const [openSections, setOpenSections] = useState>(() => new Set()); + const [previousPaginationVisible, setPreviousPaginationVisible] = + useState(paginationVisible); + + if (paginationVisible !== previousPaginationVisible) { + setPreviousPaginationVisible(paginationVisible); + + if (paginationVisible) { + setMobileSearchOpen(false); + } + } + const [searchDismissedByPagination, setSearchDismissedByPagination] = + useState(false); + + const [openSections, setOpenSections] = useState>( + () => new Set(), + ); const [artistSearchInput, setArtistSearchInput] = useState(""); const [artistSearch, setArtistSearch] = useState(""); const [activeRangeThumb, setActiveRangeThumb] = useState<"from" | "to">("to"); const [draftFrom, setDraftFrom] = useState(dateFrom ?? minYear); const [draftTo, setDraftTo] = useState(dateTo ?? maxYear); const [isMobileLayout, setIsMobileLayout] = useState( - () => typeof window !== "undefined" && window.matchMedia("(max-width: 1023px)").matches, + () => + typeof window !== "undefined" && + window.matchMedia("(max-width: 1023px)").matches, ); const filterPanelRef = useRef(null); const desktopFilterButtonRef = useRef(null); @@ -143,11 +173,6 @@ export default function CatalogueControls({ const mobileSearchButtonRef = useRef(null); const previousShowFilters = useRef(showFilters); - useEffect(() => { - setDraftFrom(dateFrom ?? minYear); - setDraftTo(dateTo ?? maxYear); - }, [dateFrom, dateTo, minYear, maxYear, period]); - useEffect(() => { const mediaQuery = window.matchMedia("(max-width: 1023px)"); const updateLayout = () => setIsMobileLayout(mediaQuery.matches); @@ -172,24 +197,40 @@ export default function CatalogueControls({ }); } - const { options: facets, loading: facetsLoading, error: facetsError } = useArtworkFacets({ - search, - artistSearch, - artists: selectedArtists, - countries: selectedCountries, - cultures: selectedCultures, - artPeriods: selectedArtPeriods, - period, - dateFrom, - dateTo, - }, showFilters); - - const availablePeriodValues = new Set(facets.periods.map(({ value }) => value)); + const { + options: facets, + loading: facetsLoading, + error: facetsError, + } = useArtworkFacets( + { + search, + artistSearch, + artists: selectedArtists, + countries: selectedCountries, + cultures: selectedCultures, + artPeriods: selectedArtPeriods, + period, + dateFrom, + dateTo, + }, + showFilters, + ); + + const availablePeriodValues = new Set( + facets.periods.map(({ value }) => value), + ); const availablePeriodOptions = periodOptions - .filter((option) => !option.value || availablePeriodValues.has(option.value) || option.value === period) + .filter( + (option) => + !option.value || + availablePeriodValues.has(option.value) || + option.value === period, + ) .map((option) => { const facet = facets.periods.find(({ value }) => value === option.value); - return facet ? { ...option, label: `${option.label} (${facet.count})` } : option; + return facet + ? { ...option, label: `${option.label} (${facet.count})` } + : option; }); useEffect(() => { @@ -208,9 +249,14 @@ export default function CatalogueControls({ const wasOpen = previousShowFilters.current; previousShowFilters.current = showFilters; if (isMobileLayout && showFilters && !wasOpen) { - filterPanelRef.current?.querySelector("button:not(:disabled)")?.focus(); + filterPanelRef.current + ?.querySelector("button:not(:disabled)") + ?.focus(); } else if (!showFilters && wasOpen) { - (isMobileLayout ? mobileFilterButtonRef.current : desktopFilterButtonRef.current)?.focus(); + (isMobileLayout + ? mobileFilterButtonRef.current + : desktopFilterButtonRef.current + )?.focus(); } }, [isMobileLayout, showFilters]); @@ -233,9 +279,10 @@ export default function CatalogueControls({ function handleFilterPanelKeyDown(event: ReactKeyboardEvent) { if (event.key !== "Tab" || !showFilters || !isMobileLayout) return; - const focusableElements = filterPanelRef.current?.querySelectorAll( - 'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])', - ); + const focusableElements = + filterPanelRef.current?.querySelectorAll( + 'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])', + ); if (!focusableElements?.length) { event.preventDefault(); return; @@ -243,10 +290,18 @@ export default function CatalogueControls({ const first = focusableElements[0]; const last = focusableElements[focusableElements.length - 1]; - if (event.shiftKey && (document.activeElement === first || !filterPanelRef.current?.contains(document.activeElement))) { + if ( + event.shiftKey && + (document.activeElement === first || + !filterPanelRef.current?.contains(document.activeElement)) + ) { event.preventDefault(); last.focus(); - } else if (!event.shiftKey && (document.activeElement === last || !filterPanelRef.current?.contains(document.activeElement))) { + } else if ( + !event.shiftKey && + (document.activeElement === last || + !filterPanelRef.current?.contains(document.activeElement)) + ) { event.preventDefault(); first.focus(); } @@ -267,14 +322,19 @@ export default function CatalogueControls({ onReset(); } - if (paginationVisible && mobileSearchOpen) { - setMobileSearchOpen(false); - } + const isMobileSearchVisible = + mobileSearchOpen && + !searchDismissedByPagination && + galleryVisible && + !paginationVisible; // AI was primary used here to get the styling we wanted for the filter options. return (
-
+
{openSections.has("sort") && (
- + onCultureChange(options.map((option) => option.value))} + value={selectedFacetOptions( + selectedCultures, + facets.cultures, + )} + onChange={(options) => + onCultureChange(options.map((option) => option.value)) + } isMulti isClearable isLoading={facetsLoading} isDisabled={facetsLoading} placeholder="Search or select a culture..." loadingMessage={() => "Updating available cultures..."} - noOptionsMessage={() => facetsLoading ? "Updating available cultures..." : "No cultures with matching artworks"} + noOptionsMessage={() => + facetsLoading + ? "Updating available cultures..." + : "No cultures with matching artworks" + } styles={{ menu: (base) => ({ ...base, zIndex: 10 }) }} theme={(theme) => selectTheme(theme, isDarkMode)} /> @@ -467,25 +564,43 @@ export default function CatalogueControls({ aria-expanded={openSections.has("artPeriod")} onClick={() => toggleSection("artPeriod")} > - Art periods