UI enhancements, i18n, history drawer, and requested features
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user