feat: add configurable video AI links

This commit is contained in:
2026-06-04 11:11:36 +02:00
parent 9dddaffdd4
commit ab5a967fe4
6 changed files with 183 additions and 0 deletions
+3
View File
@@ -108,3 +108,6 @@ CORS_ORIGIN=*
# Leave empty to use the dev proxy (see vite.config.ts). Set this if you want
# the browser to talk to a server on a different origin.
# VITE_API_BASE_URL=http://localhost:3000
# Video AI Links configuration (comma-separated Key=URL pairs)
VIDEO_AI_LINKS="Kling AI=https://klingai.com,Luma=https://lumalabs.ai,Runway=https://runwayml.com"
+20
View File
@@ -349,6 +349,23 @@ async function handleHealth(req, res) {
});
}
function handleConfig(req, res) {
let videoAiLinks = [];
const envLinks = process.env.VIDEO_AI_LINKS || '';
if (envLinks.trim()) {
// Expected format: "Kling AI=https://klingai.com,Luma=https://lumalabs.ai"
videoAiLinks = envLinks.split(',').map(part => {
const idx = part.indexOf('=');
if (idx > 0) {
return { name: part.slice(0, idx).trim(), url: part.slice(idx + 1).trim() };
}
return null;
}).filter(link => link !== null);
}
json(res, { videoAiLinks });
}
async function handleStyleRandom(req, res) {
let body;
try { body = await readJsonBody(req); }
@@ -512,6 +529,9 @@ const server = createServer(async (req, res) => {
if (req.method === 'GET' && req.url.split('?')[0] === '/api/health') {
return handleHealth(req, res);
}
if (req.method === 'GET' && req.url.split('?')[0] === '/api/config') {
return handleConfig(req, res);
}
if (req.method === 'POST' && req.url.split('?')[0] === '/api/generate') {
return handleGenerate(req, res);
}
+20
View File
@@ -9,6 +9,8 @@ import {
type VideoPrompt,
type VideoPromptType,
} from "../../lib/types";
import { useVideoAiLinks } from "../../lib/useVideoAiLinks";
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
interface VideoPromptsCardProps {
prompts: VideoPrompt[];
@@ -34,6 +36,7 @@ export function VideoPromptsCard({
const dirty = current?.prompt !== originalCurrent?.prompt;
const ref = useAutoHeight(current?.prompt ?? "", 120);
const { links } = useVideoAiLinks();
return (
<ResultCard
@@ -115,6 +118,23 @@ export function VideoPromptsCard({
{NEGATIVE_VIDEO_PROMPT}
</p>
</div>
{links && links.length > 0 && (
<div className="pt-2 flex flex-wrap gap-2">
{links.map((link, i) => (
<a
key={i}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wide bg-accent-primary/10 text-accent-primary hover:bg-accent-primary hover:text-white transition-all duration-150 border border-accent-primary/20 hover:border-accent-primary"
>
{link.name}
<FontAwesomeIcon icon={faExternalLinkAlt} className="w-3 h-3" />
</a>
))}
</div>
)}
</div>
) : (
<p className="text-sm text-fg-muted">No prompt for this tab yet.</p>
+12
View File
@@ -71,6 +71,18 @@ export async function getServerStatus(
return (await resp.json()) as ServerStatus;
}
export interface ConfigResponse {
videoAiLinks: import("./types").VideoAiLink[];
}
export async function getConfig(signal?: AbortSignal): Promise<ConfigResponse> {
const resp = await fetch(`${API_BASE}/api/config`, { signal });
if (!resp.ok) {
throw new Error(`Server config check failed (${resp.status} ${resp.statusText})`);
}
return (await resp.json()) as ConfigResponse;
}
export interface GenerateOptions {
signal?: AbortSignal;
}
+6
View File
@@ -71,3 +71,9 @@ export const SECTION_LABELS: Record<GenerationSection, string> = {
video_prompts: "video prompts",
youtube_description: "description",
};
export interface VideoAiLink {
name: string;
url: string;
}
+122
View File
@@ -9,11 +9,14 @@ import {
faPlug,
faTrashAlt,
faServer,
faVideo,
} from "@fortawesome/free-solid-svg-icons";
import { useToast } from "../lib/toast";
import { getServerStatus, type ServerStatus } from "../lib/llm";
import { clearHistory } from "../lib/history";
import { useI18n } from "../lib/i18n";
import { useVideoAiLinks } from "../lib/useVideoAiLinks";
import type { VideoAiLink } from "../lib/types";
const DRAFT_KEY = "melodymuse-draft";
@@ -41,6 +44,15 @@ export function SettingsPage() {
hasDraft: false,
});
const { links, loading: linksLoading, saveLinks, clearLocalLinks, hasLocal } = useVideoAiLinks();
const [editingLinks, setEditingLinks] = useState<VideoAiLink[] | null>(null);
useEffect(() => {
if (!linksLoading && editingLinks === null) {
setEditingLinks(links);
}
}, [links, linksLoading, editingLinks]);
const check = async (signal?: AbortSignal) => {
setChecking(true);
setStatusError(null);
@@ -76,6 +88,40 @@ export function SettingsPage() {
toast.info(t.clearDraft);
};
const handleLinkChange = (index: number, field: keyof VideoAiLink, value: string) => {
if (!editingLinks) return;
const next = [...editingLinks];
next[index] = { ...next[index], [field]: value };
setEditingLinks(next);
};
const handleAddLink = () => {
if (!editingLinks) return;
setEditingLinks([...editingLinks, { name: "", url: "" }]);
};
const handleRemoveLink = (index: number) => {
if (!editingLinks) return;
const next = [...editingLinks];
next.splice(index, 1);
setEditingLinks(next);
};
const handleSaveLinks = () => {
if (!editingLinks) return;
const validLinks = editingLinks.filter(l => l.name.trim() && l.url.trim());
saveLinks(validLinks);
setEditingLinks(validLinks);
toast.success("Video AI links saved to browser storage");
};
const handleResetLinks = () => {
if (!window.confirm("Restore default Video AI links from the server?")) return;
clearLocalLinks();
setEditingLinks(null); // will re-sync with hook
toast.info("Restored default links");
};
return (
<div className="min-h-screen">
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
@@ -177,6 +223,82 @@ export function SettingsPage() {
</div>
</section>
<hr className="border-border" />
<section>
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center justify-between">
<span className="flex items-center gap-2">
<FontAwesomeIcon icon={faVideo} className="w-4 h-4 text-accent-secondary" />
Video AI Links
</span>
{hasLocal && (
<button
type="button"
onClick={handleResetLinks}
className="text-[10px] uppercase font-bold text-fg-muted hover:text-fg transition-colors"
>
Reset to Server Defaults
</button>
)}
</h2>
<p className="text-xs text-fg-muted mb-4">
Configure the quick links shown at the bottom of the Video Prompts card.
{hasLocal ? " Currently using your local browser overrides." : " Currently using server defaults."}
</p>
{linksLoading || editingLinks === null ? (
<div className="flex items-center justify-center py-4">
<FontAwesomeIcon icon={faCircleNotch} spin className="text-fg-muted w-5 h-5" />
</div>
) : (
<div className="space-y-3">
{editingLinks.map((link, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
className="input py-1.5 text-xs flex-1"
placeholder="Name (e.g. Kling AI)"
value={link.name}
onChange={e => handleLinkChange(i, "name", e.target.value)}
/>
<input
type="url"
className="input py-1.5 text-xs flex-[2]"
placeholder="https://..."
value={link.url}
onChange={e => handleLinkChange(i, "url", e.target.value)}
/>
<button
type="button"
onClick={() => handleRemoveLink(i)}
className="p-1.5 rounded-lg text-rose-400 hover:bg-rose-400/10 transition-colors"
title="Remove"
>
<FontAwesomeIcon icon={faTrashAlt} className="w-3.5 h-3.5" />
</button>
</div>
))}
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={handleAddLink}
className="btn-secondary flex-1"
>
+ Add Link
</button>
<button
type="button"
onClick={handleSaveLinks}
className="btn-primary flex-1 py-1 text-sm font-semibold"
>
Save Links
</button>
</div>
</div>
)}
</section>
<div className="text-center pt-2">
<Link
to="/"