From 94bc5223e688f3685503500e45f9a2124b392c23 Mon Sep 17 00:00:00 2001 From: smontlouis Date: Thu, 13 Aug 2026 19:55:20 +0200 Subject: [PATCH] feat(studio): redesign responsive navigation Add a unified bottom navigation, avatar drawer, and responsive preview and inspector layout across desktop and mobile. --- src/app/components/common.tsx | 2 +- src/app/styles.css | 403 +- src/components/ui/drawer.tsx | 35 + .../studio/components/AvatarDrawer.tsx | 166 + .../studio/components/StudioIdentity.tsx | 45 + .../studio/components/StudioInspector.tsx | 3427 ++++++++--------- .../studio/components/StudioStage.tsx | 51 +- 7 files changed, 2354 insertions(+), 1775 deletions(-) create mode 100644 src/components/ui/drawer.tsx create mode 100644 src/features/studio/components/AvatarDrawer.tsx create mode 100644 src/features/studio/components/StudioIdentity.tsx diff --git a/src/app/components/common.tsx b/src/app/components/common.tsx index 7397ca8..a6a8dfa 100644 --- a/src/app/components/common.tsx +++ b/src/app/components/common.tsx @@ -109,7 +109,7 @@ export function ExportSection({ }) { const { t } = useStudioLanguage() return ( - + {t(title)} diff --git a/src/app/styles.css b/src/app/styles.css index 06cc349..9aff0c3 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -165,10 +165,24 @@ input { text-wrap: pretty; } -.brand { +.studio-identity { position: absolute; + z-index: 5; + top: 0; + right: 0; + left: 0; + display: flex; + align-items: center; + justify-content: space-between; + pointer-events: none; +} +.studio-identity > * { + pointer-events: auto; +} +.brand { + position: relative; top: 28px; - left: 32px; + margin-left: 32px; display: flex; align-items: center; gap: 9px; @@ -190,10 +204,9 @@ input { box-shadow: 0 0 0 3px rgb(91 127 229 / 15%); } .language-picker { - position: absolute; - z-index: 5; + position: relative; top: 24px; - right: 28px; + margin-right: 28px; display: flex; align-items: center; gap: 7px; @@ -225,6 +238,9 @@ input { border-radius: 5px; box-shadow: 0 0 0 2px #7895ee; } +.inspector-identity { + display: none; +} .avatar-wrap { position: relative; @@ -377,6 +393,9 @@ input { width: 100%; min-width: 0; } +.export-accordion-snapshot { + order: -1; +} .export-accordion-item { min-width: 0; overflow: hidden; @@ -782,11 +801,13 @@ input { } .inspector { + --app-footer-clearance: 140px; + height: 100vh; overflow-x: hidden; overflow-y: auto; scrollbar-gutter: stable; - padding: 28px 26px 48px; + padding: 28px 26px var(--app-footer-clearance); border-left: 1px solid #d9dce3; background: #f5f6f8; } @@ -796,7 +817,7 @@ input { padding-top: 0; } .inspector.state-player-active { - padding-bottom: 128px; + --app-footer-clearance: 220px; } @keyframes studio-workspace-in { from { @@ -826,7 +847,7 @@ input { display: flex; min-height: calc(100vh - 76px); flex-direction: column; - padding-bottom: 74px; + padding-bottom: 0; } .workspace-header { position: sticky; @@ -905,6 +926,16 @@ input { gap: 16px; margin-bottom: 22px; } +.mode-page-header { + margin-bottom: 20px; + padding: 4px 2px 0; +} +.mode-page-header .eyebrow { + margin-bottom: 5px; +} +.mode-page-header h1 { + font-size: 28px; +} .eyebrow { margin: 0 0 6px; color: #7185c4; @@ -990,7 +1021,7 @@ p { position: fixed; z-index: 9; right: 0; - bottom: 0; + bottom: 62px; width: min(520px, 100vw); box-sizing: border-box; padding: 10px 18px; @@ -1262,6 +1293,151 @@ p { padding: 5px; backdrop-filter: blur(14px); } +.desktop-mode-tabs { + display: none; +} +.mobile-mode-tabs { + position: fixed; + z-index: 10; + right: 0; + bottom: 0; + display: grid; + width: min(520px, 100vw); + min-height: 62px; + padding: 5px 8px; + border-top: 1px solid #d9dce3; + background: rgb(250 251 253 / 97%); + box-shadow: 0 -8px 24px rgb(24 28 35 / 7%); + grid-template-columns: repeat(5, minmax(0, 1fr)); + backdrop-filter: blur(16px); +} +.mobile-mode-tab { + display: flex; + height: 51px; + min-width: 0; + flex-direction: column; + gap: 3px; + border-radius: 12px; + color: #7b828e; + font-size: 9px; + font-weight: 750; +} +.mobile-mode-tab > svg { + width: 19px; + height: 19px; +} +.mobile-mode-tab > span { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} +.mobile-mode-tab[aria-pressed='true'] { + background: #edf1ff; + color: #4168d5; +} +.mobile-avatar-tab > svg { + width: 27px; + height: 27px; + margin-block: -4px -1px; + border-radius: 50%; +} +.drawer-backdrop { + position: fixed; + z-index: 40; + inset: 0; + background: transparent; + animation: drawer-backdrop-in 180ms ease-out; +} +.drawer-viewport { + position: fixed; + z-index: 41; + inset: 0; + display: flex; + align-items: flex-end; + justify-content: flex-end; + pointer-events: none; +} +.drawer-content { + width: min(520px, 100vw); + max-height: min(72dvh, 680px); + overflow-x: hidden; + overflow-y: auto; + padding: 9px 20px 28px; + border: 1px solid #d9dce3; + border-bottom: 0; + border-radius: 24px 24px 0 0; + outline: 0; + background: #f5f6f8; + box-shadow: 0 -24px 70px rgb(12 16 24 / 22%); + animation: drawer-content-in 240ms cubic-bezier(0.22, 1, 0.36, 1); + pointer-events: auto; +} +.drawer-handle { + display: grid; + width: 100%; + height: 24px; + margin: 0 0 3px; + padding: 0; + border: 0; + outline: 0; + background: transparent; + cursor: pointer; + place-items: center; +} +.drawer-handle > span { + width: 42px; + height: 5px; + border-radius: 999px; + background: #c8ccd4; +} +.avatar-drawer-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; + padding-inline: 2px; +} +.avatar-drawer-heading > span { + color: #858b96; + font-size: 11px; + font-weight: 800; +} +.drawer-title { + margin: 0; + font-size: 21px; + letter-spacing: -0.035em; +} +.drawer-description { + margin: 4px 0 0; + color: #7c838e; + font-size: 10px; +} +.avatar-drawer .avatar-shelf { + margin: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} +.avatar-drawer .avatar-shelf .avatar-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} +@keyframes drawer-backdrop-in { + from { + opacity: 0; + } +} +@keyframes drawer-content-in { + from { + transform: translateY(100%); + } +} +.inspector.state-player-active .mobile-mode-tabs { + border-top: 0; + box-shadow: none; +} .state-buttons button, .button-row button, .reset { @@ -2131,30 +2307,195 @@ p { } @media (max-width: 600px) { + html, + body, + #root { + width: 100%; + height: 100%; + overflow: hidden; + } + .studio { + height: 100dvh; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + } .stage-column { - min-height: 420px; - padding: 18px; + position: relative; + aspect-ratio: 1; + height: auto; + min-height: 0; + padding: 10px; + } + .stage-identity { + display: flex; + } + .inspector-identity { + position: sticky; + top: 0; + z-index: 20; + display: none; + min-height: 52px; + margin: 0 -14px 16px; + padding: 0 14px; + border-bottom: 1px solid #d9dce3; + background: rgb(245 246 248 / 94%); + backdrop-filter: blur(14px); + } + .inspector-identity .brand { + top: 0; + margin-left: 0; + color: #17191d; + font-size: 11px; + } + .inspector-identity .brand em { + color: #767d88; + } + .inspector-identity .brand-mark { + width: 16px; + height: 16px; + } + .inspector-identity .language-picker { + top: 0; + min-height: 32px; + margin-right: 0; + padding: 3px 6px 3px 8px; + border-color: #d9dce3; + background: white; + box-shadow: 0 4px 14px rgb(24 28 35 / 7%); + } + .inspector-identity .language-picker [data-slot='select-trigger'] { + min-width: 68px; + color: #17191d; + } + .stage-identity .brand { + top: 14px; + margin-left: 14px; + font-size: 11px; + } + .stage-identity .brand-mark { + width: 16px; + height: 16px; + } + .stage-identity .language-picker { + top: 10px; + min-height: 32px; + margin-right: 12px; + padding: 3px 6px 3px 8px; + } + .stage-identity .language-picker [data-slot='select-trigger'] { + min-width: 68px; + } + .avatar-wrap { + width: min(42dvh, 78vw, 340px); } .photo-capture-bar { - bottom: 48px; + bottom: max(12px, env(safe-area-inset-bottom)); + left: 12px; + transform: none; } .photo-capture-button { - min-width: 168px; + width: 40px; + min-width: 40px; + height: 40px; + padding: 0; + border-radius: 50%; } - .brand { - top: 18px; - left: 20px; + .photo-capture-button > svg { + width: 18px; + height: 18px; + } + .photo-capture-label, + .photo-format-badge, + .photo-help-button { + display: none; } .inspector { - padding: 22px 14px 40px; + height: auto; + min-height: 0; + overscroll-behavior: contain; + padding: 0 14px calc(var(--app-footer-clearance) + env(safe-area-inset-bottom)); + scrollbar-gutter: auto; + } + .inspector.expression-workspace-active, + .inspector.sequence-workspace-active, + .inspector.body-workspace { + padding-top: 0; + } + .inspector.expression-workspace-active .inspector-identity, + .inspector.sequence-workspace-active .inspector-identity, + .inspector.body-workspace .inspector-identity { + position: static; + } + .workspace-page { + min-height: calc(50dvh - 52px); + } + .mode-page-header { + margin-bottom: 16px; + padding-top: 18px; + } + .mode-page-header h1 { + font-size: 24px; + } + .workspace-header { + position: static; + top: auto; } .numeric-field { grid-template-columns: 100px minmax(76px, 1fr) 66px; gap: 7px; } + .desktop-mode-tabs { + display: none; + } + .mobile-mode-tabs { + position: fixed; + z-index: 10; + right: 0; + bottom: 0; + left: 0; + display: grid; + min-height: calc(62px + env(safe-area-inset-bottom)); + padding: 5px 8px max(5px, env(safe-area-inset-bottom)); + border-top: 1px solid #d9dce3; + background: rgb(250 251 253 / 97%); + box-shadow: 0 -8px 24px rgb(24 28 35 / 7%); + grid-template-columns: repeat(5, minmax(0, 1fr)); + backdrop-filter: blur(16px); + } + .mobile-mode-tab { + display: flex; + height: 51px; + min-width: 0; + flex-direction: column; + gap: 3px; + border-radius: 12px; + color: #7b828e; + font-size: 9px; + font-weight: 750; + } + .mobile-mode-tab > svg { + width: 19px; + height: 19px; + } + .mobile-mode-tab > span { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + } + .mobile-mode-tab[aria-pressed='true'] { + background: #edf1ff; + color: #4168d5; + } + .eye-columns { + grid-template-columns: minmax(0, 1fr); + } .expression-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .state-buttons { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } .avatar-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -2174,4 +2515,32 @@ p { .workspace-header-actions .state-player > span { display: none; } + .state-playback-footer { + bottom: calc(62px + env(safe-area-inset-bottom)); + padding: 6px 10px; + } + .state-playback-bar { + gap: 8px; + } + .state-playback-timeline { + gap: 4px; + padding: 3px 2px 4px; + } + .state-playback-step { + width: 42px; + height: 42px; + flex-basis: 42px; + border-radius: 9px; + } + .state-playback-controls { + padding-left: 8px; + } + .state-playback-footer .state-player [data-slot='button'] { + width: 36px; + height: 36px; + } + .state-playback-footer .state-player svg { + width: 22px; + height: 22px; + } } diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx new file mode 100644 index 0000000..ac40901 --- /dev/null +++ b/src/components/ui/drawer.tsx @@ -0,0 +1,35 @@ +import { Dialog as DialogPrimitive } from '@base-ui/react/dialog' +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +function Drawer(props: DialogPrimitive.Root.Props) { + return +} + +function DrawerContent({ className, children, ...props }: DialogPrimitive.Popup.Props) { + return ( + + + + + {children} + + + + ) +} + +function DrawerClose(props: DialogPrimitive.Close.Props) { + return +} + +function DrawerTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return +} + +function DrawerDescription({ className, ...props }: DialogPrimitive.Description.Props) { + return +} + +export { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerTitle } diff --git a/src/features/studio/components/AvatarDrawer.tsx b/src/features/studio/components/AvatarDrawer.tsx new file mode 100644 index 0000000..044b7bc --- /dev/null +++ b/src/features/studio/components/AvatarDrawer.tsx @@ -0,0 +1,166 @@ +import { Copy, Pencil, Plus, Trash2 } from 'lucide-react' +import { motion } from 'motion/react' + +import { Button } from '@/components/ui/button' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/components/ui/context-menu' +import { DrawerClose, DrawerContent, DrawerDescription, DrawerTitle } from '@/components/ui/drawer' +import { defaultAvatarEyes } from '@/features/avatar/avatars' +import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace' +import { defaultExpression } from '@/features/avatar/presets' +import type { StudioController } from '@/features/studio/useStudioController' + +export function AvatarDrawer({ + controller, + onOpenChange, +}: { + controller: StudioController + onOpenChange: (open: boolean) => void +}) { + const { + activateAvatar, + activeAvatarId, + avatarDragOrigin, + avatarDragPreview, + avatars, + avatarsRef, + cancelAvatarMove, + commitAvatarMove, + createNewAvatar, + draggedAvatarId, + draggingAvatarId, + duplicateAvatar, + expressions, + previewAvatarMove, + reduceMotion, + setDeleteAvatarOpen, + setDraggingAvatarId, + setFocusAvatarName, + t, + } = controller + + return ( + + + + +
+
+ Avatars + {t('Double-clic pour modifier')} +
+ {avatars.length} +
+
+
+ {avatars.map(avatar => ( + + + { + avatarDragOrigin.current = avatarsRef.current + avatarDragPreview.current = avatarsRef.current + draggedAvatarId.current = avatar.id + setDraggingAvatarId(avatar.id) + event.dataTransfer.effectAllowed = 'move' + }} + onDragEnter={() => previewAvatarMove(avatar.id)} + onDragOver={event => { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + }} + onDrop={event => { + event.preventDefault() + commitAvatarMove(avatar.id) + }} + onDragEnd={cancelAvatarMove} + onClick={() => activateAvatar(avatar.id, false, true)} + onDoubleClick={() => { + setFocusAvatarName(false) + activateAvatar(avatar.id, true) + onOpenChange(false) + }} + > + + {avatar.name} + + } + /> + + { + setFocusAvatarName(false) + activateAvatar(avatar.id, true) + onOpenChange(false) + }} + > + {t('Modifier')} + + duplicateAvatar(avatar)}> + {t('Dupliquer')} + + + { + activateAvatar(avatar.id, false, true) + onOpenChange(false) + setDeleteAvatarOpen(true) + }} + > + {t('Supprimer')} + + + + + ))} + +
+
+
+ ) +} diff --git a/src/features/studio/components/StudioIdentity.tsx b/src/features/studio/components/StudioIdentity.tsx new file mode 100644 index 0000000..93b4a6e --- /dev/null +++ b/src/features/studio/components/StudioIdentity.tsx @@ -0,0 +1,45 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import type { StudioController } from '@/features/studio/useStudioController' +import { type StudioLanguage } from '@/i18n' + +type StudioIdentityProps = Pick & { + className?: string +} + +export function StudioIdentity({ className = '', language, setLanguage, t }: StudioIdentityProps) { + return ( +
+
+ + Bible Strong Avatar Lab +
+
+ + +
+
+ ) +} diff --git a/src/features/studio/components/StudioInspector.tsx b/src/features/studio/components/StudioInspector.tsx index 5cbb110..042f39d 100644 --- a/src/features/studio/components/StudioInspector.tsx +++ b/src/features/studio/components/StudioInspector.tsx @@ -5,13 +5,16 @@ import { Copy, Download, FileCode2, + Move3D, Pencil, + Play, Plus, RotateCcw, + Smile, Trash2, Upload, } from 'lucide-react' -import { motion } from 'motion/react' +import { AnimatePresence, motion } from 'motion/react' import { type CSSProperties } from 'react' import { Accordion } from '@/components/ui/accordion' @@ -25,6 +28,7 @@ import { ContextMenuTrigger, } from '@/components/ui/context-menu' import { Field, FieldTitle } from '@/components/ui/field' +import { Drawer } from '@/components/ui/drawer' import { Input } from '@/components/ui/input' import { Select, @@ -35,7 +39,6 @@ import { } from '@/components/ui/select' import { Separator } from '@/components/ui/separator' import { Switch } from '@/components/ui/switch' -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { ControlSection, @@ -46,13 +49,7 @@ import { StatePlayer, } from '@/app/components/common' import { ColorField, LinkButton, NumericField } from '@/app/components/controls' -import { - formatSeconds, - scaleSurface, - type Mode, - type Side, - type SnapshotFormat, -} from '@/app/studio-utils' +import { formatSeconds, scaleSurface, type Side, type SnapshotFormat } from '@/app/studio-utils' import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace' import { findExpressionIndex, groupSequences } from '@/features/animation/sequences' import { defaultAvatarEyes } from '@/features/avatar/avatars' @@ -66,9 +63,13 @@ import { import { defaultExpression } from '@/features/avatar/presets' import { surfaceLabels, surfacePresets } from '@/features/avatar/surfaces' import { type SnapshotBackground } from '@/features/export/snapshotExporter' +import { AvatarDrawer } from '@/features/studio/components/AvatarDrawer' +import { StudioIdentity } from '@/features/studio/components/StudioIdentity' import type { StudioController } from '@/features/studio/useStudioController' +import { useState } from 'react' export function StudioInspector({ controller }: { controller: StudioController }) { + const [avatarDrawerOpen, setAvatarDrawerOpen] = useState(false) const { activateAvatar, activeAvatar, @@ -161,6 +162,7 @@ export function StudioInspector({ controller }: { controller: StudioController } setExportFormat, setFocusAvatarName, setLinked, + setLanguage, setMode, setSelectedSequenceStepId, setSequenceEditing, @@ -206,1772 +208,1757 @@ export function StudioInspector({ controller }: { controller: StudioController } workspaceBackButtonRef, } = controller return ( -
- {sequenceEditing && !editing && ( - - - setSequenceEditing(current => (current ? { ...current, draft } : current)) - } - onPreviewStep={step => { - const expressionIndex = findExpressionIndex(expressions, step.expressionId) - const preset = expressions[expressionIndex] - if (preset) transitionToExpression(preset, expressionIndex, step) - }} - onEditExpression={openExpressionEditor} - onPlay={() => launchSequence(sequenceEditing.draft, false, false)} - onPause={() => pauseState(false)} - onStop={() => stopState(false)} - playing={statePlaying} - active={activeState === sequenceEditing.draft.id} - onCancel={cancelSequenceEditing} - onSave={saveSequenceEditing} - onDuplicate={duplicateSequenceEditing} - onDelete={() => setDeleteSequenceOpen(true)} - /> - - )} - {editing && ( - - duplicateExpression(editing.index, editing.draft, true)} - onDelete={() => setDeleteExpressionOpen(true)} - /> - - )} - {!sequenceEditing && - !editing && - (bodyEditing ? ( -
- -
-

{t('Construction du corps')}

- renameActiveAvatar(event.currentTarget.value)} - onFocus={event => { - if (focusAvatarName) event.currentTarget.select() - }} - onBlur={event => { - if (!event.currentTarget.value.trim()) renameActiveAvatar('Unknown') - setFocusAvatarName(false) - }} - /> -

{t('Choisis la forme principale puis assemble les primitives autour d’elle.')}

-
-
- ) : ( - <> -
-
- Avatars - {t('Double-clic pour modifier')} -
-
- {avatars.map(avatar => ( - - - { - avatarDragOrigin.current = avatarsRef.current - avatarDragPreview.current = avatarsRef.current - draggedAvatarId.current = avatar.id - setDraggingAvatarId(avatar.id) - event.dataTransfer.effectAllowed = 'move' - }} - onDragEnter={() => previewAvatarMove(avatar.id)} - onDragOver={event => { - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - }} - onDrop={event => { - event.preventDefault() - commitAvatarMove(avatar.id) - }} - onDragEnd={cancelAvatarMove} - onClick={() => activateAvatar(avatar.id, false, true)} - onDoubleClick={() => { - setFocusAvatarName(false) - activateAvatar(avatar.id, true) - }} - > - - {avatar.name} - - } - /> - - { - setFocusAvatarName(false) - activateAvatar(avatar.id, true) - }} - > - {t('Modifier')} - - duplicateAvatar(avatar)}> - {t('Dupliquer')} - - - { - activateAvatar(avatar.id, false, true) - setDeleteAvatarOpen(true) - }} - > - {t('Supprimer')} - - - - - ))} - -
-
- setMode(value as Mode)}> - - {t('Pose')} - {t('Expressions')} - {t('Animations')} - {t('Exporter')} - - - - ))} - - {!editing && mode === 'manual' && ( -
- {bodyEditing && ( - <> - +
+ + {sequenceEditing && !editing && ( + + + setSequenceEditing(current => (current ? { ...current, draft } : current)) + } + onPreviewStep={step => { + const expressionIndex = findExpressionIndex(expressions, step.expressionId) + const preset = expressions[expressionIndex] + if (preset) transitionToExpression(preset, expressionIndex, step) + }} + onEditExpression={openExpressionEditor} + onPlay={() => launchSequence(sequenceEditing.draft, false, false)} + onPause={() => pauseState(false)} + onStop={() => stopState(false)} + playing={statePlaying} + active={activeState === sequenceEditing.draft.id} + onCancel={cancelSequenceEditing} + onSave={saveSequenceEditing} + onDuplicate={duplicateSequenceEditing} + onDelete={() => setDeleteSequenceOpen(true)} + /> + + )} + {editing && ( + + duplicateExpression(editing.index, editing.draft, true)} + onDelete={() => setDeleteExpressionOpen(true)} + /> + + )} + {!sequenceEditing && + !editing && + (bodyEditing ? ( +
+ +
+

{t('Construction du corps')}

+ renameActiveAvatar(event.currentTarget.value)} + onFocus={event => { + if (focusAvatarName) event.currentTarget.select() + }} + onBlur={event => { + if (!event.currentTarget.value.trim()) renameActiveAvatar('Unknown') + setFocusAvatarName(false) + }} + /> +

+ {t('Choisis la forme principale puis assemble les primitives autour d’elle.')} +

+
+
+ ) : null)} + + + + {!editorPageOpen && ( +
+

{activeAvatar.name}

+

+ {t( + mode === 'manual' + ? 'Pose' + : mode === 'expressions' + ? 'Expressions' + : mode === 'states' + ? 'Animations' + : 'Exporter' + )} +

+
+ )} + + {!editing && mode === 'manual' && ( +
+ {bodyEditing && ( + <> + - - - - - {t('Forme principale')} - - {t(surfaceLabels[surface.type])} · {t('porte les yeux')} - - - - {bodyNodes.map(node => ( - - ))} -
-
- - {t('Ajouter une forme')} · {bodyNodes.length}/{MAX_BODY_NODES} - -
- {bodyPrimitiveTypes.map(type => ( - - ))} -
-
- {selectedBodyNode && ( -
-
- {selectedBodyNode.name} -
+ + +
- + {bodyNodes.map(node => ( + + ))}
-
-

- {t('Gizmo local')} - {t( - 'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.' +

+ + {t('Ajouter une forme')} · {bodyNodes.length}/{MAX_BODY_NODES} + +
+ {bodyPrimitiveTypes.map(type => ( + + ))} +
+
+ {selectedBodyNode && ( +
+
+ {selectedBodyNode.name} +
+ + +
+
+

+ {t('Gizmo local')} + {t( + 'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.' + )} +

+
+ + updateSelectedBodyNode(node => ({ + ...node, + surface: scaleSurface(node.surface, size, { + width: 10, + height: 10, + depth: 10, + }), + })) + } + /> + {(['width', 'height', 'depth'] as const).map(dimension => ( + + updateSelectedBodyNode(node => ({ + ...node, + surface: { ...node.surface, [dimension]: value }, + })) + } + /> + ))} + {(selectedBodyNode.surface.type === 'cube' || + selectedBodyNode.surface.type === 'diamond' || + selectedBodyNode.surface.type === 'cylinder') && ( + + updateSelectedBodyNode(node => ({ + ...node, + surface: { ...node.surface, roundness }, + })) + } + /> + )} + {(selectedBodyNode.surface.type === 'cylinder' || + selectedBodyNode.surface.type === 'cone') && ( + + updateSelectedBodyNode(node => ({ + ...node, + surface: { ...node.surface, morphRoundness }, + })) + } + /> + )} + {selectedBodyNode.surface.type === 'cone' && ( + <> + + updateSelectedBodyNode(node => ({ + ...node, + surface: { ...node.surface, tipRoundness }, + })) + } + /> + + updateSelectedBodyNode(node => ({ + ...node, + surface: { ...node.surface, baseRoundness }, + })) + } + /> + + )} +
+
+
+

{t('Position locale')}

+ {(['X', 'Y', 'Z'] as const).map((axis, index) => ( + + updateNodeVector('position', index as 0 | 1 | 2, value) + } + /> + ))} +
+
+

{t('Rotation locale')}

+ {(['X', 'Y', 'Z'] as const).map((axis, index) => ( + + updateNodeVector('rotation', index as 0 | 1 | 2, value) + } + /> + ))} +
+
+
)} -

-
- - updateSelectedBodyNode(node => ({ - ...node, - surface: scaleSurface(node.surface, size, { - width: 10, - height: 10, - depth: 10, - }), - })) - } + + + - {(['width', 'height', 'depth'] as const).map(dimension => ( +
+ {bodyPrimitiveTypes.map(type => { + const previewSurface = + type === surface.type ? surface : surfacePresets[type] + return ( + + ) + })} +
+
- updateSelectedBodyNode(node => ({ - ...node, - surface: { ...node.surface, [dimension]: value }, - })) - } - /> - ))} - {(selectedBodyNode.surface.type === 'cube' || - selectedBodyNode.surface.type === 'diamond' || - selectedBodyNode.surface.type === 'cylinder') && ( - - updateSelectedBodyNode(node => ({ - ...node, - surface: { ...node.surface, roundness }, - })) - } - /> - )} - {(selectedBodyNode.surface.type === 'cylinder' || - selectedBodyNode.surface.type === 'cone') && ( - - updateSelectedBodyNode(node => ({ - ...node, - surface: { ...node.surface, morphRoundness }, - })) - } - /> - )} - {selectedBodyNode.surface.type === 'cone' && ( - <> - - updateSelectedBodyNode(node => ({ - ...node, - surface: { ...node.surface, tipRoundness }, - })) - } - /> - - updateSelectedBodyNode(node => ({ - ...node, - surface: { ...node.surface, baseRoundness }, - })) - } - /> - - )} -
-
-
-

