EventBuzz_Console/src/app/components/dashboard/SocialShareManagePanel.tsx

891 lines
32 KiB
TypeScript
Raw 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.

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>
);
}