379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { IoCloseOutline } from "react-icons/io5";
|
|
import {
|
|
HiOutlineChevronDoubleLeft,
|
|
HiOutlineChevronLeft,
|
|
HiOutlineChevronRight,
|
|
HiOutlineChevronDoubleRight,
|
|
} from "react-icons/hi2";
|
|
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";
|
|
import {
|
|
DownloadCard,
|
|
UpcomingCard,
|
|
type DownloadCardData,
|
|
type UpcomingCardData,
|
|
} from "./DownloadCard";
|
|
|
|
const PAGE_SIZE = 12;
|
|
|
|
// ── Sort ────────────────────────────────────────────────────────────
|
|
|
|
type SortMode =
|
|
| "titleAsc"
|
|
| "titleDesc"
|
|
| "catalogNoAsc"
|
|
| "catalogNoDesc"
|
|
| "releaseDateAsc"
|
|
| "releaseDateDesc";
|
|
|
|
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" },
|
|
];
|
|
|
|
// ── Filters ─────────────────────────────────────────────────────────
|
|
|
|
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 },
|
|
];
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
type Sortable = {
|
|
product_title: string;
|
|
catalogNo?: string | null;
|
|
releaseDate?: string | null;
|
|
};
|
|
|
|
function compareStr(a?: string | null, b?: string | null): number {
|
|
return (a ?? "").localeCompare(b ?? "", undefined, { sensitivity: "base" });
|
|
}
|
|
|
|
function compareDate(a?: string | null, b?: string | null, 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 sortItems<T extends Sortable>(list: T[], mode: SortMode): T[] {
|
|
return [...list].sort((a, b) => {
|
|
switch (mode) {
|
|
case "titleAsc":
|
|
return compareStr(a.product_title, b.product_title);
|
|
case "titleDesc":
|
|
return compareStr(b.product_title, a.product_title);
|
|
case "catalogNoAsc":
|
|
return compareStr(a.catalogNo, b.catalogNo) || compareStr(a.product_title, b.product_title);
|
|
case "catalogNoDesc":
|
|
return compareStr(b.catalogNo, a.catalogNo) || compareStr(a.product_title, b.product_title);
|
|
case "releaseDateAsc":
|
|
return (
|
|
compareDate(a.releaseDate, b.releaseDate, true) ||
|
|
compareStr(a.product_title, b.product_title)
|
|
);
|
|
case "releaseDateDesc":
|
|
return (
|
|
compareDate(b.releaseDate, a.releaseDate, false) ||
|
|
compareStr(a.product_title, b.product_title)
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
type Searchable = {
|
|
product_title: string;
|
|
albumArtist?: string | null;
|
|
catalogNo?: string | null;
|
|
};
|
|
|
|
function matchesSearch(item: Searchable, lower: string): boolean {
|
|
return (
|
|
item.product_title.toLowerCase().includes(lower) ||
|
|
(item.albumArtist?.toLowerCase().includes(lower) ?? false) ||
|
|
(item.catalogNo?.toLowerCase().includes(lower) ?? false)
|
|
);
|
|
}
|
|
|
|
type Filterable = {
|
|
genre?: string[];
|
|
instrumentation?: string[];
|
|
};
|
|
|
|
function matchesFilters(item: Filterable, filters: Record<string, string[]>): boolean {
|
|
const genres = filters.genre ?? [];
|
|
const instrumentations = filters.instrumentation ?? [];
|
|
|
|
if (genres.length > 0 && !genres.some((g) => item.genre?.includes(g))) return false;
|
|
if (
|
|
instrumentations.length > 0 &&
|
|
!instrumentations.some((i) => item.instrumentation?.includes(i))
|
|
)
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
// ── Component ───────────────────────────────────────────────────────
|
|
|
|
export function DownloadsTab() {
|
|
const [downloads, setDownloads] = useState<DownloadCardData[]>([]);
|
|
const [upcoming, setUpcoming] = useState<UpcomingCardData[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const [q, setQ] = useState("");
|
|
const [sort, setSort] = useState<SortMode>("releaseDateDesc");
|
|
const [filters, setFilters] = useState<Record<string, string[]>>({});
|
|
const [dlPage, setDlPage] = useState(1);
|
|
const [upPage, setUpPage] = useState(1);
|
|
|
|
useEffect(() => {
|
|
async function fetchDownloads() {
|
|
try {
|
|
const res = await fetch("/api/account/downloads");
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data.downloads?.length) setDownloads(data.downloads);
|
|
if (data.upcoming?.length) setUpcoming(data.upcoming);
|
|
}
|
|
} catch {
|
|
// Silently fail
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
fetchDownloads();
|
|
}, []);
|
|
|
|
const resetPages = useCallback(() => {
|
|
setDlPage(1);
|
|
setUpPage(1);
|
|
}, []);
|
|
|
|
const handleFilterChange = useCallback((param: string, selected: string[]) => {
|
|
setFilters((prev) => ({ ...prev, [param]: selected }));
|
|
setDlPage(1);
|
|
setUpPage(1);
|
|
}, []);
|
|
|
|
const handleSortChange = useCallback(
|
|
(value: SortMode) => {
|
|
setSort(value);
|
|
resetPages();
|
|
},
|
|
[resetPages],
|
|
);
|
|
|
|
const handleSearchChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setQ(e.target.value);
|
|
resetPages();
|
|
},
|
|
[resetPages],
|
|
);
|
|
|
|
const clearSearch = useCallback(() => {
|
|
setQ("");
|
|
resetPages();
|
|
}, [resetPages]);
|
|
|
|
const filteredDownloads = useMemo(() => {
|
|
const lower = q.trim().toLowerCase();
|
|
let result = downloads;
|
|
if (lower) result = result.filter((d) => matchesSearch(d, lower));
|
|
result = result.filter((d) => matchesFilters(d, filters));
|
|
return sortItems(result, sort);
|
|
}, [downloads, q, sort, filters]);
|
|
|
|
const filteredUpcoming = useMemo(() => {
|
|
const lower = q.trim().toLowerCase();
|
|
let result = upcoming;
|
|
if (lower) result = result.filter((u) => matchesSearch(u, lower));
|
|
result = result.filter((u) => matchesFilters(u, filters));
|
|
return sortItems(result, sort);
|
|
}, [upcoming, q, sort, filters]);
|
|
|
|
const dlTotalPages = Math.max(1, Math.ceil(filteredDownloads.length / PAGE_SIZE));
|
|
const dlSafePage = Math.min(dlPage, dlTotalPages);
|
|
const dlStart = (dlSafePage - 1) * PAGE_SIZE;
|
|
const paginatedDownloads = filteredDownloads.slice(dlStart, dlStart + PAGE_SIZE);
|
|
|
|
const upTotalPages = Math.max(1, Math.ceil(filteredUpcoming.length / PAGE_SIZE));
|
|
const upSafePage = Math.min(upPage, upTotalPages);
|
|
const upStart = (upSafePage - 1) * PAGE_SIZE;
|
|
const paginatedUpcoming = filteredUpcoming.slice(upStart, upStart + PAGE_SIZE);
|
|
|
|
if (loading) {
|
|
return <p className="py-12 text-center text-lightsec dark:text-darksec">Loading downloads…</p>;
|
|
}
|
|
|
|
if (downloads.length === 0 && upcoming.length === 0) {
|
|
return (
|
|
<p className="py-12 text-center text-lightsec dark:text-darksec">
|
|
No downloads available. Your digital purchases will appear here.
|
|
</p>
|
|
);
|
|
}
|
|
|
|
const totalFiltered = filteredDownloads.length + filteredUpcoming.length;
|
|
|
|
return (
|
|
<div>
|
|
{/* Count */}
|
|
<div className="mb-4">
|
|
<p className="text-sm text-lightsec dark:text-darksec">
|
|
{totalFiltered ? `${totalFiltered} release(s)` : "No releases found."}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Search + Filter + Sort bar */}
|
|
<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={clearSearch} 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>
|
|
|
|
{/* Available Downloads */}
|
|
{paginatedDownloads.length > 0 && (
|
|
<section>
|
|
<div className={CARD_GRID_CLASSES_3}>
|
|
{paginatedDownloads.map((dl) => (
|
|
<DownloadCard key={dl.product_title} item={dl} />
|
|
))}
|
|
</div>
|
|
|
|
{dlTotalPages > 1 && (
|
|
<Pagination page={dlSafePage} totalPages={dlTotalPages} onPageChange={setDlPage} />
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{/* Upcoming Releases */}
|
|
{paginatedUpcoming.length > 0 && (
|
|
<section className={paginatedDownloads.length > 0 ? "mt-16" : ""}>
|
|
<div className="mb-4">
|
|
<h2 className="font-silkasb text-sm tracking-wider uppercase">Upcoming Releases</h2>
|
|
<p className="mt-1 text-xs text-lightsec dark:text-darksec">
|
|
You'll receive download links by email when these become available.
|
|
</p>
|
|
</div>
|
|
|
|
<div className={CARD_GRID_CLASSES_3}>
|
|
{paginatedUpcoming.map((up) => (
|
|
<UpcomingCard key={up.product_title} item={up} />
|
|
))}
|
|
</div>
|
|
|
|
{upTotalPages > 1 && (
|
|
<Pagination page={upSafePage} totalPages={upTotalPages} onPageChange={setUpPage} />
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{/* No results after filtering */}
|
|
{totalFiltered === 0 && (q || Object.values(filters).some((v) => v.length > 0)) && (
|
|
<p className="py-12 text-center text-lightsec dark:text-darksec">No releases found.</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Pagination ──────────────────────────────────────────────────────
|
|
|
|
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>
|
|
);
|
|
}
|