217 lines
7.8 KiB
TypeScript
217 lines
7.8 KiB
TypeScript
"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>
|
||
);
|
||
}
|