feat(playlists): hybrid table over the drag list, and guard its destructive paths

The column sort and the filters are a VIEW; the playlist's stored order only
changes when the user presses "Save this order", which still goes through the
undoable path. Picking a sort used to write straight to the server, so merely
looking at the list by title rewrote the playlist and marked a YouTube-linked
mirror dirty.

The table:
- playlistColumns.tsx holds the column defs; the body renders them as table rows
  and fills the grip cell itself, since the handle needs the row's sortable
  listeners that a render closure can't reach.
- The # column shows the STORED position, so a view-sort never renumbers the
  playlist under the reader.
- Saving is withheld while a filter is active (it would drop the hidden items),
  and drag is disabled under any view, with the reason spelled out rather than
  silently doing nothing.
- applyView/sameOrder live in lib/playlistView.ts with tests: the sort and the
  channel grouping compose, and getting that wrong produces a plausible order
  that the save button would then persist.
- dnd-kit KeyboardSensor: the grip was focusable and called itself "reorder",
  but only a pointer could move it.

The guards:
- Delete is ONE dialog with every outcome. It was two chained confirms where the
  second one's Cancel/Escape/backdrop meant "delete here only" — dismissing the
  dialog deleted the playlist. ConfirmProvider gained useChoice() for it.
- ConfirmProvider focuses Cancel when an offered action is destructive; the
  confirm button took autoFocus unconditionally, so a stray Enter on an open
  dialog ran "Delete user" or "Clear audit log".
- Removing an item asks first, and rebases the undo history instead of throwing
  it away: useUndoable.rebase maps every snapshot, so the reorder history stays
  valid for the remaining items.
- A failed detail query showed "Loading…" forever; it now says so and offers a
  retry. Rename and reorder failures are surfaced instead of swallowed (reorder
  was a bare .then(), which also produced an unhandled rejection).
