Update carousels, card hover, and IranBay branding.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user