Build standalone MelodyMuse SPA
Drop the Supabase backend and call the AI provider directly from the
browser. The endpoint URL, API key, and model name are stored in
localStorage and used for direct /chat/completions requests.
- src/lib/llm.ts: config persistence, direct fetch, JSON extraction,
shape validation, connection test
- src/lib/prompts.ts: full + partial-regeneration system prompts and
user-message builder
- src/lib/{api,supabase}.ts removed
- supabase/ directory removed
- @supabase/supabase-js dropped from package.json
- README updated to describe the standalone architecture and CORS caveats
- .gitignore: drop Supabase entries, exclude *.tsbuildinfo
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ResultCard } from '../ResultCard';
|
||||
import { CopyButton } from '../CopyButton';
|
||||
import { NEGATIVE_VIDEO_PROMPT, type VideoPrompt, type VideoPromptType } from '../../lib/types';
|
||||
|
||||
interface VideoPromptsCardProps {
|
||||
prompts: VideoPrompt[];
|
||||
onChange: (index: number, next: string) => void;
|
||||
onRegenerate: () => void;
|
||||
regenerating: boolean;
|
||||
}
|
||||
|
||||
const TABS: VideoPromptType[] = ['Abstract', 'Cinematic', 'Hybrid'];
|
||||
|
||||
export function VideoPromptsCard({
|
||||
prompts,
|
||||
onChange,
|
||||
onRegenerate,
|
||||
regenerating,
|
||||
}: VideoPromptsCardProps) {
|
||||
const [tab, setTab] = useState<VideoPromptType>('Abstract');
|
||||
const idx = prompts.findIndex((p) => p.type === tab);
|
||||
const current = idx >= 0 ? prompts[idx] : undefined;
|
||||
|
||||
const ref = useRef<HTMLTextAreaElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || !current) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.max(120, el.scrollHeight)}px`;
|
||||
}, [current?.prompt, tab]);
|
||||
|
||||
return (
|
||||
<ResultCard
|
||||
index={3}
|
||||
icon="🎬"
|
||||
title="Video Prompts"
|
||||
onRegenerate={onRegenerate}
|
||||
regenerating={regenerating}
|
||||
headerExtra={current ? <CopyButton value={current.prompt} label="Copy" /> : null}
|
||||
>
|
||||
<div className="flex items-center gap-1 mb-4 p-1 rounded-lg bg-bg-hover/40 border border-border w-fit">
|
||||
{TABS.map((t) => {
|
||||
const isActive = t === tab;
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
className={
|
||||
isActive
|
||||
? 'px-3 py-1.5 rounded-md text-sm font-medium bg-accent-primary text-white transition-colors'
|
||||
: 'px-3 py-1.5 rounded-md text-sm font-medium text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors'
|
||||
}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{current ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-fg">Main prompt</label>
|
||||
</div>
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={current.prompt}
|
||||
onChange={(e) => onChange(idx, e.target.value)}
|
||||
rows={5}
|
||||
className="textarea text-sm leading-relaxed scrollbar-thin"
|
||||
style={{ minHeight: 120 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm italic text-fg-muted">
|
||||
→ Best for: <span className="not-italic text-fg">{current.tool_recommendation}</span>
|
||||
</p>
|
||||
|
||||
<div className="mt-1 p-3 rounded-lg bg-bg-hover/40 border border-border">
|
||||
<p className="text-[11px] uppercase tracking-wider text-fg-muted mb-1">Negative</p>
|
||||
<p className="text-xs text-fg-muted leading-relaxed">{NEGATIVE_VIDEO_PROMPT}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-fg-muted">No prompt for this tab yet.</p>
|
||||
)}
|
||||
</ResultCard>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user