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() const { t } = useStudioLanguage()
return ( return (
<AccordionItem value={value} className="export-accordion-item"> <AccordionItem value={value} className={`export-accordion-item export-accordion-${value}`}>
<AccordionTrigger className="export-accordion-trigger"> <AccordionTrigger className="export-accordion-trigger">
<span> <span>
<strong>{t(title)}</strong> <strong>{t(title)}</strong>

View File

@ -165,10 +165,24 @@ input {
text-wrap: pretty; text-wrap: pretty;
} }
.brand { .studio-identity {
position: absolute; 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; top: 28px;
left: 32px; margin-left: 32px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 9px; gap: 9px;
@ -190,10 +204,9 @@ input {
box-shadow: 0 0 0 3px rgb(91 127 229 / 15%); box-shadow: 0 0 0 3px rgb(91 127 229 / 15%);
} }
.language-picker { .language-picker {
position: absolute; position: relative;
z-index: 5;
top: 24px; top: 24px;
right: 28px; margin-right: 28px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
@ -225,6 +238,9 @@ input {
border-radius: 5px; border-radius: 5px;
box-shadow: 0 0 0 2px #7895ee; box-shadow: 0 0 0 2px #7895ee;
} }
.inspector-identity {
display: none;
}
.avatar-wrap { .avatar-wrap {
position: relative; position: relative;
@ -377,6 +393,9 @@ input {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
} }
.export-accordion-snapshot {
order: -1;
}
.export-accordion-item { .export-accordion-item {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@ -782,11 +801,13 @@ input {
} }
.inspector { .inspector {
--app-footer-clearance: 140px;
height: 100vh; height: 100vh;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
scrollbar-gutter: stable; scrollbar-gutter: stable;
padding: 28px 26px 48px; padding: 28px 26px var(--app-footer-clearance);
border-left: 1px solid #d9dce3; border-left: 1px solid #d9dce3;
background: #f5f6f8; background: #f5f6f8;
} }
@ -796,7 +817,7 @@ input {
padding-top: 0; padding-top: 0;
} }
.inspector.state-player-active { .inspector.state-player-active {
padding-bottom: 128px; --app-footer-clearance: 220px;
} }
@keyframes studio-workspace-in { @keyframes studio-workspace-in {
from { from {
@ -826,7 +847,7 @@ input {
display: flex; display: flex;
min-height: calc(100vh - 76px); min-height: calc(100vh - 76px);
flex-direction: column; flex-direction: column;
padding-bottom: 74px; padding-bottom: 0;
} }
.workspace-header { .workspace-header {
position: sticky; position: sticky;
@ -905,6 +926,16 @@ input {
gap: 16px; gap: 16px;
margin-bottom: 22px; 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 { .eyebrow {
margin: 0 0 6px; margin: 0 0 6px;
color: #7185c4; color: #7185c4;
@ -990,7 +1021,7 @@ p {
position: fixed; position: fixed;
z-index: 9; z-index: 9;
right: 0; right: 0;
bottom: 0; bottom: 62px;
width: min(520px, 100vw); width: min(520px, 100vw);
box-sizing: border-box; box-sizing: border-box;
padding: 10px 18px; padding: 10px 18px;
@ -1262,6 +1293,151 @@ p {
padding: 5px; padding: 5px;
backdrop-filter: blur(14px); 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, .state-buttons button,
.button-row button, .button-row button,
.reset { .reset {
@ -2131,30 +2307,195 @@ p {
} }
@media (max-width: 600px) { @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 { .stage-column {
min-height: 420px; position: relative;
padding: 18px; 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 { .photo-capture-bar {
bottom: 48px; bottom: max(12px, env(safe-area-inset-bottom));
left: 12px;
transform: none;
} }
.photo-capture-button { .photo-capture-button {
min-width: 168px; width: 40px;
min-width: 40px;
height: 40px;
padding: 0;
border-radius: 50%;
} }
.brand { .photo-capture-button > svg {
top: 18px; width: 18px;
left: 20px; height: 18px;
}
.photo-capture-label,
.photo-format-badge,
.photo-help-button {
display: none;
} }
.inspector { .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 { .numeric-field {
grid-template-columns: 100px minmax(76px, 1fr) 66px; grid-template-columns: 100px minmax(76px, 1fr) 66px;
gap: 7px; 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 { .expression-grid {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
.state-buttons {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.avatar-grid { .avatar-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@ -2174,4 +2515,32 @@ p {
.workspace-header-actions .state-player > span { .workspace-header-actions .state-player > span {
display: none; 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, Copy,
Download, Download,
FileCode2, FileCode2,
Move3D,
Pencil, Pencil,
Play,
Plus, Plus,
RotateCcw, RotateCcw,
Smile,
Trash2, Trash2,
Upload, Upload,
} from 'lucide-react' } from 'lucide-react'
import { motion } from 'motion/react' import { AnimatePresence, motion } from 'motion/react'
import { type CSSProperties } from 'react' import { type CSSProperties } from 'react'
import { Accordion } from '@/components/ui/accordion' import { Accordion } from '@/components/ui/accordion'
@ -25,6 +28,7 @@ import {
ContextMenuTrigger, ContextMenuTrigger,
} from '@/components/ui/context-menu' } from '@/components/ui/context-menu'
import { Field, FieldTitle } from '@/components/ui/field' import { Field, FieldTitle } from '@/components/ui/field'
import { Drawer } from '@/components/ui/drawer'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { import {
Select, Select,
@ -35,7 +39,6 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { import {
ControlSection, ControlSection,
@ -46,13 +49,7 @@ import {
StatePlayer, StatePlayer,
} from '@/app/components/common' } from '@/app/components/common'
import { ColorField, LinkButton, NumericField } from '@/app/components/controls' import { ColorField, LinkButton, NumericField } from '@/app/components/controls'
import { import { formatSeconds, scaleSurface, type Side, type SnapshotFormat } from '@/app/studio-utils'
formatSeconds,
scaleSurface,
type Mode,
type Side,
type SnapshotFormat,
} from '@/app/studio-utils'
import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace' import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace'
import { findExpressionIndex, groupSequences } from '@/features/animation/sequences' import { findExpressionIndex, groupSequences } from '@/features/animation/sequences'
import { defaultAvatarEyes } from '@/features/avatar/avatars' import { defaultAvatarEyes } from '@/features/avatar/avatars'
@ -66,9 +63,13 @@ import {
import { defaultExpression } from '@/features/avatar/presets' import { defaultExpression } from '@/features/avatar/presets'
import { surfaceLabels, surfacePresets } from '@/features/avatar/surfaces' import { surfaceLabels, surfacePresets } from '@/features/avatar/surfaces'
import { type SnapshotBackground } from '@/features/export/snapshotExporter' 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 type { StudioController } from '@/features/studio/useStudioController'
import { useState } from 'react'
export function StudioInspector({ controller }: { controller: StudioController }) { export function StudioInspector({ controller }: { controller: StudioController }) {
const [avatarDrawerOpen, setAvatarDrawerOpen] = useState(false)
const { const {
activateAvatar, activateAvatar,
activeAvatar, activeAvatar,
@ -161,6 +162,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
setExportFormat, setExportFormat,
setFocusAvatarName, setFocusAvatarName,
setLinked, setLinked,
setLanguage,
setMode, setMode,
setSelectedSequenceStepId, setSelectedSequenceStepId,
setSequenceEditing, setSequenceEditing,
@ -206,9 +208,16 @@ export function StudioInspector({ controller }: { controller: StudioController }
workspaceBackButtonRef, workspaceBackButtonRef,
} = controller } = controller
return ( return (
<Drawer open={avatarDrawerOpen} onOpenChange={setAvatarDrawerOpen}>
<main <main
className={`inspector ${editing ? 'expression-workspace-active' : sequenceEditing ? 'sequence-workspace-active' : bodyEditing ? 'body-workspace' : 'studio-workspace'}${activeSequence && !editorPageOpen ? ' state-player-active' : ''}`} 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 && ( {sequenceEditing && !editing && (
<motion.div <motion.div
key={`sequence-${sequenceEditing.sourceId ?? 'new'}`} key={`sequence-${sequenceEditing.sourceId ?? 'new'}`}
@ -298,124 +307,38 @@ export function StudioInspector({ controller }: { controller: StudioController }
setFocusAvatarName(false) 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> </div>
</header> </header>
) : ( ) : null)}
<>
<section className="avatar-shelf" aria-label={t('Choisir un avatar')}> <AnimatePresence mode="wait" initial={false}>
<div className="avatar-shelf-heading">
<strong>Avatars</strong>
<span>{t('Double-clic pour modifier')}</span>
</div>
<div className="avatar-grid">
{avatars.map(avatar => (
<motion.div <motion.div
className="avatar-sort-item" className="mode-page-transition"
data-dragging={draggingAvatarId === avatar.id || undefined} key={mode}
key={avatar.id} initial={reduceMotion ? false : { opacity: 0, x: 18 }}
layout="position" animate={{ opacity: 1, x: 0 }}
animate={{ exit={reduceMotion ? { opacity: 1, x: 0 } : { opacity: 0, x: -18 }}
opacity: draggingAvatarId === avatar.id ? 0.28 : 1, transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
scale: draggingAvatarId === avatar.id ? 0.96 : 1,
}}
transition={
reduceMotion
? { duration: 0 }
: { type: 'spring', stiffness: 520, damping: 42, mass: 0.7 }
}
> >
<ContextMenu> {!editorPageOpen && (
<ContextMenuTrigger <header className="mode-page-header">
render={ <p className="eyebrow">{activeAvatar.name}</p>
<Button <h1>
className="avatar-card" {t(
variant="outline" mode === 'manual'
aria-pressed={activeAvatarId === avatar.id} ? 'Pose'
type="button" : mode === 'expressions'
draggable ? 'Expressions'
onDragStart={event => { : mode === 'states'
avatarDragOrigin.current = avatarsRef.current ? 'Animations'
avatarDragPreview.current = avatarsRef.current : 'Exporter'
draggedAvatarId.current = avatar.id )}
setDraggingAvatarId(avatar.id) </h1>
event.dataTransfer.effectAllowed = 'move' </header>
}} )}
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>
</>
))}
{!editing && mode === 'manual' && ( {!editing && mode === 'manual' && (
<div className="panel-stack"> <div className="panel-stack">
@ -663,7 +586,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
/> />
<div className="surface-grid"> <div className="surface-grid">
{bodyPrimitiveTypes.map(type => { {bodyPrimitiveTypes.map(type => {
const previewSurface = type === surface.type ? surface : surfacePresets[type] const previewSurface =
type === surface.type ? surface : surfacePresets[type]
return ( return (
<Button <Button
className="surface-card" className="surface-card"
@ -751,7 +675,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
max={2} max={2}
step={0.01} step={0.01}
onActiveChange={active => updateHighlight(active ? 'head' : null)} onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={morphRoundness => updateSurface({ ...surface, morphRoundness })} onChange={morphRoundness =>
updateSurface({ ...surface, morphRoundness })
}
/> />
)} )}
{surface.type === 'cone' && ( {surface.type === 'cone' && (
@ -763,7 +689,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
max={2} max={2}
step={0.01} step={0.01}
onActiveChange={active => updateHighlight(active ? 'head' : null)} onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={tipRoundness => updateSurface({ ...surface, tipRoundness })} onChange={tipRoundness =>
updateSurface({ ...surface, tipRoundness })
}
/> />
<NumericField <NumericField
label="Rondeur base" label="Rondeur base"
@ -772,7 +700,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
max={2} max={2}
step={0.01} step={0.01}
onActiveChange={active => updateHighlight(active ? 'head' : null)} 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} value={expression.positionXLeft}
unit="u" unit="u"
onActiveChange={active => updateHighlight(active ? 'left' : null)} onActiveChange={active => updateHighlight(active ? 'left' : null)}
onChange={value => updateImmediate({ ...expression, positionXLeft: value })} onChange={value =>
updateImmediate({ ...expression, positionXLeft: value })
}
/> />
<NumericField <NumericField
label="Verticale" label="Verticale"
value={expression.positionYLeft} value={expression.positionYLeft}
unit="u" unit="u"
onActiveChange={active => updateHighlight(active ? 'left' : null)} onActiveChange={active => updateHighlight(active ? 'left' : null)}
onChange={value => updateImmediate({ ...expression, positionYLeft: value })} onChange={value =>
updateImmediate({ ...expression, positionYLeft: value })
}
/> />
</div> </div>
<div className="eye-column"> <div className="eye-column">
@ -1322,7 +1256,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
<div className="preset-header"> <div className="preset-header">
<div> <div>
<p className="eyebrow">{expressions.length} presets</p> <p className="eyebrow">{expressions.length} presets</p>
<h2>{t('Expressions')}</h2>
</div> </div>
<span>{t('Double-clic pour modifier')}</span> <span>{t('Double-clic pour modifier')}</span>
</div> </div>
@ -1438,7 +1371,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
<p className="eyebrow"> <p className="eyebrow">
{sequences.length} {t('animations')} {sequences.length} {t('animations')}
</p> </p>
<h2>{t('Animations')}</h2>
</div> </div>
</div> </div>
<div className="state-groups"> <div className="state-groups">
@ -1492,7 +1424,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
onDoubleClick={() => openSequenceEditor(sequence)} onDoubleClick={() => openSequenceEditor(sequence)}
> >
<ExpressionPreview <ExpressionPreview
expression={firstExpression ?? expressions[0] ?? defaultExpression} expression={
firstExpression ?? expressions[0] ?? defaultExpression
}
surface={surface} surface={surface}
bodyNodes={bodyNodes} bodyNodes={bodyNodes}
colors={activeAvatar.colors} colors={activeAvatar.colors}
@ -1566,7 +1500,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
)} )}
{!sequenceEditing && !editing && !bodyEditing && mode === 'export' && ( {!sequenceEditing && !editing && !bodyEditing && mode === 'export' && (
<Accordion className="export-panel"> <Accordion className="export-panel" defaultValue={['snapshot']}>
<ExportSection <ExportSection
value="avatar" value="avatar"
title="Exporter l’avatar" title="Exporter l’avatar"
@ -1687,7 +1621,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
> >
<Download /> <Download />
{t( {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> </Button>
</ExportSection> </ExportSection>
@ -1729,7 +1665,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
{ value: 'linear', label: t('Dégradé linéaire') }, { value: 'linear', label: t('Dégradé linéaire') },
{ value: 'radial', label: t('Dégradé radial') }, { 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')}> <SelectTrigger aria-label={t('Style d’arrière-plan')}>
<SelectValue /> <SelectValue />
@ -1791,7 +1729,10 @@ export function StudioInspector({ controller }: { controller: StudioController }
</div> </div>
<Select <Select
value={snapshotSize} 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)} onValueChange={next => next && setSnapshotSize(next)}
> >
<SelectTrigger aria-label={t('Définition du mode photo')}> <SelectTrigger aria-label={t('Définition du mode photo')}>
@ -1845,6 +1786,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
</ExportSection> </ExportSection>
</Accordion> </Accordion>
)} )}
</motion.div>
</AnimatePresence>
{activeSequence && !editorPageOpen && ( {activeSequence && !editorPageOpen && (
<motion.footer <motion.footer
className={`state-playback-footer${statePlayerExpanded ? ' is-expanded' : ''}`} className={`state-playback-footer${statePlayerExpanded ? ' is-expanded' : ''}`}
@ -1972,6 +1915,50 @@ export function StudioInspector({ controller }: { controller: StudioController }
</div> </div>
</motion.footer> </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> </main>
</Drawer>
) )
} }

View File

@ -3,17 +3,10 @@ import { motion } from 'motion/react'
import { type CSSProperties } from 'react' import { type CSSProperties } from 'react'
import { Button } from '@/components/ui/button' 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { type StudioLanguage } from '@/i18n'
import { AvatarCanvas } from '@/features/rendering/components/AvatarCanvas' import { AvatarCanvas } from '@/features/rendering/components/AvatarCanvas'
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
import type { StudioController } from '@/features/studio/useStudioController' import type { StudioController } from '@/features/studio/useStudioController'
export function StudioStage({ controller }: { controller: StudioController }) { export function StudioStage({ controller }: { controller: StudioController }) {
@ -27,7 +20,6 @@ export function StudioStage({ controller }: { controller: StudioController }) {
expression, expression,
freezeLivePreviewForManipulation, freezeLivePreviewForManipulation,
highlight, highlight,
language,
linked, linked,
mode, mode,
persistEditedEyeExpression, persistEditedEyeExpression,
@ -44,7 +36,6 @@ export function StudioStage({ controller }: { controller: StudioController }) {
selectedBodyNodeId, selectedBodyNodeId,
selectedEyeSide, selectedEyeSide,
setEditing, setEditing,
setLanguage,
setSelectedEyeSide, setSelectedEyeSide,
showWire, showWire,
snapshotFormat, snapshotFormat,
@ -65,31 +56,12 @@ export function StudioStage({ controller }: { controller: StudioController }) {
} as CSSProperties } as CSSProperties
} }
> >
<div className="brand"> <StudioIdentity
<span className="brand-mark" /> className="stage-identity"
Bible Strong <em>Avatar Lab</em> language={controller.language}
</div> setLanguage={controller.setLanguage}
<div className="language-picker"> t={t}
<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>
<AvatarCanvas <AvatarCanvas
expression={canvasExpression} expression={canvasExpression}
avatarEyes={activeAvatarEyes} avatarEyes={activeAvatarEyes}
@ -138,9 +110,14 @@ export function StudioStage({ controller }: { controller: StudioController }) {
)} )}
<TooltipProvider> <TooltipProvider>
<div className="photo-capture-bar"> <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 /> <Camera />
{t('Prendre une photo')} <span className="photo-capture-label">{t('Prendre une photo')}</span>
<span className="photo-format-badge">{snapshotFormat.toUpperCase()}</span> <span className="photo-format-badge">{snapshotFormat.toUpperCase()}</span>
</Button> </Button>
<Tooltip> <Tooltip>