Initial commit: dashboard
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,829 @@
|
||||
"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"
|
||||
| "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: "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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user