diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index de4536a..3b608eb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,14 +8,16 @@ import ArtworkDetailPage from "./pages/ArtworkDetailPage"; import GalleryPage from "./pages/GalleryPage"; import Footer from "./components/Footer"; import ProtectedRoute from "./components/ProtectedRoute"; +import { useThemeStore } from "./store/themeStore"; function App() { + const isDarkMode = useThemeStore((state) => state.isDarkMode); // No footers for Login and CreateAccount Pages const { pathname } = useLocation(); const showFooter = pathname !== "/login" && pathname !== "/create-account"; return ( -
+
} /> diff --git a/frontend/src/assets/hero3.jpg b/frontend/src/assets/hero3.jpg new file mode 100644 index 0000000..e27de58 Binary files /dev/null and b/frontend/src/assets/hero3.jpg differ diff --git a/frontend/src/assets/hero4.jpg b/frontend/src/assets/hero4.jpg new file mode 100644 index 0000000..6eadf23 Binary files /dev/null and b/frontend/src/assets/hero4.jpg differ diff --git a/frontend/src/components/AddToGalleryModal.tsx b/frontend/src/components/AddToGalleryModal.tsx index f50b902..fbf1a14 100644 --- a/frontend/src/components/AddToGalleryModal.tsx +++ b/frontend/src/components/AddToGalleryModal.tsx @@ -133,7 +133,7 @@ export default function AddToGalleryModal({ role="dialog" aria-modal="true" aria-labelledby="add-to-gallery-title" - className="w-full max-w-md rounded-2xl bg-white p-6 shadow-lg" + className="w-full max-w-md rounded-2xl bg-white p-6 shadow-lg dark:bg-[#242321] dark:text-[#f3f0ea]" >
{loading ? ( -

Loading galleries...

+

Loading galleries...

) : galleries.length === 0 ? ( -

+

You have not created any galleries yet.

) : ( @@ -163,7 +163,7 @@ export default function AddToGalleryModal({ key={gallery.id} type="button" onClick={() => void handleAddToGallery(gallery.id)} - className="rounded-lg border border-gray-200 px-4 py-3 text-left transition hover:bg-gray-100" + className="rounded-lg border border-gray-200 px-4 py-3 text-left transition hover:bg-gray-100 dark:border-[#504b46] dark:hover:bg-[#35322e]" > {gallery.title} diff --git a/frontend/src/components/ArtworkCard.css b/frontend/src/components/ArtworkCard.css index 6da270d..437bbf5 100644 --- a/frontend/src/components/ArtworkCard.css +++ b/frontend/src/components/ArtworkCard.css @@ -1,30 +1,32 @@ -.artwork-card { - min-width: 0; - padding: 16px; - border: 1px solid #dedede; - border-radius: 4px; - background: #fff; - color: #171717; - text-align: left; -} +@layer components { + .artwork-card { + min-width: 0; + padding: 16px; + border: 1px solid #dedede; + border-radius: 4px; + background: #fff; + color: #171717; + text-align: left; + } -.artwork-card__image, -.artwork-card__placeholder { - display: block; - width: 100%; - aspect-ratio: 4 / 5; -} + .artwork-card__image, + .artwork-card__placeholder { + display: block; + width: 100%; + aspect-ratio: 4 / 5; + } -.artwork-card__image { - object-fit: contain; - background: #f5f4f1; -} + .artwork-card__image { + object-fit: contain; + background: #f5f4f1; + } -.artwork-card__placeholder { - display: grid; - place-items: center; - background: #f5f4f1; - color: #595959; + .artwork-card__placeholder { + display: grid; + place-items: center; + background: #f5f4f1; + color: #595959; + } } .artwork-card__title { diff --git a/frontend/src/components/ArtworkCard.tsx b/frontend/src/components/ArtworkCard.tsx index 4194223..18dd6a8 100644 --- a/frontend/src/components/ArtworkCard.tsx +++ b/frontend/src/components/ArtworkCard.tsx @@ -1,33 +1,42 @@ import { useState } from "react"; -import { Link, useLocation } from "react-router-dom"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import { Plus } from "lucide-react"; import type { Artwork } from "../types/Artwork"; +import { useAuthStore } from "../store/authStore"; +import AddToGalleryModal from "./AddToGalleryModal"; import "./ArtworkCard.css"; type ArtworkCardProps = { artwork: Artwork; + showGalleryAction?: boolean; }; function ArtworkImage({ src, alt }: { src: string; alt: string }) { const [hasError, setHasError] = useState(false); if (hasError) { - return
Image unavailable
; + return
Image unavailable
; } return ( - {alt} setHasError(true)} - /> +
+ {alt} setHasError(true)} + /> +
); } -export default function ArtworkCard({ artwork }: ArtworkCardProps) { +export default function ArtworkCard({ artwork, showGalleryAction = true }: ArtworkCardProps) { const artist = artwork.artist_display?.trim() || "Unknown artist"; const location = useLocation(); + const navigate = useNavigate(); + const isLoggedIn = useAuthStore((state) => Boolean(state.token)); + const [showGalleryModal, setShowGalleryModal] = useState(false); // Made with AI help. function rememberCataloguePosition() { @@ -41,10 +50,11 @@ export default function ArtworkCard({ artwork }: ArtworkCardProps) { } return ( -
+
{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 && ( + + )} + + {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 (
    -
    +
    -
    + +
    - {mobileSearchOpen && !paginationVisible && ( + {mobileSearchOpen && galleryVisible && !paginationVisible && (