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:
Claude Code 2026-03-30 11:58:40 -07:00
parent 96749d3d9d
commit 884e435322
2 changed files with 315 additions and 2 deletions

View 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>
);
}

View file

@ -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