v4: a playlist's editor has a URL, as a dump's has — it opened over the playlist's header as a state of the page, which a reload or a shared link lost, while a dump's editor is /@name/dumps/<slug>/edit. It is now /@name/playlists/<slug>/edit: Edit goes there, and Cancel or Save come back to the page in its place. Both paths render PlaylistDetail at the same place, so the playlist stays loaded, and a new slug keeps the view. Anyone who may not edit it gets the page, a guest the sign-in first. playlistPath takes the view, and parsePagePath reads it, so its link has no preview, as a dump editor's has none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-09 22:22:24 +00:00
parent 21f4db268e
commit 9a215b0cc5
10 changed files with 130 additions and 46 deletions

1
API.md
View File

@@ -33,6 +33,7 @@ has one word, the same as in pages.
| `/@<name>/dumps/<slug>` | | anyone; a private dump, its poster | `Dump` |
| `/@<name>/dumps/<slug>/edit` | | its poster, `dump:moderate` | `DumpEdit` |
| `/@<name>/playlists/<slug>` | | anyone; a private playlist, its owner | `PlaylistDetail` |
| `/@<name>/playlists/<slug>/edit` | | its owner, `playlist:moderate` | `PlaylistDetail` |
| `/search?q=` | `/search/users?q=`, `/search/playlists?q=` | anyone | `Search` |
| `/notifications` | | signed in | `Notifications` |
| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` |

View File

@@ -30,12 +30,12 @@ export function isSelfHost(host: string): boolean {
/**
* Checks whether `url` links to one of this site's own dump, user or playlist
* pages, by `parsePagePath`; a dump's editor doesn't count. Matched on the
* pages, by `parsePagePath`; an editor doesn't count. Matched on the
* host as well as the path: `/@bob` on some other site is someone else's
* page, not ours.
*/
export function isSelfUrl(url: URL): boolean {
if (!isSelfHost(url.host)) return false;
const page = parsePagePath(url.pathname);
return !!page && !(page.kind === "dump" && page.edit);
return !!page && !(page.kind !== "user" && page.edit);
}

View File

@@ -119,7 +119,7 @@ export async function ogMiddleware(
if (pathname.startsWith("/api/")) return await next();
const page = parsePagePath(pathname);
if (!page || (page.kind === "dump" && page.edit)) return await next();
if (!page || (page.kind !== "user" && page.edit)) return await next();
const origin = PUBLIC_URL;
let meta: OGMeta | null = null;

View File

@@ -111,9 +111,15 @@ export function dumpPath(dump: OwnedItem, view?: "edit"): string {
return view ? `${path}/${view}` : path;
}
/** Returns the path of a playlist's page, `/@owner/playlists/<slug>`. */
export function playlistPath(playlist: OwnedItem): string {
return `${userPath(playlist.ownerUsername)}/playlists/${seg(playlist.slug)}`;
/**
* Returns the path of a playlist's page, `/@owner/playlists/<slug>`, or of
* the page with its editor open, one segment more.
*/
export function playlistPath(playlist: OwnedItem, view?: "edit"): string {
const path = `${userPath(playlist.ownerUsername)}/playlists/${
seg(playlist.slug)
}`;
return view ? `${path}/${view}` : path;
}
/**
@@ -171,11 +177,11 @@ export function communityPath(view: DigestCadence = "weekly"): string {
export type PagePath =
| { kind: "user"; username: string; rest: string[] }
| { kind: "dump"; username: string; slug: string; edit: boolean }
| { kind: "playlist"; username: string; slug: string };
| { kind: "playlist"; username: string; slug: string; edit: boolean };
/**
* Returns what a page path under a user names: a profile (with its tab), a
* dump (or its editor) or a playlist. It returns null for any other path, a
* dump or a playlist (or its editor). It returns null for any other path, a
* feed or an app page, and for one under a user that no page answers. The
* names are decoded but not looked up, so the user or the item may not exist.
*/
@@ -195,18 +201,11 @@ export function parsePagePath(pathname: string): PagePath | null {
const username = handle.slice(1);
if ((section === "dumps" || section === "playlists") && slug) {
if (section === "dumps" && more.length <= 1) {
if (more.length === 0) {
return { kind: "dump", username, slug, edit: false };
}
if (more[0] === "edit") {
return { kind: "dump", username, slug, edit: true };
}
if (more.length > 1 || (more.length === 1 && more[0] !== "edit")) {
return null;
}
if (section === "playlists" && more.length === 0) {
return { kind: "playlist", username, slug };
}
return null;
const kind = section === "dumps" ? "dump" : "playlist";
return { kind, username, slug, edit: more.length === 1 };
}
const rest = segments.slice(1);

View File

@@ -66,7 +66,18 @@ Deno.test("parsePagePath: what a path under a user names", () => {
kind: "playlist",
username: "bob",
slug: "mix",
edit: false,
});
assertEquals(parsePagePath("/@bob/playlists/mix/edit"), {
kind: "playlist",
username: "bob",
slug: "mix",
edit: true,
});
assertEquals(
playlistPath({ ownerUsername: "bob", slug: "mix" }, "edit"),
"/@bob/playlists/mix/edit",
);
for (
const other of [
"/",
@@ -78,7 +89,7 @@ Deno.test("parsePagePath: what a path under a user names", () => {
"/@bob/nope",
"/@bob/follow/users",
"/@bob/dumps/x/y",
"/@bob/playlists/x/edit",
"/@bob/playlists/x/y",
"/@bob/%E0%A4%A",
]
) {
@@ -96,6 +107,7 @@ Deno.test("handles: a user from another instance is /@name@host", () => {
kind: "playlist",
username: "alice@music.example",
slug: "mix",
edit: false,
});
assertEquals(parsePagePath("/@alice%40music.example"), {
kind: "user",

View File

@@ -27,7 +27,7 @@
* - `/@:username/*` is a user's (`UserRoutes`): their profile and its tabs,
* their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`,
* signed in only), and their playlists at `playlists/:slug`
* (`PlaylistDetail`).
* (`PlaylistDetail`), with its editor open at `playlists/:slug/edit`.
* - `/:categorySlug` and `/:categorySlug/:feedTab` are a dump category's feed
* (`Index`), with its `/follow/playlists` too.
* - Any other path is `NotFound`.
@@ -185,6 +185,7 @@ function UserRoutes() {
}
/>
<Route path="playlists/:slug" element={<PlaylistDetail />} />
<Route path="playlists/:slug/edit" element={<PlaylistDetail editing />} />
<Route path="*" element={<UserPublicProfile />} />
</Routes>
);

View File

@@ -100,12 +100,14 @@ function withDetails(
* see in it. The socket's events keep its details and dumps live: a dump
* another client removes fades out, one it adds is fetched, a new order is
* applied, one that goes private leaves and comes back at its place, and a
* deleted playlist sends the page home. A new slug replaces the URL, and a
* path that names the playlist on screen, as that one does, fetches nothing.
* deleted playlist sends the page home. A new slug replaces the URL, keeping
* the page's `view`, and a path that names the playlist on screen, as that
* one does, fetches nothing.
*/
export function usePlaylist(
username: string | undefined,
slug: string | undefined,
view?: "edit",
): UsePlaylistResult {
const navigate = useNavigate();
const { user, authFetch } = useAuth();
@@ -142,7 +144,7 @@ export function usePlaylist(
) {
setTarget({ username, slug });
}
useCanonicalPath(playlist && playlistUrl(playlist));
useCanonicalPath(playlist && playlistUrl(playlist, view));
// The socket and the API's writes name playlists by id.
const id = playlist?.id;
const url = `${API_URL}/api/playlists/${id}`;

View File

@@ -5,8 +5,8 @@
* @module
*/
import { useState } from "react";
import { useNavigate, useParams } from "react-router";
import { startTransition, useState } from "react";
import { Navigate, useLocation, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { can } from "../utils/auth/permissions.ts";
@@ -21,16 +21,31 @@ import { ConfirmModal } from "../components/shared/ConfirmModal.tsx";
import { PlaylistHeader } from "../components/playlist/PlaylistHeader.tsx";
import { PlaylistEditForm } from "../components/playlist/PlaylistEditForm.tsx";
import { PlaylistDumpList } from "../components/playlist/PlaylistDumpList.tsx";
import { playlistUrl } from "../utils/shared/urls.ts";
import type { OwnedItem } from "../../shared/paths.ts";
import { loginPath } from "../utils/auth/next.ts";
/** Passed to {@link PlaylistDetail} by `App`'s route. */
export interface PlaylistDetailProps {
/** Set at `/edit`, where the editor is open. */
editing?: boolean;
}
/**
* Renders `/@:username/playlists/:slug`, for anyone who may see the playlist:
* a private one only to its owner. `usePlaylist` loads it, by a former name or
* slug too, and keeps it live. Its owner, or a user with `playlist:moderate`, can edit or
* delete it, drag its dumps into a new order, and remove one, with a short
* window to undo.
* slug too, and keeps it live. Its owner, or a user with `playlist:moderate`,
* can drag its dumps into a new order and remove one, with a short window to
* undo, and edit or delete it in the editor that opens over its header at
* `/@:username/playlists/:slug/edit`. Both paths render the page at the same
* place, so opening the editor keeps the playlist loaded. Anyone else there
* gets the page, or is asked to sign in first.
*/
export function PlaylistDetail(): React.JSX.Element {
export function PlaylistDetail(
{ editing = false }: PlaylistDetailProps,
): React.JSX.Element {
const { handle, slug } = useParams();
const { pathname } = useLocation();
const navigate = useNavigate();
const { user } = useAuth();
const {
@@ -43,9 +58,12 @@ export function PlaylistDetail(): React.JSX.Element {
move,
saveOrder,
deletePlaylist,
} = usePlaylist(handle?.slice(1), slug);
} = usePlaylist(handle?.slice(1), slug, editing ? "edit" : undefined);
const [editOpen, setEditOpen] = useState(false);
const loaded = state.status === "loaded" ? state.data : null;
const canEdit = !!loaded &&
((!!user && user.id === loaded.userId) || can(user, "playlist:moderate"));
const editOpen = editing && canEdit;
const headerRef = useHeightTransition<HTMLDivElement>(editOpen);
const [confirmDelete, setConfirmDelete] = useState(false);
@@ -85,8 +103,15 @@ export function PlaylistDetail(): React.JSX.Element {
}
const playlist = state.data;
const canEdit = (!!user && user.id === playlist.userId) ||
can(user, "playlist:moderate");
if (editing && !canEdit) {
return user
? <Navigate to={playlistUrl(playlist)} replace />
: <Navigate to={loginPath(pathname)} replace />;
}
// The editor is a view of the page: opening it adds a history entry, which
// closing it, or saving, takes the place of.
const closeEditor = (current: OwnedItem = playlist) =>
navigate(playlistUrl(current), { replace: true });
return (
<PageShell>
@@ -100,13 +125,17 @@ export function PlaylistDetail(): React.JSX.Element {
? (
<PlaylistEditForm
playlist={playlist}
onCancel={() => setEditOpen(false)}
onCancel={() => closeEditor()}
onRequestDelete={() => setConfirmDelete(true)}
onSaved={(updated) => {
setEditOpen(false);
// Shown at once rather than left to the socket's echo,
// which may never come (disconnected).
applyDetails(updated);
// Shown at once rather than left to the socket's echo, which
// may never come (disconnected). The router navigates in a
// transition; were the details applied apart, the page would
// render them still at /edit, and put a new slug's /edit back.
startTransition(() => {
applyDetails(updated);
closeEditor({ ...playlist, slug: updated.slug });
});
}}
/>
)
@@ -114,7 +143,9 @@ export function PlaylistDetail(): React.JSX.Element {
<div className="playlist-detail-header-top">
<PlaylistHeader
playlist={playlist}
onEdit={canEdit ? () => setEditOpen(true) : undefined}
onEdit={canEdit
? () => navigate(playlistUrl(playlist, "edit"))
: undefined}
/>
</div>
)}

View File

@@ -59,7 +59,15 @@ function open(
path: "/:handle/playlists/:slug",
session,
categories,
extraRoutes: <Route path="/" element={<p>home</p>} />,
extraRoutes: (
<>
<Route path="/" element={<p>home</p>} />
<Route
path="/:handle/playlists/:slug/edit"
element={<PlaylistDetail editing />}
/>
</>
),
});
const socket = FakeWebSocket.last;
socket.welcome();
@@ -142,12 +150,39 @@ describe("PlaylistDetail", () => {
await user.clear(box);
await user.type(box, "Better tunes");
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("Better tunes")).toBeInTheDocument();
// Saving closes the editor, whose text would be found first.
await waitFor(() => expect(currentPath()).toBe("/@owner/playlists/mix"));
expect(screen.getByText("Better tunes")).toBeInTheDocument();
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({
description: "Better tunes",
isPublic: true,
});
});
test("the editor is a page: /edit opens it, closing goes back to the page", async () => {
const { user } = open({ url: "/@owner/playlists/mix/edit" });
expect(await screen.findByRole("textbox", { name: "Description" }))
.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(currentPath()).toBe("/@owner/playlists/mix");
await user.click(screen.getByRole("button", { name: "Edit" }));
expect(currentPath()).toBe("/@owner/playlists/mix/edit");
expect(calls("GET /api/users/:u/playlists/:slug")).toHaveLength(1);
});
test("at /edit, anyone else gets the page", async () => {
open({ session: visitor, url: "/@owner/playlists/mix/edit" });
await waitFor(() => expect(currentPath()).toBe("/@owner/playlists/mix"));
expect(screen.queryByRole("textbox", { name: "Description" })).toBeNull();
});
test("at /edit, a guest is asked to sign in first", async () => {
open({ session: null, url: "/@owner/playlists/mix/edit" });
await waitFor(() =>
expect(currentPath()).toBe(
"/login?next=%2F%40owner%2Fplaylists%2Fmix%2Fedit",
)
);
});
test("its categories show, and editing them sends the new set", async () => {
@@ -444,7 +479,7 @@ describe("PlaylistDetail", () => {
}),
);
expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
expect(currentPath()).toBe("/@owner/playlists/mix");
expect(currentPath()).toBe("/@owner/playlists/mix/edit");
});
test("live: added dumps appear, removed ones fade, a deleted playlist sends you home", async () => {

View File

@@ -138,9 +138,12 @@ export function userApiUrl(
.join("/");
}
/** Returns the path of the page of `playlist`, `/@owner/playlists/<slug>`. */
export function playlistUrl(playlist: OwnedItem): string {
return playlistPath(playlist);
/**
* Returns the path of the page of `playlist`, `/@owner/playlists/<slug>`, or
* of the page with its editor open (`playlistPath`).
*/
export function playlistUrl(playlist: OwnedItem, view?: "edit"): string {
return playlistPath(playlist, view);
}
/**