feat(playlists): unify Saved into a built-in Watch later playlist

The old per-video status='saved' becomes membership in a built-in, undeletable
'watch_later' playlist. Migration 0012 moves existing saved videos into each user's
Watch later list and demotes the states to 'new'. The feed/playlist serializers now
expose a 'saved' boolean (EXISTS in watch_later); the card bookmark toggles watch_later
via new /api/playlists/watch-later add/remove endpoints (create-on-demand) instead of
setting a status. Removed the 'saved' show-filter and status. Watch later shows a
localized name and hides rename/delete in the Playlists page and add-to-playlist popover.
This commit is contained in:
npeter83 2026-06-15 15:33:53 +02:00
parent 86844b0bdd
commit 2f66196816
12 changed files with 260 additions and 36 deletions

View file

@ -19,10 +19,12 @@ import { formatDuration, formatViews, relativeTime } from "../lib/format";
function Actions({
video,
onState,
onToggleSave,
onChannelFilter,
}: {
video: Video;
onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
}) {
const { t } = useTranslation();
@ -31,6 +33,11 @@ function Actions({
e.stopPropagation();
onState(video.id, video.status === status ? "new" : status);
};
const toggleSave = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onToggleSave(video.id, !video.saved);
};
return (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button
@ -48,11 +55,11 @@ function Actions({
)}
</button>
<button
onClick={act("saved")}
title={video.status === "saved" ? t("card.savedRemove") : t("card.saveForLater")}
onClick={toggleSave}
title={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "saved" && "fill-current text-accent"
video.saved && "fill-current text-accent"
)}
>
<Bookmark className="w-4 h-4" />
@ -157,7 +164,7 @@ function Thumb({
{t("card.stream")}
</span>
)}
{video.status === "saved" && (
{video.saved && (
<span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
<Bookmark className="w-3.5 h-3.5" />
</span>
@ -209,12 +216,14 @@ function VideoCard({
video,
view,
onState,
onToggleSave,
onChannelFilter,
onOpen,
}: {
video: Video;
view: "grid" | "list";
onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
onOpen?: (v: Video, startAt?: number | null) => void;
}) {
@ -264,7 +273,7 @@ function VideoCard({
</a>
<div className="text-xs text-muted mt-0.5">{meta}</div>
</div>
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
<Actions video={video} onState={onState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
</div>
);
}
@ -295,7 +304,7 @@ function VideoCard({
{video.channel_title}
</a>
<div className="text-xs text-muted mt-0.5">{meta}</div>
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
<Actions video={video} onState={onState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
</div>
</div>
</div>