feat(scene-builder): ✨ Add PromptHistory component to render and display past prompts in the SceneBuilder UI
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
96749d3d9d
commit
884e435322
2 changed files with 315 additions and 2 deletions
203
studio/src/components/SceneBuilder/PromptHistory.tsx
Normal file
203
studio/src/components/SceneBuilder/PromptHistory.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import { ReactElement, useEffect, useRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '../../theme';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface HistoryEntry {
|
||||
promptCore: string;
|
||||
outfitDescription: string;
|
||||
background: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const HISTORY_KEY = 'imajin:prompt-history';
|
||||
const MAX_HISTORY = 20;
|
||||
|
||||
// ─── Storage helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
export function usePromptHistory(): {
|
||||
load: () => HistoryEntry[];
|
||||
push: (entry: Omit<HistoryEntry, 'timestamp'>) => void;
|
||||
} {
|
||||
function load(): HistoryEntry[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(HISTORY_KEY) ?? '[]') as HistoryEntry[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function push(entry: Omit<HistoryEntry, 'timestamp'>): void {
|
||||
const history = load();
|
||||
const newEntry: HistoryEntry = { ...entry, timestamp: new Date().toISOString() };
|
||||
const updated = [
|
||||
newEntry,
|
||||
...history.filter((h) => h.promptCore !== entry.promptCore),
|
||||
].slice(0, MAX_HISTORY);
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(updated));
|
||||
}
|
||||
|
||||
return { load, push };
|
||||
}
|
||||
|
||||
// ─── Relative time ───────────────────────────────────────────────────────────
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
try {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Styled components ───────────────────────────────────────────────────────
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const HistoryBtn = styled.button`
|
||||
background: none;
|
||||
border: 1px solid ${theme.colors.border};
|
||||
border-radius: ${theme.radius.sm};
|
||||
color: ${theme.colors.textMuted};
|
||||
cursor: pointer;
|
||||
padding: 2px ${theme.spacing.xs};
|
||||
font-size: ${theme.font.size.sm};
|
||||
line-height: 1;
|
||||
transition: ${theme.transition};
|
||||
|
||||
&:hover {
|
||||
color: ${theme.colors.text};
|
||||
border-color: ${theme.colors.borderHover};
|
||||
}
|
||||
`;
|
||||
|
||||
const Dropdown = styled.div`
|
||||
position: absolute;
|
||||
top: calc(100% + ${theme.spacing.xs});
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: ${theme.colors.bgPanel};
|
||||
border: 1px solid ${theme.colors.border};
|
||||
border-radius: ${theme.radius.md};
|
||||
width: 280px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
box-shadow: ${theme.shadow.elevated};
|
||||
|
||||
&::-webkit-scrollbar { width: 4px; }
|
||||
&::-webkit-scrollbar-track { background: transparent; }
|
||||
&::-webkit-scrollbar-thumb { background: ${theme.colors.border}; border-radius: 2px; }
|
||||
`;
|
||||
|
||||
const EmptyMsg = styled.div`
|
||||
padding: ${theme.spacing.lg};
|
||||
font-size: ${theme.font.size.sm};
|
||||
color: ${theme.colors.textDim};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const HistoryItem = styled.button`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: ${theme.spacing.sm};
|
||||
width: 100%;
|
||||
padding: ${theme.spacing.sm} ${theme.spacing.md};
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid ${theme.colors.border};
|
||||
color: ${theme.colors.text};
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: ${theme.transition};
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
&:hover {
|
||||
background: ${theme.colors.bgHover};
|
||||
}
|
||||
`;
|
||||
|
||||
const PromptPreview = styled.span`
|
||||
font-size: ${theme.font.size.sm};
|
||||
color: ${theme.colors.text};
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const TimeLabel = styled.span`
|
||||
font-size: ${theme.font.size.xs};
|
||||
color: ${theme.colors.textDim};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
// ─── Props ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PromptHistoryProps {
|
||||
onRestore: (entry: HistoryEntry) => void;
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function PromptHistory({ onRestore }: PromptHistoryProps): ReactElement {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { load } = usePromptHistory();
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function handleClickOutside(e: MouseEvent): void {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
const entries = load();
|
||||
|
||||
return (
|
||||
<Wrapper ref={wrapperRef}>
|
||||
<HistoryBtn onClick={() => setOpen((v) => !v)} title="Prompt history">
|
||||
↺
|
||||
</HistoryBtn>
|
||||
{open && (
|
||||
<Dropdown>
|
||||
{entries.length === 0 ? (
|
||||
<EmptyMsg>No history yet</EmptyMsg>
|
||||
) : (
|
||||
entries.map((entry, i) => (
|
||||
<HistoryItem
|
||||
key={i}
|
||||
onClick={() => {
|
||||
onRestore(entry);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<PromptPreview>
|
||||
{entry.promptCore.slice(0, 60) || '(empty prompt)'}
|
||||
</PromptPreview>
|
||||
<TimeLabel>{relativeTime(entry.timestamp)}</TimeLabel>
|
||||
</HistoryItem>
|
||||
))
|
||||
)}
|
||||
</Dropdown>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import type { PoseDefinition } from '@lilith/imajin-config';
|
|||
import { theme } from '../../theme';
|
||||
import type { CameraAngle, MaturityRating, SceneState, ShotType } from '../../types';
|
||||
import { PoseGallery } from './PoseGallery';
|
||||
import { PromptHistory, type HistoryEntry } from './PromptHistory';
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
|
|
@ -17,6 +18,12 @@ const Section = styled.div`
|
|||
gap: ${theme.spacing.md};
|
||||
`;
|
||||
|
||||
const SectionHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const SectionLabel = styled.div`
|
||||
font-size: ${theme.font.size.xs};
|
||||
font-weight: ${theme.font.weight.semibold};
|
||||
|
|
@ -25,6 +32,29 @@ const SectionLabel = styled.div`
|
|||
color: ${theme.colors.textMuted};
|
||||
`;
|
||||
|
||||
const SectionActions = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.spacing.xs};
|
||||
`;
|
||||
|
||||
const SmallBtn = styled.button`
|
||||
background: none;
|
||||
border: 1px solid ${theme.colors.border};
|
||||
border-radius: ${theme.radius.sm};
|
||||
color: ${theme.colors.textMuted};
|
||||
cursor: pointer;
|
||||
padding: 2px ${theme.spacing.xs};
|
||||
font-size: ${theme.font.size.sm};
|
||||
line-height: 1;
|
||||
transition: ${theme.transition};
|
||||
|
||||
&:hover {
|
||||
color: ${theme.colors.text};
|
||||
border-color: ${theme.colors.borderHover};
|
||||
}
|
||||
`;
|
||||
|
||||
const Textarea = styled.textarea`
|
||||
width: 100%;
|
||||
padding: ${theme.spacing.md};
|
||||
|
|
@ -237,13 +267,45 @@ const BG_PRESETS = [
|
|||
'natural forest',
|
||||
];
|
||||
|
||||
const LIGHTING_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'studio', label: 'Studio' },
|
||||
{ value: 'natural', label: 'Natural' },
|
||||
{ value: 'golden', label: 'Golden Hour' },
|
||||
{ value: 'dramatic', label: 'Dramatic' },
|
||||
{ value: 'neon', label: 'Neon' },
|
||||
{ value: 'backlit', label: 'Backlit' },
|
||||
];
|
||||
|
||||
const STYLE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'cinematic', label: 'Cinematic' },
|
||||
{ value: 'editorial', label: 'Editorial' },
|
||||
{ value: 'intimate', label: 'Intimate' },
|
||||
{ value: 'fashion', label: 'Fashion' },
|
||||
{ value: 'candid', label: 'Candid' },
|
||||
{ value: 'portrait', label: 'Portrait' },
|
||||
];
|
||||
|
||||
const RANDOM_EXPRESSION_MOODS = [
|
||||
'playful smile',
|
||||
'confident gaze',
|
||||
'soft expression',
|
||||
'mysterious look',
|
||||
'joyful energy',
|
||||
'serene calm',
|
||||
];
|
||||
|
||||
// ─── Props ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SceneBuilderProps {
|
||||
scene: SceneState;
|
||||
maturityRating: MaturityRating;
|
||||
onChange: (scene: SceneState) => void;
|
||||
onRestoreHistory: (promptCore: string, outfit: string, background: string) => void;
|
||||
}
|
||||
|
||||
export function SceneBuilder({ scene, maturityRating, onChange }: SceneBuilderProps): ReactElement {
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function SceneBuilder({ scene, maturityRating, onChange, onRestoreHistory }: SceneBuilderProps): ReactElement {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function update(patch: Partial<SceneState>): void {
|
||||
|
|
@ -276,10 +338,28 @@ export function SceneBuilder({ scene, maturityRating, onChange }: SceneBuilderPr
|
|||
e.target.value = '';
|
||||
}
|
||||
|
||||
function handleRandomize(): void {
|
||||
const bg = BG_PRESETS[Math.floor(Math.random() * BG_PRESETS.length)];
|
||||
const mood = RANDOM_EXPRESSION_MOODS[Math.floor(Math.random() * RANDOM_EXPRESSION_MOODS.length)];
|
||||
update({ background: bg, expressionMood: mood });
|
||||
}
|
||||
|
||||
function handleRestoreHistory(entry: HistoryEntry): void {
|
||||
onRestoreHistory(entry.promptCore, entry.outfitDescription, entry.background);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Section>
|
||||
<SectionLabel>Scene description</SectionLabel>
|
||||
<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}
|
||||
|
|
@ -373,6 +453,36 @@ export function SceneBuilder({ scene, maturityRating, onChange }: SceneBuilderPr
|
|||
</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>
|
||||
|
||||
<Section>
|
||||
<SectionLabel>Outfit</SectionLabel>
|
||||
<Input
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue