kemono2/client/src/pages/profiles.tsx
2025-04-11 00:54:15 +02:00

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;
}