fix(admin): handle duplicate tag name with 409 + polish inline tag form

- admin-tags.ts: wrap createTag in try/catch, detect UNIQUE constraint violations and return 409 with friendly message
- tags/index.tsx: surface server error message in catch block via err.message (ApiError carries the message from response body)
- tags/index.tsx: replace bare form row with card-style wrapper — label for Name and Parent, card border/bg, shrink-0 submit button
This commit is contained in:
2026-04-20 22:50:35 +02:00
parent b41b8329bc
commit 113e689932
2 changed files with 64 additions and 37 deletions

View File

@@ -124,8 +124,10 @@ function AdminTagsPage() {
}); });
setNewName(""); setNewName("");
setNewParentId(null); setNewParentId(null);
} catch { } catch (err: unknown) {
setCreateError("Failed to create tag. Please try again."); const message =
err instanceof Error ? err.message : "Failed to create tag";
setCreateError(message);
} }
} }
@@ -156,20 +158,29 @@ function AdminTagsPage() {
</div> </div>
{/* Quick-add form */} {/* Quick-add form */}
<form onSubmit={handleCreate} className="flex items-center gap-3 mb-4"> <div className="mb-6 rounded-xl border border-gray-100 bg-white p-4">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
Add Tag
</p>
<form onSubmit={handleCreate} className="flex items-end gap-3">
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-1">Name</label>
<input <input
type="text" type="text"
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder="Tag name..." placeholder="e.g. Bikepacking"
className={`flex-1 ${inputClass}`} className={inputClass}
/> />
</div>
<div className="w-48">
<label className="block text-xs text-gray-500 mb-1">Parent</label>
<select <select
value={newParentId ?? ""} value={newParentId ?? ""}
onChange={(e) => onChange={(e) =>
setNewParentId(e.target.value ? Number(e.target.value) : null) setNewParentId(e.target.value ? Number(e.target.value) : null)
} }
className="rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none bg-white appearance-none w-48" className={`${inputClass} appearance-none bg-white`}
> >
<option value="">No parent (top-level)</option> <option value="">No parent (top-level)</option>
{data?.map((t) => ( {data?.map((t) => (
@@ -178,17 +189,19 @@ function AdminTagsPage() {
</option> </option>
))} ))}
</select> </select>
</div>
<button <button
type="submit" type="submit"
disabled={createMutation.isPending || !newName.trim()} disabled={createMutation.isPending || !newName.trim()}
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors disabled:opacity-50" className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors disabled:opacity-50 shrink-0"
> >
{createMutation.isPending ? "Adding..." : "Add Tag"} {createMutation.isPending ? "Adding..." : "Add Tag"}
</button> </button>
</form> </form>
{createError && ( {createError && (
<p className="text-sm text-red-500 mt-1 mb-4">{createError}</p> <p className="text-sm text-red-500 mt-2">{createError}</p>
)} )}
</div>
{/* Error state */} {/* Error state */}
{isError && ( {isError && (

View File

@@ -45,8 +45,22 @@ app.get("/:id", async (c) => {
app.post("/", zValidator("json", createTagSchema), async (c) => { app.post("/", zValidator("json", createTagSchema), async (c) => {
const db = c.get("db"); const db = c.get("db");
const data = c.req.valid("json"); const data = c.req.valid("json");
try {
const tag = await createTag(db, data); const tag = await createTag(db, data);
return c.json(tag, 201); return c.json(tag, 201);
} catch (err) {
if (
err instanceof Error &&
(err.message.includes("UNIQUE constraint failed") ||
err.message.includes("unique constraint"))
) {
return c.json(
{ error: `A tag named "${data.name}" already exists` },
409,
);
}
throw err;
}
}); });
// PUT /api/admin/tags/:id — rename and/or reparent a tag // PUT /api/admin/tags/:id — rename and/or reparent a tag