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(