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:
parent
14b8eb6084
commit
d33a109ea2
22 changed files with 445 additions and 49 deletions
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue