feat(studio): ✨ Update SceneBuilder and StudioLayout components to add new scene manipulation features and enhance UI layout
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
41be4a2fd6
commit
3fd0d7b8e0
2 changed files with 209 additions and 320 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { ChangeEvent, ReactElement, useRef, useState } from 'react';
|
||||
import { ChangeEvent, ReactElement, useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { PoseDefinition } from '@lilith/imajin-config';
|
||||
import { theme } from '../../theme';
|
||||
|
|
@ -6,34 +6,16 @@ import type { CameraAngle, MaturityRating, SceneState, ShotType } from '../../ty
|
|||
import { PoseGallery } from './PoseGallery';
|
||||
import { PromptHistory, type HistoryEntry } from './PromptHistory';
|
||||
|
||||
// 2-column grid — sections span 1 or 2 columns depending on content width needs
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: ${theme.spacing.xl};
|
||||
align-items: start;
|
||||
`;
|
||||
|
||||
const TabRow = styled.div`
|
||||
display: flex;
|
||||
gap: ${theme.spacing.xs};
|
||||
padding-bottom: ${theme.spacing.lg};
|
||||
border-bottom: 1px solid ${theme.colors.border};
|
||||
`;
|
||||
|
||||
const Tab = styled.button<{ $active: boolean }>`
|
||||
padding: ${theme.spacing.xs} ${theme.spacing.md};
|
||||
font-size: ${theme.font.size.sm};
|
||||
font-weight: ${theme.font.weight.semibold};
|
||||
border: 1px solid ${({ $active }) => ($active ? theme.colors.accent : theme.colors.border)};
|
||||
border-radius: ${theme.radius.full};
|
||||
background: ${({ $active }) => ($active ? theme.colors.accentDim : 'transparent')};
|
||||
color: ${({ $active }) => ($active ? theme.colors.accent : theme.colors.textMuted)};
|
||||
cursor: pointer;
|
||||
transition: ${theme.transition};
|
||||
|
||||
&:hover {
|
||||
border-color: ${({ $active }) => ($active ? theme.colors.accent : theme.colors.borderHover)};
|
||||
color: ${({ $active }) => ($active ? theme.colors.accent : theme.colors.text)};
|
||||
}
|
||||
const FullWidth = styled.div`
|
||||
grid-column: 1 / -1;
|
||||
`;
|
||||
|
||||
const Section = styled.div`
|
||||
|
|
@ -329,11 +311,8 @@ interface SceneBuilderProps {
|
|||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
type SceneTab = 'scene' | 'subject';
|
||||
|
||||
export function SceneBuilder({ scene, maturityRating, onChange, onRestoreHistory }: SceneBuilderProps): ReactElement {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [tab, setTab] = useState<SceneTab>('scene');
|
||||
|
||||
function update(patch: Partial<SceneState>): void {
|
||||
onChange({ ...scene, ...patch });
|
||||
|
|
@ -376,177 +355,166 @@ export function SceneBuilder({ scene, maturityRating, onChange, onRestoreHistory
|
|||
|
||||
return (
|
||||
<Container>
|
||||
{/* Prompt — always visible above tabs */}
|
||||
{/* Row 1: Prompt — full width */}
|
||||
<FullWidth>
|
||||
<Section>
|
||||
<SectionHeader>
|
||||
<SectionLabel>Scene description</SectionLabel>
|
||||
<SectionActions>
|
||||
<SmallBtn onClick={handleRandomize} title="Randomize background and mood">⚄</SmallBtn>
|
||||
<PromptHistory onRestore={handleRestoreHistory} />
|
||||
</SectionActions>
|
||||
</SectionHeader>
|
||||
<Textarea
|
||||
placeholder={SCENE_SAMPLES[maturityRating]}
|
||||
value={scene.promptCore}
|
||||
onChange={(e) => update({ promptCore: e.target.value })}
|
||||
rows={2}
|
||||
/>
|
||||
</Section>
|
||||
</FullWidth>
|
||||
|
||||
{/* Row 2: Background — full width (chip grid needs the space) */}
|
||||
<FullWidth>
|
||||
<Section>
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ChipGrid>
|
||||
{BG_PRESETS.map((bg) => (
|
||||
<Chip
|
||||
key={bg}
|
||||
$active={scene.background === bg}
|
||||
onClick={() => update({ background: scene.background === bg ? '' : bg })}
|
||||
>
|
||||
{bg}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGrid>
|
||||
<Input
|
||||
placeholder="Or describe a custom background…"
|
||||
value={BG_PRESETS.includes(scene.background) ? '' : scene.background}
|
||||
onChange={(e) => update({ background: e.target.value })}
|
||||
/>
|
||||
</Section>
|
||||
</FullWidth>
|
||||
|
||||
{/* Row 3: Framing (left) | Lighting (right) */}
|
||||
<Section>
|
||||
<SectionHeader>
|
||||
<SectionLabel>Scene description</SectionLabel>
|
||||
<SectionActions>
|
||||
<SmallBtn onClick={handleRandomize} title="Randomize background and mood">
|
||||
⚄
|
||||
</SmallBtn>
|
||||
<PromptHistory onRestore={handleRestoreHistory} />
|
||||
</SectionActions>
|
||||
</SectionHeader>
|
||||
<Textarea
|
||||
placeholder={SCENE_SAMPLES[maturityRating]}
|
||||
value={scene.promptCore}
|
||||
onChange={(e) => update({ promptCore: e.target.value })}
|
||||
rows={3}
|
||||
<SectionLabel>Framing</SectionLabel>
|
||||
<TwoCol>
|
||||
<div>
|
||||
<SelectLabel>Shot type</SelectLabel>
|
||||
<Select value={scene.shotType} onChange={handleShotChange}>
|
||||
{SHOT_TYPES.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<SelectLabel>Camera angle</SelectLabel>
|
||||
<Select value={scene.cameraAngle} onChange={handleAngleChange}>
|
||||
{ANGLES.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</TwoCol>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionLabel>Lighting</SectionLabel>
|
||||
<ChipGrid>
|
||||
{LIGHTING_OPTIONS.map(({ value, label }) => (
|
||||
<Chip
|
||||
key={value}
|
||||
$active={scene.lighting === value}
|
||||
onClick={() => update({ lighting: scene.lighting === value ? '' : value })}
|
||||
>
|
||||
{label}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGrid>
|
||||
</Section>
|
||||
|
||||
{/* Row 4: Style (left) | Expression & mood (right) */}
|
||||
<Section>
|
||||
<SectionLabel>Style</SectionLabel>
|
||||
<ChipGrid>
|
||||
{STYLE_OPTIONS.map(({ value, label }) => (
|
||||
<Chip
|
||||
key={value}
|
||||
$active={scene.stylePreset === value}
|
||||
onClick={() => update({ stylePreset: scene.stylePreset === value ? '' : value })}
|
||||
>
|
||||
{label}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGrid>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionLabel>Expression & mood</SectionLabel>
|
||||
<Input
|
||||
placeholder="e.g. sultry, laughing, mysterious, confident…"
|
||||
value={scene.expressionMood}
|
||||
onChange={(e) => update({ expressionMood: e.target.value })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<TabRow>
|
||||
<Tab $active={tab === 'scene'} onClick={() => setTab('scene')}>Scene</Tab>
|
||||
<Tab $active={tab === 'subject'} onClick={() => setTab('subject')}>
|
||||
Subject{scene.selectedPose ? ' ·' : ''}
|
||||
</Tab>
|
||||
</TabRow>
|
||||
{/* Row 5: Outfit (left) | Reference image (right) */}
|
||||
<Section>
|
||||
<SectionLabel>Outfit</SectionLabel>
|
||||
<Input
|
||||
placeholder="Describe clothing, fabrics, colors…"
|
||||
value={scene.outfitDescription}
|
||||
onChange={(e) => update({ outfitDescription: e.target.value })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{tab === 'scene' && (
|
||||
<>
|
||||
<Section>
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ChipGrid>
|
||||
{BG_PRESETS.map((bg) => (
|
||||
<Chip
|
||||
key={bg}
|
||||
$active={scene.background === bg}
|
||||
onClick={() => update({ background: scene.background === bg ? '' : bg })}
|
||||
>
|
||||
{bg}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGrid>
|
||||
<Input
|
||||
placeholder="Or describe a custom background…"
|
||||
value={BG_PRESETS.includes(scene.background) ? '' : scene.background}
|
||||
onChange={(e) => update({ background: e.target.value })}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<SectionLabel>Reference image</SectionLabel>
|
||||
{scene.referenceImage ? (
|
||||
<RefImageRow>
|
||||
<RefThumb src={`data:image/png;base64,${scene.referenceImage}`} alt="Reference" />
|
||||
<RefControls>
|
||||
<SliderLabel>
|
||||
Denoising strength
|
||||
<SliderValue>{scene.denoisingStrength.toFixed(2)}</SliderValue>
|
||||
</SliderLabel>
|
||||
<Slider
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={0.95}
|
||||
step={0.05}
|
||||
value={scene.denoisingStrength}
|
||||
onChange={(e) => update({ denoisingStrength: parseFloat(e.target.value) })}
|
||||
/>
|
||||
<RemoveBtn onClick={() => update({ referenceImage: undefined })}>✕ Remove</RemoveBtn>
|
||||
</RefControls>
|
||||
</RefImageRow>
|
||||
) : (
|
||||
<DropZone onClick={() => fileInputRef.current?.click()}>
|
||||
Drop or click to upload a reference image<br />
|
||||
<span style={{ fontSize: theme.font.size.xs, opacity: 0.6 }}>
|
||||
img2img conditioning
|
||||
</span>
|
||||
</DropZone>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionLabel>Framing</SectionLabel>
|
||||
<TwoCol>
|
||||
<div>
|
||||
<SelectLabel>Shot type</SelectLabel>
|
||||
<Select value={scene.shotType} onChange={handleShotChange}>
|
||||
{SHOT_TYPES.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<SelectLabel>Camera angle</SelectLabel>
|
||||
<Select value={scene.cameraAngle} onChange={handleAngleChange}>
|
||||
{ANGLES.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</TwoCol>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionLabel>Lighting</SectionLabel>
|
||||
<ChipGrid>
|
||||
{LIGHTING_OPTIONS.map(({ value, label }) => (
|
||||
<Chip
|
||||
key={value}
|
||||
$active={scene.lighting === value}
|
||||
onClick={() => update({ lighting: scene.lighting === value ? '' : value })}
|
||||
>
|
||||
{label}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGrid>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionLabel>Style</SectionLabel>
|
||||
<ChipGrid>
|
||||
{STYLE_OPTIONS.map(({ value, label }) => (
|
||||
<Chip
|
||||
key={value}
|
||||
$active={scene.stylePreset === value}
|
||||
onClick={() => update({ stylePreset: scene.stylePreset === value ? '' : value })}
|
||||
>
|
||||
{label}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGrid>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'subject' && (
|
||||
<>
|
||||
<Section>
|
||||
<SectionLabel>Outfit</SectionLabel>
|
||||
<Input
|
||||
placeholder="Describe clothing, fabrics, colors…"
|
||||
value={scene.outfitDescription}
|
||||
onChange={(e) => update({ outfitDescription: e.target.value })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionLabel>Expression & mood</SectionLabel>
|
||||
<Input
|
||||
placeholder="e.g. sultry, laughing, mysterious, confident…"
|
||||
value={scene.expressionMood}
|
||||
onChange={(e) => update({ expressionMood: e.target.value })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionLabel>Reference image</SectionLabel>
|
||||
{scene.referenceImage ? (
|
||||
<RefImageRow>
|
||||
<RefThumb
|
||||
src={`data:image/png;base64,${scene.referenceImage}`}
|
||||
alt="Reference"
|
||||
/>
|
||||
<RefControls>
|
||||
<SliderLabel>
|
||||
Denoising strength
|
||||
<SliderValue>{scene.denoisingStrength.toFixed(2)}</SliderValue>
|
||||
</SliderLabel>
|
||||
<Slider
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={0.95}
|
||||
step={0.05}
|
||||
value={scene.denoisingStrength}
|
||||
onChange={(e) => update({ denoisingStrength: parseFloat(e.target.value) })}
|
||||
/>
|
||||
<RemoveBtn onClick={() => update({ referenceImage: undefined })}>
|
||||
✕ Remove
|
||||
</RemoveBtn>
|
||||
</RefControls>
|
||||
</RefImageRow>
|
||||
) : (
|
||||
<DropZone onClick={() => fileInputRef.current?.click()}>
|
||||
Drop or click to upload a reference image<br />
|
||||
<span style={{ fontSize: theme.font.size.xs, opacity: 0.6 }}>
|
||||
Style, lighting, composition — used as img2img conditioning
|
||||
</span>
|
||||
</DropZone>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionLabel>Pose</SectionLabel>
|
||||
<PoseGallery selected={scene.selectedPose} onSelect={handlePoseSelect} />
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
{/* Row 6: Pose — full width */}
|
||||
<FullWidth>
|
||||
<Section>
|
||||
<SectionLabel>Pose</SectionLabel>
|
||||
<PoseGallery selected={scene.selectedPose} onSelect={handlePoseSelect} />
|
||||
</Section>
|
||||
</FullWidth>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { ReactElement, ReactNode, useState } from 'react';
|
||||
import { ReactElement, ReactNode } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '../../theme';
|
||||
|
||||
// ─── Layout shell ─────────────────────────────────────────────────────────────
|
||||
|
||||
const Root = styled.div<{ $panelOpen: boolean }>`
|
||||
const Root = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr ${({ $panelOpen }) => ($panelOpen ? '360px' : '48px')};
|
||||
grid-template-columns: 220px 1fr 340px;
|
||||
grid-template-rows: 48px 1fr;
|
||||
grid-template-areas:
|
||||
"header header header"
|
||||
|
|
@ -14,7 +14,6 @@ const Root = styled.div<{ $panelOpen: boolean }>`
|
|||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: ${theme.colors.bg};
|
||||
transition: grid-template-columns 0.2s ease;
|
||||
`;
|
||||
|
||||
const Header = styled.header`
|
||||
|
|
@ -76,9 +75,7 @@ const GenerateBar = styled.div`
|
|||
background: ${theme.colors.bgPanel};
|
||||
`;
|
||||
|
||||
// ─── Right panel ─────────────────────────────────────────────────────────────
|
||||
|
||||
type PanelTab = 'settings' | 'results';
|
||||
// ─── Right panel — always-visible Settings + Results stack ────────────────────
|
||||
|
||||
const RightPanel = styled.aside`
|
||||
grid-area: panel;
|
||||
|
|
@ -89,83 +86,37 @@ const RightPanel = styled.aside`
|
|||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const TabBtn = styled.button<{ $active: boolean }>`
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
transform: rotate(180deg);
|
||||
padding: ${theme.spacing.sm} 6px;
|
||||
font-size: ${theme.font.size.xs};
|
||||
font-weight: ${theme.font.weight.semibold};
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border: none;
|
||||
border-radius: ${theme.radius.sm};
|
||||
background: ${({ $active }) => ($active ? theme.colors.accentDim : 'transparent')};
|
||||
color: ${({ $active }) => ($active ? theme.colors.accent : theme.colors.textMuted)};
|
||||
cursor: pointer;
|
||||
transition: ${theme.transition};
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: ${({ $active }) => ($active ? theme.colors.accent : theme.colors.text)};
|
||||
background: ${({ $active }) => ($active ? theme.colors.accentDim : theme.colors.bgCard)};
|
||||
}
|
||||
const PanelSection = styled.div<{ $flex?: number; $maxHeight?: string }>`
|
||||
flex: ${({ $flex }) => $flex ?? '0 0 auto'};
|
||||
${({ $maxHeight }) => $maxHeight ? `max-height: ${$maxHeight};` : ''}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const CollapseBtn = styled.button<{ $panelOpen: boolean }>`
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
const PanelSectionHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid ${theme.colors.border};
|
||||
border-radius: ${theme.radius.sm};
|
||||
background: transparent;
|
||||
color: ${theme.colors.textMuted};
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
transition: ${theme.transition};
|
||||
|
||||
&:hover {
|
||||
color: ${theme.colors.text};
|
||||
border-color: ${theme.colors.borderHover};
|
||||
}
|
||||
`;
|
||||
|
||||
const PanelHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: ${theme.spacing.sm} ${theme.spacing.md};
|
||||
padding: ${theme.spacing.sm} ${theme.spacing.lg};
|
||||
border-bottom: 1px solid ${theme.colors.border};
|
||||
min-height: 40px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const PanelTabs = styled.div`
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
`;
|
||||
|
||||
const PanelTab = styled.button<{ $active: boolean }>`
|
||||
padding: ${theme.spacing.xs} ${theme.spacing.sm};
|
||||
const PanelSectionTitle = styled.div`
|
||||
font-size: ${theme.font.size.xs};
|
||||
font-weight: ${theme.font.weight.semibold};
|
||||
letter-spacing: 0.06em;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid ${({ $active }) => ($active ? theme.colors.accent : theme.colors.border)};
|
||||
border-radius: ${theme.radius.sm};
|
||||
background: ${({ $active }) => ($active ? theme.colors.accentDim : 'transparent')};
|
||||
color: ${({ $active }) => ($active ? theme.colors.accent : theme.colors.textMuted)};
|
||||
cursor: pointer;
|
||||
transition: ${theme.transition};
|
||||
|
||||
&:hover {
|
||||
color: ${({ $active }) => ($active ? theme.colors.accent : theme.colors.text)};
|
||||
border-color: ${({ $active }) => ($active ? theme.colors.accent : theme.colors.borderHover)};
|
||||
}
|
||||
color: ${theme.colors.textMuted};
|
||||
`;
|
||||
|
||||
const PanelBody = styled.div`
|
||||
const PanelSectionCount = styled.div`
|
||||
margin-left: auto;
|
||||
font-size: ${theme.font.size.xs};
|
||||
color: ${theme.colors.textDim};
|
||||
`;
|
||||
|
||||
const PanelScroll = styled.div`
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: ${theme.spacing.lg};
|
||||
|
|
@ -175,12 +126,10 @@ const PanelBody = styled.div`
|
|||
&::-webkit-scrollbar-thumb { background: ${theme.colors.border}; border-radius: 2px; }
|
||||
`;
|
||||
|
||||
const CollapsedStrip = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: ${theme.spacing.sm};
|
||||
gap: ${theme.spacing.sm};
|
||||
const Divider = styled.div`
|
||||
height: 1px;
|
||||
background: ${theme.colors.border};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -195,24 +144,8 @@ interface StudioLayoutProps {
|
|||
}
|
||||
|
||||
export function StudioLayout({ sidebar, main, generateBar, settings, gallery, imageCount }: StudioLayoutProps): ReactElement {
|
||||
const [activeTab, setActiveTab] = useState<PanelTab>('settings');
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
|
||||
function handleTabClick(tab: PanelTab): void {
|
||||
if (!panelOpen) {
|
||||
setPanelOpen(true);
|
||||
setActiveTab(tab);
|
||||
} else if (activeTab === tab) {
|
||||
setPanelOpen(false);
|
||||
} else {
|
||||
setActiveTab(tab);
|
||||
}
|
||||
}
|
||||
|
||||
const resultsLabel = imageCount > 0 ? `Results (${imageCount})` : 'Results';
|
||||
|
||||
return (
|
||||
<Root $panelOpen={panelOpen}>
|
||||
<Root>
|
||||
<Header>
|
||||
<Logo>imajin <span>studio</span></Logo>
|
||||
<HeaderSub>Infinite identity-preserving photo generation</HeaderSub>
|
||||
|
|
@ -226,38 +159,26 @@ export function StudioLayout({ sidebar, main, generateBar, settings, gallery, im
|
|||
</Main>
|
||||
|
||||
<RightPanel>
|
||||
{panelOpen ? (
|
||||
<>
|
||||
<PanelHeader>
|
||||
<PanelTabs>
|
||||
<PanelTab $active={activeTab === 'settings'} onClick={() => handleTabClick('settings')}>
|
||||
Settings
|
||||
</PanelTab>
|
||||
<PanelTab $active={activeTab === 'results'} onClick={() => handleTabClick('results')}>
|
||||
{resultsLabel}
|
||||
</PanelTab>
|
||||
</PanelTabs>
|
||||
<CollapseBtn $panelOpen={panelOpen} onClick={() => setPanelOpen(false)} title="Collapse panel">
|
||||
›
|
||||
</CollapseBtn>
|
||||
</PanelHeader>
|
||||
<PanelBody>
|
||||
{activeTab === 'settings' ? settings : gallery}
|
||||
</PanelBody>
|
||||
</>
|
||||
) : (
|
||||
<CollapsedStrip>
|
||||
<CollapseBtn $panelOpen={panelOpen} onClick={() => setPanelOpen(true)} title="Expand panel">
|
||||
‹
|
||||
</CollapseBtn>
|
||||
<TabBtn $active={false} onClick={() => handleTabClick('settings')}>
|
||||
Settings
|
||||
</TabBtn>
|
||||
<TabBtn $active={false} onClick={() => handleTabClick('results')}>
|
||||
{resultsLabel}
|
||||
</TabBtn>
|
||||
</CollapsedStrip>
|
||||
)}
|
||||
{/* Settings — scrollable, takes natural height up to 60% */}
|
||||
<PanelSection $maxHeight="60%">
|
||||
<PanelSectionHeader>
|
||||
<PanelSectionTitle>Settings</PanelSectionTitle>
|
||||
</PanelSectionHeader>
|
||||
<PanelScroll>{settings}</PanelScroll>
|
||||
</PanelSection>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Results — fills remaining space */}
|
||||
<PanelSection $flex={1}>
|
||||
<PanelSectionHeader>
|
||||
<PanelSectionTitle>Results</PanelSectionTitle>
|
||||
{imageCount > 0 && (
|
||||
<PanelSectionCount>{imageCount} image{imageCount !== 1 ? 's' : ''}</PanelSectionCount>
|
||||
)}
|
||||
</PanelSectionHeader>
|
||||
<PanelScroll>{gallery}</PanelScroll>
|
||||
</PanelSection>
|
||||
</RightPanel>
|
||||
</Root>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue