fix(playlists): the player's Remove follows the list it was launched from, the player survives a change of selection, and a gone list is said in the reader's language
- D3 snapshotted the removal's TARGET, but whether the player offered Remove still followed the
selected list: launched from Watch later (no Remove, by design), selecting another list under it
made Remove appear — and delete from Watch later. The snapshot now carries the launch list's
editability too.
- Selecting another list under an open player (the command palette works over it) put the page into
"Loading…", and the player was rendered only in the loaded branch — it vanished mid-playback and
came back at its launch video. It now sits at one place in the tree whatever the page is doing.
- A removal takes the video out of the CACHED list as well as the rows: the player's progress
checkpoint rewrote that entry before the refetch landed, handing the membership guard a list that
still held the video — the row came back and the undo history went.
- The watched-state callback refreshed the selected list, not the launch list (D3's sibling).
- `_own_playlist`'s 404 is structured (`{"code": "playlist", "reason": "not_found"}`) and localised:
the bare "Unknown playlist" reached the Hungarian UI in English, which also made round 1's new
`removeFailed` string unreachable. Both Remove and Move report through `notifyActionError` now.
- The "viewer moved on under the confirm" test stepped back only once, which lands on the same video
whether or not the removed one left the queue; it now takes the second step that tells them apart.
This commit is contained in:
@@ -50,7 +50,10 @@ def _check_pull_cooldown(user: User) -> None:
|
||||
def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||
pl = db.get(Playlist, playlist_id)
|
||||
if pl is None or pl.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Unknown playlist")
|
||||
# Structured, so the UI can say it in the reader's language: this 404 is exactly the one a
|
||||
# user meets in practice (the list was deleted in another tab), and a bare "Unknown
|
||||
# playlist" reached the Hungarian UI in English. One reason for "gone" and "not yours" alike.
|
||||
raise HTTPException(status_code=404, detail={"code": "playlist", "reason": "not_found"})
|
||||
return pl
|
||||
|
||||
|
||||
|
||||
@@ -128,6 +128,22 @@ def test_move_into_someone_elses_list_404s_and_changes_nothing(db, make_user, as
|
||||
assert _ids(db, theirs.id) == []
|
||||
|
||||
|
||||
def test_a_list_that_is_not_yours_answers_with_a_reason_the_ui_can_translate(
|
||||
db, make_user, as_user
|
||||
):
|
||||
# The one 404 a user meets in practice (the list went away in another tab). It used to be a bare
|
||||
# English "Unknown playlist", which the Hungarian UI then showed verbatim.
|
||||
user, other = make_user(), make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
theirs = _playlist(db, other, "Theirs", ["v1"])
|
||||
|
||||
r = as_user(user).delete(f"/api/playlists/{theirs.id}/items/v1")
|
||||
assert r.status_code == 404
|
||||
assert r.json()["detail"] == {"code": "playlist", "reason": "not_found"}
|
||||
db.expire_all()
|
||||
assert _ids(db, theirs.id) == ["v1"]
|
||||
|
||||
|
||||
def test_move_marks_both_linked_sides_dirty(db, make_user, as_user):
|
||||
"""A move edits two YouTube mirrors at once — the source lost an item, the target gained one.
|
||||
Marking only the source is the asymmetric fix this repo keeps re-learning."""
|
||||
|
||||
@@ -216,6 +216,11 @@ describe("PlayerModal — removing the playing video from its playlist", () => {
|
||||
expect(lastStart()?.videoId).toBe("C");
|
||||
prev();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
// …and A is gone from the queue, not just skipped over: B is its head now, so stepping back
|
||||
// again goes nowhere. (One step back lands on B either way — it took the second to tell.)
|
||||
prev();
|
||||
await settle();
|
||||
expect(lastStart()?.videoId).toBe("B");
|
||||
expect(started.filter((s) => s.videoId === "A")).toHaveLength(1); // never played again
|
||||
});
|
||||
|
||||
|
||||
@@ -10,9 +10,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
const h = vi.hoisted(() => ({
|
||||
notify: vi.fn(),
|
||||
selectedId: 1 as number | null,
|
||||
mounted: false,
|
||||
player: null as null | {
|
||||
video: { id: string; position_seconds: number };
|
||||
onClose: () => void;
|
||||
onState: (id: string, status: string) => void;
|
||||
onRemoveFromList?: (v: unknown) => Promise<boolean>;
|
||||
},
|
||||
}));
|
||||
@@ -34,12 +36,21 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
vi.mock("./PlaylistsProvider", () => ({ usePlaylists: () => ({ selectedId: h.selectedId }) }));
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./usePlaylistActions", () => ({ usePlaylistActions: () => ({ remove: vi.fn() }) }));
|
||||
vi.mock("./PlayerModal", () => ({
|
||||
default: (props: NonNullable<typeof h.player>) => {
|
||||
h.player = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock("./PlayerModal", async () => {
|
||||
const { useEffect } = await import("react");
|
||||
return {
|
||||
default: function PlayerStandIn(props: NonNullable<typeof h.player>) {
|
||||
h.player = props;
|
||||
useEffect(() => {
|
||||
h.mounted = true;
|
||||
return () => {
|
||||
h.mounted = false;
|
||||
};
|
||||
}, []);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import Playlists from "./Playlists";
|
||||
import { api, HttpError, type Video } from "../lib/api";
|
||||
@@ -72,10 +83,11 @@ const vid = (id: string, position_seconds: number): Video =>
|
||||
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||
}) as unknown as Video;
|
||||
|
||||
const WATCH_LATER = 9;
|
||||
const list = (id: number, items: Video[]) => ({
|
||||
id,
|
||||
name: `List ${id}`,
|
||||
kind: "user",
|
||||
kind: id === WATCH_LATER ? "watch_later" : "user",
|
||||
yt_playlist_id: null,
|
||||
item_count: items.length,
|
||||
items,
|
||||
@@ -107,6 +119,7 @@ const play = async (title: string) =>
|
||||
beforeEach(() => {
|
||||
h.selectedId = 1;
|
||||
h.player = null;
|
||||
h.mounted = false;
|
||||
h.notify.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
@@ -171,4 +184,70 @@ describe("Playlists — removing a video from inside the player", () => {
|
||||
expect(done).toBe(false);
|
||||
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" }));
|
||||
});
|
||||
|
||||
it("offers Remove by the list it was launched from, not by whichever list is selected now", async () => {
|
||||
// Watch later deliberately has no Remove. Gated on the SELECTED list, it appeared on a player
|
||||
// launched from Watch later as soon as another list was selected under it — and deleted from
|
||||
// Watch later, since the removal itself targets the launch list.
|
||||
h.selectedId = WATCH_LATER;
|
||||
const { rerender } = mount({ [WATCH_LATER]: [vid("V", 0)], 2: [vid("W", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player).not.toBeNull());
|
||||
expect(h.player!.onRemoveFromList).toBeUndefined();
|
||||
h.selectedId = 2;
|
||||
rerender();
|
||||
await screen.findByRole("button", { name: "card.play — Video W" });
|
||||
expect(h.player!.onRemoveFromList).toBeUndefined();
|
||||
});
|
||||
|
||||
it("takes the removed video out of the cached list, so a progress checkpoint cannot bring it back", async () => {
|
||||
// The player steps to the next item after a removal and stores the last one's position into
|
||||
// every cached list. Landing before the refetch, that write handed the page a list still holding
|
||||
// the video, and the page — seeing a different set of ids — reset: the row came back.
|
||||
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
|
||||
mount({ 1: [vid("V", 0), vid("W", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player).not.toBeNull());
|
||||
vi.mocked(api.playlist).mockReturnValue(new Promise(() => {})); // the refetch is still out
|
||||
await act(async () => {
|
||||
await h.player!.onRemoveFromList!(vid("V", 0));
|
||||
});
|
||||
await act(async () => {
|
||||
patchCachedProgress(qc, new Map([["V", 99]]));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "card.play — Video V" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "card.play — Video W" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Playlists — the player outlives a change of selection", () => {
|
||||
it("stays mounted while a newly selected list is still loading", async () => {
|
||||
// The page swaps itself for "Loading…" until the new list arrives; with the player rendered only
|
||||
// in the loaded branch, that took it down mid-playback and put it back at its launch video.
|
||||
const { rerender } = mount({ 1: [vid("V", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.mounted).toBe(true));
|
||||
vi.mocked(api.playlist).mockReturnValue(new Promise(() => {}));
|
||||
h.selectedId = 2;
|
||||
rerender();
|
||||
await screen.findByText("playlists.loading");
|
||||
expect(h.mounted).toBe(true);
|
||||
});
|
||||
|
||||
it("refreshes the list it was launched from when the viewer marks the video in it", async () => {
|
||||
// Invalidating the SELECTED list left the launch list's row showing the old status.
|
||||
vi.spyOn(api, "setState").mockResolvedValue(undefined as never);
|
||||
const { rerender } = mount({ 1: [vid("V", 0)], 2: [vid("W", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player).not.toBeNull());
|
||||
h.selectedId = 2;
|
||||
rerender();
|
||||
await screen.findByRole("button", { name: "card.play — Video W" });
|
||||
await act(async () => {
|
||||
h.player!.onState("V", "watched");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(qc.getQueryState(qk.playlist(1))?.isInvalidated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,8 +40,9 @@ import {
|
||||
X,
|
||||
Youtube,
|
||||
} from "lucide-react";
|
||||
import { api, HttpError, type Playlist, type Video } from "../lib/api";
|
||||
import { api, HttpError, type Playlist, type PlaylistDetail, type Video } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { useUndoable } from "../lib/useUndoable";
|
||||
@@ -227,12 +228,15 @@ export default function Playlists() {
|
||||
// normal case. Only onClose takes the player down now. `listId` is part of that snapshot for the
|
||||
// same reason: the selection can change under an open player (the command palette opens over it),
|
||||
// and "remove this from the playlist" means the list it was LAUNCHED from, not whichever is
|
||||
// selected by the time the button is pressed.
|
||||
// selected by the time the button is pressed. So does whether that button exists at all
|
||||
// (`editable`): gating it on the current selection offered Remove on a player launched from
|
||||
// Watch later — which deliberately has none — as soon as another list was selected under it.
|
||||
const [playing, setPlaying] = useState<{
|
||||
video: Video;
|
||||
queue: Video[];
|
||||
index: number;
|
||||
listId: number | null;
|
||||
editable: boolean;
|
||||
} | null>(null);
|
||||
// Where the last move went. `LS.playlist` is the SELECTED (source) list and `updated_at` moves on
|
||||
// any edit, so neither answers "where do I keep sending things"; this does, and it is a per-account
|
||||
@@ -301,6 +305,8 @@ export default function Playlists() {
|
||||
// push/delete-on-YouTube paths. Folding it in here would have taken drag and remove away from
|
||||
// every account without the scope, which they have always had.
|
||||
const canEdit = editable;
|
||||
const canEditRef = useRef(canEdit);
|
||||
canEditRef.current = canEdit;
|
||||
const [pushing, setPushing] = useState(false);
|
||||
const [reverting, setReverting] = useState(false);
|
||||
|
||||
@@ -310,7 +316,13 @@ export default function Playlists() {
|
||||
const queue = shownRef.current;
|
||||
const index = queue.findIndex((v) => v.id === id);
|
||||
if (index < 0) return;
|
||||
setPlaying({ video: queue[index]!, queue, index, listId: selectedRef.current });
|
||||
setPlaying({
|
||||
video: queue[index]!,
|
||||
queue,
|
||||
index,
|
||||
listId: selectedRef.current,
|
||||
editable: canEditRef.current,
|
||||
});
|
||||
}, []);
|
||||
const onPlay = useCallback((v: Video) => openPlayer(v.id), [openPlayer]);
|
||||
const onRemove = useCallback((v: Video) => {
|
||||
@@ -551,6 +563,14 @@ export default function Playlists() {
|
||||
order.rebase((list) => list.filter((v) => v.id !== video.id));
|
||||
lastSetRef.current = `${listId}:${idsKey(next)}`;
|
||||
}
|
||||
// …and out of the CACHED list too, not just the rows. Anything that rewrites that cache entry
|
||||
// before the refetch lands handed the membership guard a list that still held the video — the
|
||||
// player's own progress checkpoint does exactly that when a removal steps it to the next item —
|
||||
// so the guard reset the order: the removed row came back and the undo history the rebase had
|
||||
// kept went with it. (A failure resyncs below, which puts the row back where it belongs.)
|
||||
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
|
||||
old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old,
|
||||
);
|
||||
try {
|
||||
await api.removeFromPlaylist(listId, video.id);
|
||||
refreshAll(listId);
|
||||
@@ -561,7 +581,7 @@ export default function Playlists() {
|
||||
// confirmed removal fails in silence (inside the full-screen player there is not even a row
|
||||
// snapping back to show it).
|
||||
if (e instanceof HttpError && (e.status === 404 || e.status === 403))
|
||||
notify({ level: "error", message: e.detail || t("playlists.removeFailed") });
|
||||
notifyActionError(e, t("playlists.removeFailed"));
|
||||
refreshAll(listId); // …and resync, which restores any row the optimistic removal took out
|
||||
return false;
|
||||
}
|
||||
@@ -597,7 +617,7 @@ export default function Playlists() {
|
||||
// error dialog for 400/409/422 and 5xx and raises the connectivity notice for a bad gateway,
|
||||
// so toasting unconditionally reported a transport fault twice (E10 review round 2).
|
||||
if (e instanceof HttpError && (e.status === 404 || e.status === 403))
|
||||
notify({ level: "error", message: e.detail || t("playlists.moveFailed") });
|
||||
notifyActionError(e, t("playlists.moveFailed"));
|
||||
}
|
||||
// BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact
|
||||
// key, so it cannot cover the target — invalidate the whole `["playlist"]` prefix, exactly as
|
||||
@@ -636,7 +656,10 @@ export default function Playlists() {
|
||||
|
||||
function playerState(id: string, status: string) {
|
||||
api.setState(id, status).then(() => {
|
||||
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
|
||||
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
|
||||
// list that is no longer the selected one (see `playing`), and that list holds the row whose
|
||||
// status just changed. Only lists actually on screen refetch.
|
||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
});
|
||||
}
|
||||
@@ -767,293 +790,315 @@ export default function Playlists() {
|
||||
: []),
|
||||
];
|
||||
|
||||
// The open player, at ONE place in the tree whichever state the page below it is in. Selecting
|
||||
// another list under an open player (the command palette works over it) puts the page into
|
||||
// "Loading…" until that list arrives; with the player rendered only in the loaded branch, that
|
||||
// unmounted it mid-playback and remounted it at the video it was launched with.
|
||||
const player = playing && (
|
||||
<Suspense fallback={null}>
|
||||
<PlayerModal
|
||||
video={playing.video}
|
||||
queue={playing.queue}
|
||||
startIndex={playing.index}
|
||||
startAt={null}
|
||||
onClose={closePlayer}
|
||||
onState={playerState}
|
||||
onRemoveFromList={playing.editable ? removeFromPlayer : undefined}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
if (selectedId == null) {
|
||||
return <div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>;
|
||||
return (
|
||||
<>
|
||||
<div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>
|
||||
{player}
|
||||
</>
|
||||
);
|
||||
}
|
||||
// The page used to render "Loading…" forever when the detail query failed — an error read as a
|
||||
// slow network that never arrived.
|
||||
if (detailQuery.isError) {
|
||||
return (
|
||||
<div className="text-sm p-4 flex flex-col items-start gap-3">
|
||||
<span className="inline-flex items-center gap-2 text-muted">
|
||||
<AlertTriangle className="w-4 h-4 text-danger" />
|
||||
{t("playlists.loadFailed")}
|
||||
</span>
|
||||
<Button variant="outline" size="xs" onClick={() => detailQuery.refetch()}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
<div className="text-sm p-4 flex flex-col items-start gap-3">
|
||||
<span className="inline-flex items-center gap-2 text-muted">
|
||||
<AlertTriangle className="w-4 h-4 text-danger" />
|
||||
{t("playlists.loadFailed")}
|
||||
</span>
|
||||
<Button variant="outline" size="xs" onClick={() => detailQuery.refetch()}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
{player}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (!detail) {
|
||||
return <div className="text-muted text-sm p-4">{t("playlists.loading")}</div>;
|
||||
return (
|
||||
<>
|
||||
<div className="text-muted text-sm p-4">{t("playlists.loading")}</div>
|
||||
{player}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// No top padding: the sticky column header must sit flush at the scroller top so it doesn't
|
||||
// shift up when `position: sticky` engages on the first scroll (the fixed toolbar band above
|
||||
// gives the breathing room). Same reason as the channel table — see Channels.tsx.
|
||||
return (
|
||||
// No top padding: the sticky column header must sit flush at the scroller top so it doesn't
|
||||
// shift up when `position: sticky` engages on the first scroll (the fixed toolbar band above
|
||||
// gives the breathing room). Same reason as the channel table — see Channels.tsx.
|
||||
<div className="px-4 pb-4">
|
||||
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
|
||||
<>
|
||||
<div className="px-4 pb-4">
|
||||
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
|
||||
fixed band, so only the video list scrolls. */}
|
||||
<PageToolbar>
|
||||
{/* Two tight rows beside a small cover: title + actions on the first, the meta line and the
|
||||
<PageToolbar>
|
||||
{/* Two tight rows beside a small cover: title + actions on the first, the meta line and the
|
||||
view controls sharing the second. This band is FIXED chrome (it never scrolls), so every
|
||||
row it sheds is list height won back — the old layout stacked three rows next to the
|
||||
cover plus a fourth block under it, for roughly twice this height. */}
|
||||
<div className="px-4 py-2 max-w-5xl mx-auto">
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
|
||||
{items[0]?.thumbnail_url && (
|
||||
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={saveRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveRename();
|
||||
if (e.key === "Escape") setRenaming(false);
|
||||
}}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||
{editable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setRenameValue(detail.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
title={t("playlists.rename")}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="shrink-0 text-muted hover:text-fg"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{detail.yt_playlist_id && (
|
||||
<a
|
||||
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
title={t("playlists.openOnYoutube")}
|
||||
aria-label={t("playlists.openOnYoutube")}
|
||||
className="shrink-0 text-muted hover:text-accent"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
<div className="px-4 py-2 max-w-5xl mx-auto">
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
|
||||
{items[0]?.thumbnail_url && (
|
||||
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
<div className="ml-auto flex gap-1.5 items-center shrink-0">
|
||||
{linked && detail.dirty && (
|
||||
<span
|
||||
title={t("playlists.unsyncedHint")}
|
||||
className="inline-flex items-center gap-1.5 text-2xs text-accent mr-1"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
{t("playlists.unsynced")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => shown.length && openPlayer(shown[0]!.id)}
|
||||
disabled={!shown.length}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||||
</button>
|
||||
{editable && canWrite && (
|
||||
<button
|
||||
onClick={pushToYoutube}
|
||||
disabled={pushing}
|
||||
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
|
||||
aria-label={
|
||||
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
|
||||
}
|
||||
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
|
||||
detail.dirty
|
||||
? "border-accent text-accent hover:bg-accent/10"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{pushing ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : linked ? (
|
||||
<Youtube className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{linked && detail.dirty && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={revertToYoutube}
|
||||
disabled={reverting}
|
||||
title={t("playlists.revert")}
|
||||
aria-label={t("playlists.revert")}
|
||||
>
|
||||
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
)}
|
||||
{editable && (
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
onClick={deletePlaylist}
|
||||
title={t("playlists.delete")}
|
||||
aria-label={t("playlists.delete")}
|
||||
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span className="text-xs text-muted">
|
||||
{t("playlists.itemCount", { count: items.length })}
|
||||
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
|
||||
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
|
||||
</span>
|
||||
{(items.length > 1 || viewActive) && (
|
||||
<>
|
||||
<span aria-hidden="true" className="text-xs text-muted/40">
|
||||
·
|
||||
</span>
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
{t("playlists.groupByChannel")}
|
||||
</label>
|
||||
{viewActive && (
|
||||
<button
|
||||
onClick={clearView}
|
||||
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={saveRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveRename();
|
||||
if (e.key === "Escape") setRenaming(false);
|
||||
}}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||
{editable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setRenameValue(detail.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
title={t("playlists.rename")}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="shrink-0 text-muted hover:text-fg"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{detail.yt_playlist_id && (
|
||||
<a
|
||||
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
title={t("playlists.openOnYoutube")}
|
||||
aria-label={t("playlists.openOnYoutube")}
|
||||
className="shrink-0 text-muted hover:text-accent"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex gap-1.5 items-center shrink-0">
|
||||
{linked && detail.dirty && (
|
||||
<span
|
||||
title={t("playlists.unsyncedHint")}
|
||||
className="inline-flex items-center gap-1.5 text-2xs text-accent mr-1"
|
||||
>
|
||||
{t("playlists.clearView")}
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
{t("playlists.unsynced")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => shown.length && openPlayer(shown[0]!.id)}
|
||||
disabled={!shown.length}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||||
</button>
|
||||
{editable && canWrite && (
|
||||
<button
|
||||
onClick={pushToYoutube}
|
||||
disabled={pushing}
|
||||
title={
|
||||
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
|
||||
}
|
||||
aria-label={
|
||||
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
|
||||
}
|
||||
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
|
||||
detail.dirty
|
||||
? "border-accent text-accent hover:bg-accent/10"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{pushing ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : linked ? (
|
||||
<Youtube className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{canSaveOrder && (
|
||||
{linked && detail.dirty && (
|
||||
<Button
|
||||
variant="outline-accent"
|
||||
size="xs"
|
||||
onClick={saveOrder}
|
||||
icon={<Check className="w-3.5 h-3.5" />}
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={revertToYoutube}
|
||||
disabled={reverting}
|
||||
title={t("playlists.revert")}
|
||||
aria-label={t("playlists.revert")}
|
||||
>
|
||||
{t("playlists.saveOrder")}
|
||||
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
)}
|
||||
{/* ONE sentence that names the state. Three loose texts used to pile up here
|
||||
{editable && (
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
onClick={deletePlaylist}
|
||||
title={t("playlists.delete")}
|
||||
aria-label={t("playlists.delete")}
|
||||
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span className="text-xs text-muted">
|
||||
{t("playlists.itemCount", { count: items.length })}
|
||||
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
|
||||
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
|
||||
</span>
|
||||
{(items.length > 1 || viewActive) && (
|
||||
<>
|
||||
<span aria-hidden="true" className="text-xs text-muted/40">
|
||||
·
|
||||
</span>
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
{t("playlists.groupByChannel")}
|
||||
</label>
|
||||
{viewActive && (
|
||||
<button
|
||||
onClick={clearView}
|
||||
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
|
||||
>
|
||||
{t("playlists.clearView")}
|
||||
</button>
|
||||
)}
|
||||
{canSaveOrder && (
|
||||
<Button
|
||||
variant="outline-accent"
|
||||
size="xs"
|
||||
onClick={saveOrder}
|
||||
icon={<Check className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{t("playlists.saveOrder")}
|
||||
</Button>
|
||||
)}
|
||||
{/* ONE sentence that names the state. Three loose texts used to pile up here
|
||||
(reset link + save hint + "reordering is off"), and none of them answered the
|
||||
question a user actually has — "did I just change my playlist?". Worse,
|
||||
sorting a list whose stored order ALREADY matches the sort produced a
|
||||
warning, no Save button and no visible change, which reads as a malfunction.
|
||||
Now the line says which of the three situations you are in; the disabled grip
|
||||
still explains itself on hover/focus. */}
|
||||
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
|
||||
{canEdit && (
|
||||
<div className="ml-auto">
|
||||
<UndoToolbar
|
||||
canUndo={order.canUndo}
|
||||
canRedo={order.canRedo}
|
||||
onUndo={order.undo}
|
||||
onRedo={order.redo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
|
||||
{canEdit && (
|
||||
<div className="ml-auto">
|
||||
<UndoToolbar
|
||||
canUndo={order.canUndo}
|
||||
canRedo={order.canRedo}
|
||||
onUndo={order.undo}
|
||||
onRedo={order.redo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageToolbar>
|
||||
</PageToolbar>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="text-sm text-muted grid place-items-center text-center py-12">
|
||||
<div>
|
||||
<ListPlus className="w-6 h-6 mx-auto mb-2 opacity-60" />
|
||||
{t("playlists.empty")}
|
||||
{items.length === 0 ? (
|
||||
<div className="text-sm text-muted grid place-items-center text-center py-12">
|
||||
<div>
|
||||
<ListPlus className="w-6 h-6 mx-auto mb-2 opacity-60" />
|
||||
{t("playlists.empty")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
// dnd-kit's defaults are hardcoded English. They were harmless while only a pointer could
|
||||
// drag; now that the keyboard can, they ARE the instructions for the feature.
|
||||
accessibility={{ screenReaderInstructions, announcements }}
|
||||
>
|
||||
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* table-fixed, not auto: every column but the title has a declared width, so the
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
// dnd-kit's defaults are hardcoded English. They were harmless while only a pointer could
|
||||
// drag; now that the keyboard can, they ARE the instructions for the feature.
|
||||
accessibility={{ screenReaderInstructions, announcements }}
|
||||
>
|
||||
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* table-fixed, not auto: every column but the title has a declared width, so the
|
||||
title absorbs the rest and its `truncate` works. Auto layout sizes columns from
|
||||
min-content, and a nowrap title cell's min-content is the entire title. */}
|
||||
<table className="w-full table-fixed border-collapse text-sm">
|
||||
<ColumnHead
|
||||
columns={columns}
|
||||
sort={viewSort}
|
||||
onToggleSort={toggleSort}
|
||||
filters={filters}
|
||||
onFilter={applyFilter}
|
||||
/>
|
||||
<SortableContext
|
||||
items={shown.map((v) => v.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<tbody>
|
||||
{shown.map((v) => (
|
||||
<Row
|
||||
key={v.id}
|
||||
video={v}
|
||||
columns={columns}
|
||||
dragDisabled={!canEdit || viewActive}
|
||||
dragHint={dragHint}
|
||||
menuProps={rowMenu.triggerProps(v)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</table>
|
||||
{shown.length === 0 && (
|
||||
<div className="text-muted py-8 text-center text-sm">
|
||||
{t("playlists.noneMatchFilter")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DndContext>
|
||||
)}
|
||||
<table className="w-full table-fixed border-collapse text-sm">
|
||||
<ColumnHead
|
||||
columns={columns}
|
||||
sort={viewSort}
|
||||
onToggleSort={toggleSort}
|
||||
filters={filters}
|
||||
onFilter={applyFilter}
|
||||
/>
|
||||
<SortableContext
|
||||
items={shown.map((v) => v.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<tbody>
|
||||
{shown.map((v) => (
|
||||
<Row
|
||||
key={v.id}
|
||||
video={v}
|
||||
columns={columns}
|
||||
dragDisabled={!canEdit || viewActive}
|
||||
dragHint={dragHint}
|
||||
menuProps={rowMenu.triggerProps(v)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</table>
|
||||
{shown.length === 0 && (
|
||||
<div className="text-muted py-8 text-center text-sm">
|
||||
{t("playlists.noneMatchFilter")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */}
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
|
||||
|
||||
{playing && (
|
||||
<Suspense fallback={null}>
|
||||
<PlayerModal
|
||||
video={playing.video}
|
||||
queue={playing.queue}
|
||||
startIndex={playing.index}
|
||||
startAt={null}
|
||||
onClose={closePlayer}
|
||||
onState={playerState}
|
||||
onRemoveFromList={canEdit ? removeFromPlayer : undefined}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */}
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
|
||||
</div>
|
||||
{player}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,9 @@
|
||||
"movedToast": "“{{title}}” moved to “{{name}}” ✓",
|
||||
"moveFailed": "Couldn't move the video.",
|
||||
"removeFailed": "Couldn't remove the video.",
|
||||
"errors": {
|
||||
"not_found": "This playlist doesn't exist any more."
|
||||
},
|
||||
"moveErrors": {
|
||||
"not_in_playlist": "That video isn't in this playlist any more — it changed somewhere else.",
|
||||
"same_playlist": "That video is already in this playlist."
|
||||
|
||||
@@ -77,6 +77,9 @@
|
||||
"movedToast": "„{{title}}” áthelyezve ide: „{{name}}” ✓",
|
||||
"moveFailed": "Nem sikerült áthelyezni a videót.",
|
||||
"removeFailed": "Nem sikerült eltávolítani a videót.",
|
||||
"errors": {
|
||||
"not_found": "Ez a lejátszási lista már nem létezik."
|
||||
},
|
||||
"moveErrors": {
|
||||
"not_in_playlist": "Ez a videó már nincs ebben a listában — időközben máshol megváltozott.",
|
||||
"same_playlist": "Ez a videó már ebben a listában van."
|
||||
|
||||
@@ -21,7 +21,7 @@ vi.mock("../activeAccount", () => ({
|
||||
notifyActiveAccountChange: vi.fn(),
|
||||
}));
|
||||
|
||||
import { uploadReq, setUnauthorizedHandler, HttpError } from "./core";
|
||||
import { req, uploadReq, setUnauthorizedHandler, HttpError } from "./core";
|
||||
|
||||
// A fake XHR whose lifecycle the test drives: capture the instance, set status/responseText, then
|
||||
// invoke onload/onerror/abort by hand.
|
||||
@@ -117,3 +117,21 @@ describe("uploadReq", () => {
|
||||
expect(reportError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("req — a structured refusal reads in the user's language", () => {
|
||||
it("names a playlist that is gone instead of passing the server's English through", async () => {
|
||||
// The 404 every playlist route answers when the list was deleted in another tab. It used to be a
|
||||
// bare "Unknown playlist" string, shown verbatim under the Hungarian UI.
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: { code: "playlist", reason: "not_found" } }), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
await expect(req("/api/playlists/7/items/v1", { method: "DELETE" })).rejects.toMatchObject({
|
||||
status: 404,
|
||||
detail: "playlists.errors.not_found",
|
||||
});
|
||||
expect(reportError).not.toHaveBeenCalled(); // a 404 is the caller's to report
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,8 @@ function localizeDetail(d: unknown): string {
|
||||
// unknown reason falls back to the plain failure rather than printing a key.
|
||||
if (o?.code === "playlist_move")
|
||||
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
|
||||
// Any playlist route whose list is gone (deleted in another tab) — or was never the caller's.
|
||||
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
|
||||
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
|
||||
// limit, and the copy says so.
|
||||
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
|
||||
|
||||
Reference in New Issue
Block a user