Files

290 lines
8.1 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import ConfirmDialog from "@/components/ConfirmDialog";
import CatalogEmptyState from "@/components/catalog/CatalogEmptyState";
import { GALLERY_PAGE_SIZE, galleryImage } from "@/components/catalog/utils";
import { en } from "@/lib/i18n/en";
import {
addMeshkeeGalleryItems,
deleteMeshkeeGalleryItem,
listMeshkeeGallery,
type MeshkeeMediaItem,
} from "@/services/meshkee-content";
import styles from "./GalleryPanel.module.css";
const ACCEPT = "image/jpeg,image/png,image/webp,image/gif";
function isImageFile(file: File) {
return file.type.startsWith("image/");
}
export default function GalleryPanel({
meshkeeId,
emptyLabel = en.gallery.empty,
}: {
meshkeeId: string;
emptyLabel?: string;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [items, setItems] = useState<MeshkeeMediaItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [busy, setBusy] = useState(false);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [removing, setRemoving] = useState<MeshkeeMediaItem | null>(null);
const [removeBusy, setRemoveBusy] = useState(false);
const [error, setError] = useState("");
async function reload(nextPage = 1) {
setBusy(true);
setError("");
try {
const res = await listMeshkeeGallery(meshkeeId, {
page: nextPage,
pageSize: GALLERY_PAGE_SIZE,
});
setItems(res.items);
setTotal(res.total);
setPage(res.page);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not load gallery.");
} finally {
setBusy(false);
}
}
async function loadMore() {
if (busy || uploading) return;
setBusy(true);
setError("");
try {
const nextPage = page + 1;
const res = await listMeshkeeGallery(meshkeeId, {
page: nextPage,
pageSize: GALLERY_PAGE_SIZE,
});
setItems((prev) => {
const seen = new Set(prev.map((item) => item.id));
return [
...prev,
...res.items.filter((item) => !seen.has(item.id)),
];
});
setTotal(res.total);
setPage(res.page);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not load gallery.");
} finally {
setBusy(false);
}
}
useEffect(() => {
let cancelled = false;
void (async () => {
setBusy(true);
setError("");
try {
const res = await listMeshkeeGallery(meshkeeId, {
page: 1,
pageSize: GALLERY_PAGE_SIZE,
});
if (cancelled) return;
setItems(res.items);
setTotal(res.total);
setPage(1);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Could not load gallery.",
);
}
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [meshkeeId]);
async function uploadFiles(fileList: FileList | File[]) {
const files = Array.from(fileList).filter(isImageFile);
if (!files.length) {
setError("Choose image files (JPG, PNG, WebP, or GIF).");
return;
}
setUploading(true);
setError("");
try {
const res = await addMeshkeeGalleryItems(meshkeeId, { files });
const uploaded = res.items ?? [];
if (uploaded.length) {
setItems((prev) => {
const seen = new Set(uploaded.map((item) => item.id));
return [...uploaded, ...prev.filter((item) => !seen.has(item.id))];
});
setTotal((value) => value + uploaded.length);
} else {
await reload(1);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Could not upload images.");
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
}
async function confirmRemove() {
if (!removing) return;
setRemoveBusy(true);
try {
await deleteMeshkeeGalleryItem(meshkeeId, removing.id);
setItems((prev) => prev.filter((item) => item.id !== removing.id));
setTotal((value) => Math.max(0, value - 1));
setRemoving(null);
} catch (err) {
window.alert(
err instanceof Error ? err.message : "Could not remove gallery item.",
);
} finally {
setRemoveBusy(false);
}
}
const hasMore = page * GALLERY_PAGE_SIZE < total;
return (
<div className={styles.root}>
<label
className={[
styles.dropzone,
dragOver ? styles.dropzoneActive : "",
uploading ? styles.dropzoneBusy : "",
]
.filter(Boolean)
.join(" ")}
onDragEnter={(event) => {
event.preventDefault();
setDragOver(true);
}}
onDragOver={(event) => {
event.preventDefault();
setDragOver(true);
}}
onDragLeave={(event) => {
event.preventDefault();
if (event.currentTarget.contains(event.relatedTarget as Node)) {
return;
}
setDragOver(false);
}}
onDrop={(event) => {
event.preventDefault();
setDragOver(false);
if (uploading) return;
void uploadFiles(event.dataTransfer.files);
}}
>
<img
className={styles.dropIcon}
src="/images/icons/gallery.svg"
alt=""
width={36}
height={36}
/>
<p className={styles.dropTitle}>
{uploading ? en.gallery.uploading : en.gallery.dropTitle}
</p>
<p className={styles.dropHint}>{en.gallery.dropHint}</p>
<input
ref={inputRef}
className={styles.fileInput}
type="file"
accept={ACCEPT}
multiple
disabled={uploading}
onChange={(event) => {
const files = event.target.files;
if (files?.length) void uploadFiles(files);
}}
/>
</label>
{error ? <p className={styles.error}>{error}</p> : null}
{busy && !items.length ? (
<p className={styles.hint}>Loading gallery…</p>
) : null}
{items.length ? (
<div className={styles.masonry}>
{items.map((item) => {
const image = galleryImage(item);
const label = item.altText || item.fileName || "image";
return (
<figure key={item.id} className={styles.item}>
{image ? (
<img src={image} alt={item.altText ?? ""} loading="lazy" />
) : (
<div className={styles.fallback} aria-hidden />
)}
<div className={styles.itemFoot}>
<button
type="button"
className={styles.removeBtn}
disabled={removeBusy}
onClick={() => setRemoving(item)}
>
Remove
</button>
</div>
<span className="sr-only">{label}</span>
</figure>
);
})}
</div>
) : null}
{!items.length && !busy ? (
<CatalogEmptyState
icon="/images/icons/gallery.svg"
title={emptyLabel}
lead={en.gallery.emptyLead}
/>
) : null}
{hasMore ? (
<button
type="button"
className="outline-btn"
disabled={busy}
onClick={() => void loadMore()}
>
{busy ? "Loading…" : "Load more"}
</button>
) : null}
<ConfirmDialog
open={Boolean(removing)}
kicker="Remove"
title={`Remove ${removing?.altText || removing?.fileName || "image"}?`}
message="This removes the image from the business gallery. This cannot be undone."
confirmLabel="Remove"
cancelLabel="Keep"
danger
busy={removeBusy}
onClose={() => {
if (!removeBusy) setRemoving(null);
}}
onConfirm={() => {
void confirmRemove();
}}
/>
</div>
);
}