Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
725 changes: 627 additions & 98 deletions frontend/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.4",
"react-select": "^5.10.2",
"rxjs": "^7.8.2",
"tailwindcss": "^4.3.3"
},
Expand Down
203 changes: 203 additions & 0 deletions frontend/src/components/CatalogueControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { useEffect, useState, type SubmitEvent } from "react";
import { Search, SlidersHorizontal } from "lucide-react";
import { useArtists } from "../hooks/useArtists";
import Select from "react-select";
import type { ArtworkSort } from "../types/ArtworkSort";

type CatalogueControlsProps = {
onSearch: (value: string) => void;
artist: string;
onArtistChange: (value: string) => void;
sort: ArtworkSort | undefined;
onSortChange: (value: ArtworkSort | undefined) => void;
totalCount: number;
onReset: () => void;
};

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" },
];

export default function CatalogueControls({
onSearch,
artist,
onArtistChange,
sort,
onSortChange,
totalCount,
onReset,
}: CatalogueControlsProps) {
const [searchInput, setSearchInput] = useState("");
const [showFilters, setShowFilters] = useState(false);
const [artistSearchInput, setArtistSearchInput] = useState("");
const [artistSearch, setArtistSearch] = useState("");

const {
artists,
loading: artistsLoading,
error: artistsError,
} = useArtists(artistSearch, showFilters);

useEffect(() => {
if (!showFilters) return;

const timer = window.setTimeout(() => {
setArtistSearch(artistSearchInput.trim());
}, 300);

return () => {
window.clearTimeout(timer);
};
}, [artistSearchInput, showFilters]);

function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
event.preventDefault();
onSearch(searchInput);
}

function handleReset() {
setSearchInput("");
setArtistSearchInput("");
setArtistSearch("");
onReset();
}

return (
<div className="mx-8 border-t border-gray-300 pt-8">
<form role="search" onSubmit={handleSubmit} className="flex w-full">
<label htmlFor="artwork-search" className="sr-only">
Search artworks by title or artist
</label>

<input
id="artwork-search"
name="search"
type="search"
placeholder="Search by artist or artwork..."
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
className="min-w-0 flex-1 rounded-l border border-gray-300 bg-gray-100 px-3 py-3 text-sm text-gray-900 placeholder:text-gray-500 focus:border-gray-600 focus:outline-none"
/>

<button
type="submit"
className="shrink-0 cursor-pointer rounded-r bg-gray-600 px-4 py-3 text-sm text-white hover:bg-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-700"
aria-label="Search artworks"
>
<Search size={18} aria-hidden="true" />
</button>
</form>
<div className="mt-4 flex items-center justify-between gap-4">
<button
type="button"
onClick={() => setShowFilters(!showFilters)}
aria-expanded={showFilters}
aria-controls="catalogue-filters"
className="inline-flex items-center gap-2 cursor-pointer rounded border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-700"
>
<SlidersHorizontal size={18} strokeWidth={1.5} aria-hidden="true" />
{showFilters ? "Hide filters" : "Show filters"}
</button>

<p className="shrink-0 text-sm text-gray-600">
{totalCount} {totalCount === 1 ? "result" : "results"}
</p>
</div>
<div className="mt-4 flex items-center justify-between gap-4">
<div
id="catalogue-filters"
hidden={!showFilters}
className="w-full min-w-0"
>
<div className="mt-4 grid w-full max-w-4xl grid-cols-1 items-end gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
<div className="flex min-w-0 flex-col gap-2">
<label htmlFor="artist-filter" className="text-sm text-gray-700">
Artist
</label>

<Select
inputId="artist-filter"
instanceId="artist-filter"
className="w-full text-sm sm:max-w-sm"
theme={(theme) => ({
...theme,
borderRadius: 4,
colors: {
...theme.colors,
primary: "#4b5563",
primary25: "#f3f4f6",
primary50: "#e5e7eb",
primary75: "#d1d5db",
},
})}
options={artists.map((name) => ({
value: name,
label: name,
}))}
value={artist ? { value: artist, label: artist } : null}
onChange={(option) => onArtistChange(option?.value ?? "")}
inputValue={artistSearchInput}
onInputChange={(value) => setArtistSearchInput(value)}
isClearable
isLoading={artistsLoading}
filterOption={null}
placeholder="Search or select an artist..."
loadingMessage={() => "Loading artists..."}
noOptionsMessage={() =>
artistsLoading ? "Loading artists..." : "No matching artists"
}
/>

{artistsError && (
<p role="alert" className="text-sm text-red-700">
Could not load artists: {artistsError}
</p>
)}
</div>
<div className="flex min-w-0 flex-col gap-2">
<label htmlFor="artwork-sort" className="text-sm text-gray-700">
Sort by
</label>
<Select
inputId="artwork-sort"
instanceId="artwork-sort"
className="w-full text-sm sm:max-w-sm"
options={sortOptions}
value={sortOptions.find(
(option) => option.value === (sort ?? ""),
)}
onChange={(option) => onSortChange(option?.value || undefined)}
isSearchable={false}
theme={(theme) => ({
...theme,
borderRadius: 4,
colors: {
...theme.colors,
primary: "#4b5563",
primary25: "#f3f4f6",
primary50: "#e5e7eb",
primary75: "#d1d5db",
},
})}
/>
</div>
<button
type="button"
onClick={handleReset}
className="mt-2 w-fit cursor-pointer rounded border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-700"
>
Clear all
</button>
</div>
</div>
</div>
</div>
);
}
64 changes: 64 additions & 0 deletions frontend/src/hooks/useArtists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useEffect, useState } from "react";

