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