Files
pinkudex/components/image/AttachedImages.tsx
T
2026-05-26 22:46:00 +02:00

121 lines
4.5 KiB
TypeScript

"use client";
import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Plus, Trash2, Loader2 } from "lucide-react";
import { deleteAttachedImage } from "@/app/actions/attachments";
import { imageUrl } from "@/lib/assetUrls";
interface Attached {
id: number;
thumbPath: string;
width: number;
height: number;
filename: string;
sha256: string;
}
export function AttachedImages({ parentId, items }: { parentId: number; items: Attached[] }) {
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [, start] = useTransition();
async function upload(files: FileList | null) {
if (!files || files.length === 0) return;
// Drop-zone bypasses the Add button's `disabled` state, so a second
// drop while a previous upload is in flight would race the busy
// flag (and clobber fileRef.value mid-flight).
if (busy) return;
setBusy(true);
setError(null);
try {
for (const file of Array.from(files)) {
const fd = new FormData();
fd.append("file", file);
fd.append("parentImageId", String(parentId));
const res = await fetch("/api/upload", { method: "POST", body: fd });
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error ?? `upload failed (${res.status})`);
}
}
router.refresh();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
if (fileRef.current) fileRef.current.value = "";
}
}
return (
<div className="mt-3 glass rounded-2xl p-3">
<div className="flex items-center justify-between mb-2">
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
Back covers / extras {items.length > 0 && <span className="ml-1 text-[var(--color-fg-dim)]">({items.length})</span>}
</div>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={busy}
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-50"
>
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
{busy ? "Uploading…" : "Add"}
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
hidden
onChange={(e) => upload(e.target.files)}
/>
</div>
{error && (
<div className="mb-2 text-xs text-red-400">{error}</div>
)}
{items.length === 0 ? (
<div
onDragOver={(e) => { e.preventDefault(); }}
onDrop={(e) => { e.preventDefault(); upload(e.dataTransfer.files); }}
className="rounded-xl border border-dashed border-[var(--color-glass-border)] py-6 text-center text-xs text-[var(--color-fg-muted)]"
>
No back covers yet. Drag & drop here or click <span className="text-[var(--color-cyan)]">Add</span>.
</div>
) : (
<div
onDragOver={(e) => { e.preventDefault(); }}
onDrop={(e) => { e.preventDefault(); upload(e.dataTransfer.files); }}
className="flex flex-col gap-3"
>
{items.map((it) => (
<div key={it.id} className="relative group rounded-2xl overflow-hidden glass">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl({ id: it.id, code: null, ext: it.filename.match(/\.[^.]+$/)?.[0] ?? ".jpg", v: it.sha256.slice(0, 12) })}
alt={it.filename}
width={it.width}
height={it.height}
className="block w-full h-auto max-w-[800px] max-h-[538px]"
/>
<button
type="button"
onClick={() => start(async () => { await deleteAttachedImage(it.id); router.refresh(); })}
title="Remove"
aria-label="Remove"
className="absolute top-2 right-2 w-8 h-8 grid place-items-center rounded-md bg-black/70 text-red-300 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/90"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
);
}