feat: add configurable video AI links
This commit is contained in:
@@ -108,3 +108,6 @@ CORS_ORIGIN=*
|
|||||||
# Leave empty to use the dev proxy (see vite.config.ts). Set this if you want
|
# 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.
|
# the browser to talk to a server on a different origin.
|
||||||
# VITE_API_BASE_URL=http://localhost:3000
|
# 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
@@ -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) {
|
async function handleStyleRandom(req, res) {
|
||||||
let body;
|
let body;
|
||||||
try { body = await readJsonBody(req); }
|
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') {
|
if (req.method === 'GET' && req.url.split('?')[0] === '/api/health') {
|
||||||
return handleHealth(req, res);
|
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') {
|
if (req.method === 'POST' && req.url.split('?')[0] === '/api/generate') {
|
||||||
return handleGenerate(req, res);
|
return handleGenerate(req, res);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
type VideoPrompt,
|
type VideoPrompt,
|
||||||
type VideoPromptType,
|
type VideoPromptType,
|
||||||
} from "../../lib/types";
|
} from "../../lib/types";
|
||||||
|
import { useVideoAiLinks } from "../../lib/useVideoAiLinks";
|
||||||
|
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
interface VideoPromptsCardProps {
|
interface VideoPromptsCardProps {
|
||||||
prompts: VideoPrompt[];
|
prompts: VideoPrompt[];
|
||||||
@@ -34,6 +36,7 @@ export function VideoPromptsCard({
|
|||||||
const dirty = current?.prompt !== originalCurrent?.prompt;
|
const dirty = current?.prompt !== originalCurrent?.prompt;
|
||||||
|
|
||||||
const ref = useAutoHeight(current?.prompt ?? "", 120);
|
const ref = useAutoHeight(current?.prompt ?? "", 120);
|
||||||
|
const { links } = useVideoAiLinks();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResultCard
|
<ResultCard
|
||||||
@@ -115,6 +118,23 @@ export function VideoPromptsCard({
|
|||||||
{NEGATIVE_VIDEO_PROMPT}
|
{NEGATIVE_VIDEO_PROMPT}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-fg-muted">No prompt for this tab yet.</p>
|
<p className="text-sm text-fg-muted">No prompt for this tab yet.</p>
|
||||||
|
|||||||
@@ -71,6 +71,18 @@ export async function getServerStatus(
|
|||||||
return (await resp.json()) as ServerStatus;
|
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 {
|
export interface GenerateOptions {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,3 +71,9 @@ export const SECTION_LABELS: Record<GenerationSection, string> = {
|
|||||||
video_prompts: "video prompts",
|
video_prompts: "video prompts",
|
||||||
youtube_description: "description",
|
youtube_description: "description",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface VideoAiLink {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ import {
|
|||||||
faPlug,
|
faPlug,
|
||||||
faTrashAlt,
|
faTrashAlt,
|
||||||
faServer,
|
faServer,
|
||||||
|
faVideo,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { useToast } from "../lib/toast";
|
import { useToast } from "../lib/toast";
|
||||||
import { getServerStatus, type ServerStatus } from "../lib/llm";
|
import { getServerStatus, type ServerStatus } from "../lib/llm";
|
||||||
import { clearHistory } from "../lib/history";
|
import { clearHistory } from "../lib/history";
|
||||||
import { useI18n } from "../lib/i18n";
|
import { useI18n } from "../lib/i18n";
|
||||||
|
import { useVideoAiLinks } from "../lib/useVideoAiLinks";
|
||||||
|
import type { VideoAiLink } from "../lib/types";
|
||||||
|
|
||||||
const DRAFT_KEY = "melodymuse-draft";
|
const DRAFT_KEY = "melodymuse-draft";
|
||||||
|
|
||||||
@@ -41,6 +44,15 @@ export function SettingsPage() {
|
|||||||
hasDraft: false,
|
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) => {
|
const check = async (signal?: AbortSignal) => {
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
setStatusError(null);
|
setStatusError(null);
|
||||||
@@ -76,6 +88,40 @@ export function SettingsPage() {
|
|||||||
toast.info(t.clearDraft);
|
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 (
|
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">
|
||||||
@@ -177,6 +223,82 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<div className="text-center pt-2">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
|
|||||||
Reference in New Issue
Block a user