Spaces:
Sleeping
Sleeping
| import { useEffect, useState } from 'react'; | |
| const API_BASE = import.meta.env.VITE_API_BASE_URL || ''; | |
| interface CachedPrompt { | |
| prompt_id: string; | |
| created_at: string; | |
| updated_at: string; | |
| metadata: { | |
| script: string; | |
| style: string; | |
| model: string; | |
| segments_count: number; | |
| }; | |
| segments_count: number; | |
| } | |
| export function SavedPromptsLibrary({ onClose, onReuse }: { | |
| onClose: () => void; | |
| onReuse: (payload: any) => void; | |
| }) { | |
| const [prompts, setPrompts] = useState<CachedPrompt[]>([]); | |
| const [loading, setLoading] = useState(true); | |
| const [error, setError] = useState<string | null>(null); | |
| const [editingPromptId, setEditingPromptId] = useState<string | null>(null); | |
| const [editingPayloadJson, setEditingPayloadJson] = useState(''); | |
| const [editError, setEditError] = useState<string | null>(null); | |
| const [saving, setSaving] = useState(false); | |
| const [validating, setValidating] = useState(false); | |
| const [validationResult, setValidationResult] = useState<{ | |
| valid: boolean; | |
| schema_errors?: string[]; | |
| ai_checked?: boolean; | |
| ai_valid?: boolean; | |
| ai_warnings?: string[]; | |
| ai_suggestions?: string[]; | |
| } | null>(null); | |
| useEffect(() => { | |
| loadSavedPrompts(); | |
| }, []); | |
| const loadSavedPrompts = async () => { | |
| try { | |
| setLoading(true); | |
| setError(null); | |
| const response = await fetch(`${API_BASE}/api/cached-prompts?limit=50`); | |
| if (!response.ok) throw new Error('Failed to load prompts'); | |
| const data = await response.json(); | |
| const list: CachedPrompt[] = data.prompts || []; | |
| // Deduplicate: same script + same segment count = same prompt; keep most recent | |
| const seen = new Map<string, CachedPrompt>(); | |
| const key = (p: CachedPrompt) => { | |
| const script = (p.metadata?.script || '').trim().slice(0, 300); | |
| return `${script}|${p.segments_count ?? p.metadata?.segments_count ?? 0}`; | |
| }; | |
| for (const p of list) { | |
| const k = key(p); | |
| const existing = seen.get(k); | |
| if (!existing || new Date(p.updated_at) > new Date(existing.updated_at)) { | |
| seen.set(k, p); | |
| } | |
| } | |
| setPrompts(Array.from(seen.values())); | |
| } catch (err) { | |
| setError(err instanceof Error ? err.message : 'Failed to load prompts'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const handleReuse = async (promptId: string) => { | |
| try { | |
| const response = await fetch(`${API_BASE}/api/use-cached-prompt/${promptId}`, { | |
| method: 'POST' | |
| }); | |
| if (!response.ok) throw new Error('Failed to load prompt'); | |
| const { payload } = await response.json(); | |
| onReuse(payload); | |
| onClose(); | |
| } catch (err) { | |
| alert(err instanceof Error ? err.message : 'Failed to reuse prompt'); | |
| } | |
| }; | |
| const handleDelete = async (promptId: string) => { | |
| if (!confirm('Delete this saved prompt?')) return; | |
| try { | |
| await fetch(`${API_BASE}/api/cached-prompts/${promptId}`, { method: 'DELETE' }); | |
| loadSavedPrompts(); // Refresh list | |
| } catch (err) { | |
| alert('Failed to delete prompt'); | |
| } | |
| }; | |
| const handleEdit = async (promptId: string) => { | |
| setEditError(null); | |
| try { | |
| const response = await fetch(`${API_BASE}/api/cached-prompts/${promptId}`); | |
| if (!response.ok) throw new Error('Failed to load prompt'); | |
| const entry = await response.json(); | |
| const payload = entry.payload ?? { segments: entry.segments ?? [] }; | |
| setEditingPayloadJson(JSON.stringify(payload, null, 2)); | |
| setEditingPromptId(promptId); | |
| } catch (err) { | |
| setEditError(err instanceof Error ? err.message : 'Failed to load prompt'); | |
| } | |
| }; | |
| const validatePayload = (): { valid: boolean; payload?: any; error?: string } => { | |
| try { | |
| const parsed = JSON.parse(editingPayloadJson); | |
| if (!parsed || typeof parsed !== 'object') return { valid: false, error: 'Payload must be an object' }; | |
| if (!Array.isArray(parsed.segments)) return { valid: false, error: 'Payload must have a "segments" array' }; | |
| return { valid: true, payload: parsed }; | |
| } catch { | |
| return { valid: false, error: 'Invalid JSON' }; | |
| } | |
| }; | |
| const handleValidateWithAi = async () => { | |
| const { valid, payload, error } = validatePayload(); | |
| if (!valid || !payload) { | |
| setEditError(error ?? 'Invalid payload'); | |
| setValidationResult(null); | |
| return; | |
| } | |
| setEditError(null); | |
| setValidationResult(null); | |
| setValidating(true); | |
| try { | |
| const response = await fetch(`${API_BASE}/api/validate-payload`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ payload, use_ai: true }), | |
| }); | |
| const data = await response.json().catch(() => ({})); | |
| if (!response.ok) throw new Error(data.detail || response.statusText || 'Validation failed'); | |
| setValidationResult({ | |
| valid: data.valid, | |
| schema_errors: data.schema_errors, | |
| ai_checked: data.ai_checked, | |
| ai_valid: data.ai_valid, | |
| ai_warnings: data.ai_warnings, | |
| ai_suggestions: data.ai_suggestions, | |
| }); | |
| } catch (err) { | |
| setEditError(err instanceof Error ? err.message : 'Validation request failed'); | |
| } finally { | |
| setValidating(false); | |
| } | |
| }; | |
| const handleSaveEdit = async () => { | |
| const { valid, payload, error } = validatePayload(); | |
| if (!valid || !payload || !editingPromptId) { | |
| setEditError(error ?? 'Invalid payload'); | |
| return; | |
| } | |
| setEditError(null); | |
| setSaving(true); | |
| try { | |
| const response = await fetch(`${API_BASE}/api/cached-prompts/${editingPromptId}`, { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload), | |
| }); | |
| if (!response.ok) { | |
| const errData = await response.json().catch(() => ({})); | |
| throw new Error(errData.detail || response.statusText || 'Failed to update'); | |
| } | |
| setEditingPromptId(null); | |
| setEditingPayloadJson(''); | |
| loadSavedPrompts(); | |
| } catch (err) { | |
| setEditError(err instanceof Error ? err.message : 'Failed to save'); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| const closeEditModal = () => { | |
| setEditingPromptId(null); | |
| setEditingPayloadJson(''); | |
| setEditError(null); | |
| setValidationResult(null); | |
| }; | |
| return ( | |
| <div className="fixed inset-0 bg-void-950/80 backdrop-blur-sm flex items-center justify-center p-4 z-50"> | |
| <div className="bg-void-900/95 backdrop-blur-xl rounded-2xl shadow-2xl border border-void-700/50 max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col"> | |
| {/* Header */} | |
| <div className="p-6 border-b border-void-700/50 flex justify-between items-center"> | |
| <h2 className="text-2xl font-bold text-void-100">💾 My Saved Prompts</h2> | |
| <button | |
| onClick={onClose} | |
| className="text-void-400 hover:text-void-100 text-2xl font-light leading-none transition-colors" | |
| > | |
| × | |
| </button> | |
| </div> | |
| {/* Content */} | |
| <div className="flex-1 overflow-y-auto p-6"> | |
| {loading ? ( | |
| <div className="flex items-center justify-center py-12"> | |
| <div className="animate-spin rounded-full h-12 w-12 border-2 border-void-600 border-t-coral-500" /> | |
| </div> | |
| ) : error ? ( | |
| <div className="text-center py-12"> | |
| <p className="text-coral-400 mb-4">{error}</p> | |
| <button | |
| onClick={loadSavedPrompts} | |
| className="px-4 py-2 bg-coral-500/20 text-coral-400 rounded-xl border border-coral-500/50 hover:bg-coral-500/30 transition-colors" | |
| > | |
| Retry | |
| </button> | |
| </div> | |
| ) : prompts.length === 0 ? ( | |
| <div className="text-center py-12"> | |
| <div className="text-6xl mb-4 opacity-60">📭</div> | |
| <p className="text-void-300 mb-2">No saved prompts yet</p> | |
| <p className="text-sm text-void-500">Generate some prompts to see them here</p> | |
| </div> | |
| ) : ( | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| {prompts.map((prompt) => ( | |
| <div | |
| key={prompt.prompt_id} | |
| className="card hover:border-void-600/50 hover:bg-void-800/40 transition-all duration-200" | |
| > | |
| {/* Header */} | |
| <div className="flex justify-between items-start mb-3"> | |
| <span className="text-xs text-void-500"> | |
| {new Date(prompt.created_at).toLocaleDateString('en-US', { | |
| month: 'short', | |
| day: 'numeric', | |
| year: 'numeric' | |
| })} | |
| </span> | |
| <span className="px-2 py-1 bg-electric-500/20 text-electric-400 text-xs rounded-full font-medium border border-electric-500/30"> | |
| {prompt.segments_count} segments | |
| </span> | |
| </div> | |
| {/* Content Preview */} | |
| <div className="mb-4"> | |
| <p className="text-sm text-void-200 line-clamp-3 mb-2"> | |
| {(prompt.metadata?.script || '').slice(0, 120)} | |
| {(prompt.metadata?.script || '').length > 120 ? '…' : ''} | |
| </p> | |
| <span className="inline-block px-2 py-1 bg-void-800 text-void-300 text-xs rounded-lg border border-void-600/50"> | |
| {prompt.metadata?.style || '—'} | |
| </span> | |
| </div> | |
| {/* Actions */} | |
| <div className="flex gap-2"> | |
| <button | |
| onClick={() => handleReuse(prompt.prompt_id)} | |
| className="flex-1 px-3 py-2 bg-coral-500/20 text-coral-400 rounded-xl border border-coral-500/50 hover:bg-coral-500/30 text-sm font-medium transition-colors" | |
| > | |
| ♻️ Reuse | |
| </button> | |
| <button | |
| onClick={() => handleEdit(prompt.prompt_id)} | |
| className="px-3 py-2 bg-void-800 text-void-300 rounded-xl border border-void-600 hover:bg-void-700 hover:text-void-100 text-sm transition-colors" | |
| title="Edit prompt" | |
| > | |
| ✏️ | |
| </button> | |
| <button | |
| onClick={() => handleDelete(prompt.prompt_id)} | |
| className="px-3 py-2 bg-void-800/80 text-red-400 rounded-xl border border-void-600 hover:bg-red-500/10 hover:border-red-500/40 text-sm transition-colors" | |
| > | |
| 🗑️ | |
| </button> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {/* Footer */} | |
| <div className="p-4 border-t border-void-700/50"> | |
| <button | |
| onClick={onClose} | |
| className="w-full py-2.5 bg-void-800 text-void-200 rounded-xl border border-void-600 hover:bg-void-700 transition-colors" | |
| > | |
| Close | |
| </button> | |
| </div> | |
| </div> | |
| {/* Edit modal - dark chrome to match app; light body so JSON is readable */} | |
| {editingPromptId && ( | |
| <div className="fixed inset-0 bg-void-950/90 backdrop-blur-sm flex items-center justify-center p-4 z-[60]"> | |
| <div className="bg-void-900 rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col border border-void-600"> | |
| <div className="p-4 border-b border-void-600 flex justify-between items-center"> | |
| <h3 className="text-lg font-bold text-void-100">✏️ Edit saved prompt</h3> | |
| <button onClick={closeEditModal} className="text-void-400 hover:text-void-100 text-2xl font-bold leading-none transition-colors">×</button> | |
| </div> | |
| <div className="flex-1 overflow-hidden flex flex-col p-4 bg-void-800/30"> | |
| {editError && ( | |
| <p className="text-coral-400 text-sm mb-2 font-medium">{editError}</p> | |
| )} | |
| {validationResult && ( | |
| <div className="mb-3 p-3 rounded-xl bg-void-900/80 border border-void-600 text-sm"> | |
| {!validationResult.valid && validationResult.schema_errors && validationResult.schema_errors.length > 0 && ( | |
| <div className="mb-2"> | |
| <p className="font-medium text-coral-400 mb-1">Schema issues:</p> | |
| <ul className="list-disc list-inside text-coral-300/90 space-y-0.5"> | |
| {validationResult.schema_errors.map((e, i) => ( | |
| <li key={i}>{e}</li> | |
| ))} | |
| </ul> | |
| </div> | |
| )} | |
| {validationResult.ai_checked && ( | |
| <> | |
| {validationResult.ai_warnings && validationResult.ai_warnings.length > 0 && ( | |
| <div className="mb-2"> | |
| <p className="font-medium text-amber-400 mb-1">AI review warnings:</p> | |
| <ul className="list-disc list-inside text-amber-300/90 space-y-0.5"> | |
| {validationResult.ai_warnings.map((w, i) => ( | |
| <li key={i}>{w}</li> | |
| ))} | |
| </ul> | |
| </div> | |
| )} | |
| {validationResult.ai_suggestions && validationResult.ai_suggestions.length > 0 && ( | |
| <div className="mb-2"> | |
| <p className="font-medium text-electric-400 mb-1">Suggestions:</p> | |
| <ul className="list-disc list-inside text-electric-300/90 space-y-0.5"> | |
| {validationResult.ai_suggestions.map((s, i) => ( | |
| <li key={i}>{s}</li> | |
| ))} | |
| </ul> | |
| </div> | |
| )} | |
| {validationResult.valid && (!validationResult.ai_warnings?.length) && (!validationResult.ai_suggestions?.length) && validationResult.ai_valid !== false && ( | |
| <p className="text-electric-400 font-medium">✓ Schema and AI review passed.</p> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| )} | |
| <p className="text-sm text-void-300 mb-2"> | |
| Edit the JSON below. Keep the <code className="bg-void-700 text-void-100 px-1.5 py-0.5 rounded font-mono text-xs">segments</code> array structure. Use "Validate with AI" to check schema and content. | |
| </p> | |
| <textarea | |
| value={editingPayloadJson} | |
| onChange={(e) => { setEditingPayloadJson(e.target.value); setValidationResult(null); }} | |
| className="flex-1 w-full p-4 font-mono text-sm text-void-100 bg-void-950 border border-void-600 rounded-xl resize-none min-h-[320px] placeholder-void-500 focus:border-coral-500/50 focus:ring-2 focus:ring-coral-500/20 focus:outline-none" | |
| spellCheck={false} | |
| placeholder='{"segments": [...]}' | |
| /> | |
| <div className="flex gap-2 mt-3"> | |
| <button | |
| onClick={handleValidateWithAi} | |
| disabled={validating} | |
| className="px-4 py-2 bg-amber-500/20 text-amber-400 rounded-xl border border-amber-500/40 hover:bg-amber-500/30 disabled:opacity-50 text-sm font-medium transition-colors" | |
| > | |
| {validating ? 'Validating…' : 'Validate with AI'} | |
| </button> | |
| <button | |
| onClick={handleSaveEdit} | |
| disabled={saving} | |
| className="px-4 py-2 bg-coral-500/20 text-coral-400 rounded-xl border border-coral-500/50 hover:bg-coral-500/30 disabled:opacity-50 text-sm font-medium transition-colors" | |
| > | |
| {saving ? 'Saving…' : 'Save changes'} | |
| </button> | |
| <button | |
| onClick={closeEditModal} | |
| className="px-4 py-2 bg-void-800 text-void-300 rounded-xl border border-void-600 hover:bg-void-700 hover:text-void-100 text-sm font-medium transition-colors" | |
| > | |
| Cancel | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |