siftlode/frontend/src/components/Playlists.tsx
npeter83 2344902a7f perf: route- and modal-level code splitting (React.lazy)
The whole app shipped in one bundle, so the logged-out landing and every page
pulled all module code. Split into lazy chunks:
- main.tsx: lazy App + the public leaves (WatchPage, Privacy, Terms), so a
  public /watch share link never downloads the authenticated app.
- App.tsx: each module page (Feed, Channels, Playlists, Stats, Scheduler,
  Config, Users, Settings, Notifications, Messages, Downloads, ChannelPage) and
  the About/ReleaseNotes/Onboarding modals load on demand behind <Suspense>.
- Heavy modals lazy in their parents: PlayerModal (Feed, Playlists),
  DownloadDialog (DownloadButton — kept out of the feed chunk), and the
  VideoEditor/ShareDialog/ProfileEditor (DownloadCenter).
- Extracted focusAccessRequestsTab + the tab constants to lib/adminUsersTab so
  callers can pre-select the admin tab without statically importing the now
  lazy-loaded AdminUsers page (which would defeat the split).

Build now emits ~25 chunks. Landing no longer downloads any module code
(~350 KB deferred); dev landing Perf 93->95. Verified in a real browser: every
page + the video editor load with no console errors.
2026-07-04 19:43:50 +02:00

866 lines
34 KiB
TypeScript

