+ {artwork.image_url ? ( @@ -54,12 +64,34 @@ export default function ArtworkCard({ artwork }: ArtworkCardProps) { alt={artwork.image_url || `${artwork.title} by ${artist}`} /> ) : ( - Image unavailable + Image unavailable )} {artwork.title} {artist} + + {showGalleryAction && ( + { + if (isLoggedIn) setShowGalleryModal(true); + else navigate("/login"); + }} + className="absolute right-5 top-5 z-10 grid h-10 w-10 cursor-pointer place-items-center rounded-full border border-gray-300 bg-white/95 text-gray-700 shadow-sm transition hover:border-gray-500 hover:bg-white hover:text-black focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-700 dark:border-[#5b554e] dark:bg-[#292724]/95 dark:text-gray-200 dark:hover:border-gray-400 dark:hover:bg-[#35322e] dark:hover:text-white" + > + + + )} + + {showGalleryAction && showGalleryModal && ( + setShowGalleryModal(false)} + /> + )} ); } diff --git a/frontend/src/components/ArtworkGrid.tsx b/frontend/src/components/ArtworkGrid.tsx index 42eceaa..fd52685 100644 --- a/frontend/src/components/ArtworkGrid.tsx +++ b/frontend/src/components/ArtworkGrid.tsx @@ -6,11 +6,13 @@ import "./ArtworkGrid.css"; type ArtworkGridProps = { artworks: Artwork[]; renderAction?: (artwork: Artwork) => ReactNode; + showGalleryActions?: boolean; }; export default function ArtworkGrid({ artworks, renderAction, + showGalleryActions = true, }: ArtworkGridProps) { if (artworks.length === 0) { return No artworks to display.; @@ -20,7 +22,7 @@ export default function ArtworkGrid({ {artworks.map((artwork) => ( - + {renderAction?.(artwork)} ))} diff --git a/frontend/src/components/CatalogueControls.tsx b/frontend/src/components/CatalogueControls.tsx index 7b9040b..a037dcf 100644 --- a/frontend/src/components/CatalogueControls.tsx +++ b/frontend/src/components/CatalogueControls.tsx @@ -1,7 +1,11 @@ -import { useEffect, useState, type SubmitEvent } from "react"; -import { Search, SlidersHorizontal } from "lucide-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 Select from "react-select"; +import { useThemeStore } from "../store/themeStore"; +import { useAuthStore } from "../store/authStore"; +import ThemeToggleButton from "./ThemeToggleButton"; +import Select, { type Theme } from "react-select"; import "./CatalogueControls.css"; import type { ArtworkSort } from "../types/ArtworkSort"; import type { ArtworkPeriod } from "../types/ArtworkPeriod"; @@ -31,6 +35,7 @@ type CatalogueControlsProps = { showFilters: boolean; onToggleFilters: () => void; paginationVisible: boolean; + galleryVisible: boolean; }; type FilterSection = "sort" | "artists" | "period" | "country" | "culture" | "artPeriod"; @@ -67,6 +72,31 @@ function selectedFacetOptions(values: string[], options: ArtworkFacetOption[]) { }); } +function selectTheme(theme: Theme, isDarkMode: boolean): Theme { + return { + ...theme, + borderRadius: 2, + colors: { + ...theme.colors, + primary: isDarkMode ? "#a99b8d" : "#80766f", + primary25: isDarkMode ? "#3a3733" : "#f4f1ee", + primary50: isDarkMode ? "#4a4540" : "#e8e3df", + primary75: isDarkMode ? "#5b544d" : "#d8d0ca", + neutral0: isDarkMode ? "#242321" : "#ffffff", + neutral5: isDarkMode ? "#2a2927" : "#faf9f8", + neutral10: isDarkMode ? "#35332f" : "#f2f2f2", + neutral20: isDarkMode ? "#494540" : "#cccccc", + neutral30: isDarkMode ? "#625c55" : "#b3b3b3", + neutral40: isDarkMode ? "#8e857c" : "#999999", + neutral50: isDarkMode ? "#aaa198" : "#808080", + neutral60: isDarkMode ? "#c0b8af" : "#666666", + neutral70: isDarkMode ? "#d5cec5" : "#4d4d4d", + neutral80: isDarkMode ? "#eee9e2" : "#333333", + neutral90: isDarkMode ? "#f7f4ef" : "#1a1a1a", + }, + }; +} + export default function CatalogueControls({ search, onSearch, @@ -92,7 +122,10 @@ export default function CatalogueControls({ showFilters, onToggleFilters, paginationVisible, + galleryVisible, }: CatalogueControlsProps) { + const isDarkMode = useThemeStore((state) => state.isDarkMode); + const isLoggedIn = useAuthStore((state) => Boolean(state.token)); const [searchInput, setSearchInput] = useState(search); const [mobileSearchOpen, setMobileSearchOpen] = useState(false); const [openSections, setOpenSections] = useState>(() => new Set()); @@ -101,12 +134,27 @@ export default function CatalogueControls({ 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, + ); + const filterPanelRef = useRef(null); + const desktopFilterButtonRef = useRef(null); + const mobileFilterButtonRef = useRef(null); + 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); + mediaQuery.addEventListener("change", updateLayout); + return () => mediaQuery.removeEventListener("change", updateLayout); + }, []); + const rangeStart = draftFrom; const rangeEnd = draftTo; const rangeSpan = maxYear - minYear; @@ -156,6 +204,54 @@ export default function CatalogueControls({ }; }, [artistSearchInput, showFilters]); + useEffect(() => { + const wasOpen = previousShowFilters.current; + previousShowFilters.current = showFilters; + if (isMobileLayout && showFilters && !wasOpen) { + filterPanelRef.current?.querySelector("button:not(:disabled)")?.focus(); + } else if (!showFilters && wasOpen) { + (isMobileLayout ? mobileFilterButtonRef.current : desktopFilterButtonRef.current)?.focus(); + } + }, [isMobileLayout, showFilters]); + + useEffect(() => { + function handleEscape(event: KeyboardEvent) { + if (event.key !== "Escape" || event.defaultPrevented) return; + + if (mobileSearchOpen) { + setMobileSearchOpen(false); + mobileSearchButtonRef.current?.focus(); + } else if (showFilters) { + onToggleFilters(); + } + } + + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [mobileSearchOpen, onToggleFilters, showFilters]); + + 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"])', + ); + if (!focusableElements?.length) { + event.preventDefault(); + return; + } + + const first = focusableElements[0]; + const last = focusableElements[focusableElements.length - 1]; + 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))) { + event.preventDefault(); + first.focus(); + } + } + function handleSubmit(event: SubmitEvent) { event.preventDefault(); onSearch(searchInput); @@ -178,19 +274,29 @@ export default function CatalogueControls({ // AI was primary used here to get the styling we wanted for the filter options. return ( - + {showFilters ? "Hide filters" : "Show filters"} - + + + + + + Search artworks by title or artist @@ -202,49 +308,51 @@ export default function CatalogueControls({ placeholder="Search by artist or artwork..." value={searchInput} onChange={(event) => setSearchInput(event.target.value)} - className="min-w-0 flex-1 rounded-none border border-r-0 border-[#d7d3d0] bg-white px-2.5 py-2 text-sm text-[#292624] placeholder:text-gray-500 focus:border-[#80766f] focus:outline-2 focus:outline-[#80766f] lg:px-3" + className="min-w-0 flex-1 rounded-none border border-r-0 border-[#d7d3d0] bg-white px-2.5 py-2 text-sm text-[#292624] placeholder:text-gray-500 focus:border-[#80766f] focus:outline-2 focus:outline-[#80766f] dark:border-[#504b46] dark:bg-[#242321] dark:text-[#f3f0ea] dark:placeholder:text-gray-400 lg:px-3" /> - + {totalCount} {totalCount === 1 ? "result" : "results"} Filter setMobileSearchOpen((current) => !current)} aria-expanded={mobileSearchOpen} aria-label={mobileSearchOpen ? "Close search" : "Open search"} - className="grid min-h-12 min-w-12 cursor-pointer place-items-center border-0 bg-white text-[#292624] shadow-sm" + className="grid min-h-12 min-w-12 cursor-pointer place-items-center border-0 bg-white text-[#292624] shadow-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#80766f] dark:bg-[#292724] dark:text-[#f3f0ea]" > - {mobileSearchOpen && !paginationVisible && ( + {mobileSearchOpen && galleryVisible && !paginationVisible && ( Search artworks by title or artist @@ -256,12 +364,12 @@ export default function CatalogueControls({ placeholder="Search by artist or artwork..." value={searchInput} onChange={(event) => setSearchInput(event.target.value)} - className="min-w-0 flex-1 border border-r-0 border-[#d7d3d0] px-3 py-2 text-sm text-[#292624] focus:outline-2 focus:outline-[#80766f]" + className="min-w-0 flex-1 border border-r-0 border-[#d7d3d0] px-3 py-2 text-sm text-[#292624] focus:outline-2 focus:outline-[#80766f] dark:border-[#504b46] dark:bg-[#242321] dark:text-[#f3f0ea] dark:placeholder:text-gray-400" /> @@ -275,17 +383,22 @@ export default function CatalogueControls({ tabIndex={-1} />
{artwork.image_url ? ( @@ -54,12 +64,34 @@ export default function ArtworkCard({ artwork }: ArtworkCardProps) { alt={artwork.image_url || `${artwork.title} by ${artist}`} /> ) : ( - Image unavailable + Image unavailable )} {artwork.title} {artist} + + {showGalleryAction && ( + { + if (isLoggedIn) setShowGalleryModal(true); + else navigate("/login"); + }} + className="absolute right-5 top-5 z-10 grid h-10 w-10 cursor-pointer place-items-center rounded-full border border-gray-300 bg-white/95 text-gray-700 shadow-sm transition hover:border-gray-500 hover:bg-white hover:text-black focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-700 dark:border-[#5b554e] dark:bg-[#292724]/95 dark:text-gray-200 dark:hover:border-gray-400 dark:hover:bg-[#35322e] dark:hover:text-white" + > + + + )} + + {showGalleryAction && showGalleryModal && ( + setShowGalleryModal(false)} + /> + )}