v3: the profile page is split by one rule, and what it re-invented is shared across the app — UserPublicProfile was 1,949 lines because it held its data, six tabs, seven subcomponents and hand-rolled copies of things the app already had or repeated elsewhere. The page now only picks the tab and puts the pieces together (253 lines); useProfile holds the user and the three lists with their live sync, cache and scroll; each tab and header piece is one file under components/profile/; the followed and invitees tabs load on first open through the new useFetchState, keyed by username, which keeps them across tab switches. Every piece taken out replaces its copies: DumpCardList, which reads the votes from WSContext, is every list of dump cards (the index feeds, followed, search, related dumps, UserDumps, UserUpvoted, the profile), so Index no longer threads vote props into the feeds; PlaylistCardList every list of playlist cards; FeedFooter the eight sentinel/loading-more/end blocks; ListState the loading/error/empty chains; Segmented the settings toggles, RoleSwitcher and the playlist edit form, with SegmentedField built on it; LoadMoreButton the three load-more buttons; NewDumpButton UserDumps' and the profile's; useScrollRestore four copies of the scroll restore, and the profile uses useScrollSave instead of saving its whole cache on every scroll. useFading takes an onFaded callback, which replaces PlaylistDetail's copy of its timers; useUpvotedList is the vote tracking UserUpvoted and the profile each had, and a withdrawn upvote now leaves the list and the cache once faded (it was only hidden, and came back after a tab switch). usePlaylistMemberships is the membership load and toggle AddToPlaylistModal and DumpCreateModal each had. pagedList.ts (PagedList, firstPage, restoredList, fetchFirstPage, withFirst), fetchPublicUser and dataOr are shared by the profile, UserPlaylists and useUserDumpFeed; a refused side list still shows empty, as documented, and only the user fails the page. FollowedFeed's two sections are one local hook, and its readPage is expectOk. A card is its owner's when the item is the signed-in user's, everywhere: the profile's upvoted column marked every dump as the owner's on one's own profile. No message changed; the catalogs only move their references. 643 frontend tests pass (11 new for useFetchState, useFading's onFaded and pagedList), and tsc, eslint, doc:lint, deno check and deno fmt are clean.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-29 20:50:49 +00:00
parent 80f3292c8f
commit 69d9bf899b
54 changed files with 3460 additions and 3601 deletions

View File

@@ -0,0 +1,103 @@
/**
* Renders the inline editor of the signed-in user's description, under their
* profile's header.
*
* @module
*/
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Controller, FormProvider } from "react-hook-form";
import { API_URL } from "../../config/api.ts";
import { VALIDATION } from "../../../shared/validation.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { TextEditor } from "../editor/TextEditor.tsx";
import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx";
/**
* Passed to {@link DescriptionEditor} by `ProfileDescription`, which shows it
* in place of the description while editing.
*/
export interface DescriptionEditorProps {
initialDescription: string;
onCancel: () => void;
onSaved: (description: string | undefined) => void;
}
/**
* Renders the description's Markdown editor, saved to `PATCH /api/users/me`.
* Other browsers see the change through the socket's `user_updated` event.
*/
export function DescriptionEditor(
{ initialDescription, onCancel, onSaved }: DescriptionEditorProps,
): React.JSX.Element {
const { authFetch } = useAuth();
const form = useApiForm<{ description: string }>({
defaultValues: { description: initialDescription },
});
const description = form.watch("description");
const submitting = form.formState.isSubmitting;
const onSubmit = form.submit(async ({ description }) => {
const trimmed = description.trim();
// Sent even when empty: left out, the server would keep the old one.
await expectOk<Wire<PublicUser>>(
await authFetch(`${API_URL}/api/users/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ description: trimmed } satisfies UpdateUserRequest,
),
}),
);
onSaved(trimmed || undefined);
});
return (
<FormProvider {...form}>
<form
className="profile-description-editor animate-in"
onSubmit={onSubmit}
>
<Controller
name="description"
control={form.control}
render={({ field }) => (
<TextEditor
className="comment-reply-textarea"
value={field.value}
onChange={field.onChange}
onSubmit={() => void onSubmit()}
onCancel={submitting ? undefined : onCancel}
placeholder={t`Who am I?`}
autoResize
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
/>
)}
/>
<div className="profile-description-actions">
<SubmitButton
pendingLabel={<Trans>Saving…</Trans>}
disabled={description.length > VALIDATION.USER_DESCRIPTION_MAX}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="form-cancel"
onClick={onCancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
<FormError title={t`Failed to save`} />
</div>
</form>
</FormProvider>
);
}

View File

@@ -0,0 +1,90 @@
/**
* Renders the inline editor of the signed-in user's email, in their profile's
* header.
*
* @module
*/
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { FormProvider } from "react-hook-form";
import { API_URL } from "../../config/api.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx";
/**
* Passed to {@link EmailEditor} by `ProfileHeader`, which shows it in place of
* the email while editing.
*/
export interface EmailEditorProps {
initialEmail: string;
onCancel: () => void;
onSaved: (email: string) => void;
}
/**
* Renders the email field, saved to `PATCH /api/users/me`. Escape cancels.
*/
export function EmailEditor(
{ initialEmail, onCancel, onSaved }: EmailEditorProps,
): React.JSX.Element {
const { authFetch } = useAuth();
const form = useApiForm<{ email: string }>({
defaultValues: { email: initialEmail },
});
const email = form.watch("email");
const submitting = form.formState.isSubmitting;
const onSubmit = form.submit(async ({ email }) => {
await expectOk<Wire<PublicUser>>(
await authFetch(`${API_URL}/api/users/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ email: email.trim() } satisfies UpdateUserRequest,
),
}),
);
onSaved(email.trim());
});
return (
<FormProvider {...form}>
<form className="profile-email-editor animate-in" onSubmit={onSubmit}>
<input
type="email"
className="profile-email-input"
{...form.register("email")}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
disabled={submitting}
autoFocus
/>
<div className="profile-email-actions">
<SubmitButton
className="profile-email-btn profile-email-btn--save"
pendingLabel={<Trans>Saving…</Trans>}
disabled={!email.trim()}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="profile-email-btn profile-email-btn--cancel"
onClick={onCancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
</div>
<FormError title={t`Failed to save`} />
</form>
</FormProvider>
);
}

