v4: every page and API URL follows one of two rules, written down in API.md — pages are a hierarchy where @name is a user and any other root word a category or an app page, tabs and views are plain segments and a default view is the bare path; API resources are /api/<plural>/<key>, a user's key being an id or @name, the caller's things under /api/me, media a sub-resource, lookups by another key a query filter. ~ is gone: the feed is /, /hot, /new, /journal, /follow and /music/hot, a profile /@bob with its overview and the tabs dumps, upvoted, playlists (her own only), follow (users, and /follow/playlists), invitees and manage (role and name, for user:manage), which replace the separate UserDumps, UserUpvoted and UserPlaylists pages; /settings holds the account (now with the username) and /manage the category manager, both linked from the user menu and one's profile. Dumps and playlists live under their owner, /@bob/dumps/<slug> and /@bob/playlists/<slug>, named by slug alone: migration 0021 re-slugs them to the bare slugify(title), unique per owner and kind (the oldest keeps it, clashes get -2), renames the categories whose slug v4 reserves (hot, follow, settings…), and rewrites this site's v3 links in dump notes, comments, chat, and playlist and profile descriptions. A slug changes only when edited, through the edit forms' new link field, which proposes slugify(title) as the title changes; a username changes from /settings or, for user:manage, from /@name/manage. Every slug or name left behind stays an alias of its item or user for good (dump_slug_aliases, playlist_slug_aliases, user_aliases), so old links redirect without chaining and nobody else can take one; slugs change 3 times a day per item, names once in 30 days, moderators exempt. An alias, or a name in another case, answers the item with its current path: the pages put it in their URL (useCanonicalPath, without fetching again), and the OG middleware answers a 301. shared/paths.ts builds and parses every page path, for the client's links, the emails' (digest-service lost its own builders and its id fallbacks, and links /follow and /settings) and the server's matchers (OG tags, self previews, backlinks, self-url); shared/slug.ts holds slugify and the format categories share. Dumps and playlists carry ownerUsername and a required slug, and notifications join their users' current names and their items' paths each time they are sent or read, so renames reach them and a deleted item's has no link. The API was renamed per the plan's table: POST /api/users and /api/sessions, /api/password-resets, /api/me (with settings, password, avatar, media-token, playlists?contains=, follow, feed?from=, notifications), /api/users/:key/{dumps,upvoted,playlists,follow/users,follow/playlists,invitees,avatar} and by path /api/users/:key/{dumps,playlists}/:slug, /api/dumps?url= and ?category=, /api/dumps/:id/{file,thumbnail,metadata}, /api/playlists/:id/cover, /api/remote/{preview,image,bandcamp,youtube} (one router), and /api/unsubscribe, the v3 POST /api/users/unsubscribe staying for the digests already sent. A stored default feed tab "followed" reads as "follow" once. dumps.slug and playlists.slug stay nullable, since SQLite can't add NOT NULL without rebuilding tables others reference; every writer sets them and unique (user_id, slug) indexes hold them. 254 backend tests (new: migration 0021 on a v3 fixture, slug aliases, swaps and rate limits, username renames, aliases and limits, reads by path and their privacy, the OG 301s, both unsubscribe paths, v4-shaped digest links with no ids, notification joins, shared paths and slugs) and 668 frontend tests (new: the profile's tabs and follow views, Settings, Manage, the link field, canonical URLs on the dump, playlist and profile pages, bare default feed paths) pass, and tsc, eslint, deno check, deno lint, deno fmt and doc:lint are clean; the migration was also run on a copy of the development database.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* Renders the password change dialog, which the settings tab of one's own
|
||||
* profile opens.
|
||||
* Renders the password change dialog, which the settings page opens.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -53,8 +52,8 @@ export function ChangePasswordModal(
|
||||
// The server revokes every token issued before the change, this session's
|
||||
// included, and hands back fresh ones. Other tabs read them from storage.
|
||||
const tokens = await expectOk<{ token: string; mediaToken: string }>(
|
||||
await authFetch(`${API_URL}/api/users/me/change-password`, {
|
||||
method: "POST",
|
||||
await authFetch(`${API_URL}/api/me/password`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
}),
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface DescriptionEditorProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the description's Markdown editor, saved to `PATCH /api/users/me`.
|
||||
* Renders the description's Markdown editor, saved to `PATCH /api/me`.
|
||||
* Other browsers see the change through the socket's `user_updated` event.
|
||||
*/
|
||||
export function DescriptionEditor(
|
||||
@@ -48,7 +48,7 @@ export function DescriptionEditor(
|
||||
// Sent even when empty, since the server keeps the old one when it is left
|
||||
// out.
|
||||
await expectOk<Wire<PublicUser>>(
|
||||
await authFetch(`${API_URL}/api/users/me`, {
|
||||
await authFetch(`${API_URL}/api/me`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface EmailEditorProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the email field, saved to `PATCH /api/users/me`. Escape cancels.
|
||||
* Renders the email field, saved to `PATCH /api/me`. Escape cancels.
|
||||
*/
|
||||
export function EmailEditor(
|
||||
{ initialEmail, onCancel, onSaved }: EmailEditorProps,
|
||||
@@ -42,7 +42,7 @@ export function EmailEditor(
|
||||
|
||||
const onSubmit = form.submit(async ({ email }) => {
|
||||
await expectOk<Wire<PublicUser>>(
|
||||
await authFetch(`${API_URL}/api/users/me`, {
|
||||
await authFetch(`${API_URL}/api/me`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
|
||||
@@ -1,72 +1,70 @@
|
||||
/**
|
||||
* Renders the "dumps" tab of a profile, which shows the user's dumps beside
|
||||
* those they upvoted.
|
||||
* Renders a profile's "dumps" or "upvoted" tab, the whole list of the user's
|
||||
* dumps or of 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 type { PublicUser } from "../../../shared/user.ts";
|
||||
import { canLoadMore } from "../../utils/feed/pagedList.ts";
|
||||
import {
|
||||
type UserDumpList,
|
||||
useUserDumpFeed,
|
||||
} from "../../hooks/dump/useUserDumpFeed.ts";
|
||||
import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts";
|
||||
import { DumpCardList } from "../dump/DumpCardList.tsx";
|
||||
import { NewDumpButton } from "../dump/NewDumpButton.tsx";
|
||||
import { FeedFooter } from "../feed/FeedFooter.tsx";
|
||||
import { ListState } from "../shared/ListState.tsx";
|
||||
import { ProfileSection } from "./ProfileSection.tsx";
|
||||
|
||||
/** Passed to {@link ProfileDumpsTab} by `UserPublicProfile`. */
|
||||
export interface ProfileDumpsTabProps {
|
||||
username: string;
|
||||
dumps: PagedList<Dump>;
|
||||
votes: PagedList<Dump>;
|
||||
user: PublicUser;
|
||||
list: UserDumpList;
|
||||
isOwnProfile: boolean;
|
||||
/** It comes 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.
|
||||
* Renders the list from `useUserDumpFeed`, its next page loading as its end
|
||||
* scrolls into view. The owner's own dumps get a "New dump" button.
|
||||
*/
|
||||
export function ProfileDumpsTab(
|
||||
{ username, dumps, votes, isOwnProfile, upvotedCardClassName }:
|
||||
ProfileDumpsTabProps,
|
||||
{ user, list, isOwnProfile }: ProfileDumpsTabProps,
|
||||
): React.JSX.Element {
|
||||
const { state, loadMore, cardClassName } = useUserDumpFeed(user.id, list, {
|
||||
isOwner: isOwnProfile,
|
||||
});
|
||||
const loaded = state.status === "loaded" ? state.data : null;
|
||||
const sentinelRef = useInfiniteScroll(loadMore, canLoadMore(loaded));
|
||||
|
||||
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}
|
||||
<ProfileSection
|
||||
title={list === "dumps" ? t`Dumps` : t`Upvoted`}
|
||||
actions={list === "dumps" && isOwnProfile && <NewDumpButton />}
|
||||
>
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={({ data }) => data.items.length === 0}
|
||||
empty={
|
||||
<p className="empty-state">
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{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}
|
||||
{({ data }) => (
|
||||
<>
|
||||
<DumpCardList dumps={data.items} cardClassName={cardClassName} />
|
||||
<FeedFooter
|
||||
hasMore={data.hasMore}
|
||||
loadingMore={data.loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
)}
|
||||
</ProfileSection>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ListState>
|
||||
</ProfileSection>
|
||||
);
|
||||
}
|
||||
|
||||
179
src/components/profile/ProfileFollowTab.tsx
Normal file
179
src/components/profile/ProfileFollowTab.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Renders a profile's "follow" tab, which shows the users the user follows at
|
||||
* `/@name/follow` and the playlists at `/@name/follow/playlists`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import type { Wire } from "../../../shared/api.ts";
|
||||
import type { Playlist } from "../../../shared/playlist.ts";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
import { type FollowView, userPath } from "../../../shared/paths.ts";
|
||||
import { canLoadMore, pageUrl, readPage } from "../../utils/feed/pagedList.ts";
|
||||
import {
|
||||
deserializePlaylist,
|
||||
deserializePublicUser,
|
||||
} from "../../utils/shared/deserialize.ts";
|
||||
import { usePagedList } from "../../hooks/feed/usePagedList.ts";
|
||||
import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts";
|
||||
import { usePlaylistListSync } from "../../hooks/playlist/usePlaylistListSync.ts";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
import { ListState } from "../shared/ListState.tsx";
|
||||
import { TabBar } from "../shared/TabBar.tsx";
|
||||
import { FeedFooter } from "../feed/FeedFooter.tsx";
|
||||
import { FollowUserButton } from "../social/FollowButton.tsx";
|
||||
import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx";
|
||||
import { ProfileSection } from "./ProfileSection.tsx";
|
||||
|
||||
/** Passed to {@link ProfileFollowTab} by `UserPublicProfile`. */
|
||||
export interface ProfileFollowTabProps {
|
||||
user: PublicUser;
|
||||
/** The view the path names, after `/follow`. */
|
||||
view: FollowView;
|
||||
}
|
||||
|
||||
/** 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={userPath(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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns page after page of what `userId` follows, from
|
||||
* `GET /api/users/:userId/follow/<view>`, and the sentinel that loads the
|
||||
* next.
|
||||
*/
|
||||
function useFollowList<T>(
|
||||
userId: string,
|
||||
view: FollowView,
|
||||
deserialize: (wire: Wire<T>) => T,
|
||||
) {
|
||||
const url = `${API_URL}/api/users/${userId}/follow/${view}`;
|
||||
const paged = usePagedList<T>(
|
||||
url,
|
||||
useCallback(
|
||||
(page: number, signal?: AbortSignal) =>
|
||||
fetch(pageUrl(url, page), { signal })
|
||||
.then((res) => readPage(res, deserialize)),
|
||||
[url, deserialize],
|
||||
),
|
||||
);
|
||||
const loaded = paged.state.status === "loaded" ? paged.state.data : null;
|
||||
const sentinelRef = useInfiniteScroll(paged.loadMore, canLoadMore(loaded));
|
||||
return { ...paged, sentinelRef };
|
||||
}
|
||||
|
||||
/** Renders the users `userId` follows, the latest followed first. */
|
||||
function FollowedUsers({ userId }: { userId: string }) {
|
||||
const { state, sentinelRef } = useFollowList(
|
||||
userId,
|
||||
"users",
|
||||
deserializePublicUser,
|
||||
);
|
||||
return (
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={({ data }) => data.items.length === 0}
|
||||
empty={
|
||||
<p className="empty-state">
|
||||
<Trans>Not following anyone yet.</Trans>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{({ data }) => (
|
||||
<>
|
||||
<ul className="followed-user-list">
|
||||
{data.items.map((u) => <FollowedUserCard key={u.id} user={u} />)}
|
||||
</ul>
|
||||
<FeedFooter
|
||||
hasMore={data.hasMore}
|
||||
loadingMore={data.loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ListState>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the public playlists `userId` follows, which the socket keeps
|
||||
* current but never adds to.
|
||||
*/
|
||||
function FollowedPlaylists({ userId }: { userId: string }) {
|
||||
const { state, setItems, sentinelRef } = useFollowList<Playlist>(
|
||||
userId,
|
||||
"playlists",
|
||||
deserializePlaylist,
|
||||
);
|
||||
usePlaylistListSync(setItems, { noNewEntries: true });
|
||||
return (
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={({ data }) => data.items.length === 0}
|
||||
empty={
|
||||
<p className="empty-state">
|
||||
<Trans>No followed playlists yet.</Trans>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{({ data }) => (
|
||||
<>
|
||||
<PlaylistCardList playlists={data.items} />
|
||||
<FeedFooter
|
||||
hasMore={data.hasMore}
|
||||
loadingMore={data.loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ListState>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the two views under a row of tabs that switch between their paths,
|
||||
* each view loading its list when first shown.
|
||||
*/
|
||||
export function ProfileFollowTab(
|
||||
{ user, view }: ProfileFollowTabProps,
|
||||
): React.JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<ProfileSection title={<Trans>Followed</Trans>}>
|
||||
<TabBar
|
||||
tabs={[
|
||||
{ key: "users" as FollowView, label: <Trans>Users</Trans> },
|
||||
{ key: "playlists" as FollowView, label: <Trans>Playlists</Trans> },
|
||||
]}
|
||||
activeTab={view}
|
||||
onChange={(v) =>
|
||||
navigate(userPath(user.username, "follow", v), { replace: true })}
|
||||
innerClassName="followed-sub-nav"
|
||||
/>
|
||||
{view === "users"
|
||||
? <FollowedUsers userId={user.id} />
|
||||
: <FollowedPlaylists userId={user.id} />}
|
||||
</ProfileSection>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Renders the "followed" tab of a profile, which shows 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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { can } from "../../utils/auth/permissions.ts";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
@@ -22,6 +23,7 @@ import { FollowUserButton } from "../social/FollowButton.tsx";
|
||||
import { EmailEditor } from "./EmailEditor.tsx";
|
||||
import { ProfileOwnActions } from "./ProfileOwnActions.tsx";
|
||||
import { RoleChip } from "./RoleSwitcher.tsx";
|
||||
import { userPath } from "../../../shared/paths.ts";
|
||||
|
||||
/** Passed to {@link ProfileHeader} by `UserPublicProfile`, once loaded. */
|
||||
export interface ProfileHeaderProps {
|
||||
@@ -34,7 +36,8 @@ export interface ProfileHeaderProps {
|
||||
/**
|
||||
* 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.
|
||||
* (`PUT /api/me/avatar`) and email, invite someone, reach their settings (and
|
||||
* the site's tools, with `category:manage`) and log out.
|
||||
*/
|
||||
export function ProfileHeader(
|
||||
{ user, isOwnProfile, onUserChange }: ProfileHeaderProps,
|
||||
@@ -54,8 +57,8 @@ export function ProfileHeader(
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const res = await authFetch(`${API_URL}/api/avatars/me`, {
|
||||
method: "POST",
|
||||
const res = await authFetch(`${API_URL}/api/me/avatar`, {
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
});
|
||||
const body = parseAPIResponse<Wire<SelfUser>>(await res.json());
|
||||
@@ -114,7 +117,7 @@ export function ProfileHeader(
|
||||
<p className="profile-invited-by">
|
||||
<Trans>invited by</Trans>{" "}
|
||||
<Link
|
||||
to={`/users/${user.invitedByUsername}`}
|
||||
to={userPath(user.invitedByUsername)}
|
||||
className="profile-invited-by-link"
|
||||
>
|
||||
@{user.invitedByUsername}
|
||||
@@ -170,6 +173,14 @@ export function ProfileHeader(
|
||||
)}
|
||||
{isOwnProfile && (
|
||||
<ProfileOwnActions>
|
||||
<Link to="/settings" className="btn-border">
|
||||
<Trans>Settings</Trans>
|
||||
</Link>
|
||||
{can(me, "category:manage") && (
|
||||
<Link to="/manage" className="btn-border">
|
||||
<Trans>Manage</Trans>
|
||||
</Link>
|
||||
)}
|
||||
<button type="button" className="btn-border" onClick={logout}>
|
||||
<Trans>Log out</Trans>
|
||||
</button>
|
||||
|
||||
@@ -13,6 +13,7 @@ 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";
|
||||
import { userPath } from "../../../shared/paths.ts";
|
||||
|
||||
type InviteTreeNode = Invitee & { children: InviteTreeNode[] };
|
||||
|
||||
@@ -36,7 +37,7 @@ function InviteTreeList({ nodes }: { nodes: InviteTreeNode[] }) {
|
||||
{nodes.map((node) => (
|
||||
<li key={node.id} className="invite-tree-node">
|
||||
<Link
|
||||
to={`/users/${node.username}`}
|
||||
to={userPath(node.username)}
|
||||
className="invite-tree-user"
|
||||
>
|
||||
<Avatar
|
||||
@@ -59,7 +60,7 @@ export interface ProfileInviteesTabProps {
|
||||
userId: string;
|
||||
/**
|
||||
* Loaded when the tab is first opened, from
|
||||
* `GET /api/users/:username/invitees`.
|
||||
* `GET /api/users/@:username/invitees`.
|
||||
*/
|
||||
state: FetchState<Invitee[]>;
|
||||
}
|
||||
|
||||
@@ -1,55 +1,77 @@
|
||||
/**
|
||||
* Renders the "manage" tab of a profile, which holds tools for another user on
|
||||
* their profile and site-wide tools on one's own.
|
||||
* Renders a profile's "manage" tab, `/@name/manage`, where those who hold
|
||||
* `user:manage` change another user's role and name. The site's own tools are
|
||||
* the `/manage` page's.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { PublicUser, Role } from "../../../shared/user.ts";
|
||||
import { CategoryManager } from "../category/CategoryManager.tsx";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import type { Wire } from "../../../shared/api.ts";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
import { expectOk } from "../../utils/shared/api.ts";
|
||||
import { deserializePublicUser } from "../../utils/shared/deserialize.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { ProfileSection } from "./ProfileSection.tsx";
|
||||
import { RoleSwitcher } from "./RoleSwitcher.tsx";
|
||||
import { SettingRow } from "./SettingRow.tsx";
|
||||
import { UsernameField } from "./UsernameField.tsx";
|
||||
|
||||
/**
|
||||
* Passed to {@link ProfileManageTab} by `UserPublicProfile`, which shows the
|
||||
* tab only when one of its sections applies.
|
||||
* tab on another user's profile to those who hold `user:manage`.
|
||||
*/
|
||||
export interface ProfileManageTabProps {
|
||||
user: PublicUser;
|
||||
isOwnProfile: boolean;
|
||||
canManageUsers: boolean;
|
||||
canManageCategories: boolean;
|
||||
onRoleSaved: (role: Role) => void;
|
||||
/** Called with the user as the server left them. */
|
||||
onUserSaved: (user: PublicUser) => 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.
|
||||
* Renders the user's role and name. A rename here has no rate limit, and the
|
||||
* profile's URL then takes the new name.
|
||||
*/
|
||||
export function ProfileManageTab(
|
||||
{ user, isOwnProfile, canManageUsers, canManageCategories, onRoleSaved }:
|
||||
ProfileManageTabProps,
|
||||
{ user, onUserSaved }: ProfileManageTabProps,
|
||||
): React.JSX.Element {
|
||||
const { authFetch } = useAuth();
|
||||
|
||||
async function rename(username: string): Promise<string> {
|
||||
const renamed = deserializePublicUser(
|
||||
await expectOk<Wire<PublicUser>>(
|
||||
await authFetch(`${API_URL}/api/users/${user.id}/username`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ username }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
onUserSaved(renamed);
|
||||
return renamed.username;
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
<ProfileSection title={<Trans>User management</Trans>}>
|
||||
<div className="profile-appearance-grid">
|
||||
<RoleSwitcher
|
||||
userId={user.id}
|
||||
currentRole={user.role}
|
||||
onSaved={(role) => onUserSaved({ ...user, role })}
|
||||
/>
|
||||
<SettingRow label={<Trans>Username</Trans>}>
|
||||
<UsernameField
|
||||
key={user.username}
|
||||
username={user.username}
|
||||
save={rename}
|
||||
hint={
|
||||
<Trans>
|
||||
Their old name keeps leading to them, and nobody else can take
|
||||
it.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</ProfileSection>
|
||||
);
|
||||
}
|
||||
|
||||
91
src/components/profile/ProfileOverviewTab.tsx
Normal file
91
src/components/profile/ProfileOverviewTab.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Renders a profile's overview, its bare `/@name`, which shows the first page
|
||||
* of the user's dumps beside those they upvoted.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
import { userPath } from "../../../shared/paths.ts";
|
||||
import { useUserDumpFeed } from "../../hooks/dump/useUserDumpFeed.ts";
|
||||
import { DumpCardList } from "../dump/DumpCardList.tsx";
|
||||
import { NewDumpButton } from "../dump/NewDumpButton.tsx";
|
||||
import { ListState } from "../shared/ListState.tsx";
|
||||
import { ProfileSection } from "./ProfileSection.tsx";
|
||||
|
||||
/** Passed to {@link ProfileOverviewTab} by `UserPublicProfile`. */
|
||||
export interface ProfileOverviewTabProps {
|
||||
user: PublicUser;
|
||||
isOwnProfile: boolean;
|
||||
}
|
||||
|
||||
/** Counts a first page, with a `+` when there are more. */
|
||||
function countOf(state: ReturnType<typeof useUserDumpFeed>["state"]) {
|
||||
return state.status === "loaded"
|
||||
? `${state.data.items.length}${state.data.hasMore ? "+" : ""}`
|
||||
: "…";
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the first page of both lists, from `useUserDumpFeed`, each linking
|
||||
* to its tab. The owner also gets a "New dump" button.
|
||||
*/
|
||||
export function ProfileOverviewTab(
|
||||
{ user, isOwnProfile }: ProfileOverviewTabProps,
|
||||
): React.JSX.Element {
|
||||
const dumps = useUserDumpFeed(user.id, "dumps", { isOwner: isOwnProfile });
|
||||
const votes = useUserDumpFeed(user.id, "upvoted", {
|
||||
isOwner: isOwnProfile,
|
||||
source: dumps.state.status === "loaded"
|
||||
? dumps.state.data.items
|
||||
: undefined,
|
||||
});
|
||||
const empty = (
|
||||
<p className="empty-state">
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
</p>
|
||||
);
|
||||
const hasItems = (s: typeof dumps.state) =>
|
||||
s.status === "loaded" && s.data.items.length > 0;
|
||||
|
||||
return (
|
||||
<div className="profile-columns">
|
||||
<ProfileSection
|
||||
title={t`Dumps (${countOf(dumps.state)})`}
|
||||
actions={isOwnProfile && <NewDumpButton />}
|
||||
viewAllHref={hasItems(dumps.state)
|
||||
? userPath(user.username, "dumps")
|
||||
: undefined}
|
||||
>
|
||||
<ListState
|
||||
state={dumps.state}
|
||||
isEmpty={({ data }) => data.items.length === 0}
|
||||
empty={empty}
|
||||
>
|
||||
{({ data }) => <DumpCardList dumps={data.items} />}
|
||||
</ListState>
|
||||
</ProfileSection>
|
||||
<ProfileSection
|
||||
title={t`Upvoted (${countOf(votes.state)})`}
|
||||
viewAllHref={hasItems(votes.state)
|
||||
? userPath(user.username, "upvoted")
|
||||
: undefined}
|
||||
>
|
||||
<ListState
|
||||
state={votes.state}
|
||||
isEmpty={({ data }) => data.items.length === 0}
|
||||
empty={empty}
|
||||
>
|
||||
{({ data }) => (
|
||||
<DumpCardList
|
||||
dumps={data.items}
|
||||
cardClassName={votes.cardClassName}
|
||||
/>
|
||||
)}
|
||||
</ListState>
|
||||
</ProfileSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,166 @@
|
||||
/**
|
||||
* Renders the "playlists" tab of a profile, which shows the playlists the user
|
||||
* made.
|
||||
* Renders a profile's "playlists" tab, the playlists the user made. Those they
|
||||
* follow are the "follow" tab's.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import type { Playlist } from "../../../shared/playlist.ts";
|
||||
import type { PagedList } from "../../utils/feed/pagedList.ts";
|
||||
import { withFirst } from "../../utils/feed/pagedList.ts";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
import {
|
||||
canLoadMore,
|
||||
pageUrl,
|
||||
readPage,
|
||||
withFirst,
|
||||
} from "../../utils/feed/pagedList.ts";
|
||||
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
|
||||
import {
|
||||
deserializePlaylist,
|
||||
hydratePlaylist,
|
||||
} from "../../utils/shared/deserialize.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useWS } from "../../hooks/ws/useWS.ts";
|
||||
import { useFeedCache } from "../../hooks/feed/useFeedCache.ts";
|
||||
import { usePagedList } from "../../hooks/feed/usePagedList.ts";
|
||||
import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts";
|
||||
import { usePositionAwareSync } from "../../hooks/feed/usePositionAwareSync.ts";
|
||||
import {
|
||||
useScrollRestore,
|
||||
useScrollSave,
|
||||
} from "../../hooks/feed/useScrollSave.ts";
|
||||
import { usePlaylistListSync } from "../../hooks/playlist/usePlaylistListSync.ts";
|
||||
import { NewPlaylistButton } from "../playlist/NewPlaylistButton.tsx";
|
||||
import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx";
|
||||
import { FeedFooter } from "../feed/FeedFooter.tsx";
|
||||
import { ConfirmModal } from "../shared/ConfirmModal.tsx";
|
||||
import { ErrorCard } from "../shared/ErrorCard.tsx";
|
||||
import { ListState } from "../shared/ListState.tsx";
|
||||
import { ProfileSection } from "./ProfileSection.tsx";
|
||||
|
||||
/** Passed to {@link ProfilePlaylistsTab} by `UserPublicProfile`. */
|
||||
export interface ProfilePlaylistsTabProps {
|
||||
username: string;
|
||||
playlists: PagedList<Playlist>;
|
||||
user: PublicUser;
|
||||
isOwnProfile: boolean;
|
||||
/** It comes from `useProfile`, and puts 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.
|
||||
* Renders the playlists `user` made, from `GET /api/users/:userId/playlists`,
|
||||
* the private ones to their owner only, a page at a time as the list scrolls.
|
||||
* The socket keeps them live, and going back restores the list and the
|
||||
* scroll. The owner can make one, which goes first, and delete one.
|
||||
*/
|
||||
export function ProfilePlaylistsTab(
|
||||
{ username, playlists, isOwnProfile, setPlaylists }: ProfilePlaylistsTabProps,
|
||||
{ user, isOwnProfile }: ProfilePlaylistsTabProps,
|
||||
): React.JSX.Element {
|
||||
const { user: me, authFetch } = useAuth();
|
||||
const { lastPlaylistEvent } = useWS();
|
||||
const { cached, saveState } = useFeedCache<Playlist>(
|
||||
`feed:profile-playlists:${user.id}`,
|
||||
hydratePlaylist,
|
||||
);
|
||||
const url = `${API_URL}/api/users/${user.id}/playlists`;
|
||||
const { state, setItems, loadMore } = usePagedList<Playlist>(
|
||||
`${url}:${me?.id ?? "guest"}`,
|
||||
useCallback(
|
||||
(page: number, signal?: AbortSignal) =>
|
||||
authFetch(pageUrl(url, page), { signal })
|
||||
.then((res) => readPage(res, deserializePlaylist)),
|
||||
[authFetch, url],
|
||||
),
|
||||
cached,
|
||||
);
|
||||
const loaded = state.status === "loaded" ? state.data : null;
|
||||
|
||||
const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
|
||||
? (lastPlaylistEvent.playlist ?? null)
|
||||
: null;
|
||||
// Before usePlaylistListSync: it saves a playlist's place before the sync
|
||||
// takes it out, and puts it back there when it turns public again.
|
||||
usePositionAwareSync(
|
||||
loaded?.items ?? [],
|
||||
setItems,
|
||||
lastPlaylistItem,
|
||||
(p) => p.isPublic && p.userId === user.id,
|
||||
);
|
||||
usePlaylistListSync(setItems, { isOwner: isOwnProfile, ownerId: user.id });
|
||||
|
||||
const sentinelRef = useInfiniteScroll(loadMore, canLoadMore(loaded));
|
||||
useScrollSave(
|
||||
!!loaded,
|
||||
useCallback((y) => {
|
||||
if (loaded) saveState(loaded, y);
|
||||
}, [loaded, saveState]),
|
||||
);
|
||||
useScrollRestore(cached?.scrollY, !!loaded);
|
||||
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const handleDelete = async (playlistId: string) => {
|
||||
setDeleteError(null);
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) await expectOk(res); // throws the server's message
|
||||
} catch (err) {
|
||||
setDeleteError(friendlyFetchError(err));
|
||||
return;
|
||||
}
|
||||
setItems((items) => items.filter((p) => p.id !== playlistId));
|
||||
};
|
||||
|
||||
return (
|
||||
<ProfileSection
|
||||
title={
|
||||
<Trans>
|
||||
Playlists ({playlists.items.length}
|
||||
{playlists.hasMore ? "+" : ""})
|
||||
</Trans>
|
||||
}
|
||||
title={<Trans>Playlists</Trans>}
|
||||
actions={isOwnProfile && (
|
||||
<NewPlaylistButton
|
||||
onCreated={(p) => setPlaylists((items) => withFirst(items, p))}
|
||||
onCreated={(p) => setItems((items) => withFirst(items, p))}
|
||||
/>
|
||||
)}
|
||||
viewAllHref={playlists.items.length > 0
|
||||
? `/users/${username}/playlists`
|
||||
: undefined}
|
||||
>
|
||||
{playlists.items.length === 0
|
||||
? (
|
||||
{deleteError && (
|
||||
<ErrorCard title={t`Could not delete`} message={deleteError} />
|
||||
)}
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={({ data }) => data.items.length === 0}
|
||||
empty={
|
||||
<p className="empty-state">
|
||||
<Trans>No playlists yet.</Trans>
|
||||
</p>
|
||||
)
|
||||
: <PlaylistCardList playlists={playlists.items} />}
|
||||
}
|
||||
>
|
||||
{({ data }) => (
|
||||
<>
|
||||
<PlaylistCardList
|
||||
playlists={data.items}
|
||||
onDelete={isOwnProfile
|
||||
? (p) => setConfirmDeleteId(p.id)
|
||||
: undefined}
|
||||
/>
|
||||
<FeedFooter
|
||||
hasMore={data.hasMore}
|
||||
loadingMore={data.loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ListState>
|
||||
{confirmDeleteId && (
|
||||
<ConfirmModal
|
||||
message={t`Delete this playlist? This cannot be undone.`}
|
||||
confirmLabel={t`Delete playlist`}
|
||||
onConfirm={() => {
|
||||
handleDelete(confirmDeleteId);
|
||||
setConfirmDeleteId(null);
|
||||
}}
|
||||
onCancel={() => setConfirmDeleteId(null)}
|
||||
/>
|
||||
)}
|
||||
</ProfileSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import { Link } from "react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
/**
|
||||
* Passed to {@link ProfileSection} by the profile's tabs and by
|
||||
* `UserPlaylists`.
|
||||
* Passed to {@link ProfileSection} by the profile's tabs, and by the settings
|
||||
* and manage pages.
|
||||
*/
|
||||
export interface ProfileSectionProps {
|
||||
title: ReactNode;
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* Renders the "settings" tab of the signed-in user's own profile, where they
|
||||
* change their password and this browser's preferences.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
// A label shared with the emails is named by its id, its words being in
|
||||
// locales/ only; the macro's Trans takes the English in place.
|
||||
import { Trans as Message } from "@lingui/react";
|
||||
import type { ColorScheme, StyleName } from "../../../shared/theme.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 { useSettings } from "../../hooks/auth/useSettings.ts";
|
||||
import { EMAIL_ENABLED } from "../../config/instance.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 settings, each saved as soon as it is clicked. The style, color scheme
|
||||
* and digests are saved to the account; the default feed tab and sounds, for
|
||||
* now, to this browser. `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 { settings, updateSettings } = useSettings();
|
||||
// Until the session has the settings, a click would save over unknown ones.
|
||||
const digestsDisabled = !EMAIL_ENABLED || !settings;
|
||||
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>
|
||||
<ProfileSection title={<Trans>Email</Trans>}>
|
||||
<div className="profile-appearance-grid">
|
||||
<SettingRow label={<Message id="digest.weekly" />}>
|
||||
<Segmented
|
||||
options={ON_OFF_OPTIONS}
|
||||
value={settings?.digestWeekly ?? true}
|
||||
disabled={digestsDisabled}
|
||||
onChange={(on) =>
|
||||
updateSettings({ digestWeekly: on }).catch(() => {})}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label={<Message id="digest.monthly" />}>
|
||||
<Segmented
|
||||
options={ON_OFF_OPTIONS}
|
||||
value={settings?.digestMonthly ?? true}
|
||||
disabled={digestsDisabled}
|
||||
onChange={(on) =>
|
||||
updateSettings({ digestMonthly: on }).catch(() => {})}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
{!EMAIL_ENABLED && (
|
||||
<p className="form-hint profile-appearance-note">
|
||||
<Trans>
|
||||
This site doesn't send email, so no digest goes out.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</ProfileSection>
|
||||
{changePasswordOpen && (
|
||||
<ChangePasswordModal onClose={() => setChangePasswordOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Renders the header of a profile's full lists, `/users/:username/dumps`,
|
||||
* `/upvoted` and `/playlists`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
|
||||
/**
|
||||
* Passed to {@link ProfileSubpageHeader} by each of those pages, once it has
|
||||
* loaded the profile's user.
|
||||
*/
|
||||
export interface ProfileSubpageHeaderProps {
|
||||
/** The `:username` of the route, which the back link leads to. */
|
||||
username: string;
|
||||
profileUser: PublicUser;
|
||||
title: string;
|
||||
/** Buttons at the end of the title row. */
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a link back to the profile, then the page's title beside the user's
|
||||
* avatar.
|
||||
*/
|
||||
export function ProfileSubpageHeader(
|
||||
{ username, profileUser, title, actions }: ProfileSubpageHeaderProps,
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<div className="profile-subpage-header">
|
||||
<Link to={`/users/${username}`} className="profile-subpage-back">
|
||||
← {profileUser.username}
|
||||
</Link>
|
||||
<div className="profile-subpage-title-row">
|
||||
<Avatar
|
||||
userId={profileUser.id}
|
||||
username={profileUser.username}
|
||||
hasAvatar={!!profileUser.avatarMime}
|
||||
size={36}
|
||||
/>
|
||||
<h1 className="profile-subpage-title">{title}</h1>
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Renders one labelled setting of the profile's "settings" and "manage" tabs.
|
||||
* Renders one labelled setting of the settings page and the profile's
|
||||
* "manage" tab.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,7 +8,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Passed to {@link SettingRow} by `ProfileSettingsTab` and `RoleSwitcher`.
|
||||
* Passed to {@link SettingRow} by `Settings`, `ProfileManageTab` and
|
||||
* `RoleSwitcher`.
|
||||
*/
|
||||
export interface SettingRowProps {
|
||||
label: ReactNode;
|
||||
|
||||
85
src/components/profile/UsernameField.tsx
Normal file
85
src/components/profile/UsernameField.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Renders the field that renames a user, on the settings page for one's own
|
||||
* name and on a profile's "manage" tab for a moderator.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { USERNAME_RE, VALIDATION } from "../../../shared/validation.ts";
|
||||
import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
||||
import { FormError } from "../form/FormError.tsx";
|
||||
import { SubmitButton } from "../form/SubmitButton.tsx";
|
||||
|
||||
/** Passed to {@link UsernameField} by `Settings` and `ProfileManageTab`. */
|
||||
export interface UsernameFieldProps {
|
||||
username: string;
|
||||
/**
|
||||
* Sends the new name, to `PATCH /api/me` or
|
||||
* `PATCH /api/users/:key/username`, and resolves to the name the server
|
||||
* kept. A refusal rejects with its message, which the field shows.
|
||||
*/
|
||||
save: (username: string) => Promise<string>;
|
||||
/** Shown under the field, such as how often the name may change. */
|
||||
hint?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the name in a text input with a "Rename" button, which is enabled
|
||||
* once the name differs. The old name keeps leading to the user, as the hint
|
||||
* should say.
|
||||
*/
|
||||
export function UsernameField(
|
||||
{ username, save, hint }: UsernameFieldProps,
|
||||
): React.JSX.Element {
|
||||
const form = useApiForm<{ username: string }>({
|
||||
defaultValues: { username },
|
||||
});
|
||||
const draft = form.watch("username").trim();
|
||||
const error = form.formState.errors.username;
|
||||
|
||||
const onSubmit = form.submit(async ({ username: next }) => {
|
||||
form.reset({ username: await save(next.trim()) });
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form className="username-field" onSubmit={onSubmit}>
|
||||
<div className="username-field-row">
|
||||
<input
|
||||
type="text"
|
||||
className="username-field-input"
|
||||
aria-label={t`Username`}
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
maxLength={VALIDATION.USERNAME_MAX}
|
||||
aria-invalid={error ? true : undefined}
|
||||
{...form.register("username", {
|
||||
pattern: {
|
||||
value: USERNAME_RE,
|
||||
message: t`Letters, numbers and underscores only`,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<SubmitButton
|
||||
className="btn btn--ghost"
|
||||
pendingLabel={<Trans>Renaming…</Trans>}
|
||||
disabled={!draft || draft === username}
|
||||
>
|
||||
<Trans>Rename</Trans>
|
||||
</SubmitButton>
|
||||
</div>
|
||||
{error?.message && (
|
||||
<span className="form-hint form-hint--error">
|
||||
{String(error.message)}
|
||||
</span>
|
||||
)}
|
||||
{hint && <p className="form-hint">{hint}</p>}
|
||||
<FormError title={t`Could not rename`} />
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user