662 lines
23 KiB
TypeScript
662 lines
23 KiB
TypeScript
import React, { useCallback, useEffect, useState } from "react";
|
|
import { Check, Copy, Link2, Trash2 } from "lucide-react";
|
|
import { PanelShell, Label, Field, Grid, IS, SS, focusIn, focusOut, HelperText, RadioGroup } from "../panels/PanelShell";
|
|
import {
|
|
aiAvatarApi,
|
|
uploadMediaFile,
|
|
type AiAvatarConfig,
|
|
type AiAvatarEventHeader,
|
|
type AiAvatarTemplate,
|
|
} from "../../../services/api";
|
|
import { aiAvatarVisitorUrl } from "../../utils/aiAvatarUrls";
|
|
import {
|
|
EVENT_HEADER_TOGGLE_LABELS,
|
|
eventHeaderFromApi,
|
|
eventHeaderToApi,
|
|
type EventHeaderToggleKey,
|
|
} from "../../utils/formEventHeaderDisplay";
|
|
|
|
type Tab = "settings" | "avatars" | "links";
|
|
|
|
const DEFAULT_HEADER = eventHeaderFromApi(null);
|
|
|
|
export function AiAvatarManagePanel({
|
|
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<AiAvatarConfig | null>(null);
|
|
const [avatars, setAvatars] = useState<AiAvatarTemplate[]>([]);
|
|
const [copied, setCopied] = useState(false);
|
|
const [editingAvatarId, setEditingAvatarId] = useState<string | null>(null);
|
|
const [uploadingImage, setUploadingImage] = useState(false);
|
|
const [avatarDraft, setAvatarDraft] = useState({
|
|
name: "Avatar",
|
|
enabled: true,
|
|
imageUrl: "" as string,
|
|
});
|
|
|
|
const [draft, setDraft] = useState({
|
|
accessMode: "open" as "open" | "login",
|
|
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 aiAvatarApi.get(eventId);
|
|
if (res.error || !res.data) {
|
|
setError(res.error || "Failed to load AI Avatar");
|
|
setConfig(null);
|
|
setAvatars([]);
|
|
return;
|
|
}
|
|
const data = res.data;
|
|
setConfig(data);
|
|
setAvatars(data.avatars || []);
|
|
setDraft({
|
|
accessMode: data.accessMode || "open",
|
|
allowDownload: data.allowDownload !== false,
|
|
eventHeader: eventHeaderFromApi(data.eventHeader as AiAvatarEventHeader | 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 openAvatarEditor = (avatar: AiAvatarTemplate) => {
|
|
setEditingAvatarId(avatar._id);
|
|
setAvatarDraft({
|
|
name: avatar.name || "Avatar",
|
|
enabled: avatar.enabled !== false,
|
|
imageUrl: avatar.imageUrl || "",
|
|
});
|
|
};
|
|
|
|
const saveSettings = async () => {
|
|
if (!canWrite) return;
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const res = await aiAvatarApi.update(eventId, {
|
|
accessMode: draft.accessMode,
|
|
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);
|
|
setAvatars(res.data.avatars || []);
|
|
onChanged?.();
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Failed to save");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const saveAvatar = async () => {
|
|
if (!canWrite || !editingAvatarId) return;
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const res = await aiAvatarApi.updateAvatar(eventId, editingAvatarId, {
|
|
name: avatarDraft.name,
|
|
enabled: avatarDraft.enabled,
|
|
imageUrl: avatarDraft.imageUrl || null,
|
|
});
|
|
if (res.error || !res.data) {
|
|
setError(res.error || "Failed to save avatar");
|
|
return;
|
|
}
|
|
setAvatars((prev) => prev.map((a) => (a._id === res.data!._id ? res.data! : a)));
|
|
onChanged?.();
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Failed to save avatar");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const addAvatar = async () => {
|
|
if (!canWrite) return;
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const res = await aiAvatarApi.createAvatar(eventId, {
|
|
name: `Avatar ${avatars.length + 1}`,
|
|
});
|
|
if (res.error || !res.data) {
|
|
setError(res.error || "Failed to create avatar");
|
|
return;
|
|
}
|
|
setAvatars((prev) => [...prev, res.data!]);
|
|
openAvatarEditor(res.data);
|
|
onChanged?.();
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Failed to create avatar");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const deleteAvatar = async (avatarId: string) => {
|
|
if (!canWrite) return;
|
|
if (!window.confirm("Delete this avatar?")) return;
|
|
setError(null);
|
|
try {
|
|
await aiAvatarApi.deleteAvatar(eventId, avatarId);
|
|
setAvatars((prev) => prev.filter((a) => a._id !== avatarId));
|
|
if (editingAvatarId === avatarId) setEditingAvatarId(null);
|
|
onChanged?.();
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Failed to delete");
|
|
}
|
|
};
|
|
|
|
const uploadAvatarImage = async (file: File | null) => {
|
|
if (!file || !canWrite) return;
|
|
setUploadingImage(true);
|
|
setError(null);
|
|
try {
|
|
const r = await uploadMediaFile(file, { eventId, module: "ai-avatar/avatars" });
|
|
const url = r.url || "";
|
|
if (!url) throw new Error("Upload succeeded but no URL returned");
|
|
setAvatarDraft((d) => ({ ...d, imageUrl: url }));
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Avatar upload failed");
|
|
} finally {
|
|
setUploadingImage(false);
|
|
}
|
|
};
|
|
|
|
const copyLink = async () => {
|
|
if (!config?.uploadToken) return;
|
|
try {
|
|
await navigator.clipboard.writeText(aiAvatarVisitorUrl(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 !== "avatars") setEditingAvatarId(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 === "avatars" && editingAvatarId ? "Save avatar" : tab === "settings" ? "Save settings" : "Save";
|
|
const showCta = tab === "settings" || (tab === "avatars" && Boolean(editingAvatarId));
|
|
|
|
return (
|
|
<PanelShell
|
|
onClose={onClose}
|
|
title="AI Avatar"
|
|
breadcrumb={eventName ? `Event Home · ${eventName}` : "Event Home"}
|
|
description="Configure avatars and visitor AI swap link"
|
|
completedCount={0}
|
|
totalSections={0}
|
|
ctaLabel={ctaLabel}
|
|
showProgress={false}
|
|
hidePrimaryCta={!showCta}
|
|
ctaDisabled={!canWrite || saving || loading}
|
|
onCtaClick={() => {
|
|
if (tab === "avatars" && editingAvatarId) void saveAvatar();
|
|
else void saveSettings();
|
|
}}
|
|
panelWidth="min(720px,100vw)"
|
|
>
|
|
<div style={{ display: "flex", borderBottom: "1px solid var(--border)", padding: "0 12px" }}>
|
|
{tabBtn("settings", "Settings")}
|
|
{tabBtn("avatars", "Avatars")}
|
|
{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 an avatar, upload a selfie, 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
|
|
? aiAvatarVisitorUrl(config.uploadToken)
|
|
: "Enable AI Avatar 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 aiAvatarApi.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={`aa-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>
|
|
|
|
<Field>
|
|
<Label text="Allow visitors to download their photo" />
|
|
<RadioGroup
|
|
name="aa-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 === "avatars" ? (
|
|
editingAvatarId ? (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditingAvatarId(null)}
|
|
style={{
|
|
alignSelf: "flex-start",
|
|
border: "none",
|
|
background: "transparent",
|
|
color: "var(--primary)",
|
|
cursor: "pointer",
|
|
fontSize: "var(--text-sm)",
|
|
padding: 0,
|
|
}}
|
|
>
|
|
← Back to avatars
|
|
</button>
|
|
|
|
<Field>
|
|
<Label text="Avatar name" />
|
|
<input
|
|
disabled={!canWrite}
|
|
value={avatarDraft.name}
|
|
onChange={(e) => setAvatarDraft((d) => ({ ...d, name: e.target.value }))}
|
|
style={IS}
|
|
onFocus={focusIn}
|
|
onBlur={focusOut}
|
|
/>
|
|
</Field>
|
|
|
|
<Field>
|
|
<Label text="Enabled for visitors" />
|
|
<RadioGroup
|
|
name="aa-avatar-enabled"
|
|
value={avatarDraft.enabled ? "Yes" : "No"}
|
|
onChange={(v) => setAvatarDraft((d) => ({ ...d, enabled: v === "Yes" }))}
|
|
options={[
|
|
{ label: "Yes", value: "Yes" },
|
|
{ label: "No", value: "No" },
|
|
]}
|
|
/>
|
|
</Field>
|
|
|
|
<Field>
|
|
<Label text="Avatar image" />
|
|
<HelperText text="Upload the template image used for AI head swap (face from selfie is placed onto this image)." />
|
|
{avatarDraft.imageUrl ? (
|
|
<div
|
|
style={{
|
|
width: 160,
|
|
height: 160,
|
|
borderRadius: 8,
|
|
border: "1px solid var(--border)",
|
|
overflow: "hidden",
|
|
background: "var(--muted-bg, #f5f5f5)",
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
<img
|
|
src={avatarDraft.imageUrl}
|
|
alt={avatarDraft.name}
|
|
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
disabled={!canWrite || uploadingImage}
|
|
onChange={(e) => void uploadAvatarImage(e.target.files?.[0] || null)}
|
|
/>
|
|
{uploadingImage ? (
|
|
<p style={{ margin: "6px 0 0", fontSize: "var(--text-xs)", color: "var(--muted)" }}>
|
|
Uploading…
|
|
</p>
|
|
) : null}
|
|
</Field>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--muted)" }}>
|
|
{avatars.length} avatar{avatars.length === 1 ? "" : "s"}
|
|
</p>
|
|
{canWrite ? (
|
|
<button
|
|
type="button"
|
|
disabled={saving}
|
|
onClick={() => void addAvatar()}
|
|
style={{
|
|
padding: "8px 12px",
|
|
borderRadius: "var(--radius-button)",
|
|
border: "1px solid var(--border)",
|
|
background: "var(--primary)",
|
|
color: "var(--primary-foreground, #fff)",
|
|
cursor: "pointer",
|
|
fontSize: "var(--text-sm)",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
Add avatar
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
{!avatars.length ? (
|
|
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--muted)" }}>
|
|
No avatars yet. Add an image template for visitors to choose.
|
|
</p>
|
|
) : (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
{avatars.map((avatar) => (
|
|
<div
|
|
key={avatar._id}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
padding: 10,
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "var(--radius-card)",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 56,
|
|
height: 56,
|
|
borderRadius: 6,
|
|
overflow: "hidden",
|
|
background: "var(--muted-bg, #f5f5f5)",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{avatar.imageUrl ? (
|
|
<img
|
|
src={avatar.imageUrl}
|
|
alt={avatar.name}
|
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<p
|
|
style={{
|
|
margin: 0,
|
|
fontSize: "var(--text-sm)",
|
|
fontWeight: 600,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{avatar.name}
|
|
</p>
|
|
<p style={{ margin: "2px 0 0", fontSize: "var(--text-xs)", color: "var(--muted)" }}>
|
|
{avatar.enabled !== false ? "Enabled" : "Disabled"}
|
|
{!avatar.imageUrl ? " · No image" : ""}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => openAvatarEditor(avatar)}
|
|
style={{
|
|
padding: "6px 10px",
|
|
borderRadius: "var(--radius-button)",
|
|
border: "1px solid var(--border)",
|
|
background: "var(--card)",
|
|
cursor: "pointer",
|
|
fontSize: "var(--text-xs)",
|
|
}}
|
|
>
|
|
Edit
|
|
</button>
|
|
{canWrite ? (
|
|
<button
|
|
type="button"
|
|
title="Delete"
|
|
onClick={() => void deleteAvatar(avatar._id)}
|
|
style={{
|
|
padding: 6,
|
|
border: "none",
|
|
background: "transparent",
|
|
cursor: "pointer",
|
|
color: "var(--destructive)",
|
|
}}
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
) : null}
|
|
</div>
|
|
</PanelShell>
|
|
);
|
|
}
|