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