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

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