328 lines
9.3 KiB
TypeScript
328 lines
9.3 KiB
TypeScript
import clsx from "clsx";
|
|
import { Suspense, useRef, useState } from "react";
|
|
import {
|
|
useLoaderData,
|
|
LoaderFunctionArgs,
|
|
Await,
|
|
useAsyncError,
|
|
} from "react-router";
|
|
import { PAYSITE_LIST } from "#env/env-vars";
|
|
import {
|
|
ARTISTS_OR_CREATORS_LOWERCASE,
|
|
AVAILABLE_PAYSITE_LIST,
|
|
} from "#env/derived-vars";
|
|
import { createArtistsPageURL } from "#lib/urls";
|
|
import { parseOffset } from "#lib/pagination";
|
|
import { PageSkeleton } from "#components/pages";
|
|
import { FooterAd, HeaderAd, SliderAd } from "#components/advs";
|
|
import { Paginator } from "#components/pagination";
|
|
import { CardList, ArtistCard } from "#components/cards";
|
|
import { ButtonSubmit, FormRouter, FormSection } from "#components/forms";
|
|
import { LoadingIcon } from "#components/loading";
|
|
import { getArtists } from "#entities/profiles";
|
|
|
|
import * as styles from "./profiles.module.scss";
|
|
|
|
interface IProps {
|
|
results: ReturnType<typeof getArtists>;
|
|
query?: string;
|
|
service?: string;
|
|
sort_by?: ISortField;
|
|
order?: "asc" | "desc";
|
|
offset?: number;
|
|
true_count?: number;
|
|
}
|
|
|
|
const sortFields = [
|
|
"favorited",
|
|
"indexed",
|
|
"updated",
|
|
"name",
|
|
"service",
|
|
] as const;
|
|
|
|
type ISortField = (typeof sortFields)[number];
|
|
|
|
function validateSortField(input: unknown): asserts input is ISortField {
|
|
if (!sortFields.includes(input as ISortField)) {
|
|
throw new Error(`Invalid sort field value "${input}".`);
|
|
}
|
|
}
|
|
|
|
export function ArtistsPage() {
|
|
const { results, query, service, sort_by, order, offset } =
|
|
useLoaderData() as IProps;
|
|
const title = "Artists";
|
|
const heading = "Artists";
|
|
|
|
return (
|
|
<PageSkeleton name="artists" title={title} heading={heading}>
|
|
<SliderAd />
|
|
|
|
<HeaderAd />
|
|
|
|
<div className="paginator" id="paginator-top">
|
|
<SearchForm
|
|
query={query}
|
|
service={service}
|
|
sort_by={sort_by}
|
|
order={order}
|
|
/>
|
|
<Suspense fallback={<LoadingIcon />}>
|
|
<Await errorElement={<></>} resolve={results}>
|
|
{(resolvedResult: Awaited<typeof results>) => (
|
|
<Paginator
|
|
count={resolvedResult.count}
|
|
offset={offset}
|
|
constructURL={(offset) => {
|
|
const url = createArtistsPageURL(
|
|
offset,
|
|
query,
|
|
service,
|
|
sort_by,
|
|
order
|
|
);
|
|
|
|
return String(url);
|
|
}}
|
|
/>
|
|
)}
|
|
</Await>
|
|
</Suspense>
|
|
</div>
|
|
|
|
<CardList layout="phone">
|
|
<Suspense
|
|
fallback={
|
|
<p className={styles.loading}>
|
|
<LoadingIcon>Loading creators... please wait!</LoadingIcon>
|
|
</p>
|
|
}
|
|
>
|
|
<Await resolve={results} errorElement={<CollectionError />}>
|
|
{(resolvedResult: Awaited<typeof results>) =>
|
|
resolvedResult.artists.length === 0 ? (
|
|
<p className={clsx("subtitle", "card-list__item--no-results")}>
|
|
No {ARTISTS_OR_CREATORS_LOWERCASE} found for your query.
|
|
</p>
|
|
) : (
|
|
resolvedResult.artists.map((artist) => (
|
|
<ArtistCard
|
|
key={`${artist.service}-${artist.id}`}
|
|
artist={artist}
|
|
isUpdated={sort_by === "updated"}
|
|
isIndexed={sort_by === "indexed"}
|
|
isCount={sort_by === "favorited" || sort_by === undefined}
|
|
isFavorite={artist.isFavourite}
|
|
singleOf="favorite"
|
|
pluralOf="favorites"
|
|
/>
|
|
))
|
|
)
|
|
}
|
|
</Await>
|
|
</Suspense>
|
|
</CardList>
|
|
|
|
<div className="paginator" id="paginator-bottom">
|
|
<Suspense fallback={<LoadingIcon />}>
|
|
<Await errorElement={<></>} resolve={results}>
|
|
{(resolvedResult: Awaited<typeof results>) => (
|
|
<Paginator
|
|
count={resolvedResult.count}
|
|
offset={offset}
|
|
constructURL={(offset) => {
|
|
const url = createArtistsPageURL(
|
|
offset,
|
|
query,
|
|
service,
|
|
sort_by,
|
|
order
|
|
);
|
|
|
|
return String(url);
|
|
}}
|
|
/>
|
|
)}
|
|
</Await>
|
|
</Suspense>
|
|
</div>
|
|
|
|
<FooterAd />
|
|
</PageSkeleton>
|
|
);
|
|
}
|
|
|
|
interface ISearchFormProps
|
|
extends Pick<IProps, "query" | "service" | "sort_by" | "order"> { }
|
|
|
|
function SearchForm({ query, service, sort_by, order }: ISearchFormProps) {
|
|
const sortRef = useRef<HTMLInputElement>(null);
|
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const [sortDirection, setSortDirection] = useState<string | undefined>(order);
|
|
|
|
const onSortChange = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
|
if (sortRef.current) {
|
|
sortRef.current.value = sortDirection === "asc" ? "desc" : "asc";
|
|
sortRef.current.form?.requestSubmit();
|
|
}
|
|
}
|
|
const onSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => e.currentTarget.form?.requestSubmit();
|
|
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
|
|
const target = e.currentTarget as HTMLInputElement;
|
|
|
|
timeoutRef.current = setTimeout(() => {
|
|
if (target.form) target.form.requestSubmit();
|
|
}, 500);
|
|
};
|
|
|
|
return (
|
|
<FormRouter
|
|
id="search-form"
|
|
className="search-form"
|
|
method="GET"
|
|
autoComplete="off"
|
|
noValidate={true}
|
|
acceptCharset="UTF-8"
|
|
statusSection={null}
|
|
submitButton={undefined}
|
|
style={{ maxWidth: "fit-content" }}
|
|
>
|
|
{(state) => (
|
|
<>
|
|
<div className="wrapper">
|
|
<input
|
|
type="text"
|
|
name="q"
|
|
id="q"
|
|
autoComplete="off"
|
|
defaultValue={query}
|
|
placeholder="Search..."
|
|
onChange={onInputChange}
|
|
/>
|
|
<ButtonSubmit
|
|
disabled={state === "loading" || state === "submitting"}
|
|
className="search-button"
|
|
>
|
|
<img src='/static/menu/search.svg' />
|
|
</ButtonSubmit>
|
|
</div>
|
|
<div className="wrapper">
|
|
<select className="service" name="service" defaultValue={service} onChange={onSelectChange}>
|
|
<option value="">Services</option>
|
|
{AVAILABLE_PAYSITE_LIST.map((paysite, index) => (
|
|
<option key={index} value={paysite.name}>
|
|
{paysite.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select className="sort_by" name="sort_by" defaultValue={sort_by} onChange={onSelectChange}>
|
|
<option value="favorited">Popularity</option>
|
|
<option value="indexed">Date Indexed</option>
|
|
<option value="updated">Date Updated</option>
|
|
<option value="name">Alphabetical Order</option>
|
|
<option value="service">Service</option>
|
|
</select>
|
|
<button onClick={onSortChange} className="sort_dir button" title={sortDirection === "asc" ? "Ascending" : "Descending"} >
|
|
<img src="/static/sort.svg" alt="Sort" className={sortDirection} />
|
|
</button>
|
|
</div>
|
|
<input type="text" name="order" className="hidden" ref={sortRef} />
|
|
</>
|
|
)}
|
|
</FormRouter>
|
|
);
|
|
}
|
|
|
|
function CollectionError() {
|
|
const error = useAsyncError();
|
|
console.error(error);
|
|
|
|
return (
|
|
<div>
|
|
<p className={styles.error}>Failed to load artists.</p>
|
|
<details>
|
|
<summary>Details</summary>
|
|
{/* @ts-expect-error vague type definition */}
|
|
<p>{error?.statusText || error?.message}</p>
|
|
</details>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export async function loader({
|
|
request,
|
|
}: LoaderFunctionArgs): Promise<IProps> {
|
|
const searchParams = new URL(request.url).searchParams;
|
|
|
|
let offset: IProps["offset"] | undefined = undefined;
|
|
{
|
|
const inputOffset = searchParams.get("o")?.trim();
|
|
|
|
if (inputOffset) {
|
|
offset = parseOffset(inputOffset);
|
|
}
|
|
}
|
|
|
|
let query: IProps["query"] | undefined = searchParams.get("q")?.trim();
|
|
|
|
let sort_by: IProps["sort_by"] | undefined = undefined;
|
|
{
|
|
const inputValue = searchParams.get("sort_by")?.trim();
|
|
|
|
if (inputValue) {
|
|
validateSortField(inputValue);
|
|
sort_by = inputValue;
|
|
}
|
|
}
|
|
|
|
let order_by: IProps["order"] | undefined = undefined;
|
|
{
|
|
const inputValue = searchParams.get("order")?.trim();
|
|
|
|
if (inputValue) {
|
|
if (inputValue !== "asc" && inputValue !== "desc") {
|
|
throw new Error(`Invalid order by field "${inputValue}".`);
|
|
}
|
|
|
|
order_by = inputValue;
|
|
}
|
|
}
|
|
|
|
let service: IProps["service"] = undefined;
|
|
{
|
|
const inputValue = searchParams.get("service")?.trim();
|
|
|
|
if (inputValue) {
|
|
if (!PAYSITE_LIST.includes(inputValue)) {
|
|
throw new Error(`Unknown service "${inputValue}".`);
|
|
}
|
|
}
|
|
|
|
service = inputValue;
|
|
}
|
|
|
|
const results = getArtists({
|
|
offset,
|
|
order: order_by,
|
|
service,
|
|
sort_by,
|
|
query,
|
|
});
|
|
|
|
const pageProps = {
|
|
results,
|
|
sort_by,
|
|
order: order_by,
|
|
offset,
|
|
service,
|
|
query,
|
|
} satisfies IProps;
|
|
|
|
return pageProps;
|
|
}
|