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

@ -9,10 +9,10 @@ import {
Eye,
EyeOff,
History,
Pencil,
Plus,
RefreshCw,
UserMinus,
X,
} from "lucide-react";
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
import { formatEta, formatViews, relativeTime } from "../lib/format";
@ -20,6 +20,7 @@ import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import Avatar from "./Avatar";
import DataTable, { type Column } from "./DataTable";
import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider";
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
@ -42,6 +43,9 @@ export default function Channels({
canWrite,
isAdmin,
onViewChannel,
onFilterByTag,
onFocusChannel,
focusChannelName,
statusFilter,
setStatusFilter,
onOpenWizard,
@ -49,6 +53,9 @@ export default function Channels({
canWrite: boolean;
isAdmin: boolean;
onViewChannel: (id: string, name: string) => void;
onFilterByTag: (tagId: number, name: string) => void;
onFocusChannel: (name: string) => void;
focusChannelName: string | null;
statusFilter: ChannelStatusFilter;
setStatusFilter: (f: ChannelStatusFilter) => void;
onOpenWizard: () => void;
@ -73,7 +80,7 @@ export default function Channels({
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const [newTag, setNewTag] = useState("");
const [tagManagerOpen, setTagManagerOpen] = useState(false);
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["channels"] });
@ -138,17 +145,6 @@ export default function Channels({
},
onError: (e) => notifyActionError(e, "channels.notify.syncFailed"),
});
const createTag = useMutation({
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
onSuccess: () => {
setNewTag("");
qc.invalidateQueries({ queryKey: ["tags"] });
},
});
const deleteTag = useMutation({
mutationFn: (id: number) => api.deleteTag(id),
onSuccess: () => invalidate(),
});
const unsubscribe = useMutation({
mutationFn: (id: string) => api.unsubscribeChannel(id),
onSuccess: () => {
@ -307,6 +303,7 @@ export default function Channels({
c={c}
userTags={userTags}
onToggleTag={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
onTagClick={onFilterByTag}
/>
),
},
@ -427,42 +424,32 @@ export default function Channels({
/>
</p>
{/* Your tags */}
{/* Your tags — read-only overview; add/rename/delete live in the manager dialog. */}
<div className="flex flex-wrap items-center gap-1.5 mb-4">
<Tooltip hint={t("channels.tags.yourTagsHint")}>
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
{t("channels.tags.yourTags")}
</span>
</Tooltip>
{userTags.map((t) => (
{userTags.map((tg) => (
<span
key={t.id}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-card border border-border"
key={tg.id}
className="text-xs px-2 py-1 rounded-full bg-card border border-border"
>
{t.name}
<button onClick={() => deleteTag.mutate(t.id)} className="text-muted hover:text-red-400">
<X className="w-3 h-3" />
</button>
{tg.name}
</span>
))}
<form
onSubmit={(e) => {
e.preventDefault();
if (newTag.trim()) createTag.mutate(newTag.trim());
}}
className="inline-flex items-center gap-1"
<button
onClick={() => setTagManagerOpen(true)}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
>
<input
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder={t("channels.tags.newTag")}
className="w-24 bg-card border border-border rounded-full px-2.5 py-1 text-xs outline-none focus:border-accent"
/>
<button type="submit" className="text-muted hover:text-accent" title={t("channels.tags.createTag")}>
<Plus className="w-4 h-4" />
</button>
</form>
<Pencil className="w-3 h-3" />
{t("channels.tags.manage")}
</button>
</div>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
{/* Channel table */}
{channelsQuery.isLoading ? (
@ -475,6 +462,7 @@ export default function Channels({
persistKey="siftlode.channelsTable"
controlsPosition="top"
controlsLeading={statusChips}
externalFilter={focusChannelName ? { key: "channel", value: focusChannelName } : null}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")}
/>
@ -605,10 +593,12 @@ function TagsCell({
c,
userTags,
onToggleTag,
onTagClick,
}: {
c: ManagedChannel;
userTags: Tag[];
onToggleTag: (tagId: number) => void;
onTagClick: (tagId: number, name: string) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@ -634,12 +624,14 @@ function TagsCell({
return (
<div ref={ref} className="relative flex flex-wrap items-center gap-1 min-w-[160px]">
{attached.map((tg) => (
<span
<button
key={tg.id}
className="text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent"
onClick={() => onTagClick(tg.id, tg.name)}
title={t("channels.row.filterFeedByTag", { name: tg.name })}
className="text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent hover:opacity-80 transition"
>
{tg.name}
</span>
</button>
))}
<button
onClick={() => setOpen((o) => !o)}

View file

@ -66,6 +66,7 @@ export default function DataTable<T>({
rowClassName,
controlsPosition = "bottom",
controlsLeading,
externalFilter,
}: {
rows: T[];
columns: Column<T>[];
@ -77,6 +78,9 @@ export default function DataTable<T>({
rowClassName?: (row: T) => string;
controlsPosition?: "top" | "bottom" | "both";
controlsLeading?: ReactNode;
// Set a column's filter from outside (e.g. "focus this channel" deep-links here). Applied
// whenever its value changes; the user can still clear it from the header afterwards.
externalFilter?: { key: string; value: string } | null;
}) {
const { t } = useTranslation();
const initial = loadPersist(persistKey);
@ -93,6 +97,13 @@ export default function DataTable<T>({
if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size }));
}, [persistKey, sort, filters, page, size]);
useEffect(() => {
if (!externalFilter) return;
setFilters((f) => ({ ...f, [externalFilter.key]: externalFilter.value }));
setPage(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalFilter?.key, externalFilter?.value]);
useEffect(() => {
if (!openFilter) return;
function onDown(e: MouseEvent) {

View file

@ -185,6 +185,25 @@ export default function Feed({
[qc]
);
const onResetState = useCallback(
(id: string) => {
// Reset to pristine (drop the whole VideoState row, incl. resume position) — the
// un-watch toggle can't clear an in-progress position, this can.
setOverrides((o) => ({ ...o, [id]: "new" }));
api
.clearState(id)
.then(() => qc.invalidateQueries({ queryKey: ["feed"] }))
.catch(() =>
setOverrides((o) => {
const next = { ...o };
delete next[id];
return next;
})
);
},
[qc]
);
const onChannelFilter = useCallback(
(channelId: string, channelName: string) => {
setFilters({ ...filters, channelId, channelName });
@ -363,6 +382,7 @@ export default function Feed({
onState={onState}
onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter}
onResetState={onResetState}
onOpen={openVideo}
/>
))}
@ -377,6 +397,7 @@ export default function Feed({
onState={onState}
onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter}
onResetState={onResetState}
onOpen={openVideo}
/>
))}

View file

@ -36,6 +36,7 @@ import {
} from "../lib/sidebarLayout";
import { shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications";
import TagManager from "./TagManager";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const SORT_IDS = [
@ -114,11 +115,13 @@ export default function Sidebar({
setFilters,
layout,
setLayout,
onFocusChannel,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
layout: SidebarLayout;
setLayout: (l: SidebarLayout) => void;
onFocusChannel: (name: string) => void;
}) {
const { t } = useTranslation();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
@ -172,7 +175,11 @@ export default function Sidebar({
: t("sidebar.thisChannel"));
const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic");
// The user's own (non-system) tags — those assigned to channels in the Channel manager.
// Facets only cover language/topic, so these show their static channel_count.
const userTags = tags.filter((t) => !t.system);
const [customDates, setCustomDates] = useState(false);
const [tagManagerOpen, setTagManagerOpen] = useState(false);
const [editing, setEditing] = useState(false);
const sensors = useSensors(
@ -217,6 +224,7 @@ export default function Sidebar({
date: true,
language: languages.length > 0,
topic: topics.length > 0,
tags: userTags.length > 0,
};
function toggleCollapse(id: WidgetId) {
@ -393,6 +401,28 @@ export default function Sidebar({
</>
);
}
case "tags":
return (
<div className="flex flex-wrap items-center gap-1.5">
{userTags.map((tg) => (
<TagChip
key={tg.id}
tag={tg}
count={tg.channel_count}
active={filters.tags.includes(tg.id)}
onClick={() => toggleTag(tg.id)}
/>
))}
<button
onClick={() => setTagManagerOpen(true)}
title={t("sidebar.manageTags")}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
>
<Pencil className="w-3 h-3" />
{t("sidebar.manageTags")}
</button>
</div>
);
}
}
@ -496,6 +526,9 @@ export default function Sidebar({
</div>
</SortableContext>
</DndContext>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
</aside>
);
}

View file

@ -0,0 +1,213 @@
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react";
import { api, type Tag } from "../lib/api";
import { notify } from "../lib/notifications";
import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider";
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
// hover popover on the count listing the tagged channels (each a link that focuses it in the
// Channel manager). The popover is portaled to <body> so the list's own scroll can't clip it.
function TagRow({
tag,
channels,
onRename,
onDelete,
onPickChannel,
}: {
tag: Tag;
channels: { id: string; title: string }[];
onRename: (name: string) => void;
onDelete: () => void;
onPickChannel: (name: string) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState(tag.name);
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ left: 0, top: 0 });
const countRef = useRef<HTMLSpanElement | null>(null);
const closeTimer = useRef<number | undefined>(undefined);
const commit = () => {
const v = name.trim();
if (v && v !== tag.name) onRename(v);
else setName(tag.name);
};
const openPop = () => {
window.clearTimeout(closeTimer.current);
if (channels.length === 0) return;
const r = countRef.current?.getBoundingClientRect();
if (r) setPos({ left: r.right + 8, top: r.top });
setOpen(true);
};
const closeSoon = () => {
closeTimer.current = window.setTimeout(() => setOpen(false), 150);
};
return (
<div className="flex items-center gap-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1.5 text-sm outline-none focus:border-accent"
/>
<span
ref={countRef}
onMouseEnter={openPop}
onMouseLeave={closeSoon}
className={`text-xs text-muted tabular-nums shrink-0 ${
channels.length
? "cursor-help underline decoration-dotted decoration-muted/40 underline-offset-2"
: ""
}`}
>
{t("tagManager.channels", { count: tag.channel_count })}
</span>
<button
onClick={onDelete}
title={t("tagManager.delete")}
aria-label={t("tagManager.delete")}
className="shrink-0 p-1.5 rounded-md text-muted hover:text-red-400 transition"
>
<Trash2 className="w-4 h-4" />
</button>
{open &&
createPortal(
<div
onMouseEnter={openPop}
onMouseLeave={closeSoon}
style={{ position: "fixed", left: pos.left, top: pos.top, zIndex: 60 }}
className="glass-menu w-56 max-h-72 overflow-y-auto rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1">
{t("tagManager.onChannels")}
</div>
{channels.map((ch) => (
<button
key={ch.id}
onClick={() => onPickChannel(ch.title)}
className="w-full text-left text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg hover:bg-card truncate transition"
>
{ch.title}
</button>
))}
</div>,
document.body
)}
</div>
);
}
export default function TagManager({
onClose,
onFocusChannel,
}: {
onClose: () => void;
onFocusChannel: (name: string) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const [newName, setNewName] = useState("");
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const channelsForTag = (tagId: number) =>
(channelsQuery.data ?? [])
.filter((c) => c.tag_ids.includes(tagId))
.map((c) => ({ id: c.id, title: c.title ?? c.id }));
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["tags"] });
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["feed"] });
};
const create = useMutation({
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
onSuccess: () => {
setNewName("");
invalidate();
},
});
const rename = useMutation({
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
onSuccess: invalidate,
});
const del = useMutation({
mutationFn: (id: number) => api.deleteTag(id),
onSuccess: () => {
invalidate();
notify({ level: "success", message: t("tagManager.deleted") });
},
});
// Picking a channel from a tag's popover: close the dialog, then focus it in the manager.
const pickChannel = (name: string) => {
onClose();
onFocusChannel(name);
};
return (
<Modal title={t("tagManager.title")} onClose={onClose}>
<div className="flex flex-col gap-2">
{userTags.length === 0 ? (
<p className="text-sm text-muted">{t("tagManager.empty")}</p>
) : (
// Cap at ~8 rows tall, then scroll — the list can grow long.
<div className="flex flex-col gap-2 max-h-[22rem] overflow-y-auto pr-1">
{userTags.map((tag) => (
<TagRow
key={tag.id}
tag={tag}
channels={channelsForTag(tag.id)}
onPickChannel={pickChannel}
onRename={(name) => rename.mutate({ id: tag.id, name })}
onDelete={async () => {
// Only confirm when the tag is actually in use; an unused tag deletes outright.
if (tag.channel_count > 0) {
const ok = await confirm({
title: t("tagManager.deleteTitle"),
message: t("tagManager.confirmDelete", {
name: tag.name,
count: tag.channel_count,
}),
confirmLabel: t("tagManager.delete"),
danger: true,
});
if (!ok) return;
}
del.mutate(tag.id);
}}
/>
))}
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newName.trim()) create.mutate(newName.trim());
}}
className="flex items-center gap-2 mt-2 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("tagManager.newPlaceholder")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1.5 text-sm outline-none focus:border-accent"
/>
<button
type="submit"
disabled={!newName.trim()}
className="shrink-0 px-3 py-1.5 rounded-md border border-border text-sm text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-40 transition"
>
{t("tagManager.add")}
</button>
</form>
</div>
</Modal>
);
}

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>