chore: merge quick task worktree (worktree-agent-accd63c4)
This commit is contained in:
@@ -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("");
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user