From 0e529ccc6da053890340337bfb29d66cfa6c3daa Mon Sep 17 00:00:00 2001 From: Gurveer Singh Date: Thu, 5 Nov 2026 10:16:03 +0100 Subject: [PATCH 1/7] feat: Filter on Time There is now a new feature for selecting on time periode or use the sliders. --- .../src/graphql/resolvers/artworkResolvers.ts | 70 +++++++- backend/src/graphql/typeDefs.ts | 10 ++ frontend/src/components/CatalogueControls.css | 85 ++++++++++ frontend/src/components/CatalogueControls.tsx | 159 +++++++++++++++++- frontend/src/hooks/useArtworks.ts | 15 +- frontend/src/pages/HomePage.tsx | 35 ++++ frontend/src/types/ArtworkPeriod.ts | 5 + 7 files changed, 375 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/CatalogueControls.css create mode 100644 frontend/src/types/ArtworkPeriod.ts diff --git a/backend/src/graphql/resolvers/artworkResolvers.ts b/backend/src/graphql/resolvers/artworkResolvers.ts index e92e553..8215ff0 100644 --- a/backend/src/graphql/resolvers/artworkResolvers.ts +++ b/backend/src/graphql/resolvers/artworkResolvers.ts @@ -9,6 +9,13 @@ type ArtworkSort = | "YEAR_DESC" | null; +type ArtworkPeriod = + | "BEFORE_1500" + | "YEARS_1500_1799" + | "CENTURY_19" + | "CENTURY_20_PLUS" + | null; + export function createArtworkResolvers(db: Db) { return { artists: async ( @@ -89,12 +96,18 @@ export function createArtworkResolvers(db: Db) { search, artist, sort, + period, + dateFrom, + dateTo, }: { limit: number; offset: number; search?: string | null; artist?: string | null; sort?: ArtworkSort; + period?: ArtworkPeriod; + dateFrom?: number | null; + dateTo?: number | null; }, ) => { if (limit < 1 || limit > 100 || offset < 0) { @@ -128,9 +141,62 @@ export function createArtworkResolvers(db: Db) { } : {}; + const periodFilter = (() => { + switch (period) { + case "BEFORE_1500": + return { date_start: { $lte: 1499 } }; + case "YEARS_1500_1799": + return { $expr: { $and: [ + { $isNumber: "$date_start" }, + { $lte: ["$date_start", 1799] }, + { $gte: [{ $cond: [{ $isNumber: "$date_end" }, "$date_end", "$date_start"] }, 1500] }, + ] } }; + case "CENTURY_19": + return { $expr: { $and: [ + { $isNumber: "$date_start" }, + { $lte: ["$date_start", 1899] }, + { $gte: [{ $cond: [{ $isNumber: "$date_end" }, "$date_end", "$date_start"] }, 1800] }, + ] } }; + case "CENTURY_20_PLUS": + return { $expr: { $and: [ + { $isNumber: "$date_start" }, + { $gte: [{ $cond: [{ $isNumber: "$date_end" }, "$date_end", "$date_start"] }, 1900] }, + ] } }; + default: + return {}; + } + })(); + + const dateRangeFilter = (() => { + const conditions = [ + { $isNumber: "$date_start" }, + ...(typeof dateTo === "number" ? [{ $lte: ["$date_start", dateTo] }] : []), + ...(typeof dateFrom === "number" + ? [{ + $gte: [ + { + $cond: [ + { $isNumber: "$date_end" }, + "$date_end", + "$date_start", + ], + }, + dateFrom, + ], + }] + : []), + ]; + + return conditions.length > 1 ? { $expr: { $and: conditions } } : {}; + })(); + const filter = { - ...searchFilter, - ...(artist ? { artist_display: artist } : {}), + $and: [ + searchFilter, + ...(artist ? [{ artist_display: artist }] : []), + periodFilter, + dateRangeFilter, + ], }; let sortOrder: Record; diff --git a/backend/src/graphql/typeDefs.ts b/backend/src/graphql/typeDefs.ts index 9f2fe9e..873e7f2 100644 --- a/backend/src/graphql/typeDefs.ts +++ b/backend/src/graphql/typeDefs.ts @@ -55,6 +55,13 @@ export const typeDefs = `#graphql YEAR_DESC } + enum ArtworkPeriod { + BEFORE_1500 + YEARS_1500_1799 + CENTURY_19 + CENTURY_20_PLUS + } + type Query { artwork(id: Int!): Artwork hello: String! @@ -66,6 +73,9 @@ export const typeDefs = `#graphql search: String artist: String sort: ArtworkSort + period: ArtworkPeriod + dateFrom: Int + dateTo: Int ): ArtworkPage! artists( diff --git a/frontend/src/components/CatalogueControls.css b/frontend/src/components/CatalogueControls.css new file mode 100644 index 0000000..e346a60 --- /dev/null +++ b/frontend/src/components/CatalogueControls.css @@ -0,0 +1,85 @@ +.year-range { + position: relative; + height: 28px; + touch-action: pan-y; +} + +.year-range__track { + position: absolute; + top: 12px; + right: 9px; + left: 9px; + height: 4px; + border-radius: 999px; + background: #e5e1de; +} + +.year-range__selected { + position: absolute; + top: 0; + height: 100%; + border-radius: inherit; + background: #80766f; +} + +.year-range__input { + position: absolute; + z-index: 1; + top: 0; + left: 0; + width: 100%; + height: 28px; + margin: 0; + appearance: none; + background: transparent; + pointer-events: none; +} + +.year-range__input--active { + z-index: 2; +} + +.year-range__input::-webkit-slider-runnable-track { + height: 4px; + background: transparent; +} + +.year-range__input::-moz-range-track { + height: 4px; + background: transparent; +} + +.year-range__input::-webkit-slider-thumb { + width: 18px; + height: 18px; + margin-top: -7px; + appearance: none; + border: 2px solid #fff; + border-radius: 50%; + background: #80766f; + box-shadow: 0 0 0 1px #80766f; + cursor: grab; + pointer-events: auto; +} + +.year-range__input::-moz-range-thumb { + width: 14px; + height: 14px; + border: 2px solid #fff; + border-radius: 50%; + background: #80766f; + box-shadow: 0 0 0 1px #80766f; + cursor: grab; + pointer-events: auto; +} + +.year-range__input:active::-webkit-slider-thumb, +.year-range__input:active::-moz-range-thumb { + cursor: grabbing; +} + +.year-range__input:focus-visible::-webkit-slider-thumb, +.year-range__input:focus-visible::-moz-range-thumb { + outline: 3px solid #514943; + outline-offset: 3px; +} diff --git a/frontend/src/components/CatalogueControls.tsx b/frontend/src/components/CatalogueControls.tsx index 91b6c9b..284fe80 100644 --- a/frontend/src/components/CatalogueControls.tsx +++ b/frontend/src/components/CatalogueControls.tsx @@ -2,7 +2,9 @@ import { useEffect, useState, type SubmitEvent } from "react"; import { Search, SlidersHorizontal } from "lucide-react"; import { useArtists } from "../hooks/useArtists"; import Select from "react-select"; +import "./CatalogueControls.css"; import type { ArtworkSort } from "../types/ArtworkSort"; +import type { ArtworkPeriod } from "../types/ArtworkPeriod"; type CatalogueControlsProps = { onSearch: (value: string) => void; @@ -10,6 +12,13 @@ type CatalogueControlsProps = { onArtistChange: (value: string) => void; sort: ArtworkSort | undefined; onSortChange: (value: ArtworkSort | undefined) => void; + period: ArtworkPeriod | undefined; + onPeriodChange: (value: ArtworkPeriod | undefined) => void; + dateFrom: number | undefined; + dateTo: number | undefined; + onDateRangeChange: (from: number | undefined, to: number | undefined) => void; + minYear: number; + maxYear: number; totalCount: number; onReset: () => void; showFilters: boolean; @@ -30,12 +39,27 @@ const sortOptions: { { value: "YEAR_DESC", label: "Year: newest first" }, ]; +const periodOptions: { value: ArtworkPeriod | ""; label: string }[] = [ + { value: "", label: "Any time" }, + { value: "BEFORE_1500", label: "Before 1500" }, + { value: "YEARS_1500_1799", label: "1500–1799" }, + { value: "CENTURY_19", label: "1800s" }, + { value: "CENTURY_20_PLUS", label: "1900 and later" }, +]; + export default function CatalogueControls({ onSearch, artist, onArtistChange, sort, onSortChange, + period, + onPeriodChange, + dateFrom, + dateTo, + onDateRangeChange, + minYear, + maxYear, totalCount, onReset, showFilters, @@ -44,9 +68,16 @@ export default function CatalogueControls({ }: CatalogueControlsProps) { const [searchInput, setSearchInput] = useState(""); const [mobileSearchOpen, setMobileSearchOpen] = useState(false); - const [openSection, setOpenSection] = useState<"sort" | "artists" | null>(null); + const [openSection, setOpenSection] = useState<"sort" | "artists" | "period" | null>(null); const [artistSearchInput, setArtistSearchInput] = useState(""); const [artistSearch, setArtistSearch] = useState(""); + const [activeRangeThumb, setActiveRangeThumb] = useState<"from" | "to">("to"); + + const rangeStart = dateFrom ?? minYear; + const rangeEnd = dateTo ?? maxYear; + const rangeSpan = maxYear - minYear; + const rangeStartPercent = ((rangeStart - minYear) / rangeSpan) * 100; + const rangeEndPercent = ((rangeEnd - minYear) / rangeSpan) * 100; const { artists, @@ -215,6 +246,132 @@ export default function CatalogueControls({ colors: { ...theme.colors, primary: "#80766f", primary25: "#f4f1ee", primary50: "#e8e3df", primary75: "#d8d0ca" }, })} /> + + + )} + + +
+ + {openSection === "period" && ( +
+ + { + const value = event.currentTarget.valueAsNumber; + if (!Number.isFinite(value)) return; + const from = Math.max(minYear, Math.min(value, dateTo ?? maxYear)); + onDateRangeChange(from <= minYear ? undefined : from, dateTo); + }} + className="w-24 border border-[#d7d3d0] px-2 py-1.5 text-right text-sm text-[#292624] focus:outline-2 focus:outline-[#80766f]" + /> + + + +
+
+ + setActiveRangeThumb("from")} + onPointerDown={() => setActiveRangeThumb("from")} + onChange={(event) => { + const from = Math.min(Number(event.currentTarget.value), rangeEnd); + onDateRangeChange(from <= minYear ? undefined : from, dateTo); + }} + className={`year-range__input ${activeRangeThumb === "from" ? "year-range__input--active" : ""}`} + /> + setActiveRangeThumb("to")} + onPointerDown={() => setActiveRangeThumb("to")} + onChange={(event) => { + const to = Math.max(Number(event.currentTarget.value), rangeStart); + onDateRangeChange(dateFrom, to >= maxYear ? undefined : to); + }} + className={`year-range__input ${activeRangeThumb === "to" ? "year-range__input--active" : ""}`} + /> +
+ {(dateFrom !== undefined || dateTo !== undefined) && ( + + )} + )}
diff --git a/frontend/src/hooks/useArtworks.ts b/frontend/src/hooks/useArtworks.ts index df5abed..0459df2 100644 --- a/frontend/src/hooks/useArtworks.ts +++ b/frontend/src/hooks/useArtworks.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import type { Artwork } from "../types/Artwork"; import type { ArtworkSort } from "../types/ArtworkSort"; +import type { ArtworkPeriod } from "../types/ArtworkPeriod"; export function useArtworks( page: number, @@ -8,6 +9,9 @@ export function useArtworks( search = "", artist = "", sort?: ArtworkSort, + period?: ArtworkPeriod, + dateFrom?: number, + dateTo?: number, ) { const [artworks, setArtworks] = useState([]); const [totalCount, setTotalCount] = useState(0); @@ -36,6 +40,9 @@ export function useArtworks( $search: String $artist: String $sort: ArtworkSort + $period: ArtworkPeriod + $dateFrom: Int + $dateTo: Int ) { artworks( limit: $limit @@ -43,6 +50,9 @@ export function useArtworks( search: $search artist: $artist sort: $sort + period: $period + dateFrom: $dateFrom + dateTo: $dateTo ) { totalCount artworks { @@ -60,6 +70,9 @@ export function useArtworks( search, artist, sort, + period, + dateFrom, + dateTo, }, }), }); @@ -90,7 +103,7 @@ export function useArtworks( fetchArtworks(); return () => controller.abort(); - }, [page, pageSize, search, artist, sort]); + }, [page, pageSize, search, artist, sort, period, dateFrom, dateTo]); return { artworks, totalCount, loading, error }; } diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 11ce757..c9c5049 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -5,14 +5,20 @@ import Pagination from "../components/Pagination"; import { useArtworks } from "../hooks/useArtworks"; import CatalogueControls from "../components/CatalogueControls"; import type { ArtworkSort } from "../types/ArtworkSort"; +import type { ArtworkPeriod } from "../types/ArtworkPeriod"; const PAGE_SIZE = 12; +const MIN_ARTWORK_YEAR = -8000; +const MAX_ARTWORK_YEAR = new Date().getFullYear(); export default function HomePage() { const [page, setPage] = useState(1); const [search, setSearch] = useState(""); const [artist, setArtist] = useState(""); const [sort, setSort] = useState(undefined); + const [period, setPeriod] = useState(undefined); + const [dateFrom, setDateFrom] = useState(undefined); + const [dateTo, setDateTo] = useState(undefined); const [showLoading, setShowLoading] = useState(false); const [showFilters, setShowFilters] = useState(false); const [paginationVisible, setPaginationVisible] = useState(false); @@ -24,6 +30,9 @@ export default function HomePage() { search, artist, sort, + period, + dateFrom, + dateTo, ); useEffect(() => { if (!loading) return; @@ -72,6 +81,22 @@ export default function HomePage() { setPage(1); } + function handlePeriodChange(value: ArtworkPeriod | undefined) { + setShowLoading(false); + setPeriod(value); + setDateFrom(undefined); + setDateTo(undefined); + setPage(1); + } + + function handleDateRangeChange(from: number | undefined, to: number | undefined) { + setShowLoading(false); + setDateFrom(from); + setDateTo(to); + setPeriod(undefined); + setPage(1); + } + function handlePageChange(value: number) { setShowLoading(false); setPage(value); @@ -82,6 +107,9 @@ export default function HomePage() { setSearch(""); setArtist(""); setSort(undefined); + setPeriod(undefined); + setDateFrom(undefined); + setDateTo(undefined); setPage(1); } @@ -99,6 +127,13 @@ export default function HomePage() { onArtistChange={handleArtistChange} sort={sort} onSortChange={handleSortChange} + period={period} + onPeriodChange={handlePeriodChange} + dateFrom={dateFrom} + dateTo={dateTo} + onDateRangeChange={handleDateRangeChange} + minYear={MIN_ARTWORK_YEAR} + maxYear={MAX_ARTWORK_YEAR} totalCount={totalCount} onReset={handleReset} showFilters={showFilters} diff --git a/frontend/src/types/ArtworkPeriod.ts b/frontend/src/types/ArtworkPeriod.ts new file mode 100644 index 0000000..fc68cd8 --- /dev/null +++ b/frontend/src/types/ArtworkPeriod.ts @@ -0,0 +1,5 @@ +export type ArtworkPeriod = + | "BEFORE_1500" + | "YEARS_1500_1799" + | "CENTURY_19" + | "CENTURY_20_PLUS"; From e6f7c630111cc945204959a705374f91cbab9672 Mon Sep 17 00:00:00 2001 From: Gurveer Singh Date: Thu, 5 Nov 2026 10:20:02 +0100 Subject: [PATCH 2/7] fix: Must apply the slider changes This is done so that repeated and unnecessary requests are not sent when adjusting the slider. --- frontend/src/components/CatalogueControls.tsx | 56 ++++++++++++++----- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/CatalogueControls.tsx b/frontend/src/components/CatalogueControls.tsx index 284fe80..1a6ca47 100644 --- a/frontend/src/components/CatalogueControls.tsx +++ b/frontend/src/components/CatalogueControls.tsx @@ -72,12 +72,21 @@ export default function CatalogueControls({ 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 rangeStart = dateFrom ?? minYear; - const rangeEnd = dateTo ?? maxYear; + useEffect(() => { + setDraftFrom(dateFrom ?? minYear); + setDraftTo(dateTo ?? maxYear); + }, [dateFrom, dateTo, minYear, maxYear, period]); + + const rangeStart = draftFrom; + const rangeEnd = draftTo; const rangeSpan = maxYear - minYear; const rangeStartPercent = ((rangeStart - minYear) / rangeSpan) * 100; const rangeEndPercent = ((rangeEnd - minYear) / rangeSpan) * 100; + const hasUnappliedDateRange = + rangeStart !== (dateFrom ?? minYear) || rangeEnd !== (dateTo ?? maxYear); const { artists, @@ -107,6 +116,8 @@ export default function CatalogueControls({ setSearchInput(""); setArtistSearchInput(""); setArtistSearch(""); + setDraftFrom(minYear); + setDraftTo(maxYear); onReset(); } @@ -291,13 +302,13 @@ export default function CatalogueControls({ inputMode="numeric" aria-label="From year" min={minYear} - max={dateTo ?? maxYear} - value={dateFrom ?? minYear} + max={draftTo} + value={draftFrom} onChange={(event) => { const value = event.currentTarget.valueAsNumber; if (!Number.isFinite(value)) return; - const from = Math.max(minYear, Math.min(value, dateTo ?? maxYear)); - onDateRangeChange(from <= minYear ? undefined : from, dateTo); + const from = Math.max(minYear, Math.min(value, draftTo)); + setDraftFrom(from); }} className="w-24 border border-[#d7d3d0] px-2 py-1.5 text-right text-sm text-[#292624] focus:outline-2 focus:outline-[#80766f]" /> @@ -310,14 +321,14 @@ export default function CatalogueControls({ type="number" inputMode="numeric" aria-label="To year" - min={dateFrom ?? minYear} + min={draftFrom} max={maxYear} - value={dateTo ?? maxYear} + value={draftTo} onChange={(event) => { const value = event.currentTarget.valueAsNumber; if (!Number.isFinite(value)) return; - const to = Math.min(maxYear, Math.max(value, dateFrom ?? minYear)); - onDateRangeChange(dateFrom, to >= maxYear ? undefined : to); + const to = Math.min(maxYear, Math.max(value, draftFrom)); + setDraftTo(to); }} className="w-24 border border-[#d7d3d0] px-2 py-1.5 text-right text-sm text-[#292624] focus:outline-2 focus:outline-[#80766f]" /> @@ -342,7 +353,7 @@ export default function CatalogueControls({ onPointerDown={() => setActiveRangeThumb("from")} onChange={(event) => { const from = Math.min(Number(event.currentTarget.value), rangeEnd); - onDateRangeChange(from <= minYear ? undefined : from, dateTo); + setDraftFrom(from); }} className={`year-range__input ${activeRangeThumb === "from" ? "year-range__input--active" : ""}`} /> @@ -357,15 +368,32 @@ export default function CatalogueControls({ onPointerDown={() => setActiveRangeThumb("to")} onChange={(event) => { const to = Math.max(Number(event.currentTarget.value), rangeStart); - onDateRangeChange(dateFrom, to >= maxYear ? undefined : to); + setDraftTo(to); }} className={`year-range__input ${activeRangeThumb === "to" ? "year-range__input--active" : ""}`} /> - {(dateFrom !== undefined || dateTo !== undefined) && ( +

Artworks with date ranges are included when they overlap your selection.

+ {hasUnappliedDateRange && ( + + )} + {(dateFrom !== undefined || dateTo !== undefined || hasUnappliedDateRange) && ( + {openSection === "country" && ( +
+ + setOpenSection(openSection === "country" ? null : "country")} + aria-expanded={openSections.has("country")} + onClick={() => toggleSection("country")} > - Around the world