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 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}
))}
-
+
+ {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}
-
+
))}
+ {onResetState && resettable && (
+
{
+ 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"
+ >
+
+
+ )}
{onChannelFilter && (
{
@@ -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({
{meta}
-
+
);
}
@@ -325,7 +345,7 @@ function VideoCard({
{video.channel_title}
{meta}
-
+
diff --git a/frontend/src/i18n/locales/de/card.json b/frontend/src/i18n/locales/de/card.json
index 93076be..ff3e4e7 100644
--- a/frontend/src/i18n/locales/de/card.json
+++ b/frontend/src/i18n/locales/de/card.json
@@ -8,6 +8,7 @@
"unhide": "Einblenden",
"hide": "Ausblenden",
"onlyThisChannel": "Nur dieser Kanal",
+ "resetState": "Zurücksetzen — Fortschritt/Status löschen",
"thisChannel": "Dieser Kanal",
"continueTitle": "Dort fortsetzen, wo du aufgehört hast",
"continue": "Fortsetzen",
diff --git a/frontend/src/i18n/locales/de/channels.json b/frontend/src/i18n/locales/de/channels.json
index fca4e28..b559115 100644
--- a/frontend/src/i18n/locales/de/channels.json
+++ b/frontend/src/i18n/locales/de/channels.json
@@ -13,6 +13,7 @@
},
"tags": {
"yourTags": "Deine Tags",
+ "manage": "Tags verwalten",
"yourTagsHint": "Deine persönlichen Labels. Hänge sie unten an Kanäle an und filtere den Feed dann über die Seitenleiste nach Tag. (Getrennt von den automatischen Sprach-/Themen-Tags.)",
"newTag": "neuer Tag",
"createTag": "Tag erstellen"
@@ -56,6 +57,7 @@
"subs": "{{formatted}} Abonnenten",
"openOnYouTube": "Auf YouTube öffnen",
"editTags": "Tags bearbeiten",
+ "filterFeedByTag": "Feed nach „{{name}}“ filtern",
"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",
@@ -94,6 +96,7 @@
"fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden",
"needYouTube": "Verbinde dein YouTube-Konto, um dies zu tun.",
"connect": "Verbinden",
+ "filteredByTag": "Feed nach Tag gefiltert: {{name}}",
"resetDone": "Kanal zurückgesetzt — wird neu geladen",
"resetFailed": "Kanal konnte nicht zurückgesetzt werden"
},
diff --git a/frontend/src/i18n/locales/de/errors.json b/frontend/src/i18n/locales/de/errors.json
index 2a78d52..799a006 100644
--- a/frontend/src/i18n/locales/de/errors.json
+++ b/frontend/src/i18n/locales/de/errors.json
@@ -1,4 +1,8 @@
{
+ "title": "Nicht möglich",
+ "generic": "Der Server konnte die Aktion nicht ausführen. Bitte erneut versuchen.",
+ "server": "Serverfehler",
+ "ok": "OK",
"boundary": {
"title": "Etwas ist schiefgelaufen.",
"subtitle": "Die App ist auf einen unerwarteten Fehler gestoßen.",
diff --git a/frontend/src/i18n/locales/de/sidebar.json b/frontend/src/i18n/locales/de/sidebar.json
index 2668e7b..2bb2de5 100644
--- a/frontend/src/i18n/locales/de/sidebar.json
+++ b/frontend/src/i18n/locales/de/sidebar.json
@@ -26,6 +26,7 @@
"clearDates": "Daten löschen",
"reshuffle": "Neu mischen",
"noMatchingTags": "Keine passenden Tags",
+ "manageTags": "Verwalten",
"shareView": "Link zu dieser Ansicht kopieren",
"shareCopied": "Ansichts-Link in die Zwischenablage kopiert",
"shareFailed": "Link konnte nicht kopiert werden",
@@ -35,7 +36,8 @@
"date": "Upload-Datum",
"content": "Inhaltstyp",
"language": "Sprache",
- "topic": "Thema"
+ "topic": "Thema",
+ "tags": "Deine Tags"
},
"show": {
"unwatched": "Ungesehen",
diff --git a/frontend/src/i18n/locales/de/tagManager.json b/frontend/src/i18n/locales/de/tagManager.json
new file mode 100644
index 0000000..a5a86d3
--- /dev/null
+++ b/frontend/src/i18n/locales/de/tagManager.json
@@ -0,0 +1,12 @@
+{
+ "title": "Tags verwalten",
+ "channels": "{{count}} Kan.",
+ "onChannels": "Getaggte Kanäle",
+ "delete": "Löschen",
+ "deleted": "Tag gelöscht",
+ "deleteTitle": "Tag löschen",
+ "confirmDelete": "Tag „{{name}}“ löschen? Er wird von allen Kanälen entfernt, auf denen er liegt.",
+ "empty": "Noch keine Tags — füge unten einen hinzu.",
+ "newPlaceholder": "Neuer Tag-Name…",
+ "add": "Hinzufügen"
+}
diff --git a/frontend/src/i18n/locales/en/card.json b/frontend/src/i18n/locales/en/card.json
index c5fac09..2f43a26 100644
--- a/frontend/src/i18n/locales/en/card.json
+++ b/frontend/src/i18n/locales/en/card.json
@@ -8,6 +8,7 @@
"unhide": "Unhide",
"hide": "Hide",
"onlyThisChannel": "Only this channel",
+ "resetState": "Reset — clear watch progress/status",
"thisChannel": "This channel",
"continueTitle": "Continue where you left off",
"continue": "Continue",
diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json
index 182599b..80d1b11 100644
--- a/frontend/src/i18n/locales/en/channels.json
+++ b/frontend/src/i18n/locales/en/channels.json
@@ -13,6 +13,7 @@
},
"tags": {
"yourTags": "Your tags",
+ "manage": "Manage tags",
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)",
"newTag": "new tag",
"createTag": "Create tag"
@@ -56,6 +57,7 @@
"subs": "{{formatted}} subs",
"openOnYouTube": "Open on YouTube",
"editTags": "Edit tags",
+ "filterFeedByTag": "Filter the feed by “{{name}}”",
"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",
@@ -94,6 +96,7 @@
"fullHistoryFailed": "Couldn't request full history",
"needYouTube": "Connect your YouTube account to do this.",
"connect": "Connect",
+ "filteredByTag": "Feed filtered by tag: {{name}}",
"resetDone": "Channel reset — re-fetching now",
"resetFailed": "Couldn't reset this channel"
},
diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json
index 797c0c2..f22b32a 100644
--- a/frontend/src/i18n/locales/en/errors.json
+++ b/frontend/src/i18n/locales/en/errors.json
@@ -1,4 +1,8 @@
{
+ "title": "Couldn't complete that",
+ "generic": "The server couldn't carry out that action. Please try again.",
+ "server": "Server error",
+ "ok": "OK",
"boundary": {
"title": "Something went wrong.",
"subtitle": "The app hit an unexpected error.",
diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json
index 984af9c..3e36a6d 100644
--- a/frontend/src/i18n/locales/en/sidebar.json
+++ b/frontend/src/i18n/locales/en/sidebar.json
@@ -26,6 +26,7 @@
"clearDates": "clear dates",
"reshuffle": "Reshuffle",
"noMatchingTags": "No matching tags here",
+ "manageTags": "Manage",
"shareView": "Copy a link to this view",
"shareCopied": "View link copied to clipboard",
"shareFailed": "Couldn't copy the link",
@@ -35,7 +36,8 @@
"date": "Upload date",
"content": "Content type",
"language": "Language",
- "topic": "Topic"
+ "topic": "Topic",
+ "tags": "Your tags"
},
"show": {
"unwatched": "Unwatched",
diff --git a/frontend/src/i18n/locales/en/tagManager.json b/frontend/src/i18n/locales/en/tagManager.json
new file mode 100644
index 0000000..89ffd64
--- /dev/null
+++ b/frontend/src/i18n/locales/en/tagManager.json
@@ -0,0 +1,12 @@
+{
+ "title": "Manage tags",
+ "channels": "{{count}} ch.",
+ "onChannels": "Tagged channels",
+ "delete": "Delete",
+ "deleted": "Tag deleted",
+ "deleteTitle": "Delete tag",
+ "confirmDelete": "Delete the tag “{{name}}”? It will be removed from every channel it's on.",
+ "empty": "No tags yet — add one below.",
+ "newPlaceholder": "New tag name…",
+ "add": "Add"
+}
diff --git a/frontend/src/i18n/locales/hu/card.json b/frontend/src/i18n/locales/hu/card.json
index 8141b46..e80d09b 100644
--- a/frontend/src/i18n/locales/hu/card.json
+++ b/frontend/src/i18n/locales/hu/card.json
@@ -8,6 +8,7 @@
"unhide": "Megjelenítés",
"hide": "Elrejtés",
"onlyThisChannel": "Csak ez a csatorna",
+ "resetState": "Alaphelyzet — nézési előzmény/státusz törlése",
"thisChannel": "Ez a csatorna",
"continueTitle": "Folytatás ott, ahol abbahagytad",
"continue": "Folytatás",
diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json
index b40c0b8..3b43b30 100644
--- a/frontend/src/i18n/locales/hu/channels.json
+++ b/frontend/src/i18n/locales/hu/channels.json
@@ -13,6 +13,7 @@
},
"tags": {
"yourTags": "Címkéid",
+ "manage": "Címkék kezelése",
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)",
"newTag": "új címke",
"createTag": "Címke létrehozása"
@@ -56,6 +57,7 @@
"subs": "{{formatted}} feliratkozó",
"openOnYouTube": "Megnyitás a YouTube-on",
"editTags": "Címkék szerkesztése",
+ "filterFeedByTag": "Hírfolyam szűrése: „{{name}}”",
"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",
@@ -94,6 +96,7 @@
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
"needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod.",
"connect": "Csatlakoztatás",
+ "filteredByTag": "Hírfolyam szűrve címkére: {{name}}",
"resetDone": "Csatorna resetelve — újraletöltés folyamatban",
"resetFailed": "Nem sikerült resetelni a csatornát"
},
diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json
index b224a13..b3c7bc3 100644
--- a/frontend/src/i18n/locales/hu/errors.json
+++ b/frontend/src/i18n/locales/hu/errors.json
@@ -1,4 +1,8 @@
{
+ "title": "Nem sikerült",
+ "generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.",
+ "server": "Szerverhiba",
+ "ok": "OK",
"boundary": {
"title": "Hiba történt.",
"subtitle": "Az alkalmazás váratlan hibába ütközött.",
diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json
index 28425ab..030a877 100644
--- a/frontend/src/i18n/locales/hu/sidebar.json
+++ b/frontend/src/i18n/locales/hu/sidebar.json
@@ -26,6 +26,7 @@
"clearDates": "dátumok törlése",
"reshuffle": "Újrakeverés",
"noMatchingTags": "Nincs ide illő címke",
+ "manageTags": "Kezelés",
"shareView": "Hivatkozás másolása erre a nézetre",
"shareCopied": "Nézet-hivatkozás a vágólapra másolva",
"shareFailed": "Nem sikerült a hivatkozás másolása",
@@ -35,7 +36,8 @@
"date": "Feltöltés dátuma",
"content": "Tartalomtípus",
"language": "Nyelv",
- "topic": "Téma"
+ "topic": "Téma",
+ "tags": "Saját címkék"
},
"show": {
"unwatched": "Nem nézett",
diff --git a/frontend/src/i18n/locales/hu/tagManager.json b/frontend/src/i18n/locales/hu/tagManager.json
new file mode 100644
index 0000000..6ee27a5
--- /dev/null
+++ b/frontend/src/i18n/locales/hu/tagManager.json
@@ -0,0 +1,12 @@
+{
+ "title": "Címkék kezelése",
+ "channels": "{{count}} csat.",
+ "onChannels": "Címkézett csatornák",
+ "delete": "Törlés",
+ "deleted": "Címke törölve",
+ "deleteTitle": "Címke törlése",
+ "confirmDelete": "Törlöd a(z) „{{name}}” címkét? Minden csatornáról lekerül, amin rajta van.",
+ "empty": "Még nincs címke — adj hozzá lentebb.",
+ "newPlaceholder": "Új címke neve…",
+ "add": "Hozzáad"
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index aa17661..12d4d82 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -1,4 +1,6 @@
import { notify } from "./notifications";
+import i18n from "../i18n";
+import { reportError } from "./errorDialog";
export interface Me {
id: number;
@@ -196,9 +198,14 @@ async function req(url: string, opts: RequestInit = {}): Promise {
} catch {
/* no JSON body */
}
- // Server faults are worth surfacing; 401/403/404 etc. are handled by callers.
+ // Surface anything the server definitively refused as a self-explanatory dialog the
+ // user must acknowledge: 5xx (a fault) and 400/409/422 (validation/conflict, with the
+ // server's own reason). 401/403/404 are control flow handled by callers (auth, demo
+ // gating, not-found), so they just throw.
if (r.status >= 500) {
- notifyErrorThrottled(`Server error ${r.status}`, `${method} ${url}`);
+ reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
+ } else if (r.status === 400 || r.status === 409 || r.status === 422) {
+ reportError(detail);
}
throw new HttpError(r.status, detail);
}
@@ -365,6 +372,7 @@ export const api = {
req(`/api/facets?${filterParams(f).toString()}`),
setState: (id: string, status: string) =>
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
+ clearState: (id: string) => req(`/api/videos/${id}/state`, { method: "DELETE" }),
saveProgress: (id: string, positionSeconds: number, durationSeconds: number) =>
req(`/api/videos/${id}/progress`, {
method: "POST",
diff --git a/frontend/src/lib/errorDialog.ts b/frontend/src/lib/errorDialog.ts
new file mode 100644
index 0000000..63c3c66
--- /dev/null
+++ b/frontend/src/lib/errorDialog.ts
@@ -0,0 +1,38 @@
+import i18n from "../i18n";
+
+// A single, self-explanatory error dialog (modal) the user must acknowledge — used when the
+// server can't carry out an action (a definitive 4xx/5xx response). Distinct from toasts
+// (transient, e.g. connection blips) and from silent handling. Module-level so the non-React
+// api layer can raise it; an 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;
+}
diff --git a/frontend/src/lib/sidebarLayout.ts b/frontend/src/lib/sidebarLayout.ts
index bb4f922..31e8335 100644
--- a/frontend/src/lib/sidebarLayout.ts
+++ b/frontend/src/lib/sidebarLayout.ts
@@ -4,14 +4,15 @@
// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no
// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
-export type WidgetId = "date" | "language" | "topic";
+export type WidgetId = "date" | "language" | "topic" | "tags";
-export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic"];
+export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic", "tags"];
export const WIDGET_TITLES: Record = {
date: "Upload date",
language: "Language",
topic: "Topic",
+ tags: "Tags",
};
export interface SidebarLayout {