diff --git a/studio/src/components/SceneBuilder/PromptHistory.tsx b/studio/src/components/SceneBuilder/PromptHistory.tsx new file mode 100644 index 00000000..38af0069 --- /dev/null +++ b/studio/src/components/SceneBuilder/PromptHistory.tsx @@ -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) => void; +} { + function load(): HistoryEntry[] { + try { + return JSON.parse(localStorage.getItem(HISTORY_KEY) ?? '[]') as HistoryEntry[]; + } catch { + return []; + } + } + + function push(entry: Omit): 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(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 ( + + setOpen((v) => !v)} title="Prompt history"> + ↺ + + {open && ( + + {entries.length === 0 ? ( + No history yet + ) : ( + entries.map((entry, i) => ( + { + onRestore(entry); + setOpen(false); + }} + > + + {entry.promptCore.slice(0, 60) || '(empty prompt)'} + + {relativeTime(entry.timestamp)} + + )) + )} + + )} + + ); +} diff --git a/studio/src/components/SceneBuilder/index.tsx b/studio/src/components/SceneBuilder/index.tsx index 68c6bdaa..c39a14b6 100644 --- a/studio/src/components/SceneBuilder/index.tsx +++ b/studio/src/components/SceneBuilder/index.tsx @@ -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(null); function update(patch: Partial): 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 (
- Scene description + + Scene description + + + ⚄ + + + +