From c000ea8bce0ff519d109c463210baa89fdccbf81 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 17 Jun 2026 23:24:25 +0200 Subject: [PATCH 1/5] feat(ui): content-driven column widths in DataTable Columns auto-size to their content (table-layout: auto) via a per-column 'nowrap' flag; headers never wrap. Replaces hand-tuned fixed widths so values like 'N / S / L' no longer wrap, and callers give a min-width only where a column must not collapse. --- frontend/src/components/DataTable.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index ef1d5a8..1127b01 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -22,6 +22,8 @@ export interface Column { sortable?: boolean; sortValue?: (row: T) => string | number; filter?: ColumnFilter; + // Keep the cell on one line so the column auto-sizes to its content (table-layout: auto). + nowrap?: boolean; // Card fallback (below md): `cardPrimary` renders as the card heading (no label); `hideInCard` // omits the column; `cardLabel:false` shows the value without its header label. cardPrimary?: boolean; @@ -325,7 +327,7 @@ export default function DataTable({ - ); - })} +
+ {attached.map((tg) => ( + + {tg.name} + + ))} + + {open && ( +
+ {userTags.map((tg) => { + const on = c.tag_ids.includes(tg.id); + return ( + + ); + })} +
+ )}
); } diff --git a/frontend/src/i18n/locales/de/channels.json b/frontend/src/i18n/locales/de/channels.json index 94bfd9a..fca4e28 100644 --- a/frontend/src/i18n/locales/de/channels.json +++ b/frontend/src/i18n/locales/de/channels.json @@ -29,6 +29,10 @@ "channel": "Kanal", "stored": "Gespeichert", "subs": "Abonnenten", + "lastUpload": "Letzter Upload", + "length": "Länge", + "types": "N/S/L", + "typesHint": "Videos nach Typ: Normale / Shorts / Live", "sync": "Sync", "tags": "Tags", "actions": "Aktionen" @@ -51,6 +55,7 @@ "stored": "{{formatted}} gespeichert", "subs": "{{formatted}} Abonnenten", "openOnYouTube": "Auf YouTube öffnen", + "editTags": "Tags bearbeiten", "priorityHint": "Deine Einstufung für diesen Kanal. Sortiere den Feed nach „Kanalpriorität“, um Kanäle mit höherer Priorität nach oben zu bringen.", "raisePriority": "Priorität erhöhen", "lowerPriority": "Priorität senken", diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index f5adebd..182599b 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -29,6 +29,10 @@ "channel": "Channel", "stored": "Stored", "subs": "Subscribers", + "lastUpload": "Last upload", + "length": "Length", + "types": "N/S/L", + "typesHint": "Videos by type: Normal / Shorts / Live", "sync": "Sync", "tags": "Tags", "actions": "Actions" @@ -51,6 +55,7 @@ "stored": "{{formatted}} stored", "subs": "{{formatted}} subs", "openOnYouTube": "Open on YouTube", + "editTags": "Edit tags", "priorityHint": "Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.", "raisePriority": "Raise priority", "lowerPriority": "Lower priority", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 802bb13..b40c0b8 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -29,6 +29,10 @@ "channel": "Csatorna", "stored": "Tárolt", "subs": "Feliratkozók", + "lastUpload": "Utolsó feltöltés", + "length": "Hossz", + "types": "N/S/L", + "typesHint": "Videók típusonként: Normál / Short / Live", "sync": "Szinkron", "tags": "Címkék", "actions": "Műveletek" @@ -51,6 +55,7 @@ "stored": "{{formatted}} tárolt", "subs": "{{formatted}} feliratkozó", "openOnYouTube": "Megnyitás a YouTube-on", + "editTags": "Címkék szerkesztése", "priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a hírfolyamot „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.", "raisePriority": "Prioritás növelése", "lowerPriority": "Prioritás csökkentése", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e13f097..aa17661 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -293,6 +293,11 @@ export interface ManagedChannel { subscriber_count: number | null; video_count: number | null; stored_videos: number; + last_video_at: string | null; + total_duration_seconds: number; + count_normal: number; + count_short: number; + count_live: number; priority: number; hidden: boolean; deep_requested: boolean; From 14b8eb60843e51f0a083010fadce07958b516f6d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 18 Jun 2026 01:17:31 +0200 Subject: [PATCH 3/5] fix(ux): modal error dialog for server-refused actions + ESC closes only the topmost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Duplicate-tag create/rename hit the uq_tags_user_name constraint and 500'd; now caught and returned as a clear 409 ('You already have a tag named …'). - New global error dialog (errorDialog store + ErrorDialog modal) for definitive server refusals; the api layer wiring + mount land with the rest of the round. - Modal now keeps a stack so ESC dismisses only the top modal — an error over the tag editor closes the error and leaves the editor open. --- backend/app/routes/tags.py | 13 ++++++-- frontend/src/components/ErrorDialog.tsx | 24 +++++++++++++++ frontend/src/components/Modal.tsx | 20 +++++++++++-- frontend/src/i18n/locales/de/errors.json | 4 +++ frontend/src/i18n/locales/en/errors.json | 4 +++ frontend/src/i18n/locales/hu/errors.json | 4 +++ frontend/src/lib/errorDialog.ts | 38 ++++++++++++++++++++++++ 7 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/ErrorDialog.tsx create mode 100644 frontend/src/lib/errorDialog.ts diff --git a/backend/app/routes/tags.py b/backend/app/routes/tags.py index 1eb8546..96aac97 100644 --- a/backend/app/routes/tags.py +++ b/backend/app/routes/tags.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func, or_, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.auth import current_user @@ -49,7 +50,11 @@ def create_tag( category=category if category in ("language", "topic", "other") else "other", ) db.add(tag) - db.commit() + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail=f"You already have a tag named “{name}”.") return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category} @@ -76,7 +81,11 @@ def update_tag( tag.name = name if "color" in payload: tag.color = payload.get("color") - db.commit() + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail=f"You already have a tag named “{tag.name}”.") return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category} diff --git a/frontend/src/components/ErrorDialog.tsx b/frontend/src/components/ErrorDialog.tsx new file mode 100644 index 0000000..c212cfa --- /dev/null +++ b/frontend/src/components/ErrorDialog.tsx @@ -0,0 +1,24 @@ +import { useSyncExternalStore } from "react"; +import { useTranslation } from "react-i18next"; +import { dismissError, getError, subscribeError } from "../lib/errorDialog"; +import Modal from "./Modal"; + +// Renders the current app error (if any) as a modal the user must acknowledge. +export default function ErrorDialog() { + const { t } = useTranslation(); + const err = useSyncExternalStore(subscribeError, getError, getError); + if (!err) return null; + return ( + +

{err.message}

+
+ +
+
+ ); +} diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index ba4fff7..eaa6e76 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -1,7 +1,12 @@ -import { useEffect, type ReactNode } from "react"; +import { useEffect, useRef, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { X } from "lucide-react"; +// Stack of open modals so ESC only closes the topmost one — e.g. an error dialog over the +// tag editor: pressing ESC dismisses just the error and returns to the editor underneath. +let modalStack: number[] = []; +let nextModalId = 1; + // Small centered modal shell (portaled to ): backdrop + ESC + scroll-lock close. export default function Modal({ title, @@ -14,18 +19,27 @@ export default function Modal({ children: ReactNode; maxWidth?: string; }) { + // Keep a stable handler across renders so the stack id is assigned once per mount. + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; useEffect(() => { + const id = nextModalId++; + modalStack.push(id); const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape" && modalStack[modalStack.length - 1] === id) { + e.stopPropagation(); + onCloseRef.current(); + } }; window.addEventListener("keydown", onKey); const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { window.removeEventListener("keydown", onKey); + modalStack = modalStack.filter((x) => x !== id); document.body.style.overflow = prev; }; - }, [onClose]); + }, []); return createPortal(
mounted at the app root renders the current one. +export interface AppError { + title: string; + message: string; +} + +let current: AppError | null = null; +let listeners: Array<() => void> = []; +const emit = () => listeners.forEach((l) => l()); + +export function reportError(message?: string, title?: string): void { + current = { + title: title || i18n.t("errors.title"), + message: message || i18n.t("errors.generic"), + }; + emit(); +} + +export function dismissError(): void { + current = null; + emit(); +} + +export function subscribeError(listener: () => void): () => void { + listeners.push(listener); + return () => { + listeners = listeners.filter((l) => l !== listener); + }; +} + +export function getError(): AppError | null { + return current; +} From d33a109ea25567149b29a1df0f96472b3153d97a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 18 Jun 2026 01:17:31 +0200 Subject: [PATCH 4/5] 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. --- backend/app/routes/feed.py | 23 ++ frontend/src/App.tsx | 20 ++ frontend/src/components/Channels.tsx | 72 +++---- frontend/src/components/DataTable.tsx | 11 + frontend/src/components/Feed.tsx | 21 ++ frontend/src/components/Sidebar.tsx | 33 +++ frontend/src/components/TagManager.tsx | 213 +++++++++++++++++++ frontend/src/components/VideoCard.tsx | 24 ++- frontend/src/i18n/locales/de/card.json | 1 + frontend/src/i18n/locales/de/channels.json | 3 + frontend/src/i18n/locales/de/sidebar.json | 4 +- frontend/src/i18n/locales/de/tagManager.json | 12 ++ frontend/src/i18n/locales/en/card.json | 1 + frontend/src/i18n/locales/en/channels.json | 3 + frontend/src/i18n/locales/en/sidebar.json | 4 +- frontend/src/i18n/locales/en/tagManager.json | 12 ++ frontend/src/i18n/locales/hu/card.json | 1 + frontend/src/i18n/locales/hu/channels.json | 3 + frontend/src/i18n/locales/hu/sidebar.json | 4 +- frontend/src/i18n/locales/hu/tagManager.json | 12 ++ frontend/src/lib/api.ts | 12 +- frontend/src/lib/sidebarLayout.ts | 5 +- 22 files changed, 445 insertions(+), 49 deletions(-) create mode 100644 frontend/src/components/TagManager.tsx create mode 100644 frontend/src/i18n/locales/de/tagManager.json create mode 100644 frontend/src/i18n/locales/en/tagManager.json create mode 100644 frontend/src/i18n/locales/hu/tagManager.json diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 8db08b6..04ef3be 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -435,6 +435,29 @@ def set_video_state( return {"video_id": video_id, "status": status} +@router.delete("/videos/{video_id}/state") +def clear_video_state( + video_id: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Reset a video to pristine for this user — drop the whole VideoState row (status, + watch position and watched_at), as if it had never been opened. Saved/playlist membership + lives elsewhere and is untouched.""" + row = db.execute( + select(VideoState).where( + VideoState.user_id == user.id, VideoState.video_id == video_id + ) + ).scalar_one_or_none() + if row is not None: + db.delete(row) + try: + db.commit() + except StaleDataError: + db.rollback() + return {"video_id": video_id, "status": "new", "position_seconds": 0} + + @router.post("/videos/{video_id}/progress") def set_video_progress( video_id: str, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f3feb95..4bf2686 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,7 @@ import SettingsPanel from "./components/SettingsPanel"; import OnboardingWizard from "./components/OnboardingWizard"; import { shouldAutoOpenOnboarding } from "./lib/onboarding"; import Toaster from "./components/Toaster"; +import ErrorDialog from "./components/ErrorDialog"; import About from "./components/About"; import ReleaseNotes from "./components/ReleaseNotes"; import VersionBanner from "./components/VersionBanner"; @@ -108,6 +109,16 @@ export default function App() { const [aboutOpen, setAboutOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false); const [notesHighlight, setNotesHighlight] = useState(undefined); + // "Focus this channel in the manager": jump to the Channels page and seed its name filter + // so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later. + const [focusChannelName, setFocusChannelName] = useState(null); + const focusChannel = (name: string) => { + setFocusChannelName(name); + setPage("channels"); + }; + useEffect(() => { + if (page !== "channels") setFocusChannelName(null); + }, [page]); function openReleaseNotes(highlight?: string) { setNotesHighlight(highlight); @@ -271,6 +282,7 @@ export default function App() { setFilters={setFilters} layout={sidebarLayout} setLayout={setSidebarLayout} + onFocusChannel={focusChannel} /> )}
@@ -278,6 +290,8 @@ export default function App() { setWizardOpen(true)} @@ -285,6 +299,11 @@ export default function App() { setFilters({ ...filters, channelId: id, channelName: name, show: "all" }); setPage("feed"); }} + onFilterByTag={(tagId, name) => { + setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined }); + setPage("feed"); + notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) }); + }} /> ) : page === "stats" && meQuery.data!.role === "admin" ? ( @@ -333,6 +352,7 @@ export default function App() { {notesOpen && ( setNotesOpen(false)} highlight={notesHighlight} /> )} +
); } diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 5a4fb45..ba06732 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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({ />

- {/* Your tags */} + {/* Your tags — read-only overview; add/rename/delete live in the manager dialog. */}
{t("channels.tags.yourTags")} - {userTags.map((t) => ( + {userTags.map((tg) => ( - {t.name} - + {tg.name} ))} -
{ - e.preventDefault(); - if (newTag.trim()) createTag.mutate(newTag.trim()); - }} - className="inline-flex items-center gap-1" + -
+ + {t("channels.tags.manage")} +
+ {tagManagerOpen && ( + 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 (
{attached.map((tg) => ( - 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} - + ))} +
+ ); } } @@ -496,6 +526,9 @@ export default function Sidebar({ + {tagManagerOpen && ( + setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> + )} ); } diff --git a/frontend/src/components/TagManager.tsx b/frontend/src/components/TagManager.tsx new file mode 100644 index 0000000..5999728 --- /dev/null +++ b/frontend/src/components/TagManager.tsx @@ -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 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(null); + const closeTimer = useRef(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 ( +
+ 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" + /> + + {t("tagManager.channels", { count: tag.channel_count })} + + + {open && + createPortal( +
+
+ {t("tagManager.onChannels")} +
+ {channels.map((ch) => ( + + ))} +
, + document.body + )} +
+ ); +} + +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 ( + +
+ {userTags.length === 0 ? ( +

{t("tagManager.empty")}

+ ) : ( + // Cap at ~8 rows tall, then scroll — the list can grow long. +
+ {userTags.map((tag) => ( + 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); + }} + /> + ))} +
+ )} +
{ + e.preventDefault(); + if (newName.trim()) create.mutate(newName.trim()); + }} + className="flex items-center gap-2 mt-2 pt-3 border-t border-border" + > + + 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" + /> + + +
+
+ ); +} diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 8f5d317..5087913 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -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({ )} + {onResetState && resettable && ( + + )} {onChannelFilter && (