chore: merge quick task worktree (worktree-agent-accd63c4)

This commit is contained in:
2026-04-20 22:51:44 +02:00
4 changed files with 75 additions and 43 deletions

View File

@@ -161,13 +161,17 @@ function AdminItemEditPage() {
setFetchingImage(true); setFetchingImage(true);
setFetchError(null); setFetchError(null);
try { try {
const result = await apiPost<{ filename: string; sourceUrl: string }>( const result = await apiPost<{
"/api/images/from-url", filename: string;
{ url: fetchUrl.trim() }, sourceUrl: string;
); presignedUrl: string;
dominantColor: string | null;
}>("/api/images/from-url", { url: fetchUrl.trim() });
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
imageFilename: result.filename, imageFilename: result.filename,
imageUrl: result.presignedUrl,
dominantColor: result.dominantColor ?? "",
imageSourceUrl: fetchUrl.trim(), imageSourceUrl: fetchUrl.trim(),
})); }));
setFetchUrl(""); setFetchUrl("");

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,39 +158,50 @@ 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">
<input <p className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
type="text" Add Tag
value={newName} </p>
onChange={(e) => setNewName(e.target.value)} <form onSubmit={handleCreate} className="flex items-end gap-3">
placeholder="Tag name..." <div className="flex-1">
className={`flex-1 ${inputClass}`} <label className="block text-xs text-gray-500 mb-1">Name</label>
/> <input
<select type="text"
value={newParentId ?? ""} value={newName}
onChange={(e) => onChange={(e) => setNewName(e.target.value)}
setNewParentId(e.target.value ? Number(e.target.value) : null) placeholder="e.g. Bikepacking"
} className={inputClass}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none bg-white appearance-none w-48" />
> </div>
<option value="">No parent (top-level)</option> <div className="w-48">
{data?.map((t) => ( <label className="block text-xs text-gray-500 mb-1">Parent</label>
<option key={t.id} value={t.id}> <select
{t.name} value={newParentId ?? ""}
</option> onChange={(e) =>
))} setNewParentId(e.target.value ? Number(e.target.value) : null)
</select> }
<button className={`${inputClass} appearance-none bg-white`}
type="submit" >
disabled={createMutation.isPending || !newName.trim()} <option value="">No parent (top-level)</option>
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" {data?.map((t) => (
> <option key={t.id} value={t.id}>
{createMutation.isPending ? "Adding..." : "Add Tag"} {t.name}
</button> </option>
</form> ))}
{createError && ( </select>
<p className="text-sm text-red-500 mt-1 mb-4">{createError}</p> </div>
)} <button
type="submit"
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 shrink-0"
>
{createMutation.isPending ? "Adding..." : "Add Tag"}
</button>
</form>
{createError && (
<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");
const tag = await createTag(db, data); try {
return c.json(tag, 201); const tag = await createTag(db, data);
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

View File

@@ -6,7 +6,7 @@ import {
extractDominantColor, extractDominantColor,
fetchImageFromUrl, fetchImageFromUrl,
} from "../services/image.service"; } from "../services/image.service";
import { uploadImage } from "../services/storage.service"; import { getImageUrl, uploadImage } from "../services/storage.service";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]; const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB const MAX_SIZE = 5 * 1024 * 1024; // 5MB
@@ -19,7 +19,8 @@ app.post("/from-url", zValidator("json", fromUrlSchema), async (c) => {
const { url } = c.req.valid("json"); const { url } = c.req.valid("json");
try { try {
const result = await fetchImageFromUrl(url); const result = await fetchImageFromUrl(url);
return c.json(result, 201); const presignedUrl = await getImageUrl(result.filename);
return c.json({ ...result, presignedUrl }, 201);
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
// Known validation errors from the service // Known validation errors from the service