Files
dashboards/components/WebsiteImagesPage.tsx

839 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
useEffect,
useId,
useRef,
useState,
type FormEvent,
} from "react";
import ConfirmDialog from "@/components/ConfirmDialog";
import ImageCropDialog from "@/components/ImageCropDialog";
import WebsiteLinkGenerator from "@/components/WebsiteLinkGenerator";
import {
getWebsiteHomeMedia,
newMediaItem,
saveWebsiteHomeMedia,
uploadWebsiteMedia,
type WebsiteHomeMedia,
type WebsiteMediaItem,
} from "@/services/website-media";
import styles from "./WebsiteImagesPage.module.css";
type SectionKey =
| "slider"
| "standingBanners"
| "categories"
| "menuImages"
| "aboutUs"
| "banners";
type PixelSize = { width: number; height: number };
type SlotGroup = {
size: PixelSize;
indexes: number[];
};
type SectionDef = {
key: SectionKey;
label: string;
kind: "list" | "fixed";
itemCount?: number;
/** Default upload / preview size */
size: PixelSize;
/** Per-index size overrides when not using groups */
sizes?: PixelSize[];
/** Same-row size groups (e.g. homepage banners) */
groups?: SlotGroup[];
fit: "contain" | "cover";
};
type EditTarget = {
key: SectionKey;
index: number;
};
const SECTIONS: SectionDef[] = [
{
key: "slider",
label: "Slider",
kind: "list",
size: { width: 450, height: 450 },
fit: "contain",
},
{
key: "standingBanners",
label: "Standing banners",
kind: "fixed",
itemCount: 2,
size: { width: 600, height: 600 },
fit: "contain",
},
{
key: "categories",
label: "Categories",
kind: "list",
size: { width: 600, height: 600 },
fit: "contain",
},
{
key: "menuImages",
label: "Menu images",
kind: "fixed",
itemCount: 4,
size: { width: 600, height: 600 },
fit: "contain",
},
{
key: "aboutUs",
label: "About us",
kind: "fixed",
itemCount: 1,
size: { width: 979, height: 800 },
fit: "contain",
},
{
key: "banners",
label: "Banners",
kind: "fixed",
itemCount: 5,
size: { width: 650, height: 650 },
// Matches homepage: wide featured · 3 small cards · export strip
groups: [
{ size: { width: 650, height: 650 }, indexes: [0] },
{ size: { width: 900, height: 900 }, indexes: [1, 2, 3] },
{ size: { width: 593, height: 640 }, indexes: [4] },
],
fit: "contain",
},
];
function formatResolution(size: PixelSize) {
return `${size.width} × ${size.height}`;
}
function sizeForItem(section: SectionDef, index: number): PixelSize {
const fromGroup = section.groups?.find((group) =>
group.indexes.includes(index),
);
if (fromGroup) return fromGroup.size;
return section.sizes?.[index] ?? section.size;
}
function previewWidth(size: PixelSize) {
const aspect = size.width / size.height;
if (aspect >= 1.4) return 220;
if (aspect >= 0.9) return 140;
return 110;
}
function getItems(media: WebsiteHomeMedia, key: SectionKey): WebsiteMediaItem[] {
if (key === "aboutUs") return [media.aboutUs];
return media[key];
}
function withItems(
media: WebsiteHomeMedia,
key: SectionKey,
items: WebsiteMediaItem[],
): WebsiteHomeMedia {
if (key === "aboutUs") {
return { ...media, aboutUs: items[0] ?? media.aboutUs };
}
return { ...media, [key]: items };
}
function emptySlot(partial?: Partial<WebsiteMediaItem>): WebsiteMediaItem {
return newMediaItem({
imageUrl: "",
linkUrl: "/",
title: "",
subtitle: "",
cta: "",
...partial,
});
}
function padFixed(items: WebsiteMediaItem[], count: number) {
const next = items.slice(0, count);
while (next.length < count) next.push(emptySlot());
return next;
}
export default function WebsiteImagesPage() {
const [media, setMedia] = useState<WebsiteHomeMedia | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState("");
const [error, setError] = useState("");
const [editTarget, setEditTarget] = useState<EditTarget | null>(null);
const [removeTarget, setRemoveTarget] = useState<EditTarget | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
setLoading(true);
setError("");
try {
const data = await getWebsiteHomeMedia();
if (!cancelled) setMedia(data);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error
? err.message
: "Could not load website images.",
);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
async function persist(next: WebsiteHomeMedia) {
setSaving(true);
setStatus("");
setError("");
try {
const saved = await saveWebsiteHomeMedia(next);
setMedia(saved);
setStatus("Saved");
return saved;
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save.");
throw err;
} finally {
setSaving(false);
}
}
async function handleSaveItem(
target: EditTarget,
values: {
imageUrl: string;
title: string;
subtitle: string;
cta: string;
linkUrl: string;
},
) {
if (!media) return;
const section = SECTIONS.find((row) => row.key === target.key);
if (!section) return;
let items = [...getItems(media, target.key)];
if (section.kind === "fixed" && section.itemCount) {
items = padFixed(items, section.itemCount);
}
const existing = items[target.index];
const nextItem: WebsiteMediaItem = {
...(existing ?? emptySlot()),
imageUrl: values.imageUrl,
title: values.title,
subtitle: values.subtitle,
cta: values.cta,
linkUrl: values.linkUrl || "/",
};
if (section.kind === "list" && !existing) {
items.push(nextItem);
} else {
items[target.index] = nextItem;
}
if (section.kind === "fixed" && section.itemCount) {
items = padFixed(items, section.itemCount);
}
const next = withItems(media, target.key, items);
setMedia(next);
await persist(next);
setEditTarget(null);
}
async function handleRemove(target: EditTarget) {
if (!media) return;
const section = SECTIONS.find((row) => row.key === target.key);
if (!section) return;
let items = [...getItems(media, target.key)];
if (section.kind === "list") {
items = items.filter((_, index) => index !== target.index);
if (target.key === "slider" && items.length === 0) {
items = [emptySlot({ title: "New slide", cta: "browse producers" })];
}
} else {
if (section.itemCount) items = padFixed(items, section.itemCount);
items[target.index] = emptySlot({ id: items[target.index]?.id });
}
const next = withItems(media, target.key, items);
setMedia(next);
setRemoveTarget(null);
await persist(next);
}
if (loading) {
return <div className={styles.loading}>Loading website images…</div>;
}
if (!media) {
return (
<div className={styles.loading} role="alert">
{error || "Could not load website images."}
</div>
);
}
const editingSection = editTarget
? SECTIONS.find((row) => row.key === editTarget.key)
: null;
const editingItems = editTarget ? getItems(media, editTarget.key) : [];
const editingItem = editTarget ? editingItems[editTarget.index] : null;
return (
<div className={styles.page}>
<header className={styles.head}>
<div>
<h1 className={styles.title}>Website images</h1>
<p className={styles.lead}>
Homepage assets at their website pixel sizes — add or replace via
cropper.
</p>
</div>
<div className={styles.headActions}>
{status ? (
<span className={`${styles.status} ${styles.statusOk}`}>{status}</span>
) : null}
{error ? (
<span className={`${styles.status} ${styles.statusError}`}>
{error}
</span>
) : null}
</div>
</header>
{SECTIONS.map((section) => {
const raw = getItems(media, section.key);
const items =
section.kind === "fixed" && section.itemCount
? padFixed(raw, section.itemCount)
: raw;
const addSize = sizeForItem(section, items.length);
const addWidth = previewWidth(addSize);
const showListPlus = section.kind === "list";
const compactPlus = showListPlus && items.length > 0;
const thumbFitClass = `${styles.thumbTransparent} ${
section.fit === "contain" ? styles.thumbContain : styles.thumbCover
}`;
const groups = section.groups;
const hasGroups = Boolean(groups?.length);
function renderSlot(index: number, item: WebsiteMediaItem) {
const filled = Boolean(item.imageUrl?.trim());
const size = sizeForItem(section, index);
const width = previewWidth(size);
const aspect = `${size.width} / ${size.height}`;
return (
<div
key={`${section.key}-${item.id}-${index}`}
className={styles.cell}
style={{ width }}
>
{filled ? (
<>
<button
type="button"
className={`${styles.thumb} ${thumbFitClass}`}
style={{ aspectRatio: aspect }}
aria-label={`Edit ${section.label} ${index + 1}`}
disabled={saving}
onClick={() => setEditTarget({ key: section.key, index })}
>
<img src={item.imageUrl} alt="" />
</button>
<div className={styles.footer}>
<p className={styles.cellIndex}>{index + 1}</p>
<div className={styles.controls}>
<button
type="button"
className={styles.iconBtn}
aria-label="Edit"
disabled={saving}
onClick={() =>
setEditTarget({ key: section.key, index })
}
>
<EditIcon />
</button>
<button
type="button"
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
aria-label="Remove"
disabled={saving}
onClick={() =>
setRemoveTarget({ key: section.key, index })
}
>
<RemoveIcon />
</button>
</div>
</div>
</>
) : (
<button
type="button"
className={`${styles.thumb} ${styles.thumbEmpty}`}
style={{ aspectRatio: aspect }}
aria-label={`Add ${section.label} ${index + 1}`}
disabled={saving}
onClick={() => setEditTarget({ key: section.key, index })}
>
<span className={styles.plus}>+</span>
</button>
)}
</div>
);
}
return (
<section key={section.key} className={styles.section}>
<div className={styles.sectionHead}>
<h2 className={styles.sectionTitle}>{section.label}</h2>
<p className={styles.sectionMeta}>
{section.key}
{" · "}
{section.kind === "list"
? "Duplicatable"
: `Fixed · ${section.itemCount}`}
{!hasGroups ? ` · ${formatResolution(section.size)}` : null}
</p>
</div>
<div className={styles.track}>
{showListPlus ? (
<div
className={`${styles.cell} ${compactPlus ? styles.cellCompact : ""}`}
style={compactPlus ? undefined : { width: addWidth }}
>
<button
type="button"
className={`${styles.thumb} ${styles.thumbEmpty} ${
compactPlus ? styles.thumbCompact : ""
}`}
style={
compactPlus
? {
minHeight: Math.round(
addWidth / (addSize.width / addSize.height),
),
}
: {
aspectRatio: `${addSize.width} / ${addSize.height}`,
}
}
aria-label={`Add ${section.label}`}
disabled={saving}
onClick={() =>
setEditTarget({ key: section.key, index: items.length })
}
>
<span className={styles.plus}>+</span>
</button>
</div>
) : null}
{hasGroups
? groups!.map((group, groupIndex) => (
<div key={`group-${groupIndex}`} className={styles.group}>
{groupIndex > 0 ? (
<div className={styles.groupDivider} aria-hidden />
) : null}
<div className={styles.groupBody}>
<p className={styles.groupSize}>
{formatResolution(group.size)}
</p>
<div className={styles.groupItems}>
{group.indexes.map((index) => {
const item = items[index] ?? emptySlot();
return renderSlot(index, item);
})}
</div>
</div>
</div>
))
: items.map((item, index) => renderSlot(index, item))}
</div>
</section>
);
})}
<WebsiteMediaEditModal
open={Boolean(editTarget && editingSection)}
sectionLabel={editingSection?.label ?? ""}
size={
editTarget && editingSection
? sizeForItem(editingSection, editTarget.index)
: { width: 600, height: 600 }
}
fit={editingSection?.fit ?? "contain"}
initial={editingItem}
busy={saving}
onClose={() => {
if (!saving) setEditTarget(null);
}}
onSave={async (values) => {
if (!editTarget) return;
await handleSaveItem(editTarget, values);
}}
/>
<ConfirmDialog
open={Boolean(removeTarget)}
title="Remove image"
message="Clear this slot? You can add a new image later."
confirmLabel="Remove"
danger
busy={saving}
onClose={() => {
if (!saving) setRemoveTarget(null);
}}
onConfirm={() => {
if (removeTarget) void handleRemove(removeTarget);
}}
/>
</div>
);
}
function EditIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
);
}
function RemoveIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
}
function WebsiteMediaEditModal({
open,
sectionLabel,
size,
fit,
initial,
busy,
onClose,
onSave,
}: {
open: boolean;
sectionLabel: string;
size: PixelSize;
fit: "contain" | "cover";
initial: WebsiteMediaItem | null | undefined;
busy: boolean;
onClose: () => void;
onSave: (values: {
imageUrl: string;
title: string;
subtitle: string;
cta: string;
linkUrl: string;
}) => Promise<void>;
}) {
const resolution = formatResolution(size);
const aspect = size.width / size.height;
const titleId = useId();
const fileRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState("");
const [subtitle, setSubtitle] = useState("");
const [cta, setCta] = useState("");
const [linkUrl, setLinkUrl] = useState("");
const [imageUrl, setImageUrl] = useState("");
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [cropSrc, setCropSrc] = useState<string | null>(null);
const [cropOpen, setCropOpen] = useState(false);
const [localError, setLocalError] = useState("");
const [uploading, setUploading] = useState(false);
useEffect(() => {
if (!open) return;
setTitle(initial?.title ?? "");
setSubtitle(initial?.subtitle ?? "");
setCta(initial?.cta ?? "");
setLinkUrl(initial?.linkUrl ?? "");
setImageUrl(initial?.imageUrl ?? "");
setPendingFile(null);
setLocalError("");
setCropSrc(null);
setCropOpen(false);
}, [open, initial]);
useEffect(() => {
if (!open) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busy && !cropOpen) onClose();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", onKey);
};
}, [open, busy, cropOpen, onClose]);
if (!open) return null;
async function submit(event: FormEvent) {
event.preventDefault();
if (!imageUrl && !pendingFile) {
setLocalError("Add an image first.");
return;
}
setLocalError("");
setUploading(true);
try {
let nextUrl = imageUrl;
if (pendingFile) {
nextUrl = await uploadWebsiteMedia(pendingFile);
}
await onSave({
imageUrl: nextUrl,
title: title.trim(),
subtitle: subtitle.trim(),
cta: cta.trim(),
linkUrl: linkUrl.trim() || "/",
});
} catch (err) {
setLocalError(err instanceof Error ? err.message : "Could not save.");
} finally {
setUploading(false);
}
}
const working = busy || uploading;
return (
<>
<div className={styles.overlay} role="presentation" onClick={onClose}>
<div
className={styles.modal}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={(event) => event.stopPropagation()}
>
<div className={styles.modalHead}>
<div>
<p className={styles.kicker}>Website image</p>
<h2 id={titleId} className={styles.modalTitle}>
{initial?.imageUrl ? "Edit image" : "Add image"}
</h2>
<p className={styles.modalHint}>
{sectionLabel} · {resolution}
</p>
</div>
<button
type="button"
className={styles.closeBtn}
aria-label="Close"
disabled={working}
onClick={onClose}
>
×
</button>
</div>
<form className={styles.form} onSubmit={(event) => void submit(event)}>
<div className={styles.imageBlock}>
<div className={styles.imageLabelRow}>
<span className={styles.imageLabel}>Image</span>
<span className={styles.imageLabel}>{resolution}</span>
</div>
{imageUrl ? (
<div className={styles.cropPreviewWrap}>
<div
className={`${styles.cropPreview} ${styles.cropPreviewTransparent} ${
fit === "contain"
? styles.cropPreviewContain
: styles.cropPreviewCover
}`}
style={{ aspectRatio: `${size.width} / ${size.height}` }}
>
<img src={imageUrl} alt="" />
</div>
<button
type="button"
className={styles.changeImage}
disabled={working}
onClick={() => fileRef.current?.click()}
>
Change image
</button>
</div>
) : (
<button
type="button"
className={styles.pickImage}
style={{ aspectRatio: `${size.width} / ${size.height}` }}
disabled={working}
onClick={() => fileRef.current?.click()}
>
<span className={styles.pickPlus}>+</span>
<span className={styles.pickMeta}>{resolution}</span>
</button>
)}
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className={styles.fileInput}
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const url = URL.createObjectURL(file);
setCropSrc(url);
setCropOpen(true);
}}
/>
</div>
<label className={styles.field}>
<span>Title EN</span>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Title"
disabled={working}
/>
</label>
<label className={styles.field}>
<span>Subtext</span>
<textarea
value={subtitle}
onChange={(event) => setSubtitle(event.target.value)}
placeholder="Short supporting text"
disabled={working}
rows={3}
/>
</label>
<label className={styles.field}>
<span>Link text</span>
<input
value={cta}
onChange={(event) => setCta(event.target.value)}
placeholder="browse producers"
disabled={working}
/>
</label>
<div className={styles.linkRow}>
<label className={`${styles.field} ${styles.linkField}`}>
<span>Link value</span>
<input
value={linkUrl}
onChange={(event) => setLinkUrl(event.target.value)}
placeholder="/businesses"
dir="ltr"
disabled={working}
/>
</label>
<WebsiteLinkGenerator
disabled={working}
onPick={(url) => setLinkUrl(url)}
/>
</div>
{localError ? (
<p className={styles.modalError} role="alert">
{localError}
</p>
) : null}
<div className={styles.actions}>
<button
type="button"
className={`outline-btn ${styles.secondaryBtn}`}
disabled={working}
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
className={`outline-btn ${styles.primaryBtn}`}
disabled={working || (!imageUrl && !pendingFile)}
>
{working ? "…" : "Save"}
</button>
</div>
</form>
</div>
</div>
<ImageCropDialog
open={cropOpen}
imageSrc={cropSrc}
title={`Crop · ${resolution}`}
aspect={aspect}
outputType="image/png"
fileName={`${sectionLabel.toLowerCase().replace(/\s+/g, "-")}-image`}
onCancel={() => {
if (cropSrc?.startsWith("blob:")) URL.revokeObjectURL(cropSrc);
setCropOpen(false);
setCropSrc(null);
}}
onComplete={(file, previewUrl) => {
if (cropSrc?.startsWith("blob:")) URL.revokeObjectURL(cropSrc);
setPendingFile(file);
setImageUrl(previewUrl);
setCropOpen(false);
setCropSrc(null);
}}
/>
</>
);
}