265 lines
8.4 KiB
TypeScript
265 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo, useState } from "react";
|
|
import type { SanityImageSource } from "@sanity/image-url";
|
|
import { IoCloseOutline } from "react-icons/io5";
|
|
import {
|
|
HiOutlineChevronDoubleLeft,
|
|
HiOutlineChevronLeft,
|
|
HiOutlineChevronRight,
|
|
HiOutlineChevronDoubleRight,
|
|
} from "react-icons/hi2";
|
|
import { ReleaseCard } from "@/components/release/ReleaseCard";
|
|
import { SortDropdown, type SortOption } from "@/components/SortDropdown";
|
|
import { FilterDropdown, type FilterGroup } from "@/components/FilterDropdown";
|
|
import { IconButton } from "@/components/IconButton";
|
|
import { CARD_GRID_CLASSES_3 } from "@/lib/constants";
|
|
|
|
export type ArtistRelease = {
|
|
_id?: string;
|
|
name?: string;
|
|
albumArtist?: string;
|
|
catalogNo?: string;
|
|
releaseDate?: string;
|
|
slug?: string;
|
|
albumCover?: SanityImageSource;
|
|
genre?: string[];
|
|
instrumentation?: string[];
|
|
};
|
|
|
|
const PAGE_SIZE = 12;
|
|
|
|
type SortMode =
|
|
| "releaseDateDesc"
|
|
| "releaseDateAsc"
|
|
| "titleAsc"
|
|
| "titleDesc"
|
|
| "catalogNoAsc"
|
|
| "catalogNoDesc";
|
|
|
|
const SORT_OPTIONS: SortOption<SortMode>[] = [
|
|
{ value: "titleAsc", label: "Title", iconDirection: "asc" },
|
|
{ value: "titleDesc", label: "Title", iconDirection: "desc" },
|
|
{ value: "catalogNoAsc", label: "Cat. No.", iconDirection: "asc" },
|
|
{ value: "catalogNoDesc", label: "Cat. No.", iconDirection: "desc" },
|
|
{ value: "releaseDateAsc", label: "Release date", iconDirection: "asc" },
|
|
{ value: "releaseDateDesc", label: "Release date", iconDirection: "desc" },
|
|
];
|
|
|
|
const GENRE_OPTIONS = [
|
|
{ value: "earlyMusic", label: "Early Music" },
|
|
{ value: "baroque", label: "Baroque" },
|
|
{ value: "classical", label: "Classical" },
|
|
{ value: "romantic", label: "Romantic" },
|
|
{ value: "contemporary", label: "Contemporary" },
|
|
{ value: "worldMusic", label: "World Music" },
|
|
{ value: "jazz", label: "Jazz" },
|
|
{ value: "crossover", label: "Crossover" },
|
|
{ value: "electronic", label: "Electronic" },
|
|
{ value: "minimal", label: "Minimal" },
|
|
{ value: "popRock", label: "Pop / Rock" },
|
|
];
|
|
|
|
const INSTRUMENTATION_OPTIONS = [
|
|
{ value: "solo", label: "Solo" },
|
|
{ value: "chamber", label: "Chamber" },
|
|
{ value: "ensemble", label: "Ensemble" },
|
|
{ value: "orchestra", label: "Orchestral" },
|
|
{ value: "vocalChoral", label: "Vocal / Choral" },
|
|
];
|
|
|
|
const FILTER_GROUPS: FilterGroup[] = [
|
|
{ label: "Genre", param: "genre", options: GENRE_OPTIONS },
|
|
{ label: "Instrumentation", param: "instrumentation", options: INSTRUMENTATION_OPTIONS },
|
|
];
|
|
|
|
function compareStr(a?: string, b?: string): number {
|
|
return (a ?? "").localeCompare(b ?? "", undefined, { sensitivity: "base" });
|
|
}
|
|
|
|
function compareDate(a?: string, b?: string, asc = true): number {
|
|
const da = a ?? (asc ? "9999-12-31" : "0000-01-01");
|
|
const db = b ?? (asc ? "9999-12-31" : "0000-01-01");
|
|
return da < db ? -1 : da > db ? 1 : 0;
|
|
}
|
|
|
|
function sortReleases(list: ArtistRelease[], mode: SortMode): ArtistRelease[] {
|
|
return [...list].sort((a, b) => {
|
|
switch (mode) {
|
|
case "titleAsc":
|
|
return compareStr(a.name, b.name);
|
|
case "titleDesc":
|
|
return compareStr(b.name, a.name);
|
|
case "catalogNoAsc":
|
|
return compareStr(a.catalogNo, b.catalogNo) || compareStr(a.name, b.name);
|
|
case "catalogNoDesc":
|
|
return compareStr(b.catalogNo, a.catalogNo) || compareStr(a.name, b.name);
|
|
case "releaseDateAsc":
|
|
return compareDate(a.releaseDate, b.releaseDate, true) || compareStr(a.name, b.name);
|
|
case "releaseDateDesc":
|
|
return compareDate(b.releaseDate, a.releaseDate, false) || compareStr(a.name, b.name);
|
|
}
|
|
});
|
|
}
|
|
|
|
function matchesSearch(r: ArtistRelease, lower: string): boolean {
|
|
return (
|
|
(r.name?.toLowerCase().includes(lower) ?? false) ||
|
|
(r.albumArtist?.toLowerCase().includes(lower) ?? false) ||
|
|
(r.catalogNo?.toLowerCase().includes(lower) ?? false)
|
|
);
|
|
}
|
|
|
|
function matchesFilters(r: ArtistRelease, filters: Record<string, string[]>): boolean {
|
|
const genres = filters.genre ?? [];
|
|
const instrumentations = filters.instrumentation ?? [];
|
|
|
|
if (genres.length > 0 && !genres.some((g) => r.genre?.includes(g))) return false;
|
|
if (instrumentations.length > 0 && !instrumentations.some((i) => r.instrumentation?.includes(i)))
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
type Props = {
|
|
releases: ArtistRelease[];
|
|
};
|
|
|
|
export function ArtistReleasesTab({ releases }: Props) {
|
|
const [q, setQ] = useState("");
|
|
const [sort, setSort] = useState<SortMode>("releaseDateDesc");
|
|
const [filters, setFilters] = useState<Record<string, string[]>>({});
|
|
const [page, setPage] = useState(1);
|
|
|
|
const handleFilterChange = useCallback((param: string, selected: string[]) => {
|
|
setFilters((prev) => ({ ...prev, [param]: selected }));
|
|
setPage(1);
|
|
}, []);
|
|
|
|
const handleSortChange = useCallback((value: SortMode) => {
|
|
setSort(value);
|
|
setPage(1);
|
|
}, []);
|
|
|
|
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setQ(e.target.value);
|
|
setPage(1);
|
|
}, []);
|
|
|
|
const filtered = useMemo(() => {
|
|
const lower = q.trim().toLowerCase();
|
|
|
|
let result = releases;
|
|
if (lower) result = result.filter((r) => matchesSearch(r, lower));
|
|
result = result.filter((r) => matchesFilters(r, filters));
|
|
|
|
return sortReleases(result, sort);
|
|
}, [releases, q, sort, filters]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
|
const safePage = Math.min(page, totalPages);
|
|
const start = (safePage - 1) * PAGE_SIZE;
|
|
const paginated = filtered.slice(start, start + PAGE_SIZE);
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-4">
|
|
<p className="text-sm text-lightsec dark:text-darksec">
|
|
{filtered.length ? `${filtered.length} release(s)` : "No releases found."}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mb-12 flex items-center gap-3">
|
|
<input
|
|
value={q}
|
|
onChange={handleSearchChange}
|
|
placeholder="Search releases…"
|
|
className="no-ring w-full rounded-xl border border-lightline px-6 py-3 text-lighttext shadow-lg transition-all duration-200 ease-in-out hover:border-lightline-hover hover:text-trptkblue focus:border-lightline-focus dark:border-darkline dark:text-darktext dark:hover:border-darkline-hover dark:hover:text-white dark:focus:border-darkline-focus"
|
|
aria-label="Search releases"
|
|
/>
|
|
|
|
{q ? (
|
|
<IconButton
|
|
onClick={() => {
|
|
setQ("");
|
|
setPage(1);
|
|
}}
|
|
className="text-lg"
|
|
aria-label="Clear search"
|
|
>
|
|
<IoCloseOutline />
|
|
</IconButton>
|
|
) : null}
|
|
|
|
<FilterDropdown groups={FILTER_GROUPS} values={filters} onChange={handleFilterChange} />
|
|
|
|
<SortDropdown
|
|
options={SORT_OPTIONS}
|
|
value={sort}
|
|
onChange={handleSortChange}
|
|
ariaLabel="Sort releases"
|
|
/>
|
|
</div>
|
|
|
|
{paginated.length > 0 ? (
|
|
<section className={CARD_GRID_CLASSES_3}>
|
|
{paginated.map((r) => (
|
|
<ReleaseCard key={r._id ?? r.slug} release={r} />
|
|
))}
|
|
</section>
|
|
) : null}
|
|
|
|
{totalPages > 1 ? (
|
|
<Pagination page={safePage} totalPages={totalPages} onPageChange={setPage} />
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Pagination({
|
|
page,
|
|
totalPages,
|
|
onPageChange,
|
|
}: {
|
|
page: number;
|
|
totalPages: number;
|
|
onPageChange: (p: number) => void;
|
|
}) {
|
|
const hasPrev = page > 1;
|
|
const hasNext = page < totalPages;
|
|
|
|
return (
|
|
<nav aria-label="Pagination" className="mt-12 flex items-center justify-between">
|
|
<div className="text-sm text-lightsec dark:text-darksec">
|
|
Page {page} of {totalPages}
|
|
</div>
|
|
|
|
<div className="flex gap-3 text-lg">
|
|
<IconButton disabled={!hasPrev} onClick={() => onPageChange(1)} aria-label="First page">
|
|
<HiOutlineChevronDoubleLeft />
|
|
</IconButton>
|
|
<IconButton
|
|
disabled={!hasPrev}
|
|
onClick={() => onPageChange(page - 1)}
|
|
aria-label="Previous page"
|
|
>
|
|
<HiOutlineChevronLeft />
|
|
</IconButton>
|
|
<IconButton
|
|
disabled={!hasNext}
|
|
onClick={() => onPageChange(page + 1)}
|
|
aria-label="Next page"
|
|
>
|
|
<HiOutlineChevronRight />
|
|
</IconButton>
|
|
<IconButton
|
|
disabled={!hasNext}
|
|
onClick={() => onPageChange(totalPages)}
|
|
aria-label="Last page"
|
|
>
|
|
<HiOutlineChevronDoubleRight />
|
|
</IconButton>
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|