kemono2/client/src/pages/profile.tsx
2025-04-11 00:58:59 +02:00

256 lines
6.9 KiB
TypeScript

import { LoaderFunctionArgs, redirect, useLoaderData } from "react-router";
import { createDiscordServerPageURL, createProfilePageURL } from "#lib/urls";
import { parseOffset } from "#lib/pagination";
import { ElementType } from "#lib/types";
import { fetchProfilePosts } from "#api/profiles";
import { FooterAd, SliderAd } from "#components/advs";
import { Paginator } from "#components/pagination";
import { CardList, PostCard } from "#components/cards";
import { ProfilePageSkeleton } from "#components/pages";
import { ButtonSubmit, FormRouter } from "#components/forms";
import {
ProfileHeader,
Tabs,
IArtistDetails,
getArtist,
} from "#entities/profiles";
import { paysites } from "#entities/paysites";
import { IPost } from "#entities/posts";
import { findFavouritePosts } from "#entities/account";
import { useRef, useState } from "react";
interface IProps {
profile: IArtistDetails;
postsData?: {
count: number;
offset?: number;
posts: (IPost & { isFavourite: boolean })[];
};
query?: string;
tags?: string[];
dmCount?: number;
hasLinks?: boolean;
}
export function ProfilePage() {
const { profile, postsData, query, tags, dmCount, hasLinks } =
useLoaderData() as IProps;
const [isLoading, setIsLoading] = useState(false);
const { service, id, name } = profile;
const paysite = paysites[service];
const title = `Posts of "${name}" from "${paysite.title}"`;
return (
<ProfilePageSkeleton name="user" title={title} profile={profile}>
<SliderAd />
<ProfileHeader service={service} profileID={id} profileName={name} />
<div className="paginator" id="paginator-top">
<Tabs
currentPage="posts"
service={service}
artistID={id}
dmCount={dmCount}
hasLinks={hasLinks}
/>
{!(postsData && (postsData?.count !== 0 || query)) ? undefined : (
<>
<SearchForm
query={query}
onLoadingChange={(loading) => setIsLoading(loading)}
/>
<Paginator
offset={postsData.offset}
count={postsData.count}
constructURL={(offset) =>
String(
createProfilePageURL({
service,
profileID: id,
offset,
query,
tags,
})
)
}
/>
</>
)}
</div>
{!postsData ? (
<div className="no-results">
<h2 className="site-section__subheading">
Nobody here but us chickens!
</h2>
<p className="subtitle">There are no posts for your query.</p>
</div>
) : (
<>
<CardList className={isLoading ? "card-list--loading" : ""}>
{postsData.posts.map((post) => (
<PostCard
key={`${post.id}-${post.service}-${post.user}`}
post={post}
isServiceIconsDisabled={true}
isFavourite={post.isFavourite}
/>
))}
</CardList>
<FooterAd />
<div className="paginator" id="paginator-bottom">
<Paginator
offset={postsData.offset}
count={postsData.count}
constructURL={(offset) =>
String(
createProfilePageURL({
service,
profileID: id,
offset,
query,
tags,
})
)
}
/>
</div>
</>
)}
</ProfilePageSkeleton>
);
}
interface ISearchFormProps
extends Pick<IProps, "query"> {
onLoadingChange: (loading: boolean) => void;
}
function SearchForm({ query, onLoadingChange }: ISearchFormProps) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
const target = e.currentTarget as HTMLInputElement;
onLoadingChange(true);
timeoutRef.current = setTimeout(() => {
if (target.form) target.form.requestSubmit();
onLoadingChange(false);
}, 1000);
};
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"
onClick={() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
onLoadingChange(false);
}}
>
<img src='/static/menu/search.svg' />
</ButtonSubmit>
</div>
</>
)}
</FormRouter>
);
}
export async function loader({
params,
request,
}: LoaderFunctionArgs): Promise<IProps | Response> {
const searchParams = new URL(request.url).searchParams;
const service = params.service?.trim() || "";
const profileID = params.creator_id?.trim() || "";
if (service === "discord") {
return redirect(String(createDiscordServerPageURL(profileID)));
}
const offsetParam = searchParams.get("o")?.trim();
const offset = offsetParam ? parseOffset(offsetParam) : undefined;
const query = searchParams.get("q")?.trim();
const tags = searchParams.getAll("tag");
const profile = await getArtist(service, profileID);
if (profileID == profile.public_id && profile.public_id != profile.id) {
return redirect(String(createProfilePageURL({ service, profileID: profile.id })));
}
const { props, results: posts } = await fetchProfilePosts(
service,
profileID,
offset,
query,
tags
);
const { count, dm_count, has_links } = props;
const hasLinks = !has_links || has_links === "0" ? false : true;
const favPostData = await findFavouritePosts(
posts.map(({ service, user, id }) => {
return {
service,
user,
id,
};
})
);
const finalPosts = posts.map<
ElementType<Required<IProps>["postsData"]["posts"]>
>((post) => {
const match = favPostData.find(
({ service, user, id }) =>
service === post.service && user === post.user && id === post.id
);
return { ...post, isFavourite: !match ? false : true };
});
return {
profile,
query,
tags,
postsData: {
count,
offset,
posts: finalPosts,
},
dmCount: dm_count,
hasLinks,
};
}