v4: every request about a user, and the dump editor's path, come from one builder each — fourteen places wrote /api/users/<key>/… by hand, some encoding a handle (@alice%40music.example) and some not, a slug never; userApiUrl (src/utils/shared/urls.ts) now writes them all, by the user's id or by @ and the handle a page's path gives, each segment encoded. The editor's path was the dump's page's with /edit added in two places; dumpPath takes the view, as userPath and feedPath do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -102,9 +102,13 @@ export function userPath(
|
||||
: `${base}/${tab}`;
|
||||
}
|
||||
|
||||
/** Returns the path of a dump's page, `/@owner/dumps/<slug>`. */
|
||||
export function dumpPath(dump: OwnedItem): string {
|
||||
return `${userPath(dump.ownerUsername)}/dumps/${seg(dump.slug)}`;
|
||||
/**
|
||||
* Returns the path of a dump's page, `/@owner/dumps/<slug>`, or of its
|
||||
* editor, one segment more.
|
||||
*/
|
||||
export function dumpPath(dump: OwnedItem, view?: "edit"): string {
|
||||
const path = `${userPath(dump.ownerUsername)}/dumps/${seg(dump.slug)}`;
|
||||
return view ? `${path}/${view}` : path;
|
||||
}
|
||||
|
||||
/** Returns the path of a playlist's page, `/@owner/playlists/<slug>`. */
|
||||
|
||||
@@ -20,6 +20,10 @@ Deno.test("builders: one hierarchy, default views bare", () => {
|
||||
const item = { ownerUsername: "bob", slug: "never-gonna" };
|
||||
assertEquals(dumpPath(item), "/@bob/dumps/never-gonna");
|
||||
assertEquals(playlistPath(item), "/@bob/playlists/never-gonna");
|
||||
assertEquals(
|
||||
dumpPath({ ownerUsername: "bob", slug: "a-b" }, "edit"),
|
||||
"/@bob/dumps/a-b/edit",
|
||||
);
|
||||
assertEquals(feedPath(), "/");
|
||||
assertEquals(feedPath(undefined, "hot"), "/hot");
|
||||
assertEquals(feedPath("music"), "/music");
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { userApiUrl } from "../../utils/shared/urls.ts";
|
||||
import type { Wire } from "../../../shared/api.ts";
|
||||
import type { Playlist } from "../../../shared/playlist.ts";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
@@ -45,7 +45,7 @@ function useFollowList<T>(
|
||||
view: FollowView,
|
||||
deserialize: (wire: Wire<T>) => T,
|
||||
) {
|
||||
const url = `${API_URL}/api/users/${userId}/follow/${view}`;
|
||||
const url = userApiUrl({ id: userId }, "follow", view);
|
||||
const paged = usePagedList<T>(
|
||||
url,
|
||||
useCallback(
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { userApiUrl } from "../../utils/shared/urls.ts";
|
||||
import type { Wire } from "../../../shared/api.ts";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
import { expectOk } from "../../utils/shared/api.ts";
|
||||
@@ -42,7 +42,7 @@ export function ProfileManageTab(
|
||||
async function rename(username: string): Promise<string> {
|
||||
const renamed = deserializePublicUser(
|
||||
await expectOk<Wire<PublicUser>>(
|
||||
await authFetch(`${API_URL}/api/users/${user.id}/username`, {
|
||||
await authFetch(userApiUrl(user, "username"), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ username }),
|
||||
}),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSearchParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { userApiUrl } from "../../utils/shared/urls.ts";
|
||||
import type { Playlist } from "../../../shared/playlist.ts";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
import {
|
||||
@@ -93,7 +94,7 @@ function PlaylistList(
|
||||
}`,
|
||||
hydratePlaylist,
|
||||
);
|
||||
const url = `${API_URL}/api/users/${user.id}/playlists${
|
||||
const url = `${userApiUrl(user, "playlists")}${
|
||||
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
|
||||
}`;
|
||||
const { state, setItems: setAllItems, loadMore } = usePagedList<Playlist>(
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { userApiUrl } from "../../utils/shared/urls.ts";
|
||||
import type { Wire } from "../../../shared/api.ts";
|
||||
import type { PublicUser, Role } from "../../../shared/user.ts";
|
||||
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
|
||||
@@ -65,7 +65,7 @@ export function RoleSwitcher(
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/users/${userId}/role`, {
|
||||
const res = await authFetch(userApiUrl({ id: userId }, "role"), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ role }),
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useContext, useState } from "react";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { userApiUrl } from "../../utils/shared/urls.ts";
|
||||
import { AvatarOverrideContext } from "../../contexts/ws/AvatarOverrideContext.ts";
|
||||
|
||||
/**
|
||||
@@ -51,8 +51,8 @@ export function Avatar(
|
||||
|
||||
if (effectiveHasAvatar && !imgFailed) {
|
||||
const src = effectiveVersion
|
||||
? `${API_URL}/api/users/${userId}/avatar?v=${effectiveVersion}`
|
||||
: `${API_URL}/api/users/${userId}/avatar`;
|
||||
? `${userApiUrl({ id: userId }, "avatar")}?v=${effectiveVersion}`
|
||||
: userApiUrl({ id: userId }, "avatar");
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { userApiUrl } from "../../utils/shared/urls.ts";
|
||||
import { pageUrl, readPage } from "../../utils/feed/pagedList.ts";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import {
|
||||
@@ -71,7 +71,7 @@ export function useUserDumpFeed(
|
||||
hydrateDump,
|
||||
);
|
||||
|
||||
const url = `${API_URL}/api/users/${userId}/${list}${
|
||||
const url = `${userApiUrl({ id: userId }, list)}${
|
||||
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
|
||||
}`;
|
||||
const paged = usePagedList<Dump>(
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
deserializePlaylistWithDumps,
|
||||
} from "../../utils/shared/deserialize.ts";
|
||||
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
|
||||
import { isAt, playlistUrl } from "../../utils/shared/urls.ts";
|
||||
import { isAt, playlistUrl, userApiUrl } from "../../utils/shared/urls.ts";
|
||||
import { useCanonicalPath } from "../shared/useCanonicalPath.ts";
|
||||
import { useAuth } from "../auth/useAuth.ts";
|
||||
import { useWS } from "../ws/useWS.ts";
|
||||
@@ -124,7 +124,8 @@ export function usePlaylist(
|
||||
`${target.username}/${target.slug}:${user?.id ?? "guest"}`,
|
||||
(signal) =>
|
||||
authFetch(
|
||||
`${API_URL}/api/users/@${target.username}/playlists/${target.slug}`,
|
||||
// The key above is falsy, and nothing fetched, without both.
|
||||
userApiUrl({ username: target.username! }, "playlists", target.slug!),
|
||||
{ signal },
|
||||
)
|
||||
.then(expectOk<Wire<PlaylistWithDumps>>)
|
||||
|
||||
@@ -9,7 +9,12 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { dumpThumbnailUrl, dumpUrl, isAt } from "../utils/shared/urls.ts";
|
||||
import {
|
||||
dumpThumbnailUrl,
|
||||
dumpUrl,
|
||||
isAt,
|
||||
userApiUrl,
|
||||
} from "../utils/shared/urls.ts";
|
||||
import { can } from "../utils/auth/permissions.ts";
|
||||
import { CategoryChips } from "../components/category/CategoryChips.tsx";
|
||||
import { AddToPlaylistModal } from "../components/playlist/AddToPlaylistModal.tsx";
|
||||
@@ -132,7 +137,7 @@ export function Dump(): React.JSX.Element {
|
||||
const controller = new AbortController();
|
||||
|
||||
if (preloaded) {
|
||||
fetch(`${API_URL}/api/users/${preloaded.userId}`, {
|
||||
fetch(userApiUrl({ id: preloaded.userId }), {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((r) => r.json())
|
||||
@@ -148,7 +153,7 @@ export function Dump(): React.JSX.Element {
|
||||
setOp(null);
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/users/@${username}/dumps/${slug}`,
|
||||
userApiUrl({ username }, "dumps", slug),
|
||||
{ cache: "no-store", signal: controller.signal },
|
||||
);
|
||||
const apiResponse = parseAPIResponse<Wire<Dump>>(await res.json());
|
||||
@@ -158,7 +163,7 @@ export function Dump(): React.JSX.Element {
|
||||
const dump: Dump = deserializeDump(apiResponse.data);
|
||||
setDumpState({ status: "loaded", dump });
|
||||
|
||||
fetch(`${API_URL}/api/users/${dump.userId}`, {
|
||||
fetch(userApiUrl({ id: dump.userId }), {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((r) => r.json())
|
||||
@@ -544,7 +549,7 @@ export function Dump(): React.JSX.Element {
|
||||
|
||||
<div className="dump-actions">
|
||||
{canEdit && (
|
||||
<Link to={`${dumpUrl(dump)}/edit`}>
|
||||
<Link to={dumpUrl(dump, "edit")}>
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
dumpFileUrl,
|
||||
dumpThumbnailUrl,
|
||||
dumpUrl,
|
||||
userApiUrl,
|
||||
} from "../utils/shared/urls.ts";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { PageError } from "../components/layout/PageError.tsx";
|
||||
@@ -92,7 +93,7 @@ export function DumpEdit(): React.JSX.Element {
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
useCanonicalPath(
|
||||
state.status === "loaded" ? `${dumpUrl(state.dump)}/edit` : null,
|
||||
state.status === "loaded" ? dumpUrl(state.dump, "edit") : null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,7 +103,7 @@ export function DumpEdit(): React.JSX.Element {
|
||||
setState({ status: "loading" });
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_URL}/api/users/@${username}/dumps/${slug}`,
|
||||
userApiUrl({ username }, "dumps", slug),
|
||||
{
|
||||
cache: "no-store",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { useLocation, useNavigate, useParams } from "react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { userApiUrl } from "../utils/shared/urls.ts";
|
||||
import type { Invitee } from "../../shared/user.ts";
|
||||
import type { Wire } from "../../shared/api.ts";
|
||||
import {
|
||||
@@ -91,7 +91,7 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
const invitees = useFetchState<Invitee[]>(
|
||||
user?.id,
|
||||
(signal) =>
|
||||
fetch(`${API_URL}/api/users/${user!.id}/invitees`, { signal })
|
||||
fetch(userApiUrl(user!, "invitees"), { signal })
|
||||
.then((res) => dataOr<Wire<Invitee>[]>(res, []))
|
||||
.then((entries) => entries.map(deserializeInvitee)),
|
||||
{ enabled: tab === "invitees" },
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { splitHandle } from "../../../shared/validation.ts";
|
||||
import { userApiUrl } from "../shared/urls.ts";
|
||||
|
||||
/**
|
||||
* Checks whether `host`, as a handle writes it, is this instance's: its API's,
|
||||
@@ -38,5 +39,5 @@ export function homeHost(handle: string): string | undefined {
|
||||
export function homeUrl(handle: string, path?: string): string | null {
|
||||
if (!homeHost(handle)) return null;
|
||||
const query = path ? `?path=${encodeURIComponent(path)}` : "";
|
||||
return `${API_URL}/api/users/@${encodeURIComponent(handle)}/home${query}`;
|
||||
return `${userApiUrl({ username: handle }, "home")}${query}`;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import type { Wire } from "../../../shared/api.ts";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
import { expectOk } from "./api.ts";
|
||||
import { deserializePublicUser } from "./deserialize.ts";
|
||||
import { userApiUrl } from "./urls.ts";
|
||||
|
||||
/**
|
||||
* Returns `GET /api/users/@:username`, for the profile, which fails as a
|
||||
@@ -24,10 +24,7 @@ export async function fetchPublicUser(
|
||||
username: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<PublicUser> {
|
||||
const res = await fetch(
|
||||
`${API_URL}/api/users/@${encodeURIComponent(username)}`,
|
||||
{ signal },
|
||||
);
|
||||
const res = await fetch(userApiUrl({ username }), { signal });
|
||||
if (res.status === 404) throw new Error("User not found");
|
||||
return deserializePublicUser(await expectOk<Wire<PublicUser>>(res));
|
||||
}
|
||||
|
||||
@@ -98,11 +98,12 @@ export function normalizeUrl(input: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path of the page of `dump`, `/@owner/dumps/<slug>`. Its owner's
|
||||
* name and its slug come with every dump the API sends.
|
||||
* Returns the path of the page of `dump`, `/@owner/dumps/<slug>`, or of its
|
||||
* editor (`dumpPath`). Its owner's name and its slug come with every dump the
|
||||
* API sends.
|
||||
*/
|
||||
export function dumpUrl(dump: OwnedItem): string {
|
||||
return dumpPath(dump);
|
||||
export function dumpUrl(dump: OwnedItem, view?: "edit"): string {
|
||||
return dumpPath(dump, view);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,6 +120,24 @@ export function isAt(
|
||||
item.ownerUsername.toLowerCase() === username?.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL of `/api/users/:key`, or of what is under it, `segments`
|
||||
* such as `"dumps", slug`, each encoded: every request about a user takes it
|
||||
* from here. The key is `user`'s id, or, when the caller has only the name a
|
||||
* page's path gives, `@` and that handle, which the API resolves as the page
|
||||
* would, a former name or a remote user's included.
|
||||
*/
|
||||
export function userApiUrl(
|
||||
user: { id: string } | { username: string },
|
||||
...segments: string[]
|
||||
): string {
|
||||
const key = "id" in user
|
||||
? encodeURIComponent(user.id)
|
||||
: `@${encodeURIComponent(user.username)}`;
|
||||
return [`${API_URL}/api/users`, key, ...segments.map(encodeURIComponent)]
|
||||
.join("/");
|
||||
}
|
||||
|
||||
/** Returns the path of the page of `playlist`, `/@owner/playlists/<slug>`. */
|
||||
export function playlistUrl(playlist: OwnedItem): string {
|
||||
return playlistPath(playlist);
|
||||
|
||||
Reference in New Issue
Block a user