839 lines
24 KiB
TypeScript
839 lines
24 KiB
TypeScript
"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);
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|