Add cancel, history, revert, and quality-of-life features
UX - Add elapsed-time counter and Cancel button to in-flight generations, wired through AbortController so partial responses are discarded cleanly. The cancel action is available from both the input panel and the results header. - Add a Recent generations panel below the input. The last 6 successful generations are saved to localStorage; one click reloads both the input fields and the generated assets. - Add a Revert button to every editable card. It appears the moment the current value diverges from the last generated value and restores the field with a single click. - Add an Empty state to the right panel with a friendly hint pointing at the Generate button and the Settings page. - Add a 'Try an example' button that fills the input with a random starter idea (idea + mood + vocals). - Add Cmd/Ctrl+Enter as a keyboard shortcut to generate. - Add a Footer with project info and a privacy reminder. - Add a 'Clear saved key' button to the Settings page so the user can remove the API key without overwriting it. Bug fix - Settings > Test Connection used to save the in-progress form values to localStorage before testing. It now uses the in-memory candidate config, so failed tests don't pollute the saved config. Code quality - Extract the duplicated useAutoHeight hook to src/lib/useAutoHeight.ts. - Extract a useElapsed hook for the loading timer. - Move InputValues into src/lib/types.ts (was duplicated in InputPanel.tsx) and add SECTION_LABELS, replacing the humanizeSection switch in HomePage. - Centralize the filename sanitization: HomePage now calls sanitizeFilename from zip.ts instead of duplicating the regex. - Add previewConfig / testConnectionWithConfig helpers to llm.ts to support in-memory connection tests.
This commit is contained in:
@@ -11,6 +11,18 @@ OpenAI-compatible `/chat/completions` endpoint, using credentials that you
|
|||||||
paste into the Settings page. Those credentials are kept in this browser's
|
paste into the Settings page. Those credentials are kept in this browser's
|
||||||
`localStorage`.
|
`localStorage`.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🎵 **All 5 Suno assets in one click** — titles, lyrics, style, video prompts, YouTube description
|
||||||
|
- ✏️ **Inline editing** — every generated field is editable; revert any edit with one click
|
||||||
|
- 🔄 **Per-section regeneration** — regenerate just one section; the rest of the song is passed as context for consistency
|
||||||
|
- ⏱ **Elapsed-time counter** + ❌ **Cancel** button for long generations
|
||||||
|
- 🕘 **Recent generations** — last 6 are kept in localStorage; one click to reload
|
||||||
|
- ⌨️ **Keyboard shortcut** — `Cmd/Ctrl + Enter` to generate
|
||||||
|
- 🎲 **Try an example** — fill the input with a random sample idea
|
||||||
|
- 🌗 **Dark / light mode** — toggle in the header, persisted
|
||||||
|
- 💾 **Standalone** — no backend, no signup, no telemetry
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
- React 18 + TypeScript + Vite
|
- React 18 + TypeScript + Vite
|
||||||
@@ -27,15 +39,29 @@ MelodyMuse/
|
|||||||
│ ├── main.tsx
|
│ ├── main.tsx
|
||||||
│ ├── index.css # Tailwind layers + design-system components
|
│ ├── index.css # Tailwind layers + design-system components
|
||||||
│ ├── components/ # UI building blocks
|
│ ├── components/ # UI building blocks
|
||||||
│ │ └── cards/ # The five result cards
|
│ │ ├── cards/ # The five result cards
|
||||||
|
│ │ ├── ConfigBanner.tsx
|
||||||
|
│ │ ├── CopyButton.tsx
|
||||||
|
│ │ ├── EqualizerIcon.tsx
|
||||||
|
│ │ ├── Footer.tsx
|
||||||
|
│ │ ├── HistoryPanel.tsx
|
||||||
|
│ │ ├── InputPanel.tsx
|
||||||
|
│ │ ├── ResultCard.tsx
|
||||||
|
│ │ ├── ResultsPanel.tsx
|
||||||
|
│ │ ├── SkeletonCard.tsx
|
||||||
|
│ │ ├── StickyZipBar.tsx
|
||||||
|
│ │ └── ThemeToggle.tsx
|
||||||
│ ├── pages/ # HomePage, SettingsPage
|
│ ├── pages/ # HomePage, SettingsPage
|
||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
|
│ │ ├── history.ts # recent-generations persistence
|
||||||
│ │ ├── llm.ts # direct fetch → provider, JSON extraction, config persistence
|
│ │ ├── llm.ts # direct fetch → provider, JSON extraction, config persistence
|
||||||
│ │ ├── prompts.ts # system + user prompt construction
|
│ │ ├── prompts.ts # system + user prompt construction
|
||||||
│ │ ├── types.ts # shared TypeScript types
|
|
||||||
│ │ ├── zip.ts # JSZip layout
|
|
||||||
│ │ ├── theme.ts # dark/light mode
|
│ │ ├── theme.ts # dark/light mode
|
||||||
│ │ └── toast.tsx # toast context
|
│ │ ├── toast.tsx # toast context
|
||||||
|
│ │ ├── types.ts # shared TypeScript types + SECTION_LABELS
|
||||||
|
│ │ ├── useAutoHeight.ts # shared textarea auto-grow hook
|
||||||
|
│ │ ├── useElapsed.ts # shared "X seconds elapsed" hook
|
||||||
|
│ │ └── zip.ts # JSZip layout
|
||||||
│ └── vite-env.d.ts
|
│ └── vite-env.d.ts
|
||||||
├── public/favicon.svg
|
├── public/favicon.svg
|
||||||
├── index.html
|
├── index.html
|
||||||
@@ -68,10 +94,11 @@ MelodyMuse/
|
|||||||
- **Model Name** — e.g. `MiniMax-M3`
|
- **Model Name** — e.g. `MiniMax-M3`
|
||||||
|
|
||||||
Click **Save Configuration**, then **Test Connection** to confirm
|
Click **Save Configuration**, then **Test Connection** to confirm
|
||||||
everything is wired up.
|
everything is wired up. **Test Connection** uses your unsaved form
|
||||||
|
values — your changes are only persisted when you click **Save**.
|
||||||
|
|
||||||
4. **Generate**. Return to the home page, type a music idea, click
|
4. **Generate**. Return to the home page, type a music idea, click
|
||||||
**Generate Song Assets**.
|
**Generate Song Assets** (or press `Cmd/Ctrl + Enter`).
|
||||||
|
|
||||||
## Build for production
|
## Build for production
|
||||||
|
|
||||||
@@ -112,8 +139,11 @@ The configured endpoint must expose an OpenAI-compatible
|
|||||||
|
|
||||||
- The API key is held in `localStorage` and is only sent to the endpoint you
|
- The API key is held in `localStorage` and is only sent to the endpoint you
|
||||||
configure. No analytics, no telemetry, no third-party calls.
|
configure. No analytics, no telemetry, no third-party calls.
|
||||||
- The `MelodyMuse-config` localStorage key contains your endpoint URL, model
|
- Three localStorage keys are used:
|
||||||
name, and key. You can clear it at any time from your browser's devtools
|
- `melodymuse-config` — `{ api_endpoint, api_key, model_name }`
|
||||||
|
- `melodymuse-history` — the last 6 generations (input + assets)
|
||||||
|
- `melodymuse-theme` — `'dark' | 'light'`
|
||||||
|
- You can clear them at any time from your browser's devtools
|
||||||
(Application → Local Storage).
|
(Application → Local Storage).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="mt-16 border-t border-border">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-fg-muted">
|
||||||
|
<p>
|
||||||
|
<span className="gradient-text font-semibold">MelodyMuse</span> ·
|
||||||
|
Generate Suno song assets with AI
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
All generation runs in your browser — your API key never leaves this
|
||||||
|
device.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Clock, History, Trash2, X } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
clearHistory,
|
||||||
|
formatRelative,
|
||||||
|
loadHistory,
|
||||||
|
removeFromHistory,
|
||||||
|
type HistoryEntry,
|
||||||
|
} from '../lib/history';
|
||||||
|
import type { InputValues } from '../lib/types';
|
||||||
|
|
||||||
|
interface HistoryPanelProps {
|
||||||
|
onLoad: (entry: HistoryEntry) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryPanel({ onLoad }: HistoryPanelProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [entries, setEntries] = useState<HistoryEntry[]>([]);
|
||||||
|
|
||||||
|
// Refresh the list whenever the panel is opened, so newly-saved generations
|
||||||
|
// appear without needing a full page refresh.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setEntries(loadHistory());
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (entries.length === 0 && !open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="w-full flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-border bg-bg-card/50 hover:bg-bg-hover transition-colors text-sm"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-fg">
|
||||||
|
<History className="w-4 h-4" />
|
||||||
|
Recent generations
|
||||||
|
{entries.length > 0 && (
|
||||||
|
<span className="text-xs text-fg-muted">({entries.length})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-fg-muted">{open ? 'Hide' : 'Show'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2 rounded-lg border border-border bg-bg-card/30 divide-y divide-border">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="px-3 py-4 text-xs text-fg-muted text-center">
|
||||||
|
No recent generations yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<HistoryRow
|
||||||
|
key={e.id}
|
||||||
|
entry={e}
|
||||||
|
onLoad={() => {
|
||||||
|
onLoad(e);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
onRemove={() => setEntries(removeFromHistory(e.id))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="px-3 py-2 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
clearHistory();
|
||||||
|
setEntries([]);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-fg-muted hover:text-rose-300 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryRow({
|
||||||
|
entry,
|
||||||
|
onLoad,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
entry: HistoryEntry;
|
||||||
|
onLoad: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const summary = summarizeInput(entry.input);
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-2 flex items-center justify-between gap-2 text-sm hover:bg-bg-hover/40 transition-colors">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLoad}
|
||||||
|
className="flex-1 text-left min-w-0"
|
||||||
|
>
|
||||||
|
<div className="truncate text-fg">{summary}</div>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-fg-muted mt-0.5">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span>{formatRelative(entry.timestamp)}</span>
|
||||||
|
{entry.input.mood && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="truncate">{entry.input.mood}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="text-fg-muted hover:text-rose-300 transition-colors p-1"
|
||||||
|
aria-label="Remove from history"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeInput(v: InputValues): string {
|
||||||
|
const idea = v.idea.trim();
|
||||||
|
const max = 90;
|
||||||
|
if (idea.length <= max) return idea || '(no idea text)';
|
||||||
|
return idea.slice(0, max - 1) + '…';
|
||||||
|
}
|
||||||
+154
-66
@@ -1,27 +1,54 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type FormEvent, type KeyboardEvent } from "react";
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from "react-router-dom";
|
||||||
import { ChevronDown, ChevronUp, Loader2, Settings as SettingsIcon, Sparkles } from 'lucide-react';
|
import {
|
||||||
import { EqualizerIcon } from './EqualizerIcon';
|
ChevronDown,
|
||||||
import type { Language, Vocals } from '../lib/types';
|
ChevronUp,
|
||||||
|
Loader2,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Sparkles,
|
||||||
|
Shuffle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { EqualizerIcon } from "./EqualizerIcon";
|
||||||
|
import type { InputValues, Language, Vocals } from "../lib/types";
|
||||||
|
import { formatElapsed, useElapsed } from "../lib/useElapsed";
|
||||||
|
|
||||||
|
export type { InputValues };
|
||||||
|
|
||||||
const LANGUAGES: { value: Language; label: string }[] = [
|
const LANGUAGES: { value: Language; label: string }[] = [
|
||||||
{ value: 'English', label: 'English' },
|
{ value: "English", label: "English" },
|
||||||
{ value: 'Deutsch', label: 'Deutsch' },
|
{ value: "Deutsch", label: "Deutsch" },
|
||||||
{ value: 'Español', label: 'Español' },
|
{ value: "Español", label: "Español" },
|
||||||
{ value: 'Français', label: 'Français' },
|
{ value: "Français", label: "Français" },
|
||||||
{ value: 'Italiano', label: 'Italiano' },
|
{ value: "Italiano", label: "Italiano" },
|
||||||
{ value: 'Português', label: 'Português' },
|
{ value: "Português", label: "Português" },
|
||||||
{ value: 'Polski', label: 'Polski' },
|
{ value: "Polski", label: "Polski" },
|
||||||
{ value: 'Other', label: 'Other' },
|
{ value: "Other", label: "Other" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface InputValues {
|
// A handful of starter ideas shown as quick-fill chips. Kept short and varied
|
||||||
idea: string;
|
// so the user can see the kind of input the model expects.
|
||||||
language: Language | string;
|
const EXAMPLE_IDEAS: { text: string; mood: string; vocals: Vocals }[] = [
|
||||||
customLanguage: string;
|
{
|
||||||
mood: string;
|
text: "melancholic lo-fi house beat for a rainy sunday morning",
|
||||||
vocals: Vocals;
|
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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
interface InputPanelProps {
|
interface InputPanelProps {
|
||||||
values: InputValues;
|
values: InputValues;
|
||||||
@@ -29,16 +56,48 @@ interface InputPanelProps {
|
|||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
cancelButton?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InputPanel({ values, onChange, onSubmit, loading, disabled }: InputPanelProps) {
|
export function InputPanel({
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
loading,
|
||||||
|
disabled,
|
||||||
|
cancelButton,
|
||||||
|
}: InputPanelProps) {
|
||||||
const [optionsOpen, setOptionsOpen] = useState(false);
|
const [optionsOpen, setOptionsOpen] = useState(false);
|
||||||
|
const elapsed = useElapsed(loading);
|
||||||
|
|
||||||
const set = <K extends keyof InputValues>(key: K, value: InputValues[K]) =>
|
const set = <K extends keyof InputValues>(key: K, value: InputValues[K]) =>
|
||||||
onChange({ ...values, [key]: value });
|
onChange({ ...values, [key]: value });
|
||||||
|
|
||||||
const canSubmit = !loading && !disabled && values.idea.trim().length > 0;
|
const canSubmit = !loading && !disabled && values.idea.trim().length > 0;
|
||||||
|
|
||||||
|
const submit = (e?: FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (canSubmit) onSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Submit on Cmd/Ctrl+Enter, even from inside the textarea.
|
||||||
|
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* Animated gradient backdrop behind the header */}
|
{/* Animated gradient backdrop behind the header */}
|
||||||
@@ -46,32 +105,52 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
|
|||||||
|
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<EqualizerIcon className="w-8 h-8 shrink-0" />
|
<EqualizerIcon className="w-8 h-8 shrink-0" />
|
||||||
<h1 className="text-3xl font-bold gradient-text leading-none">MelodyMuse</h1>
|
<h1 className="text-3xl font-bold gradient-text leading-none">
|
||||||
|
MelodyMuse
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-fg-muted mb-6 ml-11">
|
<p className="text-sm text-fg-muted mb-6 ml-11">
|
||||||
Generate your Suno song assets with AI
|
Generate your Suno song assets with AI
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form
|
<form onSubmit={submit} className="space-y-4">
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (canSubmit) onSubmit();
|
|
||||||
}}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="idea" className="sr-only">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
Describe your music idea
|
<label htmlFor="idea" className="sr-only">
|
||||||
</label>
|
Describe your music idea
|
||||||
|
</label>
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-fg-muted">
|
||||||
|
Music idea
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={fillExample}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] text-fg-muted hover:text-fg transition-colors"
|
||||||
|
title="Fill the input with a random example"
|
||||||
|
>
|
||||||
|
<Shuffle className="w-3 h-3" />
|
||||||
|
Try an example
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
id="idea"
|
id="idea"
|
||||||
value={values.idea}
|
value={values.idea}
|
||||||
onChange={(e) => set('idea', e.target.value)}
|
onChange={(e) => set("idea", e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder={`Describe your music idea...\ne.g. 'melancholic house beat for a rainy night'`}
|
placeholder={`Describe your music idea...\ne.g. 'melancholic house beat for a rainy night'`}
|
||||||
className="textarea text-base leading-relaxed"
|
className="textarea text-base leading-relaxed"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<p className="mt-1 text-[11px] text-fg-muted">
|
||||||
|
<kbd className="px-1 py-0.5 rounded bg-bg-hover border border-border font-mono">
|
||||||
|
{navigator?.platform?.toLowerCase().includes("mac")
|
||||||
|
? "⌘"
|
||||||
|
: "Ctrl"}
|
||||||
|
+Enter
|
||||||
|
</kbd>{" "}
|
||||||
|
to generate
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -91,13 +170,16 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
|
|||||||
{optionsOpen && (
|
{optionsOpen && (
|
||||||
<div className="space-y-4 p-4 rounded-lg border border-border bg-bg-card/50">
|
<div className="space-y-4 p-4 rounded-lg border border-border bg-bg-card/50">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="language" className="block text-xs uppercase tracking-wider text-fg-muted mb-1">
|
<label
|
||||||
|
htmlFor="language"
|
||||||
|
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
|
||||||
|
>
|
||||||
Language
|
Language
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="language"
|
id="language"
|
||||||
value={values.language}
|
value={values.language}
|
||||||
onChange={(e) => set('language', e.target.value as Language)}
|
onChange={(e) => set("language", e.target.value as Language)}
|
||||||
className="input"
|
className="input"
|
||||||
>
|
>
|
||||||
{LANGUAGES.map((l) => (
|
{LANGUAGES.map((l) => (
|
||||||
@@ -106,19 +188,22 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{values.language === 'Other' && (
|
{values.language === "Other" && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input mt-2"
|
className="input mt-2"
|
||||||
placeholder="e.g. 日本語, Türkçe, Русский"
|
placeholder="e.g. 日本語, Türkçe, Русский"
|
||||||
value={values.customLanguage}
|
value={values.customLanguage}
|
||||||
onChange={(e) => set('customLanguage', e.target.value)}
|
onChange={(e) => set("customLanguage", e.target.value)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="mood" className="block text-xs uppercase tracking-wider text-fg-muted mb-1">
|
<label
|
||||||
|
htmlFor="mood"
|
||||||
|
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
|
||||||
|
>
|
||||||
Mood
|
Mood
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -126,7 +211,7 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. melancholic, euphoric, tense…"
|
placeholder="e.g. melancholic, euphoric, tense…"
|
||||||
value={values.mood}
|
value={values.mood}
|
||||||
onChange={(e) => set('mood', e.target.value)}
|
onChange={(e) => set("mood", e.target.value)}
|
||||||
className="input"
|
className="input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,25 +223,25 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => set('vocals', 'vocals')}
|
onClick={() => set("vocals", "vocals")}
|
||||||
className={
|
className={
|
||||||
values.vocals === 'vocals'
|
values.vocals === "vocals"
|
||||||
? 'px-3 py-2 rounded-lg font-medium bg-accent-primary text-white transition-colors'
|
? "px-3 py-2 rounded-lg font-medium bg-accent-primary text-white transition-colors"
|
||||||
: 'chip justify-center py-2'
|
: "chip justify-center py-2"
|
||||||
}
|
}
|
||||||
aria-pressed={values.vocals === 'vocals'}
|
aria-pressed={values.vocals === "vocals"}
|
||||||
>
|
>
|
||||||
🎤 Vocals
|
🎤 Vocals
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => set('vocals', 'instrumental')}
|
onClick={() => set("vocals", "instrumental")}
|
||||||
className={
|
className={
|
||||||
values.vocals === 'instrumental'
|
values.vocals === "instrumental"
|
||||||
? 'px-3 py-2 rounded-lg font-medium bg-accent-primary text-white transition-colors'
|
? "px-3 py-2 rounded-lg font-medium bg-accent-primary text-white transition-colors"
|
||||||
: 'chip justify-center py-2'
|
: "chip justify-center py-2"
|
||||||
}
|
}
|
||||||
aria-pressed={values.vocals === 'instrumental'}
|
aria-pressed={values.vocals === "instrumental"}
|
||||||
>
|
>
|
||||||
🎹 Instrumental
|
🎹 Instrumental
|
||||||
</button>
|
</button>
|
||||||
@@ -165,23 +250,26 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<div className="space-y-2">
|
||||||
type="submit"
|
<button
|
||||||
disabled={!canSubmit}
|
type="submit"
|
||||||
className="w-full py-4 rounded-xl text-base font-semibold text-white shadow-lg shadow-violet-900/30 transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none flex items-center justify-center gap-2 gradient-bg hover:brightness-110 active:brightness-95"
|
disabled={!canSubmit}
|
||||||
>
|
className="w-full py-4 rounded-xl text-base font-semibold text-white shadow-lg shadow-violet-900/30 transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none flex items-center justify-center gap-2 gradient-bg hover:brightness-110 active:brightness-95"
|
||||||
{loading ? (
|
>
|
||||||
<>
|
{loading ? (
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
<>
|
||||||
Generating…
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
</>
|
<span>Generating… {formatElapsed(elapsed)}</span>
|
||||||
) : (
|
</>
|
||||||
<>
|
) : (
|
||||||
<Sparkles className="w-5 h-5" />
|
<>
|
||||||
Generate Song Assets
|
<Sparkles className="w-5 h-5" />
|
||||||
</>
|
Generate Song Assets
|
||||||
)}
|
</>
|
||||||
</button>
|
)}
|
||||||
|
</button>
|
||||||
|
{loading && cancelButton}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RefreshCw } from "lucide-react";
|
import { Music, RefreshCw, Sparkles } from "lucide-react";
|
||||||
import type { SongAssets } from "../lib/types";
|
import type { SongAssets } from "../lib/types";
|
||||||
import { SkeletonCard } from "./SkeletonCard";
|
import { SkeletonCard } from "./SkeletonCard";
|
||||||
import { TitlesCard } from "./cards/TitlesCard";
|
import { TitlesCard } from "./cards/TitlesCard";
|
||||||
@@ -18,18 +18,22 @@ export type RegeneratingMap = Partial<Record<SectionKey | "all", boolean>>;
|
|||||||
|
|
||||||
interface ResultsPanelProps {
|
interface ResultsPanelProps {
|
||||||
assets: SongAssets | null;
|
assets: SongAssets | null;
|
||||||
loading: boolean; // true during the initial "Generate all" call
|
originalAssets: SongAssets | null; // last *generated* values, for Revert
|
||||||
|
loading: boolean; // initial "Generate all" skeleton
|
||||||
selectedTitleIndex: number;
|
selectedTitleIndex: number;
|
||||||
onSelectTitle: (i: number) => void;
|
onSelectTitle: (i: number) => void;
|
||||||
onChange: <K extends keyof SongAssets>(key: K, value: SongAssets[K]) => void;
|
onChange: <K extends keyof SongAssets>(key: K, value: SongAssets[K]) => void;
|
||||||
onChangeVideoPrompt: (index: number, value: string) => void;
|
onChangeVideoPrompt: (index: number, value: string) => void;
|
||||||
onRegenerateAll: () => void;
|
onRegenerateAll: () => void;
|
||||||
onRegenerateSection: (section: SectionKey) => void;
|
onRegenerateSection: (section: SectionKey) => void;
|
||||||
|
onCancel?: () => void; // cancel the in-flight generation
|
||||||
|
anyRegenerating: boolean;
|
||||||
regenerating: RegeneratingMap;
|
regenerating: RegeneratingMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResultsPanel({
|
export function ResultsPanel({
|
||||||
assets,
|
assets,
|
||||||
|
originalAssets,
|
||||||
loading,
|
loading,
|
||||||
selectedTitleIndex,
|
selectedTitleIndex,
|
||||||
onSelectTitle,
|
onSelectTitle,
|
||||||
@@ -37,26 +41,41 @@ export function ResultsPanel({
|
|||||||
onChangeVideoPrompt,
|
onChangeVideoPrompt,
|
||||||
onRegenerateAll,
|
onRegenerateAll,
|
||||||
onRegenerateSection,
|
onRegenerateSection,
|
||||||
|
onCancel,
|
||||||
|
anyRegenerating,
|
||||||
regenerating,
|
regenerating,
|
||||||
}: ResultsPanelProps) {
|
}: ResultsPanelProps) {
|
||||||
const showSkeletons = loading && !assets;
|
const showSkeletons = loading && !assets;
|
||||||
const regenAll = Boolean(regenerating.all);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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/80 backdrop-blur border-b border-border mb-6 flex items-center justify-between gap-3">
|
<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/80 backdrop-blur border-b border-border mb-6 flex items-center justify-between gap-3">
|
||||||
<h2 className="text-lg font-semibold text-fg">Results</h2>
|
<h2 className="text-lg font-semibold text-fg">Results</h2>
|
||||||
<button
|
<div className="flex items-center gap-1">
|
||||||
type="button"
|
{anyRegenerating && onCancel && (
|
||||||
onClick={onRegenerateAll}
|
<button
|
||||||
disabled={loading || regenAll}
|
type="button"
|
||||||
className="btn-ghost"
|
onClick={onCancel}
|
||||||
>
|
className="btn-ghost"
|
||||||
<RefreshCw
|
aria-label="Cancel generation"
|
||||||
className={`w-4 h-4 ${regenAll || loading ? "animate-spin" : ""}`}
|
>
|
||||||
/>
|
<span className="text-rose-300">Cancel</span>
|
||||||
<span>Regenerate All</span>
|
</button>
|
||||||
</button>
|
)}
|
||||||
|
{assets && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRegenerateAll}
|
||||||
|
disabled={anyRegenerating}
|
||||||
|
className="btn-ghost"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`w-4 h-4 ${regenerating.all ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
<span>Regenerate All</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showSkeletons ? (
|
{showSkeletons ? (
|
||||||
@@ -67,7 +86,7 @@ export function ResultsPanel({
|
|||||||
<SkeletonCard height="h-32" rows={4} />
|
<SkeletonCard height="h-32" rows={4} />
|
||||||
<SkeletonCard height="h-56" rows={3} />
|
<SkeletonCard height="h-56" rows={3} />
|
||||||
</div>
|
</div>
|
||||||
) : assets ? (
|
) : assets && originalAssets ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<TitlesCard
|
<TitlesCard
|
||||||
titles={assets.titles}
|
titles={assets.titles}
|
||||||
@@ -78,6 +97,7 @@ export function ResultsPanel({
|
|||||||
/>
|
/>
|
||||||
<LyricsCard
|
<LyricsCard
|
||||||
lyrics={assets.lyrics}
|
lyrics={assets.lyrics}
|
||||||
|
originalLyrics={originalAssets.lyrics}
|
||||||
onChange={(v) => onChange("lyrics", v)}
|
onChange={(v) => onChange("lyrics", v)}
|
||||||
onRegenerate={() => onRegenerateSection("lyrics")}
|
onRegenerate={() => onRegenerateSection("lyrics")}
|
||||||
regenerating={Boolean(regenerating.lyrics)}
|
regenerating={Boolean(regenerating.lyrics)}
|
||||||
@@ -85,6 +105,8 @@ export function ResultsPanel({
|
|||||||
<StyleCard
|
<StyleCard
|
||||||
style={assets.style}
|
style={assets.style}
|
||||||
negativeStyle={assets.negative_style}
|
negativeStyle={assets.negative_style}
|
||||||
|
originalStyle={originalAssets.style}
|
||||||
|
originalNegativeStyle={originalAssets.negative_style}
|
||||||
onStyleChange={(v) => onChange("style", v)}
|
onStyleChange={(v) => onChange("style", v)}
|
||||||
onNegativeChange={(v) => onChange("negative_style", v)}
|
onNegativeChange={(v) => onChange("negative_style", v)}
|
||||||
onRegenerate={() => onRegenerateSection("style")}
|
onRegenerate={() => onRegenerateSection("style")}
|
||||||
@@ -92,18 +114,48 @@ export function ResultsPanel({
|
|||||||
/>
|
/>
|
||||||
<VideoPromptsCard
|
<VideoPromptsCard
|
||||||
prompts={assets.video_prompts}
|
prompts={assets.video_prompts}
|
||||||
|
originalPrompts={originalAssets.video_prompts}
|
||||||
onChange={onChangeVideoPrompt}
|
onChange={onChangeVideoPrompt}
|
||||||
onRegenerate={() => onRegenerateSection("video_prompts")}
|
onRegenerate={() => onRegenerateSection("video_prompts")}
|
||||||
regenerating={Boolean(regenerating.video_prompts)}
|
regenerating={Boolean(regenerating.video_prompts)}
|
||||||
/>
|
/>
|
||||||
<YouTubeCard
|
<YouTubeCard
|
||||||
description={assets.youtube_description}
|
description={assets.youtube_description}
|
||||||
|
originalDescription={originalAssets.youtube_description}
|
||||||
onChange={(v) => onChange("youtube_description", v)}
|
onChange={(v) => onChange("youtube_description", v)}
|
||||||
onRegenerate={() => onRegenerateSection("youtube_description")}
|
onRegenerate={() => onRegenerateSection("youtube_description")}
|
||||||
regenerating={Boolean(regenerating.youtube_description)}
|
regenerating={Boolean(regenerating.youtube_description)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : (
|
||||||
|
<EmptyState />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="card p-10 text-center">
|
||||||
|
<div className="mx-auto w-14 h-14 rounded-full gradient-bg-soft flex items-center justify-center mb-4">
|
||||||
|
<Music className="w-7 h-7 text-violet-300" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-semibold text-fg mb-1">
|
||||||
|
Your song will appear here
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-fg-muted max-w-sm mx-auto">
|
||||||
|
Describe a music idea on the left and press{" "}
|
||||||
|
<kbd className="px-1.5 py-0.5 rounded bg-bg-hover border border-border text-[11px] font-mono">
|
||||||
|
Generate
|
||||||
|
</kbd>{" "}
|
||||||
|
to create titles, lyrics, style, video prompts, and a YouTube
|
||||||
|
description — all in one go.
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 flex items-center justify-center gap-2 text-xs text-fg-muted">
|
||||||
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
|
Tip: open <span className="text-fg">Settings</span> to add your API key
|
||||||
|
first.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,25 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { ResultCard } from "../ResultCard";
|
||||||
import { ResultCard } from '../ResultCard';
|
import { CopyButton } from "../CopyButton";
|
||||||
import { CopyButton } from '../CopyButton';
|
import { RotateCcw } from "lucide-react";
|
||||||
|
import { useAutoHeight } from "../../lib/useAutoHeight";
|
||||||
|
|
||||||
interface LyricsCardProps {
|
interface LyricsCardProps {
|
||||||
lyrics: string;
|
lyrics: string;
|
||||||
|
originalLyrics: string;
|
||||||
onChange: (next: string) => void;
|
onChange: (next: string) => void;
|
||||||
onRegenerate: () => void;
|
onRegenerate: () => void;
|
||||||
regenerating: boolean;
|
regenerating: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-grow the textarea to fit its content. Min height is enforced in CSS via
|
export function LyricsCard({
|
||||||
// `min-height`; we only ever grow.
|
lyrics,
|
||||||
function useAutoHeight(value: string, minHeight = 200) {
|
originalLyrics,
|
||||||
const ref = useRef<HTMLTextAreaElement | null>(null);
|
onChange,
|
||||||
useEffect(() => {
|
onRegenerate,
|
||||||
const el = ref.current;
|
regenerating,
|
||||||
if (!el) return;
|
}: LyricsCardProps) {
|
||||||
el.style.height = 'auto';
|
|
||||||
el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`;
|
|
||||||
}, [value, minHeight]);
|
|
||||||
return ref;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LyricsCard({ lyrics, onChange, onRegenerate, regenerating }: LyricsCardProps) {
|
|
||||||
const ref = useAutoHeight(lyrics, 200);
|
const ref = useAutoHeight(lyrics, 200);
|
||||||
|
const dirty = lyrics !== originalLyrics;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResultCard
|
<ResultCard
|
||||||
@@ -32,7 +28,23 @@ export function LyricsCard({ lyrics, onChange, onRegenerate, regenerating }: Lyr
|
|||||||
title="Lyrics"
|
title="Lyrics"
|
||||||
onRegenerate={onRegenerate}
|
onRegenerate={onRegenerate}
|
||||||
regenerating={regenerating}
|
regenerating={regenerating}
|
||||||
headerExtra={lyrics ? <CopyButton value={lyrics} label="Copy" /> : null}
|
headerExtra={
|
||||||
|
<>
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(originalLyrics)}
|
||||||
|
className="btn-ghost"
|
||||||
|
aria-label="Revert lyrics to last generated version"
|
||||||
|
title="Revert to last generated"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
<span>Revert</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{lyrics && <CopyButton value={lyrics} label="Copy" />}
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -1,30 +1,24 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { RotateCcw } from "lucide-react";
|
||||||
import { ResultCard } from '../ResultCard';
|
import { ResultCard } from "../ResultCard";
|
||||||
import { CopyButton } from '../CopyButton';
|
import { CopyButton } from "../CopyButton";
|
||||||
|
import { useAutoHeight } from "../../lib/useAutoHeight";
|
||||||
|
|
||||||
interface StyleCardProps {
|
interface StyleCardProps {
|
||||||
style: string;
|
style: string;
|
||||||
negativeStyle: string;
|
negativeStyle: string;
|
||||||
|
originalStyle: string;
|
||||||
|
originalNegativeStyle: string;
|
||||||
onStyleChange: (next: string) => void;
|
onStyleChange: (next: string) => void;
|
||||||
onNegativeChange: (next: string) => void;
|
onNegativeChange: (next: string) => void;
|
||||||
onRegenerate: () => void;
|
onRegenerate: () => void;
|
||||||
regenerating: boolean;
|
regenerating: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useAutoHeight(value: string, minHeight: number) {
|
|
||||||
const ref = useRef<HTMLTextAreaElement | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
el.style.height = 'auto';
|
|
||||||
el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`;
|
|
||||||
}, [value, minHeight]);
|
|
||||||
return ref;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StyleCard({
|
export function StyleCard({
|
||||||
style,
|
style,
|
||||||
negativeStyle,
|
negativeStyle,
|
||||||
|
originalStyle,
|
||||||
|
originalNegativeStyle,
|
||||||
onStyleChange,
|
onStyleChange,
|
||||||
onNegativeChange,
|
onNegativeChange,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
@@ -33,6 +27,9 @@ export function StyleCard({
|
|||||||
const styleRef = useAutoHeight(style, 96);
|
const styleRef = useAutoHeight(style, 96);
|
||||||
const negRef = useAutoHeight(negativeStyle, 64);
|
const negRef = useAutoHeight(negativeStyle, 64);
|
||||||
|
|
||||||
|
const dirty =
|
||||||
|
style !== originalStyle || negativeStyle !== originalNegativeStyle;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResultCard
|
<ResultCard
|
||||||
index={2}
|
index={2}
|
||||||
@@ -40,6 +37,23 @@ export function StyleCard({
|
|||||||
title="Style"
|
title="Style"
|
||||||
onRegenerate={onRegenerate}
|
onRegenerate={onRegenerate}
|
||||||
regenerating={regenerating}
|
regenerating={regenerating}
|
||||||
|
headerExtra={
|
||||||
|
dirty && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onStyleChange(originalStyle);
|
||||||
|
onNegativeChange(originalNegativeStyle);
|
||||||
|
}}
|
||||||
|
className="btn-ghost"
|
||||||
|
aria-label="Revert style to last generated version"
|
||||||
|
title="Revert to last generated"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
<span>Revert</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -59,7 +73,9 @@ export function StyleCard({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<label className="text-sm font-medium text-fg">Negative Style</label>
|
<label className="text-sm font-medium text-fg">
|
||||||
|
Negative Style
|
||||||
|
</label>
|
||||||
<CopyButton value={negativeStyle} label="Copy" />
|
<CopyButton value={negativeStyle} label="Copy" />
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -1,34 +1,38 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useState } from "react";
|
||||||
import { ResultCard } from '../ResultCard';
|
import { RotateCcw } from "lucide-react";
|
||||||
import { CopyButton } from '../CopyButton';
|
import { ResultCard } from "../ResultCard";
|
||||||
import { NEGATIVE_VIDEO_PROMPT, type VideoPrompt, type VideoPromptType } from '../../lib/types';
|
import { CopyButton } from "../CopyButton";
|
||||||
|
import { useAutoHeight } from "../../lib/useAutoHeight";
|
||||||
|
import {
|
||||||
|
NEGATIVE_VIDEO_PROMPT,
|
||||||
|
type VideoPrompt,
|
||||||
|
type VideoPromptType,
|
||||||
|
} from "../../lib/types";
|
||||||
|
|
||||||
interface VideoPromptsCardProps {
|
interface VideoPromptsCardProps {
|
||||||
prompts: VideoPrompt[];
|
prompts: VideoPrompt[];
|
||||||
|
originalPrompts: VideoPrompt[];
|
||||||
onChange: (index: number, next: string) => void;
|
onChange: (index: number, next: string) => void;
|
||||||
onRegenerate: () => void;
|
onRegenerate: () => void;
|
||||||
regenerating: boolean;
|
regenerating: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS: VideoPromptType[] = ['Abstract', 'Cinematic', 'Hybrid'];
|
const TABS: VideoPromptType[] = ["Abstract", "Cinematic", "Hybrid"];
|
||||||
|
|
||||||
export function VideoPromptsCard({
|
export function VideoPromptsCard({
|
||||||
prompts,
|
prompts,
|
||||||
|
originalPrompts,
|
||||||
onChange,
|
onChange,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
regenerating,
|
regenerating,
|
||||||
}: VideoPromptsCardProps) {
|
}: VideoPromptsCardProps) {
|
||||||
const [tab, setTab] = useState<VideoPromptType>('Abstract');
|
const [tab, setTab] = useState<VideoPromptType>("Abstract");
|
||||||
const idx = prompts.findIndex((p) => p.type === tab);
|
const idx = prompts.findIndex((p) => p.type === tab);
|
||||||
const current = idx >= 0 ? prompts[idx] : undefined;
|
const current = idx >= 0 ? prompts[idx] : undefined;
|
||||||
|
const originalCurrent = originalPrompts.find((p) => p.type === tab);
|
||||||
|
const dirty = current?.prompt !== originalCurrent?.prompt;
|
||||||
|
|
||||||
const ref = useRef<HTMLTextAreaElement | null>(null);
|
const ref = useAutoHeight(current?.prompt ?? "", 120);
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el || !current) return;
|
|
||||||
el.style.height = 'auto';
|
|
||||||
el.style.height = `${Math.max(120, el.scrollHeight)}px`;
|
|
||||||
}, [current?.prompt, tab]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResultCard
|
<ResultCard
|
||||||
@@ -37,7 +41,25 @@ export function VideoPromptsCard({
|
|||||||
title="Video Prompts"
|
title="Video Prompts"
|
||||||
onRegenerate={onRegenerate}
|
onRegenerate={onRegenerate}
|
||||||
regenerating={regenerating}
|
regenerating={regenerating}
|
||||||
headerExtra={current ? <CopyButton value={current.prompt} label="Copy" /> : null}
|
headerExtra={
|
||||||
|
current ? (
|
||||||
|
<>
|
||||||
|
{dirty && originalCurrent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(idx, originalCurrent.prompt)}
|
||||||
|
className="btn-ghost"
|
||||||
|
aria-label="Revert video prompt to last generated version"
|
||||||
|
title="Revert to last generated"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
<span>Revert</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<CopyButton value={current.prompt} label="Copy" />
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 mb-4 p-1 rounded-lg bg-bg-hover/40 border border-border w-fit">
|
<div className="flex items-center gap-1 mb-4 p-1 rounded-lg bg-bg-hover/40 border border-border w-fit">
|
||||||
{TABS.map((t) => {
|
{TABS.map((t) => {
|
||||||
@@ -49,8 +71,8 @@ export function VideoPromptsCard({
|
|||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(t)}
|
||||||
className={
|
className={
|
||||||
isActive
|
isActive
|
||||||
? 'px-3 py-1.5 rounded-md text-sm font-medium bg-accent-primary text-white transition-colors'
|
? "px-3 py-1.5 rounded-md text-sm font-medium bg-accent-primary text-white transition-colors"
|
||||||
: 'px-3 py-1.5 rounded-md text-sm font-medium text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors'
|
: "px-3 py-1.5 rounded-md text-sm font-medium text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors"
|
||||||
}
|
}
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
@@ -78,12 +100,19 @@ export function VideoPromptsCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm italic text-fg-muted">
|
<p className="text-sm italic text-fg-muted">
|
||||||
→ Best for: <span className="not-italic text-fg">{current.tool_recommendation}</span>
|
→ Best for:{" "}
|
||||||
|
<span className="not-italic text-fg">
|
||||||
|
{current.tool_recommendation}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-1 p-3 rounded-lg bg-bg-hover/40 border border-border">
|
<div className="mt-1 p-3 rounded-lg bg-bg-hover/40 border border-border">
|
||||||
<p className="text-[11px] uppercase tracking-wider text-fg-muted mb-1">Negative</p>
|
<p className="text-[11px] uppercase tracking-wider text-fg-muted mb-1">
|
||||||
<p className="text-xs text-fg-muted leading-relaxed">{NEGATIVE_VIDEO_PROMPT}</p>
|
Negative
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-fg-muted leading-relaxed">
|
||||||
|
{NEGATIVE_VIDEO_PROMPT}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { RotateCcw } from "lucide-react";
|
||||||
import { ResultCard } from '../ResultCard';
|
import { ResultCard } from "../ResultCard";
|
||||||
import { CopyButton } from '../CopyButton';
|
import { CopyButton } from "../CopyButton";
|
||||||
|
import { useAutoHeight } from "../../lib/useAutoHeight";
|
||||||
|
|
||||||
interface YouTubeCardProps {
|
interface YouTubeCardProps {
|
||||||
description: string;
|
description: string;
|
||||||
|
originalDescription: string;
|
||||||
onChange: (next: string) => void;
|
onChange: (next: string) => void;
|
||||||
onRegenerate: () => void;
|
onRegenerate: () => void;
|
||||||
regenerating: boolean;
|
regenerating: boolean;
|
||||||
@@ -11,17 +13,13 @@ interface YouTubeCardProps {
|
|||||||
|
|
||||||
export function YouTubeCard({
|
export function YouTubeCard({
|
||||||
description,
|
description,
|
||||||
|
originalDescription,
|
||||||
onChange,
|
onChange,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
regenerating,
|
regenerating,
|
||||||
}: YouTubeCardProps) {
|
}: YouTubeCardProps) {
|
||||||
const ref = useRef<HTMLTextAreaElement | null>(null);
|
const ref = useAutoHeight(description, 300);
|
||||||
useEffect(() => {
|
const dirty = description !== originalDescription;
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
el.style.height = 'auto';
|
|
||||||
el.style.height = `${Math.max(300, el.scrollHeight)}px`;
|
|
||||||
}, [description]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResultCard
|
<ResultCard
|
||||||
@@ -30,7 +28,23 @@ export function YouTubeCard({
|
|||||||
title="YouTube Description"
|
title="YouTube Description"
|
||||||
onRegenerate={onRegenerate}
|
onRegenerate={onRegenerate}
|
||||||
regenerating={regenerating}
|
regenerating={regenerating}
|
||||||
headerExtra={description ? <CopyButton value={description} label="Copy" /> : null}
|
headerExtra={
|
||||||
|
<>
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(originalDescription)}
|
||||||
|
className="btn-ghost"
|
||||||
|
aria-label="Revert description to last generated version"
|
||||||
|
title="Revert to last generated"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
<span>Revert</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{description && <CopyButton value={description} label="Copy" />}
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Recent-generations history. Persists the last N successful generations in
|
||||||
|
// localStorage so the user can revisit a song without re-running the model.
|
||||||
|
|
||||||
|
import type { InputValues } from '../components/InputPanel';
|
||||||
|
import type { SongAssets } from './types';
|
||||||
|
|
||||||
|
const HISTORY_KEY = 'melodymuse-history';
|
||||||
|
const MAX_HISTORY = 6;
|
||||||
|
|
||||||
|
export interface HistoryEntry {
|
||||||
|
id: string;
|
||||||
|
timestamp: number;
|
||||||
|
input: InputValues;
|
||||||
|
assets: SongAssets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAll(): HistoryEntry[] {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(HISTORY_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.filter(
|
||||||
|
(e): e is HistoryEntry =>
|
||||||
|
e &&
|
||||||
|
typeof e === 'object' &&
|
||||||
|
typeof e.id === 'string' &&
|
||||||
|
typeof e.timestamp === 'number' &&
|
||||||
|
typeof e.input === 'object' &&
|
||||||
|
typeof e.assets === 'object',
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAll(entries: HistoryEntry[]): void {
|
||||||
|
window.localStorage.setItem(HISTORY_KEY, JSON.stringify(entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadHistory(): HistoryEntry[] {
|
||||||
|
return readAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addToHistory(
|
||||||
|
input: InputValues,
|
||||||
|
assets: SongAssets,
|
||||||
|
): HistoryEntry[] {
|
||||||
|
const entry: HistoryEntry = {
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
input,
|
||||||
|
assets,
|
||||||
|
};
|
||||||
|
const next = [entry, ...readAll()].slice(0, MAX_HISTORY);
|
||||||
|
writeAll(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeFromHistory(id: string): HistoryEntry[] {
|
||||||
|
const next = readAll().filter((e) => e.id !== id);
|
||||||
|
writeAll(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHistory(): void {
|
||||||
|
writeAll([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelative(timestamp: number, now: number = Date.now()): string {
|
||||||
|
const diff = Math.max(0, now - timestamp);
|
||||||
|
const s = Math.floor(diff / 1000);
|
||||||
|
if (s < 60) return 'just now';
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
if (d < 7) return `${d}d ago`;
|
||||||
|
return new Date(timestamp).toLocaleDateString();
|
||||||
|
}
|
||||||
+65
-3
@@ -13,12 +13,13 @@ import { buildSystemPrompt, buildUserMessage } from "./prompts";
|
|||||||
import type {
|
import type {
|
||||||
ConfigDisplay,
|
ConfigDisplay,
|
||||||
GenerateRequest,
|
GenerateRequest,
|
||||||
|
InputValues,
|
||||||
SongAssets,
|
SongAssets,
|
||||||
TestConnectionResult,
|
TestConnectionResult,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const STORAGE_KEY = "melodymuse-config";
|
const STORAGE_KEY = "melodymuse-config";
|
||||||
const DEFAULT_MODEL = "MiniMax-M3";
|
export const DEFAULT_MODEL = "MiniMax-M3";
|
||||||
|
|
||||||
export interface StoredConfig {
|
export interface StoredConfig {
|
||||||
api_endpoint: string;
|
api_endpoint: string;
|
||||||
@@ -97,6 +98,32 @@ export function setConfig(patch: Partial<StoredConfig>): ConfigDisplay {
|
|||||||
return getConfigDisplay();
|
return getConfigDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist *only* the fields the caller passed that are non-empty. Unlike
|
||||||
|
* `setConfig`, this overwrites the endpoint/model and replaces the API key if
|
||||||
|
* a new one is provided. Used by /settings's Test Connection path when the
|
||||||
|
* user is in the middle of editing and wants to try values before committing.
|
||||||
|
*/
|
||||||
|
export function previewConfig(patch: Partial<StoredConfig>): StoredConfig {
|
||||||
|
const current = loadConfig();
|
||||||
|
const candidate: StoredConfig = {
|
||||||
|
api_endpoint:
|
||||||
|
typeof patch.api_endpoint === "string" &&
|
||||||
|
patch.api_endpoint.trim().length > 0
|
||||||
|
? patch.api_endpoint.trim()
|
||||||
|
: current.api_endpoint,
|
||||||
|
api_key:
|
||||||
|
typeof patch.api_key === "string" && patch.api_key.length > 0
|
||||||
|
? patch.api_key
|
||||||
|
: current.api_key,
|
||||||
|
model_name:
|
||||||
|
typeof patch.model_name === "string" && patch.model_name.trim().length > 0
|
||||||
|
? patch.model_name.trim()
|
||||||
|
: current.model_name,
|
||||||
|
};
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
// JSON extraction + shape validation
|
// JSON extraction + shape validation
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -183,6 +210,7 @@ interface ProviderRequest {
|
|||||||
async function callProvider(
|
async function callProvider(
|
||||||
body: ProviderRequest,
|
body: ProviderRequest,
|
||||||
cfg: StoredConfig,
|
cfg: StoredConfig,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const base = cfg.api_endpoint.replace(/\/+$/, "");
|
const base = cfg.api_endpoint.replace(/\/+$/, "");
|
||||||
const url = `${base}/chat/completions`;
|
const url = `${base}/chat/completions`;
|
||||||
@@ -196,8 +224,13 @@ async function callProvider(
|
|||||||
Authorization: `Bearer ${cfg.api_key}`,
|
Authorization: `Bearer ${cfg.api_key}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") {
|
||||||
|
// Re-throw so the caller can recognize a cancellation distinctly.
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
// fetch() throws TypeError for network/CORS failures. Surface a helpful
|
// fetch() throws TypeError for network/CORS failures. Surface a helpful
|
||||||
// message — the user is running the app standalone, so CORS is the most
|
// message — the user is running the app standalone, so CORS is the most
|
||||||
// likely culprit.
|
// likely culprit.
|
||||||
@@ -242,15 +275,25 @@ type PartialRequest = Omit<GenerateRequest, "section"> & {
|
|||||||
section: Exclude<GenerateRequest["section"], "all">;
|
section: Exclude<GenerateRequest["section"], "all">;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function generateSong(req: AllRequest): Promise<SongAssets>;
|
export interface GenerateOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateSong(
|
||||||
|
req: AllRequest,
|
||||||
|
options?: GenerateOptions,
|
||||||
|
): Promise<SongAssets>;
|
||||||
export async function generateSong(
|
export async function generateSong(
|
||||||
req: PartialRequest,
|
req: PartialRequest,
|
||||||
|
options?: GenerateOptions,
|
||||||
): Promise<Partial<SongAssets>>;
|
): Promise<Partial<SongAssets>>;
|
||||||
export async function generateSong(
|
export async function generateSong(
|
||||||
req: GenerateRequest,
|
req: GenerateRequest,
|
||||||
|
options?: GenerateOptions,
|
||||||
): Promise<Partial<SongAssets>>;
|
): Promise<Partial<SongAssets>>;
|
||||||
export async function generateSong(
|
export async function generateSong(
|
||||||
req: GenerateRequest,
|
req: GenerateRequest,
|
||||||
|
options?: GenerateOptions,
|
||||||
): Promise<Partial<SongAssets>> {
|
): Promise<Partial<SongAssets>> {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
if (!isConfigured(cfg)) {
|
if (!isConfigured(cfg)) {
|
||||||
@@ -267,6 +310,7 @@ export async function generateSong(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
|
options?.signal,
|
||||||
);
|
);
|
||||||
|
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
@@ -291,8 +335,20 @@ export async function generateSong(
|
|||||||
return parsed as Partial<SongAssets>;
|
return parsed as Partial<SongAssets>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test the connection using the *currently saved* configuration.
|
||||||
|
*/
|
||||||
export async function testConnection(): Promise<TestConnectionResult> {
|
export async function testConnection(): Promise<TestConnectionResult> {
|
||||||
const cfg = loadConfig();
|
return testConnectionWithConfig(loadConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test the connection using an arbitrary (in-memory) configuration. Used by
|
||||||
|
* the Settings page to try out a candidate key/endpoint before persisting it.
|
||||||
|
*/
|
||||||
|
export async function testConnectionWithConfig(
|
||||||
|
cfg: StoredConfig,
|
||||||
|
): Promise<TestConnectionResult> {
|
||||||
if (!isConfigured(cfg)) {
|
if (!isConfigured(cfg)) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -311,9 +367,15 @@ export async function testConnection(): Promise<TestConnectionResult> {
|
|||||||
);
|
);
|
||||||
return { success: true, message: "Connection successful" };
|
return { success: true, message: "Connection successful" };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") {
|
||||||
|
return { success: false, message: "Cancelled" };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: err instanceof Error ? err.message : "Connection test failed",
|
message: err instanceof Error ? err.message : "Connection test failed",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-export so consumers can import everything from one place.
|
||||||
|
export type { InputValues };
|
||||||
|
|||||||
+38
-25
@@ -1,27 +1,27 @@
|
|||||||
// Domain types shared across the app. These mirror the JSON shape produced by
|
// Domain types shared across the app. These mirror the JSON shape produced by
|
||||||
// the `generate-song` edge function.
|
// the LLM module.
|
||||||
|
|
||||||
export type Language =
|
export type Language =
|
||||||
| 'English'
|
| "English"
|
||||||
| 'Deutsch'
|
| "Deutsch"
|
||||||
| 'Español'
|
| "Español"
|
||||||
| 'Français'
|
| "Français"
|
||||||
| 'Italiano'
|
| "Italiano"
|
||||||
| 'Português'
|
| "Português"
|
||||||
| 'Polski'
|
| "Polski"
|
||||||
| 'Other';
|
| "Other";
|
||||||
|
|
||||||
export type Vocals = 'vocals' | 'instrumental';
|
export type Vocals = "vocals" | "instrumental";
|
||||||
|
|
||||||
export type GenerationSection =
|
export type GenerationSection =
|
||||||
| 'all'
|
| "all"
|
||||||
| 'lyrics'
|
| "lyrics"
|
||||||
| 'style'
|
| "style"
|
||||||
| 'titles'
|
| "titles"
|
||||||
| 'video_prompts'
|
| "video_prompts"
|
||||||
| 'youtube_description';
|
| "youtube_description";
|
||||||
|
|
||||||
export type VideoPromptType = 'Abstract' | 'Cinematic' | 'Hybrid';
|
export type VideoPromptType = "Abstract" | "Cinematic" | "Hybrid";
|
||||||
|
|
||||||
export interface VideoPrompt {
|
export interface VideoPrompt {
|
||||||
type: VideoPromptType;
|
type: VideoPromptType;
|
||||||
@@ -40,7 +40,7 @@ export interface SongAssets {
|
|||||||
|
|
||||||
export interface GenerateRequest {
|
export interface GenerateRequest {
|
||||||
input: string;
|
input: string;
|
||||||
language: string; // 'English' or free-text from "Other"
|
language: string; // 'English' or free-text from "Other"
|
||||||
mood?: string;
|
mood?: string;
|
||||||
vocals: Vocals;
|
vocals: Vocals;
|
||||||
section: GenerationSection;
|
section: GenerationSection;
|
||||||
@@ -53,16 +53,29 @@ export interface ConfigDisplay {
|
|||||||
api_key_set: boolean;
|
api_key_set: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveConfigPayload {
|
|
||||||
api_endpoint: string;
|
|
||||||
api_key: string; // empty string => keep existing
|
|
||||||
model_name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TestConnectionResult {
|
export interface TestConnectionResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InputValues {
|
||||||
|
idea: string;
|
||||||
|
language: Language | string;
|
||||||
|
customLanguage: string;
|
||||||
|
mood: string;
|
||||||
|
vocals: Vocals;
|
||||||
|
}
|
||||||
|
|
||||||
export const NEGATIVE_VIDEO_PROMPT =
|
export const NEGATIVE_VIDEO_PROMPT =
|
||||||
'text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur';
|
"text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur";
|
||||||
|
|
||||||
|
// Human-readable labels for each regenerable section — used by toasts and the
|
||||||
|
// history panel. Keep in sync with `GenerationSection`.
|
||||||
|
export const SECTION_LABELS: Record<GenerationSection, string> = {
|
||||||
|
all: "everything",
|
||||||
|
titles: "titles",
|
||||||
|
lyrics: "lyrics",
|
||||||
|
style: "style",
|
||||||
|
video_prompts: "video prompts",
|
||||||
|
youtube_description: "description",
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
// Auto-grow a textarea to fit its content, with a configurable minimum height.
|
||||||
|
// Use this anywhere a free-form text field should grow with the user's input.
|
||||||
|
export function useAutoHeight(
|
||||||
|
value: string,
|
||||||
|
minHeight = 0,
|
||||||
|
) {
|
||||||
|
const ref = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`;
|
||||||
|
}, [value, minHeight]);
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
// Returns the number of seconds elapsed since `active` became true. The returned
|
||||||
|
// value ticks every 250 ms while active, freezes when `active` is false.
|
||||||
|
export function useElapsed(active: boolean): number {
|
||||||
|
const [seconds, setSeconds] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) {
|
||||||
|
setSeconds(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = Date.now();
|
||||||
|
setSeconds(0);
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
setSeconds(Math.floor((Date.now() - start) / 1000));
|
||||||
|
}, 250);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
return seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatElapsed(s: number): string {
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const r = s % 60;
|
||||||
|
return `${m}m ${r.toString().padStart(2, '0')}s`;
|
||||||
|
}
|
||||||
+159
-82
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { InputPanel } from "../components/InputPanel";
|
||||||
import { InputPanel, type InputValues } from "../components/InputPanel";
|
import type { InputValues } from "../components/InputPanel";
|
||||||
import {
|
import {
|
||||||
ResultsPanel,
|
ResultsPanel,
|
||||||
type RegeneratingMap,
|
type RegeneratingMap,
|
||||||
@@ -9,10 +9,17 @@ import {
|
|||||||
import { StickyZipBar } from "../components/StickyZipBar";
|
import { StickyZipBar } from "../components/StickyZipBar";
|
||||||
import { ConfigBanner } from "../components/ConfigBanner";
|
import { ConfigBanner } from "../components/ConfigBanner";
|
||||||
import { ThemeToggle } from "../components/ThemeToggle";
|
import { ThemeToggle } from "../components/ThemeToggle";
|
||||||
|
import { HistoryPanel } from "../components/HistoryPanel";
|
||||||
|
import { Footer } from "../components/Footer";
|
||||||
import { useToast } from "../lib/toast";
|
import { useToast } from "../lib/toast";
|
||||||
import { generateSong, getConfigDisplay } from "../lib/llm";
|
import { generateSong, getConfigDisplay } from "../lib/llm";
|
||||||
import { buildSongZip, downloadBlob } from "../lib/zip";
|
import { addToHistory, type HistoryEntry } from "../lib/history";
|
||||||
import type { SongAssets, VideoPrompt } from "../lib/types";
|
import { buildSongZip, downloadBlob, sanitizeFilename } from "../lib/zip";
|
||||||
|
import {
|
||||||
|
SECTION_LABELS,
|
||||||
|
type SongAssets,
|
||||||
|
type VideoPrompt,
|
||||||
|
} from "../lib/types";
|
||||||
|
|
||||||
const DEFAULT_INPUT: InputValues = {
|
const DEFAULT_INPUT: InputValues = {
|
||||||
idea: "",
|
idea: "",
|
||||||
@@ -28,16 +35,32 @@ function resolveLanguage(v: InputValues): string {
|
|||||||
: v.language;
|
: v.language;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `true` when any in-flight generation is running, false otherwise.
|
||||||
|
function isAnyBusy(loading: boolean, regenerating: RegeneratingMap): boolean {
|
||||||
|
if (loading) return true;
|
||||||
|
for (const v of Object.values(regenerating)) if (v) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const [input, setInput] = useState<InputValues>(DEFAULT_INPUT);
|
const [input, setInput] = useState<InputValues>(DEFAULT_INPUT);
|
||||||
const [assets, setAssets] = useState<SongAssets | null>(null);
|
const [assets, setAssets] = useState<SongAssets | null>(null);
|
||||||
|
// The "last generated" snapshot. The cards compare their current editable
|
||||||
|
// values against these to decide whether to show the Revert button.
|
||||||
|
const [originalAssets, setOriginalAssets] = useState<SongAssets | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [regenerating, setRegenerating] = useState<RegeneratingMap>({});
|
const [regenerating, setRegenerating] = useState<RegeneratingMap>({});
|
||||||
const [selectedTitleIndex, setSelectedTitleIndex] = useState(0);
|
const [selectedTitleIndex, setSelectedTitleIndex] = useState(0);
|
||||||
const [apiKeySet, setApiKeySet] = useState<boolean>(false);
|
const [apiKeySet, setApiKeySet] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// AbortController for the *current* network request. Refs are used so the
|
||||||
|
// cancel handler always sees the latest value without re-binding.
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const anyRegenerating = isAnyBusy(loading, regenerating);
|
||||||
|
|
||||||
// Check localStorage on mount: if the API key isn't set, show a banner
|
// Check localStorage on mount: if the API key isn't set, show a banner
|
||||||
// pointing the user at Settings.
|
// pointing the user at Settings.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,6 +68,9 @@ export function HomePage() {
|
|||||||
setApiKeySet(cfg.api_key_set);
|
setApiKeySet(cfg.api_key_set);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Clean up any in-flight request on unmount.
|
||||||
|
useEffect(() => () => abortRef.current?.abort(), []);
|
||||||
|
|
||||||
const selectedTitle = useMemo(() => {
|
const selectedTitle = useMemo(() => {
|
||||||
if (!assets || assets.titles.length === 0) return undefined;
|
if (!assets || assets.titles.length === 0) return undefined;
|
||||||
return assets.titles[
|
return assets.titles[
|
||||||
@@ -52,94 +78,129 @@ export function HomePage() {
|
|||||||
];
|
];
|
||||||
}, [assets, selectedTitleIndex]);
|
}, [assets, selectedTitleIndex]);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
abortRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runGeneration = useCallback(
|
||||||
|
async (
|
||||||
|
buildRequest: (
|
||||||
|
signal: AbortSignal,
|
||||||
|
) => Promise<GenerateCall> | GenerateCall,
|
||||||
|
onSuccess: (result: Partial<SongAssets>) => void,
|
||||||
|
busySetter: (b: boolean) => void,
|
||||||
|
successMsg: string,
|
||||||
|
) => {
|
||||||
|
// Cancel any in-flight request before starting a new one.
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
busySetter(true);
|
||||||
|
try {
|
||||||
|
const req = await buildRequest(ctrl.signal);
|
||||||
|
const result = await generateSong(req, { signal: ctrl.signal });
|
||||||
|
onSuccess(result);
|
||||||
|
toast.success(successMsg);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") {
|
||||||
|
toast.info("Generation cancelled");
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Generation failed — please try again",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
busySetter(false);
|
||||||
|
if (abortRef.current === ctrl) abortRef.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[toast],
|
||||||
|
);
|
||||||
|
|
||||||
const handleGenerate = useCallback(async () => {
|
const handleGenerate = useCallback(async () => {
|
||||||
if (!input.idea.trim()) return;
|
if (!input.idea.trim()) return;
|
||||||
setLoading(true);
|
await runGeneration(
|
||||||
setAssets(null);
|
() => ({
|
||||||
try {
|
|
||||||
const result = await generateSong({
|
|
||||||
input: input.idea.trim(),
|
input: input.idea.trim(),
|
||||||
language: resolveLanguage(input),
|
language: resolveLanguage(input),
|
||||||
mood: input.mood.trim() || undefined,
|
mood: input.mood.trim() || undefined,
|
||||||
vocals: input.vocals,
|
vocals: input.vocals,
|
||||||
section: "all",
|
section: "all" as const,
|
||||||
});
|
}),
|
||||||
setAssets(result);
|
(result) => {
|
||||||
setSelectedTitleIndex(0);
|
const next = result as SongAssets;
|
||||||
toast.success("Song assets generated");
|
setAssets(next);
|
||||||
} catch (err) {
|
setOriginalAssets(next);
|
||||||
toast.error(
|
setSelectedTitleIndex(0);
|
||||||
err instanceof Error
|
// Persist to history. Don't `await` — the user shouldn't wait.
|
||||||
? err.message
|
addToHistory(input, next);
|
||||||
: "Generation failed — please try again",
|
},
|
||||||
);
|
setLoading,
|
||||||
} finally {
|
"Song assets generated",
|
||||||
setLoading(false);
|
);
|
||||||
}
|
}, [input, runGeneration]);
|
||||||
}, [input, toast]);
|
|
||||||
|
|
||||||
const handleRegenerateAll = useCallback(async () => {
|
const handleRegenerateAll = useCallback(async () => {
|
||||||
if (!input.idea.trim()) return;
|
if (!input.idea.trim()) return;
|
||||||
setLoading(true);
|
await runGeneration(
|
||||||
setRegenerating((m) => ({ ...m, all: true }));
|
() => ({
|
||||||
try {
|
|
||||||
const result = await generateSong({
|
|
||||||
input: input.idea.trim(),
|
input: input.idea.trim(),
|
||||||
language: resolveLanguage(input),
|
language: resolveLanguage(input),
|
||||||
mood: input.mood.trim() || undefined,
|
mood: input.mood.trim() || undefined,
|
||||||
vocals: input.vocals,
|
vocals: input.vocals,
|
||||||
section: "all",
|
section: "all" as const,
|
||||||
});
|
}),
|
||||||
setAssets(result);
|
(result) => {
|
||||||
setSelectedTitleIndex(0);
|
const next = result as SongAssets;
|
||||||
toast.success("Regenerated all sections");
|
setAssets(next);
|
||||||
} catch (err) {
|
setOriginalAssets(next);
|
||||||
toast.error(
|
setSelectedTitleIndex(0);
|
||||||
err instanceof Error
|
addToHistory(input, next);
|
||||||
? err.message
|
},
|
||||||
: "Generation failed — please try again",
|
(b) => {
|
||||||
);
|
setLoading(b);
|
||||||
} finally {
|
setRegenerating((m) => ({ ...m, all: b }));
|
||||||
setLoading(false);
|
},
|
||||||
setRegenerating((m) => ({ ...m, all: false }));
|
"Regenerated all sections",
|
||||||
}
|
);
|
||||||
}, [input, toast]);
|
}, [input, runGeneration]);
|
||||||
|
|
||||||
const handleRegenerateSection = useCallback(
|
const handleRegenerateSection = useCallback(
|
||||||
async (section: SectionKey) => {
|
async (section: SectionKey) => {
|
||||||
if (!input.idea.trim() || !assets) return;
|
if (!input.idea.trim() || !assets) return;
|
||||||
setRegenerating((m) => ({ ...m, [section]: true }));
|
await runGeneration(
|
||||||
try {
|
() => ({
|
||||||
const result = await generateSong({
|
|
||||||
input: input.idea.trim(),
|
input: input.idea.trim(),
|
||||||
language: resolveLanguage(input),
|
language: resolveLanguage(input),
|
||||||
mood: input.mood.trim() || undefined,
|
mood: input.mood.trim() || undefined,
|
||||||
vocals: input.vocals,
|
vocals: input.vocals,
|
||||||
section,
|
section,
|
||||||
// Pass the rest of the song as context so the model can stay consistent.
|
|
||||||
context: assets,
|
context: assets,
|
||||||
});
|
}),
|
||||||
setAssets({ ...assets, ...result });
|
(result) => {
|
||||||
toast.success(`Regenerated ${humanizeSection(section)}`);
|
// Merge partial into current assets, and update the original snapshot
|
||||||
} catch (err) {
|
// for the keys that the model returned. Any keys the user has
|
||||||
toast.error(
|
// hand-edited that the model *didn't* return stay as-is.
|
||||||
err instanceof Error
|
const merged: SongAssets = { ...assets, ...result };
|
||||||
? err.message
|
setAssets(merged);
|
||||||
: "Generation failed — please try again",
|
setOriginalAssets((prev) => (prev ? { ...prev, ...result } : merged));
|
||||||
);
|
},
|
||||||
} finally {
|
(b) => setRegenerating((m) => ({ ...m, [section]: b })),
|
||||||
setRegenerating((m) => ({ ...m, [section]: false }));
|
`Regenerated ${SECTION_LABELS[section]}`,
|
||||||
}
|
);
|
||||||
},
|
},
|
||||||
[input, assets, toast],
|
[input, assets, runGeneration],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDownload = useCallback(async () => {
|
const handleDownload = useCallback(async () => {
|
||||||
if (!assets || !selectedTitle) return;
|
if (!assets || !selectedTitle) return;
|
||||||
try {
|
try {
|
||||||
const blob = await buildSongZip(assets, selectedTitle);
|
const blob = await buildSongZip(assets, selectedTitle);
|
||||||
const filename = `${selectedTitle.replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_-]/g, "")}.zip`;
|
downloadBlob(blob, `${sanitizeFilename(selectedTitle)}.zip`);
|
||||||
downloadBlob(blob, filename);
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Could not generate ZIP — please try again");
|
toast.error("Could not generate ZIP — please try again");
|
||||||
}
|
}
|
||||||
@@ -166,16 +227,23 @@ export function HomePage() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Restore a previous generation from history.
|
||||||
|
const handleLoadHistory = useCallback(
|
||||||
|
(entry: HistoryEntry) => {
|
||||||
|
setInput(entry.input);
|
||||||
|
setAssets(entry.assets);
|
||||||
|
setOriginalAssets(entry.assets);
|
||||||
|
setSelectedTitleIndex(0);
|
||||||
|
toast.info(`Loaded "${truncate(entry.input.idea, 60)}"`);
|
||||||
|
},
|
||||||
|
[toast],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen pb-32">
|
<div className="min-h-screen pb-32">
|
||||||
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
|
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between">
|
||||||
<Link
|
<span className="text-sm text-fg-muted">MelodyMuse</span>
|
||||||
to="/"
|
|
||||||
className="text-sm text-fg-muted hover:text-fg transition-colors"
|
|
||||||
>
|
|
||||||
MelodyMuse
|
|
||||||
</Link>
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -196,12 +264,23 @@ export function HomePage() {
|
|||||||
onChange={setInput}
|
onChange={setInput}
|
||||||
onSubmit={handleGenerate}
|
onSubmit={handleGenerate}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
cancelButton={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancel}
|
||||||
|
className="btn-secondary w-full"
|
||||||
|
>
|
||||||
|
Cancel generation
|
||||||
|
</button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
<HistoryPanel onLoad={handleLoadHistory} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<ResultsPanel
|
<ResultsPanel
|
||||||
assets={assets}
|
assets={assets}
|
||||||
|
originalAssets={originalAssets}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
selectedTitleIndex={selectedTitleIndex}
|
selectedTitleIndex={selectedTitleIndex}
|
||||||
onSelectTitle={setSelectedTitleIndex}
|
onSelectTitle={setSelectedTitleIndex}
|
||||||
@@ -209,6 +288,8 @@ export function HomePage() {
|
|||||||
onChangeVideoPrompt={handleVideoPromptChange}
|
onChangeVideoPrompt={handleVideoPromptChange}
|
||||||
onRegenerateAll={handleRegenerateAll}
|
onRegenerateAll={handleRegenerateAll}
|
||||||
onRegenerateSection={handleRegenerateSection}
|
onRegenerateSection={handleRegenerateSection}
|
||||||
|
onCancel={cancel}
|
||||||
|
anyRegenerating={anyRegenerating}
|
||||||
regenerating={regenerating}
|
regenerating={regenerating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,21 +303,17 @@ export function HomePage() {
|
|||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function humanizeSection(s: SectionKey): string {
|
function truncate(s: string, n: number): string {
|
||||||
switch (s) {
|
const t = s.trim();
|
||||||
case "titles":
|
if (t.length <= n) return t || "previous generation";
|
||||||
return "titles";
|
return t.slice(0, n - 1) + "…";
|
||||||
case "lyrics":
|
|
||||||
return "lyrics";
|
|
||||||
case "style":
|
|
||||||
return "style";
|
|
||||||
case "video_prompts":
|
|
||||||
return "video prompts";
|
|
||||||
case "youtube_description":
|
|
||||||
return "description";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Internal alias so the `runGeneration` helper stays readable.
|
||||||
|
type GenerateCall = Parameters<typeof generateSong>[0];
|
||||||
|
|||||||
+56
-19
@@ -1,14 +1,30 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeft, Eye, EyeOff, Loader2, Save, Plug } from "lucide-react";
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Loader2,
|
||||||
|
Save,
|
||||||
|
Plug,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import { ThemeToggle } from "../components/ThemeToggle";
|
import { ThemeToggle } from "../components/ThemeToggle";
|
||||||
import { useToast } from "../lib/toast";
|
import { useToast } from "../lib/toast";
|
||||||
import { getConfigDisplay, setConfig, testConnection } from "../lib/llm";
|
import {
|
||||||
|
getConfigDisplay,
|
||||||
|
setConfig,
|
||||||
|
previewConfig,
|
||||||
|
testConnectionWithConfig,
|
||||||
|
saveConfig,
|
||||||
|
loadConfig,
|
||||||
|
DEFAULT_MODEL,
|
||||||
|
} from "../lib/llm";
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
api_endpoint: "",
|
api_endpoint: "",
|
||||||
api_key: "",
|
api_key: "",
|
||||||
model_name: "MiniMax-M3",
|
model_name: DEFAULT_MODEL,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
@@ -34,7 +50,7 @@ export function SettingsPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onSave = async (e: React.FormEvent) => {
|
const onSave = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -55,27 +71,28 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Test using *in-memory* form values — don't persist until the user clicks
|
||||||
|
// Save. An empty API key field still falls back to the previously saved key,
|
||||||
|
// matching the behaviour of Save.
|
||||||
const onTest = async () => {
|
const onTest = async () => {
|
||||||
setTesting(true);
|
setTesting(true);
|
||||||
// Persist current form values first so the test reflects them. setConfig
|
const candidate = previewConfig({
|
||||||
// preserves the existing key when the field is empty, so this is safe.
|
api_endpoint: endpoint.trim(),
|
||||||
try {
|
api_key: apiKey,
|
||||||
const result = setConfig({
|
model_name: model.trim(),
|
||||||
api_endpoint: endpoint.trim(),
|
});
|
||||||
api_key: apiKey,
|
|
||||||
model_name: model.trim(),
|
if (
|
||||||
});
|
!candidate.api_endpoint ||
|
||||||
setApiKey("");
|
!candidate.api_key ||
|
||||||
setApiKeySet(result.api_key_set);
|
!candidate.model_name
|
||||||
} catch (err) {
|
) {
|
||||||
setTesting(false);
|
setTesting(false);
|
||||||
toast.error(
|
toast.error("❌ Fill in endpoint, key, and model before testing");
|
||||||
err instanceof Error ? err.message : "Failed to save configuration",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await testConnection();
|
const result = await testConnectionWithConfig(candidate);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(`✅ ${result.message}`);
|
toast.success(`✅ ${result.message}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -84,6 +101,16 @@ export function SettingsPage() {
|
|||||||
setTesting(false);
|
setTesting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onClearKey = () => {
|
||||||
|
if (!apiKeySet) return;
|
||||||
|
if (!window.confirm("Clear the saved API key from this browser?")) return;
|
||||||
|
const current = loadConfig();
|
||||||
|
saveConfig({ ...current, api_key: "" });
|
||||||
|
setApiKeySet(false);
|
||||||
|
setApiKey("");
|
||||||
|
toast.info("Saved API key cleared");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
|
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
|
||||||
@@ -175,6 +202,16 @@ export function SettingsPage() {
|
|||||||
A key is currently saved.
|
A key is currently saved.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{apiKeySet && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearKey}
|
||||||
|
className="mt-2 inline-flex items-center gap-1 text-xs text-fg-muted hover:text-rose-300 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
Clear saved key
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user