Add a sort toolbar to the Playlists page: Title A-Z/Z-A, shortest/longest first, and a 'group by channel' toggle that groups items by channel (A-Z) and applies the chosen sort within each group. Applying a sort reorders the items and persists via the existing reorder API. The item order is now an undoable value: drag, sort and grouping all go through it, so every change is reversible via undo/redo buttons and Ctrl/Cmd+Z / Ctrl+Y (Ctrl+Shift+Z). Built on two reusable pieces, not playlist-specific: - useUndoable<T> — a generic past/present/future snapshot hook (set/undo/redo/reset + canUndo/canRedo), ref-backed so callbacks are stable and never see a stale snapshot; onApply runs the side effect (here: persist order). - UndoToolbar — undo/redo buttons + keyboard shortcuts, plain props so any undo source can use it. Undo history is reset only when the item set actually changes (different playlist or add/remove), so a refetch confirming our own reorder doesn't wipe it; membership changes (remove) clear history since they aren't reorder-undoable. Trilingual.
676 lines
25 KiB
TypeScript
676 lines
25 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
DndContext,
|
||
PointerSensor,
|
||
closestCenter,
|
||
useSensor,
|
||
useSensors,
|
||
type DragEndEvent,
|
||
} from "@dnd-kit/core";
|
||
import {
|
||
SortableContext,
|
||
arrayMove,
|
||
useSortable,
|
||
verticalListSortingStrategy,
|
||
} from "@dnd-kit/sortable";
|
||
import { CSS } from "@dnd-kit/utilities";
|
||
import {
|
||
Check,
|
||
GripVertical,
|
||
ListPlus,
|
||
Pencil,
|
||
Play,
|
||
Plus,
|
||
RefreshCw,
|
||
Trash2,
|
||
Upload,
|
||
X,
|
||
Youtube,
|
||
} from "lucide-react";
|
||
import { api, type Playlist, type Video } from "../lib/api";
|
||
import { formatDuration } from "../lib/format";
|
||
import { notify } from "../lib/notifications";
|
||
import { useUndoable } from "../lib/useUndoable";
|
||
import PlayerModal from "./PlayerModal";
|
||
import UndoToolbar from "./UndoToolbar";
|
||
import { useConfirm } from "./ConfirmProvider";
|
||
|
||
type SortMode = "manual" | "title-asc" | "title-desc" | "dur-asc" | "dur-desc";
|
||
|
||
function comparator(mode: SortMode): ((a: Video, b: Video) => number) | null {
|
||
switch (mode) {
|
||
case "title-asc":
|
||
return (a, b) => (a.title ?? "").localeCompare(b.title ?? "");
|
||
case "title-desc":
|
||
return (a, b) => (b.title ?? "").localeCompare(a.title ?? "");
|
||
case "dur-asc":
|
||
return (a, b) => (a.duration_seconds ?? Infinity) - (b.duration_seconds ?? Infinity);
|
||
case "dur-desc":
|
||
return (a, b) => (b.duration_seconds ?? -Infinity) - (a.duration_seconds ?? -Infinity);
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Sort a playlist's items. When `group` is on, items are grouped by channel (groups ordered
|
||
// by channel name A–Z) and the chosen sort is applied within each group; with sort "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[], mode: SortMode, group: boolean): Video[] {
|
||
const cmp = comparator(mode);
|
||
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 keys = [...groups.keys()].sort((a, b) => 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[]) =>
|
||
items
|
||
.map((v) => v.id)
|
||
.sort()
|
||
.join(",");
|
||
|
||
function Row({
|
||
video,
|
||
index,
|
||
readOnly,
|
||
onPlay,
|
||
onRemove,
|
||
}: {
|
||
video: Video;
|
||
index: number;
|
||
readOnly?: boolean;
|
||
onPlay: () => void;
|
||
onRemove: () => void;
|
||
}) {
|
||
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,
|
||
};
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={style}
|
||
className="flex items-center gap-2.5 p-2 rounded-lg border border-border bg-card"
|
||
>
|
||
{readOnly ? (
|
||
<span className="w-4" />
|
||
) : (
|
||
<button
|
||
{...attributes}
|
||
{...listeners}
|
||
className="text-muted hover:text-fg cursor-grab active:cursor-grabbing"
|
||
aria-label="reorder"
|
||
>
|
||
<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}
|
||
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>
|
||
)}
|
||
{!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>
|
||
);
|
||
}
|
||
|
||
export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||
const { t } = useTranslation();
|
||
const qc = useQueryClient();
|
||
const confirm = useConfirm();
|
||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||
const [newName, setNewName] = useState("");
|
||
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.
|
||
const order = useUndoable<Video[]>([], {
|
||
onApply: (next) => {
|
||
if (selectedId == null) return;
|
||
api
|
||
.reorderPlaylist(selectedId, next.map((v) => v.id))
|
||
.then(() => qc.invalidateQueries({ queryKey: ["playlists"] }));
|
||
},
|
||
});
|
||
const items = order.value;
|
||
const [sortMode, setSortMode] = useState<SortMode>("manual");
|
||
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.
|
||
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
||
|
||
const listQuery = useQuery({
|
||
queryKey: ["playlists"],
|
||
queryFn: () => api.playlists(),
|
||
refetchOnWindowFocus: true,
|
||
});
|
||
const playlists = listQuery.data ?? [];
|
||
|
||
// Keep the selection valid: default to the first playlist, and if the selected one
|
||
// disappears (e.g. just deleted) drop to the next available, or clear when none remain.
|
||
useEffect(() => {
|
||
if (!playlists.length) {
|
||
if (selectedId != null) setSelectedId(null);
|
||
return;
|
||
}
|
||
if (selectedId == null || !playlists.some((p) => p.id === selectedId)) {
|
||
setSelectedId(playlists[0].id);
|
||
}
|
||
}, [playlists, selectedId]);
|
||
|
||
const detailQuery = useQuery({
|
||
queryKey: ["playlist", selectedId],
|
||
queryFn: () => api.playlist(selectedId as number),
|
||
enabled: selectedId != null,
|
||
// Refetch when returning to the tab so a change made elsewhere (another tab/device)
|
||
// shows up — including the player's live N / M count, which reads the queue length.
|
||
refetchOnWindowFocus: true,
|
||
});
|
||
const detail = detailQuery.data;
|
||
|
||
useEffect(() => {
|
||
if (!detail) return;
|
||
const key = idsKey(detail.items);
|
||
if (key !== lastSetRef.current) {
|
||
lastSetRef.current = key;
|
||
order.reset(detail.items);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [detail]);
|
||
|
||
// Reset the sort controls when switching playlists (the order itself is per-playlist).
|
||
useEffect(() => {
|
||
setSortMode("manual");
|
||
setGroupBy(false);
|
||
}, [selectedId]);
|
||
|
||
function applySort(mode: SortMode, group: boolean) {
|
||
order.set(sortItems(items, mode, group));
|
||
}
|
||
const undo = () => {
|
||
setSortMode("manual");
|
||
setGroupBy(false);
|
||
order.undo();
|
||
};
|
||
const redo = () => {
|
||
setSortMode("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 chip just marks their origin.
|
||
const mirrored = detail?.source === "youtube";
|
||
const editable = !builtin;
|
||
const linked = editable && !!detail?.yt_playlist_id;
|
||
const [syncing, setSyncing] = useState(false);
|
||
const [pushing, setPushing] = useState(false);
|
||
|
||
async function pushToYoutube() {
|
||
if (selectedId == null || !detail || pushing) return;
|
||
setPushing(true);
|
||
try {
|
||
const plan = await api.playlistPushPlan(selectedId);
|
||
if (plan.action === "update" && !plan.to_insert && !plan.to_delete && !plan.to_reorder) {
|
||
notify({ level: "info", message: t("playlists.pushUpToDate") });
|
||
return;
|
||
}
|
||
if (!plan.affordable) {
|
||
notify({
|
||
level: "warning",
|
||
message: t("playlists.pushNoQuota", {
|
||
left: plan.remaining_today,
|
||
units: plan.units_estimate,
|
||
}),
|
||
});
|
||
return;
|
||
}
|
||
let message =
|
||
plan.action === "create"
|
||
? t("playlists.pushPlanCreate", {
|
||
count: plan.to_insert,
|
||
units: plan.units_estimate,
|
||
left: plan.remaining_today,
|
||
})
|
||
: t("playlists.pushPlanUpdate", {
|
||
insert: plan.to_insert,
|
||
del: plan.to_delete,
|
||
reorder: plan.to_reorder,
|
||
units: plan.units_estimate,
|
||
left: plan.remaining_today,
|
||
});
|
||
if (plan.yt_extra > 0) message += " " + t("playlists.pushDiverged", { count: plan.yt_extra });
|
||
const ok = await confirm({
|
||
title: t("playlists.pushTitle"),
|
||
message,
|
||
confirmLabel: t("playlists.pushConfirm"),
|
||
});
|
||
if (!ok) return;
|
||
const r = await api.pushPlaylist(selectedId);
|
||
refreshAll();
|
||
if (r.failures.length) {
|
||
notify({ level: "warning", message: t("playlists.pushPartial", { count: r.failures.length }) });
|
||
} else {
|
||
notify({ level: "success", message: t("playlists.pushDone") });
|
||
}
|
||
} catch {
|
||
notify({ level: "warning", message: t("playlists.pushFailed") });
|
||
} finally {
|
||
setPushing(false);
|
||
}
|
||
}
|
||
|
||
async function syncYoutube() {
|
||
if (syncing) return;
|
||
setSyncing(true);
|
||
try {
|
||
const r = await api.syncYoutubePlaylists();
|
||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||
qc.invalidateQueries({ queryKey: ["playlist"] });
|
||
notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) });
|
||
} catch {
|
||
notify({ level: "warning", message: t("playlists.syncFailed") });
|
||
} finally {
|
||
setSyncing(false);
|
||
}
|
||
}
|
||
|
||
const createMut = useMutation({
|
||
mutationFn: (name: string) => api.createPlaylist(name),
|
||
onSuccess: (pl: Playlist) => {
|
||
setNewName("");
|
||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||
setSelectedId(pl.id);
|
||
},
|
||
});
|
||
|
||
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.
|
||
setSortMode("manual");
|
||
setGroupBy(false);
|
||
order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists
|
||
}
|
||
|
||
async function removeItem(videoId: string) {
|
||
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);
|
||
lastSetRef.current = idsKey(next);
|
||
await api.removeFromPlaylist(selectedId, videoId);
|
||
refreshAll();
|
||
}
|
||
|
||
async function saveRename() {
|
||
if (selectedId == null) return;
|
||
const name = renameValue.trim();
|
||
setRenaming(false);
|
||
if (name && name !== detail?.name) {
|
||
await api.renamePlaylist(selectedId, name);
|
||
refreshAll();
|
||
}
|
||
}
|
||
|
||
async function deletePlaylist() {
|
||
if (selectedId == null || !detail) return;
|
||
const ok = await confirm({
|
||
title: t("playlists.delete"),
|
||
message: t("playlists.confirmDelete", { name: detail.name }),
|
||
confirmLabel: 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,
|
||
});
|
||
}
|
||
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).
|
||
const remaining = playlists.filter((p) => p.id !== deletedId);
|
||
setSelectedId(remaining[0]?.id ?? null);
|
||
try {
|
||
await api.deletePlaylist(deletedId, onYoutube);
|
||
} catch {
|
||
notify({ level: "warning", message: t("playlists.deleteYtFailed") });
|
||
}
|
||
qc.removeQueries({ queryKey: ["playlist", deletedId] });
|
||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||
}
|
||
|
||
function playerState(id: string, status: string) {
|
||
api.setState(id, status).then(() => {
|
||
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
|
||
qc.invalidateQueries({ queryKey: ["feed"] });
|
||
});
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full min-h-0">
|
||
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-3">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-sm font-semibold">{t("playlists.title")}</span>
|
||
<button
|
||
onClick={syncYoutube}
|
||
disabled={syncing}
|
||
title={t("playlists.syncYoutube")}
|
||
aria-label={t("playlists.syncYoutube")}
|
||
className="inline-flex items-center gap-1 text-[11px] text-muted hover:text-accent disabled:opacity-40 transition"
|
||
>
|
||
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
|
||
<Youtube className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
{playlists.length === 0 && !listQuery.isLoading && (
|
||
<div className="text-xs text-muted px-1 py-2">{t("playlists.noneYet")}</div>
|
||
)}
|
||
<div className="space-y-1">
|
||
{playlists.map((pl) => (
|
||
<button
|
||
key={pl.id}
|
||
onClick={() => setSelectedId(pl.id)}
|
||
className={`w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
|
||
pl.id === selectedId
|
||
? "bg-accent/15 border-accent"
|
||
: "border-transparent hover:bg-card/60"
|
||
}`}
|
||
>
|
||
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
|
||
{pl.cover_thumbnail && (
|
||
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
||
)}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-1 text-[13px] text-fg">
|
||
<span className="truncate">{plName(pl)}</span>
|
||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||
<Youtube
|
||
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div className="text-[11px] text-muted">
|
||
{t("playlists.itemCount", { count: pl.item_count })}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
if (newName.trim()) createMut.mutate(newName.trim());
|
||
}}
|
||
className="flex items-center gap-1.5 mt-3 pt-3 border-t border-border"
|
||
>
|
||
<Plus className="w-4 h-4 text-muted shrink-0" />
|
||
<input
|
||
value={newName}
|
||
onChange={(e) => setNewName(e.target.value)}
|
||
placeholder={t("playlists.newPlaylist")}
|
||
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
||
/>
|
||
</form>
|
||
</aside>
|
||
|
||
<main className="flex-1 min-w-0 overflow-y-auto p-4">
|
||
{selectedId == null || !detail ? (
|
||
<div className="text-muted text-sm p-4">
|
||
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
|
||
</div>
|
||
) : (
|
||
<>
|
||
<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">
|
||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||
{editable && (
|
||
<button
|
||
onClick={() => {
|
||
setRenameValue(detail.name);
|
||
setRenaming(true);
|
||
}}
|
||
title={t("playlists.rename")}
|
||
className="text-muted hover:text-fg"
|
||
>
|
||
<Pencil className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="text-xs text-muted mt-0.5">
|
||
{t("playlists.itemCount", { count: items.length })}
|
||
</div>
|
||
<div className="flex gap-2 mt-3 flex-wrap">
|
||
<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")}
|
||
className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 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" />
|
||
)}
|
||
{linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
|
||
</button>
|
||
)}
|
||
{editable && (
|
||
<button
|
||
onClick={deletePlaylist}
|
||
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" /> {t("playlists.delete")}
|
||
</button>
|
||
)}
|
||
{linked && detail.dirty && (
|
||
<span className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg text-accent">
|
||
{t("playlists.unsynced")}
|
||
</span>
|
||
)}
|
||
{mirrored && (
|
||
<span className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg text-muted">
|
||
<Youtube className="w-3.5 h-3.5" /> {t("playlists.ytReadonly")}
|
||
</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={sortMode}
|
||
onChange={(e) => {
|
||
const m = e.target.value as SortMode;
|
||
setSortMode(m);
|
||
applySort(m, 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-asc">{t("playlists.sortTitleAsc")}</option>
|
||
<option value="title-desc">{t("playlists.sortTitleDesc")}</option>
|
||
<option value="dur-asc">{t("playlists.sortDurAsc")}</option>
|
||
<option value="dur-desc">{t("playlists.sortDurDesc")}</option>
|
||
</select>
|
||
<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(sortMode, e.target.checked);
|
||
}}
|
||
className="accent-accent"
|
||
/>
|
||
{t("playlists.groupByChannel")}
|
||
</label>
|
||
<div className="ml-auto">
|
||
<UndoToolbar
|
||
canUndo={order.canUndo}
|
||
canRedo={order.canRedo}
|
||
onUndo={undo}
|
||
onRedo={redo}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{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}
|
||
>
|
||
<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>
|
||
)}
|
||
</>
|
||
)}
|
||
</main>
|
||
|
||
{playingIndex != null && items[playingIndex] && (
|
||
<PlayerModal
|
||
video={items[playingIndex]}
|
||
queue={items}
|
||
startIndex={playingIndex}
|
||
startAt={null}
|
||
onClose={() => setPlayingIndex(null)}
|
||
onState={playerState}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|