Files
blog-manager/components/ImagePanel.tsx
2026-11-01 13:23:44 -05:00

217 lines
7.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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/<slug>/ (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<HTMLInputElement>(null);
const [over, setOver] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<section className="images">
<h2>Images</h2>
{error && <div className="notice error">{error}</div>}
{gallery.length > 0 && (
<>
<p className="muted small">
Gallery (shown below the post; the first image is also the card cover). Edits here are saved with the
post.
</p>
<table className="list" style={{ marginBottom: 20 }}>
<tbody>
{gallery.map((img, i) => (
<tr key={imageSrc(img)}>
<td className="mono small" style={{ width: 30 }}>
{i === 0 ? "cover" : i + 1}
</td>
<td style={{ width: 80 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={"/" + imageSrc(img)} alt="" style={{ height: 40, borderRadius: 4 }} />
</td>
<td className="mono small">{imageSrc(img)}</td>
<td>
<input
placeholder="description (alt text)"
value={imageDescription(img) ?? ""}
onChange={(e) =>
onGalleryChange(gallery.map((g, j) => (j === i ? withDescription(imageSrc(g), e.target.value) : g)))
}
/>
</td>
<td className="actions">
<button type="button" className="ghost" disabled={i === 0} onClick={() => move(i, -1)} title="Move up">
↑
</button>
<button
type="button"
className="ghost"
disabled={i === gallery.length - 1}
onClick={() => move(i, 1)}
title="Move down"
>
↓
</button>
<button type="button" onClick={() => onGalleryChange(gallery.filter((_, j) => j !== i))}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
{external.length > 0 && (
<p className="muted small">
{external.length} gallery image(s) live outside public/img/blog/{slug ?? "<slug>"}/.
</p>
)}
</>
)}
{!slug ? (
<div className="image-drop">Save the post once to enable image uploads.</div>
) : (
<div
className={`image-drop${over ? " over" : ""}`}
onClick={() => 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}/`}
<input
ref={inputRef}
type="file"
multiple
accept=".png,.jpg,.jpeg,.gif,.webp,.svg,.mp4"
hidden
onChange={(e) => {
if (e.target.files) upload(e.target.files);
e.target.value = "";
}}
/>
</div>
)}
<div className="image-grid">
{files.map((file) => {
const inGallery = galleryIndex(file.src) >= 0;
return (
<div key={file.name} className={`image-card${inGallery ? " in-gallery" : ""}`}>
<div className="thumb">
{/\.mp4$/i.test(file.name) ? (
<video src={"/" + file.src} muted />
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={"/" + file.src} alt={file.name} />
)}
</div>
<div className="body">
<div className="name">
{file.name} <span className="muted">· {formatSize(file.size)}</span>
</div>
<div className="row">
<button type="button" onClick={() => onInsert(file)} title="Insert at cursor in the body">
Insert
</button>
<button
type="button"
onClick={() =>
onGalleryChange(
inGallery ? gallery.filter((g) => imageSrc(g) !== file.src) : [...gallery, file.src]
)
}
>
{inGallery ? "− Gallery" : "+ Gallery"}
</button>
<button type="button" className="danger" onClick={() => remove(file)}>
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
</section>
);
}