290 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|