The Atelier replaces Avatar Studio: pick a species, colour, eyes, glasses, hats, neckwear, arms and temperament, with every option drawn as your own Oo wearing it. Onboarding's "Design your Oo" now offers species looks per vibe and opens the Atelier for the rest; mobile bot settings carry the same editor instead of a bare colour row, so picking a colour there no longer throws away the rest of the look. Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
617 lines
21 KiB
TypeScript
617 lines
21 KiB
TypeScript
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<T extends string | number>({
|
|
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 (
|
|
<fieldset>
|
|
<legend className="mb-1.5 text-ui-label font-medium text-muted-foreground">{title}</legend>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{values.map((value) => {
|
|
const active = value === selected;
|
|
return (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
aria-label={label(value)}
|
|
title={label(value)}
|
|
aria-pressed={active}
|
|
onClick={() => onSelect(value)}
|
|
className={`grid size-12 place-items-center overflow-hidden rounded-xl transition-transform hover:scale-105 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
|
active ? "bg-foreground/10 ring-2 ring-primary" : "bg-muted/60 hover:bg-muted"
|
|
}`}
|
|
>
|
|
{render(value)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</fieldset>
|
|
);
|
|
}
|
|
|
|
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<OoAvatar | null>(null);
|
|
const [preview, setPreview] = useState<OoMoment | undefined>(undefined);
|
|
const [dragOver, setDragOver] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(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<K extends Field>(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<OoAvatar>, focus: "body" | "face" = "body") {
|
|
return (
|
|
<OoFigure
|
|
avatar={{ ...look, ...next }}
|
|
color={next.color ?? look.color}
|
|
fallbackShape={next.shape ?? look.shape ?? 0}
|
|
moment="idle"
|
|
size={44}
|
|
focus={focus}
|
|
still
|
|
/>
|
|
);
|
|
}
|
|
|
|
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<HTMLButtonElement>) {
|
|
event.preventDefault();
|
|
setDragOver(false);
|
|
const file = event.dataTransfer.files?.[0];
|
|
if (file) processImageFile(file);
|
|
}
|
|
|
|
function handlePaste(event: ClipboardEvent<HTMLButtonElement>) {
|
|
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 (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
className="max-h-[92vh] w-[420px] max-w-full gap-3 overflow-y-auto rounded-3xl p-5 sm:max-w-[420px]"
|
|
data-testid="oo-atelier"
|
|
>
|
|
<DialogHeader className="flex-row items-center justify-between space-y-0">
|
|
<DialogTitle className="text-ui-body font-semibold tracking-tight">
|
|
<Trans>Atelier</Trans>
|
|
</DialogTitle>
|
|
<DialogDescription className="sr-only">
|
|
<Trans>Choose how your Oo looks and moves</Trans>
|
|
</DialogDescription>
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpenChange(false)}
|
|
className="flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
aria-label={t`Close`}
|
|
>
|
|
<X className="size-icon-md" />
|
|
</button>
|
|
</DialogHeader>
|
|
|
|
<div className="flex justify-center py-1" data-testid="oo-atelier-preview">
|
|
<BotAvatar
|
|
color={isPicture && !draft ? value : encoded}
|
|
identity={identity}
|
|
status={status}
|
|
size={128}
|
|
moment={preview}
|
|
interactive
|
|
/>
|
|
</div>
|
|
|
|
<Tabs defaultValue="body">
|
|
<TabsList className="w-full">
|
|
<TabsTrigger value="body">
|
|
<Trans>Body</Trans>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="eyes">
|
|
<Trans>Eyes</Trans>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="extras">
|
|
<Trans>Extras</Trans>
|
|
</TabsTrigger>
|
|
{allowUpload ? (
|
|
<TabsTrigger value="upload">
|
|
<Trans>Upload</Trans>
|
|
</TabsTrigger>
|
|
) : null}
|
|
</TabsList>
|
|
|
|
<TabsContent value="body" className="space-y-4 pt-2" data-testid="oo-atelier-body">
|
|
<OptionGroup
|
|
title={t`Species`}
|
|
values={OO_SPECIES}
|
|
selected={look.species}
|
|
label={(species) => 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" ? (
|
|
<OptionGroup
|
|
title={t`Shape`}
|
|
values={Array.from({ length: OO_CLASSIC_SHAPE_COUNT }, (_, index) => index)}
|
|
selected={look.shape ?? -1}
|
|
label={(shape) => t`Shape ${shape + 1}`}
|
|
render={(shape) => thumb({ shape })}
|
|
onSelect={(shape) => set("shape", shape)}
|
|
/>
|
|
) : null}
|
|
<fieldset>
|
|
<legend className="mb-1.5 text-ui-label font-medium text-muted-foreground">
|
|
<Trans>Color</Trans>
|
|
</legend>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{OO_PALETTE_HEXES.map((color) => {
|
|
const selected = !isPicture && look.color.toLowerCase() === color.toLowerCase();
|
|
return (
|
|
<button
|
|
key={color}
|
|
type="button"
|
|
onClick={() => set("color", color)}
|
|
aria-label={t`Color ${color}`}
|
|
aria-pressed={selected}
|
|
className={`size-7 rounded-full border transition-transform hover:scale-110 active:scale-95 focus-visible:ring-2 focus-visible:ring-ring ${
|
|
selected
|
|
? "scale-105 border-transparent ring-2 ring-foreground ring-offset-2 ring-offset-popover"
|
|
: "border-border"
|
|
}`}
|
|
style={{ backgroundColor: color }}
|
|
/>
|
|
);
|
|
})}
|
|
<input
|
|
type="color"
|
|
aria-label={t`Custom color`}
|
|
value={look.color.length === 7 ? look.color : DEFAULT_OO_AVATAR.color}
|
|
onChange={(event) => setDraft({ ...look, color: event.target.value })}
|
|
onBlur={settle}
|
|
className="size-7 cursor-pointer rounded-full border border-border bg-transparent p-0"
|
|
/>
|
|
</div>
|
|
</fieldset>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<label className="text-ui-label font-medium text-muted-foreground">
|
|
<Trans>Energy</Trans>
|
|
<input
|
|
type="range"
|
|
min={OO_ENERGY_RANGE.min}
|
|
max={OO_ENERGY_RANGE.max}
|
|
step={0.05}
|
|
value={look.energy}
|
|
onChange={(event) => slide("energy", Number(event.target.value))}
|
|
onPointerUp={settle}
|
|
onKeyUp={settle}
|
|
onBlur={settle}
|
|
className={sliderClass}
|
|
/>
|
|
</label>
|
|
<label className="text-ui-label font-medium text-muted-foreground">
|
|
<Trans>Playful</Trans>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={1}
|
|
step={0.05}
|
|
value={look.playful}
|
|
onChange={(event) => slide("playful", Number(event.target.value))}
|
|
onPointerUp={settle}
|
|
onKeyUp={settle}
|
|
onBlur={settle}
|
|
className={sliderClass}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="eyes" className="space-y-4 pt-2" data-testid="oo-atelier-eyes">
|
|
<OptionGroup
|
|
title={t`Lashes`}
|
|
values={OO_LASHES}
|
|
selected={look.lashes}
|
|
label={(lashes) => labels.lashes[lashes]}
|
|
render={(lashes) => thumb({ lashes }, "face")}
|
|
onSelect={(lashes) => set("lashes", lashes)}
|
|
/>
|
|
<OptionGroup
|
|
title={t`Eyeliner`}
|
|
values={OO_LINERS}
|
|
selected={look.liner}
|
|
label={(liner) => labels.liner[liner]}
|
|
render={(liner) => thumb({ liner }, "face")}
|
|
onSelect={(liner) => set("liner", liner)}
|
|
/>
|
|
<OptionGroup
|
|
title={t`Eyeshadow`}
|
|
values={OO_EYESHADOWS}
|
|
selected={look.eyeshadow}
|
|
label={(eyeshadow) => labels.eyeshadow[eyeshadow]}
|
|
render={(eyeshadow) => thumb({ eyeshadow }, "face")}
|
|
onSelect={(eyeshadow) => set("eyeshadow", eyeshadow)}
|
|
/>
|
|
<OptionGroup
|
|
title={t`Iris`}
|
|
values={OO_IRISES}
|
|
selected={look.iris}
|
|
label={(iris) => labels.iris[iris]}
|
|
render={(iris) => thumb({ iris }, "face")}
|
|
onSelect={(iris) => set("iris", iris)}
|
|
/>
|
|
<OptionGroup
|
|
title={t`Sparkle`}
|
|
values={OO_SPARKLES}
|
|
selected={look.sparkle}
|
|
label={(sparkle) => labels.sparkle[sparkle]}
|
|
render={(sparkle) => thumb({ sparkle }, "face")}
|
|
onSelect={(sparkle) => set("sparkle", sparkle)}
|
|
/>
|
|
<OptionGroup
|
|
title={t`Brows`}
|
|
values={OO_BROWS}
|
|
selected={look.brows}
|
|
label={(brows) => labels.brows[brows]}
|
|
render={(brows) => thumb({ brows }, "face")}
|
|
onSelect={(brows) => set("brows", brows)}
|
|
/>
|
|
<OptionGroup
|
|
title={t`Cheeks`}
|
|
values={OO_CHEEKS}
|
|
selected={look.cheeks}
|
|
label={(cheeks) => labels.cheeks[cheeks]}
|
|
render={(cheeks) => thumb({ cheeks }, "face")}
|
|
onSelect={(cheeks) => set("cheeks", cheeks)}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="extras" className="space-y-4 pt-2" data-testid="oo-atelier-extras">
|
|
<OptionGroup
|
|
title={t`Glasses`}
|
|
values={OO_EYEWEAR}
|
|
selected={look.eyewear}
|
|
label={(eyewear) => labels.eyewear[eyewear]}
|
|
render={(eyewear) => thumb({ eyewear }, "face")}
|
|
onSelect={(eyewear) => set("eyewear", eyewear)}
|
|
/>
|
|
<OptionGroup
|
|
title={t`Hat`}
|
|
values={OO_HEADWEAR}
|
|
selected={look.head}
|
|
label={(head) => labels.head[head]}
|
|
render={(head) => thumb({ head })}
|
|
onSelect={(head) => set("head", head)}
|
|
/>
|
|
<OptionGroup
|
|
title={t`Neck`}
|
|
values={OO_NECKWEAR}
|
|
selected={look.neck}
|
|
label={(neck) => labels.neck[neck]}
|
|
render={(neck) => thumb({ neck })}
|
|
onSelect={(neck) => set("neck", neck)}
|
|
/>
|
|
<OptionGroup
|
|
title={t`Arms`}
|
|
values={OO_ARMS}
|
|
selected={look.arms}
|
|
label={(arms) => labels.arms[arms]}
|
|
render={(arms) => thumb({ arms })}
|
|
onSelect={(arms) => set("arms", arms)}
|
|
/>
|
|
</TabsContent>
|
|
|
|
{allowUpload ? (
|
|
<TabsContent value="upload" className="pt-2">
|
|
<button
|
|
type="button"
|
|
aria-label={t`Image upload area`}
|
|
onDragOver={(event) => {
|
|
event.preventDefault();
|
|
setDragOver(true);
|
|
}}
|
|
onDragLeave={() => setDragOver(false)}
|
|
onDrop={handleDrop}
|
|
onPaste={handlePaste}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className={`flex w-full flex-col items-center justify-center rounded-xl border border-dashed p-6 text-center transition-colors ${
|
|
dragOver
|
|
? "border-primary bg-primary/10"
|
|
: "border-border bg-muted hover:border-foreground/30"
|
|
}`}
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0];
|
|
if (file) processImageFile(file);
|
|
}}
|
|
/>
|
|
<div className="mb-2 grid size-10 place-items-center rounded-full bg-secondary text-muted-foreground">
|
|
<Upload strokeWidth={1.8} className="size-icon-lg" />
|
|
</div>
|
|
<p className="text-ui-body-sm font-medium text-muted-foreground">
|
|
<Trans>Drag, drop, or paste an image</Trans>
|
|
</p>
|
|
<span className="mt-3 rounded-lg bg-secondary px-3 py-1.5 text-ui-label font-medium text-foreground">
|
|
<Trans>Choose file</Trans>
|
|
</span>
|
|
</button>
|
|
</TabsContent>
|
|
) : null}
|
|
</Tabs>
|
|
|
|
<DialogFooter className="flex-row items-center justify-between border-border sm:justify-between">
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => commit(ooRandomAvatar())}
|
|
aria-label={t`Surprise me`}
|
|
title={t`Surprise me`}
|
|
className="flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
>
|
|
<Dices className="size-icon-md" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => commit({ ...DEFAULT_OO_AVATAR, color: look.color })}
|
|
className="px-2 py-1 text-ui-label font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<Trans>Reset</Trans>
|
|
</button>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpenChange(false)}
|
|
className="flex items-center gap-1.5 rounded-xl bg-secondary px-4 py-1.5 text-ui-body-sm font-medium text-foreground transition-colors hover:bg-accent"
|
|
>
|
|
<Check className="size-icon-md" />
|
|
<Trans>Done</Trans>
|
|
</button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => setOpen(true)}
|
|
className="group relative cursor-pointer rounded-2xl outline-none transition-transform hover:scale-[1.03] focus-visible:ring-2 focus-visible:ring-ring"
|
|
aria-label={t`Customize bot avatar`}
|
|
data-testid="oo-atelier-trigger"
|
|
>
|
|
<BotAvatar color={value} identity={identity} size={size} status={status} />
|
|
<div className="absolute -right-1 -bottom-1 flex size-6 items-center justify-center rounded-full border-2 border-background bg-secondary text-foreground shadow-md transition-transform group-hover:scale-110">
|
|
<Pencil strokeWidth={2.2} className="size-icon-sm" />
|
|
</div>
|
|
</button>
|
|
<OoAtelierDialog
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
value={value}
|
|
identity={identity}
|
|
status={status}
|
|
onChange={onChange}
|
|
allowUpload
|
|
/>
|
|
</>
|
|
);
|
|
}
|