UI enhancements, i18n, history drawer, and requested features

This commit is contained in:
2026-06-04 00:01:32 +02:00
parent c3e167bde9
commit 3a2c738bd0
14 changed files with 598 additions and 288 deletions
+39 -19
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClock, faHistory, faTrashAlt, faTimes } from "@fortawesome/free-solid-svg-icons";
import { faClock, faHistory, faTrashAlt, faTimes, faCircleNotch } from "@fortawesome/free-solid-svg-icons";
import {
clearHistory,
formatRelative,
@@ -20,16 +20,26 @@ interface HistoryDrawerProps {
export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) {
const [entries, setEntries] = useState<HistoryEntry[]>([]);
const [loading, setLoading] = useState(false);
const { t } = useI18n();
useEffect(() => {
setEntries(loadHistory());
}, []);
const fetchHistory = async () => {
setLoading(true);
try {
const data = await loadHistory();
setEntries(data);
} finally {
setLoading(false);
}
};
useEffect(() => {
const handler = () => setEntries(loadHistory());
window.addEventListener(HISTORY_UPDATED_EVENT, handler);
return () => window.removeEventListener(HISTORY_UPDATED_EVENT, handler);
if (open) fetchHistory();
}, [open]);
useEffect(() => {
window.addEventListener(HISTORY_UPDATED_EVENT, fetchHistory);
return () => window.removeEventListener(HISTORY_UPDATED_EVENT, fetchHistory);
}, []);
// Close on Escape
@@ -65,24 +75,27 @@ export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) {
<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 && (
{!loading && 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
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={faTimes} />
</button>
<div className="flex items-center gap-2">
{loading && <FontAwesomeIcon icon={faCircleNotch} spin className="text-fg-muted w-3 h-3" />}
<button
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={faTimes} />
</button>
</div>
</div>
{/* Entries */}
<div className="flex-1 overflow-y-auto scrollbar-thin p-2 space-y-1">
{entries.length === 0 ? (
{entries.length === 0 && !loading ? (
<p className="px-3 py-10 text-xs text-fg-muted text-center italic">{t.noHistory}</p>
) : (
entries.map((e) => (
@@ -90,7 +103,9 @@ export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) {
key={e.id}
entry={e}
onLoad={() => { onLoad(e); onClose(); }}
onRemove={() => setEntries(removeFromHistory(e.id))}
onRemove={async () => {
await removeFromHistory(e.id);
}}
/>
))
)}
@@ -101,11 +116,16 @@ export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) {
<div className="p-2 border-t border-border shrink-0">
<button
type="button"
onClick={() => { clearHistory(); setEntries([]); }}
onClick={async () => {
if (window.confirm(t.clearHistoryConfirm)) {
await 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'}
{t.clearHistory}
</button>
</div>
)}
+39 -25
View File
@@ -3,7 +3,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCircleNotch,
faWandMagicSparkles,
faShuffle,
faFire,
faMicrophone,
faMusic,
@@ -89,42 +88,65 @@ export function InputPanel({
}
};
const fillExample = () => {
const ex = SURPRISE_PAIRS[Math.floor(Math.random() * SURPRISE_PAIRS.length)];
onChange({ ...values, idea: ex.idea, mood: values.mood || ex.mood, vocals: ex.vocals });
};
// "Surprise me" fills both idea AND style with a coherent pair
// "Surprise me" fills style. If idea is empty, fills a random idea too.
const handleSurpriseMe = async () => {
if (styleLoading) return;
// Try to get a random style from the server; also fill idea locally
const hasIdea = values.idea.trim().length > 0;
const ex = SURPRISE_PAIRS[Math.floor(Math.random() * SURPRISE_PAIRS.length)];
const ideaToUse = hasIdea ? values.idea : ex.idea;
const ctrl = new AbortController();
setStyleLoading("normal");
try {
const style = await randomStyle("normal", ctrl.signal);
onChange({ ...values, idea: ex.idea, style_hint: style, mood: ex.mood, vocals: ex.vocals });
const style = await randomStyle("normal", ctrl.signal, ideaToUse);
onChange({
...values,
idea: ideaToUse,
style_hint: style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
// Fallback to local style if server fails
onChange({ ...values, idea: ex.idea, style_hint: ex.style, mood: ex.mood, vocals: ex.vocals });
onChange({
...values,
idea: ideaToUse,
style_hint: hasIdea ? values.style_hint : ex.style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
} finally {
setStyleLoading((curr) => (curr === "normal" ? null : curr));
}
};
// "Go crazy" fills both idea AND style with a wild pair
// "Go crazy" fills style wildly. If idea is empty, fills a wild idea too.
const handleGoCrazy = async () => {
if (styleLoading) return;
const hasIdea = values.idea.trim().length > 0;
const ex = CRAZY_PAIRS[Math.floor(Math.random() * CRAZY_PAIRS.length)];
const ideaToUse = hasIdea ? values.idea : ex.idea;
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 });
const style = await randomStyle("crazy", ctrl.signal, ideaToUse);
onChange({
...values,
idea: ideaToUse,
style_hint: style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.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 });
onChange({
...values,
idea: ideaToUse,
style_hint: hasIdea ? values.style_hint : ex.style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
} finally {
setStyleLoading((curr) => (curr === "crazy" ? null : curr));
}
@@ -151,15 +173,7 @@ export function InputPanel({
/>
{/* Example / Surprise / Crazy buttons */}
<div className="flex flex-wrap gap-2 pt-0.5">
<button
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"
>
<FontAwesomeIcon icon={faShuffle} className="w-3 h-3" />
{t.tryExample}
</button>
{/* Removed Try an Example button */}
<button
type="button"
onClick={handleSurpriseMe}
+12 -9
View File
@@ -7,6 +7,7 @@ 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"
@@ -27,7 +28,7 @@ interface ResultsPanelProps {
onChange: <K extends keyof SongAssets>(key: K, value: SongAssets[K]) => void;
onChangeVideoPrompt: (index: number, value: string) => void;
onRegenerateAll: () => void;
onRegenerateSection: (section: SectionKey) => void;
onRegenerateSection: (section: SectionKey, selectedText?: string) => void;
onCancel?: () => void;
anyRegenerating: boolean;
regenerating: RegeneratingMap;
@@ -49,11 +50,12 @@ export function ResultsPanel({
regenerating,
}: ResultsPanelProps) {
const showSkeletons = loading && !assets;
const { t } = useI18n();
return (
<div>
<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>
<h2 className="text-sm font-bold text-fg">{t.results}</h2>
<div className="flex items-center gap-1">
{anyRegenerating && onCancel && (
<button
@@ -61,7 +63,7 @@ export function ResultsPanel({
onClick={onCancel}
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
{t.cancel}
</button>
)}
{assets && (
@@ -72,7 +74,7 @@ export function ResultsPanel({
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 h-3" />
Regenerate all
{t.regenerateAll}
</button>
)}
</div>
@@ -100,7 +102,7 @@ export function ResultsPanel({
lyrics={assets.lyrics}
originalLyrics={originalAssets.lyrics}
onChange={(v) => onChange("lyrics", v)}
onRegenerate={() => onRegenerateSection("lyrics")}
onRegenerate={(selectedText) => onRegenerateSection("lyrics", selectedText)}
regenerating={Boolean(regenerating.lyrics)}
/>
<StyleCard
@@ -136,6 +138,7 @@ 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">
@@ -144,13 +147,13 @@ function EmptyState() {
<FontAwesomeIcon icon={faMusic} className="w-7 h-7 text-accent-primary" />
</div>
</div>
<h3 className="text-base font-bold text-fg mb-1.5">Your song will appear here</h3>
<h3 className="text-base font-bold text-fg mb-1.5">{t.emptyStateTitle}</h3>
<p className="text-sm text-fg-muted max-w-sm leading-relaxed">
Describe a music idea on the left and press{" "}
{t.emptyStateBody}{" "}
<kbd className="px-1.5 py-0.5 rounded-lg bg-bg-hover border border-border text-[11px] font-mono text-fg">
Generate
{t.generateAssets}
</kbd>{" "}
to create titles, lyrics, style, video prompts, and a YouTube description.
{t.emptyStateBody2}
</p>
</div>
);
+35 -15
View File
@@ -3,12 +3,13 @@ import { CopyButton } from "../CopyButton";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFileAlt, faUndo } from "@fortawesome/free-solid-svg-icons";
import { useAutoHeight } from "../../lib/useAutoHeight";
import { useI18n } from "../../lib/i18n";
interface LyricsCardProps {
lyrics: string;
originalLyrics: string;
onChange: (next: string) => void;
onRegenerate: () => void;
onRegenerate: (selectedText?: string) => void;
regenerating: boolean;
}
@@ -21,13 +22,25 @@ export function LyricsCard({
}: LyricsCardProps) {
const ref = useAutoHeight(lyrics, 200);
const dirty = lyrics !== originalLyrics;
const { t } = useI18n();
const handleRegenerate = () => {
let selected = "";
if (ref.current) {
selected = ref.current.value.substring(
ref.current.selectionStart,
ref.current.selectionEnd
).trim();
}
onRegenerate(selected || undefined);
};
return (
<ResultCard
index={1}
icon={<FontAwesomeIcon icon={faFileAlt} className="text-blue-400" />}
title="Lyrics"
onRegenerate={onRegenerate}
title={t.lyrics}
onRegenerate={handleRegenerate}
regenerating={regenerating}
headerExtra={
<>
@@ -37,25 +50,32 @@ export function LyricsCard({
onClick={() => onChange(originalLyrics)}
className="btn-ghost"
aria-label="Revert lyrics to last generated version"
title="Revert to last generated"
title={t.revert}
>
<FontAwesomeIcon icon={faUndo} className="w-3.5 h-3.5" />
<span>Revert</span>
<span>{t.revert}</span>
</button>
)}
{lyrics && <CopyButton value={lyrics} label="Copy" />}
{lyrics && <CopyButton value={lyrics} label={t.copy} />}
</>
}
>
<textarea
ref={ref}
value={lyrics}
onChange={(e) => onChange(e.target.value)}
placeholder="Lyrics will appear here…"
spellCheck
className="textarea font-mono text-sm leading-relaxed scrollbar-thin"
style={{ minHeight: 200 }}
/>
<div className="flex flex-col gap-2">
<textarea
ref={ref}
value={lyrics}
onChange={(e) => onChange(e.target.value)}
placeholder="Lyrics will appear here…"
spellCheck
className="textarea font-mono text-sm leading-relaxed scrollbar-thin max-h-[18rem] overflow-y-auto"
style={{ minHeight: 200 }}
/>
{lyrics && (
<p className="text-[10px] text-fg-muted font-medium px-1">
{t.partialRegenHint}
</p>
)}
</div>
</ResultCard>
);
}
+7 -6
View File
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUndo } from "@fortawesome/free-solid-svg-icons";
import { faYoutube } from "@fortawesome/free-brands-svg-icons";
import { useAutoHeight } from "../../lib/useAutoHeight";
import { useI18n } from "../../lib/i18n";
interface YouTubeCardProps {
description: string;
@@ -22,12 +23,13 @@ export function YouTubeCard({
}: YouTubeCardProps) {
const ref = useAutoHeight(description, 300);
const dirty = description !== originalDescription;
const { t } = useI18n();
return (
<ResultCard
index={4}
icon={<FontAwesomeIcon icon={faYoutube} className="text-red-500" />}
title="YouTube Description"
title={t.youtubeDesc}
onRegenerate={onRegenerate}
regenerating={regenerating}
headerExtra={
@@ -38,13 +40,13 @@ export function YouTubeCard({
onClick={() => onChange(originalDescription)}
className="btn-ghost"
aria-label="Revert description to last generated version"
title="Revert to last generated"
title={t.revert}
>
<FontAwesomeIcon icon={faUndo} className="w-3.5 h-3.5" />
<span>Revert</span>
<span>{t.revert}</span>
</button>
)}
{description && <CopyButton value={description} label="Copy" />}
{description && <CopyButton value={description} label={t.copy} />}
</>
}
>
@@ -52,8 +54,7 @@ export function YouTubeCard({
ref={ref}
value={description}
onChange={(e) => onChange(e.target.value)}
rows={10}
className="textarea text-sm leading-relaxed scrollbar-thin"
className="textarea text-sm leading-relaxed scrollbar-thin max-h-[18rem] overflow-y-auto"
style={{ minHeight: 300 }}
/>
</ResultCard>