Initial commit
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user