{t('Position locale')}

- {(['X', 'Y', 'Z'] as const).map((axis, index) => ( - - updateNodeVector('position', index as 0 | 1 | 2, value) - } - /> - ))} -
-
-

{t('Rotation locale')}

- {(['X', 'Y', 'Z'] as const).map((axis, index) => ( - - updateNodeVector('rotation', index as 0 | 1 | 2, value) - } - /> - ))} -
-
-
- )} - - - -
- {bodyPrimitiveTypes.map(type => { - const previewSurface = type === surface.type ? surface : surfacePresets[type] - return ( - - ) - })} -
-
- - updateSurface( - scaleSurface(surface, size, { width: 120, height: 120, depth: 100 }) - ) - } - /> - updateSurface({ ...surface, width })} - /> - updateSurface({ ...surface, height })} - /> - updateSurface({ ...surface, depth })} - /> - {(surface.type === 'cube' || surface.type === 'diamond') && ( - updateHighlight(active ? 'head' : null)} - onChange={roundness => updateSurface({ ...surface, roundness })} - /> - )} - {surface.type === 'cylinder' && ( - updateHighlight(active ? 'head' : null)} - onChange={roundness => updateSurface({ ...surface, roundness })} - /> - )} - {(surface.type === 'cylinder' || surface.type === 'cone') && ( - updateHighlight(active ? 'head' : null)} - onChange={morphRoundness => updateSurface({ ...surface, morphRoundness })} - /> - )} - {surface.type === 'cone' && ( - <> - updateHighlight(active ? 'head' : null)} - onChange={tipRoundness => updateSurface({ ...surface, tipRoundness })} - /> - updateHighlight(active ? 'head' : null)} - onChange={baseRoundness => updateSurface({ ...surface, baseRoundness })} - /> - - )} -
-
- - - updateAvatarColors({ body })} - /> - - - -

