891 lines
32 KiB
TypeScript
891 lines
32 KiB
TypeScript
import React, { useCallback, useEffect, useState } from "react";
|
||
import { Check, Copy, Image, Link2, Trash2, Type } from "lucide-react";
|
||
import { PanelShell, Label, Field, Grid, IS, SS, focusIn, focusOut, HelperText, RadioGroup } from "../panels/PanelShell";
|
||
import {
|
||
socialShareApi,
|
||
uploadMediaFile,
|
||
type SocialShareConfig,
|
||
type SocialShareEventHeader,
|
||
type SocialShareFrame,
|
||
} from "../../../services/api";
|
||
import { socialShareVisitorUrl } from "../../utils/socialShareUrls";
|
||
import {
|
||
EVENT_HEADER_TOGGLE_LABELS,
|
||
eventHeaderFromApi,
|
||
eventHeaderToApi,
|
||
type EventHeaderToggleKey,
|
||
} from "../../utils/formEventHeaderDisplay";
|
||
import { PhotoWallFrameDesigner } from "./PhotoWallFrameDesigner";
|
||
import { PhotoWallFrameInspector } from "./PhotoWallFrameInspector";
|
||
import {
|
||
defaultCustomTextElement,
|
||
defaultFrameImageLayout,
|
||
defaultPhotoElement,
|
||
normalizeFrameImageLayout,
|
||
normalizeFrameLayout,
|
||
type PhotoWallFrameElement,
|
||
type PhotoWallFrameImageLayout,
|
||
} from "./photoWallFrameLayout";
|
||
|
||
type Tab = "settings" | "frames" | "links";
|
||
|
||
const DEFAULT_HEADER = eventHeaderFromApi(null);
|
||
|
||
export function SocialShareManagePanel({
|
||
eventId,
|
||
eventName,
|
||
canWrite,
|
||
onClose,
|
||
onChanged,
|
||
}: {
|
||
eventId: string;
|
||
eventName?: string;
|
||
canWrite: boolean;
|
||
onClose: () => void;
|
||
onChanged?: () => void;
|
||
}) {
|
||
const [tab, setTab] = useState<Tab>("settings");
|
||
const [loading, setLoading] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [config, setConfig] = useState<SocialShareConfig | null>(null);
|
||
const [frames, setFrames] = useState<SocialShareFrame[]>([]);
|
||
const [copied, setCopied] = useState(false);
|
||
const [editingFrameId, setEditingFrameId] = useState<string | null>(null);
|
||
const [selectedElId, setSelectedElId] = useState<string | null>(null);
|
||
const [uploadingFrame, setUploadingFrame] = useState(false);
|
||
const [frameDraft, setFrameDraft] = useState({
|
||
name: "Frame",
|
||
enabled: true,
|
||
frameImageUrl: "" as string,
|
||
frameImageLayout: defaultFrameImageLayout() as PhotoWallFrameImageLayout,
|
||
frameCanvasWidth: 1080,
|
||
frameCanvasHeight: 1080,
|
||
frameLayout: [] as PhotoWallFrameElement[],
|
||
});
|
||
|
||
const [draft, setDraft] = useState({
|
||
accessMode: "open" as "open" | "login",
|
||
requireUploadCrop: false,
|
||
maxPhotoWidth: 1080,
|
||
maxPhotoHeight: 1080,
|
||
allowDownload: true,
|
||
eventHeader: DEFAULT_HEADER,
|
||
sharePlatforms: {
|
||
linkedin: false,
|
||
facebook: false,
|
||
instagram: false,
|
||
snapchat: false,
|
||
x: false,
|
||
whatsapp: false,
|
||
},
|
||
});
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const res = await socialShareApi.get(eventId);
|
||
if (res.error || !res.data) {
|
||
setError(res.error || "Failed to load social share");
|
||
setConfig(null);
|
||
setFrames([]);
|
||
return;
|
||
}
|
||
const data = res.data;
|
||
setConfig(data);
|
||
setFrames(data.frames || []);
|
||
setDraft({
|
||
accessMode: data.accessMode || "open",
|
||
requireUploadCrop: Boolean(data.requireUploadCrop),
|
||
maxPhotoWidth: data.maxPhotoWidth ?? 1080,
|
||
maxPhotoHeight: data.maxPhotoHeight ?? 1080,
|
||
allowDownload: data.allowDownload !== false,
|
||
eventHeader: eventHeaderFromApi(data.eventHeader as SocialShareEventHeader | null),
|
||
sharePlatforms: {
|
||
linkedin: Boolean(data.sharePlatforms?.linkedin),
|
||
facebook: Boolean(data.sharePlatforms?.facebook),
|
||
instagram: Boolean(data.sharePlatforms?.instagram),
|
||
snapchat: Boolean(data.sharePlatforms?.snapchat),
|
||
x: Boolean(data.sharePlatforms?.x),
|
||
whatsapp: Boolean(data.sharePlatforms?.whatsapp),
|
||
},
|
||
});
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Failed to load");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [eventId]);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
const openFrameEditor = (frame: SocialShareFrame) => {
|
||
setEditingFrameId(frame._id);
|
||
setSelectedElId(null);
|
||
setFrameDraft({
|
||
name: frame.name || "Frame",
|
||
enabled: frame.enabled !== false,
|
||
frameImageUrl: frame.frameImageUrl || "",
|
||
frameImageLayout: normalizeFrameImageLayout(frame.frameImageLayout || defaultFrameImageLayout()),
|
||
frameCanvasWidth: frame.frameCanvasWidth ?? 1080,
|
||
frameCanvasHeight: frame.frameCanvasHeight ?? 1080,
|
||
frameLayout: normalizeFrameLayout(
|
||
frame.frameLayout,
|
||
frame.frameCanvasWidth ?? 1080,
|
||
frame.frameCanvasHeight ?? 1080,
|
||
),
|
||
});
|
||
};
|
||
|
||
const saveSettings = async () => {
|
||
if (!canWrite) return;
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
const res = await socialShareApi.update(eventId, {
|
||
accessMode: draft.accessMode,
|
||
requireUploadCrop: draft.requireUploadCrop,
|
||
maxPhotoWidth: draft.maxPhotoWidth,
|
||
maxPhotoHeight: draft.maxPhotoHeight,
|
||
allowDownload: draft.allowDownload,
|
||
sharePlatforms: draft.sharePlatforms,
|
||
eventHeader: eventHeaderToApi(draft.eventHeader),
|
||
});
|
||
if (res.error || !res.data) {
|
||
setError(res.error || "Failed to save");
|
||
return;
|
||
}
|
||
setConfig(res.data);
|
||
setFrames(res.data.frames || []);
|
||
onChanged?.();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Failed to save");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const saveFrame = async () => {
|
||
if (!canWrite || !editingFrameId) return;
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
const res = await socialShareApi.updateFrame(eventId, editingFrameId, {
|
||
name: frameDraft.name,
|
||
enabled: frameDraft.enabled,
|
||
frameImageUrl: frameDraft.frameImageUrl || null,
|
||
frameImageLayout: frameDraft.frameImageLayout,
|
||
frameCanvasWidth: frameDraft.frameCanvasWidth,
|
||
frameCanvasHeight: frameDraft.frameCanvasHeight,
|
||
frameLayout: frameDraft.frameLayout,
|
||
});
|
||
if (res.error || !res.data) {
|
||
setError(res.error || "Failed to save frame");
|
||
return;
|
||
}
|
||
setFrames((prev) => prev.map((f) => (f._id === res.data!._id ? res.data! : f)));
|
||
onChanged?.();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Failed to save frame");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const addFrame = async () => {
|
||
if (!canWrite) return;
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
const res = await socialShareApi.createFrame(eventId, {
|
||
name: `Frame ${frames.length + 1}`,
|
||
frameLayout: [defaultPhotoElement(1080, 1080)],
|
||
});
|
||
if (res.error || !res.data) {
|
||
setError(res.error || "Failed to create frame");
|
||
return;
|
||
}
|
||
setFrames((prev) => [...prev, res.data!]);
|
||
openFrameEditor(res.data);
|
||
onChanged?.();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Failed to create frame");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const deleteFrame = async (frameId: string) => {
|
||
if (!canWrite) return;
|
||
if (!window.confirm("Delete this frame?")) return;
|
||
setError(null);
|
||
try {
|
||
await socialShareApi.deleteFrame(eventId, frameId);
|
||
setFrames((prev) => prev.filter((f) => f._id !== frameId));
|
||
if (editingFrameId === frameId) setEditingFrameId(null);
|
||
onChanged?.();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Failed to delete");
|
||
}
|
||
};
|
||
|
||
const uploadFrameImage = async (file: File | null) => {
|
||
if (!file || !canWrite) return;
|
||
setUploadingFrame(true);
|
||
setError(null);
|
||
try {
|
||
const r = await uploadMediaFile(file, { eventId, module: "social-share/frames" });
|
||
const url = r.url || "";
|
||
if (!url) throw new Error("Upload succeeded but no URL returned");
|
||
setFrameDraft((d) => ({ ...d, frameImageUrl: url }));
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Frame upload failed");
|
||
} finally {
|
||
setUploadingFrame(false);
|
||
}
|
||
};
|
||
|
||
const addFrameElement = (el: PhotoWallFrameElement) => {
|
||
setFrameDraft((d) => ({ ...d, frameLayout: [...d.frameLayout, el] }));
|
||
setSelectedElId(el.id);
|
||
};
|
||
|
||
const copyLink = async () => {
|
||
if (!config?.uploadToken) return;
|
||
try {
|
||
await navigator.clipboard.writeText(socialShareVisitorUrl(config.uploadToken));
|
||
setCopied(true);
|
||
window.setTimeout(() => setCopied(false), 1500);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
};
|
||
|
||
const tabBtn = (id: Tab, label: string) => (
|
||
<button
|
||
key={id}
|
||
type="button"
|
||
onClick={() => {
|
||
setTab(id);
|
||
if (id !== "frames") setEditingFrameId(null);
|
||
}}
|
||
style={{
|
||
flex: 1,
|
||
padding: "8px 10px",
|
||
border: "none",
|
||
borderBottom: tab === id ? "2px solid var(--primary)" : "2px solid transparent",
|
||
background: "transparent",
|
||
color: tab === id ? "var(--primary)" : "var(--muted)",
|
||
fontFamily: "var(--font-noto-sans)",
|
||
fontSize: "var(--text-sm)",
|
||
fontWeight: tab === id ? 600 : 400,
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
|
||
const ctaLabel =
|
||
tab === "frames" && editingFrameId ? "Save frame" : tab === "settings" ? "Save settings" : "Save";
|
||
const showCta = tab === "settings" || (tab === "frames" && Boolean(editingFrameId));
|
||
|
||
return (
|
||
<PanelShell
|
||
onClose={onClose}
|
||
title="Social Share"
|
||
breadcrumb={eventName ? `Event Home · ${eventName}` : "Event Home"}
|
||
description="Configure frames and visitor share link"
|
||
completedCount={0}
|
||
totalSections={0}
|
||
ctaLabel={ctaLabel}
|
||
showProgress={false}
|
||
hidePrimaryCta={!showCta}
|
||
ctaDisabled={!canWrite || saving || loading}
|
||
onCtaClick={() => {
|
||
if (tab === "frames" && editingFrameId) void saveFrame();
|
||
else void saveSettings();
|
||
}}
|
||
panelWidth={tab === "frames" && editingFrameId ? "min(960px,100vw)" : "min(720px,100vw)"}
|
||
>
|
||
<div style={{ display: "flex", borderBottom: "1px solid var(--border)", padding: "0 12px" }}>
|
||
{tabBtn("settings", "Settings")}
|
||
{tabBtn("frames", "Frames")}
|
||
{tabBtn("links", "Links")}
|
||
</div>
|
||
|
||
<div style={{ padding: "16px 20px", display: "flex", flexDirection: "column", gap: "14px" }}>
|
||
{error ? (
|
||
<p style={{ margin: 0, color: "var(--destructive)", fontSize: "var(--text-sm)" }}>{error}</p>
|
||
) : null}
|
||
{loading ? (
|
||
<p style={{ margin: 0, color: "var(--muted)", fontSize: "var(--text-sm)" }}>Loading…</p>
|
||
) : null}
|
||
|
||
{!loading && tab === "links" && config ? (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
||
<div
|
||
style={{
|
||
border: "1px solid var(--border)",
|
||
borderRadius: "var(--radius-card)",
|
||
padding: "12px",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: "8px",
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||
<Link2 size={16} color="var(--primary)" />
|
||
<strong style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-inter)" }}>
|
||
Visitor upload URL
|
||
</strong>
|
||
</div>
|
||
<p style={{ margin: 0, fontSize: "var(--text-xs)", color: "var(--muted)" }}>
|
||
Guests pick a frame, upload a photo, then download or share
|
||
</p>
|
||
<code
|
||
style={{
|
||
fontSize: "var(--text-xs)",
|
||
wordBreak: "break-all",
|
||
color: "var(--foreground)",
|
||
background: "var(--muted-bg, #f5f5f5)",
|
||
padding: "8px",
|
||
borderRadius: "6px",
|
||
}}
|
||
>
|
||
{config.uploadToken
|
||
? socialShareVisitorUrl(config.uploadToken)
|
||
: "Enable social share to generate link"}
|
||
</code>
|
||
<button
|
||
type="button"
|
||
disabled={!config.uploadToken}
|
||
onClick={() => void copyLink()}
|
||
style={{
|
||
alignSelf: "flex-start",
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
padding: "8px 12px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "1px solid var(--border)",
|
||
background: "var(--card)",
|
||
cursor: config.uploadToken ? "pointer" : "not-allowed",
|
||
fontSize: "var(--text-sm)",
|
||
opacity: config.uploadToken ? 1 : 0.55,
|
||
}}
|
||
>
|
||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||
{copied ? "Copied" : "Copy"}
|
||
</button>
|
||
</div>
|
||
{canWrite ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
void socialShareApi.update(eventId, { regenerateTokens: true }).then((res) => {
|
||
if (res.data) {
|
||
setConfig(res.data);
|
||
onChanged?.();
|
||
}
|
||
});
|
||
}}
|
||
style={{
|
||
alignSelf: "flex-start",
|
||
padding: "8px 12px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "1px solid var(--border)",
|
||
background: "var(--card)",
|
||
cursor: "pointer",
|
||
fontSize: "var(--text-sm)",
|
||
color: "var(--muted)",
|
||
}}
|
||
>
|
||
Regenerate token
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{!loading && tab === "settings" ? (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
||
<Field>
|
||
<Label text="Access mode" />
|
||
<select
|
||
disabled={!canWrite}
|
||
value={draft.accessMode}
|
||
onChange={(e) => setDraft((d) => ({ ...d, accessMode: e.target.value as "open" | "login" }))}
|
||
style={SS}
|
||
onFocus={focusIn}
|
||
onBlur={focusOut}
|
||
>
|
||
<option value="open">Open (anyone with link)</option>
|
||
<option value="login">Login required</option>
|
||
</select>
|
||
</Field>
|
||
|
||
<Field>
|
||
<Label text="Event header on visitor page" />
|
||
<Grid cols={2}>
|
||
{(Object.keys(EVENT_HEADER_TOGGLE_LABELS) as EventHeaderToggleKey[]).map((key) => (
|
||
<Field key={key}>
|
||
<Label text={EVENT_HEADER_TOGGLE_LABELS[key]} />
|
||
<RadioGroup
|
||
name={`ss-header-${key}`}
|
||
value={draft.eventHeader[key]}
|
||
onChange={(v) =>
|
||
setDraft((d) => ({
|
||
...d,
|
||
eventHeader: { ...d.eventHeader, [key]: v as "Yes" | "No" },
|
||
}))
|
||
}
|
||
options={[
|
||
{ label: "Show", value: "Yes" },
|
||
{ label: "Hide", value: "No" },
|
||
]}
|
||
/>
|
||
</Field>
|
||
))}
|
||
</Grid>
|
||
</Field>
|
||
|
||
<Grid cols={2}>
|
||
<Field>
|
||
<Label text="Max photo width (px)" />
|
||
<input
|
||
type="number"
|
||
min={80}
|
||
max={1200}
|
||
disabled={!canWrite}
|
||
value={draft.maxPhotoWidth}
|
||
onChange={(e) => setDraft((d) => ({ ...d, maxPhotoWidth: Number(e.target.value) || 1080 }))}
|
||
style={IS}
|
||
onFocus={focusIn}
|
||
onBlur={focusOut}
|
||
/>
|
||
</Field>
|
||
<Field>
|
||
<Label text="Max photo height (px)" />
|
||
<input
|
||
type="number"
|
||
min={80}
|
||
max={1200}
|
||
disabled={!canWrite}
|
||
value={draft.maxPhotoHeight}
|
||
onChange={(e) => setDraft((d) => ({ ...d, maxPhotoHeight: Number(e.target.value) || 1080 }))}
|
||
style={IS}
|
||
onFocus={focusIn}
|
||
onBlur={focusOut}
|
||
/>
|
||
</Field>
|
||
</Grid>
|
||
|
||
<Field>
|
||
<Label text="Require crop to match ratio" />
|
||
<HelperText text="If the photo aspect ratio does not match max width ÷ height, visitors must crop before upload." />
|
||
<RadioGroup
|
||
name="ss-require-crop"
|
||
value={draft.requireUploadCrop ? "Yes" : "No"}
|
||
onChange={(v) => setDraft((d) => ({ ...d, requireUploadCrop: v === "Yes" }))}
|
||
options={[
|
||
{ label: "Yes", value: "Yes" },
|
||
{ label: "No", value: "No" },
|
||
]}
|
||
/>
|
||
</Field>
|
||
|
||
<Field>
|
||
<Label text="Allow visitors to download their photo" />
|
||
<RadioGroup
|
||
name="ss-allow-download"
|
||
value={draft.allowDownload ? "Yes" : "No"}
|
||
onChange={(v) => setDraft((d) => ({ ...d, allowDownload: v === "Yes" }))}
|
||
options={[
|
||
{ label: "Yes", value: "Yes" },
|
||
{ label: "No", value: "No" },
|
||
]}
|
||
/>
|
||
</Field>
|
||
|
||
<Field>
|
||
<Label text="Share on social platforms" />
|
||
<HelperText text="Shown after upload when at least one platform is enabled." />
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px" }}>
|
||
{(
|
||
[
|
||
["linkedin", "LinkedIn"],
|
||
["facebook", "Facebook"],
|
||
["instagram", "Instagram"],
|
||
["snapchat", "Snapchat"],
|
||
["x", "X (Twitter)"],
|
||
["whatsapp", "WhatsApp"],
|
||
] as const
|
||
).map(([key, label]) => (
|
||
<label
|
||
key={key}
|
||
style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)" }}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
disabled={!canWrite}
|
||
checked={draft.sharePlatforms[key]}
|
||
onChange={(e) =>
|
||
setDraft((d) => ({
|
||
...d,
|
||
sharePlatforms: { ...d.sharePlatforms, [key]: e.target.checked },
|
||
}))
|
||
}
|
||
/>
|
||
{label}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</Field>
|
||
</div>
|
||
) : null}
|
||
|
||
{!loading && tab === "frames" ? (
|
||
editingFrameId ? (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setEditingFrameId(null)}
|
||
style={{
|
||
alignSelf: "flex-start",
|
||
border: "none",
|
||
background: "transparent",
|
||
color: "var(--primary)",
|
||
cursor: "pointer",
|
||
fontSize: "var(--text-sm)",
|
||
padding: 0,
|
||
}}
|
||
>
|
||
← Back to frames
|
||
</button>
|
||
|
||
<Field>
|
||
<Label text="Frame name" />
|
||
<input
|
||
disabled={!canWrite}
|
||
value={frameDraft.name}
|
||
onChange={(e) => setFrameDraft((d) => ({ ...d, name: e.target.value }))}
|
||
style={IS}
|
||
onFocus={focusIn}
|
||
onBlur={focusOut}
|
||
/>
|
||
</Field>
|
||
|
||
<Field>
|
||
<Label text="Enabled for visitors" />
|
||
<RadioGroup
|
||
name="ss-frame-enabled"
|
||
value={frameDraft.enabled ? "Yes" : "No"}
|
||
onChange={(v) => setFrameDraft((d) => ({ ...d, enabled: v === "Yes" }))}
|
||
options={[
|
||
{ label: "Yes", value: "Yes" },
|
||
{ label: "No", value: "No" },
|
||
]}
|
||
/>
|
||
</Field>
|
||
|
||
<Grid cols={2}>
|
||
<Field>
|
||
<Label text="Canvas width (px)" />
|
||
<input
|
||
type="number"
|
||
min={200}
|
||
max={4096}
|
||
disabled={!canWrite}
|
||
value={frameDraft.frameCanvasWidth}
|
||
onChange={(e) =>
|
||
setFrameDraft((d) => ({ ...d, frameCanvasWidth: Number(e.target.value) || 1080 }))
|
||
}
|
||
style={IS}
|
||
onFocus={focusIn}
|
||
onBlur={focusOut}
|
||
/>
|
||
</Field>
|
||
<Field>
|
||
<Label text="Canvas height (px)" />
|
||
<input
|
||
type="number"
|
||
min={200}
|
||
max={4096}
|
||
disabled={!canWrite}
|
||
value={frameDraft.frameCanvasHeight}
|
||
onChange={(e) =>
|
||
setFrameDraft((d) => ({ ...d, frameCanvasHeight: Number(e.target.value) || 1080 }))
|
||
}
|
||
style={IS}
|
||
onFocus={focusIn}
|
||
onBlur={focusOut}
|
||
/>
|
||
</Field>
|
||
</Grid>
|
||
|
||
<Field>
|
||
<Label text="Frame image" />
|
||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
||
<label
|
||
style={{
|
||
padding: "8px 12px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "1px solid var(--border)",
|
||
background: "var(--card)",
|
||
cursor: canWrite ? "pointer" : "not-allowed",
|
||
fontSize: "var(--text-sm)",
|
||
}}
|
||
>
|
||
{uploadingFrame ? "Uploading…" : frameDraft.frameImageUrl ? "Replace frame" : "Upload frame"}
|
||
<input
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
hidden
|
||
disabled={!canWrite || uploadingFrame}
|
||
onChange={(e) => void uploadFrameImage(e.target.files?.[0] ?? null)}
|
||
/>
|
||
</label>
|
||
{frameDraft.frameImageUrl ? (
|
||
<button
|
||
type="button"
|
||
disabled={!canWrite}
|
||
onClick={() => setFrameDraft((d) => ({ ...d, frameImageUrl: "" }))}
|
||
style={{
|
||
padding: "8px 12px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "1px solid var(--border)",
|
||
background: "transparent",
|
||
cursor: "pointer",
|
||
fontSize: "var(--text-sm)",
|
||
}}
|
||
>
|
||
Clear
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</Field>
|
||
|
||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||
<button
|
||
type="button"
|
||
disabled={!canWrite}
|
||
onClick={() =>
|
||
addFrameElement(
|
||
defaultPhotoElement(frameDraft.frameCanvasWidth, frameDraft.frameCanvasHeight),
|
||
)
|
||
}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
padding: "8px 12px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "1px solid var(--border)",
|
||
background: "var(--card)",
|
||
cursor: canWrite ? "pointer" : "not-allowed",
|
||
fontSize: "var(--text-sm)",
|
||
}}
|
||
>
|
||
<Image size={14} /> Photo slot
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!canWrite}
|
||
onClick={() =>
|
||
addFrameElement(
|
||
defaultCustomTextElement(frameDraft.frameCanvasWidth, frameDraft.frameCanvasHeight),
|
||
)
|
||
}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
padding: "8px 12px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "1px solid var(--border)",
|
||
background: "var(--card)",
|
||
cursor: canWrite ? "pointer" : "not-allowed",
|
||
fontSize: "var(--text-sm)",
|
||
}}
|
||
>
|
||
<Type size={14} /> Custom text
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "minmax(0,1fr) minmax(200px,260px)",
|
||
gap: 16,
|
||
alignItems: "start",
|
||
}}
|
||
>
|
||
<PhotoWallFrameDesigner
|
||
canvasWidth={frameDraft.frameCanvasWidth}
|
||
canvasHeight={frameDraft.frameCanvasHeight}
|
||
frameImageUrl={frameDraft.frameImageUrl}
|
||
frameImageLayout={frameDraft.frameImageLayout}
|
||
layoutElements={frameDraft.frameLayout}
|
||
selectedId={selectedElId}
|
||
onSelect={setSelectedElId}
|
||
onLayoutChange={(next) => setFrameDraft((d) => ({ ...d, frameLayout: next }))}
|
||
onFrameImageLayoutChange={(next) =>
|
||
setFrameDraft((d) => ({ ...d, frameImageLayout: next }))
|
||
}
|
||
/>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||
<PhotoWallFrameInspector
|
||
element={frameDraft.frameLayout.find((e) => e.id === selectedElId) || null}
|
||
disabled={!canWrite}
|
||
canvasWidth={frameDraft.frameCanvasWidth}
|
||
canvasHeight={frameDraft.frameCanvasHeight}
|
||
onChange={(patch) => {
|
||
if (!selectedElId) return;
|
||
setFrameDraft((d) => ({
|
||
...d,
|
||
frameLayout: d.frameLayout.map((el) =>
|
||
el.id === selectedElId ? { ...el, ...patch } : el,
|
||
),
|
||
}));
|
||
}}
|
||
/>
|
||
{selectedElId ? (
|
||
<button
|
||
type="button"
|
||
disabled={!canWrite}
|
||
onClick={() => {
|
||
setFrameDraft((d) => ({
|
||
...d,
|
||
frameLayout: d.frameLayout.filter((el) => el.id !== selectedElId),
|
||
}));
|
||
setSelectedElId(null);
|
||
}}
|
||
style={{
|
||
padding: "8px 12px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "1px solid var(--destructive)",
|
||
background: "transparent",
|
||
color: "var(--destructive)",
|
||
cursor: "pointer",
|
||
fontSize: "var(--text-sm)",
|
||
}}
|
||
>
|
||
Remove selected layer
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||
<HelperText text="Visitors choose one of these frames before uploading their photo." />
|
||
{canWrite ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => void addFrame()}
|
||
disabled={saving}
|
||
style={{
|
||
alignSelf: "flex-start",
|
||
padding: "8px 12px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "none",
|
||
background: "var(--primary)",
|
||
color: "var(--primary-foreground)",
|
||
cursor: "pointer",
|
||
fontSize: "var(--text-sm)",
|
||
}}
|
||
>
|
||
Add frame
|
||
</button>
|
||
) : null}
|
||
{frames.length === 0 ? (
|
||
<p style={{ margin: 0, color: "var(--muted)", fontSize: "var(--text-sm)" }}>
|
||
No frames yet. Add one to get started.
|
||
</p>
|
||
) : (
|
||
frames.map((frame) => (
|
||
<div
|
||
key={frame._id}
|
||
style={{
|
||
border: "1px solid var(--border)",
|
||
borderRadius: "var(--radius-card)",
|
||
padding: "12px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: 56,
|
||
height: 56,
|
||
borderRadius: 8,
|
||
overflow: "hidden",
|
||
background: "var(--muted-bg, #f5f5f5)",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
{frame.frameImageUrl ? (
|
||
<img
|
||
src={frame.frameImageUrl}
|
||
alt=""
|
||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<strong style={{ fontSize: "var(--text-sm)" }}>{frame.name || "Frame"}</strong>
|
||
<p style={{ margin: "4px 0 0", fontSize: "var(--text-xs)", color: "var(--muted)" }}>
|
||
{frame.enabled !== false ? "Enabled" : "Disabled"} · {frame.frameCanvasWidth}×
|
||
{frame.frameCanvasHeight}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => openFrameEditor(frame)}
|
||
style={{
|
||
padding: "8px 12px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "1px solid var(--border)",
|
||
background: "var(--card)",
|
||
cursor: "pointer",
|
||
fontSize: "var(--text-sm)",
|
||
}}
|
||
>
|
||
Edit
|
||
</button>
|
||
{canWrite ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => void deleteFrame(frame._id)}
|
||
aria-label="Delete frame"
|
||
style={{
|
||
width: 36,
|
||
height: 36,
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "1px solid var(--border)",
|
||
background: "var(--card)",
|
||
cursor: "pointer",
|
||
color: "var(--destructive)",
|
||
}}
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
)
|
||
) : null}
|
||
</div>
|
||
</PanelShell>
|
||
);
|
||
}
|