Files
bellsystems-cp/frontend/src/melodies/MelodyForm.jsx

673 lines
31 KiB
JavaScript

import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import api from "../api/client";
import TranslationModal from "./TranslationModal";
import SelectBuiltMelodyModal from "./builder/SelectBuiltMelodyModal";
import BuildOnTheFlyModal from "./builder/BuildOnTheFlyModal";
import SpeedCalculatorModal from "./SpeedCalculatorModal";
import {
getLocalizedValue,
getLanguageName,
normalizeColor,
formatDuration,
parseLocalizedString,
serializeLocalizedString,
} from "./melodyUtils";
const MELODY_TYPES = ["orthodox", "catholic", "all"];
const MELODY_TONES = ["normal", "festive", "cheerful", "lamentation"];
const defaultInfo = {
name: "",
description: "",
melodyTone: "normal",
customTags: [],
minSpeed: 0,
maxSpeed: 0,
totalNotes: 1,
steps: 0,
color: "",
isTrueRing: false,
previewURL: "",
};
const defaultSettings = {
speed: 50,
duration: 0,
totalRunDuration: 0,
pauseDuration: 0,
infiniteLoop: false,
echoRing: [],
noteAssignments: [],
};
// Dark-themed styles
const sectionStyle = {
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
};
const headingStyle = { color: "var(--text-heading)" };
const labelStyle = { color: "var(--text-secondary)" };
const mutedStyle = { color: "var(--text-muted)" };
export default function MelodyForm() {
const { id } = useParams();
const isEdit = Boolean(id);
const navigate = useNavigate();
const [information, setInformation] = useState({ ...defaultInfo });
const [settings, setSettings] = useState({ ...defaultSettings });
const [type, setType] = useState("all");
const [url, setUrl] = useState("");
const [uid, setUid] = useState("");
const [pid, setPid] = useState("");
const [melodyStatus, setMelodyStatus] = useState("draft");
const [binaryFile, setBinaryFile] = useState(null);
const [previewFile, setPreviewFile] = useState(null);
const [existingFiles, setExistingFiles] = useState({});
const [uploading, setUploading] = useState(false);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [tagInput, setTagInput] = useState("");
const [melodySettings, setMelodySettings] = useState(null);
const [editLang, setEditLang] = useState("en");
const [translationModal, setTranslationModal] = useState({
open: false,
field: "",
fieldKey: "",
multiline: false,
});
const [showSelectBuilt, setShowSelectBuilt] = useState(false);
const [showBuildOnTheFly, setShowBuildOnTheFly] = useState(false);
const [showSpeedCalc, setShowSpeedCalc] = useState(false);
const [builtMelody, setBuiltMelody] = useState(null);
const [assignedBinaryName, setAssignedBinaryName] = useState(null);
useEffect(() => {
api.get("/settings/melody").then((ms) => {
setMelodySettings(ms);
setEditLang(ms.primary_language || "en");
});
}, []);
useEffect(() => {
if (isEdit) loadMelody();
}, [id]);
useEffect(() => {
const count = information.totalNotes || 1;
setSettings((prev) => {
const current = [...(prev.noteAssignments || [])];
if (current.length < count) {
while (current.length < count) current.push(0);
} else if (current.length > count) {
current.length = count;
}
return { ...prev, noteAssignments: current };
});
}, [information.totalNotes]);
const loadMelody = async () => {
setLoading(true);
try {
const [melody, files] = await Promise.all([
api.get(`/melodies/${id}`),
api.get(`/melodies/${id}/files`),
]);
setInformation({ ...defaultInfo, ...melody.information });
setSettings({ ...defaultSettings, ...melody.default_settings });
setType(melody.type || "all");
setUrl(melody.url || "");
setUid(melody.uid || "");
setPid(melody.pid || "");
setMelodyStatus(melody.status || "published");
setExistingFiles(files);
// Load built melody assignment (non-fatal)
try {
const bm = await api.get(`/builder/melodies/for-melody/${id}`);
setBuiltMelody(bm || null);
setAssignedBinaryName(bm?.name || null);
} catch {
setBuiltMelody(null);
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const updateInfo = (field, value) =>
setInformation((prev) => ({ ...prev, [field]: value }));
const updateSettings = (field, value) =>
setSettings((prev) => ({ ...prev, [field]: value }));
const addTag = () => {
const tag = tagInput.trim();
if (tag && !information.customTags.includes(tag))
updateInfo("customTags", [...information.customTags, tag]);
setTagInput("");
};
const removeTag = (tag) =>
updateInfo("customTags", information.customTags.filter((t) => t !== tag));
const parseIntList = (str) => {
if (!str.trim()) return [];
return str.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
};
const updateLocalizedField = (fieldKey, text) => {
const dict = parseLocalizedString(information[fieldKey]);
dict[editLang] = text;
updateInfo(fieldKey, serializeLocalizedString(dict));
};
const buildBody = () => {
const { notes, ...infoWithoutNotes } = information;
return {
information: infoWithoutNotes,
default_settings: settings,
type, url, uid, pid,
};
};
const uploadFiles = async (melodyId) => {
if (binaryFile || previewFile) {
setUploading(true);
if (binaryFile) await api.upload(`/melodies/${melodyId}/upload/binary`, binaryFile);
if (previewFile) await api.upload(`/melodies/${melodyId}/upload/preview`, previewFile);
setUploading(false);
}
};
const handleSave = async (publish) => {
setSaving(true);
setError("");
try {
const body = buildBody();
let melodyId = id;
if (isEdit) {
await api.put(`/melodies/${id}`, body);
} else {
const created = await api.post(`/melodies?publish=${publish}`, body);
melodyId = created.id;
}
await uploadFiles(melodyId);
navigate(`/melodies/${melodyId}`);
} catch (err) {
setError(err.message);
setUploading(false);
} finally {
setSaving(false);
}
};
const handlePublishAction = async () => {
setSaving(true);
setError("");
try {
const body = buildBody();
await api.put(`/melodies/${id}`, body);
await uploadFiles(id);
await api.post(`/melodies/${id}/publish`);
navigate(`/melodies/${id}`);
} catch (err) {
setError(err.message);
setUploading(false);
} finally {
setSaving(false);
}
};
const handleUnpublishAction = async () => {
setSaving(true);
setError("");
try {
await api.post(`/melodies/${id}/unpublish`);
navigate(`/melodies/${id}`);
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
// Default form submit: save as draft for new, update for existing
if (isEdit) {
await handleSave(false);
} else {
await handleSave(false);
}
};
if (loading) {
return <div className="text-center py-8" style={mutedStyle}>Loading...</div>;
}
const languages = melodySettings?.available_languages || ["en"];
const durationValues = melodySettings?.duration_values || [0];
const quickColors = melodySettings?.quick_colors || [];
const durationIndex = durationValues.indexOf(settings.duration);
const currentDurationIdx = durationIndex >= 0 ? durationIndex : 0;
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={headingStyle}>
{isEdit ? "Edit Melody" : "Add Melody"}
</h1>
<div className="flex gap-3">
{isEdit && (
<button
type="button"
onClick={() => setShowSpeedCalc(true)}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
>
Speed Calculator
</button>
)}
<button
type="button"
onClick={() => navigate(isEdit ? `/melodies/${id}` : "/melodies")}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Cancel
</button>
{!isEdit ? (
<>
<button
type="submit"
form="melody-form"
disabled={saving || uploading}
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
>
{saving ? "Saving..." : "Save as Draft"}
</button>
<button
type="button"
disabled={saving || uploading}
onClick={() => handleSave(true)}
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{uploading ? "Uploading files..." : "Publish Melody"}
</button>
</>
) : melodyStatus === "draft" ? (
<>
<button
type="submit"
form="melody-form"
disabled={saving || uploading}
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
>
{saving ? "Saving..." : "Update Draft"}
</button>
<button
type="button"
disabled={saving || uploading}
onClick={handlePublishAction}
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ backgroundColor: "#16a34a", color: "#fff" }}
>
{uploading ? "Uploading files..." : "Publish"}
</button>
</>
) : (
<>
<button
type="submit"
form="melody-form"
disabled={saving || uploading}
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{uploading ? "Uploading files..." : saving ? "Saving..." : "Update Melody"}
</button>
<button
type="button"
disabled={saving || uploading}
onClick={handleUnpublishAction}
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ backgroundColor: "#ea580c", color: "#fff" }}
>
Unpublish
</button>
</>
)}
</div>
</div>
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
<form id="melody-form" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* ===== Left Column ===== */}
<div className="space-y-6">
{/* --- Melody Info Section --- */}
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Melody Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Name (localized) */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium" style={labelStyle}>Name *</label>
<button
type="button"
onClick={() => setTranslationModal({ open: true, field: "Name", fieldKey: "name", multiline: false })}
className="transition-colors" style={mutedStyle} title="Edit translations"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</button>
{languages.length > 1 && (
<select value={editLang} onChange={(e) => setEditLang(e.target.value)} className="ml-auto text-xs px-2 py-1 rounded border">
{languages.map((l) => (<option key={l} value={l}>{getLanguageName(l)}</option>))}
</select>
)}
</div>
<input type="text" required value={getLocalizedValue(information.name, editLang, "")} onChange={(e) => updateLocalizedField("name", e.target.value)} className={inputClass} />
</div>
{/* Description (localized) */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium" style={labelStyle}>Description</label>
<button
type="button"
onClick={() => setTranslationModal({ open: true, field: "Description", fieldKey: "description", multiline: true })}
className="transition-colors" style={mutedStyle} title="Edit translations"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</button>
</div>
<textarea value={getLocalizedValue(information.description, editLang, "")} onChange={(e) => updateLocalizedField("description", e.target.value)} rows={3} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Melody Tone</label>
<select value={information.melodyTone} onChange={(e) => updateInfo("melodyTone", e.target.value)} className={inputClass}>
{MELODY_TONES.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Type</label>
<select value={type} onChange={(e) => setType(e.target.value)} className={inputClass}>
{MELODY_TYPES.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Total Active Notes (bells)</label>
<input type="number" min="1" max="16" value={information.totalNotes} onChange={(e) => { const val = parseInt(e.target.value, 10); updateInfo("totalNotes", Math.max(1, Math.min(16, val || 1))); }} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Steps</label>
<input type="number" min="0" value={information.steps} onChange={(e) => updateInfo("steps", parseInt(e.target.value, 10) || 0)} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Min Speed</label>
<input type="number" min="0" value={information.minSpeed} onChange={(e) => updateInfo("minSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Max Speed</label>
<input type="number" min="0" value={information.maxSpeed} onChange={(e) => updateInfo("maxSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
</div>
{/* Color */}
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>Color</label>
<div className="flex items-center gap-2 mb-2">
<span className="w-8 h-8 rounded flex-shrink-0 border" style={{ backgroundColor: information.color ? normalizeColor(information.color) : "transparent", borderColor: "var(--border-primary)" }} />
<input type="text" value={information.color} onChange={(e) => updateInfo("color", e.target.value)} placeholder="e.g. #FF5733 or 0xFF5733" className="flex-1 px-3 py-2 rounded-md text-sm border" />
</div>
<div className="flex flex-wrap gap-2 items-center">
{quickColors.map((color) => (
<button key={color} type="button" onClick={() => updateInfo("color", color)}
className="w-7 h-7 rounded-md border-2 transition-all cursor-pointer"
style={{ backgroundColor: normalizeColor(color), borderColor: information.color === color ? "var(--accent)" : "var(--border-secondary)" }}
title={color}
/>
))}
<label className="relative inline-flex items-center gap-1.5 px-3 py-1.5 border-2 border-dashed rounded-md text-xs transition-colors cursor-pointer" style={{ borderColor: "var(--border-primary)", color: "var(--text-muted)" }} title="Pick a custom color">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
Custom
<input type="color" value={normalizeColor(information.color)} onChange={(e) => updateInfo("color", e.target.value)} className="absolute inset-0 opacity-0 w-full h-full cursor-pointer" />
</label>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<input type="checkbox" id="isTrueRing" checked={information.isTrueRing} onChange={(e) => updateInfo("isTrueRing", e.target.checked)} className="h-4 w-4 rounded" />
<label htmlFor="isTrueRing" className="text-sm font-medium" style={labelStyle}>True Ring</label>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>Custom Tags</label>
<div className="flex gap-2 mb-2">
<input type="text" value={tagInput} onChange={(e) => setTagInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addTag(); } }} placeholder="Add a tag and press Enter" className="flex-1 px-3 py-2 rounded-md text-sm border" />
<button type="button" onClick={addTag} className="px-3 py-2 text-sm rounded-md transition-colors" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}>Add</button>
</div>
{information.customTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{information.customTags.map((tag) => (
<span key={tag} className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
{tag}
<button type="button" onClick={() => removeTag(tag)} style={{ color: "var(--badge-blue-text)" }}>&times;</button>
</span>
))}
</div>
)}
</div>
</div>
</section>
{/* --- Identifiers Section --- */}
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Identifiers</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID (Playback ID)</label>
<input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="eg. builtin_festive_vesper" className={inputClass} />
</div>
{isEdit && url && (
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>URL (auto-set from binary upload)</label>
<input type="text" value={url} readOnly className={inputClass} style={{ opacity: 0.7 }} />
</div>
)}
</div>
</section>
</div>
{/* ===== Right Column ===== */}
<div className="space-y-6">
{/* --- Default Settings Section --- */}
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Default Settings</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>Speed</label>
<div className="flex items-center gap-3">
<input type="range" min="1" max="100" value={settings.speed} onChange={(e) => updateSettings("speed", parseInt(e.target.value, 10))} className="flex-1 h-2 rounded-lg appearance-none cursor-pointer" />
<span className="text-sm font-medium w-12 text-right" style={labelStyle}>{settings.speed}%</span>
</div>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>Duration</label>
<div className="flex items-center gap-3">
<input type="range" min="0" max={Math.max(0, durationValues.length - 1)} value={currentDurationIdx} onChange={(e) => updateSettings("duration", durationValues[parseInt(e.target.value, 10)] ?? 0)} className="flex-1 h-2 rounded-lg appearance-none cursor-pointer" />
<span className="text-sm font-medium w-24 text-right" style={labelStyle}>{formatDuration(settings.duration)}</span>
</div>
<div className="text-xs mt-1" style={mutedStyle}>{settings.duration}s</div>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Total Run Duration</label>
<input type="number" min="0" value={settings.totalRunDuration} onChange={(e) => updateSettings("totalRunDuration", parseInt(e.target.value, 10) || 0)} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Pause Duration</label>
<input type="number" min="0" value={settings.pauseDuration} onChange={(e) => updateSettings("pauseDuration", parseInt(e.target.value, 10) || 0)} className={inputClass} />
</div>
<div className="flex items-center gap-2 pt-6">
<input type="checkbox" id="infiniteLoop" checked={settings.infiniteLoop} onChange={(e) => updateSettings("infiniteLoop", e.target.checked)} className="h-4 w-4 rounded" />
<label htmlFor="infiniteLoop" className="text-sm font-medium" style={labelStyle}>Infinite Loop</label>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>Echo Ring (comma-separated integers)</label>
<input type="text" value={settings.echoRing.join(", ")} onChange={(e) => updateSettings("echoRing", parseIntList(e.target.value))} placeholder="e.g. 0, 1, 0, 1" className={inputClass} />
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-2" style={labelStyle}>Note Assignments</label>
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
{Array.from({ length: information.totalNotes }, (_, i) => (
<div key={i}>
<label className="block text-xs mb-0.5 text-left" style={mutedStyle}>Note #{i + 1}</label>
<input type="number" min="0" required value={settings.noteAssignments[i] ?? 0}
onChange={(e) => { const na = [...settings.noteAssignments]; while (na.length <= i) na.push(0); na[i] = parseInt(e.target.value, 10) || 0; updateSettings("noteAssignments", na); }}
className="w-full px-2 py-1.5 rounded-md text-sm text-center border"
/>
</div>
))}
</div>
<p className="text-xs mt-1" style={mutedStyle}>Assign which bell rings for each note (0 = none)</p>
</div>
</div>
</section>
{/* --- File Upload Section --- */}
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Files</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bsm / .bin)</label>
{existingFiles.binary_url && (
<p className="text-xs mb-1" style={{ color: "var(--success)" }}>
{assignedBinaryName
? <><strong>{assignedBinaryName}.bsm</strong> is set. Selecting a new file will replace it.</>
: "Current file uploaded. Selecting a new file will replace it."}
</p>
)}
<input type="file" accept=".bin,.bsm" onChange={(e) => setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
{isEdit && (
<div className="flex gap-2 mt-2">
<button
type="button"
onClick={() => setShowSelectBuilt(true)}
className="px-3 py-1.5 text-xs rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Select Built Melody
</button>
<button
type="button"
onClick={() => setShowBuildOnTheFly(true)}
className="px-3 py-1.5 text-xs rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Build on the Fly
</button>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Audio Preview (.mp3)</label>
{existingFiles.preview_url && (
<div className="mb-1">
<p className="text-xs mb-1" style={{ color: "var(--success)" }}>Current preview uploaded. Selecting a new file will replace it.</p>
<audio controls src={existingFiles.preview_url} className="h-8" />
</div>
)}
<input type="file" accept=".mp3,.wav,.ogg" onChange={(e) => setPreviewFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
</div>
</div>
</section>
</div>
</div>
</form>
<TranslationModal
open={translationModal.open}
onClose={() => setTranslationModal((prev) => ({ ...prev, open: false }))}
field={translationModal.field}
value={parseLocalizedString(information[translationModal.fieldKey])}
onChange={(updated) => updateInfo(translationModal.fieldKey, serializeLocalizedString(updated))}
languages={languages}
multiline={translationModal.multiline}
/>
{isEdit && (
<>
<SpeedCalculatorModal
open={showSpeedCalc}
melody={{ id, information, default_settings: settings, type, url, uid, pid }}
builtMelody={builtMelody}
onClose={() => setShowSpeedCalc(false)}
onSaved={() => { setShowSpeedCalc(false); loadMelody(); }}
/>
<SelectBuiltMelodyModal
open={showSelectBuilt}
melodyId={id}
onClose={() => setShowSelectBuilt(false)}
onSuccess={(archetype) => {
setShowSelectBuilt(false);
setAssignedBinaryName(archetype.name);
if (!pid.trim() && archetype.pid) setPid(archetype.pid);
loadMelody();
}}
/>
<BuildOnTheFlyModal
open={showBuildOnTheFly}
melodyId={id}
defaultName={getLocalizedValue(information.name, "en", "") || getLocalizedValue(information.name, editLang, "")}
defaultPid={pid}
onClose={() => setShowBuildOnTheFly(false)}
onSuccess={(archetype) => {
setShowBuildOnTheFly(false);
setAssignedBinaryName(archetype.name);
if (!pid.trim() && archetype.pid) setPid(archetype.pid);
loadMelody();
}}
/>
</>
)}
</div>
);
}