2026-07-13 05:13:40 +02:00
|
|
|
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
2026-06-15 14:45:18 +02:00
|
|
|
import { useTranslation } from "react-i18next";
|
2026-07-13 05:13:40 +02:00
|
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
2026-06-15 14:45:18 +02:00
|
|
|
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 {
|
2026-06-15 22:13:32 +02:00
|
|
|
ArrowDown,
|
|
|
|
|
ArrowUp,
|
2026-06-15 22:40:21 +02:00
|
|
|
ExternalLink,
|
2026-06-15 14:45:18 +02:00
|
|
|
GripVertical,
|
|
|
|
|
ListPlus,
|
|
|
|
|
Pencil,
|
|
|
|
|
Play,
|
2026-06-15 19:37:17 +02:00
|
|
|
RefreshCw,
|
2026-06-15 22:28:16 +02:00
|
|
|
RotateCcw,
|
2026-06-15 14:45:18 +02:00
|
|
|
Trash2,
|
2026-06-15 21:23:13 +02:00
|
|
|
Upload,
|
2026-06-15 14:45:18 +02:00
|
|
|
X,
|
2026-06-15 19:37:17 +02:00
|
|
|
Youtube,
|
2026-06-15 14:45:18 +02:00
|
|
|
} from "lucide-react";
|
2026-07-13 05:13:40 +02:00
|
|
|
import { api, type Video } from "../lib/api";
|
2026-06-15 14:45:18 +02:00
|
|
|
import { formatDuration } from "../lib/format";
|
2026-06-15 19:37:17 +02:00
|
|
|
import { notify } from "../lib/notifications";
|
2026-07-11 18:33:16 +02:00
|
|
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
2026-06-15 21:52:28 +02:00
|
|
|
import { useUndoable } from "../lib/useUndoable";
|
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
|
|
|
const PlayerModal = lazy(() => import("./PlayerModal"));
|
2026-06-15 21:52:28 +02:00
|
|
|
import UndoToolbar from "./UndoToolbar";
|
2026-06-15 15:06:47 +02:00
|
|
|
import { useConfirm } from "./ConfirmProvider";
|
2026-06-15 14:45:18 +02:00
|
|
|
|
2026-06-15 22:13:32 +02:00
|
|
|
type SortKey = "manual" | "title" | "duration" | "channel";
|
|
|
|
|
type SortDir = "asc" | "desc";
|
2026-06-15 21:52:28 +02:00
|
|
|
|
2026-06-15 22:13:32 +02:00
|
|
|
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);
|
|
|
|
|
};
|
2026-06-15 21:52:28 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sort a playlist's items. When `group` is on, items are grouped by channel (groups ordered
|
2026-06-15 22:13:32 +02:00
|
|
|
// 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);
|
2026-06-15 21:52:28 +02:00
|
|
|
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]);
|
|
|
|
|
}
|
2026-06-15 22:13:32 +02:00
|
|
|
const sign = dir === "asc" ? 1 : -1;
|
|
|
|
|
const keys = [...groups.keys()].sort((a, b) => sign * a.localeCompare(b));
|
2026-06-15 21:52:28 +02:00
|
|
|
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(",");
|
|
|
|
|
|
2026-06-15 14:45:18 +02:00
|
|
|
function Row({
|
|
|
|
|
video,
|
|
|
|
|
index,
|
2026-06-15 19:37:17 +02:00
|
|
|
readOnly,
|
2026-06-15 14:45:18 +02:00
|
|
|
onPlay,
|
|
|
|
|
onRemove,
|
|
|
|
|
}: {
|
|
|
|
|
video: Video;
|
|
|
|
|
index: number;
|
2026-06-15 19:37:17 +02:00
|
|
|
readOnly?: boolean;
|
2026-06-15 14:45:18 +02:00
|
|
|
onPlay: () => void;
|
|
|
|
|
onRemove: () => void;
|
|
|
|
|
}) {
|
2026-06-16 09:36:51 +02:00
|
|
|
const { t } = useTranslation();
|
2026-06-15 14:45:18 +02:00
|
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
2026-06-15 19:37:17 +02:00
|
|
|
useSortable({ id: video.id, disabled: readOnly });
|
2026-06-15 14:45:18 +02:00
|
|
|
const style = {
|
|
|
|
|
transform: CSS.Transform.toString(transform),
|
|
|
|
|
transition,
|
|
|
|
|
opacity: isDragging ? 0.6 : 1,
|
|
|
|
|
};
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
ref={setNodeRef}
|
|
|
|
|
style={style}
|
2026-07-12 22:54:13 +02:00
|
|
|
className="flex items-center gap-2.5 p-2 rounded-lg glass-card glass-hover"
|
2026-06-15 14:45:18 +02:00
|
|
|
>
|
2026-06-15 19:37:17 +02:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-06-15 14:45:18 +02:00
|
|
|
<span className="text-xs text-muted w-4 text-center tabular-nums">{index + 1}</span>
|
|
|
|
|
<button
|
|
|
|
|
onClick={onPlay}
|
2026-07-04 19:13:11 +02:00
|
|
|
aria-label={`${t("card.play")} — ${video.title}`}
|
2026-06-15 14:45:18 +02:00
|
|
|
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>
|
2026-06-16 09:36:51 +02:00
|
|
|
{video.duration_seconds != null ? (
|
2026-06-15 14:45:18 +02:00
|
|
|
<span className="text-[11px] text-muted tabular-nums">
|
|
|
|
|
{formatDuration(video.duration_seconds)}
|
|
|
|
|
</span>
|
2026-06-16 09:36:51 +02:00
|
|
|
) : 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}
|
2026-06-15 19:37:17 +02:00
|
|
|
{!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>
|
|
|
|
|
)}
|
2026-06-15 14:45:18 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 02:28:06 +02:00
|
|
|
export default function Playlists({
|
|
|
|
|
canWrite,
|
2026-07-13 05:13:40 +02:00
|
|
|
selectedId,
|
|
|
|
|
setSelectedId,
|
2026-07-13 02:28:06 +02:00
|
|
|
}: {
|
|
|
|
|
canWrite: boolean;
|
2026-07-13 05:13:40 +02:00
|
|
|
// The selected playlist is App state, shared with the App-level PlaylistsRail (which owns the
|
|
|
|
|
// list + its rail controls). This detail pane reacts to the selection.
|
|
|
|
|
selectedId: number | null;
|
|
|
|
|
setSelectedId: (id: number | null) => void;
|
2026-07-13 02:28:06 +02:00
|
|
|
}) {
|
2026-06-15 14:45:18 +02:00
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const qc = useQueryClient();
|
2026-06-15 15:06:47 +02:00
|
|
|
const confirm = useConfirm();
|
2026-06-15 14:45:18 +02:00
|
|
|
const [renaming, setRenaming] = useState(false);
|
|
|
|
|
const [renameValue, setRenameValue] = useState("");
|
2026-06-15 21:52:28 +02:00
|
|
|
// 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;
|
2026-06-15 22:44:57 +02:00
|
|
|
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] });
|
|
|
|
|
});
|
2026-06-15 21:52:28 +02:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const items = order.value;
|
2026-06-15 22:13:32 +02:00
|
|
|
const [sortKey, setSortKey] = useState<SortKey>("manual");
|
|
|
|
|
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
2026-06-15 21:52:28 +02:00
|
|
|
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>("");
|
2026-06-15 16:11:37 +02:00
|
|
|
// The open player, as an index into `items` (the queue); null = closed.
|
|
|
|
|
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
2026-06-15 14:45:18 +02:00
|
|
|
|
2026-06-15 16:27:28 +02:00
|
|
|
const listQuery = useQuery({
|
|
|
|
|
queryKey: ["playlists"],
|
|
|
|
|
queryFn: () => api.playlists(),
|
|
|
|
|
refetchOnWindowFocus: true,
|
|
|
|
|
});
|
2026-06-15 14:45:18 +02:00
|
|
|
const playlists = listQuery.data ?? [];
|
|
|
|
|
|
|
|
|
|
const detailQuery = useQuery({
|
|
|
|
|
queryKey: ["playlist", selectedId],
|
|
|
|
|
queryFn: () => api.playlist(selectedId as number),
|
|
|
|
|
enabled: selectedId != null,
|
2026-06-15 16:27:28 +02:00
|
|
|
// 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,
|
2026-06-15 14:45:18 +02:00
|
|
|
});
|
|
|
|
|
const detail = detailQuery.data;
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-06-15 21:52:28 +02:00
|
|
|
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
|
2026-06-15 14:45:18 +02:00
|
|
|
}, [detail]);
|
|
|
|
|
|
2026-06-15 21:52:28 +02:00
|
|
|
// Reset the sort controls when switching playlists (the order itself is per-playlist).
|
|
|
|
|
useEffect(() => {
|
2026-06-15 22:13:32 +02:00
|
|
|
setSortKey("manual");
|
|
|
|
|
setSortDir("asc");
|
2026-06-15 21:52:28 +02:00
|
|
|
setGroupBy(false);
|
|
|
|
|
}, [selectedId]);
|
|
|
|
|
|
2026-06-15 22:13:32 +02:00
|
|
|
function applySort(key: SortKey, dir: SortDir, group: boolean) {
|
|
|
|
|
order.set(sortItems(items, key, dir, group));
|
2026-06-15 21:52:28 +02:00
|
|
|
}
|
|
|
|
|
const undo = () => {
|
2026-06-15 22:13:32 +02:00
|
|
|
setSortKey("manual");
|
2026-06-15 21:52:28 +02:00
|
|
|
setGroupBy(false);
|
|
|
|
|
order.undo();
|
|
|
|
|
};
|
|
|
|
|
const redo = () => {
|
2026-06-15 22:13:32 +02:00
|
|
|
setSortKey("manual");
|
2026-06-15 21:52:28 +02:00
|
|
|
setGroupBy(false);
|
|
|
|
|
order.redo();
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-15 14:45:18 +02:00
|
|
|
const sensors = useSensors(
|
|
|
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-15 15:33:53 +02:00
|
|
|
// 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;
|
2026-06-15 22:13:32 +02:00
|
|
|
|
2026-06-15 15:33:53 +02:00
|
|
|
const builtin = detail?.kind === "watch_later";
|
2026-06-15 22:40:21 +02:00
|
|
|
// 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.
|
2026-06-15 21:40:13 +02:00
|
|
|
const editable = !builtin;
|
2026-06-15 21:23:13 +02:00
|
|
|
const linked = editable && !!detail?.yt_playlist_id;
|
|
|
|
|
const [pushing, setPushing] = useState(false);
|
2026-06-15 22:28:16 +02:00
|
|
|
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;
|
|
|
|
|
setReverting(true);
|
|
|
|
|
try {
|
|
|
|
|
await api.revertPlaylist(selectedId);
|
|
|
|
|
refreshAll();
|
2026-07-11 18:33:16 +02:00
|
|
|
notify({ level: "success", message: t("playlists.revertDone", { name: plName(detail) }) });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
notifyYouTubeActionError(e, t("playlists.revertFailed"));
|
2026-06-15 22:28:16 +02:00
|
|
|
} finally {
|
|
|
|
|
setReverting(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-15 21:23:13 +02:00
|
|
|
|
|
|
|
|
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) {
|
2026-07-11 18:33:16 +02:00
|
|
|
notify({ level: "info", message: t("playlists.pushUpToDate", { name: plName(detail) }) });
|
2026-06-15 21:23:13 +02:00
|
|
|
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 {
|
2026-07-11 18:33:16 +02:00
|
|
|
notify({ level: "success", message: t("playlists.pushDone", { name: plName(detail) }) });
|
2026-06-15 21:23:13 +02:00
|
|
|
}
|
2026-07-11 18:33:16 +02:00
|
|
|
} catch (e) {
|
|
|
|
|
notifyYouTubeActionError(e, t("playlists.pushFailed"));
|
2026-06-15 21:23:13 +02:00
|
|
|
} finally {
|
|
|
|
|
setPushing(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-15 19:37:17 +02:00
|
|
|
|
2026-06-15 14:45:18 +02:00
|
|
|
function refreshAll() {
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["playlists"] });
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 21:52:28 +02:00
|
|
|
function onDragEnd(e: DragEndEvent) {
|
2026-06-15 14:45:18 +02:00
|
|
|
const { active: a, over } = e;
|
2026-06-15 21:40:13 +02:00
|
|
|
if (!over || a.id === over.id || selectedId == null) return;
|
2026-06-15 14:45:18 +02:00
|
|
|
const oldIndex = items.findIndex((v) => v.id === a.id);
|
|
|
|
|
const newIndex = items.findIndex((v) => v.id === over.id);
|
|
|
|
|
if (oldIndex < 0 || newIndex < 0) return;
|
2026-06-15 21:52:28 +02:00
|
|
|
// A manual drag breaks any active sort/grouping; reflect that in the controls.
|
2026-06-15 22:13:32 +02:00
|
|
|
setSortKey("manual");
|
2026-06-15 21:52:28 +02:00
|
|
|
setGroupBy(false);
|
|
|
|
|
order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists
|
2026-06-15 14:45:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function removeItem(videoId: string) {
|
|
|
|
|
if (selectedId == null) return;
|
2026-06-15 21:52:28 +02:00
|
|
|
// 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);
|
2026-07-11 18:33:16 +02:00
|
|
|
try {
|
|
|
|
|
await api.removeFromPlaylist(selectedId, videoId);
|
|
|
|
|
refreshAll();
|
|
|
|
|
} catch {
|
|
|
|
|
// The optimistic removal above didn't stick — resync from the server to restore the row.
|
|
|
|
|
// api.req already surfaced the error to the user via the global error dialog.
|
|
|
|
|
refreshAll();
|
|
|
|
|
}
|
2026-06-15 14:45:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-06-15 15:06:47 +02:00
|
|
|
const ok = await confirm({
|
|
|
|
|
title: t("playlists.delete"),
|
|
|
|
|
message: t("playlists.confirmDelete", { name: detail.name }),
|
|
|
|
|
confirmLabel: t("playlists.delete"),
|
|
|
|
|
danger: true,
|
|
|
|
|
});
|
|
|
|
|
if (!ok) return;
|
2026-06-15 21:23:13 +02:00
|
|
|
// 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,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-15 15:15:56 +02:00
|
|
|
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);
|
2026-06-15 21:23:13 +02:00
|
|
|
try {
|
|
|
|
|
await api.deletePlaylist(deletedId, onYoutube);
|
|
|
|
|
} catch {
|
|
|
|
|
notify({ level: "warning", message: t("playlists.deleteYtFailed") });
|
|
|
|
|
}
|
2026-06-15 15:15:56 +02:00
|
|
|
qc.removeQueries({ queryKey: ["playlist", deletedId] });
|
2026-06-15 14:45:18 +02:00
|
|
|
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 (
|
2026-07-13 05:13:40 +02:00
|
|
|
<div className="px-4 pb-4">
|
2026-06-15 14:56:39 +02:00
|
|
|
{selectedId == null || !detail ? (
|
2026-06-15 14:45:18 +02:00
|
|
|
<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"
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
2026-06-15 22:40:21 +02:00
|
|
|
<div className="flex items-center gap-2 min-w-0">
|
2026-06-15 15:33:53 +02:00
|
|
|
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
2026-06-15 19:37:17 +02:00
|
|
|
{editable && (
|
2026-06-15 15:33:53 +02:00
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setRenameValue(detail.name);
|
|
|
|
|
setRenaming(true);
|
|
|
|
|
}}
|
|
|
|
|
title={t("playlists.rename")}
|
2026-06-15 22:40:21 +02:00
|
|
|
aria-label={t("playlists.rename")}
|
|
|
|
|
className="shrink-0 text-muted hover:text-fg"
|
2026-06-15 15:33:53 +02:00
|
|
|
>
|
|
|
|
|
<Pencil className="w-3.5 h-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-06-15 22:40:21 +02:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-06-15 14:45:18 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className="text-xs text-muted mt-0.5">
|
|
|
|
|
{t("playlists.itemCount", { count: items.length })}
|
|
|
|
|
</div>
|
2026-06-15 22:40:21 +02:00
|
|
|
<div className="flex gap-1.5 mt-3 flex-wrap items-center">
|
2026-06-15 14:45:18 +02:00
|
|
|
<button
|
2026-06-15 16:11:37 +02:00
|
|
|
onClick={() => items.length && setPlayingIndex(0)}
|
2026-06-15 14:45:18 +02:00
|
|
|
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>
|
2026-06-15 21:23:13 +02:00
|
|
|
{editable && canWrite && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={pushToYoutube}
|
|
|
|
|
disabled={pushing}
|
|
|
|
|
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
|
2026-06-15 22:40:21 +02:00
|
|
|
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 ${
|
2026-06-15 21:23:13 +02:00
|
|
|
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" />
|
|
|
|
|
)}
|
2026-06-15 22:40:21 +02:00
|
|
|
</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" : ""}`} />
|
2026-06-15 21:23:13 +02:00
|
|
|
</button>
|
|
|
|
|
)}
|
2026-06-15 19:37:17 +02:00
|
|
|
{editable && (
|
2026-06-15 15:33:53 +02:00
|
|
|
<button
|
|
|
|
|
onClick={deletePlaylist}
|
2026-06-15 22:40:21 +02:00
|
|
|
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"
|
2026-06-15 15:33:53 +02:00
|
|
|
>
|
2026-06-15 22:40:21 +02:00
|
|
|
<Trash2 className="w-3.5 h-3.5" />
|
2026-06-15 15:33:53 +02:00
|
|
|
</button>
|
|
|
|
|
)}
|
2026-06-15 21:23:13 +02:00
|
|
|
{linked && detail.dirty && (
|
2026-06-15 22:40:21 +02:00
|
|
|
<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")}
|
2026-06-15 19:37:17 +02:00
|
|
|
</span>
|
|
|
|
|
)}
|
2026-06-15 14:45:18 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-15 21:52:28 +02:00
|
|
|
{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
|
2026-06-15 22:13:32 +02:00
|
|
|
value={sortKey}
|
2026-06-15 21:52:28 +02:00
|
|
|
onChange={(e) => {
|
2026-06-15 22:13:32 +02:00
|
|
|
const k = e.target.value as SortKey;
|
|
|
|
|
setSortKey(k);
|
|
|
|
|
applySort(k, sortDir, groupBy);
|
2026-06-15 21:52:28 +02:00
|
|
|
}}
|
|
|
|
|
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>
|
2026-06-15 22:13:32 +02:00
|
|
|
<option value="title">{t("playlists.sortTitle")}</option>
|
|
|
|
|
<option value="duration">{t("playlists.sortDuration")}</option>
|
|
|
|
|
<option value="channel">{t("playlists.sortChannel")}</option>
|
2026-06-15 21:52:28 +02:00
|
|
|
</select>
|
2026-06-15 22:13:32 +02:00
|
|
|
<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>
|
2026-06-15 21:52:28 +02:00
|
|
|
<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);
|
2026-06-15 22:13:32 +02:00
|
|
|
applySort(sortKey, sortDir, e.target.checked);
|
2026-06-15 21:52:28 +02:00
|
|
|
}}
|
|
|
|
|
className="accent-accent"
|
|
|
|
|
/>
|
|
|
|
|
{t("playlists.groupByChannel")}
|
|
|
|
|
</label>
|
|
|
|
|
<div className="ml-auto">
|
|
|
|
|
<UndoToolbar
|
|
|
|
|
canUndo={order.canUndo}
|
|
|
|
|
canRedo={order.canRedo}
|
|
|
|
|
onUndo={undo}
|
|
|
|
|
onRedo={redo}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-06-15 14:45:18 +02:00
|
|
|
{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}
|
2026-06-15 16:11:37 +02:00
|
|
|
onPlay={() => setPlayingIndex(i)}
|
2026-06-15 14:45:18 +02:00
|
|
|
onRemove={() => removeItem(v.id)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</SortableContext>
|
|
|
|
|
</DndContext>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-06-15 16:11:37 +02:00
|
|
|
{playingIndex != null && items[playingIndex] && (
|
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
|
|
|
<Suspense fallback={null}>
|
|
|
|
|
<PlayerModal
|
|
|
|
|
video={items[playingIndex]}
|
|
|
|
|
queue={items}
|
|
|
|
|
startIndex={playingIndex}
|
|
|
|
|
startAt={null}
|
|
|
|
|
onClose={() => setPlayingIndex(null)}
|
|
|
|
|
onState={playerState}
|
|
|
|
|
/>
|
|
|
|
|
</Suspense>
|
2026-06-15 14:45:18 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|