- {t( - 'Définis l’identité du regard de cet avatar. Les poses s’ajoutent ensuite à cette base.' - )} -

- {(['width', 'height', 'size'] as const).map(dimension => ( - -
-

- {t( - { - width: 'Largeur', - height: 'Hauteur', - size: 'Taille proportionnelle', - }[dimension] - )} -

- - setLinked(current => ({ - ...current, - [dimension]: !current[dimension], - })) - } - /> -
-
- {(['Left', 'Right'] as Side[]).map(side => { - const width = activeAvatarEyes[`width${side}`] - const height = activeAvatarEyes[`height${side}`] - const value = - dimension === 'width' - ? width - : dimension === 'height' - ? height - : Math.max(width, height) - return ( - - updateHighlight( - active - ? linked[dimension] - ? 'both' - : side === 'Left' - ? 'left' - : 'right' - : null + onChange={size => + updateSurface( + scaleSurface(surface, size, { width: 120, height: 120, depth: 100 }) ) } - onChange={next => - dimension === 'size' - ? updateAvatarEyeSize(side, next) - : updateAvatarEyeDimension(side, dimension, next) - } /> - ) - })} -
-
- ))} - -
-
-

{t('Position et espacement')}

-

- {t('Coordonnées propres à l’avatar, indépendantes des poses.')} + updateSurface({ ...surface, width })} + /> + updateSurface({ ...surface, height })} + /> + updateSurface({ ...surface, depth })} + /> + {(surface.type === 'cube' || surface.type === 'diamond') && ( + updateHighlight(active ? 'head' : null)} + onChange={roundness => updateSurface({ ...surface, roundness })} + /> + )} + {surface.type === 'cylinder' && ( + updateHighlight(active ? 'head' : null)} + onChange={roundness => updateSurface({ ...surface, roundness })} + /> + )} + {(surface.type === 'cylinder' || surface.type === 'cone') && ( + updateHighlight(active ? 'head' : null)} + onChange={morphRoundness => + updateSurface({ ...surface, morphRoundness }) + } + /> + )} + {surface.type === 'cone' && ( + <> + updateHighlight(active ? 'head' : null)} + onChange={tipRoundness => + updateSurface({ ...surface, tipRoundness }) + } + /> + updateHighlight(active ? 'head' : null)} + onChange={baseRoundness => + updateSurface({ ...surface, baseRoundness }) + } + /> + + )} +

