diff --git a/shared/paths.ts b/shared/paths.ts index 32c13877..d6282788 100644 --- a/shared/paths.ts +++ b/shared/paths.ts @@ -102,9 +102,13 @@ export function userPath( : `${base}/${tab}`; } -/** Returns the path of a dump's page, `/@owner/dumps/`. */ -export function dumpPath(dump: OwnedItem): string { - return `${userPath(dump.ownerUsername)}/dumps/${seg(dump.slug)}`; +/** + * Returns the path of a dump's page, `/@owner/dumps/`, 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/`. */ diff --git a/shared/test/paths.test.ts b/shared/test/paths.test.ts index 75fa913f..f4269f3c 100644 --- a/shared/test/paths.test.ts +++ b/shared/test/paths.test.ts @@ -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"); diff --git a/src/components/profile/ProfileFollowTab.tsx b/src/components/profile/ProfileFollowTab.tsx index 4838f126..3a36fbbf 100644 --- a/src/components/profile/ProfileFollowTab.tsx +++ b/src/components/profile/ProfileFollowTab.tsx @@ -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( view: FollowView, deserialize: (wire: Wire) => T, ) { - const url = `${API_URL}/api/users/${userId}/follow/${view}`; + const url = userApiUrl({ id: userId }, "follow", view); const paged = usePagedList( url, useCallback( diff --git a/src/components/profile/ProfileManageTab.tsx b/src/components/profile/ProfileManageTab.tsx index 0502481f..ab40c6d1 100644 --- a/src/components/profile/ProfileManageTab.tsx +++ b/src/components/profile/ProfileManageTab.tsx @@ -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 { const renamed = deserializePublicUser( await expectOk>( - await authFetch(`${API_URL}/api/users/${user.id}/username`, { + await authFetch(userApiUrl(user, "username"), { method: "PATCH", body: JSON.stringify({ username }), }), diff --git a/src/components/profile/ProfilePlaylistsTab.tsx b/src/components/profile/ProfilePlaylistsTab.tsx index e9b9b439..304ff471 100644 --- a/src/components/profile/ProfilePlaylistsTab.tsx +++ b/src/components/profile/ProfilePlaylistsTab.tsx @@ -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( diff --git a/src/components/profile/RoleSwitcher.tsx b/src/components/profile/RoleSwitcher.tsx index f21f0450..3683782f 100644 --- a/src/components/profile/RoleSwitcher.tsx +++ b/src/components/profile/RoleSwitcher.tsx @@ -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 }), }); diff --git a/src/components/shared/Avatar.tsx b/src/components/shared/Avatar.tsx index fd495da2..29b7bbf3 100644 --- a/src/components/shared/Avatar.tsx +++ b/src/components/shared/Avatar.tsx @@ -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 ( ( diff --git a/src/hooks/playlist/usePlaylist.ts b/src/hooks/playlist/usePlaylist.ts index 0929fc5b..3d3da4c0 100644 --- a/src/hooks/playlist/usePlaylist.ts +++ b/src/hooks/playlist/usePlaylist.ts @@ -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>) diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index fd8ca196..91d610b2 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -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>(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 {
{canEdit && ( - + Edit )} diff --git a/src/pages/DumpEdit.tsx b/src/pages/DumpEdit.tsx index cd7ec46d..4f8d5094 100644 --- a/src/pages/DumpEdit.tsx +++ b/src/pages/DumpEdit.tsx @@ -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(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}` } : {}, diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index 31592c7b..963b5c69 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -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( user?.id, (signal) => - fetch(`${API_URL}/api/users/${user!.id}/invitees`, { signal }) + fetch(userApiUrl(user!, "invitees"), { signal }) .then((res) => dataOr[]>(res, [])) .then((entries) => entries.map(deserializeInvitee)), { enabled: tab === "invitees" }, diff --git a/src/utils/auth/home.ts b/src/utils/auth/home.ts index 55fff4af..7e2be507 100644 --- a/src/utils/auth/home.ts +++ b/src/utils/auth/home.ts @@ -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}`; } diff --git a/src/utils/shared/fetchPublicUser.ts b/src/utils/shared/fetchPublicUser.ts index 7845a0be..103d8735 100644 --- a/src/utils/shared/fetchPublicUser.ts +++ b/src/utils/shared/fetchPublicUser.ts @@ -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 { - 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>(res)); } diff --git a/src/utils/shared/urls.ts b/src/utils/shared/urls.ts index 710c0f49..497f7e3b 100644 --- a/src/utils/shared/urls.ts +++ b/src/utils/shared/urls.ts @@ -98,11 +98,12 @@ export function normalizeUrl(input: string): string { } /** - * Returns the path of the page of `dump`, `/@owner/dumps/`. Its owner's - * name and its slug come with every dump the API sends. + * Returns the path of the page of `dump`, `/@owner/dumps/`, 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/`. */ export function playlistUrl(playlist: OwnedItem): string { return playlistPath(playlist);