feat(tags): tag UX overhaul + per-card video reset

- Channel-row tags show only what's attached; a '+' opens a per-channel picker. Clicking a tag
  filters the feed by it; a 'Your tags' sidebar widget makes that filter visible/clearable.
- Tag manager dialog (add/rename/delete; delete only confirms when the tag is in use), reachable
  from the Channel manager and the feed sidebar; hovering a tag's count lists its channels, each a
  link that focuses it in the Channel manager (DataTable gains an external filter input).
- Video cards get a reset action that clears all watch state (incl. an in-progress position).
- api.req() now raises the error dialog on 5xx and 400/409/422 with the server's reason.
This commit is contained in:
npeter83 2026-06-18 01:17:31 +02:00
parent 14b8eb6084
commit d33a109ea2
22 changed files with 445 additions and 49 deletions

View file

@ -19,11 +19,13 @@ import { formatDuration, formatViews, relativeTime } from "../lib/format";
function Actions({
video,
onState,
onResetState,
onToggleSave,
onChannelFilter,
}: {
video: Video;
onState: (id: string, status: string) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
}) {
@ -33,6 +35,9 @@ function Actions({
e.stopPropagation();
onState(video.id, video.status === status ? "new" : status);
};
// Pristine = never opened: default status and no resume position. The reset clears the
// whole state (incl. an in-progress position the un-watch toggle can't touch).
const resettable = video.status !== "new" || video.position_seconds > 0;
const toggleSave = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@ -79,6 +84,19 @@ function Actions({
<EyeOff className="w-4 h-4" />
)}
</button>
{onResetState && resettable && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onResetState(video.id);
}}
title={t("card.resetState")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
>
<RotateCcw className="w-4 h-4" />
</button>
)}
{onChannelFilter && (
<button
onClick={(e) => {
@ -224,6 +242,7 @@ function VideoCard({
video,
view,
onState,
onResetState,
onToggleSave,
onChannelFilter,
onOpen,
@ -231,6 +250,7 @@ function VideoCard({
video: Video;
view: "grid" | "list";
onState: (id: string, status: string) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
onOpen?: (v: Video, startAt?: number | null) => void;
@ -294,7 +314,7 @@ function VideoCard({
</a>
<div className="text-xs text-muted mt-0.5">{meta}</div>
</div>
<Actions video={video} onState={onState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
</div>
);
}
@ -325,7 +345,7 @@ function VideoCard({
{video.channel_title}
</a>
<div className="text-xs text-muted mt-0.5">{meta}</div>
<Actions video={video} onState={onState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
</div>
</div>
</div>