+ + + + updateAvatarColors({ body })} + /> + + + +

+ {t( + 'Définis l’identité du regard de cet avatar. Les poses s’ajoutent ensuite à cette base.' + )}

-
- - setLinked(current => ({ ...current, position: !current.position })) - } - /> -
-
- {(['Left', 'Right'] as Side[]).map(side => ( -
-

{t(side === 'Left' ? 'Ĺ’il gauche' : 'Ĺ’il droit')}

- - updateHighlight( - active - ? linked.position - ? 'both' - : side === 'Left' - ? 'left' - : 'right' - : null - ) - } - onChange={value => updateAvatarEyePosition(side, 'X', value)} - /> - - updateHighlight( - active - ? linked.position - ? 'both' - : side === 'Left' - ? 'left' - : 'right' - : null - ) - } - onChange={value => updateAvatarEyePosition(side, 'Y', value)} - /> -
- ))} -
-
- updateHighlight(active ? 'both' : null)} - onChange={spacing => updateAvatarEyes({ spacing })} - /> -
- - -
- - - setLinked(current => ({ ...current, rotation: !current.rotation })) - } - /> -
-
- - updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null) - } - onChange={leftAngle => - updateAvatarEyes({ - leftAngle, - ...(linked.rotation ? { rightAngle: -leftAngle } : {}), - }) - } - /> - - updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null) - } - onChange={rightAngle => - updateAvatarEyes({ - rightAngle, - ...(linked.rotation ? { leftAngle: -rightAngle } : {}), - }) - } - /> -
-
- - - updateAvatarColors({ eyes })} - /> - - - - )} - {!bodyEditing && ( - <> - - - - updateImmediate({ ...expression, bodyColor })} - /> - {expression.bodyColor && ( - - )} - - - - updateHighlight(active ? 'head' : null)} - onChange={value => updateImmediate({ ...expression, headX: value })} - /> - updateHighlight(active ? 'head' : null)} - onChange={value => updateImmediate({ ...expression, headY: value })} - /> - updateHighlight(active ? 'head' : null)} - onChange={value => updateImmediate({ ...expression, headZ: value })} - /> - - - - - - updateImmediate({ ...expression, eyeColor })} - /> - {expression.eyeColor && ( - - )} - - {(['width', 'height', 'size'] as const).map(dimension => ( - -
-

- {t( - { - width: 'Largeur', - height: 'Hauteur', - size: 'Taille proportionnelle', - }[dimension] - )} -

- - setLinked(current => ({ - ...current, - [dimension]: !current[dimension], - })) - } - /> -
-
- {(['Left', 'Right'] as Side[]).map(side => { - const width = expression[`width${side}`] - const height = expression[`height${side}`] - const value = - dimension === 'width' - ? width - : dimension === 'height' - ? height - : Math.max(width, height) - return ( - - updateHighlight( - active - ? linked[dimension] - ? 'both' - : side === 'Left' - ? 'left' - : 'right' - : null + {(['width', 'height', 'size'] as const).map(dimension => ( + +
+

+ {t( + { + width: 'Largeur', + height: 'Hauteur', + size: 'Taille proportionnelle', + }[dimension] + )} +

+ + setLinked(current => ({ + ...current, + [dimension]: !current[dimension], + })) + } + /> +
+
+ {(['Left', 'Right'] as Side[]).map(side => { + const width = activeAvatarEyes[`width${side}`] + const height = activeAvatarEyes[`height${side}`] + const value = + dimension === 'width' + ? width + : dimension === 'height' + ? height + : Math.max(width, height) + return ( + + updateHighlight( + active + ? linked[dimension] + ? 'both' + : side === 'Left' + ? 'left' + : 'right' + : null + ) + } + onChange={next => + dimension === 'size' + ? updateAvatarEyeSize(side, next) + : updateAvatarEyeDimension(side, dimension, next) + } + /> ) - } - onChange={next => - dimension === 'size' - ? updateSize(side, next) - : updateDimension(side, dimension, next) + })} +
+
+ ))} + +
+
+

{t('Position et espacement')}

+

+ {t('Coordonnées propres à l’avatar, indépendantes des poses.')} +

+
+ + setLinked(current => ({ ...current, position: !current.position })) } /> - ) - })} -
-
- ))} - - -
-
-

