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:
@@ -6,6 +6,7 @@ import { Check, ListPlus, Plus, Youtube } from "lucide-react";
|
|||||||
import { api, type Playlist } from "../lib/api";
|
import { api, type Playlist } from "../lib/api";
|
||||||
import { useDismiss } from "../lib/useDismiss";
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
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
|
// 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.
|
// 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" />}
|
{pl.has_video && <Check className="w-3 h-3" />}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate flex-1 text-left">
|
<span className="truncate flex-1 text-left">
|
||||||
{pl.kind === "watch_later" ? t("playlists.watchLater") : pl.name}
|
{playlistName(pl, t)}
|
||||||
</span>
|
</span>
|
||||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||||
<Youtube className="w-3 h-3 shrink-0 text-muted" />
|
<Youtube className="w-3 h-3 shrink-0 text-muted" />
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import Modal from "./Modal";
|
|||||||
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
|
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
|
||||||
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
|
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
|
||||||
// if (await confirm({ message: "…", danger: true })) { … }
|
// 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 {
|
export interface ConfirmOptions {
|
||||||
title?: string;
|
title?: string;
|
||||||
message: ReactNode;
|
message: ReactNode;
|
||||||
@@ -13,62 +18,108 @@ export interface ConfirmOptions {
|
|||||||
danger?: boolean;
|
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>;
|
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 {
|
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 }) {
|
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<{
|
const [state, setState] = useState<{
|
||||||
opts: ConfirmOptions;
|
opts: ChoiceOptions;
|
||||||
resolve: (ok: boolean) => void;
|
resolve: (id: string | null) => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const confirm = useCallback<ConfirmFn>(
|
const ask = useCallback<ChoiceFn>(
|
||||||
(opts) => new Promise<boolean>((resolve) => setState({ opts, resolve })),
|
(opts) => new Promise<string | null>((resolve) => setState({ opts, resolve })),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
function close(ok: boolean) {
|
function close(id: string | null) {
|
||||||
state?.resolve(ok);
|
state?.resolve(id);
|
||||||
setState(null);
|
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 (
|
return (
|
||||||
<ConfirmContext.Provider value={confirm}>
|
<ChoiceContext.Provider value={ask}>
|
||||||
{children}
|
{children}
|
||||||
{state && (
|
{state && (
|
||||||
<Modal
|
<Modal
|
||||||
title={state.opts.title ?? t("common.confirmTitle")}
|
title={state.opts.title ?? t("common.confirmTitle")}
|
||||||
onClose={() => close(false)}
|
onClose={() => close(null)}
|
||||||
maxWidth="max-w-sm"
|
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
|
<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
|
<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"
|
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")}
|
{state.opts.cancelLabel ?? t("common.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{choices.map((c, i) => (
|
||||||
autoFocus
|
<button
|
||||||
onClick={() => close(true)}
|
key={c.id}
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
|
autoFocus={!anyDanger && i === choices.length - 1}
|
||||||
state.opts.danger
|
onClick={() => close(c.id)}
|
||||||
? "bg-red-500 text-white hover:bg-red-600"
|
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
|
||||||
: "bg-accent text-accent-fg hover:opacity-90"
|
c.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>
|
>
|
||||||
|
{c.label || t("common.confirm")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
</ConfirmContext.Provider>
|
</ChoiceContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
useSensor,
|
useSensor,
|
||||||
@@ -12,13 +13,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
SortableContext,
|
SortableContext,
|
||||||
arrayMove,
|
arrayMove,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
useSortable,
|
useSortable,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
AlertTriangle,
|
||||||
ArrowUp,
|
Check,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
ListPlus,
|
ListPlus,
|
||||||
@@ -28,66 +30,36 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
|
||||||
Youtube,
|
Youtube,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type Video } from "../lib/api";
|
import { api, type Video } from "../lib/api";
|
||||||
import { formatDuration } from "../lib/format";
|
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
|
import { playlistName } from "../lib/playlistName";
|
||||||
import { useUndoable } from "../lib/useUndoable";
|
import { useUndoable } from "../lib/useUndoable";
|
||||||
|
import { cycleSort, type SortState } from "../lib/columnSort";
|
||||||
|
import { applyView, sameOrder } from "../lib/playlistView";
|
||||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||||
import UndoToolbar from "./UndoToolbar";
|
import UndoToolbar from "./UndoToolbar";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useChoice, useConfirm } from "./ConfirmProvider";
|
||||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||||
import { useMe } from "../lib/useMe";
|
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";
|
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
|
||||||
type SortDir = "asc" | "desc";
|
// body that is still a drag-reorderable @dnd-kit list.
|
||||||
|
//
|
||||||
function comparator(key: SortKey, dir: SortDir): ((a: Video, b: Video) => number) | null {
|
// The distinction that shapes the whole module: **the column sort and the filters are a VIEW**,
|
||||||
if (key === "manual") return null;
|
// while the playlist's own order is stored data. Before S4 the sort control wrote straight through
|
||||||
const sign = dir === "asc" ? 1 : -1;
|
// to the server, so picking "sort by title" silently rewrote the playlist and marked a
|
||||||
if (key === "duration")
|
// YouTube-linked mirror dirty. Now nothing is written until the user presses "Save this order",
|
||||||
return (a, b) => {
|
// which still goes through the undoable path — the capability is intact, it just stopped being a
|
||||||
const av = a.duration_seconds;
|
// side effect of looking at the list.
|
||||||
const bv = b.duration_seconds;
|
//
|
||||||
if (av == null && bv == null) return 0;
|
// It composes `tableHeader`'s pieces instead of rendering a `DataTable`, because DataTable owns
|
||||||
if (av == null) return 1; // nulls always last, both directions
|
// its own filter/sort/page state and the stored order needs a single owner.
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const idsKey = (items: Video[]) =>
|
const idsKey = (items: Video[]) =>
|
||||||
items
|
items
|
||||||
@@ -97,86 +69,56 @@ const idsKey = (items: Video[]) =>
|
|||||||
|
|
||||||
function Row({
|
function Row({
|
||||||
video,
|
video,
|
||||||
index,
|
columns,
|
||||||
readOnly,
|
dragDisabled,
|
||||||
onPlay,
|
dragHint,
|
||||||
onRemove,
|
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
index: number;
|
columns: Column<Video>[];
|
||||||
readOnly?: boolean;
|
dragDisabled: boolean;
|
||||||
onPlay: () => void;
|
dragHint: string;
|
||||||
onRemove: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
useSortable({ id: video.id, disabled: readOnly });
|
id: video.id,
|
||||||
const style = {
|
disabled: dragDisabled,
|
||||||
transform: CSS.Transform.toString(transform),
|
});
|
||||||
transition,
|
|
||||||
opacity: isDragging ? 0.6 : 1,
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div
|
<tr
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={{
|
||||||
className="flex items-center gap-2.5 p-2 rounded-lg glass-card glass-hover"
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
className="border-b border-border/50 hover:bg-card/40 transition"
|
||||||
>
|
>
|
||||||
{readOnly ? (
|
{columns.map((col) => (
|
||||||
<span className="w-4" />
|
<td
|
||||||
) : (
|
key={col.key}
|
||||||
<button
|
style={col.width ? { width: col.width } : undefined}
|
||||||
{...attributes}
|
className={`px-2 py-1.5 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}`}
|
||||||
{...listeners}
|
|
||||||
className="text-muted hover:text-fg cursor-grab active:cursor-grabbing"
|
|
||||||
aria-label="reorder"
|
|
||||||
>
|
>
|
||||||
<GripVertical className="w-4 h-4" />
|
{col.key === GRIP_KEY ? (
|
||||||
</button>
|
// 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
|
||||||
<span className="text-xs text-muted w-4 text-center tabular-nums">{index + 1}</span>
|
// put and can explain itself instead of silently doing nothing.
|
||||||
<button
|
<button
|
||||||
onClick={onPlay}
|
{...attributes}
|
||||||
aria-label={`${t("card.play")} — ${video.title}`}
|
{...listeners}
|
||||||
className="relative w-[62px] h-[35px] rounded overflow-hidden bg-surface shrink-0 group"
|
disabled={dragDisabled}
|
||||||
>
|
title={dragDisabled ? dragHint : t("playlists.dragHandle")}
|
||||||
{video.thumbnail_url && (
|
aria-label={t("playlists.dragHandleOf", { title: video.title })}
|
||||||
<img src={video.thumbnail_url} alt="" className="w-full h-full object-cover" />
|
className="text-muted enabled:hover:text-fg enabled:cursor-grab enabled:active:cursor-grabbing disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
)}
|
>
|
||||||
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover:opacity-100 transition">
|
<GripVertical className="w-4 h-4" />
|
||||||
<Play className="w-4 h-4 text-white fill-current" />
|
</button>
|
||||||
</span>
|
) : (
|
||||||
</button>
|
col.render(video)
|
||||||
<button onClick={onPlay} className="min-w-0 flex-1 text-left">
|
)}
|
||||||
<div className="text-[13px] text-fg truncate">{video.title}</div>
|
</td>
|
||||||
<div className="text-[11px] text-muted truncate">{video.channel_title}</div>
|
))}
|
||||||
</button>
|
</tr>
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,31 +132,44 @@ export default function Playlists() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
|
const choose = useChoice();
|
||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
const [renameValue, setRenameValue] = useState("");
|
const [renameValue, setRenameValue] = useState("");
|
||||||
// The item order is undoable: drag, sort and group all go through `order.set`, so each is
|
// The stored order is undoable: drag and "save this order" both go through `order.set`, so each
|
||||||
// reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server.
|
// is reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server.
|
||||||
const order = useUndoable<Video[]>([], {
|
const order = useUndoable<Video[]>([], {
|
||||||
onApply: (next) => {
|
onApply: (next) => {
|
||||||
if (selectedId == null) return;
|
if (selectedId == null) return;
|
||||||
api.reorderPlaylist(selectedId, next.map((v) => v.id)).then(() => {
|
api
|
||||||
// Refresh the sidebar (cover may change) and the detail (so the now-dirty state,
|
.reorderPlaylist(
|
||||||
// and the Reset/Unsynced indicators, show without an F5). The membership guard
|
selectedId,
|
||||||
// keeps the refetch from wiping the undo history on a pure reorder.
|
next.map((v) => v.id)
|
||||||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
)
|
||||||
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
|
.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 items = order.value;
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("manual");
|
// VIEW state — never written to the server on its own.
|
||||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
const [viewSort, setViewSort] = useState<SortState>(null);
|
||||||
|
const [filters, setFilters] = useState<FilterMap>({});
|
||||||
const [groupBy, setGroupBy] = useState(false);
|
const [groupBy, setGroupBy] = useState(false);
|
||||||
// Tracks the membership (id set) currently loaded into `order`, so a refetch that only
|
// 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
|
// 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).
|
// the actual set of items changes (different playlist, or an add/remove).
|
||||||
const lastSetRef = useRef<string>("");
|
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 [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const listQuery = useQuery({
|
const listQuery = useQuery({
|
||||||
@@ -244,43 +199,85 @@ export default function Playlists() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [detail]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setSortKey("manual");
|
setViewSort(null);
|
||||||
setSortDir("asc");
|
setFilters({});
|
||||||
setGroupBy(false);
|
setGroupBy(false);
|
||||||
}, [selectedId]);
|
}, [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";
|
const builtin = detail?.kind === "watch_later";
|
||||||
// YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a
|
// 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.
|
// dirty mirror so it won't clobber unpushed edits); the YT-link button marks their origin.
|
||||||
const editable = !builtin;
|
const editable = !builtin;
|
||||||
const linked = editable && !!detail?.yt_playlist_id;
|
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 [pushing, setPushing] = useState(false);
|
||||||
const [reverting, setReverting] = 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() {
|
async function revertToYoutube() {
|
||||||
if (selectedId == null || !detail || reverting) return;
|
if (selectedId == null || !detail || reverting) return;
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
@@ -345,7 +342,10 @@ export default function Playlists() {
|
|||||||
const r = await api.pushPlaylist(selectedId);
|
const r = await api.pushPlaylist(selectedId);
|
||||||
refreshAll();
|
refreshAll();
|
||||||
if (r.failures.length) {
|
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 {
|
} else {
|
||||||
notify({ level: "success", message: t("playlists.pushDone", { name: plName(detail) }) });
|
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) {
|
function onDragEnd(e: DragEndEvent) {
|
||||||
const { active: a, over } = e;
|
const { active: a, over } = e;
|
||||||
if (!over || a.id === over.id || selectedId == null) return;
|
if (!over || a.id === over.id || selectedId == null) return;
|
||||||
const oldIndex = items.findIndex((v) => v.id === a.id);
|
const oldIndex = items.findIndex((v) => v.id === a.id);
|
||||||
const newIndex = items.findIndex((v) => v.id === over.id);
|
const newIndex = items.findIndex((v) => v.id === over.id);
|
||||||
if (oldIndex < 0 || newIndex < 0) return;
|
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
|
order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeItem(videoId: string) {
|
async function removeItem(video: Video) {
|
||||||
if (selectedId == null) return;
|
if (selectedId == null) return;
|
||||||
// Membership changes aren't undoable: reset the order to the new set (clearing history)
|
const ok = await confirm({
|
||||||
// and keep lastSetRef in sync so the follow-up refetch doesn't reset again.
|
title: t("playlists.removeItem"),
|
||||||
const next = items.filter((v) => v.id !== videoId);
|
message: t("playlists.confirmRemove", { title: video.title }),
|
||||||
order.reset(next);
|
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);
|
lastSetRef.current = idsKey(next);
|
||||||
try {
|
try {
|
||||||
await api.removeFromPlaylist(selectedId, videoId);
|
await api.removeFromPlaylist(selectedId, video.id);
|
||||||
refreshAll();
|
refreshAll();
|
||||||
} catch {
|
} catch {
|
||||||
// The optimistic removal above didn't stick — resync from the server to restore the row.
|
// The optimistic removal above didn't stick — resync from the server to restore the row.
|
||||||
@@ -389,37 +388,43 @@ export default function Playlists() {
|
|||||||
refreshAll();
|
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() {
|
async function saveRename() {
|
||||||
if (selectedId == null) return;
|
if (selectedId == null) return;
|
||||||
const name = renameValue.trim();
|
const name = renameValue.trim();
|
||||||
setRenaming(false);
|
setRenaming(false);
|
||||||
if (name && name !== detail?.name) {
|
if (name && name !== detail?.name) {
|
||||||
await api.renamePlaylist(selectedId, name);
|
try {
|
||||||
|
await api.renamePlaylist(selectedId, name);
|
||||||
|
} catch (e) {
|
||||||
|
notifyYouTubeActionError(e, t("playlists.renameFailed"));
|
||||||
|
}
|
||||||
refreshAll();
|
refreshAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deletePlaylist() {
|
async function deletePlaylist() {
|
||||||
if (selectedId == null || !detail) return;
|
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"),
|
title: t("playlists.delete"),
|
||||||
message: t("playlists.confirmDelete", { name: detail.name }),
|
message: offerYoutube
|
||||||
confirmLabel: t("playlists.delete"),
|
? t("playlists.confirmDeleteLinked", { name: plName(detail) })
|
||||||
danger: true,
|
: 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;
|
if (picked == null) return;
|
||||||
// For a YouTube-linked playlist, offer to delete it on YouTube too (Cancel = here only).
|
const onYoutube = picked === "both";
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const deletedId = selectedId;
|
const deletedId = selectedId;
|
||||||
// Pick the next selection from what's left *now* (don't go via null, or the
|
// 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).
|
// auto-select effect would re-pick the just-deleted id from the stale list).
|
||||||
@@ -428,7 +433,10 @@ export default function Playlists() {
|
|||||||
try {
|
try {
|
||||||
await api.deletePlaylist(deletedId, onYoutube);
|
await api.deletePlaylist(deletedId, onYoutube);
|
||||||
} catch {
|
} 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.removeQueries({ queryKey: ["playlist", deletedId] });
|
||||||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
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 (
|
return (
|
||||||
<div className="px-4 pb-4 pt-3">
|
<div className="px-4 pb-4 pt-3">
|
||||||
{selectedId == null || !detail ? (
|
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
|
||||||
<div className="text-muted text-sm p-4">
|
fixed band, so only the video list scrolls. */}
|
||||||
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
|
<PageToolbar>
|
||||||
</div>
|
<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">
|
||||||
{/* Header (title, actions, sort/group) is fixed chrome — it portals into the shell's
|
{items[0]?.thumbnail_url && (
|
||||||
fixed band, so only the video list scrolls. */}
|
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||||
<PageToolbar>
|
)}
|
||||||
<div className="px-4 pt-3 pb-2">
|
</div>
|
||||||
<div className="flex gap-3 items-start mb-4">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="w-24 h-[54px] rounded-lg bg-card overflow-hidden shrink-0">
|
{renaming ? (
|
||||||
{items[0]?.thumbnail_url && (
|
<input
|
||||||
<img
|
autoFocus
|
||||||
src={items[0].thumbnail_url}
|
value={renameValue}
|
||||||
alt=""
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
className="w-full h-full object-cover"
|
onBlur={saveRename}
|
||||||
/>
|
onKeyDown={(e) => {
|
||||||
)}
|
if (e.key === "Enter") saveRename();
|
||||||
</div>
|
if (e.key === "Escape") setRenaming(false);
|
||||||
<div className="min-w-0 flex-1">
|
}}
|
||||||
{renaming ? (
|
aria-label={t("playlists.rename")}
|
||||||
<input
|
className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-none focus:border-accent"
|
||||||
autoFocus
|
/>
|
||||||
value={renameValue}
|
) : (
|
||||||
onChange={(e) => setRenameValue(e.target.value)}
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
onBlur={saveRename}
|
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
{editable && (
|
{editable && (
|
||||||
<button
|
<button
|
||||||
onClick={deletePlaylist}
|
onClick={() => {
|
||||||
title={t("playlists.delete")}
|
setRenameValue(detail.name);
|
||||||
aria-label={t("playlists.delete")}
|
setRenaming(true);
|
||||||
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"
|
}}
|
||||||
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
{linked && detail.dirty && (
|
{detail.yt_playlist_id && (
|
||||||
<span
|
<a
|
||||||
title={t("playlists.unsyncedHint")}
|
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
|
||||||
className="inline-flex items-center gap-1.5 text-[11px] text-accent ml-1"
|
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" />
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
{t("playlists.unsynced")}
|
</a>
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{editable && items.length > 1 && (
|
{items.length > 1 && (
|
||||||
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-3xl">
|
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-5xl">
|
||||||
<span className="text-xs text-muted">{t("playlists.sortLabel")}</span>
|
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
|
||||||
<select
|
<input
|
||||||
value={sortKey}
|
type="checkbox"
|
||||||
onChange={(e) => {
|
checked={groupBy}
|
||||||
const k = e.target.value as SortKey;
|
onChange={(e) => setGroupBy(e.target.checked)}
|
||||||
setSortKey(k);
|
className="accent-accent"
|
||||||
applySort(k, sortDir, groupBy);
|
/>
|
||||||
}}
|
{t("playlists.groupByChannel")}
|
||||||
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
</label>
|
||||||
>
|
{viewActive && (
|
||||||
<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>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={clearView}
|
||||||
const d = sortDir === "asc" ? "desc" : "asc";
|
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{sortDir === "asc" ? (
|
{t("playlists.clearView")}
|
||||||
<ArrowUp className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
|
)}
|
||||||
<input
|
{canSaveOrder && (
|
||||||
type="checkbox"
|
<button
|
||||||
checked={groupBy}
|
onClick={saveOrder}
|
||||||
onChange={(e) => {
|
title={t("playlists.saveOrderHint")}
|
||||||
setGroupBy(e.target.checked);
|
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"
|
||||||
applySort(sortKey, sortDir, e.target.checked);
|
>
|
||||||
}}
|
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
|
||||||
className="accent-accent"
|
</button>
|
||||||
/>
|
)}
|
||||||
{t("playlists.groupByChannel")}
|
{viewActive && (
|
||||||
</label>
|
<span className="text-[11px] text-muted/80">{dragHint}</span>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
<UndoToolbar
|
<UndoToolbar
|
||||||
canUndo={order.canUndo}
|
canUndo={order.canUndo}
|
||||||
canRedo={order.canRedo}
|
canRedo={order.canRedo}
|
||||||
onUndo={undo}
|
onUndo={order.undo}
|
||||||
onRedo={redo}
|
onRedo={order.redo}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</PageToolbar>
|
)}
|
||||||
|
</div>
|
||||||
|
</PageToolbar>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="text-sm text-muted grid place-items-center text-center py-12">
|
<div className="text-sm text-muted grid place-items-center text-center py-12">
|
||||||
<div>
|
<div>
|
||||||
<ListPlus className="w-6 h-6 mx-auto mb-2 opacity-60" />
|
<ListPlus className="w-6 h-6 mx-auto mb-2 opacity-60" />
|
||||||
{t("playlists.empty")}
|
{t("playlists.empty")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||||
sensors={sensors}
|
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
|
||||||
collisionDetection={closestCenter}
|
<div className="max-w-5xl">
|
||||||
onDragEnd={onDragEnd}
|
<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
|
<tbody>
|
||||||
items={items.map((v) => v.id)}
|
{shown.map((v) => (
|
||||||
strategy={verticalListSortingStrategy}
|
<Row
|
||||||
>
|
key={v.id}
|
||||||
<div className="space-y-1.5 max-w-3xl">
|
video={v}
|
||||||
{items.map((v, i) => (
|
columns={columns}
|
||||||
<Row
|
dragDisabled={!canEdit || viewActive}
|
||||||
key={v.id}
|
dragHint={dragHint}
|
||||||
video={v}
|
/>
|
||||||
index={i}
|
))}
|
||||||
onPlay={() => setPlayingIndex(i)}
|
</tbody>
|
||||||
onRemove={() => removeItem(v.id)}
|
</SortableContext>
|
||||||
/>
|
</table>
|
||||||
))}
|
{shown.length === 0 && (
|
||||||
</div>
|
<div className="text-muted py-8 text-center text-sm">
|
||||||
</SortableContext>
|
{t("playlists.noneMatchFilter")}
|
||||||
</DndContext>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
</DndContext>
|
||||||
|
)}
|
||||||
|
|
||||||
{playingIndex != null && items[playingIndex] && (
|
{playingIndex != null && shown[playingIndex] && (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<PlayerModal
|
<PlayerModal
|
||||||
video={items[playingIndex]}
|
video={shown[playingIndex]}
|
||||||
queue={items}
|
queue={shown}
|
||||||
startIndex={playingIndex}
|
startIndex={playingIndex}
|
||||||
startAt={null}
|
startAt={null}
|
||||||
onClose={() => setPlayingIndex(null)}
|
onClose={closePlayer}
|
||||||
onState={playerState}
|
onState={playerState}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import SidePanel from "./SidePanel";
|
|||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
|
import { playlistName } from "../lib/playlistName";
|
||||||
|
|
||||||
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean };
|
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean };
|
||||||
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [playlists, selectedId, listQuery.data]);
|
}, [playlists, selectedId, listQuery.data]);
|
||||||
|
|
||||||
const plName = (p: { kind: string; name: string }) =>
|
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
|
||||||
p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
|
|
||||||
|
|
||||||
const sortedPlaylists = useMemo(() => {
|
const sortedPlaylists = useMemo(() => {
|
||||||
const sign = plSort.dir === "asc" ? 1 : -1;
|
const sign = plSort.dir === "asc" ? 1 : -1;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -17,5 +17,6 @@
|
|||||||
"accessRequestsReview": "Review",
|
"accessRequestsReview": "Review",
|
||||||
"demoTitle": "Demo account",
|
"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.",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,16 +42,10 @@
|
|||||||
"pushDone": "“{{name}}” synced to YouTube ✓",
|
"pushDone": "“{{name}}” synced to YouTube ✓",
|
||||||
"pushPartial": "Synced, but {{count}} item(s) were skipped.",
|
"pushPartial": "Synced, but {{count}} item(s) were skipped.",
|
||||||
"pushFailed": "Couldn't sync to YouTube.",
|
"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",
|
"deleteOnYoutubeConfirm": "Delete on YouTube",
|
||||||
"deleteHereOnly": "Here only",
|
"deleteHereOnly": "Here only",
|
||||||
"deleteYtFailed": "Couldn't delete on YouTube; removed here only.",
|
"deleteYtFailed": "Couldn't delete on YouTube; removed here only.",
|
||||||
"sortLabel": "Sort",
|
"sortLabel": "Sort",
|
||||||
"sortManual": "Custom order",
|
|
||||||
"sortTitle": "Title",
|
|
||||||
"sortDuration": "Duration",
|
|
||||||
"sortChannel": "Channel",
|
|
||||||
"dirAsc": "Ascending",
|
"dirAsc": "Ascending",
|
||||||
"dirDesc": "Descending",
|
"dirDesc": "Descending",
|
||||||
"groupByChannel": "Group by channel",
|
"groupByChannel": "Group by channel",
|
||||||
@@ -59,5 +53,25 @@
|
|||||||
"railSortName": "Name",
|
"railSortName": "Name",
|
||||||
"railSortCount": "Item count",
|
"railSortCount": "Item count",
|
||||||
"railSortDuration": "Total length",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,5 +17,6 @@
|
|||||||
"accessRequestsReview": "Áttekintés",
|
"accessRequestsReview": "Áttekintés",
|
||||||
"demoTitle": "Demo fiók",
|
"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.",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,16 +42,10 @@
|
|||||||
"pushDone": "„{{name}}” szinkronizálva a YouTube-ra ✓",
|
"pushDone": "„{{name}}” szinkronizálva a YouTube-ra ✓",
|
||||||
"pushPartial": "Szinkronizálva, de {{count}} elem kimaradt.",
|
"pushPartial": "Szinkronizálva, de {{count}} elem kimaradt.",
|
||||||
"pushFailed": "Nem sikerült a YouTube-ra szinkronizálás.",
|
"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",
|
"deleteOnYoutubeConfirm": "Törlés a YouTube-on",
|
||||||
"deleteHereOnly": "Csak itt",
|
"deleteHereOnly": "Csak itt",
|
||||||
"deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva.",
|
"deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva.",
|
||||||
"sortLabel": "Rendezés",
|
"sortLabel": "Rendezés",
|
||||||
"sortManual": "Egyéni sorrend",
|
|
||||||
"sortTitle": "Cím",
|
|
||||||
"sortDuration": "Hossz",
|
|
||||||
"sortChannel": "Csatorna",
|
|
||||||
"dirAsc": "Növekvő",
|
"dirAsc": "Növekvő",
|
||||||
"dirDesc": "Csökkenő",
|
"dirDesc": "Csökkenő",
|
||||||
"groupByChannel": "Csoportosítás csatorna szerint",
|
"groupByChannel": "Csoportosítás csatorna szerint",
|
||||||
@@ -59,5 +53,25 @@
|
|||||||
"railSortName": "Név",
|
"railSortName": "Név",
|
||||||
"railSortCount": "Elemszám",
|
"railSortCount": "Elemszám",
|
||||||
"railSortDuration": "Összhossz",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -15,6 +15,7 @@ export interface Undoable<T> {
|
|||||||
value: T;
|
value: T;
|
||||||
set: (next: T) => void;
|
set: (next: T) => void;
|
||||||
reset: (value: T) => void;
|
reset: (value: T) => void;
|
||||||
|
rebase: (map: (value: T) => T) => void;
|
||||||
undo: () => void;
|
undo: () => void;
|
||||||
redo: () => void;
|
redo: () => void;
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
@@ -77,11 +78,27 @@ export function useUndoable<T>(
|
|||||||
force();
|
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;
|
const h = ref.current;
|
||||||
return {
|
return {
|
||||||
value: h.present,
|
value: h.present,
|
||||||
set,
|
set,
|
||||||
reset,
|
reset,
|
||||||
|
rebase,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
canUndo: h.past.length > 0,
|
canUndo: h.past.length > 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user