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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user