{t('Ĺ’il gauche')}

- updateHighlight(active ? 'left' : null)} - onChange={value => updateImmediate({ ...expression, positionXLeft: value })} - /> - updateHighlight(active ? 'left' : null)} - onChange={value => updateImmediate({ ...expression, positionYLeft: value })} - /> -
-
-

{t('Ĺ’il droit')}

- updateHighlight(active ? 'right' : null)} - onChange={value => - updateImmediate({ ...expression, positionXRight: value }) - } - /> - updateHighlight(active ? 'right' : null)} - onChange={value => - updateImmediate({ ...expression, positionYRight: value }) - } - /> -
-
-
- updateHighlight(active ? 'both' : null)} - onChange={updateSpacing} - /> -
-
- -
- - - setLinked(current => ({ ...current, rotation: !current.rotation })) - } - /> -
-
- - updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null) - } - onChange={value => - updateImmediate({ - ...expression, - leftAngle: value, - ...(linked.rotation ? { rightAngle: -value } : {}), - }) - } - /> - - updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null) - } - onChange={value => - updateImmediate({ - ...expression, - rightAngle: value, - ...(linked.rotation ? { leftAngle: -value } : {}), - }) - } - /> -
-
- - - - - updateImmediate({ ...expression, perspective: value })} - /> -
- {t('Afficher le maillage')} - -
- -
-
- - )} -
- )} - - {!sequenceEditing && !editing && bodyEditing && ( -
-
- - -
- -
- )} - - {!sequenceEditing && !editing && !bodyEditing && mode === 'expressions' && ( -
- -
-
-

{expressions.length} presets

-

{t('Expressions')}

-
- {t('Double-clic pour modifier')} -
-
- {expressions.map((preset, index) => ( - - transitionToExpression(preset, index)} - onEdit={() => openExpressionEditor(index, preset)} - onDuplicate={() => duplicateExpression(index, preset)} - onDelete={() => { - openExpressionEditor(index, preset) - setDeleteExpressionOpen(true) - }} - draggable - onDragStart={event => { - expressionDragOrigin.current = expressions - expressionDragPreview.current = expressions - draggedExpressionId.current = preset.id - setDraggingExpressionId(preset.id) - event.dataTransfer.effectAllowed = 'move' - }} - onDragEnter={() => previewExpressionMove(preset.id)} - onDragOver={event => { - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - }} - onDrop={event => { - event.preventDefault() - commitExpressionMove(preset.id) - }} - onDragEnd={cancelExpressionMove} - /> - - ))} - -
-
- - - { - springSpeedRef.current = value - setSpringSpeed(value) - }} - /> -
- - -
-
-
- )} - - {!sequenceEditing && !editing && !bodyEditing && mode === 'states' && ( -
- -
-
-

- {sequences.length} {t('animations')} -

-

{t('Animations')}

-
-
-
- {groupSequences(sequences).map(group => ( -
event.preventDefault()} - onDrop={event => { - event.preventDefault() - commitStateMove(null, group.name) - }} - > - - {group.sequences.every(sequence => sequence.builtIn) - ? t(group.name) - : group.name} - -
- {group.sequences.map(sequence => { - const firstStep = sequence.steps[0] - const firstExpression = firstStep - ? expressionById.get(firstStep.expressionId) - : undefined - const card = ( +
+
+ {(['Left', 'Right'] as Side[]).map(side => ( +
+

{t(side === 'Left' ? 'Ĺ’il gauche' : 'Ĺ’il droit')}

+ + updateHighlight( + active + ? linked.position + ? 'both' + : side === 'Left' + ? 'left' + : 'right' + : null + ) + } + onChange={value => updateAvatarEyePosition(side, 'X', value)} + /> + + updateHighlight( + active + ? linked.position + ? 'both' + : side === 'Left' + ? 'left' + : 'right' + : null + ) + } + onChange={value => updateAvatarEyePosition(side, 'Y', value)} + /> +
+ ))} +
+
+ updateHighlight(active ? 'both' : null)} + onChange={spacing => updateAvatarEyes({ spacing })} + /> +
+ + +
+ + + setLinked(current => ({ ...current, rotation: !current.rotation })) + } + /> +
+
+ + updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null) + } + onChange={leftAngle => + updateAvatarEyes({ + leftAngle, + ...(linked.rotation ? { rightAngle: -leftAngle } : {}), + }) + } + /> + + updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null) + } + onChange={rightAngle => + updateAvatarEyes({ + rightAngle, + ...(linked.rotation ? { leftAngle: -rightAngle } : {}), + }) + } + /> +
+
+ + + updateAvatarColors({ eyes })} + /> + + + + )} + {!bodyEditing && ( + <> + + + + updateImmediate({ ...expression, bodyColor })} + /> + {expression.bodyColor && ( + + )} + + + + updateHighlight(active ? 'head' : null)} + onChange={value => updateImmediate({ ...expression, headX: value })} + /> + updateHighlight(active ? 'head' : null)} + onChange={value => updateImmediate({ ...expression, headY: value })} + /> + updateHighlight(active ? 'head' : null)} + onChange={value => updateImmediate({ ...expression, headZ: value })} + /> + + + + + + updateImmediate({ ...expression, eyeColor })} + /> + {expression.eyeColor && ( + + )} + + {(['width', 'height', 'size'] as const).map(dimension => ( + +
+

+ {t( + { + width: 'Largeur', + height: 'Hauteur', + size: 'Taille proportionnelle', + }[dimension] + )} +

+ + setLinked(current => ({ + ...current, + [dimension]: !current[dimension], + })) + } + /> +
+
+ {(['Left', 'Right'] as Side[]).map(side => { + const width = expression[`width${side}`] + const height = expression[`height${side}`] + const value = + dimension === 'width' + ? width + : dimension === 'height' + ? height + : Math.max(width, height) + return ( + + updateHighlight( + active + ? linked[dimension] + ? 'both' + : side === 'Left' + ? 'left' + : 'right' + : null + ) + } + onChange={next => + dimension === 'size' + ? updateSize(side, next) + : updateDimension(side, dimension, next) + } + /> + ) + })} +
+
+ ))} + + +
+
+

