Fixes to Add Melody Page, minor UI Tweaks
This commit is contained in:
@@ -26,7 +26,6 @@ const defaultInfo = {
|
||||
color: "",
|
||||
isTrueRing: false,
|
||||
previewURL: "",
|
||||
notes: [],
|
||||
};
|
||||
|
||||
const defaultSettings = {
|
||||
@@ -39,6 +38,15 @@ const defaultSettings = {
|
||||
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);
|
||||
@@ -60,14 +68,10 @@ export default function MelodyForm() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Tag input state
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
|
||||
// Melody settings (languages, colors, durations from backend)
|
||||
const [melodySettings, setMelodySettings] = useState(null);
|
||||
const [editLang, setEditLang] = useState("en");
|
||||
|
||||
// Translation modal state
|
||||
const [translationModal, setTranslationModal] = useState({
|
||||
open: false,
|
||||
field: "",
|
||||
@@ -83,12 +87,9 @@ export default function MelodyForm() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
loadMelody();
|
||||
}
|
||||
if (isEdit) loadMelody();
|
||||
}, [id]);
|
||||
|
||||
// Sync noteAssignments length when totalNotes changes
|
||||
useEffect(() => {
|
||||
const count = information.totalNotes || 1;
|
||||
setSettings((prev) => {
|
||||
@@ -123,39 +124,26 @@ export default function MelodyForm() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateInfo = (field, value) => {
|
||||
const updateInfo = (field, value) =>
|
||||
setInformation((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const updateSettings = (field, value) => {
|
||||
const updateSettings = (field, value) =>
|
||||
setSettings((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
const tag = tagInput.trim();
|
||||
if (tag && !information.customTags.includes(tag)) {
|
||||
if (tag && !information.customTags.includes(tag))
|
||||
updateInfo("customTags", [...information.customTags, tag]);
|
||||
}
|
||||
setTagInput("");
|
||||
};
|
||||
|
||||
const removeTag = (tag) => {
|
||||
updateInfo(
|
||||
"customTags",
|
||||
information.customTags.filter((t) => t !== tag)
|
||||
);
|
||||
};
|
||||
const removeTag = (tag) =>
|
||||
updateInfo("customTags", information.customTags.filter((t) => t !== tag));
|
||||
|
||||
// Parse comma-separated integers for list fields
|
||||
const parseIntList = (str) => {
|
||||
if (!str.trim()) return [];
|
||||
return str
|
||||
.split(",")
|
||||
.map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => !isNaN(n));
|
||||
return str.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
|
||||
};
|
||||
|
||||
// Update a localized field for the current edit language
|
||||
const updateLocalizedField = (fieldKey, text) => {
|
||||
const dict = parseLocalizedString(information[fieldKey]);
|
||||
dict[editLang] = text;
|
||||
@@ -166,19 +154,15 @@ export default function MelodyForm() {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const { notes, ...infoWithoutNotes } = information;
|
||||
const body = {
|
||||
information,
|
||||
information: infoWithoutNotes,
|
||||
default_settings: settings,
|
||||
type,
|
||||
url,
|
||||
uid,
|
||||
pid,
|
||||
type, url, uid, pid,
|
||||
};
|
||||
|
||||
let melodyId = id;
|
||||
|
||||
if (isEdit) {
|
||||
await api.put(`/melodies/${id}`, body);
|
||||
} else {
|
||||
@@ -186,15 +170,10 @@ export default function MelodyForm() {
|
||||
melodyId = created.id;
|
||||
}
|
||||
|
||||
// Upload files if selected
|
||||
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);
|
||||
}
|
||||
if (binaryFile) await api.upload(`/melodies/${melodyId}/upload/binary`, binaryFile);
|
||||
if (previewFile) await api.upload(`/melodies/${melodyId}/upload/preview`, previewFile);
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
@@ -208,328 +187,174 @@ export default function MelodyForm() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-8 text-gray-500">Loading...</div>;
|
||||
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 || [];
|
||||
|
||||
// Duration slider helpers
|
||||
const durationIndex = durationValues.indexOf(settings.duration);
|
||||
const currentDurationIdx = durationIndex >= 0 ? durationIndex : 0;
|
||||
|
||||
const inputClass =
|
||||
"w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm";
|
||||
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
{isEdit ? "Edit Melody" : "Add Melody"}
|
||||
</h1>
|
||||
<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">
|
||||
<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>
|
||||
<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-heading)" }}
|
||||
>
|
||||
{uploading ? "Uploading files..." : saving ? "Saving..." : isEdit ? "Update Melody" : "Create Melody"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-md p-3 mb-4">
|
||||
<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 onSubmit={handleSubmit}>
|
||||
<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="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Melody Information
|
||||
</h2>
|
||||
<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 text-gray-700">
|
||||
Name *
|
||||
</label>
|
||||
<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="text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="Edit translations"
|
||||
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 border border-gray-200 rounded text-gray-500"
|
||||
>
|
||||
{languages.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{getLanguageName(l)}
|
||||
</option>
|
||||
))}
|
||||
<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}
|
||||
/>
|
||||
<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 text-gray-700">
|
||||
Description
|
||||
</label>
|
||||
<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="text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="Edit translations"
|
||||
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}
|
||||
/>
|
||||
<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 text-gray-700 mb-1">
|
||||
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>
|
||||
))}
|
||||
<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 text-gray-700 mb-1">
|
||||
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>
|
||||
))}
|
||||
<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 text-gray-700 mb-1">
|
||||
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}
|
||||
/>
|
||||
<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 text-gray-700 mb-1">
|
||||
Steps
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={information.steps}
|
||||
onChange={(e) =>
|
||||
updateInfo("steps", parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
<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 text-gray-700 mb-1">
|
||||
Min Speed
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={information.minSpeed}
|
||||
onChange={(e) =>
|
||||
updateInfo("minSpeed", parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
<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 text-gray-700 mb-1">
|
||||
Max Speed
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={information.maxSpeed}
|
||||
onChange={(e) =>
|
||||
updateInfo("maxSpeed", parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
<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 with picker, preview, and quick colors */}
|
||||
{/* Color */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Color
|
||||
</label>
|
||||
<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 border border-gray-300 flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: information.color
|
||||
? normalizeColor(information.color)
|
||||
: "transparent",
|
||||
}}
|
||||
/>
|
||||
<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 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
/>
|
||||
<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 ${
|
||||
information.color === color
|
||||
? "border-blue-500 ring-2 ring-blue-200"
|
||||
: "border-gray-200 hover:border-gray-400"
|
||||
}`}
|
||||
style={{ backgroundColor: normalizeColor(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 border-gray-300 rounded-md text-xs text-gray-500 hover:border-gray-400 hover:text-gray-700 transition-colors cursor-pointer"
|
||||
title="Pick a custom 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={information.color ? normalizeColor(information.color) : "#000000"}
|
||||
onChange={(e) => updateInfo("color", e.target.value)}
|
||||
className="absolute inset-0 opacity-0 w-full h-full cursor-pointer"
|
||||
/>
|
||||
<input type="color" value={information.color ? normalizeColor(information.color) : "#000000"} 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 text-blue-600 rounded border-gray-300"
|
||||
/>
|
||||
<label
|
||||
htmlFor="isTrueRing"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
True Ring
|
||||
</label>
|
||||
<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 text-gray-700 mb-1">
|
||||
Custom Tags
|
||||
</label>
|
||||
<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 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTag}
|
||||
className="px-3 py-2 bg-gray-100 text-gray-700 text-sm rounded-md hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<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 bg-blue-50 text-blue-700 text-xs rounded-full"
|
||||
>
|
||||
<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)}
|
||||
className="text-blue-400 hover:text-blue-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<button type="button" onClick={() => removeTag(tag)} style={{ color: "var(--badge-blue-text)" }}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -539,45 +364,23 @@ export default function MelodyForm() {
|
||||
</section>
|
||||
|
||||
{/* --- Identifiers Section --- */}
|
||||
<section className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Identifiers
|
||||
</h2>
|
||||
<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 text-gray-700 mb-1">
|
||||
UID (leave empty for now)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={uid}
|
||||
onChange={(e) => setUid(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
<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>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
PID (Playback ID)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pid}
|
||||
onChange={(e) => setPid(e.target.value)}
|
||||
placeholder="eg. builtin_festive_vesper"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>UID (leave empty for now)</label>
|
||||
<input type="text" value={uid} onChange={(e) => setUid(e.target.value)} 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>
|
||||
@@ -585,254 +388,92 @@ export default function MelodyForm() {
|
||||
{/* ===== Right Column ===== */}
|
||||
<div className="space-y-6">
|
||||
{/* --- Default Settings Section --- */}
|
||||
<section className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Default Settings
|
||||
</h2>
|
||||
<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">
|
||||
{/* Speed slider */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Speed
|
||||
</label>
|
||||
<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 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 w-12 text-right">
|
||||
{settings.speed}%
|
||||
</span>
|
||||
<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>
|
||||
|
||||
{/* Duration slider */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Duration
|
||||
</label>
|
||||
<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 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 w-24 text-right">
|
||||
{formatDuration(settings.duration)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{settings.duration}s
|
||||
<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 text-gray-700 mb-1">
|
||||
Total Run Duration
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={settings.totalRunDuration}
|
||||
onChange={(e) =>
|
||||
updateSettings(
|
||||
"totalRunDuration",
|
||||
parseInt(e.target.value, 10) || 0
|
||||
)
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
<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 text-gray-700 mb-1">
|
||||
Pause Duration
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={settings.pauseDuration}
|
||||
onChange={(e) =>
|
||||
updateSettings(
|
||||
"pauseDuration",
|
||||
parseInt(e.target.value, 10) || 0
|
||||
)
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
<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 text-blue-600 rounded border-gray-300"
|
||||
/>
|
||||
<label
|
||||
htmlFor="infiniteLoop"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
Infinite Loop
|
||||
</label>
|
||||
<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 text-gray-700 mb-1">
|
||||
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}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* Note Assignments - dynamic fields */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Note Assignments
|
||||
</label>
|
||||
<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 text-gray-400 mb-0.5 text-left">
|
||||
Note #{i + 1}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
required
|
||||
value={settings.noteAssignments[i] ?? 0}
|
||||
onChange={(e) => {
|
||||
const newAssignments = [
|
||||
...settings.noteAssignments,
|
||||
];
|
||||
while (newAssignments.length <= i)
|
||||
newAssignments.push(0);
|
||||
newAssignments[i] =
|
||||
parseInt(e.target.value, 10) || 0;
|
||||
updateSettings("noteAssignments", newAssignments);
|
||||
}}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm text-center focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{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 text-gray-400 mt-1">
|
||||
Assign which bell rings for each note (0 = none)
|
||||
</p>
|
||||
<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="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Files
|
||||
</h2>
|
||||
<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 text-gray-700 mb-1">
|
||||
Binary File (.bin)
|
||||
</label>
|
||||
{existingFiles.binary_url && (
|
||||
<p className="text-xs text-green-600 mb-1">
|
||||
Current file uploaded. Selecting a new file will replace
|
||||
it.
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept=".bin"
|
||||
onChange={(e) => setBinaryFile(e.target.files[0] || null)}
|
||||
className="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
||||
/>
|
||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bin)</label>
|
||||
{existingFiles.binary_url && (<p className="text-xs mb-1" style={{ color: "var(--success)" }}>Current file uploaded. Selecting a new file will replace it.</p>)}
|
||||
<input type="file" accept=".bin" onChange={(e) => setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Audio Preview (.mp3)
|
||||
</label>
|
||||
<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 text-green-600 mb-1">
|
||||
Current preview uploaded. Selecting a new file will
|
||||
replace it.
|
||||
</p>
|
||||
<audio
|
||||
controls
|
||||
src={existingFiles.preview_url}
|
||||
className="h-8"
|
||||
/>
|
||||
<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 text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* --- Actions --- */}
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || uploading}
|
||||
className="px-6 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{uploading
|
||||
? "Uploading files..."
|
||||
: saving
|
||||
? "Saving..."
|
||||
: isEdit
|
||||
? "Update Melody"
|
||||
: "Create Melody"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(isEdit ? `/melodies/${id}` : "/melodies")}
|
||||
className="px-6 py-2 bg-gray-100 text-gray-700 text-sm rounded-md hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Translation Modal */}
|
||||
<TranslationModal
|
||||
open={translationModal.open}
|
||||
onClose={() =>
|
||||
setTranslationModal((prev) => ({ ...prev, open: false }))
|
||||
}
|
||||
onClose={() => setTranslationModal((prev) => ({ ...prev, open: false }))}
|
||||
field={translationModal.field}
|
||||
value={parseLocalizedString(information[translationModal.fieldKey])}
|
||||
onChange={(updated) => updateInfo(translationModal.fieldKey, serializeLocalizedString(updated))}
|
||||
|
||||
Reference in New Issue
Block a user