feat: Structured prompt editing for Pro model
Backend: - Added prompt_structure JSON column to clips DB (migration in init_db) - Updated GenerateRequest model with optional prompt_structure dict - build_prompt() method combines structured fields into single prompt: 'Generate a track with the following elements:\n\n**Style:** [style]\n...' - create_clip() accepts and stores prompt_structure JSON - get_clips()/get_clip() parse prompt_structure back to dict Frontend: - New StructuredPromptEditor component with 8 collapsible fields: Style, Timbre, Structure, Mood, Tempo, Instrumentation, Effects, Additional - Fields show filled/empty state with colored dots - Expand/collapse individual fields - 'Expand All' button - Counter: 'X of 8 fields filled' - Generator component updated with Basic/Advanced mode toggle (Pro only) - Basic: single textarea (works for both models) - Advanced: StructuredPromptEditor (Pro only) - Mode resets to Basic when switching to Clip Preview - DetailPanel shows structured prompt breakdown when present - Each filled field shown in its own labeled box - Style, Timbre, Structure, Mood, Tempo, Instrumentation, Effects, Additional - Clip interface updated with prompt_structure field across all components Architecture KB and Tasks KB updated.
This commit is contained in:
1 parent
a79b2806dc
commit
70beb89ad9
8 files changed
+272
-24
No files matched your search
+22
-3
@@ -78,15 +78,20 @@ async def init_db():
|
|||||||
await db.execute("ALTER TABLE clips ADD COLUMN key TEXT")
|
await db.execute("ALTER TABLE clips ADD COLUMN key TEXT")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Column already exists
|
pass # Column already exists
|
||||||
|
# Migration: add prompt_structure column if it doesn't exist
|
||||||
|
try:
|
||||||
|
await db.execute("ALTER TABLE clips ADD COLUMN prompt_structure TEXT")
|
||||||
|
except Exception:
|
||||||
|
pass # Column already exists
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
async def create_clip(prompt: str, model: str, duration: int) -> str:
|
async def create_clip(prompt: str, model: str, duration: int, prompt_structure: str = None) -> str:
|
||||||
clip_id = str(uuid.uuid4())
|
clip_id = str(uuid.uuid4())
|
||||||
filename = f"{clip_id}.mp3"
|
filename = f"{clip_id}.mp3"
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT INTO clips (id, prompt, model, duration_seconds, filename, status) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO clips (id, prompt, model, duration_seconds, filename, status, prompt_structure) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
(clip_id, prompt, model, duration, filename, "generating")
|
(clip_id, prompt, model, duration, filename, "generating", prompt_structure)
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return clip_id
|
return clip_id
|
||||||
@@ -121,6 +126,13 @@ async def get_clips(limit: int = 50, offset: int = 0):
|
|||||||
clips = [dict(row) for row in rows]
|
clips = [dict(row) for row in rows]
|
||||||
for clip in clips:
|
for clip in clips:
|
||||||
clip["created_at"] = format_timestamp(clip.get("created_at"))
|
clip["created_at"] = format_timestamp(clip.get("created_at"))
|
||||||
|
# Parse prompt_structure JSON
|
||||||
|
if clip.get("prompt_structure"):
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
clip["prompt_structure"] = json.loads(clip["prompt_structure"])
|
||||||
|
except Exception:
|
||||||
|
clip["prompt_structure"] = None
|
||||||
return clips
|
return clips
|
||||||
|
|
||||||
async def get_clip(clip_id: str):
|
async def get_clip(clip_id: str):
|
||||||
@@ -131,6 +143,13 @@ async def get_clip(clip_id: str):
|
|||||||
if row:
|
if row:
|
||||||
clip = dict(row)
|
clip = dict(row)
|
||||||
clip["created_at"] = format_timestamp(clip.get("created_at"))
|
clip["created_at"] = format_timestamp(clip.get("created_at"))
|
||||||
|
# Parse prompt_structure JSON
|
||||||
|
if clip.get("prompt_structure"):
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
clip["prompt_structure"] = json.loads(clip["prompt_structure"])
|
||||||
|
except Exception:
|
||||||
|
clip["prompt_structure"] = None
|
||||||
return clip
|
return clip
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -77,13 +77,18 @@ async def _generate_and_save(clip_id: str, prompt: str, model: str, duration: in
|
|||||||
@app.post("/api/generate", response_model=GenerateResponse)
|
@app.post("/api/generate", response_model=GenerateResponse)
|
||||||
async def generate(request: GenerateRequest, background_tasks: BackgroundTasks):
|
async def generate(request: GenerateRequest, background_tasks: BackgroundTasks):
|
||||||
duration = request.get_effective_duration()
|
duration = request.get_effective_duration()
|
||||||
clip_id = await create_clip(request.prompt, request.model, duration)
|
# Build prompt from structured fields if present
|
||||||
|
final_prompt = request.build_prompt()
|
||||||
|
# Serialize structured prompt for DB storage
|
||||||
|
import json
|
||||||
|
prompt_structure_json = json.dumps(request.prompt_structure) if request.prompt_structure else None
|
||||||
|
clip_id = await create_clip(final_prompt, request.model, duration, prompt_structure_json)
|
||||||
|
|
||||||
# Kick off generation in background
|
# Kick off generation in background
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
_generate_and_save,
|
_generate_and_save,
|
||||||
clip_id,
|
clip_id,
|
||||||
request.prompt,
|
final_prompt,
|
||||||
request.model,
|
request.model,
|
||||||
duration
|
duration
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class GenerateRequest(BaseModel):
|
|||||||
prompt: str
|
prompt: str
|
||||||
model: str = DEFAULT_MODEL
|
model: str = DEFAULT_MODEL
|
||||||
duration: Optional[int] = None # defaults to model default
|
duration: Optional[int] = None # defaults to model default
|
||||||
|
prompt_structure: Optional[dict] = None # structured prompt fields for Pro mode
|
||||||
|
|
||||||
@field_validator('model')
|
@field_validator('model')
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -40,6 +41,26 @@ class GenerateRequest(BaseModel):
|
|||||||
dur = self.duration if self.duration is not None else info["default_duration"]
|
dur = self.duration if self.duration is not None else info["default_duration"]
|
||||||
return min(dur, info["max_duration"])
|
return min(dur, info["max_duration"])
|
||||||
|
|
||||||
|
def build_prompt(self) -> str:
|
||||||
|
"""Build the final prompt from structured fields or return the simple prompt."""
|
||||||
|
if self.prompt_structure:
|
||||||
|
parts = ["Generate a track with the following elements:"]
|
||||||
|
field_order = [
|
||||||
|
("style", "Style"),
|
||||||
|
("timbre", "Timbre"),
|
||||||
|
("structure", "Structure"),
|
||||||
|
("mood", "Mood"),
|
||||||
|
("tempo", "Tempo"),
|
||||||
|
("instrumentation", "Instrumentation"),
|
||||||
|
("effects", "Effects"),
|
||||||
|
("additional", "Additional Notes"),
|
||||||
|
]
|
||||||
|
for key, label in field_order:
|
||||||
|
if key in self.prompt_structure and self.prompt_structure[key]:
|
||||||
|
parts.append(f"**{label}:** {self.prompt_structure[key]}")
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
return self.prompt
|
||||||
|
|
||||||
class GenerateResponse(BaseModel):
|
class GenerateResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
status: str
|
status: str
|
||||||
@@ -57,6 +78,9 @@ class Clip(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
error_message: Optional[str] = None
|
error_message: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
prompt_structure: Optional[dict] = None
|
||||||
|
bpm: Optional[float] = None
|
||||||
|
key: Optional[str] = None
|
||||||
|
|
||||||
class RenameRequest(BaseModel):
|
class RenameRequest(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface Clip {
|
|||||||
artwork_filename?: string | null
|
artwork_filename?: string | null
|
||||||
bpm?: number | null
|
bpm?: number | null
|
||||||
key?: string | null
|
key?: string | null
|
||||||
|
prompt_structure?: Record<string, string> | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface Clip {
|
|||||||
artwork_filename?: string | null
|
artwork_filename?: string | null
|
||||||
bpm?: number | null
|
bpm?: number | null
|
||||||
key?: string | null
|
key?: string | null
|
||||||
|
prompt_structure?: Record<string, string> | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +281,34 @@ export default function DetailPanel({ clip, isPlaying, currentClipId, onClose, o
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Structured Prompt Breakdown */}
|
||||||
|
{clip.prompt_structure && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-lyria-muted uppercase tracking-wider mb-1 block">Structured Prompt</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ key: 'style', label: 'Style' },
|
||||||
|
{ key: 'timbre', label: 'Timbre' },
|
||||||
|
{ key: 'structure', label: 'Structure' },
|
||||||
|
{ key: 'mood', label: 'Mood' },
|
||||||
|
{ key: 'tempo', label: 'Tempo' },
|
||||||
|
{ key: 'instrumentation', label: 'Instrumentation' },
|
||||||
|
{ key: 'effects', label: 'Effects' },
|
||||||
|
{ key: 'additional', label: 'Additional' },
|
||||||
|
].map(({ key, label }) => {
|
||||||
|
const val = clip.prompt_structure?.[key]
|
||||||
|
if (!val) return null
|
||||||
|
return (
|
||||||
|
<div key={key} className="bg-white/5 rounded-md p-2">
|
||||||
|
<span className="text-xs text-lyria-accent font-medium">{label}: </span>
|
||||||
|
<span className="text-sm text-white/90">{val}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-lyria-muted uppercase tracking-wider mb-1 block">Status</label>
|
<label className="text-xs text-lyria-muted uppercase tracking-wider mb-1 block">Status</label>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface Clip {
|
|||||||
artwork_filename?: string | null
|
artwork_filename?: string | null
|
||||||
bpm?: number | null
|
bpm?: number | null
|
||||||
key?: string | null
|
key?: string | null
|
||||||
|
prompt_structure?: Record<string, string> | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import StructuredPromptEditor, { PromptStructure } from './StructuredPromptEditor'
|
||||||
|
|
||||||
interface ModelInfo {
|
interface ModelInfo {
|
||||||
name: string
|
name: string
|
||||||
@@ -11,6 +12,8 @@ interface Props {
|
|||||||
onGenerate: () => void
|
onGenerate: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PromptMode = 'basic' | 'advanced'
|
||||||
|
|
||||||
export default function Generator({ onGenerate }: Props) {
|
export default function Generator({ onGenerate }: Props) {
|
||||||
const [prompt, setPrompt] = useState('')
|
const [prompt, setPrompt] = useState('')
|
||||||
const [model, setModel] = useState('lyria-3-clip-preview')
|
const [model, setModel] = useState('lyria-3-clip-preview')
|
||||||
@@ -18,6 +21,8 @@ export default function Generator({ onGenerate }: Props) {
|
|||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [models, setModels] = useState<Record<string, ModelInfo>>({})
|
const [models, setModels] = useState<Record<string, ModelInfo>>({})
|
||||||
|
const [promptMode, setPromptMode] = useState<PromptMode>('basic')
|
||||||
|
const [structuredPrompt, setStructuredPrompt] = useState<PromptStructure>({})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/models')
|
fetch('/api/models')
|
||||||
@@ -37,28 +42,40 @@ export default function Generator({ onGenerate }: Props) {
|
|||||||
if (info) {
|
if (info) {
|
||||||
setDuration(info.default_duration)
|
setDuration(info.default_duration)
|
||||||
}
|
}
|
||||||
|
// Reset to basic mode when switching to Clip Preview
|
||||||
|
if (newModel === 'lyria-3-clip-preview') {
|
||||||
|
setPromptMode('basic')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentModelInfo = models[model]
|
const currentModelInfo = models[model]
|
||||||
const maxDuration = currentModelInfo?.max_duration || 30
|
const maxDuration = currentModelInfo?.max_duration || 30
|
||||||
const minDuration = 5
|
const minDuration = 5
|
||||||
|
const isPro = model === 'lyria-3-pro-preview'
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!prompt.trim()) return
|
if (promptMode === 'basic' && !prompt.trim()) return
|
||||||
|
if (promptMode === 'advanced' && Object.values(structuredPrompt).every(v => !v?.trim())) return
|
||||||
|
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
prompt: prompt.trim(),
|
||||||
|
model,
|
||||||
|
duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promptMode === 'advanced' && isPro) {
|
||||||
|
body.prompt_structure = structuredPrompt
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch('/api/generate', {
|
const res = await fetch('/api/generate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body)
|
||||||
prompt: prompt.trim(),
|
|
||||||
model,
|
|
||||||
duration
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -78,6 +95,7 @@ export default function Generator({ onGenerate }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setPrompt('')
|
setPrompt('')
|
||||||
|
setStructuredPrompt({})
|
||||||
onGenerate()
|
onGenerate()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Something went wrong')
|
setError(err instanceof Error ? err.message : 'Something went wrong')
|
||||||
@@ -93,18 +111,7 @@ export default function Generator({ onGenerate }: Props) {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
{/* Model & Duration */}
|
||||||
<label className="block text-xs text-lyria-muted mb-1.5">
|
|
||||||
Prompt
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={prompt}
|
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
|
||||||
placeholder="Dark distorted kickdrum with atmospheric synth pads, 180 BPM..."
|
|
||||||
className="w-full h-24 bg-lyria-dark border border-white/10 rounded-md px-3 py-2 text-sm text-white placeholder:text-lyria-muted/50 focus:outline-none focus:border-lyria-accent resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="block text-xs text-lyria-muted mb-1.5">
|
<label className="block text-xs text-lyria-muted mb-1.5">
|
||||||
@@ -143,13 +150,69 @@ export default function Generator({ onGenerate }: Props) {
|
|||||||
<span>{duration}s / {maxDuration}s max</span>
|
<span>{duration}s / {maxDuration}s max</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Prompt Mode Toggle (Pro only) */}
|
||||||
|
{isPro && (
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPromptMode('basic')}
|
||||||
|
className={`flex-1 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
promptMode === 'basic'
|
||||||
|
? 'bg-lyria-accent text-black'
|
||||||
|
: 'text-lyria-muted hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Basic
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPromptMode('advanced')}
|
||||||
|
className={`flex-1 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
promptMode === 'advanced'
|
||||||
|
? 'bg-lyria-accent text-black'
|
||||||
|
: 'text-lyria-muted hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Advanced
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Basic Prompt */}
|
||||||
|
{(promptMode === 'basic' || !isPro) && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-lyria-muted mb-1.5">
|
||||||
|
Prompt
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder="Dark distorted kickdrum with atmospheric synth pads, 180 BPM..."
|
||||||
|
className="w-full h-24 bg-lyria-dark border border-white/10 rounded-md px-3 py-2 text-sm text-white placeholder:text-lyria-muted/50 focus:outline-none focus:border-lyria-accent resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Advanced Prompt */}
|
||||||
|
{promptMode === 'advanced' && isPro && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-lyria-muted mb-1.5">
|
||||||
|
Structured Prompt
|
||||||
|
</label>
|
||||||
|
<StructuredPromptEditor
|
||||||
|
value={structuredPrompt}
|
||||||
|
onChange={setStructuredPrompt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-xs text-red-400 break-words">{error}</p>
|
<p className="text-xs text-red-400 break-words">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading || !prompt.trim()}
|
disabled={isLoading || (promptMode === 'basic' ? !prompt.trim() : Object.values(structuredPrompt).every(v => !v?.trim()))}
|
||||||
className="w-full py-2.5 bg-lyria-accent text-white text-sm font-medium rounded-md hover:bg-lyria-accent/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
className="w-full py-2.5 bg-lyria-accent text-white text-sm font-medium rounded-md hover:bg-lyria-accent/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Generating...' : 'Generate'}
|
{isLoading ? 'Generating...' : 'Generate'}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export interface PromptStructure {
|
||||||
|
style?: string
|
||||||
|
timbre?: string
|
||||||
|
structure?: string
|
||||||
|
mood?: string
|
||||||
|
tempo?: string
|
||||||
|
instrumentation?: string
|
||||||
|
effects?: string
|
||||||
|
additional?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: PromptStructure
|
||||||
|
onChange: (value: PromptStructure) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELDS = [
|
||||||
|
{ key: 'style', label: 'Style / Genre', placeholder: 'e.g., Gabber, Ambient, Orchestral, Phonk...' },
|
||||||
|
{ key: 'timbre', label: 'Timbre / Texture', placeholder: 'e.g., Warm analog synths, gritty distortion, ethereal pads...' },
|
||||||
|
{ key: 'structure', label: 'Structure', placeholder: 'e.g., Intro-Verse-Chorus-Verse-Chorus-Bridge-Chorus-Outro' },
|
||||||
|
{ key: 'mood', label: 'Mood / Energy', placeholder: 'e.g., Dark and aggressive, euphoric, melancholic, triumphant...' },
|
||||||
|
{ key: 'tempo', label: 'Tempo / BPM', placeholder: 'e.g., 180 BPM, slow build to 200 BPM, variable...' },
|
||||||
|
{ key: 'instrumentation', label: 'Instrumentation', placeholder: 'e.g., Heavy kick drums, distorted leads, vocal chops, sub-bass...' },
|
||||||
|
{ key: 'effects', label: 'Effects / Processing', placeholder: 'e.g., Heavy sidechain compression, reverb tails, bitcrush...' },
|
||||||
|
{ key: 'additional', label: 'Additional Notes', placeholder: 'Any other details, references, or specific instructions...' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export default function StructuredPromptEditor({ value, onChange }: Props) {
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set(['style', 'structure', 'mood']))
|
||||||
|
|
||||||
|
const toggleField = (key: string) => {
|
||||||
|
setExpanded(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key)
|
||||||
|
} else {
|
||||||
|
next.add(key)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateField = (key: string, text: string) => {
|
||||||
|
onChange({ ...value, [key]: text })
|
||||||
|
}
|
||||||
|
|
||||||
|
const filledCount = FIELDS.filter(f => value[f.key as keyof PromptStructure]?.trim()).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-lyria-muted">
|
||||||
|
{filledCount} of {FIELDS.length} fields filled
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(new Set(FIELDS.map(f => f.key)))}
|
||||||
|
className="text-xs text-lyria-accent hover:text-lyria-accent/80 transition-colors"
|
||||||
|
>
|
||||||
|
Expand All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{FIELDS.map(({ key, label, placeholder }) => {
|
||||||
|
const isExpanded = expanded.has(key)
|
||||||
|
const hasValue = value[key as keyof PromptStructure]?.trim()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="border border-white/5 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleField(key)}
|
||||||
|
className={`w-full flex items-center justify-between px-3 py-2 text-sm transition-colors ${
|
||||||
|
hasValue ? 'bg-white/5' : 'bg-transparent hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${hasValue ? 'bg-lyria-accent' : 'bg-lyria-muted/30'}`} />
|
||||||
|
<span className={hasValue ? 'text-white' : 'text-lyria-muted'}>{label}</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className={`w-3.5 h-3.5 text-lyria-muted transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-3 pb-3">
|
||||||
|
<textarea
|
||||||
|
value={value[key as keyof PromptStructure] || ''}
|
||||||
|
onChange={(e) => updateField(key, e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full h-20 bg-black/20 border border-white/5 rounded-md px-3 py-2 text-sm text-white placeholder:text-lyria-muted/30 focus:outline-none focus:border-lyria-accent/50 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user