initial commit
This commit is contained in:
216
components/ImagePanel.tsx
Normal file
216
components/ImagePanel.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user