import { Trans, useLingui } from "@lingui/react/macro"; import type { OoAvatar, OoMoment } from "@rakazo/core"; import { DEFAULT_OO_AVATAR, encodeOoAvatar, OO_ARMS, OO_BROWS, OO_CHEEKS, OO_CLASSIC_SHAPE_COUNT, OO_ENERGY_RANGE, OO_EYESHADOWS, OO_EYEWEAR, OO_HEADWEAR, OO_IRISES, OO_LASHES, OO_LINERS, OO_NECKWEAR, OO_PALETTE_HEXES, OO_SPARKLES, OO_SPECIES, ooLookForEditing, ooRandomAvatar, } from "@rakazo/core"; import { BotAvatar, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, OoFigure, Tabs, TabsContent, TabsList, TabsTrigger, } from "@rakazo/ui-web"; import { Check, Dices, Pencil, Upload, X } from "lucide-react"; import type { ClipboardEvent, DragEvent, ReactNode } from "react"; import { useRef, useState } from "react"; type Field = keyof OoAvatar; /** Option rows of the Atelier; every value is drawn as the Oo wearing it. */ function useOptionLabels() { const { t } = useLingui(); const none = t`None`; return { species: { classic: t`Classic`, cat: t`Cat`, dog: t`Dog`, bunny: t`Bunny`, bear: t`Bear`, robot: t`Robot`, ghost: t`Ghost`, frog: t`Frog`, penguin: t`Penguin`, }, lashes: { none, soft: t`Soft`, full: t`Full`, flick: t`Flick` }, liner: { none, wing: t`Winged` }, eyeshadow: { none, rose: t`Rose`, lilac: t`Lilac`, gold: t`Gold`, mint: t`Mint` }, iris: { classic: t`Classic`, brown: t`Brown`, blue: t`Blue`, green: t`Green`, violet: t`Violet`, }, sparkle: { dot: t`Dot`, star: t`Star`, heart: t`Heart` }, brows: { auto: t`When needed`, fine: t`Fine`, bushy: t`Bushy` }, cheeks: { none, pink: t`Pink`, peach: t`Peach`, freckles: t`Freckles` }, eyewear: { none, round: t`Round`, nerd: t`Nerd`, sun: t`Sunglasses`, heart: t`Heart`, monocle: t`Monocle`, reading: t`Reading glasses`, }, head: { none, chef: t`Chef's hat`, crown: t`Crown`, headphones: t`Headphones`, bow: t`Bow`, beanie: t`Beanie`, flower: t`Flower`, party: t`Party hat`, }, neck: { none, bowtie: t`Bow tie`, scarf: t`Scarf`, bell: t`Bell` }, arms: { glass: t`Glass`, stub: t`Stub`, paw: t`Paw`, robot: t`Robot`, none }, } as const; } function OptionGroup({ title, values, selected, label, render, onSelect, }: { title: string; values: readonly T[]; selected: T; label: (value: T) => string; render: (value: T) => ReactNode; onSelect: (value: T) => void; }) { return (
{title}
{values.map((value) => { const active = value === selected; return ( ); })}
); } export interface OoAtelierDialogProps { open: boolean; onOpenChange: (open: boolean) => void; value: string; identity: string; status?: string; onChange: (value: string) => void; /** Bots may wear an uploaded picture instead of an Oo. */ allowUpload?: boolean; } export function OoAtelierDialog({ open, onOpenChange, value, identity, status, onChange, allowUpload = false, }: OoAtelierDialogProps) { const { t } = useLingui(); const labels = useOptionLabels(); const [draft, setDraft] = useState(null); const [preview, setPreview] = useState(undefined); const [dragOver, setDragOver] = useState(false); const fileInputRef = useRef(null); const isPicture = value.startsWith("data:image/"); const look = draft ?? ooLookForEditing(value, identity); const encoded = encodeOoAvatar(look); function commit(next: OoAvatar) { setDraft(null); onChange(encodeOoAvatar(next)); } function set(field: K, next: OoAvatar[K]) { commit({ ...look, [field]: next }); } function slide(field: "energy" | "playful", next: number) { setDraft({ ...look, [field]: next }); setPreview(field === "energy" ? "working" : "greeting"); } function settle() { if (draft) commit(draft); setPreview(undefined); } function thumb(next: Partial, focus: "body" | "face" = "body") { return ( ); } function processImageFile(file: File) { if (!file.type.startsWith("image/")) return; const reader = new FileReader(); reader.onload = (event) => { const src = event.target?.result as string; if (!src) return; const img = new Image(); img.onload = () => { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); const targetSize = 256; canvas.width = targetSize; canvas.height = targetSize; if (!ctx) return; ctx.beginPath(); ctx.arc(targetSize / 2, targetSize / 2, targetSize / 2, 0, Math.PI * 2); ctx.clip(); const minDim = Math.min(img.width, img.height); const sx = (img.width - minDim) / 2; const sy = (img.height - minDim) / 2; ctx.drawImage(img, sx, sy, minDim, minDim, 0, 0, targetSize, targetSize); onChange(canvas.toDataURL("image/webp", 0.9)); onOpenChange(false); }; img.src = src; }; reader.readAsDataURL(file); } function handleDrop(event: DragEvent) { event.preventDefault(); setDragOver(false); const file = event.dataTransfer.files?.[0]; if (file) processImageFile(file); } function handlePaste(event: ClipboardEvent) { const items = event.clipboardData?.items; if (!items) return; for (let index = 0; index < items.length; index += 1) { const item = items[index]; if (!item?.type.startsWith("image/")) continue; const file = item.getAsFile(); if (file) { processImageFile(file); break; } } } const sliderClass = "w-full accent-primary"; return ( Atelier Choose how your Oo looks and moves
Body Eyes Extras {allowUpload ? ( Upload ) : null} labels.species[species]} render={(species) => thumb({ species, arms: species === "robot" ? "robot" : look.arms }) } onSelect={(species) => commit({ ...look, species, arms: species === "robot" ? "robot" : look.arms === "robot" ? "glass" : look.arms, }) } /> {look.species === "classic" ? ( index)} selected={look.shape ?? -1} label={(shape) => t`Shape ${shape + 1}`} render={(shape) => thumb({ shape })} onSelect={(shape) => set("shape", shape)} /> ) : null}
Color
{OO_PALETTE_HEXES.map((color) => { const selected = !isPicture && look.color.toLowerCase() === color.toLowerCase(); return (
labels.lashes[lashes]} render={(lashes) => thumb({ lashes }, "face")} onSelect={(lashes) => set("lashes", lashes)} /> labels.liner[liner]} render={(liner) => thumb({ liner }, "face")} onSelect={(liner) => set("liner", liner)} /> labels.eyeshadow[eyeshadow]} render={(eyeshadow) => thumb({ eyeshadow }, "face")} onSelect={(eyeshadow) => set("eyeshadow", eyeshadow)} /> labels.iris[iris]} render={(iris) => thumb({ iris }, "face")} onSelect={(iris) => set("iris", iris)} /> labels.sparkle[sparkle]} render={(sparkle) => thumb({ sparkle }, "face")} onSelect={(sparkle) => set("sparkle", sparkle)} /> labels.brows[brows]} render={(brows) => thumb({ brows }, "face")} onSelect={(brows) => set("brows", brows)} /> labels.cheeks[cheeks]} render={(cheeks) => thumb({ cheeks }, "face")} onSelect={(cheeks) => set("cheeks", cheeks)} /> labels.eyewear[eyewear]} render={(eyewear) => thumb({ eyewear }, "face")} onSelect={(eyewear) => set("eyewear", eyewear)} /> labels.head[head]} render={(head) => thumb({ head })} onSelect={(head) => set("head", head)} /> labels.neck[neck]} render={(neck) => thumb({ neck })} onSelect={(neck) => set("neck", neck)} /> labels.arms[arms]} render={(arms) => thumb({ arms })} onSelect={(arms) => set("arms", arms)} /> {allowUpload ? ( ) : null}
); } export interface OoAtelierProps { value: string; identity?: string; status?: string; size?: number; onChange: (value: string) => void; disabled?: boolean; } /** The bot's avatar; tapping it opens the Atelier. */ export function OoAtelier({ value, identity = "", status, size = 72, onChange, disabled = false, }: OoAtelierProps) { const { t } = useLingui(); const [open, setOpen] = useState(false); return ( <> ); }