initial commit

This commit is contained in:
Hunter
2026-11-01 13:23:44 -05:00
commit e03ea4f770
53 changed files with 10802 additions and 0 deletions

216
components/ImagePanel.tsx Normal file
View 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>
);
}