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:
2026-06-03 01:25:22 +02:00
parent f2401d0c57
commit d42410560f
37 changed files with 5443 additions and 376 deletions
+32
View File
@@ -0,0 +1,32 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { X } from 'lucide-react';
interface ConfigBannerProps {
message: string;
linkTo: string;
linkLabel: string;
}
export function ConfigBanner({ message, linkTo, linkLabel }: ConfigBannerProps) {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
return (
<div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
<p className="flex-1">
{message}{' '}
<Link to={linkTo} className="font-semibold underline underline-offset-2 hover:text-amber-100">
{linkLabel}
</Link>
</p>
<button
type="button"
onClick={() => setDismissed(true)}
className="text-amber-200/70 hover:text-amber-100 transition-colors"
aria-label="Dismiss"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
interface CopyButtonProps {
value: string;
label?: string;
className?: string;
}
export function CopyButton({ value, label = 'Copy', className = '' }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const onClick = async () => {
try {
await navigator.clipboard.writeText(value);
} catch {
// Fallback: use a hidden textarea + execCommand for older browsers.
const ta = document.createElement('textarea');
ta.value = value;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
} catch {
/* swallow */
}
document.body.removeChild(ta);
}
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
};
return (
<button
type="button"
onClick={onClick}
className={`btn-ghost ${className}`}
aria-label={copied ? 'Copied' : label}
>
{copied ? (
<>
<Check className="w-4 h-4 text-emerald-400" />
<span>Copied!</span>
</>
) : (
<>
<Copy className="w-4 h-4" />
<span>{label}</span>
</>
)}
</button>
);
}
+37
View File
@@ -0,0 +1,37 @@
// Animated equalizer/waveform icon for the MelodyMuse header.
export function EqualizerIcon({ className = 'w-7 h-7' }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<linearGradient id="eq-grad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#a78bfa" />
<stop offset="1" stopColor="#22d3ee" />
</linearGradient>
</defs>
<g stroke="url(#eq-grad)" strokeWidth="2.4" strokeLinecap="round">
<line x1="5" y1="13" x2="5" y2="19">
<animate attributeName="y1" values="13;8;13" dur="1.2s" repeatCount="indefinite" />
<animate attributeName="y2" values="19;24;19" dur="1.2s" repeatCount="indefinite" />
</line>
<line x1="11" y1="9" x2="11" y2="23">
<animate attributeName="y1" values="9;14;9" dur="1.4s" repeatCount="indefinite" />
<animate attributeName="y2" values="23;18;23" dur="1.4s" repeatCount="indefinite" />
</line>
<line x1="17" y1="11" x2="17" y2="21">
<animate attributeName="y1" values="11;6;11" dur="1.0s" repeatCount="indefinite" />
<animate attributeName="y2" values="21;26;21" dur="1.0s" repeatCount="indefinite" />
</line>
<line x1="23" y1="8" x2="23" y2="24">
<animate attributeName="y1" values="8;13;8" dur="1.6s" repeatCount="indefinite" />
<animate attributeName="y2" values="24;19;24" dur="1.6s" repeatCount="indefinite" />
</line>
</g>
</svg>
);
}
+198
View File
@@ -0,0 +1,198 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { ChevronDown, ChevronUp, Loader2, Settings as SettingsIcon, Sparkles } from 'lucide-react';
import { EqualizerIcon } from './EqualizerIcon';
import type { Language, Vocals } from '../lib/types';
const LANGUAGES: { value: Language; label: string }[] = [
{ value: 'English', label: 'English' },
{ value: 'Deutsch', label: 'Deutsch' },
{ value: 'Español', label: 'Español' },
{ value: 'Français', label: 'Français' },
{ value: 'Italiano', label: 'Italiano' },
{ value: 'Português', label: 'Português' },
{ value: 'Polski', label: 'Polski' },
{ value: 'Other', label: 'Other' },
];
export interface InputValues {
idea: string;
language: Language | string;
customLanguage: string;
mood: string;
vocals: Vocals;
}
interface InputPanelProps {
values: InputValues;
onChange: (next: InputValues) => void;
onSubmit: () => void;
loading: boolean;
disabled?: boolean;
}
export function InputPanel({ values, onChange, onSubmit, loading, disabled }: InputPanelProps) {
const [optionsOpen, setOptionsOpen] = useState(false);
const set = <K extends keyof InputValues>(key: K, value: InputValues[K]) =>
onChange({ ...values, [key]: value });
const canSubmit = !loading && !disabled && values.idea.trim().length > 0;
return (
<div className="relative">
{/* Animated gradient backdrop behind the header */}
<div className="absolute -top-10 -left-10 -right-10 h-64 animated-gradient-bg rounded-3xl -z-10 opacity-70 pointer-events-none" />
<div className="flex items-center gap-3 mb-1">
<EqualizerIcon className="w-8 h-8 shrink-0" />
<h1 className="text-3xl font-bold gradient-text leading-none">MelodyMuse</h1>
</div>
<p className="text-sm text-fg-muted mb-6 ml-11">
Generate your Suno song assets with AI
</p>
<form
onSubmit={(e) => {
e.preventDefault();
if (canSubmit) onSubmit();
}}
className="space-y-4"
>
<div>
<label htmlFor="idea" className="sr-only">
Describe your music idea
</label>
<textarea
id="idea"
value={values.idea}
onChange={(e) => set('idea', e.target.value)}
rows={4}
placeholder={`Describe your music idea...\ne.g. 'melancholic house beat for a rainy night'`}
className="textarea text-base leading-relaxed"
required
/>
</div>
<button
type="button"
onClick={() => setOptionsOpen((v) => !v)}
className="w-full flex items-center justify-between px-3 py-2 rounded-lg border border-border bg-bg-card hover:bg-bg-hover transition-colors text-sm"
aria-expanded={optionsOpen}
>
<span className="font-medium text-fg"> Options</span>
{optionsOpen ? (
<ChevronUp className="w-4 h-4 text-fg-muted" />
) : (
<ChevronDown className="w-4 h-4 text-fg-muted" />
)}
</button>
{optionsOpen && (
<div className="space-y-4 p-4 rounded-lg border border-border bg-bg-card/50">
<div>
<label htmlFor="language" className="block text-xs uppercase tracking-wider text-fg-muted mb-1">
Language
</label>
<select
id="language"
value={values.language}
onChange={(e) => set('language', e.target.value as Language)}
className="input"
>
{LANGUAGES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
{values.language === 'Other' && (
<input
type="text"
className="input mt-2"
placeholder="e.g. 日本語, Türkçe, Русский"
value={values.customLanguage}
onChange={(e) => set('customLanguage', e.target.value)}
/>
)}
</div>
<div>
<label htmlFor="mood" className="block text-xs uppercase tracking-wider text-fg-muted mb-1">
Mood
</label>
<input
id="mood"
type="text"
placeholder="e.g. melancholic, euphoric, tense…"
value={values.mood}
onChange={(e) => set('mood', e.target.value)}
className="input"
/>
</div>
<div>
<span className="block text-xs uppercase tracking-wider text-fg-muted mb-1">
Vocals
</span>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => set('vocals', 'vocals')}
className={
values.vocals === 'vocals'
? 'px-3 py-2 rounded-lg font-medium bg-accent-primary text-white transition-colors'
: 'chip justify-center py-2'
}
aria-pressed={values.vocals === 'vocals'}
>
🎤 Vocals
</button>
<button
type="button"
onClick={() => set('vocals', 'instrumental')}
className={
values.vocals === 'instrumental'
? 'px-3 py-2 rounded-lg font-medium bg-accent-primary text-white transition-colors'
: 'chip justify-center py-2'
}
aria-pressed={values.vocals === 'instrumental'}
>
🎹 Instrumental
</button>
</div>
</div>
</div>
)}
<button
type="submit"
disabled={!canSubmit}
className="w-full py-4 rounded-xl text-base font-semibold text-white shadow-lg shadow-violet-900/30 transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none flex items-center justify-center gap-2 gradient-bg hover:brightness-110 active:brightness-95"
>
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Generating
</>
) : (
<>
<Sparkles className="w-5 h-5" />
Generate Song Assets
</>
)}
</button>
<div className="text-center">
<Link
to="/settings"
className="inline-flex items-center gap-1.5 text-xs text-fg-muted hover:text-fg transition-colors"
>
<SettingsIcon className="w-3.5 h-3.5" />
Settings
</Link>
</div>
</form>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import type { ReactNode } from 'react';
import { RefreshCw } from 'lucide-react';
interface ResultCardProps {
index: number; // 0..4 — controls the stagger delay
icon: ReactNode;
title: string;
onRegenerate?: () => void;
regenerating?: boolean;
headerExtra?: ReactNode;
children: ReactNode;
}
export function ResultCard({
index,
icon,
title,
onRegenerate,
regenerating,
headerExtra,
children,
}: ResultCardProps) {
return (
<section
className={`card p-5 opacity-0 animate-fade-up stagger-${index}`}
style={{ animationFillMode: 'forwards' }}
>
<header className="flex items-start justify-between gap-3 mb-4">
<div className="flex items-center gap-2">
<span className="text-lg leading-none">{icon}</span>
<h2 className="text-base font-semibold text-fg">{title}</h2>
</div>
<div className="flex items-center gap-1">
{headerExtra}
{onRegenerate && (
<button
type="button"
onClick={onRegenerate}
disabled={regenerating}
className="btn-ghost"
aria-label={`Regenerate ${title}`}
>
<RefreshCw className={`w-4 h-4 ${regenerating ? 'animate-spin' : ''}`} />
<span>Regenerate</span>
</button>
)}
</div>
</header>
<div className={regenerating ? 'opacity-60 pointer-events-none transition-opacity' : ''}>
{children}
</div>
</section>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { RefreshCw } from "lucide-react";
import type { SongAssets } from "../lib/types";
import { SkeletonCard } from "./SkeletonCard";
import { TitlesCard } from "./cards/TitlesCard";
import { LyricsCard } from "./cards/LyricsCard";
import { StyleCard } from "./cards/StyleCard";
import { VideoPromptsCard } from "./cards/VideoPromptsCard";
import { YouTubeCard } from "./cards/YouTubeCard";
export type SectionKey =
| "titles"
| "lyrics"
| "style"
| "video_prompts"
| "youtube_description";
export type RegeneratingMap = Partial<Record<SectionKey | "all", boolean>>;
interface ResultsPanelProps {
assets: SongAssets | null;
loading: boolean; // true during the initial "Generate all" call
selectedTitleIndex: number;
onSelectTitle: (i: number) => void;
onChange: <K extends keyof SongAssets>(key: K, value: SongAssets[K]) => void;
onChangeVideoPrompt: (index: number, value: string) => void;
onRegenerateAll: () => void;
onRegenerateSection: (section: SectionKey) => void;
regenerating: RegeneratingMap;
}
export function ResultsPanel({
assets,
loading,
selectedTitleIndex,
onSelectTitle,
onChange,
onChangeVideoPrompt,
onRegenerateAll,
onRegenerateSection,
regenerating,
}: ResultsPanelProps) {
const showSkeletons = loading && !assets;
const regenAll = Boolean(regenerating.all);
return (
<div>
<div className="sticky top-0 z-30 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 py-3 bg-bg/80 backdrop-blur border-b border-border mb-6 flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-fg">Results</h2>
<button
type="button"
onClick={onRegenerateAll}
disabled={loading || regenAll}
className="btn-ghost"
>
<RefreshCw
className={`w-4 h-4 ${regenAll || loading ? "animate-spin" : ""}`}
/>
<span>Regenerate All</span>
</button>
</div>
{showSkeletons ? (
<div className="space-y-4">
<SkeletonCard height="h-16" rows={1} />
<SkeletonCard height="h-40" rows={4} />
<SkeletonCard height="h-24" rows={3} />
<SkeletonCard height="h-32" rows={4} />
<SkeletonCard height="h-56" rows={3} />
</div>
) : assets ? (
<div className="space-y-4">
<TitlesCard
titles={assets.titles}
selectedIndex={selectedTitleIndex}
onSelect={onSelectTitle}
onRegenerate={() => onRegenerateSection("titles")}
regenerating={Boolean(regenerating.titles)}
/>
<LyricsCard
lyrics={assets.lyrics}
onChange={(v) => onChange("lyrics", v)}
onRegenerate={() => onRegenerateSection("lyrics")}
regenerating={Boolean(regenerating.lyrics)}
/>
<StyleCard
style={assets.style}
negativeStyle={assets.negative_style}
onStyleChange={(v) => onChange("style", v)}
onNegativeChange={(v) => onChange("negative_style", v)}
onRegenerate={() => onRegenerateSection("style")}
regenerating={Boolean(regenerating.style)}
/>
<VideoPromptsCard
prompts={assets.video_prompts}
onChange={onChangeVideoPrompt}
onRegenerate={() => onRegenerateSection("video_prompts")}
regenerating={Boolean(regenerating.video_prompts)}
/>
<YouTubeCard
description={assets.youtube_description}
onChange={(v) => onChange("youtube_description", v)}
onRegenerate={() => onRegenerateSection("youtube_description")}
regenerating={Boolean(regenerating.youtube_description)}
/>
</div>
) : null}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
interface SkeletonCardProps {
height?: string;
rows?: number;
className?: string;
}
// Generic skeleton used while a single section regenerates, or for the entire
// results panel during the initial "Generate all" call. The shape loosely
// matches a result card so the layout doesn't jump when content arrives.
export function SkeletonCard({ height = 'h-40', rows = 2, className = '' }: SkeletonCardProps) {
return (
<div className={`card p-5 animate-pulse ${className}`}>
<div className="h-4 w-1/3 bg-bg-hover rounded mb-4" />
<div className={`${height} bg-bg-hover rounded-lg mb-3`} />
{Array.from({ length: Math.max(0, rows - 1) }).map((_, i) => (
<div key={i} className="h-3 w-full bg-bg-hover rounded mb-2" />
))}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { FolderArchive, Download } from 'lucide-react';
import { sanitizeFilename } from '../lib/zip';
interface StickyZipBarProps {
title: string | undefined;
busy: boolean;
onDownload: () => void;
}
export function StickyZipBar({ title, busy, onDownload }: StickyZipBarProps) {
const filename = title && title.trim().length > 0
? `${sanitizeFilename(title)}.zip`
: null;
return (
<div className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-bg/90 backdrop-blur supports-[backdrop-filter]:bg-bg/70">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-fg min-w-0">
<FolderArchive className="w-4 h-4 text-fg-muted shrink-0" />
<span className="truncate font-mono">
{filename ?? <span className="text-fg-muted">Select a title to name your download</span>}
</span>
</div>
<button
type="button"
onClick={onDownload}
disabled={!filename || busy}
className="btn-primary py-2.5 px-5"
>
<Download className="w-4 h-4" />
{filename ? 'Download ZIP' : 'Select a title first'}
</button>
</div>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { Moon, Sun } from 'lucide-react';
import { useEffect, useState } from 'react';
import { getStoredTheme, toggleTheme, type Theme } from '../lib/theme';
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>('dark');
useEffect(() => {
setTheme(getStoredTheme());
}, []);
const onClick = () => {
const next = toggleTheme();
setTheme(next);
};
return (
<button
type="button"
onClick={onClick}
className="btn-ghost p-2"
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useRef } from 'react';
import { ResultCard } from '../ResultCard';
import { CopyButton } from '../CopyButton';
interface LyricsCardProps {
lyrics: string;
onChange: (next: string) => void;
onRegenerate: () => void;
regenerating: boolean;
}
// Auto-grow the textarea to fit its content. Min height is enforced in CSS via
// `min-height`; we only ever grow.
function useAutoHeight(value: string, minHeight = 200) {
const ref = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`;
}, [value, minHeight]);
return ref;
}
export function LyricsCard({ lyrics, onChange, onRegenerate, regenerating }: LyricsCardProps) {
const ref = useAutoHeight(lyrics, 200);
return (
<ResultCard
index={1}
icon="📝"
title="Lyrics"
onRegenerate={onRegenerate}
regenerating={regenerating}
headerExtra={lyrics ? <CopyButton value={lyrics} label="Copy" /> : null}
>
<textarea
ref={ref}
value={lyrics}
onChange={(e) => onChange(e.target.value)}
placeholder="Lyrics will appear here…"
spellCheck
className="textarea font-mono text-sm leading-relaxed scrollbar-thin"
style={{ minHeight: 200 }}
/>
</ResultCard>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useRef } from 'react';
import { ResultCard } from '../ResultCard';
import { CopyButton } from '../CopyButton';
interface StyleCardProps {
style: string;
negativeStyle: string;
onStyleChange: (next: string) => void;
onNegativeChange: (next: string) => void;
onRegenerate: () => void;
regenerating: boolean;
}
function useAutoHeight(value: string, minHeight: number) {
const ref = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`;
}, [value, minHeight]);
return ref;
}
export function StyleCard({
style,
negativeStyle,
onStyleChange,
onNegativeChange,
onRegenerate,
regenerating,
}: StyleCardProps) {
const styleRef = useAutoHeight(style, 96);
const negRef = useAutoHeight(negativeStyle, 64);
return (
<ResultCard
index={2}
icon="🎵"
title="Style"
onRegenerate={onRegenerate}
regenerating={regenerating}
>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-fg">Style Prompt</label>
<CopyButton value={style} label="Copy" />
</div>
<textarea
ref={styleRef}
value={style}
onChange={(e) => onStyleChange(e.target.value)}
rows={4}
className="textarea text-sm leading-relaxed scrollbar-thin"
style={{ minHeight: 96 }}
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-fg">Negative Style</label>
<CopyButton value={negativeStyle} label="Copy" />
</div>
<textarea
ref={negRef}
value={negativeStyle}
onChange={(e) => onNegativeChange(e.target.value)}
rows={2}
className="textarea text-sm leading-relaxed scrollbar-thin"
style={{ minHeight: 64 }}
/>
</div>
</div>
</ResultCard>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import { ResultCard } from "../ResultCard";
import { RefreshCw } from "lucide-react";
interface TitlesCardProps {
titles: string[];
selectedIndex: number;
onSelect: (index: number) => void;
onRegenerate: () => void;
regenerating: boolean;
}
export function TitlesCard({
titles,
selectedIndex,
onSelect,
onRegenerate,
regenerating,
}: TitlesCardProps) {
// Clamp selectedIndex so it always points at a real title (defensive against
// list shrinkage when regenerating).
const initial = Math.min(
Math.max(0, selectedIndex),
Math.max(0, titles.length - 1),
);
const [localIndex, setLocalIndex] = useState(initial);
const active =
titles.length > 0 ? Math.min(localIndex, titles.length - 1) : 0;
// Bubble the local selection up to the parent so the ZIP bar can use it.
useEffect(() => {
onSelect(active);
}, [active, onSelect]);
return (
<ResultCard
index={0}
icon="🏷️"
title="Song Titles"
onRegenerate={onRegenerate}
regenerating={regenerating}
>
<div className="flex flex-wrap gap-2">
{titles.map((t, i) => {
const isSelected = i === active;
return (
<button
key={`${t}-${i}`}
type="button"
onClick={() => setLocalIndex(i)}
className={
isSelected
? "px-4 py-2 rounded-full text-sm sm:text-base font-semibold bg-accent-primary text-white shadow-md shadow-violet-900/30 transition-colors"
: "chip text-sm sm:text-base"
}
aria-pressed={isSelected}
>
{t}
</button>
);
})}
{regenerating && (
<span className="chip text-fg-muted">
<RefreshCw className="w-4 h-4 animate-spin" />
Generating new titles
</span>
)}
</div>
<p className="mt-3 text-xs text-fg-muted">
Selected: <span className="text-fg">{titles[active] ?? "—"}</span>
</p>
</ResultCard>
);
}
+94
View File
@@ -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>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useRef } from 'react';
import { ResultCard } from '../ResultCard';
import { CopyButton } from '../CopyButton';
interface YouTubeCardProps {
description: string;
onChange: (next: string) => void;
onRegenerate: () => void;
regenerating: boolean;
}
export function YouTubeCard({
description,
onChange,
onRegenerate,
regenerating,
}: YouTubeCardProps) {
const ref = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.max(300, el.scrollHeight)}px`;
}, [description]);
return (
<ResultCard
index={4}
icon="📺"
title="YouTube Description"
onRegenerate={onRegenerate}
regenerating={regenerating}
headerExtra={description ? <CopyButton value={description} label="Copy" /> : null}
>
<textarea
ref={ref}
value={description}
onChange={(e) => onChange(e.target.value)}
rows={10}
className="textarea text-sm leading-relaxed scrollbar-thin"
style={{ minHeight: 300 }}
/>
</ResultCard>
);
}