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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+148
-49
@@ -1,3 +1,5 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -5,12 +7,13 @@
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f8fafc;
|
||||
--bg-card: rgba(255, 255, 255, 0.6);
|
||||
--bg-hover: rgba(241, 245, 249, 0.8);
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--bg: #f0f4ff;
|
||||
--bg-card: rgba(255, 255, 255, 0.65);
|
||||
--bg-hover: rgba(237, 242, 255, 0.85);
|
||||
--border: rgba(100, 80, 200, 0.12);
|
||||
--accent-primary: #7c3aed;
|
||||
--accent-secondary: #0891b2;
|
||||
--accent-warm: #e879f9;
|
||||
--fg: #0f172a;
|
||||
--fg-muted: #64748b;
|
||||
--tag: #8b5cf6;
|
||||
@@ -18,66 +21,102 @@
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--bg: #05050a;
|
||||
--bg-card: rgba(17, 17, 24, 0.5);
|
||||
--bg-hover: rgba(22, 22, 31, 0.7);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--accent-primary: #7c3aed;
|
||||
--bg: #070710;
|
||||
--bg-card: rgba(18, 16, 32, 0.55);
|
||||
--bg-hover: rgba(30, 26, 50, 0.75);
|
||||
--border: rgba(140, 100, 255, 0.12);
|
||||
--accent-primary: #8b5cf6;
|
||||
--accent-secondary: #06b6d4;
|
||||
--fg: #e2e8f0;
|
||||
--accent-warm: #e879f9;
|
||||
--fg: #ede9fe;
|
||||
--fg-muted: #94a3b8;
|
||||
--tag: #a78bfa;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
overflow: hidden; /* App controls its own scrolling */
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-bg text-fg antialiased font-sans transition-colors duration-300;
|
||||
@apply bg-bg text-fg antialiased transition-colors duration-300;
|
||||
margin: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-feature-settings: "cv11", "ss01", "ss03";
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(124, 58, 237, 0.3);
|
||||
background: rgba(139, 92, 246, 0.3);
|
||||
color: var(--fg);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-bg-card border border-border rounded-2xl shadow-xl backdrop-blur-xl transition-colors duration-300;
|
||||
@apply bg-bg-card border border-border rounded-2xl shadow-lg backdrop-blur-xl transition-colors duration-300;
|
||||
box-shadow: 0 4px 24px rgba(100, 60, 200, 0.08), 0 1px 3px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply card transition-all duration-200;
|
||||
}
|
||||
.card-hover:hover {
|
||||
box-shadow: 0 8px 32px rgba(139, 92, 246, 0.15), 0 2px 8px rgba(0,0,0,0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-bold text-white shadow-lg
|
||||
transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none;
|
||||
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-warm) 100%);
|
||||
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.4);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
box-shadow: 0 6px 20px rgba(139, 92, 246, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-primary:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-bold
|
||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold text-sm
|
||||
bg-bg-card border border-border text-fg hover:bg-bg-hover shadow-sm backdrop-blur-md
|
||||
transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.12);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply inline-flex items-center justify-center gap-1.5 px-2.5 py-1.5 rounded-md text-sm
|
||||
text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
@apply inline-flex items-center justify-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm
|
||||
text-fg-muted hover:text-fg hover:bg-bg-hover transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-fun {
|
||||
@apply inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold
|
||||
border transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed backdrop-blur-sm;
|
||||
}
|
||||
.btn-fun:hover:not(:disabled) {
|
||||
transform: translateY(-1px) scale(1.03);
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full bg-bg-card backdrop-blur-md border border-border rounded-lg px-3 py-2 text-[14px] text-fg
|
||||
@apply w-full bg-bg-card backdrop-blur-md border border-border rounded-xl px-3 py-2 text-[14px] text-fg
|
||||
placeholder-fg-muted
|
||||
focus:outline-none focus:border-accent-primary
|
||||
focus:ring-1 focus:ring-accent-primary
|
||||
transition-colors duration-150 shadow-inner;
|
||||
focus:ring-2 focus:ring-accent-primary
|
||||
transition-all duration-150;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
@@ -86,26 +125,30 @@
|
||||
|
||||
.chip {
|
||||
@apply inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border
|
||||
bg-bg-card backdrop-blur-md text-sm font-bold text-fg hover:bg-bg-hover shadow-sm
|
||||
transition-colors duration-150;
|
||||
bg-bg-card backdrop-blur-md text-sm font-semibold text-fg hover:bg-bg-hover shadow-sm
|
||||
transition-all duration-150 cursor-pointer;
|
||||
}
|
||||
.chip:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%);
|
||||
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-warm) 60%, var(--accent-secondary) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%);
|
||||
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-warm) 60%, var(--accent-secondary) 100%);
|
||||
}
|
||||
|
||||
.stagger-0 { animation-delay: 0ms; }
|
||||
.stagger-1 { animation-delay: 80ms; }
|
||||
.stagger-2 { animation-delay: 160ms; }
|
||||
.stagger-3 { animation-delay: 240ms; }
|
||||
.stagger-4 { animation-delay: 320ms; }
|
||||
.stagger-1 { animation-delay: 60ms; }
|
||||
.stagger-2 { animation-delay: 120ms; }
|
||||
.stagger-3 { animation-delay: 180ms; }
|
||||
.stagger-4 { animation-delay: 240ms; }
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@@ -113,30 +156,86 @@
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border);
|
||||
border-radius: 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--fg-muted);
|
||||
}
|
||||
|
||||
/* Liquid background elements */
|
||||
/* Liquid background blobs */
|
||||
.liquid-blob-1 {
|
||||
position: fixed; top: -15%; left: -10%; width: 60vw; height: 60vw;
|
||||
background: radial-gradient(circle, var(--accent-primary) 0%, transparent 60%);
|
||||
opacity: 0.15; filter: blur(120px); z-index: -1; pointer-events: none;
|
||||
position: fixed; top: -20%; left: -15%; width: 70vw; height: 70vw;
|
||||
background: radial-gradient(circle, var(--accent-primary) 0%, transparent 65%);
|
||||
opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none;
|
||||
animation: blob-drift-1 18s ease-in-out infinite;
|
||||
}
|
||||
.liquid-blob-2 {
|
||||
position: fixed; bottom: -15%; right: -10%; width: 60vw; height: 60vw;
|
||||
background: radial-gradient(circle, var(--accent-secondary) 0%, transparent 60%);
|
||||
opacity: 0.15; filter: blur(120px); z-index: -1; pointer-events: none;
|
||||
position: fixed; bottom: -20%; right: -15%; width: 65vw; height: 65vw;
|
||||
background: radial-gradient(circle, var(--accent-secondary) 0%, transparent 65%);
|
||||
opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none;
|
||||
animation: blob-drift-2 22s ease-in-out infinite;
|
||||
}
|
||||
.liquid-blob-3 {
|
||||
position: fixed; top: 30%; right: 20%; width: 40vw; height: 40vw;
|
||||
background: radial-gradient(circle, var(--accent-warm) 0%, transparent 65%);
|
||||
opacity: 0.10; filter: blur(100px); z-index: -1; pointer-events: none;
|
||||
animation: blob-drift-3 26s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Animated logo bars */
|
||||
.music-bar {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(180deg, var(--accent-warm) 0%, var(--accent-primary) 100%);
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
.music-bar-1 { animation: bar-bounce 1.0s ease-in-out infinite; }
|
||||
.music-bar-2 { animation: bar-bounce 1.0s ease-in-out 0.15s infinite; }
|
||||
.music-bar-3 { animation: bar-bounce 1.0s ease-in-out 0.30s infinite; }
|
||||
.music-bar-4 { animation: bar-bounce 1.0s ease-in-out 0.45s infinite; }
|
||||
}
|
||||
|
||||
/* Keyframes */
|
||||
@keyframes bar-bounce {
|
||||
0%, 100% { height: 6px; opacity: 0.6; }
|
||||
50% { height: 18px; opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes blob-drift-1 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(3%, 5%) scale(1.05); }
|
||||
66% { transform: translate(-3%, 2%) scale(0.97); }
|
||||
}
|
||||
|
||||
@keyframes blob-drift-2 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(-4%, -3%) scale(1.04); }
|
||||
66% { transform: translate(2%, 5%) scale(0.98); }
|
||||
}
|
||||
|
||||
@keyframes blob-drift-3 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(-5%, 5%) scale(1.06); }
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
0% { opacity: 0; transform: translateY(16px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: fade-up 300ms ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 260ms cubic-bezier(0.25, 0.8, 0.25, 1) forwards;
|
||||
}
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
return 'dark';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -9,6 +9,7 @@ export type Language =
|
||||
| "Italiano"
|
||||
| "Português"
|
||||
| "Polski"
|
||||
| "Czech"
|
||||
| "Other";
|
||||
|
||||
export type Vocals = "vocals" | "instrumental";
|
||||
|
||||
+134
-88
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faYoutube } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faSun, faMoon, faMusic } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faSun, faMoon, faHistory, faCog } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Link } from "react-router-dom";
|
||||
import { InputPanel } from "../components/InputPanel";
|
||||
import type { InputValues } from "../components/InputPanel";
|
||||
import {
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
type SectionKey,
|
||||
} from "../components/ResultsPanel";
|
||||
import { StickyZipBar } from "../components/StickyZipBar";
|
||||
import { HistoryPanel } from "../components/HistoryPanel";
|
||||
import { HistoryDrawer } from "../components/HistoryPanel";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { useToast } from "../lib/toast";
|
||||
import { generateSong, getServerStatus } from "../lib/llm";
|
||||
@@ -61,6 +62,18 @@ function loadDraft(): InputValues | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Animated equalizer bars for the logo */
|
||||
function MusicBarsLogo() {
|
||||
return (
|
||||
<div className="flex items-end gap-[3px] h-5" aria-hidden>
|
||||
<div className="music-bar music-bar-1" style={{ height: 8 }} />
|
||||
<div className="music-bar music-bar-2" style={{ height: 14 }} />
|
||||
<div className="music-bar music-bar-3" style={{ height: 10 }} />
|
||||
<div className="music-bar music-bar-4" style={{ height: 6 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const toast = useToast();
|
||||
const { t, lang, setLang } = useI18n();
|
||||
@@ -74,22 +87,23 @@ export function HomePage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState<RegeneratingMap>({});
|
||||
const [selectedTitleIndex, setSelectedTitleIndex] = useState(0);
|
||||
const [customTitle, setCustomTitle] = useState<string | null>(null);
|
||||
const [serverOk, setServerOk] = useState<boolean | null>(null);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const anyRegenerating = isAnyBusy(loading, regenerating);
|
||||
|
||||
// Save draft
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => {
|
||||
try {
|
||||
window.localStorage.setItem(DRAFT_KEY, JSON.stringify(input));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try { window.localStorage.setItem(DRAFT_KEY, JSON.stringify(input)); }
|
||||
catch { /* ignore */ }
|
||||
}, DRAFT_DEBOUNCE_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [input]);
|
||||
|
||||
// Server health check
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const check = async () => {
|
||||
@@ -102,20 +116,17 @@ export function HomePage() {
|
||||
};
|
||||
check();
|
||||
const id = window.setInterval(check, 30_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
return () => { cancelled = true; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
// Resolved title for ZIP: custom override > chip selection
|
||||
const selectedTitle = useMemo(() => {
|
||||
if (customTitle !== null) return customTitle;
|
||||
if (!assets || assets.titles.length === 0) return undefined;
|
||||
return assets.titles[
|
||||
Math.min(selectedTitleIndex, assets.titles.length - 1)
|
||||
];
|
||||
}, [assets, selectedTitleIndex]);
|
||||
return assets.titles[Math.min(selectedTitleIndex, assets.titles.length - 1)];
|
||||
}, [assets, selectedTitleIndex, customTitle]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
@@ -125,26 +136,18 @@ export function HomePage() {
|
||||
useEffect(() => {
|
||||
if (!anyRegenerating) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
if (e.key === "Escape") { e.preventDefault(); cancel(); }
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [anyRegenerating, cancel]);
|
||||
|
||||
const buildRequest = useCallback(
|
||||
(
|
||||
section: GenerateCall["section"],
|
||||
extra: Partial<GenerateCall> = {},
|
||||
): GenerateCall => ({
|
||||
(section: GenerateCall["section"], extra: Partial<GenerateCall> = {}): GenerateCall => ({
|
||||
input: input.idea.trim(),
|
||||
language: resolveLanguage(input),
|
||||
...(input.mood.trim() ? { mood: input.mood.trim() } : {}),
|
||||
...(input.style_hint.trim()
|
||||
? { style_hint: input.style_hint.trim() }
|
||||
: {}),
|
||||
...(input.style_hint.trim() ? { style_hint: input.style_hint.trim() } : {}),
|
||||
vocals: input.vocals,
|
||||
section,
|
||||
...extra,
|
||||
@@ -173,11 +176,7 @@ export function HomePage() {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
toast.info(t.generationCancelled);
|
||||
} else {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t.generationFailed,
|
||||
);
|
||||
toast.error(err instanceof Error ? err.message : t.generationFailed);
|
||||
}
|
||||
} finally {
|
||||
busySetter(false);
|
||||
@@ -191,6 +190,7 @@ export function HomePage() {
|
||||
if (!input.idea.trim()) return;
|
||||
setAssets(null);
|
||||
setOriginalAssets(null);
|
||||
setCustomTitle(null);
|
||||
await runGeneration(
|
||||
() => buildRequest("all"),
|
||||
(result) => {
|
||||
@@ -209,6 +209,7 @@ export function HomePage() {
|
||||
if (!input.idea.trim()) return;
|
||||
setAssets(null);
|
||||
setOriginalAssets(null);
|
||||
setCustomTitle(null);
|
||||
await runGeneration(
|
||||
() => buildRequest("all"),
|
||||
(result) => {
|
||||
@@ -235,6 +236,8 @@ export function HomePage() {
|
||||
const merged: SongAssets = { ...assets, ...result };
|
||||
setAssets(merged);
|
||||
setOriginalAssets((prev) => (prev ? { ...prev, ...result } : merged));
|
||||
// Reset custom title if titles were regenerated
|
||||
if (section === "titles") setCustomTitle(null);
|
||||
},
|
||||
(b) => setRegenerating((m) => ({ ...m, [section]: b })),
|
||||
`${t.regeneratedSection} ${SECTION_LABELS[section]}`,
|
||||
@@ -281,6 +284,7 @@ export function HomePage() {
|
||||
setAssets(entry.assets);
|
||||
setOriginalAssets(entry.assets);
|
||||
setSelectedTitleIndex(0);
|
||||
setCustomTitle(null);
|
||||
toast.info(`Loaded "${truncate(entry.input.idea, 60)}"`);
|
||||
},
|
||||
[anyRegenerating, cancel, toast],
|
||||
@@ -288,99 +292,128 @@ export function HomePage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-hidden">
|
||||
{/* Liquid background blobs */}
|
||||
<div className="liquid-blob-1" />
|
||||
<div className="liquid-blob-2" />
|
||||
<div className="liquid-blob-3" />
|
||||
|
||||
{/* Header */}
|
||||
<header className="shrink-0 z-20 bg-bg-card/40 backdrop-blur-md border-b border-border shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-12 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faMusic} className="text-accent-primary" />
|
||||
<span className="text-sm font-bold text-fg tracking-wide">
|
||||
{t.appTitle}
|
||||
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 h-12 flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<MusicBarsLogo />
|
||||
<span className="text-sm font-extrabold gradient-text tracking-tight">
|
||||
MelodyMuse
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
{/* Right controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Social links — icon only */}
|
||||
<a
|
||||
href="https://www.youtube.com/@AIWentNonsense"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold text-fg-muted hover:text-rose-500 transition-colors"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-rose-500 hover:bg-rose-500/10 transition-all duration-150"
|
||||
title="AI Went Nonsense on YouTube"
|
||||
>
|
||||
<FontAwesomeIcon icon={faYoutube} className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">YouTube</span>
|
||||
</a>
|
||||
|
||||
{/* Server offline indicator */}
|
||||
{serverOk === false && (
|
||||
<span className="text-xs font-bold text-rose-400 bg-rose-400/10 px-2 py-0.5 rounded" title="Server unreachable">
|
||||
<span className="text-[10px] font-bold text-rose-400 bg-rose-400/10 px-2 py-0.5 rounded-full border border-rose-400/20">
|
||||
{t.serverOffline}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1 border-l border-border pl-4">
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-5 w-px bg-border" />
|
||||
|
||||
{/* History toggle */}
|
||||
<button
|
||||
onClick={() => setHistoryOpen((o) => !o)}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-accent-primary hover:bg-bg-hover transition-all duration-150 relative"
|
||||
title={t.history}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHistory} className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Settings link */}
|
||||
<Link
|
||||
to="/settings"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-accent-primary hover:bg-bg-hover transition-all duration-150"
|
||||
title={t.settings}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCog} className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
|
||||
{/* Language */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => setLang("de")}
|
||||
className={`w-7 h-7 rounded flex items-center justify-center text-[15px] transition-colors ${lang === "de" ? "bg-bg-hover shadow-sm" : "opacity-50 hover:opacity-100"}`}
|
||||
className={`w-7 h-7 rounded-lg flex items-center justify-center text-base transition-all duration-150 ${lang === "de" ? "bg-bg-hover shadow-sm scale-110" : "opacity-40 hover:opacity-80"}`}
|
||||
title="Deutsch"
|
||||
>
|
||||
🇩🇪
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLang("en")}
|
||||
className={`w-7 h-7 rounded flex items-center justify-center text-[15px] transition-colors ${lang === "en" ? "bg-bg-hover shadow-sm" : "opacity-50 hover:opacity-100"}`}
|
||||
className={`w-7 h-7 rounded-lg flex items-center justify-center text-base transition-all duration-150 ${lang === "en" ? "bg-bg-hover shadow-sm scale-110" : "opacity-40 hover:opacity-80"}`}
|
||||
title="English"
|
||||
>
|
||||
🇬🇧
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-7 h-7 rounded flex items-center justify-center text-fg-muted hover:text-accent-primary hover:bg-bg-hover transition-colors ml-1"
|
||||
title="Toggle Theme"
|
||||
>
|
||||
<FontAwesomeIcon icon={theme === "dark" ? faSun : faMoon} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-accent-primary hover:bg-bg-hover transition-all duration-150"
|
||||
title="Toggle theme"
|
||||
>
|
||||
<FontAwesomeIcon icon={theme === "dark" ? faSun : faMoon} className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Layout: Fixed Input Top, Scrollable Bottom */}
|
||||
<div className="flex-1 min-h-0 flex flex-col max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-4 gap-6">
|
||||
|
||||
{/* Input Control Bar (Always visible at top) */}
|
||||
<div className="shrink-0 z-10 animate-fade-up">
|
||||
<InputPanel
|
||||
values={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleGenerate}
|
||||
loading={loading}
|
||||
disabled={anyRegenerating}
|
||||
cancelButton={
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
className="btn-secondary w-full"
|
||||
>
|
||||
{t.cancelGeneration}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{/* Main content: 1/3 left input + 2/3 right results */}
|
||||
<div className="flex-1 min-h-0 flex overflow-hidden max-w-screen-2xl mx-auto w-full px-4 sm:px-6 py-4 gap-5">
|
||||
|
||||
{/* LEFT PANEL — 1/3 — Input */}
|
||||
<div className="w-[340px] xl:w-[380px] shrink-0 flex flex-col">
|
||||
<div className="card p-4 flex flex-col flex-1 min-h-0">
|
||||
<InputPanel
|
||||
values={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleGenerate}
|
||||
loading={loading}
|
||||
disabled={anyRegenerating}
|
||||
cancelButton={
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
className="btn-secondary w-full text-xs"
|
||||
>
|
||||
{t.cancelGeneration}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Area (History Sidebar + Results Area) */}
|
||||
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden animate-fade-up stagger-1">
|
||||
{/* History Sidebar */}
|
||||
<div className="hidden lg:flex flex-col w-1/4 shrink-0 overflow-hidden">
|
||||
<HistoryPanel onLoad={handleLoadHistory} />
|
||||
</div>
|
||||
|
||||
{/* Results Main Area */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin pb-20">
|
||||
{/* RIGHT PANEL — 2/3 — Results */}
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin px-1">
|
||||
<ResultsPanel
|
||||
assets={assets}
|
||||
originalAssets={originalAssets}
|
||||
loading={loading}
|
||||
selectedTitleIndex={selectedTitleIndex}
|
||||
onSelectTitle={setSelectedTitleIndex}
|
||||
onTitleEdit={setCustomTitle}
|
||||
onChange={handleAssetChange}
|
||||
onChangeVideoPrompt={handleVideoPromptChange}
|
||||
onRegenerateAll={handleRegenerateAll}
|
||||
@@ -389,18 +422,31 @@ export function HomePage() {
|
||||
anyRegenerating={anyRegenerating}
|
||||
regenerating={regenerating}
|
||||
/>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
{/* Download bar inside results column, always visible */}
|
||||
{assets && (
|
||||
<div className="shrink-0 pt-2">
|
||||
<StickyZipBar
|
||||
title={selectedTitle}
|
||||
busy={loading}
|
||||
onDownload={handleDownload}
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{assets && (
|
||||
<StickyZipBar
|
||||
title={selectedTitle}
|
||||
busy={loading}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
)}
|
||||
{/* Footer — always visible */}
|
||||
<Footer />
|
||||
|
||||
{/* History Drawer (right-side slide-in) */}
|
||||
<HistoryDrawer
|
||||
open={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
onLoad={handleLoadHistory}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export default {
|
||||
accent: {
|
||||
primary: "var(--accent-primary)",
|
||||
secondary: "var(--accent-secondary)",
|
||||
warm: "var(--accent-warm)",
|
||||
},
|
||||
fg: {
|
||||
DEFAULT: "var(--fg)",
|
||||
|
||||
Reference in New Issue
Block a user