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:
parent
d71d7478bb
commit
94bc5223e6
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/components/ui/drawer.tsx
Normal file
35
src/components/ui/drawer.tsx
Normal 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 }
|
||||||
166
src/features/studio/components/AvatarDrawer.tsx
Normal file
166
src/features/studio/components/AvatarDrawer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
src/features/studio/components/StudioIdentity.tsx
Normal file
45
src/features/studio/components/StudioIdentity.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user