feat(studio): redesign responsive navigation

Add a unified bottom navigation, avatar drawer, and responsive preview and inspector layout across desktop and mobile.
This commit is contained in:
smontlouis 2026-08-13 19:55:20 +02:00
parent d71d7478bb
commit 94bc5223e6
7 changed files with 2354 additions and 1775 deletions

View File

@ -109,7 +109,7 @@ export function ExportSection({
}) {
const { t } = useStudioLanguage()
return (
<AccordionItem value={value} className="export-accordion-item">
<AccordionItem value={value} className={`export-accordion-item export-accordion-${value}`}>
<AccordionTrigger className="export-accordion-trigger">
<span>
<strong>{t(title)}</strong>

View File

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

View File

@ -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 <DialogPrimitive.Root {...props} />
}
function DrawerContent({ className, children, ...props }: DialogPrimitive.Popup.Props) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Backdrop className="drawer-backdrop" />
<DialogPrimitive.Viewport className="drawer-viewport">
<DialogPrimitive.Popup className={cn('drawer-content', className)} {...props}>
{children}
</DialogPrimitive.Popup>
</DialogPrimitive.Viewport>
</DialogPrimitive.Portal>
)
}
function DrawerClose(props: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close {...props} />
}
function DrawerTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return <DialogPrimitive.Title className={cn('drawer-title', className)} {...props} />
}
function DrawerDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return <DialogPrimitive.Description className={cn('drawer-description', className)} {...props} />
}
export { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerTitle }

View File

@ -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 (
<DrawerContent className="avatar-drawer">
<DrawerClose className="drawer-handle" aria-label="Close avatar picker">
<span />
</DrawerClose>
<div className="avatar-drawer-heading">
<div>
<DrawerTitle>Avatars</DrawerTitle>
<DrawerDescription>{t('Double-clic pour modifier')}</DrawerDescription>
</div>
<span>{avatars.length}</span>
</div>
<section className="avatar-shelf" aria-label={t('Choisir un avatar')}>
<div className="avatar-grid">
{avatars.map(avatar => (
<motion.div
className="avatar-sort-item"
data-dragging={draggingAvatarId === avatar.id || undefined}
key={avatar.id}
layout="position"
animate={{
opacity: draggingAvatarId === avatar.id ? 0.28 : 1,
scale: draggingAvatarId === avatar.id ? 0.96 : 1,
}}
transition={
reduceMotion
? { duration: 0 }
: { type: 'spring', stiffness: 520, damping: 42, mass: 0.7 }
}
>
<ContextMenu>
<ContextMenuTrigger
render={
<Button
className="avatar-card"
variant="outline"
aria-pressed={activeAvatarId === avatar.id}
type="button"
draggable
onDragStart={event => {
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)
}}
>
<ExpressionPreview
expression={expressions[0] ?? defaultExpression}
surface={avatar.body.primary}
bodyNodes={avatar.body.nodes}
colors={avatar.colors}
avatarEyes={avatar.eyes ?? defaultAvatarEyes}
id={`avatar-${avatar.id}`}
/>
<span>{avatar.name}</span>
</Button>
}
/>
<ContextMenuContent>
<ContextMenuItem
onClick={() => {
setFocusAvatarName(false)
activateAvatar(avatar.id, true)
onOpenChange(false)
}}
>
<Pencil /> {t('Modifier')}
</ContextMenuItem>
<ContextMenuItem onClick={() => duplicateAvatar(avatar)}>
<Copy /> {t('Dupliquer')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={avatars.length <= 1}
onClick={() => {
activateAvatar(avatar.id, false, true)
onOpenChange(false)
setDeleteAvatarOpen(true)
}}
>
<Trash2 /> {t('Supprimer')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</motion.div>
))}
<Button
variant="outline"
className="avatar-add creation-card"
onClick={() => {
createNewAvatar()
onOpenChange(false)
}}
aria-label={t('Nouvel avatar')}
>
<Plus />
</Button>
</div>
</section>
</DrawerContent>
)
}

View File

