"use client"; import { useRef, useState } from "react"; import { api } from "@/lib/client"; import { imageDescription, imageSrc, type GalleryImage, type ImageFile } from "@/lib/types"; /* Two related things in one panel: * - files: what's in public/img/blog// (upload / delete / insert) * - gallery: the post's images[] (ordered; the first is the card cover). * Uploading and deleting files happens immediately; gallery edits are part * of the post and are saved with it. */ type Props = { /** The saved slug; uploads are disabled until the post exists. */ slug: string | null; files: ImageFile[]; onFilesChange: (files: ImageFile[]) => void; gallery: GalleryImage[]; onGalleryChange: (gallery: GalleryImage[]) => void; onInsert: (file: ImageFile) => void; }; function formatSize(bytes: number): string { return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB`; } function withDescription(src: string, description: string): GalleryImage { return description.trim() ? { src, description } : src; } export default function ImagePanel({ slug, files, onFilesChange, gallery, onGalleryChange, onInsert }: Props) { const inputRef = useRef(null); const [over, setOver] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const galleryIndex = (src: string) => gallery.findIndex((g) => imageSrc(g) === src); async function upload(list: FileList | File[]) { if (!slug || !list.length) return; const form = new FormData(); for (const f of Array.from(list)) form.append("file", f); setBusy(true); setError(null); try { const res = await api<{ files: ImageFile[] }>(`/api/images/${slug}`, { method: "POST", body: form }); onFilesChange(res.files); } catch (err) { setError((err as Error).message); } finally { setBusy(false); } } async function remove(file: ImageFile) { if (!slug || !window.confirm(`Delete ${file.name} from disk?`)) return; try { const res = await api<{ files: ImageFile[] }>( `/api/images/${slug}?name=${encodeURIComponent(file.name)}`, { method: "DELETE" } ); onFilesChange(res.files); if (galleryIndex(file.src) >= 0) onGalleryChange(gallery.filter((g) => imageSrc(g) !== file.src)); } catch (err) { setError((err as Error).message); } } function move(index: number, delta: number) { const next = [...gallery]; const [item] = next.splice(index, 1); next.splice(index + delta, 0, item); onGalleryChange(next); } // Gallery entries pointing outside this post's folder (e.g. hand-added // paths) still need to be manageable, so list them too. const fileSrcs = new Set(files.map((f) => f.src)); const external = gallery.filter((g) => !fileSrcs.has(imageSrc(g))); return (

Images

{error &&
{error}
} {gallery.length > 0 && ( <>

Gallery (shown below the post; the first image is also the card cover). Edits here are saved with the post.

{gallery.map((img, i) => ( ))}
{i === 0 ? "cover" : i + 1} {/* eslint-disable-next-line @next/next/no-img-element */} {imageSrc(img)} onGalleryChange(gallery.map((g, j) => (j === i ? withDescription(imageSrc(g), e.target.value) : g))) } />
{external.length > 0 && (

{external.length} gallery image(s) live outside public/img/blog/{slug ?? ""}/.

)} )} {!slug ? (
Save the post once to enable image uploads.
) : (
inputRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)} onDrop={(e) => { e.preventDefault(); setOver(false); upload(e.dataTransfer.files); }} > {busy ? "Uploading…" : `Drop images here or click to upload → public/img/blog/${slug}/`} { if (e.target.files) upload(e.target.files); e.target.value = ""; }} />
)}
{files.map((file) => { const inGallery = galleryIndex(file.src) >= 0; return (
{/\.mp4$/i.test(file.name) ? (
{file.name} · {formatSize(file.size)}
); })}
); }