diff --git a/backend/src/graphql/resolvers/artworkResolvers.ts b/backend/src/graphql/resolvers/artworkResolvers.ts index e92e553..9e4a2b3 100644 --- a/backend/src/graphql/resolvers/artworkResolvers.ts +++ b/backend/src/graphql/resolvers/artworkResolvers.ts @@ -1,4 +1,4 @@ -import type { Db } from "mongodb"; +import type { Db, Filter } from "mongodb"; type ArtworkSort = | "TITLE_ASC" @@ -9,8 +9,222 @@ type ArtworkSort = | "YEAR_DESC" | null; +type ArtworkPeriod = + | "BEFORE_1500" + | "YEARS_1500_1799" + | "CENTURY_19" + | "CENTURY_20_PLUS" + | null; + +async function distinctArtworkValues(db: Db, field: "culture" | "period") { + const values = await db.collection("met_artworks").aggregate<{ name: string }>([ + { $match: { [field]: { $type: "string" } } }, + { $project: { name: { $trim: { input: `$${field}` } } } }, + { $match: { name: { $regex: "\\S" } } }, + { $group: { _id: "$name" } }, + { $sort: { _id: 1 } }, + { $project: { _id: 0, name: "$_id" } }, + ]).toArray(); + + return values.map(({ name }) => name); +} + +type ArtworkFacetFilters = { + search?: string | null; + artists?: string[] | null; + countries?: string[] | null; + cultures?: string[] | null; + artPeriods?: string[] | null; + period?: ArtworkPeriod; + dateFrom?: number | null; + dateTo?: number | null; +}; + +type ArtworkFacet = "artists" | "countries" | "cultures" | "artPeriods" | "time"; + +function makeArtworkFilter(filters: ArtworkFacetFilters, omit?: ArtworkFacet): Filter { + const searchTerm = filters.search?.trim() ?? ""; + const escapedSearchTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const parts: Filter[] = []; + + if (searchTerm) { + parts.push({ + $or: [ + { title: { $regex: escapedSearchTerm, $options: "i" } }, + { artist_display: { $regex: escapedSearchTerm, $options: "i" } }, + ], + }); + } + + if (omit !== "artists" && filters.artists?.length) { + parts.push({ + $expr: { + $in: [ + { $trim: { input: { $ifNull: ["$artist_display", ""] } } }, + filters.artists, + ], + }, + }); + } + + const addNormalizedFilter = ( + facet: Exclude, + field: "country" | "culture" | "period", + values?: string[] | null, + ) => { + if (omit === facet || !values?.length) return; + parts.push({ + $expr: { + $in: [ + { $trim: { input: { $ifNull: [`$${field}`, ""] } } }, + values, + ], + }, + }); + }; + + addNormalizedFilter("countries", "country", filters.countries); + addNormalizedFilter("cultures", "culture", filters.cultures); + addNormalizedFilter("artPeriods", "period", filters.artPeriods); + + if (omit !== "time" && filters.period) { + switch (filters.period) { + case "BEFORE_1500": + parts.push({ date_start: { $lte: 1499 } }); + break; + case "YEARS_1500_1799": + parts.push({ $expr: { $and: [ + { $isNumber: "$date_start" }, + { $lte: ["$date_start", 1799] }, + { $gte: [{ $cond: [{ $isNumber: "$date_end" }, "$date_end", "$date_start"] }, 1500] }, + ] } }); + break; + case "CENTURY_19": + parts.push({ $expr: { $and: [ + { $isNumber: "$date_start" }, + { $lte: ["$date_start", 1899] }, + { $gte: [{ $cond: [{ $isNumber: "$date_end" }, "$date_end", "$date_start"] }, 1800] }, + ] } }); + break; + case "CENTURY_20_PLUS": + parts.push({ $expr: { $and: [ + { $isNumber: "$date_start" }, + { $gte: [{ $cond: [{ $isNumber: "$date_end" }, "$date_end", "$date_start"] }, 1900] }, + ] } }); + break; + } + } + + if (omit !== "time" && (typeof filters.dateFrom === "number" || typeof filters.dateTo === "number")) { + parts.push({ $expr: { $and: [ + { $isNumber: "$date_start" }, + ...(typeof filters.dateTo === "number" ? [{ $lte: ["$date_start", filters.dateTo] }] : []), + ...(typeof filters.dateFrom === "number" + ? [{ $gte: [{ $cond: [{ $isNumber: "$date_end" }, "$date_end", "$date_start"] }, filters.dateFrom] }] + : []), + ] } }); + } + + return parts.length ? { $and: parts } : {}; +} + +async function countFacetOptions( + db: Db, + field: "artist_display" | "country" | "culture" | "period", + filter: Filter, + search = "", +) { + const escapedSearch = search.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const rows = await db.collection("met_artworks").aggregate<{ value: string; count: number }>([ + { $match: filter }, + { $match: { [field]: { $type: "string" } } }, + { $project: { value: { $trim: { input: `$${field}` } } } }, + { $match: { + value: { $regex: escapedSearch || "\\S", $options: "i" }, + } }, + { $group: { _id: "$value", count: { $sum: 1 } } }, + { $sort: { _id: 1 } }, + { $project: { _id: 0, value: "$_id", count: 1 } }, + ]).toArray(); + + return rows.filter(({ value }) => value.trim() !== ""); +} + export function createArtworkResolvers(db: Db) { return { + artworkFilterOptions: async ( + _: unknown, + { + search, + artistSearch, + artists, + countries, + cultures, + artPeriods, + period, + dateFrom, + dateTo, + }: ArtworkFacetFilters & { artistSearch?: string | null }, + ) => { + const filters = { search, artists, countries, cultures, artPeriods, period, dateFrom, dateTo }; + const [artistOptions, countryOptions, cultureOptions, artPeriodOptions] = await Promise.all([ + countFacetOptions(db, "artist_display", makeArtworkFilter(filters, "artists"), artistSearch ?? ""), + countFacetOptions(db, "country", makeArtworkFilter(filters, "countries")), + countFacetOptions(db, "culture", makeArtworkFilter(filters, "cultures")), + countFacetOptions(db, "period", makeArtworkFilter(filters, "artPeriods")), + ]); + + const timeFilter = makeArtworkFilter(filters, "time"); + const periodBuckets: [string, Filter][] = [ + ["BEFORE_1500", { date_start: { $lte: 1499 } }], + ["YEARS_1500_1799", { $expr: { $and: [ + { $isNumber: "$date_start" }, + { $lte: ["$date_start", 1799] }, + { $gte: [{ $cond: [{ $isNumber: "$date_end" }, "$date_end", "$date_start"] }, 1500] }, + ] } }], + ["CENTURY_19", { $expr: { $and: [ + { $isNumber: "$date_start" }, + { $lte: ["$date_start", 1899] }, + { $gte: [{ $cond: [{ $isNumber: "$date_end" }, "$date_end", "$date_start"] }, 1800] }, + ] } }], + ["CENTURY_20_PLUS", { $expr: { $and: [ + { $isNumber: "$date_start" }, + { $gte: [{ $cond: [{ $isNumber: "$date_end" }, "$date_end", "$date_start"] }, 1900] }, + ] } }], + ]; + const periodOptions = await Promise.all(periodBuckets.map(async ([value, periodFilter]) => ({ + value, + count: await db.collection("met_artworks").countDocuments( + { $and: [timeFilter, periodFilter] }, + { collation: { locale: "en", strength: 1 } }, + ), + }))); + + return { + artists: artistOptions, + countries: countryOptions, + cultures: cultureOptions, + artPeriods: artPeriodOptions, + periods: periodOptions.filter((option) => option.count > 0), + }; + }, + + cultures: () => distinctArtworkValues(db, "culture"), + artPeriods: () => distinctArtworkValues(db, "period"), + + countries: async () => { + const countries = await db.collection("met_artworks").aggregate<{ name: string }>([ + { $match: { country: { $type: "string" } } }, + { $project: { name: { $trim: { input: "$country" } } } }, + { $match: { name: { $regex: "\\S" } } }, + { $group: { _id: "$name" } }, + { $sort: { _id: 1 } }, + { $project: { _id: 0, name: "$_id" } }, + ]).toArray(); + + return countries.map(({ name }) => name); + }, + artists: async ( _: unknown, { @@ -88,13 +302,33 @@ export function createArtworkResolvers(db: Db) { offset, search, artist, + artists, + country, + countries, + culture, + cultures, + artPeriod, + artPeriods, sort, + period, + dateFrom, + dateTo, }: { limit: number; offset: number; search?: string | null; artist?: string | null; + artists?: string[] | null; + country?: string | null; + countries?: string[] | null; + culture?: string | null; + cultures?: string[] | null; + artPeriod?: string | null; + artPeriods?: string[] | null; sort?: ArtworkSort; + period?: ArtworkPeriod; + dateFrom?: number | null; + dateTo?: number | null; }, ) => { if (limit < 1 || limit > 100 || offset < 0) { @@ -128,9 +362,100 @@ 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 }] : []), + ...(artists?.length ? [{ + $expr: { + $in: [ + { $trim: { input: { $ifNull: ["$artist_display", ""] } } }, + artists, + ], + }, + }] : []), + ...(country || countries?.length + ? [{ + $expr: { + $in: [ + { $trim: { input: { $ifNull: ["$country", ""] } } }, + countries?.length ? countries : [country], + ], + }, + }] + : []), + ...(culture || cultures?.length + ? [{ + $expr: { + $in: [ + { $trim: { input: { $ifNull: ["$culture", ""] } } }, + cultures?.length ? cultures : [culture], + ], + }, + }] + : []), + ...(artPeriod || artPeriods?.length + ? [{ + $expr: { + $in: [ + { $trim: { input: { $ifNull: ["$period", ""] } } }, + artPeriods?.length ? artPeriods : [artPeriod], + ], + }, + }] + : []), + periodFilter, + dateRangeFilter, + ], }; let sortOrder: Record; diff --git a/backend/src/graphql/typeDefs.ts b/backend/src/graphql/typeDefs.ts index 9f2fe9e..a6be351 100644 --- a/backend/src/graphql/typeDefs.ts +++ b/backend/src/graphql/typeDefs.ts @@ -20,6 +20,19 @@ export const typeDefs = `#graphql totalCount: Int! } + type ArtworkFilterOption { + value: String! + count: Int! + } + + type ArtworkFilterOptions { + artists: [ArtworkFilterOption!]! + countries: [ArtworkFilterOption!]! + cultures: [ArtworkFilterOption!]! + artPeriods: [ArtworkFilterOption!]! + periods: [ArtworkFilterOption!]! + } + type User { id: ID! username: String! @@ -55,6 +68,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! @@ -65,7 +85,17 @@ export const typeDefs = `#graphql offset: Int! search: String artist: String + artists: [String!] + country: String + countries: [String!] + culture: String + cultures: [String!] + artPeriod: String + artPeriods: [String!] sort: ArtworkSort + period: ArtworkPeriod + dateFrom: Int + dateTo: Int ): ArtworkPage! artists( @@ -74,6 +104,22 @@ export const typeDefs = `#graphql search: String ): [String!]! + countries: [String!]! + cultures: [String!]! + artPeriods: [String!]! + + artworkFilterOptions( + search: String + artistSearch: String + artists: [String!] + countries: [String!] + cultures: [String!] + artPeriods: [String!] + period: ArtworkPeriod + dateFrom: Int + dateTo: Int + ): ArtworkFilterOptions! + myGalleries: [Gallery!]! gallery( diff --git a/frontend/src/components/ArtworkCard.tsx b/frontend/src/components/ArtworkCard.tsx index ed9aaa7..4194223 100644 --- a/frontend/src/components/ArtworkCard.tsx +++ b/frontend/src/components/ArtworkCard.tsx @@ -29,9 +29,24 @@ export default function ArtworkCard({ artwork }: ArtworkCardProps) { const artist = artwork.artist_display?.trim() || "Unknown artist"; const location = useLocation(); +// Made with AI help. + function rememberCataloguePosition() { + try { + sessionStorage.setItem("catalogue-return", JSON.stringify({ + url: `${location.pathname}${location.search}`, + scrollY: window.scrollY, + })); + } catch { + } + } + return (
- + {artwork.image_url ? ( void; - artist: string; - onArtistChange: (value: string) => void; + artists: string[]; + onArtistChange: (value: string[]) => void; + countries: string[]; + onCountryChange: (value: string[]) => void; + cultures: string[]; + onCultureChange: (value: string[]) => void; + artPeriods: string[]; + onArtPeriodChange: (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; @@ -17,6 +33,8 @@ type CatalogueControlsProps = { paginationVisible: boolean; }; +type FilterSection = "sort" | "artists" | "period" | "country" | "culture" | "artPeriod"; + const sortOptions: { value: ArtworkSort | ""; label: string; @@ -30,29 +48,101 @@ 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" }, +]; + +function toFacetOptions(options: ArtworkFacetOption[]) { + return options.map(({ value, count }) => ({ value, label: `${value} (${count})` })); +} + +function selectedFacetOptions(values: string[], options: ArtworkFacetOption[]) { + return values.map((value) => { + const option = options.find((candidate) => candidate.value === value); + return { value, label: option ? `${value} (${option.count})` : value }; + }); +} + export default function CatalogueControls({ + search, onSearch, - artist, + artists: selectedArtists, onArtistChange, + countries: selectedCountries, + onCountryChange, + cultures: selectedCultures, + onCultureChange, + artPeriods: selectedArtPeriods, + onArtPeriodChange, sort, onSortChange, + period, + onPeriodChange, + dateFrom, + dateTo, + onDateRangeChange, + minYear, + maxYear, totalCount, onReset, showFilters, onToggleFilters, paginationVisible, }: CatalogueControlsProps) { - const [searchInput, setSearchInput] = useState(""); + const [searchInput, setSearchInput] = useState(search); const [mobileSearchOpen, setMobileSearchOpen] = useState(false); - const [openSection, setOpenSection] = useState<"sort" | "artists" | null>(null); + 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); + + 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); + + function toggleSection(section: FilterSection) { + setOpenSections((current) => { + const next = new Set(current); + if (next.has(section)) next.delete(section); + else next.add(section); + return next; + }); + } - const { - artists, - loading: artistsLoading, - error: artistsError, - } = useArtists(artistSearch, showFilters); + 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) + .map((option) => { + const facet = facets.periods.find(({ value }) => value === option.value); + return facet ? { ...option, label: `${option.label} (${facet.count})` } : option; + }); useEffect(() => { if (!showFilters) return; @@ -76,6 +166,8 @@ export default function CatalogueControls({ setSearchInput(""); setArtistSearchInput(""); setArtistSearch(""); + setDraftFrom(minYear); + setDraftTo(maxYear); onReset(); } @@ -175,17 +267,18 @@ export default function CatalogueControls({ )} - {showFilters && ( - - {openSection === "sort" && ( + {openSections.has("sort") && (
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"} + styles={{ menu: (base) => ({ ...base, zIndex: 10 }) }} + theme={(theme) => ({ + ...theme, + borderRadius: 2, + colors: { ...theme.colors, primary: "#80766f", primary25: "#f4f1ee", primary50: "#e8e3df", primary75: "#d8d0ca" }, + })} + /> + {facetsError && ( +

+ Could not load filter options: {facetsError} +

+ )} +
+ )} + + +
+ + {openSections.has("artPeriod") && ( +
+ + onCountryChange(options.map((option) => option.value))} + isMulti + isClearable + isLoading={facetsLoading} + isDisabled={facetsLoading} + placeholder="Search or select a country..." + loadingMessage={() => "Updating available countries..."} + noOptionsMessage={() => facetsLoading ? "Updating available countries..." : "No countries with matching artworks"} + styles={{ menu: (base) => ({ ...base, zIndex: 10 }) }} + theme={(theme) => ({ + ...theme, + borderRadius: 2, + colors: { ...theme.colors, primary: "#80766f", primary25: "#f4f1ee", primary50: "#e8e3df", primary75: "#d8d0ca" }, + })} + /> + {facetsError && ( +

+ Could not load filter options: {facetsError} +

+ )} +
+ )} +
+ +
+ + {openSections.has("period") && ( +
+ + { + const value = event.currentTarget.valueAsNumber; + if (!Number.isFinite(value)) return; + 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]" + /> + + + +
+
+ + setActiveRangeThumb("from")} + onPointerDown={() => setActiveRangeThumb("from")} + onChange={(event) => { + const from = Math.min(Number(event.currentTarget.value), rangeEnd); + setDraftFrom(from); + }} + 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); + setDraftTo(to); + }} + className={`year-range__input ${activeRangeThumb === "to" ? "year-range__input--active" : ""}`} + /> +
+ {hasUnappliedDateRange && ( + + )} + {(dateFrom !== undefined || dateTo !== undefined || hasUnappliedDateRange) && ( + + )} + )}
@@ -223,12 +586,12 @@ export default function CatalogueControls({ - {openSection === "artists" && ( + {openSections.has("artists") && (