{t('Ĺ’il gauche')}

+ updateHighlight(active ? 'left' : null)} + onChange={value => + updateImmediate({ ...expression, positionXLeft: value }) + } + /> + updateHighlight(active ? 'left' : null)} + onChange={value => + updateImmediate({ ...expression, positionYLeft: value }) + } + /> +
+
+

{t('Ĺ’il droit')}

+ updateHighlight(active ? 'right' : null)} + onChange={value => + updateImmediate({ ...expression, positionXRight: value }) + } + /> + updateHighlight(active ? 'right' : null)} + onChange={value => + updateImmediate({ ...expression, positionYRight: value }) + } + /> +
+
+
+ updateHighlight(active ? 'both' : null)} + onChange={updateSpacing} + /> +
+
+ +
+ + + setLinked(current => ({ ...current, rotation: !current.rotation })) + } + /> +
+
+ + updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null) + } + onChange={value => + updateImmediate({ + ...expression, + leftAngle: value, + ...(linked.rotation ? { rightAngle: -value } : {}), + }) + } + /> + + updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null) + } + onChange={value => + updateImmediate({ + ...expression, + rightAngle: value, + ...(linked.rotation ? { leftAngle: -value } : {}), + }) + } + /> +
+
+
+ + + + updateImmediate({ ...expression, perspective: value })} + /> +
+ {t('Afficher le maillage')} + +
+
+
+ + )} +
+ )} + + {!sequenceEditing && !editing && bodyEditing && ( +
+
+ + +
+ +
+ )} + + {!sequenceEditing && !editing && !bodyEditing && mode === 'expressions' && ( +
+ +
+
+

{expressions.length} presets

+
+ {t('Double-clic pour modifier')} +
+
+ {expressions.map((preset, index) => ( + + transitionToExpression(preset, index)} + onEdit={() => openExpressionEditor(index, preset)} + onDuplicate={() => duplicateExpression(index, preset)} + onDelete={() => { + openExpressionEditor(index, preset) + setDeleteExpressionOpen(true) + }} draggable - aria-pressed={selectedState === sequence.id} onDragStart={event => { - stateDragOrigin.current = sequences - stateDragPreview.current = sequences - draggedStateId.current = sequence.id - setDraggingStateId(sequence.id) + expressionDragOrigin.current = expressions + expressionDragPreview.current = expressions + draggedExpressionId.current = preset.id + setDraggingExpressionId(preset.id) event.dataTransfer.effectAllowed = 'move' }} - onDragEnter={() => previewStateMove(sequence.id, group.name)} + onDragEnter={() => previewExpressionMove(preset.id)} onDragOver={event => { event.preventDefault() - event.stopPropagation() event.dataTransfer.dropEffect = 'move' }} onDrop={event => { event.preventDefault() - event.stopPropagation() - commitStateMove(sequence.id, group.name) + commitExpressionMove(preset.id) }} - onDragEnd={cancelStateMove} - onClick={() => launchSequence(sequence)} - onDoubleClick={() => openSequenceEditor(sequence)} - > - - {sequence.builtIn ? t(sequence.name) : sequence.name} - - ) - return ( - - - - - openSequenceEditor(sequence)}> - - {t('Modifier')} - - duplicateState(sequence)}> - - {t('Dupliquer')} - - - { - openSequenceEditor(sequence) - setDeleteSequenceOpen(true) - }} - > - - {t('Supprimer')} - - - - - ) - })} -
-
- ))} -
- -
-
-
-
- )} - - {!sequenceEditing && !editing && !bodyEditing && mode === 'export' && ( - - - -
- -
- {t('Avatar sélectionné')} - {activeAvatar.name} -
-
-
- - - -
- - -
-
- - -
-
-

- {selectedExportAnimations.length}/{sequences.length} {t('sélectionnées')} -

-

{t('Animations Ă  exporter')}

-
- -
-
- {sequences.map(animation => { - const firstStep = animation.steps[0] - const firstExpression = firstStep - ? expressionById.get(firstStep.expressionId) - : undefined - return ( + onDragEnd={cancelExpressionMove} + /> + + ))} +
+
+ + + { + springSpeedRef.current = value + setSpringSpeed(value) + }} + /> +
+ + +
+
+
+ )} + + {!sequenceEditing && !editing && !bodyEditing && mode === 'states' && ( +
+ +
+
+

+ {sequences.length} {t('animations')} +

+
+
+
+ {groupSequences(sequences).map(group => ( +
event.preventDefault()} + onDrop={event => { + event.preventDefault() + commitStateMove(null, group.name) + }} + > + + {group.sequences.every(sequence => sequence.builtIn) + ? t(group.name) + : group.name} + +
+ {group.sequences.map(sequence => { + const firstStep = sequence.steps[0] + const firstExpression = firstStep + ? expressionById.get(firstStep.expressionId) + : undefined + const card = ( + + ) + return ( + + + + + openSequenceEditor(sequence)}> + + {t('Modifier')} + + duplicateState(sequence)}> + + {t('Dupliquer')} + + + { + openSequenceEditor(sequence) + setDeleteSequenceOpen(true) + }} + > + + {t('Supprimer')} + + + + + ) + })} +
+
+ ))} +
+ +
+
+
+
+ )} + + {!sequenceEditing && !editing && !bodyEditing && mode === 'export' && ( + + + +
+ +
+ {t('Avatar sélectionné')} + {activeAvatar.name} +
+
+
+ + + +
+ + +
+
+ + +
+
+

+ {selectedExportAnimations.length}/{sequences.length} {t('sélectionnées')} +

+

{t('Animations Ă  exporter')}

+
+ +
+
+ {sequences.map(animation => { + const firstStep = animation.steps[0] + const firstExpression = firstStep + ? expressionById.get(firstStep.expressionId) + : undefined + return ( + + ) + })} +
+
+ + +
+ + + + +
+ {t('Aperçu du mode photo')} + {activeAvatar.name} + + {snapshotSize} × {snapshotSize} px · {snapshotFormat.toUpperCase()} + +
+
+ + + + + {t('Style')} + + + {snapshotBackground !== 'transparent' && ( +
+ + {(snapshotBackground === 'linear' || snapshotBackground === 'radial') && ( + + )} +
+ )} +
+ + + +
+ {t('Format d’export')} + {t('Choisis le type de fichier généré par le mode photo.')} +
+ +
+ + +
+ {t('Définition')} + {t('Dimensions du fichier exporté.')} +
+ +
+
+
+ + +
+ + + { + prepareStudioProjectImport(event.currentTarget.files?.[0]) + event.currentTarget.value = '' + }} + /> +
+ {projectImportError && ( +

+ {projectImportError} +

+ )} +
+
+ )} +
+
+ {activeSequence && !editorPageOpen && ( + + + {statePlayerExpanded && ( + +
+
+

{t('Détails de l’animation')}

+

{activeSequenceLabel}

+

+ {activeSequence.builtIn + ? t(activeSequence.description) + : activeSequence.description} +

+
+ + {t('Mode de lecture')} · {t(activeSequence.playbackMode)} + +
+
+
+ {t('Expressions')} + {activeSequence.steps.length} + + {activeSequence.steps + .map(step => formatSeconds(step.holdMs, language)) + .join(' · ')} + +
+
+ {t('Premier clignement')} + + {activeSequence.blink.enabled + ? formatSeconds(activeSequence.blink.initialDelayMs, language) + : t('Désactivé')} + + {t('après le lancement')} +
+
+ {t('Intervalle du clignement')} + + {formatSeconds(activeSequence.blink.minIntervalMs, language)}– + {formatSeconds(activeSequence.blink.maxIntervalMs, language)} + + {t('tirage aléatoire')} +
+
+ {t('Durée du clignement')} + {activeSequence.blink.durationMs} ms + {t('fermeture et ouverture')} +
+
+
+ )} +
+
+ {activeSequence.steps.map((step, position) => { + const expressionIndex = findExpressionIndex(expressions, step.expressionId) + const preset = expressions[expressionIndex] + if (!preset) return null + return ( + ) })}
- - +
+ +
+
+
+ )} + {!editorPageOpen && ( +
+ ))} + + )} + +
+ ) } diff --git a/src/features/studio/components/StudioStage.tsx b/src/features/studio/components/StudioStage.tsx index dacce16..b825b40 100644 --- a/src/features/studio/components/StudioStage.tsx +++ b/src/features/studio/components/StudioStage.tsx @@ -3,17 +3,10 @@ import { motion } from 'motion/react' import { type CSSProperties } from 'react' import { Button } from '@/components/ui/button' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { type StudioLanguage } from '@/i18n' import { AvatarCanvas } from '@/features/rendering/components/AvatarCanvas' +import { StudioIdentity } from '@/features/studio/components/StudioIdentity' import type { StudioController } from '@/features/studio/useStudioController' export function StudioStage({ controller }: { controller: StudioController }) { @@ -27,7 +20,6 @@ export function StudioStage({ controller }: { controller: StudioController }) { expression, freezeLivePreviewForManipulation, highlight, - language, linked, mode, persistEditedEyeExpression, @@ -44,7 +36,6 @@ export function StudioStage({ controller }: { controller: StudioController }) { selectedBodyNodeId, selectedEyeSide, setEditing, - setLanguage, setSelectedEyeSide, showWire, snapshotFormat, @@ -65,31 +56,12 @@ export function StudioStage({ controller }: { controller: StudioController }) { } as CSSProperties } > -
- - Bible Strong Avatar Lab -
-
- - -
+
-