From b55a944e7c0515190099e9d1585ff97b69fe1381 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Thu, 18 Jun 2026 01:17:31 +0200
Subject: [PATCH 1/2] 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 1e530d23a6dd432fe6cacae611946f829a26758f Mon Sep 17 00:00:00 2001
From: npeter83
Date: Thu, 18 Jun 2026 01:17:31 +0200
Subject: [PATCH 2/2] 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} />
)}
+