feat: major UI overhaul - 1/3+2/3 layout, animated logo, history drawer, editable titles, Czech, dark default, playful design

This commit is contained in:
2026-06-03 23:06:56 +02:00
parent 8d09b17467
commit c3e167bde9
11 changed files with 678 additions and 437 deletions
+6 -7
View File
@@ -3,13 +3,12 @@ import { useI18n } from "../lib/i18n";
export function Footer() {
const { t } = useI18n();
return (
<footer className="mt-8 border-t border-border pt-4">
<div className="flex flex-col sm:flex-row items-center justify-between gap-2 text-[11px] font-semibold text-fg-muted uppercase tracking-wide">
<p>
<span className="gradient-text">MelodyMuse</span> ·
{t.appSubtitle}
</p>
</div>
<footer className="shrink-0 h-8 flex items-center px-6 border-t border-border bg-bg/50 backdrop-blur-sm">
<p className="text-[10px] font-semibold text-fg-muted uppercase tracking-wider">
<span className="gradient-text font-bold">MelodyMuse</span>
<span className="mx-1.5 opacity-40">·</span>
{t.appSubtitle}
</p>
</footer>
);
}
+90 -52
View File
@@ -12,11 +12,13 @@ import {
import type { InputValues } from "../lib/types";
import { useI18n } from "../lib/i18n";
interface HistoryPanelProps {
interface HistoryDrawerProps {
open: boolean;
onClose: () => void;
onLoad: (entry: HistoryEntry) => void;
}
export function HistoryPanel({ onLoad }: HistoryPanelProps) {
export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) {
const [entries, setEntries] = useState<HistoryEntry[]>([]);
const { t } = useI18n();
@@ -25,63 +27,96 @@ export function HistoryPanel({ onLoad }: HistoryPanelProps) {
}, []);
useEffect(() => {
const handler = () => {
setEntries(loadHistory());
};
const handler = () => setEntries(loadHistory());
window.addEventListener(HISTORY_UPDATED_EVENT, handler);
return () => window.removeEventListener(HISTORY_UPDATED_EVENT, handler);
}, []);
// Close on Escape
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
return (
<div className="flex flex-col h-full bg-bg-card/40 backdrop-blur-md border border-border rounded-xl shadow-lg overflow-hidden">
<div className="px-4 py-3 border-b border-border bg-bg/20">
<span className="flex items-center gap-2 text-sm font-bold text-fg">
<FontAwesomeIcon icon={faHistory} className="text-accent-secondary" />
{t.history}
{entries.length > 0 && (
<span className="text-[10px] bg-accent-primary/20 text-accent-primary px-1.5 py-0.5 rounded-full">
{entries.length}
</span>
)}
</span>
</div>
<>
{/* Backdrop */}
{open && (
<div
className="fixed inset-0 z-40 bg-black/30 backdrop-blur-sm"
onClick={onClose}
aria-hidden
/>
)}
<div className="flex-1 overflow-y-auto scrollbar-thin p-2 space-y-1">
{entries.length === 0 ? (
<p className="px-3 py-6 text-xs text-fg-muted text-center italic">
{t.noHistory}
</p>
) : (
entries.map((e) => (
<HistoryRow
key={e.id}
entry={e}
onLoad={() => onLoad(e)}
onRemove={() => setEntries(removeFromHistory(e.id))}
/>
))
)}
</div>
{entries.length > 0 && (
<div className="p-2 border-t border-border bg-bg/20">
{/* Drawer */}
<div
className={`fixed top-0 right-0 h-full z-50 w-80 flex flex-col bg-bg-card/90 backdrop-blur-2xl border-l border-border shadow-2xl transition-transform duration-300 ease-out ${
open ? "translate-x-0" : "translate-x-full"
}`}
role="dialog"
aria-modal
aria-label="History"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<span className="flex items-center gap-2 text-sm font-bold text-fg">
<FontAwesomeIcon icon={faHistory} className="text-accent-primary" />
{t.history}
{entries.length > 0 && (
<span className="text-[10px] bg-accent-primary/15 text-accent-primary px-1.5 py-0.5 rounded-full font-bold">
{entries.length}
</span>
)}
</span>
<button
type="button"
onClick={() => {
clearHistory();
setEntries([]);
}}
className="w-full inline-flex items-center justify-center gap-2 text-xs font-semibold text-fg-muted hover:text-rose-400 hover:bg-rose-400/10 transition-colors py-2 rounded-lg"
onClick={onClose}
className="w-7 h-7 flex items-center justify-center rounded-lg text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors"
aria-label="Close history"
>
<FontAwesomeIcon icon={faTrashAlt} />
{t.history === 'History' ? 'Clear history' : 'Verlauf löschen'}
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
)}
</div>
{/* Entries */}
<div className="flex-1 overflow-y-auto scrollbar-thin p-2 space-y-1">
{entries.length === 0 ? (
<p className="px-3 py-10 text-xs text-fg-muted text-center italic">{t.noHistory}</p>
) : (
entries.map((e) => (
<HistoryRow
key={e.id}
entry={e}
onLoad={() => { onLoad(e); onClose(); }}
onRemove={() => setEntries(removeFromHistory(e.id))}
/>
))
)}
</div>
{/* Footer */}
{entries.length > 0 && (
<div className="p-2 border-t border-border shrink-0">
<button
type="button"
onClick={() => { clearHistory(); setEntries([]); }}
className="w-full inline-flex items-center justify-center gap-2 text-xs font-semibold text-fg-muted hover:text-rose-400 hover:bg-rose-400/10 transition-colors py-2 rounded-lg"
>
<FontAwesomeIcon icon={faTrashAlt} />
{t.history === 'History' ? 'Clear history' : 'Verlauf löschen'}
</button>
</div>
)}
</div>
</>
);
}
// Keep old named export for compatibility but point to the drawer
export { HistoryDrawer as HistoryPanel };
function HistoryRow({
entry,
onLoad,
@@ -93,15 +128,18 @@ function HistoryRow({
}) {
const summary = summarizeInput(entry.input);
return (
<div className="group px-3 py-2.5 flex items-center justify-between gap-2 text-sm hover:bg-bg-hover/80 rounded-lg transition-colors cursor-pointer" onClick={onLoad}>
<div
className="group px-3 py-2.5 flex items-center justify-between gap-2 text-sm hover:bg-bg-hover/80 rounded-xl transition-colors cursor-pointer"
onClick={onLoad}
>
<div className="flex-1 min-w-0">
<div className="truncate text-fg font-medium text-[13px]">{summary}</div>
<div className="flex items-center gap-1.5 text-[10px] text-fg-muted mt-1 uppercase tracking-wider font-semibold">
<div className="truncate text-fg font-semibold text-[13px]">{summary}</div>
<div className="flex items-center gap-1.5 text-[10px] text-fg-muted mt-0.5 uppercase tracking-wider font-semibold">
<FontAwesomeIcon icon={faClock} />
<span>{formatRelative(entry.timestamp)}</span>
{entry.input.mood && (
<>
<span className="opacity-50">·</span>
<span className="opacity-40">·</span>
<span className="truncate">{entry.input.mood}</span>
</>
)}
@@ -110,7 +148,7 @@ function HistoryRow({
<button
type="button"
onClick={(e) => { e.stopPropagation(); onRemove(); }}
className="opacity-0 group-hover:opacity-100 text-fg-muted hover:text-rose-400 transition-all p-1.5 rounded-md hover:bg-rose-400/10"
className="opacity-0 group-hover:opacity-100 text-fg-muted hover:text-rose-400 transition-all p-1.5 rounded-lg hover:bg-rose-400/10"
aria-label="Remove from history"
>
<FontAwesomeIcon icon={faTimes} />
@@ -121,7 +159,7 @@ function HistoryRow({
function summarizeInput(v: InputValues): string {
const idea = v.idea.trim();
const max = 60;
const max = 55;
if (idea.length <= max) return idea || "(no idea text)";
return idea.slice(0, max - 1) + "…";
}
+200 -177
View File
@@ -11,43 +11,44 @@ import {
import type { InputValues, Language, Vocals } from "../lib/types";
import { formatElapsed, useElapsed } from "../lib/useElapsed";
import { randomStyle } from "../lib/llm";
import { useToast } from "../lib/toast";
import { useI18n } from "../lib/i18n";
export type { InputValues };
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: "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" },
{ value: "Polski", label: "Polski" },
{ value: "Czech", label: "Čeština" },
{ value: "Other", label: "Other…" },
];
const EXAMPLE_IDEAS: { text: string; mood: string; vocals: Vocals }[] = [
{
text: "melancholic lo-fi house beat for a rainy sunday morning",
mood: "melancholic",
vocals: "instrumental",
},
{
text: "euphoric summer pop anthem with a singalong chorus",
mood: "euphoric",
vocals: "vocals",
},
{
text: "tense cinematic underscore for a thriller chase scene",
mood: "tense",
vocals: "instrumental",
},
{
text: "dreamy ambient ballad about missing someone far away",
mood: "longing",
vocals: "vocals",
},
// Curated "Surprise me" pairs — coherent idea + matching style
const SURPRISE_PAIRS: { idea: string; style: string; mood: string; vocals: Vocals }[] = [
{ idea: "A melancholic lo-fi house beat for a rainy Sunday morning", style: "lo-fi, downtempo, warm vinyl crackle, 85 BPM", mood: "melancholic", vocals: "instrumental" },
{ idea: "An epic orchestral fantasy theme for a dragon rider", style: "cinematic orchestra, soaring strings, choir, 120 BPM", mood: "epic", vocals: "instrumental" },
{ idea: "A dreamy synthwave love song about neon city nights", style: "synthwave, retro arpeggios, lush reverb, 100 BPM", mood: "dreamy", vocals: "vocals" },
{ idea: "A summer beach pop anthem with a massive singalong chorus", style: "upbeat pop, electric guitar, catchy hooks, 128 BPM", mood: "euphoric", vocals: "vocals" },
{ idea: "A tense cinematic underscore for a space station emergency", style: "dark ambient, pulsing synths, tension pads, 90 BPM", mood: "tense", vocals: "instrumental" },
{ idea: "A jazzy bossa nova track about sipping coffee in Paris", style: "bossa nova, acoustic guitar, warm brass, 110 BPM", mood: "relaxed", vocals: "vocals" },
{ idea: "A dark folk ballad about a lighthouse keeper lost at sea", style: "dark folk, acoustic guitar, haunting vocals, 72 BPM", mood: "longing", vocals: "vocals" },
{ idea: "A bouncy chiptune adventure theme for a retro platformer", style: "chiptune, 8-bit, NES style, upbeat, 140 BPM", mood: "playful", vocals: "instrumental" },
];
// Curated "Go crazy" pairs — wild, absurd, unexpected combos
const CRAZY_PAIRS: { idea: string; style: string; mood: string; vocals: Vocals }[] = [
{ idea: "A death metal ode to a sentient washing machine who dreams of becoming a DJ", style: "death metal, heavy blast beats, distorted guitar, 180 BPM", mood: "aggressive", vocals: "vocals" },
{ idea: "An operatic trap banger about a medieval wizard discovering TikTok", style: "operatic trap, 808s, harpsichord, 140 BPM", mood: "chaotic", vocals: "vocals" },
{ idea: "A romantic waltz between a robot and a cactus on Mars", style: "classical waltz, electronic glitch, orchestral, 90 BPM", mood: "bittersweet", vocals: "instrumental" },
{ idea: "A gospel choir celebrating the invention of bubble wrap", style: "gospel, full choir, claps, euphoric brass, 120 BPM", mood: "joyful", vocals: "vocals" },
{ idea: "An avant-garde jazz piece where all instruments are kitchen utensils", style: "experimental jazz, freeform, atonal, 0 BPM (undefined)", mood: "chaotic", vocals: "instrumental" },
{ idea: "A synthpop ballad about a time-traveling accountant in ancient Rome", style: "80s synthpop, catchy arpeggios, reverb, 118 BPM", mood: "quirky", vocals: "vocals" },
{ idea: "A Viking drinking song remixed by an alien DJ", style: "folk metal, hypnotic alien synths, epic choir, 160 BPM", mood: "wild", vocals: "vocals" },
{ idea: "A lullaby for a grumpy dragon who cannot fall asleep", style: "ambient lullaby, gentle bells, warm pads, 60 BPM", mood: "tender", vocals: "vocals" },
];
interface InputPanelProps {
@@ -69,7 +70,6 @@ export function InputPanel({
}: InputPanelProps) {
const [styleLoading, setStyleLoading] = useState<null | "normal" | "crazy">(null);
const elapsed = useElapsed(loading);
const toast = useToast();
const { t } = useI18n();
const set = <K extends keyof InputValues>(key: K, value: InputValues[K]) =>
@@ -90,113 +90,125 @@ export function InputPanel({
};
const fillExample = () => {
const ex = EXAMPLE_IDEAS[Math.floor(Math.random() * EXAMPLE_IDEAS.length)];
onChange({
...values,
idea: ex.text,
mood: values.mood || ex.mood,
vocals: ex.vocals,
});
const ex = SURPRISE_PAIRS[Math.floor(Math.random() * SURPRISE_PAIRS.length)];
onChange({ ...values, idea: ex.idea, mood: values.mood || ex.mood, vocals: ex.vocals });
};
const handleStyleRandom = async (mode: "normal" | "crazy") => {
// "Surprise me" fills both idea AND style with a coherent pair
const handleSurpriseMe = async () => {
if (styleLoading) return;
// Try to get a random style from the server; also fill idea locally
const ex = SURPRISE_PAIRS[Math.floor(Math.random() * SURPRISE_PAIRS.length)];
const ctrl = new AbortController();
setStyleLoading(mode);
setStyleLoading("normal");
try {
const style = await randomStyle(mode, ctrl.signal);
onChange({ ...values, style_hint: style });
const style = await randomStyle("normal", ctrl.signal);
onChange({ ...values, idea: ex.idea, style_hint: style, mood: ex.mood, vocals: ex.vocals });
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
toast.error(
err instanceof Error
? err.message
: `Could not generate a ${mode} style — please try again`,
);
// Fallback to local style if server fails
onChange({ ...values, idea: ex.idea, style_hint: ex.style, mood: ex.mood, vocals: ex.vocals });
} finally {
setStyleLoading((curr) => (curr === mode ? null : curr));
setStyleLoading((curr) => (curr === "normal" ? null : curr));
}
};
return (
<form onSubmit={submit} className="card p-4 space-y-4 backdrop-blur-md bg-bg-card/80">
{/* Top Row: Idea and Generate Button */}
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<label
htmlFor="idea"
className="text-xs font-semibold uppercase tracking-wider text-fg-muted"
>
{t.musicIdea}
</label>
<button
type="button"
onClick={fillExample}
className="inline-flex items-center gap-1.5 text-xs text-fg-muted hover:text-accent-primary transition-colors"
title="Fill the input with a random example"
>
<FontAwesomeIcon icon={faShuffle} className="w-3 h-3" />
{t.tryExample}
</button>
</div>
<input
id="idea"
type="text"
value={values.idea}
onChange={(e) => set("idea", e.target.value)}
onKeyDown={onCmdEnter}
placeholder={t.placeholderIdea}
className="input text-[15px]"
required
/>
</div>
// "Go crazy" fills both idea AND style with a wild pair
const handleGoCrazy = async () => {
if (styleLoading) return;
const ex = CRAZY_PAIRS[Math.floor(Math.random() * CRAZY_PAIRS.length)];
const ctrl = new AbortController();
setStyleLoading("crazy");
try {
const style = await randomStyle("crazy", ctrl.signal);
onChange({ ...values, idea: ex.idea, style_hint: style, mood: ex.mood, vocals: ex.vocals });
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
onChange({ ...values, idea: ex.idea, style_hint: ex.style, mood: ex.mood, vocals: ex.vocals });
} finally {
setStyleLoading((curr) => (curr === "crazy" ? null : curr));
}
};
<div className="flex items-end gap-2 shrink-0">
const anyLoading = styleLoading !== null;
return (
<form onSubmit={submit} className="flex flex-col gap-4 h-full">
{/* Music Idea */}
<div className="flex flex-col gap-1.5">
<label htmlFor="idea" className="text-xs font-bold uppercase tracking-wider text-fg-muted">
{t.musicIdea}
</label>
<textarea
id="idea"
value={values.idea}
onChange={(e) => set("idea", e.target.value)}
onKeyDown={onCmdEnter}
placeholder={t.placeholderIdea}
className="textarea text-[14px] leading-relaxed scrollbar-thin"
style={{ minHeight: 90, resize: "vertical" }}
required
/>
{/* Example / Surprise / Crazy buttons */}
<div className="flex flex-wrap gap-2 pt-0.5">
<button
type="submit"
disabled={!canSubmit}
className="h-[42px] px-6 rounded-lg text-sm font-bold text-white shadow-md shadow-accent-primary/20 transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none flex items-center justify-center gap-2 gradient-bg hover:brightness-110 active:brightness-95"
type="button"
onClick={fillExample}
disabled={anyLoading}
className="btn-fun bg-bg-card border-border text-fg-muted hover:text-fg hover:border-accent-primary/40"
>
{loading ? (
<>
<FontAwesomeIcon icon={faCircleNotch} spin className="w-4 h-4" />
{t.generating} {formatElapsed(elapsed)}
</>
) : (
<>
<FontAwesomeIcon icon={faWandMagicSparkles} className="w-4 h-4" />
{t.generateAssets}
</>
)}
<FontAwesomeIcon icon={faShuffle} className="w-3 h-3" />
{t.tryExample}
</button>
<button
type="button"
onClick={handleSurpriseMe}
disabled={anyLoading}
className="btn-fun border-accent-secondary/50 text-accent-secondary hover:bg-accent-secondary/10 bg-accent-secondary/5"
>
{styleLoading === "normal"
? <FontAwesomeIcon icon={faCircleNotch} spin className="w-3 h-3" />
: <FontAwesomeIcon icon={faWandMagicSparkles} className="w-3 h-3" />}
{t.surpriseMe}
</button>
<button
type="button"
onClick={handleGoCrazy}
disabled={anyLoading}
className="btn-fun border-rose-500/50 text-rose-400 hover:bg-rose-500/10 bg-rose-500/5"
>
{styleLoading === "crazy"
? <FontAwesomeIcon icon={faCircleNotch} spin className="w-3 h-3" />
: <FontAwesomeIcon icon={faFire} className="w-3 h-3" />}
{t.goCrazy}
</button>
{loading && cancelButton}
</div>
</div>
{/* Bottom Row: Options (Always Visible) */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 pt-4 border-t border-border">
{/* Divider */}
<div className="border-t border-border" />
{/* Options Grid */}
<div className="grid grid-cols-2 gap-3">
{/* Language */}
<div>
<label htmlFor="language" className="block text-xs font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
<div className="flex flex-col gap-1.5">
<label htmlFor="language" className="text-xs font-bold uppercase tracking-wider text-fg-muted">
{t.language}
</label>
<select
id="language"
value={values.language}
onChange={(e) => set("language", e.target.value as Language)}
className="input h-[38px] py-0"
className="input h-[36px] py-0 text-sm"
>
{LANGUAGES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
<option key={l.value} value={l.value}>{l.label}</option>
))}
</select>
{values.language === "Other" && (
<input
type="text"
className="input mt-2 h-[38px]"
className="input h-[36px] text-sm"
placeholder="e.g. 日本語"
value={values.customLanguage}
onChange={(e) => set("customLanguage", e.target.value)}
@@ -204,87 +216,98 @@ export function InputPanel({
)}
</div>
{/* Style */}
<div className="md:col-span-2">
<label htmlFor="style_hint" className="flex items-center justify-between text-xs font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
<span>{t.musicStyle} <span className="normal-case opacity-70 font-normal">{t.optional}</span></span>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleStyleRandom("normal")}
disabled={styleLoading !== null}
className="text-[10px] text-accent-secondary hover:text-accent-primary transition-colors disabled:opacity-50"
>
{styleLoading === "normal" ? <FontAwesomeIcon icon={faCircleNotch} spin /> : <FontAwesomeIcon icon={faWandMagicSparkles} />} {t.surpriseMe}
</button>
<button
type="button"
onClick={() => handleStyleRandom("crazy")}
disabled={styleLoading !== null}
className="text-[10px] text-rose-400 hover:text-rose-300 transition-colors disabled:opacity-50"
>
{styleLoading === "crazy" ? <FontAwesomeIcon icon={faCircleNotch} spin /> : <FontAwesomeIcon icon={faFire} />} {t.goCrazy}
</button>
</div>
{/* Mood */}
<div className="flex flex-col gap-1.5">
<label htmlFor="mood" className="text-xs font-bold uppercase tracking-wider text-fg-muted">
{t.mood} <span className="normal-case font-normal opacity-60 text-[10px]">{t.optional}</span>
</label>
<input
id="style_hint"
id="mood"
type="text"
placeholder={t.placeholderStyle}
value={values.style_hint}
onChange={(e) => set("style_hint", e.target.value)}
onKeyDown={onCmdEnter}
className="input h-[38px]"
placeholder={t.placeholderMood}
value={values.mood}
onChange={(e) => set("mood", e.target.value)}
className="input h-[36px] text-sm"
/>
</div>
</div>
{/* Mood & Vocals */}
<div className="flex gap-4">
<div className="flex-1">
<label htmlFor="mood" className="block text-xs font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
{t.mood} <span className="normal-case opacity-70 font-normal">{t.optional}</span>
</label>
<input
id="mood"
type="text"
placeholder={t.placeholderMood}
value={values.mood}
onChange={(e) => set("mood", e.target.value)}
className="input h-[38px]"
/>
</div>
{/* Style */}
<div className="flex flex-col gap-1.5">
<label htmlFor="style_hint" className="text-xs font-bold uppercase tracking-wider text-fg-muted">
{t.musicStyle} <span className="normal-case font-normal opacity-60 text-[10px]">{t.optional}</span>
</label>
<input
id="style_hint"
type="text"
placeholder={t.placeholderStyle}
value={values.style_hint}
onChange={(e) => set("style_hint", e.target.value)}
onKeyDown={onCmdEnter}
className="input h-[36px] text-sm"
/>
</div>
<div className="shrink-0">
<label className="block text-xs font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
{t.vocals}
</label>
<div className="inline-flex rounded-lg border border-border bg-bg h-[38px] p-0.5">
<button
type="button"
onClick={() => set("vocals", "vocals")}
className={`px-3 flex items-center gap-1.5 rounded-md text-xs font-bold transition-colors ${
values.vocals === "vocals" ? "bg-accent-primary text-white" : "text-fg-muted hover:text-fg"
}`}
title={t.vocalsOption}
>
<FontAwesomeIcon icon={faMicrophone} />
</button>
<button
type="button"
onClick={() => set("vocals", "instrumental")}
className={`px-3 flex items-center gap-1.5 rounded-md text-xs font-bold transition-colors ${
values.vocals === "instrumental" ? "bg-accent-primary text-white" : "text-fg-muted hover:text-fg"
}`}
title={t.instrumentalOption}
>
<FontAwesomeIcon icon={faMusic} />
</button>
</div>
</div>
{/* Vocals Toggle */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold uppercase tracking-wider text-fg-muted">{t.vocals}</label>
<div className="inline-flex rounded-xl border border-border bg-bg h-[36px] p-0.5 w-full">
<button
type="button"
onClick={() => set("vocals", "vocals")}
className={`flex-1 flex items-center justify-center gap-2 rounded-lg text-xs font-bold transition-all duration-150 ${
values.vocals === "vocals"
? "gradient-bg text-white shadow-md"
: "text-fg-muted hover:text-fg"
}`}
title={t.vocalsOption}
>
<FontAwesomeIcon icon={faMicrophone} />
<span>{t.vocalsOption}</span>
</button>
<button
type="button"
onClick={() => set("vocals", "instrumental")}
className={`flex-1 flex items-center justify-center gap-2 rounded-lg text-xs font-bold transition-all duration-150 ${
values.vocals === "instrumental"
? "gradient-bg text-white shadow-md"
: "text-fg-muted hover:text-fg"
}`}
title={t.instrumentalOption}
>
<FontAwesomeIcon icon={faMusic} />
<span>{t.instrumentalOption}</span>
</button>
</div>
</div>
{/* Spacer to push generate button down */}
<div className="flex-1" />
{/* Generate Button */}
<div className="flex flex-col gap-2">
<button
type="submit"
disabled={!canSubmit}
className="w-full h-[46px] rounded-xl text-sm font-bold text-white shadow-lg transition-all duration-150
disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none
flex items-center justify-center gap-2 gradient-bg hover:brightness-110 hover:-translate-y-0.5
active:brightness-95 active:translate-y-0"
>
{loading ? (
<>
<FontAwesomeIcon icon={faCircleNotch} spin className="w-4 h-4" />
{t.generating} {formatElapsed(elapsed)}
</>
) : (
<>
<FontAwesomeIcon icon={faWandMagicSparkles} className="w-4 h-4" />
{t.generateAssets}
</>
)}
</button>
{loading && cancelButton}
</div>
</form>
);
}
+15 -22
View File
@@ -7,7 +7,6 @@ import { LyricsCard } from "./cards/LyricsCard";
import { StyleCard } from "./cards/StyleCard";
import { VideoPromptsCard } from "./cards/VideoPromptsCard";
import { YouTubeCard } from "./cards/YouTubeCard";
import { useI18n } from "../lib/i18n";
export type SectionKey =
| "titles"
@@ -24,6 +23,7 @@ interface ResultsPanelProps {
loading: boolean;
selectedTitleIndex: number;
onSelectTitle: (i: number) => void;
onTitleEdit?: (customTitle: string) => void;
onChange: <K extends keyof SongAssets>(key: K, value: SongAssets[K]) => void;
onChangeVideoPrompt: (index: number, value: string) => void;
onRegenerateAll: () => void;
@@ -39,6 +39,7 @@ export function ResultsPanel({
loading,
selectedTitleIndex,
onSelectTitle,
onTitleEdit,
onChange,
onChangeVideoPrompt,
onRegenerateAll,
@@ -51,15 +52,14 @@ export function ResultsPanel({
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/60 backdrop-blur-md border-b border-border mb-6 flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-fg">Results</h2>
<div className="sticky top-0 z-30 -mx-4 px-4 py-2.5 bg-bg/60 backdrop-blur-md border-b border-border mb-5 flex items-center justify-between gap-3">
<h2 className="text-sm font-bold text-fg">Results</h2>
<div className="flex items-center gap-1">
{anyRegenerating && onCancel && (
<button
type="button"
onClick={onCancel}
className="px-2.5 py-1.5 rounded-md text-xs font-medium text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors"
aria-label="Cancel generation"
className="px-2.5 py-1.5 rounded-lg text-xs font-semibold text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors"
>
Cancel
</button>
@@ -69,13 +69,9 @@ export function ResultsPanel({
type="button"
onClick={onRegenerateAll}
disabled={anyRegenerating}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-semibold text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<FontAwesomeIcon
icon={faSync}
spin={regenerating.all}
className="w-3.5 h-3.5"
/>
<FontAwesomeIcon icon={faSync} spin={regenerating.all} className="w-3 h-3" />
Regenerate all
</button>
)}
@@ -91,11 +87,12 @@ export function ResultsPanel({
<SkeletonCard height="h-56" rows={3} />
</div>
) : assets && originalAssets ? (
<div className="space-y-4">
<div className="space-y-4 pb-4">
<TitlesCard
titles={assets.titles}
selectedIndex={selectedTitleIndex}
onSelect={onSelectTitle}
onTitleEdit={onTitleEdit}
onRegenerate={() => onRegenerateSection("titles")}
regenerating={Boolean(regenerating.titles)}
/>
@@ -139,25 +136,21 @@ export function ResultsPanel({
}
function EmptyState() {
const { t } = useI18n();
return (
<div className="flex flex-col items-center justify-center text-center px-6 py-20 rounded-2xl border border-dashed border-border backdrop-blur-sm bg-bg-card/30">
<div className="relative w-16 h-16 mb-5">
<div className="absolute inset-0 rounded-full bg-gradient-to-br from-accent-primary/20 to-accent-secondary/20 blur-xl" />
<div className="absolute inset-0 rounded-full bg-gradient-to-br from-accent-primary/20 to-accent-warm/20 blur-xl" />
<div className="relative w-full h-full rounded-full bg-bg-card border border-border flex items-center justify-center shadow-lg">
<FontAwesomeIcon icon={faMusic} className="w-7 h-7 text-accent-primary" />
</div>
</div>
<h3 className="text-base font-semibold text-fg mb-1.5">
Your song will appear here
</h3>
<h3 className="text-base font-bold text-fg mb-1.5">Your song will appear here</h3>
<p className="text-sm text-fg-muted max-w-sm leading-relaxed">
Describe a music idea above and press{" "}
<kbd className="px-1.5 py-0.5 rounded bg-bg-hover border border-border text-[11px] font-mono text-fg">
{t.generateAssets}
Describe a music idea on the left and press{" "}
<kbd className="px-1.5 py-0.5 rounded-lg bg-bg-hover border border-border text-[11px] font-mono text-fg">
Generate
</kbd>{" "}
to get titles, lyrics, style, video prompts, and a YouTube description
in one go.
to create titles, lyrics, style, video prompts, and a YouTube description.
</p>
</div>
);
+34 -21
View File
@@ -6,34 +6,47 @@ interface StickyZipBarProps {
title: string | undefined;
busy: boolean;
onDownload: () => void;
inline?: boolean;
}
export function StickyZipBar({ title, busy, onDownload }: StickyZipBarProps) {
export function StickyZipBar({ title, busy, onDownload, inline }: StickyZipBarProps) {
const filename =
title && title.trim().length > 0 ? `${sanitizeFilename(title)}.zip` : null;
const inner = (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-fg min-w-0">
<FontAwesomeIcon icon={faFileZipper} className="w-4 h-4 text-fg-muted shrink-0" />
<span className="truncate font-mono text-[13px]">
{filename ?? (
<span className="text-fg-muted italic text-xs">Select a title to name your download</span>
)}
</span>
</div>
<button
type="button"
onClick={onDownload}
disabled={!filename || busy}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-bold text-white shadow-md gradient-bg hover:brightness-110 hover:-translate-y-0.5 active:brightness-95 active:translate-y-0 transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none disabled:-translate-y-0"
>
<FontAwesomeIcon icon={faDownload} className="w-4 h-4" />
{filename ? "Download ZIP" : "Select a title first"}
</button>
</div>
);
if (inline) {
return (
<div className="card px-4 py-3">
{inner}
</div>
);
}
return (
<div className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-bg/80 backdrop-blur supports-[backdrop-filter]:bg-bg/60">
<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">
<FontAwesomeIcon icon={faFileZipper} className="w-4 h-4 text-fg-muted shrink-0" />
<span className="truncate font-mono text-[13px]">
{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="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white shadow-md shadow-violet-900/20 gradient-bg hover:brightness-110 active:brightness-95 transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none"
>
<FontAwesomeIcon icon={faDownload} className="w-4 h-4" />
{filename ? "Download ZIP" : "Select a title first"}
</button>
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 py-3">
{inner}
</div>
</div>
);
+48 -20
View File
@@ -1,12 +1,13 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { ResultCard } from "../ResultCard";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTags, faSync } from "@fortawesome/free-solid-svg-icons";
import { faTags, faSync, faPen } from "@fortawesome/free-solid-svg-icons";
interface TitlesCardProps {
titles: string[];
selectedIndex: number;
onSelect: (index: number) => void;
onTitleEdit?: (customTitle: string) => void;
onRegenerate: () => void;
regenerating: boolean;
}
@@ -15,24 +16,34 @@ export function TitlesCard({
titles,
selectedIndex,
onSelect,
onTitleEdit,
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 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;
const [customTitle, setCustomTitle] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const active = titles.length > 0 ? Math.min(localIndex, titles.length - 1) : 0;
const displayTitle = customTitle ?? titles[active] ?? "";
// Bubble the local selection up to the parent so the ZIP bar can use it.
useEffect(() => {
onSelect(active);
}, [active, onSelect]);
// When a chip is selected, reset custom title to that chip's text
const handleChipClick = (i: number) => {
setLocalIndex(i);
setCustomTitle(null);
onTitleEdit?.(titles[i] ?? "");
};
const handleCustomEdit = (val: string) => {
setCustomTitle(val);
onTitleEdit?.(val);
};
return (
<ResultCard
index={0}
@@ -41,18 +52,19 @@ export function TitlesCard({
onRegenerate={onRegenerate}
regenerating={regenerating}
>
<div className="flex flex-wrap gap-2">
{/* Chips */}
<div className="flex flex-wrap gap-2 mb-3">
{titles.map((t, i) => {
const isSelected = i === active;
const isSelected = i === active && customTitle === null;
return (
<button
key={`${t}-${i}`}
type="button"
onClick={() => setLocalIndex(i)}
onClick={() => handleChipClick(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"
? "px-4 py-2 rounded-full text-sm font-bold gradient-bg text-white shadow-md transition-all"
: "chip text-sm"
}
aria-pressed={isSelected}
>
@@ -62,13 +74,29 @@ export function TitlesCard({
})}
{regenerating && (
<span className="chip text-fg-muted">
<FontAwesomeIcon icon={faSync} spin className="w-4 h-4" />
Generating new titles
<FontAwesomeIcon icon={faSync} spin className="w-3.5 h-3.5" />
Generating
</span>
)}
</div>
<p className="mt-3 text-xs text-fg-muted">
Selected: <span className="text-fg">{titles[active] ?? "—"}</span>
{/* Editable title input */}
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none">
<FontAwesomeIcon icon={faPen} className="w-3 h-3" />
</span>
<input
ref={inputRef}
type="text"
value={displayTitle}
onChange={(e) => handleCustomEdit(e.target.value)}
placeholder="Edit title…"
className="input pl-8 text-sm font-semibold"
aria-label="Custom song title"
/>
</div>
<p className="mt-1.5 text-[11px] text-fg-muted">
This title is used for the ZIP filename. You can edit it freely.
</p>
</ResultCard>
);