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 && (