203 lines
7.1 KiB
TypeScript
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>
|
|
);
|
|
}
|