export function useArtists(search = "", enabled = true) {
const [artists, setArtists] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!enabled) return;

const controller = new AbortController();

async function fetchArtists() {
setLoading(true);
setError("");

try {
const response = await fetch(`${import.meta.env.BASE_URL}graphql`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
signal: controller.signal,
body: JSON.stringify({
query: `
query ArtistOptions($search: String, $limit: Int!, $offset: Int!) {
artists(search: $search, limit: $limit, offset: $offset)
}
`,
variables: {
search: search.trim(),
limit: 50,
offset: 0,
},
}),
});

if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}

const result = await response.json();

if (result.errors?.length) {
throw new Error(result.errors[0].message);
}

setArtists(result.data.artists);
} catch (err) {
if (controller.signal.aborted) return;

setError(err instanceof Error ? err.message : "Unknown error");
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}

fetchArtists();

return () => controller.abort();
}, [search, enabled]);
return { artists, loading, error };
}
30 changes: 26 additions & 4 deletions frontend/src/hooks/useArtworks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { useEffect, useState } from "react";
import type { Artwork } from "../types/Artwork";
import type { ArtworkSort } from "../types/ArtworkSort";

export function useArtworks(page: number, pageSize = 12) {
export function useArtworks(
page: number,
pageSize = 12,
search = "",
artist = "",
sort?: ArtworkSort,
) {
const [artworks, setArtworks] = useState<Artwork[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [error, setError] = useState("");
Expand All @@ -23,8 +30,20 @@ export function useArtworks(page: number, pageSize = 12) {
signal: controller.signal,
body: JSON.stringify({
query: `
query GetArtworks($limit: Int!, $offset: Int!) {
artworks(limit: $limit, offset: $offset) {
query GetArtworks(
$limit: Int!
$offset: Int!
$search: String
$artist: String
$sort: ArtworkSort
) {
artworks(
limit: $limit
offset: $offset
search: $search
artist: $artist
sort: $sort
) {
totalCount
artworks {
id
Expand All @@ -38,6 +57,9 @@ export function useArtworks(page: number, pageSize = 12) {
variables: {
limit: pageSize,
offset: (page - 1) * pageSize,
search,
artist,
sort,
},
}),
});
Expand Down Expand Up @@ -68,7 +90,7 @@ export function useArtworks(page: number, pageSize = 12) {
fetchArtworks();

return () => controller.abort();
}, [page, pageSize]);
}, [page, pageSize, search, artist, sort]);

return { artworks, totalCount, loading, error };
}
Loading