feat(playlists): frontend foundation — page, add-to-playlist, nav
Add the Playlists page (left rail of playlists + detail with drag&drop reorder, inline rename, one-step delete, remove item, Play all / row-click playback via the existing PlayerModal) and an AddToPlaylist popover (portaled, multi-toggle + inline new-playlist) wired into the VideoCard hover overlay and the PlayerModal. New api client methods + Playlist types, a 'playlists' page route + account-menu entry, and trilingual strings. Local only — YouTube sync comes in later phases.
This commit is contained in:
parent
bf769379cb
commit
40a44cdde2
14 changed files with 634 additions and 4 deletions
173
frontend/src/components/AddToPlaylist.tsx
Normal file
173
frontend/src/components/AddToPlaylist.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ListPlus, Plus } from "lucide-react";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
|
||||
// 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.
|
||||
export default function AddToPlaylist({
|
||||
videoId,
|
||||
className,
|
||||
}: {
|
||||
videoId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
||||
const [newName, setNewName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const membership = useQuery({
|
||||
queryKey: ["playlist-membership", videoId],
|
||||
queryFn: () => api.playlists(videoId),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
function place() {
|
||||
const r = triggerRef.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
const width = 240;
|
||||
setCoords({
|
||||
top: Math.round(r.bottom + 6),
|
||||
left: Math.round(Math.min(r.left, window.innerWidth - width - 8)),
|
||||
});
|
||||
}
|
||||
|
||||
function toggleOpen(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!open) place();
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onDoc(e: MouseEvent) {
|
||||
if (
|
||||
!panelRef.current?.contains(e.target as Node) &&
|
||||
!triggerRef.current?.contains(e.target as Node)
|
||||
)
|
||||
setOpen(false);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
document.addEventListener("keydown", onKey);
|
||||
window.addEventListener("resize", place);
|
||||
window.addEventListener("scroll", place, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDoc);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("resize", place);
|
||||
window.removeEventListener("scroll", place, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function refresh() {
|
||||
qc.invalidateQueries({ queryKey: ["playlist-membership", videoId] });
|
||||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||
}
|
||||
|
||||
async function toggle(pl: Playlist) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
|
||||
else await api.addToPlaylist(pl.id, videoId);
|
||||
refresh();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createAndAdd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const name = newName.trim();
|
||||
if (!name || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const pl = await api.createPlaylist(name);
|
||||
await api.addToPlaylist(pl.id, videoId);
|
||||
setNewName("");
|
||||
refresh();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const lists = membership.data ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={toggleOpen}
|
||||
title={t("playlists.addToPlaylist")}
|
||||
aria-label={t("playlists.addToPlaylist")}
|
||||
className={className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"}
|
||||
>
|
||||
<ListPlus className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={{ position: "fixed", top: coords.top, left: coords.left, width: 240 }}
|
||||
className="z-50 glass rounded-xl p-2 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="text-xs text-muted px-1.5 pb-1.5">
|
||||
{t("playlists.addToPlaylist")}
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{lists.length === 0 && !membership.isLoading && (
|
||||
<div className="text-xs text-muted px-1.5 py-2">
|
||||
{t("playlists.noneYet")}
|
||||
</div>
|
||||
)}
|
||||
{lists.map((pl) => (
|
||||
<button
|
||||
key={pl.id}
|
||||
onClick={() => toggle(pl)}
|
||||
disabled={busy}
|
||||
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg hover:bg-card/60 transition disabled:opacity-50"
|
||||
>
|
||||
<span
|
||||
className={`grid place-items-center w-4 h-4 rounded border ${
|
||||
pl.has_video
|
||||
? "bg-accent border-accent text-accent-fg"
|
||||
: "border-border"
|
||||
}`}
|
||||
>
|
||||
{pl.has_video && <Check className="w-3 h-3" />}
|
||||
</span>
|
||||
<span className="truncate">{pl.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
onSubmit={createAndAdd}
|
||||
className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 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>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BarChart3, Home, Info, Library, LogOut, Search, Settings, Shield, Tv, User } from "lucide-react";
|
||||
import { BarChart3, Home, Info, Library, ListVideo, LogOut, Search, Settings, Shield, Tv, User } from "lucide-react";
|
||||
import type { FeedFilters, Me } from "../lib/api";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { type LangCode } from "../i18n";
|
||||
|
|
@ -194,6 +194,12 @@ function AccountMenu({
|
|||
{t("header.account.channels")}
|
||||
</button>
|
||||
)}
|
||||
{page !== "playlists" && (
|
||||
<button onClick={() => { setPage("playlists"); setOpen(false); }} className={itemClass}>
|
||||
<ListVideo className="w-4 h-4" />
|
||||
{t("header.account.playlists")}
|
||||
</button>
|
||||
)}
|
||||
{me.role === "admin" && page !== "stats" && (
|
||||
<button onClick={() => { setPage("stats"); setOpen(false); }} className={itemClass}>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
|
|||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Check, CheckCheck, X } from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import { api, type Video } from "../lib/api";
|
||||
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||
|
||||
|
|
@ -518,12 +519,18 @@ export default function PlayerModal({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!navigated && (
|
||||
<AddToPlaylist
|
||||
videoId={video.id}
|
||||
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
|
||||
/>
|
||||
)}
|
||||
{!navigated && (
|
||||
<button
|
||||
onClick={() => setWatched(!watched)}
|
||||
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||
className={
|
||||
"ml-auto shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
|
||||
"shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
|
||||
(watched
|
||||
? "bg-accent text-accent-fg shadow-sm hover:opacity-90"
|
||||
: "text-muted hover:text-fg hover:bg-surface border border-border")
|
||||
|
|
|
|||
349
frontend/src/components/Playlists.tsx
Normal file
349
frontend/src/components/Playlists.tsx
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
import { useEffect, 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,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, type Playlist, type Video } from "../lib/api";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import PlayerModal from "./PlayerModal";
|
||||
|
||||
function Row({
|
||||
video,
|
||||
index,
|
||||
onPlay,
|
||||
onRemove,
|
||||
}: {
|
||||
video: Video;
|
||||
index: number;
|
||||
onPlay: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id: video.id });
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<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() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [items, setItems] = useState<Video[]>([]);
|
||||
const [active, setActive] = useState<{ video: Video; startAt: number | null } | null>(null);
|
||||
|
||||
const listQuery = useQuery({ queryKey: ["playlists"], queryFn: () => api.playlists() });
|
||||
const playlists = listQuery.data ?? [];
|
||||
|
||||
// Default the selection to the first playlist once the list loads.
|
||||
useEffect(() => {
|
||||
if (selectedId == null && playlists.length) setSelectedId(playlists[0].id);
|
||||
}, [playlists, selectedId]);
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["playlist", selectedId],
|
||||
queryFn: () => api.playlist(selectedId as number),
|
||||
enabled: selectedId != null,
|
||||
});
|
||||
const detail = detailQuery.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (detail) setItems(detail.items);
|
||||
}, [detail]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
|
||||
);
|
||||
|
||||
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] });
|
||||
}
|
||||
|
||||
async 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;
|
||||
const next = arrayMove(items, oldIndex, newIndex);
|
||||
setItems(next);
|
||||
await api.reorderPlaylist(selectedId, next.map((v) => v.id));
|
||||
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
|
||||
}
|
||||
|
||||
async function removeItem(videoId: string) {
|
||||
if (selectedId == null) return;
|
||||
setItems((cur) => cur.filter((v) => v.id !== videoId));
|
||||
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;
|
||||
if (!window.confirm(t("playlists.confirmDelete", { name: detail.name }))) return;
|
||||
await api.deletePlaylist(selectedId);
|
||||
setSelectedId(null);
|
||||
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="text-sm font-semibold mb-2">{t("playlists.title")}</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="text-[13px] text-fg truncate">{pl.name}</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">
|
||||
{!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">{detail.name}</h2>
|
||||
<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[0] && setActive({ video: items[0], startAt: null })}
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
</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={() => setActive({ video: v, startAt: null })}
|
||||
onRemove={() => removeItem(v.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{active && (
|
||||
<PlayerModal
|
||||
video={active.video}
|
||||
startAt={active.startAt}
|
||||
onClose={() => setActive(null)}
|
||||
onState={playerState}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import clsx from "clsx";
|
||||
import type { Video } from "../lib/api";
|
||||
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||
|
|
@ -56,6 +57,7 @@ function Actions({
|
|||
>
|
||||
<Bookmark className="w-4 h-4" />
|
||||
</button>
|
||||
<AddToPlaylist videoId={video.id} />
|
||||
<button
|
||||
onClick={act("hidden")}
|
||||
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue