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:
1
API.md
1
API.md
@@ -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` |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user