diff --git a/VERSION b/VERSION index faef31a..a3df0a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.0 +0.8.0 diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 04ad3f7..309ef24 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -27,16 +27,33 @@ def list_channels( .order_by(Subscription.priority.desc(), func.lower(Channel.title)) ).all() - # Per-channel stored video count (shared data) in one grouped query. + # Per-channel aggregates over the shared catalog in ONE grouped pass: stored count, last + # upload, total duration, and a Normal/Shorts/Live breakdown. Cheap — a single grouped scan + # (~35 ms over the full 233k-row catalog on the dev DB), so computed on read rather than + # denormalised (measure-first: reads aren't frequent/hot enough to warrant cached columns). channel_ids = [c.id for _, c in rows] - stored: dict[str, int] = {} + agg: dict[str, dict] = {} if channel_ids: - for cid, count in db.execute( - select(Video.channel_id, func.count(Video.id)) + for cid, n, last_up, total_dur, shorts, live in db.execute( + select( + Video.channel_id, + func.count(Video.id), + func.max(Video.published_at), + func.coalesce(func.sum(Video.duration_seconds), 0), + func.count(Video.id).filter(Video.is_short.is_(True)), + func.count(Video.id).filter(Video.live_status.in_(("live", "upcoming"))), + ) .where(Video.channel_id.in_(channel_ids)) .group_by(Video.channel_id) ).all(): - stored[cid] = count + agg[cid] = { + "stored": n, + "last_video_at": last_up.isoformat() if last_up else None, + "total_duration_seconds": int(total_dur or 0), + "count_short": int(shorts), + "count_live": int(live), + "count_normal": max(0, int(n) - int(shorts) - int(live)), + } # Channels already in the *global* deep-backfill queue — i.e. at least one user (any # user) has requested full history and it isn't done yet. Their whole back-catalogue is @@ -71,7 +88,12 @@ def list_channels( "thumbnail_url": ch.thumbnail_url, "subscriber_count": ch.subscriber_count, "video_count": ch.video_count, - "stored_videos": stored.get(ch.id, 0), + "stored_videos": (agg.get(ch.id) or {}).get("stored", 0), + "last_video_at": (agg.get(ch.id) or {}).get("last_video_at"), + "total_duration_seconds": (agg.get(ch.id) or {}).get("total_duration_seconds", 0), + "count_normal": (agg.get(ch.id) or {}).get("count_normal", 0), + "count_short": (agg.get(ch.id) or {}).get("count_short", 0), + "count_live": (agg.get(ch.id) or {}).get("count_live", 0), "priority": sub.priority, "hidden": sub.hidden, "deep_requested": sub.deep_requested, 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/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/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 e9eb9a0..ba06732 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -9,21 +9,29 @@ import { Eye, EyeOff, History, + Pencil, Plus, RefreshCw, UserMinus, - X, } from "lucide-react"; import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; -import { formatEta } from "../lib/format"; +import { formatEta, formatViews, relativeTime } from "../lib/format"; 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"; +// Compact total-duration label for a whole channel (hours-scale, so H:MM:SS would be huge). +function fmtTotalDuration(sec: number): string { + if (!sec) return "—"; + const h = Math.round(sec / 3600); + return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`; +} + const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [ { id: "all", labelKey: "channels.filters.all" }, { id: "needs_full", labelKey: "channels.filters.needsFull" }, @@ -35,6 +43,9 @@ export default function Channels({ canWrite, isAdmin, onViewChannel, + onFilterByTag, + onFocusChannel, + focusChannelName, statusFilter, setStatusFilter, onOpenWizard, @@ -42,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; @@ -66,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"] }); @@ -131,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: () => { @@ -231,7 +234,7 @@ export default function Channels({ key: "stored", header: t("channels.cols.stored"), align: "right", - width: "84px", + nowrap: true, sortable: true, sortValue: (c) => c.stored_videos, render: (c) => {c.stored_videos.toLocaleString()}, @@ -240,26 +243,54 @@ export default function Channels({ key: "subs", header: t("channels.cols.subs"), align: "right", - width: "92px", + nowrap: true, sortable: true, sortValue: (c) => c.subscriber_count ?? -1, render: (c) => ( - {c.subscriber_count != null ? c.subscriber_count.toLocaleString() : "—"} + {c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"} ), }, + { + key: "lastUpload", + header: t("channels.cols.lastUpload"), + nowrap: true, + sortable: true, + sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0), + render: (c) => {relativeTime(c.last_video_at) || "—"}, + }, + { + key: "length", + header: t("channels.cols.length"), + align: "right", + nowrap: true, + sortable: true, + sortValue: (c) => c.total_duration_seconds, + render: (c) => {fmtTotalDuration(c.total_duration_seconds)}, + }, + { + key: "types", + header: t("channels.cols.types"), + nowrap: true, + render: (c) => ( + + + {c.count_normal} / {c.count_short} / {c.count_live} + + + ), + }, { key: "sync", header: t("channels.cols.sync"), - width: "130px", + nowrap: true, cardLabel: false, render: (c) => , }, { key: "tags", header: t("channels.cols.tags"), - width: "360px", cardLabel: false, filter: { kind: "multi", @@ -272,6 +303,7 @@ export default function Channels({ c={c} userTags={userTags} onToggleTag={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))} + onTagClick={onFilterByTag} /> ), }, @@ -279,6 +311,7 @@ export default function Channels({ key: "actions", header: t("channels.cols.actions"), align: "right", + nowrap: true, width: "104px", cardLabel: false, render: (c) => ( @@ -391,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 ? ( @@ -439,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")} /> @@ -569,30 +593,79 @@ 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); + const ref = useRef(null); + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); if (userTags.length === 0) return null; + // Show only the tags actually attached to this channel; the “+” opens a per-channel + // picker to attach/detach (kept the convenient "kirakás" without listing every tag + // on every row). Tag create/delete still lives in the top "YOUR TAGS" row. + const attached = userTags.filter((tg) => c.tag_ids.includes(tg.id)); return ( -
- {userTags.map((tg) => { - const on = c.tag_ids.includes(tg.id); - return ( - - ); - })} +
+ {attached.map((tg) => ( + + ))} + + {open && ( +
+ {userTags.map((tg) => { + const on = c.tag_ids.includes(tg.id); + return ( + + ); + })} +
+ )}
); } diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index ef1d5a8..f2e729d 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; @@ -64,6 +66,7 @@ export default function DataTable({ rowClassName, controlsPosition = "bottom", controlsLeading, + externalFilter, }: { rows: T[]; columns: Column[]; @@ -75,6 +78,9 @@ export default function DataTable({ 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); @@ -91,6 +97,13 @@ export default function DataTable({ 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) { @@ -325,7 +338,7 @@ export default function DataTable({ +
+ + ); +} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 6b0e0f5..7b245ef 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -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} /> ))} 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(
") 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 ( +
+ {userTags.map((tg) => ( + toggleTag(tg.id)} + /> + ))} + +
+ ); } } @@ -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 && (