import { lazy, Suspense, useEffect, useMemo, 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 {
ArrowDown,
ArrowUp,
Check,
ExternalLink,
GripVertical,
ListPlus,
Pencil,
Pin,
Play,
Plus,
RefreshCw,
RotateCcw,
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 { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage";
import { useUndoable } from "../lib/useUndoable";
const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar";
import { useConfirm } from "./ConfirmProvider";
type SortKey = "manual" | "title" | "duration" | "channel";
type SortDir = "asc" | "desc";
function comparator(key: SortKey, dir: SortDir): ((a: Video, b: Video) => number) | null {
if (key === "manual") return null;
const sign = dir === "asc" ? 1 : -1;
if (key === "duration")
return (a, b) => {
const av = a.duration_seconds;
const bv = b.duration_seconds;
if (av == null && bv == null) return 0;
if (av == null) return 1; // nulls always last, both directions
if (bv == null) return -1;
return sign * (av - bv);
};
return (a, b) => {
const av = (key === "channel" ? a.channel_title : a.title) ?? "";
const bv = (key === "channel" ? b.channel_title : b.title) ?? "";
return sign * av.localeCompare(bv);
};
}
// Sort a playlist's items. When `group` is on, items are grouped by channel (groups ordered
// by channel name in the chosen direction) and the chosen sort is applied within each group;
// with key "manual" the within-group order is left as-is. Returns the same array reference
// when nothing would change, so it doesn't create a spurious undo entry.
function sortItems(items: Video[], key: SortKey, dir: SortDir, group: boolean): Video[] {
const cmp = comparator(key, dir);
if (!group) return cmp ? [...items].sort(cmp) : items;
const groups = new Map<string, Video[]>();
for (const v of items) {
const k = v.channel_title ?? "";
const g = groups.get(k);
if (g) g.push(v);
else groups.set(k, [v]);
}
const sign = dir === "asc" ? 1 : -1;
const keys = [...groups.keys()].sort((a, b) => sign * a.localeCompare(b));
const out: Video[] = [];
for (const k of keys) {
const g = groups.get(k)!;
out.push(...(cmp ? [...g].sort(cmp) : g));
}
return out;
}
const idsKey = (items: Video[]) =>
items
.map((v) => v.id)
.sort()
.join(",");
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: SortDir; dirtyFirst: boolean };
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
function Row({
video,
index,
readOnly,
onPlay,
onRemove,
}: {
video: Video;
index: number;
readOnly?: boolean;
onPlay: () => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: video.id, disabled: readOnly });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
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}
aria-label={`${t("card.play")}${video.title}`}
className="relative w-[62px] h-[35px] rounded overflow-hidden bg-surface shrink-0 group"
>
{video.thumbnail_url && (
<img src={video.thumbnail_url} alt="" className="w-full h-full object-cover" />
)}
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover:opacity-100 transition">
<Play className="w-4 h-4 text-white fill-current" />
</span>
</button>
<button onClick={onPlay} className="min-w-0 flex-1 text-left">
<div className="text-[13px] text-fg truncate">{video.title}</div>
<div className="text-[11px] text-muted truncate">{video.channel_title}</div>
</button>
{video.duration_seconds != null ? (
<span className="text-[11px] text-muted tabular-nums">
{formatDuration(video.duration_seconds)}
</span>
) : video.live_status === "live" || video.live_status === "upcoming" ? (
<span
className={`text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
video.live_status === "live"
? "bg-red-500/90 text-white"
: "bg-card border border-border text-muted"
}`}
>
{t(`card.${video.live_status}`)}
</span>
) : null}
{!readOnly && (
<button
onClick={onRemove}
title="remove"
className="text-muted hover:text-fg p-1"
aria-label="remove from playlist"
>
<X className="w-4 h-4" />
</button>
)}
</div>
);
}
export default function Playlists({ canWrite }: { canWrite: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
// Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL).
const [selectedId, setSelectedId] = useState<number | null>(() => {
const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null;
});
useEffect(() => {
if (selectedId != null) setAccountRaw(LS.playlist, String(selectedId));
}, [selectedId]);
const selectedRef = useRef<HTMLButtonElement | null>(null);
const scrolledRef = useRef(false);
// How the left playlist rail is ordered (persisted).
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
useEffect(() => {
writeAccount(LS.plSort, plSort);
}, [plSort]);
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(() => {
// 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] });
});
},
});
const items = order.value;
const [sortKey, setSortKey] = useState<SortKey>("manual");
const [sortDir, setSortDir] = useState<SortDir>("asc");
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(() => {
// Wait for the first load before adjusting the selection — otherwise the empty
// placeholder list would null the restored selection and we'd fall back to the first.
if (!listQuery.data) return;
if (!playlists.length) {
if (selectedId != null) setSelectedId(null);
return;
}
if (selectedId == null || !playlists.some((p) => p.id === selectedId)) {
setSelectedId(playlists[0].id);
}
}, [playlists, selectedId, listQuery.data]);
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(() => {
setSortKey("manual");
setSortDir("asc");
setGroupBy(false);
}, [selectedId]);
function applySort(key: SortKey, dir: SortDir, group: boolean) {
order.set(sortItems(items, key, dir, group));
}
const undo = () => {
setSortKey("manual");
setGroupBy(false);
order.undo();
};
const redo = () => {
setSortKey("manual");
setGroupBy(false);
order.redo();
};
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
);
// Watch later is a built-in playlist: show a localized name and no rename/delete.
const plName = (p: { kind: string; name: string }) =>
p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
// The left rail in the chosen order. "custom" keeps the server position order (Array.sort
// is stable); "dirty first" floats playlists with unpushed edits to the top.
const sortedPlaylists = useMemo(() => {
const sign = plSort.dir === "asc" ? 1 : -1;
return [...playlists].sort((a, b) => {
if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1;
switch (plSort.key) {
case "name":
return sign * plName(a).localeCompare(plName(b));
case "count":
return sign * (a.item_count - b.item_count);
case "duration":
return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0));
default:
return 0; // custom: server order
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playlists, plSort]);
// Scroll the restored selection into view once after the rail first renders.
useEffect(() => {
if (!scrolledRef.current && selectedRef.current) {
selectedRef.current.scrollIntoView({ block: "nearest" });
scrolledRef.current = true;
}
}, [sortedPlaylists]);
const builtin = detail?.kind === "watch_later";
// YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a
// dirty mirror so it won't clobber unpushed edits); the YT-link button marks their origin.
const editable = !builtin;
const linked = editable && !!detail?.yt_playlist_id;
const [syncing, setSyncing] = useState(false);
const [pushing, setPushing] = useState(false);
const [reverting, setReverting] = useState(false);
async function revertToYoutube() {
if (selectedId == null || !detail || reverting) return;
const ok = await confirm({
title: t("playlists.revertTitle"),
message: t("playlists.revertMsg"),
confirmLabel: t("playlists.revertConfirm"),
danger: true,
});
if (!ok) return;
const plName = detail.kind === "watch_later" ? t("playlists.watchLater") : detail.name;
setReverting(true);
try {
await api.revertPlaylist(selectedId);
refreshAll();
notify({ level: "success", message: t("playlists.revertDone", { name: plName }) });
} catch {
notify({ level: "warning", message: t("playlists.revertFailed") });
} finally {
setReverting(false);
}
}
async function pushToYoutube() {
if (selectedId == null || !detail || pushing) return;
const plName = detail.kind === "watch_later" ? t("playlists.watchLater") : detail.name;
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", { name: plName }) });
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", { name: plName }) });
}
} 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.
setSortKey("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 > 1 && (
<div className="flex items-center gap-1 mb-2">
<select
value={plSort.key}
onChange={(e) =>
setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })
}
aria-label={t("playlists.sortLabel")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-1.5 py-1 text-[11px] outline-none focus:border-accent"
>
<option value="custom">{t("playlists.railSortCustom")}</option>
<option value="name">{t("playlists.railSortName")}</option>
<option value="count">{t("playlists.railSortCount")}</option>
<option value="duration">{t("playlists.railSortDuration")}</option>
</select>
<button
onClick={() =>
setPlSort({ ...plSort, dir: plSort.dir === "asc" ? "desc" : "asc" })
}
disabled={plSort.key === "custom"}
title={plSort.dir === "asc" ? t("playlists.dirAsc") : t("playlists.dirDesc")}
className="p-1 rounded-md border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"
>
{plSort.dir === "asc" ? (
<ArrowUp className="w-3.5 h-3.5" />
) : (
<ArrowDown className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
title={t("playlists.dirtyFirst")}
aria-pressed={plSort.dirtyFirst}
className={`p-1 rounded-md border transition ${
plSort.dirtyFirst
? "border-accent text-accent"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
<Pin className="w-3.5 h-3.5" />
</button>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newName.trim()) createMut.mutate(newName.trim());
}}
className="flex items-center gap-1.5 mb-3 pb-3 border-b 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>
{playlists.length === 0 && !listQuery.isLoading && (
<div className="text-xs text-muted px-1 py-2">{t("playlists.noneYet")}</div>
)}
<div className="space-y-1">
{sortedPlaylists.map((pl) => (
<button
key={pl.id}
ref={pl.id === selectedId ? selectedRef : null}
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 })}
{pl.total_duration_seconds > 0 &&
` · ${formatDuration(pl.total_duration_seconds)}`}
</div>
</div>
</button>
))}
</div>
</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 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 && (
<button
onClick={deletePlaylist}
title={t("playlists.delete")}
aria-label={t("playlists.delete")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-red-400 hover:border-red-400/50 transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{linked && detail.dirty && (
<span
title={t("playlists.unsyncedHint")}
className="inline-flex items-center gap-1.5 text-[11px] text-accent ml-1"
>
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
</span>
)}
</div>
</div>
</div>
{editable && items.length > 1 && (
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-3xl">
<span className="text-xs text-muted">{t("playlists.sortLabel")}</span>
<select
value={sortKey}
onChange={(e) => {
const k = e.target.value as SortKey;
setSortKey(k);
applySort(k, sortDir, groupBy);
}}
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
>
<option value="manual">{t("playlists.sortManual")}</option>
<option value="title">{t("playlists.sortTitle")}</option>
<option value="duration">{t("playlists.sortDuration")}</option>
<option value="channel">{t("playlists.sortChannel")}</option>
</select>
<button
onClick={() => {
const d = sortDir === "asc" ? "desc" : "asc";
setSortDir(d);
if (sortKey !== "manual" || groupBy) applySort(sortKey, d, groupBy);
}}
disabled={sortKey === "manual" && !groupBy}
title={sortDir === "asc" ? t("playlists.dirAsc") : t("playlists.dirDesc")}
className="p-1.5 rounded-lg border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"
>
{sortDir === "asc" ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
</button>
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={groupBy}
onChange={(e) => {
setGroupBy(e.target.checked);
applySort(sortKey, sortDir, e.target.checked);
}}
className="accent-accent"
/>
{t("playlists.groupByChannel")}
</label>
<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] && (
<Suspense fallback={null}>
<PlayerModal
video={items[playingIndex]}
queue={items}
startIndex={playingIndex}
startAt={null}
onClose={() => setPlayingIndex(null)}
onState={playerState}
/>
</Suspense>
)}
</div>
);
}