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:
khannurien
2026-10-06 16:08:59 +00:00
parent fd0d81fe66
commit 4468438ba7
228 changed files with 7320 additions and 5193 deletions

View File

@@ -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 }),
}),

View File

@@ -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(

View File

@@ -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(

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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[]>;
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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)} />
)}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View 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>
);
}