@ -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<StudioController, 'language' | 'setLanguage' | 't'> & {
className?: string
}
export function StudioIdentity({ className = '', language, setLanguage, t }: StudioIdentityProps) {
return (
<div className={`studio-identity ${className}`.trim()}>
<div className="brand">
<span className="brand-mark" />
Bible Strong <em>Avatar Lab</em>
</div>
<div className="language-picker">
<span aria-hidden="true">{language === 'en' ? '🇬🇧' : language === 'fr' ? '🇫🇷' : '🇨🇳'}</span>
<Select
value={language}
items={[
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'zh-CN', label: '中文' },
]}
onValueChange={next => next && setLanguage(next as StudioLanguage)}
>
<SelectTrigger aria-label={t('Langue de l’interface')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="fr">Français</SelectItem>
<SelectItem value="zh-CN">中文</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@ -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,9 +208,16 @@ export function StudioInspector({ controller }: { controller: StudioController }
workspaceBackButtonRef,
} = controller
return (
<Drawer open={avatarDrawerOpen} onOpenChange={setAvatarDrawerOpen}>
<main
className={`inspector ${editing ? 'expression-workspace-active' : sequenceEditing ? 'sequence-workspace-active' : bodyEditing ? 'body-workspace' : 'studio-workspace'}${activeSequence && !editorPageOpen ? ' state-player-active' : ''}`}
>
<StudioIdentity
className="inspector-identity"
language={language}
setLanguage={setLanguage}
t={t}
/>
{sequenceEditing && !editing && (
<motion.div
key={`sequence-${sequenceEditing.sourceId ?? 'new'}`}
@ -298,124 +307,38 @@ export function StudioInspector({ controller }: { controller: StudioController }
setFocusAvatarName(false)
}}
/>
<p>{t('Choisis la forme principale puis assemble les primitives autour d’elle.')}</p>
<p>
{t('Choisis la forme principale puis assemble les primitives autour d’elle.')}
</p>
</div>
</header>
) : (
<>
<section className="avatar-shelf" aria-label={t('Choisir un avatar')}>
<div className="avatar-shelf-heading">
<strong>Avatars</strong>
<span>{t('Double-clic pour modifier')}</span>
</div>
<div className="avatar-grid">
{avatars.map(avatar => (
) : null)}
<AnimatePresence mode="wait" initial={false}>
<motion.div
className="avatar-sort-item"
data-dragging={draggingAvatarId === avatar.id || undefined}
key={avatar.id}
layout="position"
animate={{
opacity: draggingAvatarId === avatar.id ? 0.28 : 1,
scale: draggingAvatarId === avatar.id ? 0.96 : 1,
}}
transition={
reduceMotion
? { duration: 0 }
: { type: 'spring', stiffness: 520, damping: 42, mass: 0.7 }
}
className="mode-page-transition"
key={mode}
initial={reduceMotion ? false : { opacity: 0, x: 18 }}
animate={{ opacity: 1, x: 0 }}
exit={reduceMotion ? { opacity: 1, x: 0 } : { opacity: 0, x: -18 }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
>
<ContextMenu>
<ContextMenuTrigger
render={
<Button
className="avatar-card"
variant="outline"
aria-pressed={activeAvatarId === avatar.id}
type="button"
draggable
onDragStart={event => {
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)
}}
>
<ExpressionPreview
expression={expressions[0] ?? defaultExpression}
surface={avatar.body.primary}
bodyNodes={avatar.body.nodes}
colors={avatar.colors}
avatarEyes={avatar.eyes ?? defaultAvatarEyes}
id={`avatar-${avatar.id}`}
/>
<span>{avatar.name}</span>
</Button>
}
/>
<ContextMenuContent>
<ContextMenuItem
onClick={() => {
setFocusAvatarName(false)
activateAvatar(avatar.id, true)
}}
>
<Pencil /> {t('Modifier')}
</ContextMenuItem>
<ContextMenuItem onClick={() => duplicateAvatar(avatar)}>
<Copy /> {t('Dupliquer')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={avatars.length <= 1}
onClick={() => {
activateAvatar(avatar.id, false, true)
setDeleteAvatarOpen(true)
}}
>
<Trash2 /> {t('Supprimer')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</motion.div>
))}
<Button
variant="outline"
className="avatar-add creation-card"
onClick={createNewAvatar}
aria-label={t('Nouvel avatar')}
>
<Plus />
</Button>
</div>
</section>
<Tabs value={mode} onValueChange={value => setMode(value as Mode)}>
<TabsList className="tabs" aria-label={t('Mode d’édition')}>
<TabsTrigger value="manual">{t('Pose')}</TabsTrigger>
<TabsTrigger value="expressions">{t('Expressions')}</TabsTrigger>
<TabsTrigger value="states">{t('Animations')}</TabsTrigger>
<TabsTrigger value="export">{t('Exporter')}</TabsTrigger>
</TabsList>
</Tabs>
</>
))}
{!editorPageOpen && (
<header className="mode-page-header">
<p className="eyebrow">{activeAvatar.name}</p>
<h1>
{t(
mode === 'manual'
? 'Pose'
: mode === 'expressions'
? 'Expressions'
: mode === 'states'
? 'Animations'
: 'Exporter'
)}
</h1>
</header>
)}
{!editing && mode === 'manual' && (
<div className="panel-stack">
@ -663,7 +586,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
/>
<div className="surface-grid">
{bodyPrimitiveTypes.map(type => {
const previewSurface = type === surface.type ? surface : surfacePresets[type]
const previewSurface =
type === surface.type ? surface : surfacePresets[type]
return (
<Button
className="surface-card"
@ -751,7 +675,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
max={2}
step={0.01}
onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={morphRoundness => updateSurface({ ...surface, morphRoundness })}
onChange={morphRoundness =>
updateSurface({ ...surface, morphRoundness })
}
/>
)}
{surface.type === 'cone' && (
@ -763,7 +689,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
max={2}
step={0.01}
onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={tipRoundness => updateSurface({ ...surface, tipRoundness })}
onChange={tipRoundness =>
updateSurface({ ...surface, tipRoundness })
}
/>
<NumericField
label="Rondeur base"
@ -772,7 +700,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
max={2}
step={0.01}
onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={baseRoundness => updateSurface({ ...surface, baseRoundness })}
onChange={baseRoundness =>
updateSurface({ ...surface, baseRoundness })
}
/>
</>
)}
@ -1163,14 +1093,18 @@ export function StudioInspector({ controller }: { controller: StudioController }
value={expression.positionXLeft}
unit="u"
onActiveChange={active => updateHighlight(active ? 'left' : null)}
onChange={value => updateImmediate({ ...expression, positionXLeft: value })}
onChange={value =>
updateImmediate({ ...expression, positionXLeft: value })
}
/>
<NumericField
label="Verticale"
value={expression.positionYLeft}
unit="u"
onActiveChange={active => updateHighlight(active ? 'left' : null)}
onChange={value => updateImmediate({ ...expression, positionYLeft: value })}
onChange={value =>
updateImmediate({ ...expression, positionYLeft: value })
}
/>
</div>
<div className="eye-column">
@ -1322,7 +1256,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
<div className="preset-header">
<div>
<p className="eyebrow">{expressions.length} presets</p>
<h2>{t('Expressions')}</h2>
</div>
<span>{t('Double-clic pour modifier')}</span>
</div>
@ -1438,7 +1371,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
<p className="eyebrow">
{sequences.length} {t('animations')}
</p>
<h2>{t('Animations')}</h2>
</div>
</div>
<div className="state-groups">
@ -1492,7 +1424,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
onDoubleClick={() => openSequenceEditor(sequence)}
>
<ExpressionPreview
expression={firstExpression ?? expressions[0] ?? defaultExpression}
expression={
firstExpression ?? expressions[0] ?? defaultExpression
}
surface={surface}
bodyNodes={bodyNodes}
colors={activeAvatar.colors}
@ -1566,7 +1500,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
)}
{!sequenceEditing && !editing && !bodyEditing && mode === 'export' && (
<Accordion className="export-panel">
<Accordion className="export-panel" defaultValue={['snapshot']}>
<ExportSection
value="avatar"
title="Exporter l’avatar"
@ -1687,7 +1621,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
>
<Download />
{t(
exportFormat === 'react' ? 'Télécharger le package React' : 'Télécharger le module'
exportFormat === 'react'
? 'Télécharger le package React'
: 'Télécharger le module'
)}
</Button>
</ExportSection>
@ -1729,7 +1665,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
{ value: 'linear', label: t('Dégradé linéaire') },
{ value: 'radial', label: t('Dégradé radial') },
]}
onValueChange={next => next && setSnapshotBackground(next as SnapshotBackground)}
onValueChange={next =>
next && setSnapshotBackground(next as SnapshotBackground)
}
>
<SelectTrigger aria-label={t('Style d’arrière-plan')}>
<SelectValue />
@ -1791,7 +1729,10 @@ export function StudioInspector({ controller }: { controller: StudioController }
</div>
<Select
value={snapshotSize}
items={['512', '1024', '2048'].map(value => ({ value, label: `${value} px` }))}
items={['512', '1024', '2048'].map(value => ({
value,
label: `${value} px`,
}))}
onValueChange={next => next && setSnapshotSize(next)}
>
<SelectTrigger aria-label={t('Définition du mode photo')}>
@ -1845,6 +1786,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
</ExportSection>
</Accordion>
)}
</motion.div>
</AnimatePresence>
{activeSequence && !editorPageOpen && (
<motion.footer
className={`state-playback-footer${statePlayerExpanded ? ' is-expanded' : ''}`}
@ -1972,6 +1915,50 @@ export function StudioInspector({ controller }: { controller: StudioController }
</div>
</motion.footer>
)}
{!editorPageOpen && (
<nav className="mobile-mode-tabs" aria-label={t('Mode d’édition')}>
<Button
className="mobile-mode-tab mobile-avatar-tab"
variant="ghost"
type="button"
aria-pressed={avatarDrawerOpen}
aria-label={t('Choisir un avatar')}
onClick={() => setAvatarDrawerOpen(true)}
>
<ExpressionPreview
expression={expressions[0] ?? defaultExpression}
surface={activeAvatar.body.primary}
bodyNodes={activeAvatar.body.nodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
id={`active-avatar-tab-${activeAvatar.id}`}
/>
<span>{activeAvatar.name}</span>
</Button>
{(
[
['manual', t('Pose'), Move3D],
['expressions', t('Expressions'), Smile],
['states', t('Animations'), Play],
['export', t('Exporter'), Download],
] as const
).map(([value, label, Icon]) => (
<Button
className="mobile-mode-tab"
variant="ghost"
type="button"
key={value}
aria-pressed={mode === value}
onClick={() => setMode(value)}
>
<Icon />
<span>{label}</span>
</Button>
))}
</nav>
)}
<AvatarDrawer controller={controller} onOpenChange={setAvatarDrawerOpen} />
</main>
</Drawer>
)
}

View File

@ -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
}
>
<div className="brand">
<span className="brand-mark" />
Bible Strong <em>Avatar Lab</em>
</div>
<div className="language-picker">
<span aria-hidden="true">{language === 'en' ? '🇬🇧' : language === 'fr' ? '🇫🇷' : '🇨🇳'}</span>
<Select
value={language}
items={[
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'zh-CN', label: '中文' },
]}
onValueChange={next => next && setLanguage(next as StudioLanguage)}
>
<SelectTrigger aria-label={t('Langue de l’interface')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="fr">Français</SelectItem>
<SelectItem value="zh-CN">中文</SelectItem>
</SelectContent>
</Select>
</div>
<StudioIdentity
className="stage-identity"
language={controller.language}
setLanguage={controller.setLanguage}
t={t}
/>
<AvatarCanvas
expression={canvasExpression}
avatarEyes={activeAvatarEyes}
@ -138,9 +110,14 @@ export function StudioStage({ controller }: { controller: StudioController }) {
)}
<TooltipProvider>
<div className="photo-capture-bar">
<Button className="photo-capture-button" type="button" onClick={takePicture}>
<Button
className="photo-capture-button"
type="button"
aria-label={t('Prendre une photo')}
onClick={takePicture}
>
<Camera />
{t('Prendre une photo')}
<span className="photo-capture-label">{t('Prendre une photo')}</span>
<span className="photo-format-badge">{snapshotFormat.toUpperCase()}</span>
</Button>
<Tooltip>