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.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
103
src/components/profile/DescriptionEditor.tsx
Normal file
103
src/components/profile/DescriptionEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
src/components/profile/EmailEditor.tsx
Normal file
90
src/components/profile/EmailEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
src/components/profile/ProfileDescription.tsx
Normal file
91
src/components/profile/ProfileDescription.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
src/components/profile/ProfileDumpsTab.tsx
Normal file
71
src/components/profile/ProfileDumpsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
src/components/profile/ProfileFollowedTab.tsx
Normal file
119
src/components/profile/ProfileFollowedTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
src/components/profile/ProfileHeader.tsx
Normal file
182
src/components/profile/ProfileHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
src/components/profile/ProfileInviteesTab.tsx
Normal file
90
src/components/profile/ProfileInviteesTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/profile/ProfileManageTab.tsx
Normal file
55
src/components/profile/ProfileManageTab.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
119
src/components/profile/ProfileOwnActions.tsx
Normal file
119
src/components/profile/ProfileOwnActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/components/profile/ProfilePlaylistsTab.tsx
Normal file
57
src/components/profile/ProfilePlaylistsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/profile/ProfileSection.tsx
Normal file
48
src/components/profile/ProfileSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
src/components/profile/ProfileSettingsTab.tsx
Normal file
128
src/components/profile/ProfileSettingsTab.tsx
Normal 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)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
95
src/components/profile/RoleSwitcher.tsx
Normal file
95
src/components/profile/RoleSwitcher.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
src/components/profile/SettingRow.tsx
Normal file
31
src/components/profile/SettingRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user