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