From 398c7f53e238f662028283070d6d65b7b18e0659 Mon Sep 17 00:00:00 2001 From: smontlouis Date: Thu, 13 Aug 2026 22:47:52 +0200 Subject: [PATCH] feat(studio): refine responsive workspace navigation Add a resizable stacked layout and move avatar selection into a dedicated grid page. --- package.json | 1 + pnpm-lock.yaml | 14 +++ src/app/studio-utils.ts | 2 +- src/app/styles.css | 109 +++++++++++++----- src/components/ui/resizable.tsx | 40 +++++++ .../studio/components/AvatarDrawer.tsx | 58 ++-------- .../studio/components/StudioInspector.tsx | 24 ++-- src/features/studio/components/StudioView.tsx | 49 +++++++- src/i18n/index.ts | 1 + src/i18n/zh.ts | 1 + 10 files changed, 208 insertions(+), 91 deletions(-) create mode 100644 src/components/ui/resizable.tsx diff --git a/package.json b/package.json index 89ccbbb..66df015 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "motion": "^13.0.0", "react": "19.2.3", "react-dom": "19.2.3", + "react-resizable-panels": "^4.12.2", "tailwind-merge": "^3.6.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08f796c..2c26efc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) + react-resizable-panels: + specifier: ^4.12.2 + version: 4.12.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -879,6 +882,12 @@ packages: peerDependencies: react: ^19.2.3 + react-resizable-panels@4.12.2: + resolution: {integrity: sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react@19.2.3: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} @@ -1648,6 +1657,11 @@ snapshots: react: 19.2.3 scheduler: 0.27.0 + react-resizable-panels@4.12.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react@19.2.3: {} reselect@5.2.0: {} diff --git a/src/app/studio-utils.ts b/src/app/studio-utils.ts index 87c96db..a94a60f 100644 --- a/src/app/studio-utils.ts +++ b/src/app/studio-utils.ts @@ -12,7 +12,7 @@ import { type BodyNode } from '@/features/avatar/body' import { poseFromExpression, renderAvatar, type Expression } from '@/features/avatar/geometry' import { type SurfaceConfig } from '@/features/avatar/surfaces' -export type Mode = 'manual' | 'expressions' | 'states' | 'export' +export type Mode = 'avatars' | 'manual' | 'expressions' | 'states' | 'export' export type ExportFormat = 'react' | 'javascript' export type SnapshotFormat = 'svg' | 'png' export type PlaybackStatus = 'playing' | 'paused' | 'stopped' diff --git a/src/app/styles.css b/src/app/styles.css index f833403..75b5c34 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -65,6 +65,9 @@ input { min-height: 100vh; grid-template-columns: minmax(460px, 1fr) minmax(440px, 520px); } +.studio-root { + min-height: 100vh; +} .stage-column { position: sticky; @@ -290,6 +293,7 @@ input { position: relative; z-index: 1; width: min(72vh, 72vw, 680px); + max-width: 100%; aspect-ratio: 1; } .stage-playback-status { @@ -1289,14 +1293,15 @@ p { font-size: 8px; } .avatar-shelf .avatar-grid { - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 6px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; } .avatar-shelf .avatar-card, .avatar-shelf .avatar-add { - height: 74px; - min-height: 74px; - border-radius: 10px; + height: auto; + min-height: 0; + aspect-ratio: 1; + border-radius: 14px; } .avatar-sort-item { min-width: 0; @@ -1318,13 +1323,14 @@ p { cursor: grabbing; } .avatar-shelf .avatar-card svg { - height: 48px; - flex: 0 1 48px; + height: auto; + min-height: 0; + flex: 1 1 auto; } .avatar-shelf .avatar-card > span { min-height: 12px; - padding: 0 3px; - font-size: 8px; + padding: 2px 4px; + font-size: 9px; } .tabs { position: sticky; @@ -2333,20 +2339,77 @@ p { } @media (max-width: 980px) { + .studio-root, .studio { - grid-template-columns: 1fr; + height: 100dvh; + min-height: 0; + overflow: hidden; + } + .studio-mobile-resizable { + display: flex; + width: 100%; + background: #f5f6f8; + } + .mobile-stage-panel, + .mobile-inspector-panel { + height: 100%; + min-height: 0; + overflow: hidden; + } + .studio-resize-handle { + z-index: 30; + height: 14px; + min-height: 14px; + border-block: 1px solid #d9dce3; + background: #f5f6f8; + touch-action: none; + } + .studio-resize-handle::after { + height: 40px; + } + .studio-resize-handle > div { + width: 38px; + height: 4px; + border-radius: 999px; + background: #b9bfca; + rotate: 0deg !important; + transform: none; + transition: + background-color 160ms ease, + width 160ms ease; + } + .studio-resize-handle:hover > div, + .studio-resize-handle:focus-visible > div, + .studio-resize-handle[data-resize-handle-active] > div { + width: 46px; + background: #5b7fe5; } .stage-column { position: relative; - height: min(72vh, 680px); - min-height: 480px; + width: 100%; + height: 100%; + min-height: 0; } .inspector { + width: 100%; + height: 100%; + min-height: 0; + overscroll-behavior: contain; border-top: 1px solid #d9dce3; border-left: 0; } .avatar-wrap { - width: min(62vh, 82vw); + width: auto; + max-width: 82vw; + height: min(100%, 82vw, 620px); + max-height: 100%; + } + .workspace-footer, + .state-playback-footer, + .mobile-mode-tabs { + right: 0; + left: 0; + width: 100%; } } @@ -2358,17 +2421,7 @@ p { height: 100%; overflow: hidden; } - .studio { - height: 100dvh; - min-height: 0; - grid-template-rows: auto minmax(0, 1fr); - overflow: hidden; - } .stage-column { - position: relative; - aspect-ratio: 1; - height: auto; - min-height: 0; padding: 10px; } .stage-identity { @@ -2434,7 +2487,10 @@ p { display: none; } .avatar-wrap { - width: min(42dvh, 78vw, 340px); + width: auto; + max-width: 100%; + height: min(100%, 78vw, 340px); + max-height: 100%; } .photo-capture-bar { bottom: max(36px, calc(env(safe-area-inset-bottom) + 24px)); @@ -2461,7 +2517,8 @@ p { display: none; } .inspector { - height: auto; + width: 100%; + height: 100%; min-height: 0; overscroll-behavior: contain; padding: 0 14px calc(var(--app-footer-clearance) + env(safe-area-inset-bottom)); @@ -2550,7 +2607,7 @@ p { grid-template-columns: repeat(2, minmax(0, 1fr)); } .avatar-shelf .avatar-grid { - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); padding-right: 0; } .avatar-edit { diff --git a/src/components/ui/resizable.tsx b/src/components/ui/resizable.tsx new file mode 100644 index 0000000..a6f9c69 --- /dev/null +++ b/src/components/ui/resizable.tsx @@ -0,0 +1,40 @@ +import * as ResizablePrimitive from 'react-resizable-panels' + +import { cn } from '@/lib/utils' + +function ResizablePanelGroup({ className, ...props }: ResizablePrimitive.GroupProps) { + return ( + + ) +} + +function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) { + return +} + +function ResizableHandle({ + withHandle, + className, + ...props +}: ResizablePrimitive.SeparatorProps & { + withHandle?: boolean +}) { + return ( + div]:rotate-90', + className + )} + {...props} + > + {withHandle &&
} + + ) +} + +export { ResizableHandle, ResizablePanel, ResizablePanelGroup } diff --git a/src/features/studio/components/AvatarDrawer.tsx b/src/features/studio/components/AvatarDrawer.tsx index 2c88dd1..38a8f08 100644 --- a/src/features/studio/components/AvatarDrawer.tsx +++ b/src/features/studio/components/AvatarDrawer.tsx @@ -1,5 +1,5 @@ import { Copy, Pencil, Plus, Trash2 } from 'lucide-react' -import { motion, useDragControls } from 'motion/react' +import { motion } from 'motion/react' import { Button } from '@/components/ui/button' import { @@ -9,20 +9,12 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from '@/components/ui/context-menu' -import { 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 drawerDragControls = useDragControls() +export function AvatarPage({ controller }: { controller: StudioController }) { const { activateAvatar, activeAvatarId, @@ -46,38 +38,12 @@ export function AvatarDrawer({ } = controller return ( - { - if (info.offset.y > 70 || info.velocity.y > 600) onOpenChange(false) - }} - /> - } - > - -
-
- Avatars - {t('Double-clic pour modifier')} -
- {avatars.length} -
+
+
+ {t('Double-clic pour modifier')} + {avatars.length} +
{avatars.map(avatar => ( { setFocusAvatarName(false) activateAvatar(avatar.id, true) - onOpenChange(false) }} > { setFocusAvatarName(false) activateAvatar(avatar.id, true) - onOpenChange(false) }} > {t('Modifier')} @@ -159,7 +123,6 @@ export function AvatarDrawer({ disabled={avatars.length <= 1} onClick={() => { activateAvatar(avatar.id, false, true) - onOpenChange(false) setDeleteAvatarOpen(true) }} > @@ -172,16 +135,13 @@ export function AvatarDrawer({
- +
) } diff --git a/src/features/studio/components/StudioInspector.tsx b/src/features/studio/components/StudioInspector.tsx index 042f39d..6a371d5 100644 --- a/src/features/studio/components/StudioInspector.tsx +++ b/src/features/studio/components/StudioInspector.tsx @@ -63,13 +63,11 @@ 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 { AvatarPage } 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, @@ -208,7 +206,7 @@ export function StudioInspector({ controller }: { controller: StudioController } workspaceBackButtonRef, } = controller return ( - +
@@ -330,11 +328,13 @@ export function StudioInspector({ controller }: { controller: StudioController } {t( mode === 'manual' ? 'Pose' - : mode === 'expressions' - ? 'Expressions' - : mode === 'states' - ? 'Animations' - : 'Exporter' + : mode === 'avatars' + ? 'Avatars' + : mode === 'expressions' + ? 'Expressions' + : mode === 'states' + ? 'Animations' + : 'Exporter' )} @@ -1229,6 +1229,7 @@ export function StudioInspector({ controller }: { controller: StudioController } )}
)} + {!editorPageOpen && mode === 'avatars' && } {!sequenceEditing && !editing && bodyEditing && (