- Local reorder/remove no longer require YouTube write scope.
- playlistName() replaces the watch-later localizer copied into three files.
This commit is contained in:
2026-07-20 23:30:49 +02:00
parent 89000538d4
commit 83e673718f
13 changed files with 851 additions and 437 deletions
+2 -1
View File
@@ -6,6 +6,7 @@ import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { playlistName } from "../lib/playlistName";
// A small popover (portaled to body, so the feed grid can't clip it) for toggling a
// video's membership in the user's playlists, with inline "new playlist" creation.
@@ -159,7 +160,7 @@ export default function AddToPlaylist({
{pl.has_video && <Check className="w-3 h-3" />}
</span>
<span className="truncate flex-1 text-left">
{pl.kind === "watch_later" ? t("playlists.watchLater") : pl.name}
{playlistName(pl, t)}
</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube className="w-3 h-3 shrink-0 text-muted" />
+76 -25
View File
@@ -5,6 +5,11 @@ import Modal from "./Modal";
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
// if (await confirm({ message: "…", danger: true })) { … }
//
// For a decision with more than two outcomes use `useChoice()` instead — never chain two
// confirms, because the second dialog's Cancel/Escape/backdrop is then indistinguishable from
// a deliberate answer (the playlist delete used to read a dismissal as "delete here only" and
// went ahead and deleted).
export interface ConfirmOptions {
title?: string;
message: ReactNode;
@@ -13,62 +18,108 @@ export interface ConfirmOptions {
danger?: boolean;
}
export interface Choice {
id: string;
label: string;
danger?: boolean;
}
export interface ChoiceOptions {
title?: string;
message: ReactNode;
cancelLabel?: string;
/** Rendered left→right after Cancel. Resolves to the clicked choice's `id`. */
choices: Choice[];
}
type ChoiceFn = (opts: ChoiceOptions) => Promise<string | null>;
type ConfirmFn = (opts: ConfirmOptions) => Promise<boolean>;
const ConfirmContext = createContext<ConfirmFn>(() => Promise.resolve(false));
const ChoiceContext = createContext<ChoiceFn>(() => Promise.resolve(null));
/** Two outcomes: resolves true on confirm, false on cancel/Escape/backdrop. */
export function useConfirm(): ConfirmFn {
return useContext(ConfirmContext);
const ask = useContext(ChoiceContext);
return useCallback(
async (opts: ConfirmOptions) => {
const picked = await ask({
title: opts.title,
message: opts.message,
cancelLabel: opts.cancelLabel,
choices: [
{ id: "confirm", label: opts.confirmLabel ?? "", danger: opts.danger },
],
});
return picked === "confirm";
},
[ask]
);
}
/** Three or more outcomes. Resolves to the chosen id, or **null** when the user cancels,
* presses Escape or clicks the backdrop — dismissal is never one of the actions. */
export function useChoice(): ChoiceFn {
return useContext(ChoiceContext);
}
export function ConfirmProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const [state, setState] = useState<{
opts: ConfirmOptions;
resolve: (ok: boolean) => void;
opts: ChoiceOptions;
resolve: (id: string | null) => void;
} | null>(null);
const confirm = useCallback<ConfirmFn>(
(opts) => new Promise<boolean>((resolve) => setState({ opts, resolve })),
const ask = useCallback<ChoiceFn>(
(opts) => new Promise<string | null>((resolve) => setState({ opts, resolve })),
[]
);
function close(ok: boolean) {
state?.resolve(ok);
function close(id: string | null) {
state?.resolve(id);
setState(null);
}
const choices = state?.opts.choices ?? [];
// Focus Cancel when any offered action is destructive: the confirm button used to take
// autoFocus unconditionally, so a stray Enter on an open dialog executed "Delete user" or
// "Clear audit log" without the user ever looking at it.
const anyDanger = choices.some((c) => c.danger);
return (
<ConfirmContext.Provider value={confirm}>
<ChoiceContext.Provider value={ask}>
{children}
{state && (
<Modal
title={state.opts.title ?? t("common.confirmTitle")}
onClose={() => close(false)}
maxWidth="max-w-sm"
onClose={() => close(null)}
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
>
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
<div className="flex justify-end gap-2 mt-5">
<div className="flex justify-end gap-2 mt-5 flex-wrap">
<button
onClick={() => close(false)}
autoFocus={anyDanger}
onClick={() => close(null)}
className="px-4 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg hover:bg-surface transition"
>
{state.opts.cancelLabel ?? t("common.cancel")}
</button>
<button
autoFocus
onClick={() => close(true)}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
state.opts.danger
? "bg-red-500 text-white hover:bg-red-600"
: "bg-accent text-accent-fg hover:opacity-90"
}`}
>
{state.opts.confirmLabel ?? t("common.confirm")}
</button>
{choices.map((c, i) => (
<button
key={c.id}
autoFocus={!anyDanger && i === choices.length - 1}
onClick={() => close(c.id)}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
c.danger
? "bg-red-500 text-white hover:bg-red-600"
: "bg-accent text-accent-fg hover:opacity-90"
}`}
>
{c.label || t("common.confirm")}
</button>
))}
</div>
</Modal>
)}
</ConfirmContext.Provider>
</ChoiceContext.Provider>
);
}
+425 -393
View File
@@ -1,8 +1,9 @@
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
@@ -12,13 +13,14 @@ import {
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ArrowDown,
ArrowUp,
AlertTriangle,
Check,
ExternalLink,
GripVertical,
ListPlus,
@@ -28,66 +30,36 @@ import {
RotateCcw,
Trash2,
Upload,
X,
Youtube,
} from "lucide-react";
import { api, type Video } from "../lib/api";
import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { playlistName } from "../lib/playlistName";
import { useUndoable } from "../lib/useUndoable";
import { cycleSort, type SortState } from "../lib/columnSort";
import { applyView, sameOrder } from "../lib/playlistView";
const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar";
import { PageToolbar } from "./PageShell";
import { useConfirm } from "./ConfirmProvider";
import { useChoice, useConfirm } from "./ConfirmProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
import { ColumnHead, alignClass, filterRows, isActive, type Column, type FilterMap } from "./tableHeader";
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
type SortKey = "manual" | "title" | "duration" | "channel";
type SortDir = "asc" | "desc";
function comparator(key: SortKey, dir: SortDir): ((a: Video, b: Video) => number) | null {
if (key === "manual") return null;
const sign = dir === "asc" ? 1 : -1;
if (key === "duration")
return (a, b) => {
const av = a.duration_seconds;
const bv = b.duration_seconds;
if (av == null && bv == null) return 0;
if (av == null) return 1; // nulls always last, both directions
if (bv == null) return -1;
return sign * (av - bv);
};
return (a, b) => {
const av = (key === "channel" ? a.channel_title : a.title) ?? "";
const bv = (key === "channel" ? b.channel_title : b.title) ?? "";
return sign * av.localeCompare(bv);
};
}
// Sort a playlist's items. When `group` is on, items are grouped by channel (groups ordered
// by channel name in the chosen direction) and the chosen sort is applied within each group;
// with key "manual" the within-group order is left as-is. Returns the same array reference
// when nothing would change, so it doesn't create a spurious undo entry.
function sortItems(items: Video[], key: SortKey, dir: SortDir, group: boolean): Video[] {
const cmp = comparator(key, dir);
if (!group) return cmp ? [...items].sort(cmp) : items;
const groups = new Map<string, Video[]>();
for (const v of items) {
const k = v.channel_title ?? "";
const g = groups.get(k);
if (g) g.push(v);
else groups.set(k, [v]);
}
const sign = dir === "asc" ? 1 : -1;
const keys = [...groups.keys()].sort((a, b) => sign * a.localeCompare(b));
const out: Video[] = [];
for (const k of keys) {
const g = groups.get(k)!;
out.push(...(cmp ? [...g].sort(cmp) : g));
}
return out;
}
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
// body that is still a drag-reorderable @dnd-kit list.
//
// The distinction that shapes the whole module: **the column sort and the filters are a VIEW**,
// while the playlist's own order is stored data. Before S4 the sort control wrote straight through
// to the server, so picking "sort by title" silently rewrote the playlist and marked a
// YouTube-linked mirror dirty. Now nothing is written until the user presses "Save this order",
// which still goes through the undoable path — the capability is intact, it just stopped being a
// side effect of looking at the list.
//
// It composes `tableHeader`'s pieces instead of rendering a `DataTable`, because DataTable owns
// its own filter/sort/page state and the stored order needs a single owner.
const idsKey = (items: Video[]) =>
items
@@ -97,86 +69,56 @@ const idsKey = (items: Video[]) =>
function Row({
video,
index,
readOnly,
onPlay,
onRemove,
columns,
dragDisabled,
dragHint,
}: {
video: Video;
index: number;
readOnly?: boolean;
onPlay: () => void;
onRemove: () => void;
columns: Column<Video>[];
dragDisabled: boolean;
dragHint: string;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: video.id, disabled: readOnly });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: video.id,
disabled: dragDisabled,
});
return (
<div
<tr
ref={setNodeRef}
style={style}
className="flex items-center gap-2.5 p-2 rounded-lg glass-card glass-hover"
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
}}
className="border-b border-border/50 hover:bg-card/40 transition"
>
{readOnly ? (
<span className="w-4" />
) : (
<button
{...attributes}
{...listeners}
className="text-muted hover:text-fg cursor-grab active:cursor-grabbing"
aria-label="reorder"
{columns.map((col) => (
<td
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={`px-2 py-1.5 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}`}
>
<GripVertical className="w-4 h-4" />
</button>
)}
<span className="text-xs text-muted w-4 text-center tabular-nums">{index + 1}</span>
<button
onClick={onPlay}
aria-label={`${t("card.play")}${video.title}`}
className="relative w-[62px] h-[35px] rounded overflow-hidden bg-surface shrink-0 group"
>
{video.thumbnail_url && (
<img src={video.thumbnail_url} alt="" className="w-full h-full object-cover" />
)}
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover:opacity-100 transition">
<Play className="w-4 h-4 text-white fill-current" />
</span>
</button>
<button onClick={onPlay} className="min-w-0 flex-1 text-left">
<div className="text-[13px] text-fg truncate">{video.title}</div>
<div className="text-[11px] text-muted truncate">{video.channel_title}</div>
</button>
{video.duration_seconds != null ? (
<span className="text-[11px] text-muted tabular-nums">
{formatDuration(video.duration_seconds)}
</span>
) : video.live_status === "live" || video.live_status === "upcoming" ? (
<span
className={`text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
video.live_status === "live"
? "bg-red-500/90 text-white"
: "bg-card border border-border text-muted"
}`}
>
{t(`card.${video.live_status}`)}
</span>
) : null}
{!readOnly && (
<button
onClick={onRemove}
title="remove"
className="text-muted hover:text-fg p-1"
aria-label="remove from playlist"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{col.key === GRIP_KEY ? (
// The one cell the column defs can't render: the handle needs this row's sortable
// attributes/listeners. Disabled (not hidden) under a view, so the affordance stays
// put and can explain itself instead of silently doing nothing.
<button
{...attributes}
{...listeners}
disabled={dragDisabled}
title={dragDisabled ? dragHint : t("playlists.dragHandle")}
aria-label={t("playlists.dragHandleOf", { title: video.title })}
className="text-muted enabled:hover:text-fg enabled:cursor-grab enabled:active:cursor-grabbing disabled:opacity-30 disabled:cursor-not-allowed"
>
<GripVertical className="w-4 h-4" />
</button>
) : (
col.render(video)
)}
</td>
))}
</tr>
);
}
@@ -190,31 +132,44 @@ export default function Playlists() {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const choose = useChoice();
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
// The item order is undoable: drag, sort and group all go through `order.set`, so each is
// reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server.
// The stored order is undoable: drag and "save this order" both go through `order.set`, so each
// is reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server.
const order = useUndoable<Video[]>([], {
onApply: (next) => {
if (selectedId == null) return;
api.reorderPlaylist(selectedId, next.map((v) => v.id)).then(() => {
// Refresh the sidebar (cover may change) and the detail (so the now-dirty state,
// and the Reset/Unsynced indicators, show without an F5). The membership guard
// keeps the refetch from wiping the undo history on a pure reorder.
qc.invalidateQueries({ queryKey: ["playlists"] });
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
});
api
.reorderPlaylist(
selectedId,
next.map((v) => v.id)
)
.then(() => {
// Refresh the sidebar (cover may change) and the detail (so the now-dirty state,
// and the Reset/Unsynced indicators, show without an F5). The membership guard
// keeps the refetch from wiping the undo history on a pure reorder.
qc.invalidateQueries({ queryKey: ["playlists"] });
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
})
.catch((e) => {
// Was a bare .then(): a failed reorder left the UI showing an order the server had
// rejected, and produced an unhandled rejection. Say so and resync from the server.
notifyYouTubeActionError(e, t("playlists.reorderFailed"));
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
});
},
});
const items = order.value;
const [sortKey, setSortKey] = useState<SortKey>("manual");
const [sortDir, setSortDir] = useState<SortDir>("asc");
// VIEW state — never written to the server on its own.
const [viewSort, setViewSort] = useState<SortState>(null);
const [filters, setFilters] = useState<FilterMap>({});
const [groupBy, setGroupBy] = useState(false);
// Tracks the membership (id set) currently loaded into `order`, so a refetch that only
// re-confirms our own reorder doesn't wipe the undo history — we reset history only when
// the actual set of items changes (different playlist, or an add/remove).
const lastSetRef = useRef<string>("");
// The open player, as an index into `items` (the queue); null = closed.
// The open player, as an index into the DISPLAYED rows (the queue); null = closed.
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
const listQuery = useQuery({
@@ -244,43 +199,85 @@ export default function Playlists() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detail]);
// Reset the sort controls when switching playlists (the order itself is per-playlist).
// Reset the view when switching playlists (the stored order itself is per-playlist).
useEffect(() => {
setSortKey("manual");
setSortDir("asc");
setViewSort(null);
setFilters({});
setGroupBy(false);
}, [selectedId]);
function applySort(key: SortKey, dir: SortDir, group: boolean) {
order.set(sortItems(items, key, dir, group));
}
const undo = () => {
setSortKey("manual");
setGroupBy(false);
order.undo();
};
const redo = () => {
setSortKey("manual");
setGroupBy(false);
order.redo();
};
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
);
// Watch later is a built-in playlist: show a localized name and no rename/delete.
const plName = (p: { kind: string; name: string }) =>
p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
const builtin = detail?.kind === "watch_later";
// YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a
// dirty mirror so it won't clobber unpushed edits); the YT-link button marks their origin.
const editable = !builtin;
const linked = editable && !!detail?.yt_playlist_id;
// LOCAL editing (reorder, remove) needs no YouTube write scope — `canWrite` gates only the
// 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 [pushing, setPushing] = useState(false);
const [reverting, setReverting] = useState(false);
const onPlay = useCallback((v: Video) => {
setPlayingIndex((_) => shownRef.current.findIndex((x) => x.id === v.id));
}, []);
const onRemove = useCallback((v: Video) => {
removeRef.current(v);
}, []);
const storedIndex = useMemo(() => {
const m = new Map(items.map((v, i) => [v.id, i]));
return (v: Video) => m.get(v.id) ?? 0;
}, [items]);
const columns = useMemo(
() => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }),
[t, storedIndex, onPlay, onRemove, canEdit]
);
const filterActive = Object.values(filters).some(isActive);
const shown = useMemo(
() => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy),
[items, columns, filters, viewSort, groupBy]
);
// Read by the memoized onPlay callback without making it depend on the list.
const shownRef = useRef(shown);
shownRef.current = shown;
const viewActive = viewSort !== null || groupBy || filterActive;
// Saving a FILTERED view would drop every hidden item from the playlist, so the button only
// offers itself when the view is a pure reordering of the whole list.
const canSaveOrder = canEdit && !filterActive && !sameOrder(shown, items);
function toggleSort(key: string) {
setViewSort((s) => cycleSort(s, key));
}
function applyFilter(key: string, value: string | string[]) {
setFilters((f) => ({ ...f, [key]: value }));
}
function clearView() {
setViewSort(null);
setFilters({});
setGroupBy(false);
}
function saveOrder() {
order.set(shown); // onApply persists it
clearView();
}
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
// The grip was focusable and called itself "reorder", but only a pointer could move it.
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
function refreshAll() {
qc.invalidateQueries({ queryKey: ["playlists"] });
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
}
async function revertToYoutube() {
if (selectedId == null || !detail || reverting) return;
const ok = await confirm({
@@ -345,7 +342,10 @@ export default function Playlists() {
const r = await api.pushPlaylist(selectedId);
refreshAll();
if (r.failures.length) {
notify({ level: "warning", message: t("playlists.pushPartial", { count: r.failures.length }) });
notify({
level: "warning",
message: t("playlists.pushPartial", { count: r.failures.length }),
});
} else {
notify({ level: "success", message: t("playlists.pushDone", { name: plName(detail) }) });
}
@@ -356,32 +356,31 @@ export default function Playlists() {
}
}
function refreshAll() {
qc.invalidateQueries({ queryKey: ["playlists"] });
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
}
function onDragEnd(e: DragEndEvent) {
const { active: a, over } = e;
if (!over || a.id === over.id || selectedId == null) return;
const oldIndex = items.findIndex((v) => v.id === a.id);
const newIndex = items.findIndex((v) => v.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
// A manual drag breaks any active sort/grouping; reflect that in the controls.
setSortKey("manual");
setGroupBy(false);
order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists
}
async function removeItem(videoId: string) {
async function removeItem(video: Video) {
if (selectedId == null) return;
// Membership changes aren't undoable: reset the order to the new set (clearing history)
// and keep lastSetRef in sync so the follow-up refetch doesn't reset again.
const next = items.filter((v) => v.id !== videoId);
order.reset(next);
const ok = await confirm({
title: t("playlists.removeItem"),
message: t("playlists.confirmRemove", { title: video.title }),
confirmLabel: t("playlists.removeItem"),
danger: true,
});
if (!ok) return;
// A removal isn't itself undoable, but it no longer THROWS AWAY the reorder history: rebasing
// drops the item from every snapshot, so the stack stays valid for what's left.
const next = items.filter((v) => v.id !== video.id);
order.rebase((list) => list.filter((v) => v.id !== video.id));
lastSetRef.current = idsKey(next);
try {
await api.removeFromPlaylist(selectedId, videoId);
await api.removeFromPlaylist(selectedId, video.id);
refreshAll();
} catch {
// The optimistic removal above didn't stick — resync from the server to restore the row.
@@ -389,37 +388,43 @@ export default function Playlists() {
refreshAll();
}
}
// Kept in a ref so the memoized column callbacks don't rebuild on every state change.
const removeRef = useRef(removeItem);
removeRef.current = removeItem;
async function saveRename() {
if (selectedId == null) return;
const name = renameValue.trim();
setRenaming(false);
if (name && name !== detail?.name) {
await api.renamePlaylist(selectedId, name);
try {
await api.renamePlaylist(selectedId, name);
} catch (e) {
notifyYouTubeActionError(e, t("playlists.renameFailed"));
}
refreshAll();
}
}
async function deletePlaylist() {
if (selectedId == null || !detail) return;
const ok = await confirm({
// ONE dialog with every outcome. It used to be two chained confirms, where the second one's
// Cancel/Escape/backdrop meant "delete here only" — dismissing the dialog deleted the playlist.
const offerYoutube = linked && canWrite;
const picked = await choose({
title: t("playlists.delete"),
message: t("playlists.confirmDelete", { name: detail.name }),
confirmLabel: t("playlists.delete"),
danger: true,
message: offerYoutube
? t("playlists.confirmDeleteLinked", { name: plName(detail) })
: t("playlists.confirmDelete", { name: plName(detail) }),
choices: offerYoutube
? [
{ id: "here", label: t("playlists.deleteHereOnly"), danger: true },
{ id: "both", label: t("playlists.deleteOnYoutubeConfirm"), danger: true },
]
: [{ id: "here", label: t("playlists.delete"), danger: true }],
});
if (!ok) return;
// For a YouTube-linked playlist, offer to delete it on YouTube too (Cancel = here only).
let onYoutube = false;
if (linked && canWrite) {
onYoutube = await confirm({
title: t("playlists.deleteOnYoutubeTitle"),
message: t("playlists.deleteOnYoutubeMsg", { name: detail.name }),
confirmLabel: t("playlists.deleteOnYoutubeConfirm"),
cancelLabel: t("playlists.deleteHereOnly"),
danger: true,
});
}
if (picked == null) return;
const onYoutube = picked === "both";
const deletedId = selectedId;
// Pick the next selection from what's left *now* (don't go via null, or the
// auto-select effect would re-pick the just-deleted id from the stale list).
@@ -428,7 +433,10 @@ export default function Playlists() {
try {
await api.deletePlaylist(deletedId, onYoutube);
} catch {
notify({ level: "warning", message: t("playlists.deleteYtFailed") });
notify({
level: "warning",
message: onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed"),
});
}
qc.removeQueries({ queryKey: ["playlist", deletedId] });
qc.invalidateQueries({ queryKey: ["playlists"] });
@@ -441,237 +449,261 @@ export default function Playlists() {
});
}
// Stable identity: PlayerModal's keydown/scroll-lock effect depends on it, so a fresh closure
// every render re-subscribed those listeners and re-ran focusModal() under the open player.
const closePlayer = useCallback(() => setPlayingIndex(null), []);
const dragHint = filterActive
? t("playlists.dragBlockedFilter")
: t("playlists.dragBlockedSort");
if (selectedId == null) {
return <div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>;
}
// 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-red-400" />
{t("playlists.loadFailed")}
</span>
<button
onClick={() => detailQuery.refetch()}
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted hover:text-fg hover:border-accent transition"
>
{t("common.retry")}
</button>
</div>
);
}
if (!detail) {
return <div className="text-muted text-sm p-4">{t("playlists.loading")}</div>;
}
return (
<div className="px-4 pb-4 pt-3">
{selectedId == null || !detail ? (
<div className="text-muted text-sm p-4">
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
</div>
) : (
<>
{/* Header (title, actions, sort/group) is fixed chrome — it portals into the shell's
fixed band, so only the video list scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 pb-2">
<div className="flex gap-3 items-start mb-4">
<div className="w-24 h-[54px] 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">
{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);
}}
className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-none focus:border-accent"
/>
) : (
<div className="flex items-center gap-2 min-w-0">
<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>
)}
<div className="text-xs text-muted mt-0.5">
{t("playlists.itemCount", { count: items.length })}
</div>
<div className="flex gap-1.5 mt-3 flex-wrap items-center">
<button
onClick={() => items.length && setPlayingIndex(0)}
disabled={!items.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-accent text-accent-fg disabled:opacity-40 hover:opacity-90 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
onClick={revertToYoutube}
disabled={reverting}
title={t("playlists.revert")}
aria-label={t("playlists.revert")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
>
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
</button>
)}
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
fixed band, so only the video list scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 pb-2">
<div className="flex gap-3 items-start mb-4">
<div className="w-24 h-[54px] 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">
{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="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-none focus:border-accent"
/>
) : (
<div className="flex items-center gap-2 min-w-0">
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{editable && (
<button
onClick={deletePlaylist}
title={t("playlists.delete")}
aria-label={t("playlists.delete")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-red-400 hover:border-red-400/50 transition"
onClick={() => {
setRenameValue(detail.name);
setRenaming(true);
}}
title={t("playlists.rename")}
aria-label={t("playlists.rename")}
className="shrink-0 text-muted hover:text-fg"
>
<Trash2 className="w-3.5 h-3.5" />
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{linked && detail.dirty && (
<span
title={t("playlists.unsyncedHint")}
className="inline-flex items-center gap-1.5 text-[11px] text-accent ml-1"
{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"
>
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
</span>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
)}
<div className="text-xs text-muted mt-0.5">
{t("playlists.itemCount", { count: items.length })}
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
</div>
<div className="flex gap-1.5 mt-3 flex-wrap items-center">
<button
onClick={() => shown.length && setPlayingIndex(0)}
disabled={!shown.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-accent text-accent-fg disabled:opacity-40 hover:opacity-90 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
onClick={revertToYoutube}
disabled={reverting}
title={t("playlists.revert")}
aria-label={t("playlists.revert")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
>
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
</button>
)}
{editable && (
<button
onClick={deletePlaylist}
title={t("playlists.delete")}
aria-label={t("playlists.delete")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-red-400 hover:border-red-400/50 transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{linked && detail.dirty && (
<span
title={t("playlists.unsyncedHint")}
className="inline-flex items-center gap-1.5 text-[11px] text-accent ml-1"
>
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
</span>
)}
</div>
</div>
</div>
{editable && items.length > 1 && (
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-3xl">
<span className="text-xs text-muted">{t("playlists.sortLabel")}</span>
<select
value={sortKey}
onChange={(e) => {
const k = e.target.value as SortKey;
setSortKey(k);
applySort(k, sortDir, groupBy);
}}
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
>
<option value="manual">{t("playlists.sortManual")}</option>
<option value="title">{t("playlists.sortTitle")}</option>
<option value="duration">{t("playlists.sortDuration")}</option>
<option value="channel">{t("playlists.sortChannel")}</option>
</select>
{items.length > 1 && (
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-5xl">
<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={() => {
const d = sortDir === "asc" ? "desc" : "asc";
setSortDir(d);
if (sortKey !== "manual" || groupBy) applySort(sortKey, d, groupBy);
}}
disabled={sortKey === "manual" && !groupBy}
title={sortDir === "asc" ? t("playlists.dirAsc") : t("playlists.dirDesc")}
className="p-1.5 rounded-lg border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"
onClick={clearView}
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
>
{sortDir === "asc" ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
{t("playlists.clearView")}
</button>
<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);
applySort(sortKey, sortDir, e.target.checked);
}}
className="accent-accent"
/>
{t("playlists.groupByChannel")}
</label>
)}
{canSaveOrder && (
<button
onClick={saveOrder}
title={t("playlists.saveOrderHint")}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
>
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
</button>
)}
{viewActive && (
<span className="text-[11px] text-muted/80">{dragHint}</span>
)}
{canEdit && (
<div className="ml-auto">
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={undo}
onRedo={redo}
onUndo={order.undo}
onRedo={order.redo}
/>
</div>
</div>
)}
)}
</div>
</PageToolbar>
)}
</div>
</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")}
</div>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
{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>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
<div className="max-w-5xl">
<table className="w-full border-collapse text-sm">
<ColumnHead
columns={columns}
sort={viewSort}
onToggleSort={toggleSort}
filters={filters}
onFilter={applyFilter}
/>
<SortableContext
items={shown.map((v) => v.id)}
strategy={verticalListSortingStrategy}
>
<SortableContext
items={items.map((v) => v.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1.5 max-w-3xl">
{items.map((v, i) => (
<Row
key={v.id}
video={v}
index={i}
onPlay={() => setPlayingIndex(i)}
onRemove={() => removeItem(v.id)}
/>
))}
</div>
</SortableContext>
</DndContext>
<tbody>
{shown.map((v) => (
<Row
key={v.id}
video={v}
columns={columns}
dragDisabled={!canEdit || viewActive}
dragHint={dragHint}
/>
))}
</tbody>
</SortableContext>
</table>
{shown.length === 0 && (
<div className="text-muted py-8 text-center text-sm">
{t("playlists.noneMatchFilter")}
</div>
)}
</>
)}
</div>
</DndContext>
)}
{playingIndex != null && items[playingIndex] && (
{playingIndex != null && shown[playingIndex] && (
<Suspense fallback={null}>
<PlayerModal
video={items[playingIndex]}
queue={items}
video={shown[playingIndex]}
queue={shown}
startIndex={playingIndex}
startAt={null}
onClose={() => setPlayingIndex(null)}
onClose={closePlayer}
onState={playerState}
/>
</Suspense>
+2 -2
View File
@@ -12,6 +12,7 @@ import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
import { playlistName } from "../lib/playlistName";
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean };
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
@@ -69,8 +70,7 @@ export default function PlaylistsRail({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playlists, selectedId, listQuery.data]);
const plName = (p: { kind: string; name: string }) =>
p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
const sortedPlaylists = useMemo(() => {
const sign = plSort.dir === "asc" ? 1 : -1;
+128
View File
@@ -0,0 +1,128 @@
import { Play, X } from "lucide-react";
import type { TFunction } from "i18next";
import type { Video } from "../lib/api";
import { formatDuration } from "../lib/format";
import type { Column } from "./tableHeader";
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
//
// The `grip` column carries no renderer: the drag handle needs the `useSortable` attributes that
// only exist inside the row component, which a `render(row)` closure can't reach. It stays in the
// list anyway so ONE column array drives both the header and the body — the row renderer fills
// that cell itself. Everything else is an ordinary cell.
export const GRIP_KEY = "grip";
export function playlistColumns({
t,
indexOf,
onPlay,
onRemove,
canEdit,
}: {
t: TFunction;
/** Position in the STORED order (1-based on screen) — not the row's position in the current view,
* so a view-sort never renumbers the playlist under the reader. */
indexOf: (v: Video) => number;
onPlay: (v: Video) => void;
onRemove: (v: Video) => void;
canEdit: boolean;
}): Column<Video>[] {
const cols: Column<Video>[] = [
{ key: GRIP_KEY, header: "", width: "2.25rem", render: () => null, hideInCard: true },
{
key: "pos",
header: "#",
align: "right",
width: "3rem",
nowrap: true,
hideInCard: true,
render: (v) => <span className="text-xs text-muted tabular-nums">{indexOf(v) + 1}</span>,
},
{
key: "title",
header: t("playlists.colTitle"),
sortable: true,
sortValue: (v) => v.title ?? "",
filter: { kind: "text", get: (v) => v.title ?? "" },
cardPrimary: true,
render: (v) => (
<div className="flex items-center gap-2.5 min-w-0">
<button
onClick={() => onPlay(v)}
aria-label={`${t("card.play")}${v.title}`}
className="relative w-[62px] h-[35px] rounded overflow-hidden bg-surface shrink-0 group"
>
{v.thumbnail_url && (
<img src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
)}
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition">
<Play className="w-4 h-4 text-white fill-current" />
</span>
</button>
<button onClick={() => onPlay(v)} className="min-w-0 flex-1 text-left">
<span className="block text-[13px] text-fg truncate max-w-[34rem]">{v.title}</span>
</button>
</div>
),
},
{
key: "channel",
header: t("playlists.colChannel"),
sortable: true,
sortValue: (v) => v.channel_title ?? "",
filter: { kind: "text", get: (v) => v.channel_title ?? "" },
render: (v) => (
<span className="text-[13px] text-muted truncate block max-w-[14rem]">{v.channel_title}</span>
),
},
{
key: "duration",
header: t("playlists.colDuration"),
align: "right",
nowrap: true,
sortable: true,
// Nulls (live/upcoming, or not yet enriched) sort last in BOTH directions in the old
// comparator; sortRows is a plain numeric compare, so park them at the end of an ascending
// sort with a large sentinel rather than letting them read as zero-length.
sortValue: (v) => v.duration_seconds ?? Number.MAX_SAFE_INTEGER,
render: (v) =>
v.duration_seconds != null ? (
<span className="text-[11px] text-muted tabular-nums">
{formatDuration(v.duration_seconds)}
</span>
) : v.live_status === "live" || v.live_status === "upcoming" ? (
<span
className={`text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
v.live_status === "live"
? "bg-red-500/90 text-white"
: "bg-card border border-border text-muted"
}`}
>
{t(`card.${v.live_status}`)}
</span>
) : null,
},
];
if (canEdit) {
cols.push({
key: "actions",
header: "",
align: "right",
width: "2.5rem",
nowrap: true,
render: (v) => (
<button
onClick={() => onRemove(v)}
title={t("playlists.removeItem")}
aria-label={t("playlists.removeItemOf", { title: v.title })}
className="text-muted hover:text-fg p-1"
>
<X className="w-4 h-4" />
</button>
),
});
}
return cols;
}
+2 -1
View File
@@ -17,5 +17,6 @@
"accessRequestsReview": "Review",
"demoTitle": "Demo account",
"demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.",
"backToTop": "Back to top"
"backToTop": "Back to top",
"retry": "Retry"
}
+21 -7
View File
@@ -42,16 +42,10 @@
"pushDone": "“{{name}}” synced to YouTube ✓",
"pushPartial": "Synced, but {{count}} item(s) were skipped.",
"pushFailed": "Couldn't sync to YouTube.",
"deleteOnYoutubeTitle": "Delete on YouTube too?",
"deleteOnYoutubeMsg": "Also delete “{{name}}” from your YouTube account? Choose “Here only” to keep it on YouTube.",
"deleteOnYoutubeConfirm": "Delete on YouTube",
"deleteHereOnly": "Here only",
"deleteYtFailed": "Couldn't delete on YouTube; removed here only.",
"sortLabel": "Sort",
"sortManual": "Custom order",
"sortTitle": "Title",
"sortDuration": "Duration",
"sortChannel": "Channel",
"dirAsc": "Ascending",
"dirDesc": "Descending",
"groupByChannel": "Group by channel",
@@ -59,5 +53,25 @@
"railSortName": "Name",
"railSortCount": "Item count",
"railSortDuration": "Total length",
"dirtyFirst": "Unsynced first"
"dirtyFirst": "Unsynced first",
"colTitle": "Title",
"colChannel": "Channel",
"colDuration": "Length",
"removeItem": "Remove",
"removeItemOf": "Remove “{{title}}” from the playlist",
"confirmRemove": "Remove “{{title}}” from this playlist?",
"dragHandle": "Drag to reorder",
"dragHandleOf": "Reorder “{{title}}”",
"dragBlockedSort": "Reordering by hand is off while a column sort or grouping is applied.",
"dragBlockedFilter": "Reordering by hand is off while a filter is applied.",
"clearView": "Reset view",
"saveOrder": "Save this order",
"saveOrderHint": "Store the order you see as the playlist's own order (undoable).",
"filteredCount": "{{count}} shown",
"noneMatchFilter": "No videos match the filter.",
"loadFailed": "Couldn't load this playlist.",
"reorderFailed": "Couldn't save the new order.",
"renameFailed": "Couldn't rename the playlist.",
"deleteFailed": "Couldn't delete the playlist.",
"confirmDeleteLinked": "Delete the playlist “{{name}}”? It is synced with YouTube — choose whether to delete it there as well. This can't be undone."
}
+2 -1
View File
@@ -17,5 +17,6 @@
"accessRequestsReview": "Áttekintés",
"demoTitle": "Demo fiók",
"demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.",
"backToTop": "Vissza a tetejére"
"backToTop": "Vissza a tetejére",
"retry": "Újra"
}
+21 -7
View File
@@ -42,16 +42,10 @@
"pushDone": "„{{name}}” szinkronizálva a YouTube-ra ✓",
"pushPartial": "Szinkronizálva, de {{count}} elem kimaradt.",
"pushFailed": "Nem sikerült a YouTube-ra szinkronizálás.",
"deleteOnYoutubeTitle": "Törlöd a YouTube-on is?",
"deleteOnYoutubeMsg": "Törlöd a(z) „{{name}}” listát a YouTube-fiókodból is? A „Csak itt” megtartja a YouTube-on.",
"deleteOnYoutubeConfirm": "Törlés a YouTube-on",
"deleteHereOnly": "Csak itt",
"deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva.",
"sortLabel": "Rendezés",
"sortManual": "Egyéni sorrend",
"sortTitle": "Cím",
"sortDuration": "Hossz",
"sortChannel": "Csatorna",
"dirAsc": "Növekvő",
"dirDesc": "Csökkenő",
"groupByChannel": "Csoportosítás csatorna szerint",
@@ -59,5 +53,25 @@
"railSortName": "Név",
"railSortCount": "Elemszám",
"railSortDuration": "Összhossz",
"dirtyFirst": "Nem szinkronizáltak elöl"
"dirtyFirst": "Nem szinkronizáltak elöl",
"colTitle": "Cím",
"colChannel": "Csatorna",
"colDuration": "Hossz",
"removeItem": "Eltávolítás",
"removeItemOf": "„{{title}}” eltávolítása a listáról",
"confirmRemove": "Eltávolítod a(z) „{{title}}” videót erről a listáról?",
"dragHandle": "Húzd az átrendezéshez",
"dragHandleOf": "„{{title}}” átrendezése",
"dragBlockedSort": "A kézi átrendezés ki van kapcsolva, amíg oszloprendezés vagy csoportosítás van érvényben.",
"dragBlockedFilter": "A kézi átrendezés ki van kapcsolva, amíg szűrő van érvényben.",
"clearView": "Nézet visszaállítása",
"saveOrder": "Sorrend mentése",
"saveOrderHint": "A látott sorrend legyen a lista saját sorrendje (visszavonható).",
"filteredCount": "{{count}} látszik",
"noneMatchFilter": "Egy videó sem felel meg a szűrőnek.",
"loadFailed": "Nem sikerült betölteni ezt a listát.",
"reorderFailed": "Nem sikerült elmenteni az új sorrendet.",
"renameFailed": "Nem sikerült átnevezni a listát.",
"deleteFailed": "Nem sikerült törölni a listát.",
"confirmDeleteLinked": "Törlöd a(z) „{{name}}” listát? A YouTube-bal szinkronban van — válaszd ki, hogy ott is törlődjön-e. Nem vonható vissza."
}
+7
View File
@@ -0,0 +1,7 @@
import type { TFunction } from "i18next";
/** Watch later is a built-in playlist whose stored `name` is not translatable — show the localized
* label instead. Was hand-inlined in Playlists, PlaylistsRail and AddToPlaylist; one copy now. */
export function playlistName(p: { kind: string; name: string }, t: TFunction): string {
return p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { applyView, sameOrder } from "./playlistView";
import type { Column } from "../components/tableHeader";
import type { Video } from "./api";
// The playlist's view derivation (E4 S4). What makes it worth a test: the column sort and the
// channel grouping compose — groups are ordered by channel name in the SORT's direction, and the
// sort then applies within each group. Getting that backwards silently produces a plausible-looking
// order, and the whole "save this order" feature writes whatever this function returned.
const v = (id: string, title: string, channel: string, secs: number | null = 60): Video =>
({ id, title, channel_title: channel, duration_seconds: secs }) as Video;
const columns = [
{ key: "title", header: "Title", render: () => null, sortable: true, sortValue: (x: Video) => x.title ?? "" },
{ key: "channel", header: "Channel", render: () => null, sortable: true, sortValue: (x: Video) => x.channel_title ?? "" },
{
key: "duration",
header: "Length",
render: () => null,
sortable: true,
sortValue: (x: Video) => x.duration_seconds ?? Number.MAX_SAFE_INTEGER,
},
] as Column<Video>[];
const titles = (rows: Video[]) => rows.map((r) => r.title);
describe("applyView", () => {
const rows = [
v("1", "delta", "Beta"),
v("2", "alpha", "Alpha"),
v("3", "charlie", "Beta"),
v("4", "bravo", "Alpha"),
];
it("returns the stored order untouched with no sort and no grouping", () => {
expect(applyView(rows, columns, null, false)).toBe(rows);
});
it("sorts by the named column", () => {
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, false))).toEqual([
"alpha",
"bravo",
"charlie",
"delta",
]);
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, false))).toEqual([
"delta",
"charlie",
"bravo",
"alpha",
]);
});
it("groups by channel, ordering the groups by name and sorting within each", () => {
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, true))).toEqual([
"alpha", // Alpha
"bravo", // Alpha
"charlie", // Beta
"delta", // Beta
]);
});
it("reverses the GROUP order too when the sort is descending", () => {
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, true))).toEqual([
"delta", // Beta first, because the direction flips the group order as well
"charlie",
"bravo", // then Alpha
"alpha",
]);
});
it("groups without a sort by keeping each group's stored order", () => {
expect(titles(applyView(rows, columns, null, true))).toEqual([
"alpha",
"bravo",
"delta",
"charlie",
]);
});
it("parks unknown durations at the end of an ascending sort, not at zero", () => {
const mixed = [v("1", "long", "A", 900), v("2", "live", "A", null), v("3", "short", "A", 30)];
expect(titles(applyView(mixed, columns, { key: "duration", dir: "asc" }, false))).toEqual([
"short",
"long",
"live",
]);
});
});
describe("sameOrder", () => {
const a = [v("1", "x", "A"), v("2", "y", "A")];
it("is true for the same ids in the same positions", () => {
expect(sameOrder(a, [v("1", "different title", "B"), v("2", "y", "A")])).toBe(true);
});
it("is false when the order differs", () => {
expect(sameOrder(a, [a[1]!, a[0]!])).toBe(false);
});
it("is false when the length differs — a filtered view is never 'the same order'", () => {
expect(sameOrder(a, [a[0]!])).toBe(false);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { sortRows, type SortState } from "./columnSort";
// The playlist detail's VIEW derivation: what the reader sees, as opposed to the playlist's stored
// order. Kept structural (no `Column`, no component imports) like `columnSort.ts`, so it stays pure
// and testable — the "save this order" button writes whatever `applyView` returned, which is reason
// enough for this to be covered rather than eyeballed.
interface Grouped {
channel_title?: string | null;
}
interface Sortable<T> {
key: string;
sortValue?: (row: T) => string | number;
}
/** Filters are applied by the caller; this is sort, then optional channel grouping. Groups are
* ordered by channel name in the sort's direction, and the sort applies WITHIN each group (with no
* sort, each group keeps its stored order). */
export function applyView<T extends Grouped>(
rows: T[],
columns: Sortable<T>[],
sort: SortState,
group: boolean
): T[] {
const sorted = sortRows(rows, columns, sort);
if (!group) return sorted;
const groups = new Map<string, T[]>();
for (const v of sorted) {
const k = v.channel_title ?? "";
const g = groups.get(k);
if (g) g.push(v);
else groups.set(k, [v]);
}
const sign = sort?.dir === "desc" ? -1 : 1;
const keys = [...groups.keys()].sort((a, b) => sign * a.localeCompare(b));
return keys.flatMap((k) => groups.get(k)!);
}
/** Same items in the same positions. Identity is the id, so a refetch that changed only a title
* doesn't read as a reorder — and a shorter list (a filtered view) is never "the same order". */
export const sameOrder = (a: { id: string }[], b: { id: string }[]) =>
a.length === b.length && a.every((v, i) => v.id === b[i]!.id);
+17
View File
@@ -15,6 +15,7 @@ export interface Undoable<T> {
value: T;
set: (next: T) => void;
reset: (value: T) => void;
rebase: (map: (value: T) => T) => void;
undo: () => void;
redo: () => void;
canUndo: boolean;
@@ -77,11 +78,27 @@ export function useUndoable<T>(
force();
}, []);
// Apply the same transformation to EVERY snapshot — past, present and future — and keep the
// history. For changes that are outside the undoable dimension but invalidate the snapshots:
// removing a playlist item is not itself undoable, yet dropping it from each stored order keeps
// the user's reorder history valid and usable instead of throwing it away (which is what a
// `reset` does). No `onApply`: the caller is already persisting the change by its own route.
const rebase = useCallback((map: (value: T) => T) => {
const s = ref.current;
ref.current = {
past: s.past.map(map),
present: map(s.present),
future: s.future.map(map),
};
force();
}, []);
const h = ref.current;
return {
value: h.present,
set,
reset,
rebase,
undo,
redo,
canUndo: h.past.length > 0,