Files
gerbeur/src/pages/UserPublicProfile.tsx

203 lines
7.1 KiB
TypeScript

/**
* Renders a user's profile page, which is also where the signed-in user edits
* their own profile.
*
* @module
*/
import { useLocation, useNavigate, useParams } from "react-router";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type { Invitee } from "../../shared/user.ts";
import type { Wire } from "../../shared/api.ts";
import {
FOLLOW_VIEWS,
type FollowView,
PROFILE_TABS,
type ProfileTab,
userPath,
} from "../../shared/paths.ts";
import { deserializeInvitee } from "../utils/shared/deserialize.ts";
import { dataOr } from "../utils/shared/api.ts";
import { can } from "../utils/auth/permissions.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useFetchState } from "../hooks/shared/useFetchState.ts";
import { useTabParam } from "../hooks/shared/useTabParam.ts";
import { useCanonicalPath } from "../hooks/shared/useCanonicalPath.ts";
import { useProfile } from "../hooks/profile/useProfile.ts";
import { PageShell } from "../components/layout/PageShell.tsx";
import { PageError } from "../components/layout/PageError.tsx";
import { TabBar } from "../components/shared/TabBar.tsx";
import { ProfileHeader } from "../components/profile/ProfileHeader.tsx";
import { ProfileDescription } from "../components/profile/ProfileDescription.tsx";
import { ProfileOverviewTab } from "../components/profile/ProfileOverviewTab.tsx";
import { ProfileDumpsTab } from "../components/profile/ProfileDumpsTab.tsx";
import { ProfilePlaylistsTab } from "../components/profile/ProfilePlaylistsTab.tsx";
import { ProfileFollowTab } from "../components/profile/ProfileFollowTab.tsx";
import { ProfileInviteesTab } from "../components/profile/ProfileInviteesTab.tsx";
import { ProfileManageTab } from "../components/profile/ProfileManageTab.tsx";
import { NotFound } from "./NotFound.tsx";
/** The overview, the bare `/@name`, which no path segment names. */
const OVERVIEW = "overview";
type Tab = ProfileTab | typeof OVERVIEW;
const TABS: readonly Tab[] = [OVERVIEW, ...PROFILE_TABS];
/** The lists of dumps, which `?category=` narrows; the other tabs drop it. */
const DUMP_LISTS: readonly Tab[] = ["dumps", "upvoted"];
/**
* Renders `/@:username` and its tabs, `/@:username/:tab`, and the follow
* tab's view `/@:username/follow/playlists`, for anyone. `useProfile` loads
* the user, by a former name or another case too, and the URL then takes the
* current one; each tab loads its own lists, by the user's id, when shown.
* On their own profile, users edit their avatar, description and email, and
* create invites. The "manage" tab, another user's role and name, is for those
* who hold `user:manage`. A tab or view the profile hasn't is `NotFound`.
*/
export function UserPublicProfile(): React.JSX.Element {
const { handle = "" } = useParams();
const username = handle.slice(1);
const { pathname } = useLocation();
const navigate = useNavigate();
const { user: me, logout } = useAuth();
const { state, isOwnProfile, setUser } = useProfile(username);
const user = state.status === "loaded" ? state.data : null;
useDocumentTitle(
user ? user.username : state.status === "error" ? null : undefined,
);
// The base is the path's own first segment, however it spells the name.
const base = `/${pathname.split("/")[1]}`;
const [tab, setTab] = useTabParam<Tab>(base, TABS, OVERVIEW);
useCanonicalPath(
user && `${userPath(user.username)}${pathname.slice(base.length)}`,
);
// Only the follow tab has a view, one segment further.
const extra = pathname.slice(base.length).split("/").filter(Boolean).slice(1);
const view: FollowView | null = extra.length === 0
? "users"
: tab === "follow" && extra.length === 1 && extra[0] !== FOLLOW_VIEWS[0] &&
(FOLLOW_VIEWS as readonly string[]).includes(extra[0])
? extra[0] as FollowView
: null;
const canManageUsers = can(me, "user:manage");
const showManageTab = !isOwnProfile && canManageUsers;
const invitees = useFetchState<Invitee[]>(
user?.id,
(signal) =>
fetch(`${API_URL}/api/users/${user!.id}/invitees`, { signal })
.then((res) => dataOr<Wire<Invitee>[]>(res, []))
.then((entries) => entries.map(deserializeInvitee)),
{ enabled: tab === "invitees" },
);
if (!tab || !view || (tab === "manage" && user && !showManageTab)) {
return <NotFound />;
}
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">
<Trans>Loading profile…</Trans>
</p>
</PageShell>
);
}
if (state.status === "error") {
return (
<PageError
message={state.error}
actions={
<>
<button
className="btn-border"
type="button"
onClick={() => navigate("/")}
>
<Trans>← Back</Trans>
</button>
{me && (
<button
className="btn-border btn-border--danger"
type="button"
onClick={logout}
>
<Trans>Log out</Trans>
</button>
)}
</>
}
/>
);
}
const profileUser = state.data;
return (
<PageShell>
<ProfileHeader
user={profileUser}
isOwnProfile={isOwnProfile}
onUserChange={(u) => setUser(() => u)}
/>
<ProfileDescription
description={profileUser.description}
isOwnProfile={isOwnProfile}
onSaved={(description) => setUser((u) => ({ ...u, description }))}
/>
<TabBar
tabs={[
{ key: OVERVIEW as Tab, label: <Trans>Overview</Trans> },
{ key: "dumps" as Tab, label: <Trans>Dumps</Trans> },
{ key: "upvoted" as Tab, label: <Trans>Upvoted</Trans> },
{ key: "playlists" as Tab, label: <Trans>Playlists</Trans> },
{ key: "follow" as Tab, label: <Trans>Followed</Trans> },
{ key: "invitees" as Tab, label: <Trans>Invitees</Trans> },
...(showManageTab
? [{ key: "manage" as Tab, label: <Trans>Manage</Trans> }]
: []),
]}
activeTab={tab}
onChange={(key) =>
setTab(key, DUMP_LISTS.includes(key) ? undefined : "")}
className="profile-tabs-scroller"
innerClassName="profile-tabs"
/>
{tab === OVERVIEW && (
<ProfileOverviewTab user={profileUser} isOwnProfile={isOwnProfile} />
)}
{(tab === "dumps" || tab === "upvoted") && (
<ProfileDumpsTab
key={tab}
user={profileUser}
list={tab}
isOwnProfile={isOwnProfile}
/>
)}
{tab === "playlists" && (
<ProfilePlaylistsTab user={profileUser} isOwnProfile={isOwnProfile} />
)}
{tab === "follow" && <ProfileFollowTab user={profileUser} view={view} />}
{tab === "invitees" && (
<ProfileInviteesTab userId={profileUser.id} state={invitees.state} />
)}
{tab === "manage" && (
<ProfileManageTab
user={profileUser}
onUserSaved={(u) => setUser(() => u)}
/>
)}
</PageShell>
);
}