View File

@@ -0,0 +1,91 @@
/**
* Renders a profile's description, which the signed-in user edits in place on
* their own profile.
*
* @module
*/
import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { Markdown } from "../editor/Markdown.tsx";
import { DescriptionEditor } from "./DescriptionEditor.tsx";
/** Passed to {@link ProfileDescription} by `UserPublicProfile`. */
export interface ProfileDescriptionProps {
description: string | undefined;
isOwnProfile: boolean;
/** Called with the saved description, undefined once cleared. */
onSaved: (description: string | undefined) => void;
}
/**
* Renders the description in Markdown, or nothing when there is none. On
* their own profile, users get a "Who am I?" placeholder instead, and a click
* or Enter swaps in {@link DescriptionEditor}.
*/
export function ProfileDescription(
{ description, isOwnProfile, onSaved }: ProfileDescriptionProps,
): React.JSX.Element | null {
const [editing, setEditing] = useState(false);
const ref = useHeightTransition<HTMLDivElement>(editing);
if (!description && !isOwnProfile) return null;
return (
<div className="profile-description" ref={ref}>
{editing
? (
<DescriptionEditor
initialDescription={description ?? ""}
onCancel={() => setEditing(false)}
onSaved={(saved) => {
onSaved(saved);
setEditing(false);
}}
/>
)
: (
<div
className={`profile-description-view${
isOwnProfile ? " profile-description-view--editable" : ""
}`}
onClick={isOwnProfile ? () => setEditing(true) : undefined}
// Focusable rather than a button, so the Markdown inside keeps its
// own structure and links.
tabIndex={isOwnProfile ? 0 : undefined}
title={isOwnProfile ? t`Edit description` : undefined}
onKeyDown={isOwnProfile
? (e) => {
if (
(e.key === "Enter" || e.key === " ") &&
e.target === e.currentTarget
) {
e.preventDefault();
setEditing(true);
}
}
: undefined}
>
{description
? (
<Markdown className="profile-description-text">
{description}
</Markdown>
)
: (
<div className="profile-description-empty">
<Trans>Who am I?</Trans>
</div>
)}
{isOwnProfile && (
<span className="profile-description-edit-btn" aria-hidden>
✎
</span>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,71 @@
/**
* The "dumps" tab of a profile: the user's dumps beside those they upvoted.
*
* @module
*/
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import type { Dump } from "../../../shared/dump.ts";
import type { PagedList } from "../../utils/feed/pagedList.ts";
import { DumpCardList } from "../dump/DumpCardList.tsx";
import { NewDumpButton } from "../dump/NewDumpButton.tsx";
import { ProfileSection } from "./ProfileSection.tsx";
/** Passed to {@link ProfileDumpsTab} by `UserPublicProfile`. */
export interface ProfileDumpsTabProps {
username: string;
dumps: PagedList<Dump>;
votes: PagedList<Dump>;
isOwnProfile: boolean;
/** From `useProfile`, which fades out a withdrawn upvote. */
upvotedCardClassName: (dump: Dump) => string | undefined;
}
/**
* Renders the first page of both lists, each linking to its full page. The
* owner also gets a "New dump" button.
*/
export function ProfileDumpsTab(
{ username, dumps, votes, isOwnProfile, upvotedCardClassName }:
ProfileDumpsTabProps,
): React.JSX.Element {
return (
<div className="profile-columns">
<ProfileSection
title={t`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
actions={isOwnProfile && <NewDumpButton />}
viewAllHref={dumps.items.length > 0
? `/users/${username}/dumps`
: undefined}
>
{dumps.items.length === 0
? (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)
: <DumpCardList dumps={dumps.items} />}
</ProfileSection>
<ProfileSection
title={t`Upvoted (${votes.items.length}${votes.hasMore ? "+" : ""})`}
viewAllHref={votes.items.length > 0
? `/users/${username}/upvoted`
: undefined}
>
{votes.items.length === 0
? (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)
: (
<DumpCardList
dumps={votes.items}
cardClassName={upvotedCardClassName}
/>
)}
</ProfileSection>
</div>
);
}

View File

@@ -0,0 +1,119 @@
/**
* The "followed" tab of a profile: the users and the playlists the user
* follows.
*
* @module
*/
import { Link } from "react-router";
import { Trans } from "@lingui/react/macro";
import type { Playlist } from "../../../shared/playlist.ts";
import type { PublicUser } from "../../../shared/user.ts";
import type { PagedList } from "../../utils/feed/pagedList.ts";
import type { FetchState } from "../../hooks/shared/useFetchState.ts";
import { Avatar } from "../shared/Avatar.tsx";
import { ListState } from "../shared/ListState.tsx";
import { LoadMoreButton } from "../shared/LoadMoreButton.tsx";
import { FollowUserButton } from "../social/FollowButton.tsx";
import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx";
import { ProfileSection } from "./ProfileSection.tsx";
/**
* Loaded by `UserPublicProfile` when the tab is first opened. The followed
* users have no page of their own, so their list grows in the tab.
*/
export interface ProfileFollowed {
users: PagedList<PublicUser>;
playlists: PagedList<Playlist>;
}
/** Passed to {@link ProfileFollowedTab} by `UserPublicProfile`. */
export interface ProfileFollowedTabProps {
username: string;
state: FetchState<ProfileFollowed>;
onLoadMoreUsers: () => void;
}
/** Renders a followed user, with a button to follow or unfollow them. */
function FollowedUserCard({ user }: { user: PublicUser }) {
return (
<li className="followed-user-card">
<Link to={`/users/${user.username}`} className="followed-user-card-link">
<Avatar
userId={user.id}
username={user.username}
hasAvatar={!!user.avatarMime}
size={36}
version={user.updatedAt?.getTime()}
/>
<span className="followed-user-card-name">@{user.username}</span>
</Link>
<FollowUserButton
targetUserId={user.id}
targetUsername={user.username}
/>
</li>
);
}
/**
* Renders the followed users, with a "Load more" button, beside the first
* page of the followed playlists, which link to the user's playlists page.
*/
export function ProfileFollowedTab(
{ username, state, onLoadMoreUsers }: ProfileFollowedTabProps,
): React.JSX.Element {
return (
<div className="profile-columns">
<ProfileSection title={<Trans>Following</Trans>}>
<ListState
state={state}
isEmpty={({ data }) => data.users.items.length === 0}
empty={
<p className="empty-state">
<Trans>Not following anyone yet.</Trans>
</p>
}
>
{({ data: { users } }) => (
<>
<ul className="followed-user-list">
{users.items.map((u) => (
<FollowedUserCard
key={u.id}
user={u}
/>
))}
</ul>
{users.hasMore && (
<LoadMoreButton
loadingMore={users.loadingMore}
onClick={onLoadMoreUsers}
/>
)}
</>
)}
</ListState>
</ProfileSection>
<ProfileSection
title={<Trans>Followed playlists</Trans>}
viewAllHref={state.status === "loaded" && state.data.playlists.hasMore
? `/users/${username}/playlists`
: undefined}
>
<ListState
state={state}
isEmpty={({ data }) => data.playlists.items.length === 0}
empty={
<p className="empty-state">
<Trans>No followed playlists yet.</Trans>
</p>
}
>
{({ data }) => <PlaylistCardList playlists={data.playlists.items} />}
</ListState>
</ProfileSection>
</div>
);
}

View File

@@ -0,0 +1,182 @@
/**
* Renders the header of a user's profile, where the signed-in user also edits
* their own avatar and email.
*
* @module
*/
import { useRef, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, SelfUser } from "../../../shared/user.ts";
import { parseAPIResponse } from "../../utils/shared/api.ts";
import { deserializeSelfUser } from "../../utils/shared/deserialize.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { Avatar } from "../shared/Avatar.tsx";
import { ErrorCard } from "../shared/ErrorCard.tsx";
import { FollowUserButton } from "../social/FollowButton.tsx";
import { EmailEditor } from "./EmailEditor.tsx";
import { ProfileOwnActions } from "./ProfileOwnActions.tsx";
import { RoleChip } from "./RoleSwitcher.tsx";
/** Passed to {@link ProfileHeader} by `UserPublicProfile`, once loaded. */
export interface ProfileHeaderProps {
user: PublicUser;
isOwnProfile: boolean;
/** Called with the user as a new avatar left them. */
onUserChange: (user: PublicUser) => void;
}
/**
* Renders the avatar, username, role and inviter. Visitors get a follow
* button. On their own profile, users change their avatar
* (`POST /api/avatars/me`) and email, invite someone and log out.
*/
export function ProfileHeader(
{ user, isOwnProfile, onUserChange }: ProfileHeaderProps,
): React.JSX.Element {
const { user: me, authFetch, updateSession, logout } = useAuth();
const [uploading, setUploading] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [emailEditing, setEmailEditing] = useState(false);
const infoRef = useHeightTransition<HTMLDivElement>(emailEditing);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setAvatarError(null);
setUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
const res = await authFetch(`${API_URL}/api/avatars/me`, {
method: "POST",
body: formData,
});
const body = parseAPIResponse<Wire<SelfUser>>(await res.json());
if (!body.success) {
setAvatarError(body.error.message);
return;
}
const self = deserializeSelfUser(body.data);
updateSession({ user: self });
onUserChange(self);
} catch {
setAvatarError(t`Upload failed`);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleEmailSaved = (email: string) => {
if (me) updateSession({ user: { ...me, email } });
setEmailEditing(false);
};
return (
<div className="profile-header">
<div className="profile-avatar-wrapper">
<Avatar
userId={user.id}
username={user.username}
hasAvatar={!!user.avatarMime}
size={128}
version={user.updatedAt?.getTime()}
/>
{isOwnProfile && (
<label className="avatar-change-overlay" title={t`Change avatar`}>
{uploading ? "…" : "✎"}
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleAvatarUpload}
disabled={uploading}
style={{ display: "none" }}
/>
</label>
)}
</div>
<div className="profile-info" ref={infoRef}>
<div className="profile-info-scroll">
<div className="profile-username-row">
<h1 className="profile-username">{user.username}</h1>
<RoleChip role={user.role} />
</div>
{user.invitedByUsername
? (
<p className="profile-invited-by">
<Trans>invited by</Trans>{" "}
<Link
to={`/users/${user.invitedByUsername}`}
className="profile-invited-by-link"
>
@{user.invitedByUsername}
</Link>
</p>
)
: (
<p className="profile-invited-by profile-invited-by--founding">
O.G.
</p>
)}
{isOwnProfile && (
emailEditing
? (
<EmailEditor
initialEmail={me?.email ?? ""}
onCancel={() => setEmailEditing(false)}
onSaved={handleEmailSaved}
/>
)
: (
<p
className="profile-email-display"
onClick={() => setEmailEditing(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setEmailEditing(true);
}
}}
tabIndex={0}
role="button"
title={t`Edit email`}
>
{me?.email ?? t`Add email…`}
<span className="profile-description-edit-btn" aria-hidden>
✎
</span>
</p>
)
)}
{avatarError && (
<ErrorCard
title={t`Failed to update avatar`}
message={avatarError}
/>
)}
{!isOwnProfile && (
<FollowUserButton
targetUserId={user.id}
targetUsername={user.username}
/>
)}
{isOwnProfile && (
<ProfileOwnActions>
<button type="button" className="btn-border" onClick={logout}>
<Trans>Log out</Trans>
</button>
</ProfileOwnActions>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
/**
* The "invitees" tab of a profile: whom the user invited, and whom they
* invited in turn.
*
* @module
*/
import { useMemo } from "react";
import { Link } from "react-router";
import { Trans } from "@lingui/react/macro";
import type { Invitee } from "../../../shared/user.ts";
import type { FetchState } from "../../hooks/shared/useFetchState.ts";
import { Avatar } from "../shared/Avatar.tsx";
import { ListState } from "../shared/ListState.tsx";
import { ProfileSection } from "./ProfileSection.tsx";
type InviteTreeNode = Invitee & { children: InviteTreeNode[] };
/**
* Returns the invitees of `parentId` among `entries`, each with theirs. The
* server answers the whole tree as a flat list.
*/
function buildInviteTree(
entries: Invitee[],
parentId: string,
): InviteTreeNode[] {
return entries
.filter((e) => e.invitedById === parentId)
.map((e) => ({ ...e, children: buildInviteTree(entries, e.id) }));
}
/** Renders the invite tree, each invitee nested under who invited them. */
function InviteTreeList({ nodes }: { nodes: InviteTreeNode[] }) {
return (
<ul className="invite-tree">
{nodes.map((node) => (
<li key={node.id} className="invite-tree-node">
<Link
to={`/users/${node.username}`}
className="invite-tree-user"
>
<Avatar
userId={node.id}
username={node.username}
hasAvatar={!!node.avatarMime}
size={24}
/>
@{node.username}
</Link>
{node.children.length > 0 && <InviteTreeList nodes={node.children} />}
</li>
))}
</ul>
);
}
/** Passed to {@link ProfileInviteesTab} by `UserPublicProfile`. */
export interface ProfileInviteesTabProps {
userId: string;
/**
* Loaded when the tab is first opened, from
* `GET /api/users/:username/invitees`.
*/
state: FetchState<Invitee[]>;
}
/** Renders the invitees of `userId` as a tree. */
export function ProfileInviteesTab(
{ userId, state }: ProfileInviteesTabProps,
): React.JSX.Element {
const nodes = useMemo(
() => state.status === "loaded" ? buildInviteTree(state.data, userId) : [],
[state, userId],
);
return (
<ProfileSection title={<Trans>Invitees</Trans>}>
<ListState
state={state}
isEmpty={({ data }) => data.length === 0}
empty={
<p className="empty-state invite-tree-empty">
<Trans>No invitees yet.</Trans>
</p>
}
>
{() => <InviteTreeList nodes={nodes} />}
</ListState>
</ProfileSection>
);
}

View File

@@ -0,0 +1,55 @@
/**
* The "manage" tab of a profile, which holds tools for another user on their
* profile and site-wide tools on one's own.
*
* @module
*/
import { Trans } from "@lingui/react/macro";
import type { PublicUser, Role } from "../../../shared/user.ts";
import { CategoryManager } from "../category/CategoryManager.tsx";
import { ProfileSection } from "./ProfileSection.tsx";
import { RoleSwitcher } from "./RoleSwitcher.tsx";
/**
* Passed to {@link ProfileManageTab} by `UserPublicProfile`, which shows the
* tab only when one of its sections applies.
*/
export interface ProfileManageTabProps {
user: PublicUser;
isOwnProfile: boolean;
canManageUsers: boolean;
canManageCategories: boolean;
onRoleSaved: (role: Role) => void;
}
/**
* Renders the user's role to those who hold `user:manage`, on another user's
* profile, and the {@link CategoryManager} to those who hold
* `category:manage`, on their own.
*/
export function ProfileManageTab(
{ user, isOwnProfile, canManageUsers, canManageCategories, onRoleSaved }:
ProfileManageTabProps,
): React.JSX.Element {
return (
<>
{!isOwnProfile && canManageUsers && (
<ProfileSection title={<Trans>User management</Trans>}>
<div className="profile-appearance-grid">
<RoleSwitcher
userId={user.id}
currentRole={user.role}
onSaved={onRoleSaved}
/>
</div>
</ProfileSection>
)}
{isOwnProfile && canManageCategories && (
<ProfileSection title={<Trans>Categories</Trans>}>
<CategoryManager />
</ProfileSection>
)}
</>
);
}

View File

@@ -0,0 +1,119 @@
/**
* Renders the actions of the signed-in user's own profile header: inviting
* someone, and whatever the header adds, the log out button.
*
* @module
*/
import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { ErrorCard } from "../shared/ErrorCard.tsx";
/**
* Renders the "Invite someone" button, then `children`, the log out button.
* Each opening of the panel creates a new single-use invite link, for
* `/register`.
*/
export function ProfileOwnActions(
{ children }: { children: React.ReactNode },
): React.JSX.Element {
const { authFetch } = useAuth();
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const ref = useHeightTransition<HTMLDivElement>(
(inviteUrl ?? "") + (error ?? ""),
);
async function toggle() {
setError(null);
setCopied(false);
if (inviteUrl) {
setInviteUrl(null);
return;
}
setPending(true);
try {
const res = await authFetch(`${API_URL}/api/invites`, { method: "POST" });
const body = await res.json();
if (body.success) {
setInviteUrl(
`${globalThis.location.origin}/register?token=${body.data.token}`,
);
} else {
setError(t`Failed to generate invite`);
}
} catch {
setError(t`Failed to generate invite`);
} finally {
setPending(false);
}
}
async function copy() {
if (!inviteUrl) return;
try {
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// The clipboard is denied outside a secure context. The link can still
// be selected by hand.
}
}
return (
<div className="profile-own-actions" ref={ref}>
<div className="profile-own-actions-row">
<button
type="button"
className="invite-btn"
onClick={toggle}
disabled={pending}
aria-expanded={inviteUrl !== null}
>
<Trans>+ Invite someone</Trans>
</button>
{children}
</div>
{inviteUrl && (
<div
className="invite-panel animate-in"
role="group"
aria-label={t`Invite link`}
>
<input
className="invite-url"
type="text"
readOnly
value={inviteUrl}
aria-label={t`Invite link`}
onFocus={(e) => e.currentTarget.select()}
/>
<button
type="button"
className={`invite-copy-btn${
copied ? " invite-copy-btn--done" : ""
}`}
onClick={copy}
>
{copied ? t`Copied!` : t`Copy`}
</button>
<span className="invite-panel-hint">
<Trans>Single use, valid for 7 days.</Trans>
</span>
</div>
)}
{error && (
<div className="invite-panel invite-panel--error animate-in">
<ErrorCard title={t`Failed to generate invite`} message={error} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
/**
* The "playlists" tab of a profile: the playlists the user made.
*
* @module
*/
import { Trans } from "@lingui/react/macro";
import type { Playlist } from "../../../shared/playlist.ts";
import type { PagedList } from "../../utils/feed/pagedList.ts";
import { withFirst } from "../../utils/feed/pagedList.ts";
import { NewPlaylistButton } from "../playlist/NewPlaylistButton.tsx";
import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx";
import { ProfileSection } from "./ProfileSection.tsx";
/** Passed to {@link ProfilePlaylistsTab} by `UserPublicProfile`. */
export interface ProfilePlaylistsTabProps {
username: string;
playlists: PagedList<Playlist>;
isOwnProfile: boolean;
/** From `useProfile`, to put a playlist made here first. */
setPlaylists: (fn: (items: Playlist[]) => Playlist[]) => void;
}
/**
* Renders the first page of the playlists, linking to the full page. The
* owner also gets a "New playlist" button.
*/
export function ProfilePlaylistsTab(
{ username, playlists, isOwnProfile, setPlaylists }: ProfilePlaylistsTabProps,
): React.JSX.Element {
return (
<ProfileSection
title={
<Trans>
Playlists ({playlists.items.length}
{playlists.hasMore ? "+" : ""})
</Trans>
}
actions={isOwnProfile && (
<NewPlaylistButton
onCreated={(p) => setPlaylists((items) => withFirst(items, p))}
/>
)}
viewAllHref={playlists.items.length > 0
? `/users/${username}/playlists`
: undefined}
>
{playlists.items.length === 0
? (
<p className="empty-state">
<Trans>No playlists yet.</Trans>
</p>
)
: <PlaylistCardList playlists={playlists.items} />}
</ProfileSection>
);
}

View File

@@ -0,0 +1,48 @@
/**
* Renders a titled section of a profile page.
*
* @module
*/
import type { ReactNode } from "react";
import { Link } from "react-router";
import { Trans } from "@lingui/react/macro";
/**
* Passed to {@link ProfileSection} by the profile's tabs and by
* `UserPlaylists`.
*/
export interface ProfileSectionProps {
title: ReactNode;
/** Buttons at the end of the title row. */
actions?: ReactNode;
/**
* The page with the whole list, of which the section shows the first page.
* The profile leaves it out while the list is empty.
*/
viewAllHref?: string;
children: ReactNode;
}
/**
* Renders a `.profile-section`: its title row, `children`, then a
* "View all →" link to `viewAllHref`.
*/
export function ProfileSection(
{ title, actions, viewAllHref, children }: ProfileSectionProps,
): React.JSX.Element {
return (
<section className="profile-section">
<div className="profile-section-header">
<h2 className="profile-section-title">{title}</h2>
{actions}
</div>
{children}
{viewAllHref && (
<Link to={viewAllHref} className="profile-view-all">
<Trans>View all →</Trans>
</Link>
)}
</section>
);
}

View File

@@ -0,0 +1,128 @@
/**
* The "settings" tab of the signed-in user's own profile: their password, and
* this browser's preferences.
*
* @module
*/
import { useState } from "react";
import { Trans } from "@lingui/react/macro";
import type {
ColorScheme,
StyleName,
} from "../../contexts/theme/ThemeContext.ts";
import type { FeedTab } from "../../config/feedTabs.ts";
import { useTheme } from "../../hooks/theme/useTheme.ts";
import { useDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts";
import { useSoundsEnabled } from "../../hooks/shared/useSoundsEnabled.ts";
import { previewChime } from "../../utils/shared/sound.ts";
import { Segmented, type SegmentedOption } from "../shared/Segmented.tsx";
import { ChangePasswordModal } from "./ChangePasswordModal.tsx";
import { ProfileSection } from "./ProfileSection.tsx";
import { SettingRow } from "./SettingRow.tsx";
// The styles' names are their own, in every language.
const STYLE_OPTIONS: SegmentedOption<StyleName>[] = [
{ value: "smooth", label: "Smooth" },
{ value: "brutalist", label: "Brutalist" },
{ value: "geocities", label: "GeoCities" },
{ value: "nyt", label: "Times" },
{ value: "kanagawa", label: "Kanagawa" },
{ value: "melange", label: "Melange" },
];
const COLOR_SCHEME_OPTIONS: SegmentedOption<ColorScheme>[] = [
{ value: "auto", label: <Trans>Auto</Trans> },
{ value: "light", label: <Trans>Light</Trans> },
{ value: "dark", label: <Trans>Dark</Trans> },
];
const FEED_TAB_OPTIONS: SegmentedOption<FeedTab>[] = [
{ value: "hot", label: <Trans>Hot</Trans> },
{ value: "new", label: <Trans>New</Trans> },
{ value: "journal", label: <Trans>Journal</Trans> },
{ value: "followed", label: <Trans>Followed</Trans> },
];
const ON_OFF_OPTIONS: SegmentedOption<boolean>[] = [
{ value: true, label: <Trans>On</Trans> },
{ value: false, label: <Trans>Off</Trans> },
];
/**
* Renders the password change, which opens {@link ChangePasswordModal}, and
* the style, color scheme, default feed tab and sounds, each stored for this
* browser as soon as it is clicked. `UserPublicProfile` renders it on one's
* own profile only.
*/
export function ProfileSettingsTab(): React.JSX.Element {
const { style, colorScheme, setStyle, setColorScheme } = useTheme();
const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab();
const [soundsEnabled, setSoundsEnabled] = useSoundsEnabled();
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
return (
<>
<ProfileSection title={<Trans>Account</Trans>}>
<div className="profile-appearance-grid">
<SettingRow label={<Trans>Password</Trans>}>
<button
type="button"
className="btn btn--ghost"
onClick={() => setChangePasswordOpen(true)}
>
<Trans>Change password…</Trans>
</button>
</SettingRow>
</div>
</ProfileSection>
<ProfileSection title={<Trans>Appearance</Trans>}>
<div className="profile-appearance-grid">
<SettingRow label={<Trans>Style</Trans>}>
<Segmented
options={STYLE_OPTIONS}
value={style}
onChange={setStyle}
/>
</SettingRow>
<SettingRow label={<Trans>Color scheme</Trans>}>
<Segmented
options={COLOR_SCHEME_OPTIONS}
value={colorScheme}
onChange={setColorScheme}
/>
</SettingRow>
</div>
</ProfileSection>
<ProfileSection title={<Trans>Feeds</Trans>}>
<div className="profile-appearance-grid">
<SettingRow label={<Trans>Default tab</Trans>}>
<Segmented
options={FEED_TAB_OPTIONS}
value={defaultFeedTab}
onChange={setDefaultFeedTab}
/>
</SettingRow>
</div>
</ProfileSection>
<ProfileSection title={<Trans>Notifications</Trans>}>
<div className="profile-appearance-grid">
<SettingRow label={<Trans>Sounds</Trans>}>
<Segmented
options={ON_OFF_OPTIONS}
value={soundsEnabled}
onChange={(on) => {
setSoundsEnabled(on);
// Let the user hear what they just turned on.
if (on) previewChime("notify");
}}
/>
</SettingRow>
</div>
</ProfileSection>
{changePasswordOpen && (
<ChangePasswordModal onClose={() => setChangePasswordOpen(false)} />
)}
</>
);
}

View File

@@ -0,0 +1,95 @@
/**
* Shows a user's role on their profile, and lets those who hold `user:manage`
* change it.
*
* @module
*/
import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, Role } from "../../../shared/user.ts";
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
import { deserializePublicUser } from "../../utils/shared/deserialize.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { ErrorCard } from "../shared/ErrorCard.tsx";
import { Segmented, type SegmentedOption } from "../shared/Segmented.tsx";
import { SettingRow } from "./SettingRow.tsx";
const ROLE_OPTIONS: SegmentedOption<Role>[] = [
{ value: "user", label: <Trans>User</Trans> },
{ value: "moderator", label: <Trans>Moderator</Trans> },
{ value: "admin", label: <Trans>Admin</Trans> },
];
/**
* Renders the role badge next to the username in `ProfileHeader`, for
* moderators and admins only.
*/
export function RoleChip({ role }: { role: Role }): React.JSX.Element | null {
if (role === "user") return null;
const label = ROLE_OPTIONS.find((o) => o.value === role)?.label;
return (
<span className={`profile-role-chip profile-role-chip--${role}`}>
{label}
</span>
);
}
/**
* Passed to {@link RoleSwitcher} by `ProfileManageTab`, which shows it only to
* those who hold `user:manage`, on another user's profile.
*/
export interface RoleSwitcherProps {
userId: string;
currentRole: Role;
/** Called with the role the server kept. */
onSaved: (role: Role) => void;
}
/**
* Renders the user's role as a {@link Segmented} whose clicks save it to
* `PATCH /api/users/:id/role`, which checks the permission again.
*/
export function RoleSwitcher(
{ userId, currentRole, onSaved }: RoleSwitcherProps,
): React.JSX.Element {
const { authFetch } = useAuth();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function changeRole(role: Role) {
if (role === currentRole || saving) return;
setSaving(true);
setError(null);
try {
const res = await authFetch(`${API_URL}/api/users/${userId}/role`, {
method: "PATCH",
body: JSON.stringify({ role }),
});
onSaved(
deserializePublicUser(await expectOk<Wire<PublicUser>>(res)).role,
);
} catch (err) {
setError(friendlyFetchError(err));
} finally {
setSaving(false);
}
}
return (
<>
<SettingRow label={<Trans>Role</Trans>}>
<Segmented
options={ROLE_OPTIONS}
value={currentRole}
onChange={changeRole}
disabled={saving}
/>
</SettingRow>
{error && <ErrorCard title={t`Failed to update role`} message={error} />}
</>
);
}

View File

@@ -0,0 +1,31 @@
/**
* Renders one labelled setting of the profile's "settings" and "manage" tabs.
*
* @module
*/
import type { ReactNode } from "react";
/**
* Passed to {@link SettingRow} by `ProfileSettingsTab` and `RoleSwitcher`.
*/
export interface SettingRowProps {
label: ReactNode;
/** The control, usually a `Segmented`. */
children: ReactNode;
}
/**
* Renders `label` beside `children`, as a row of the
* `.profile-appearance-grid` its caller wraps the rows in.
*/
export function SettingRow(
{ label, children }: SettingRowProps,
): React.JSX.Element {
return (
<div className="profile-appearance-row">
<span className="profile-appearance-label">{label}</span>
{children}
</div>
);
}