Phase 2 UI Adjustments/Edits by bonamin

This commit is contained in:
2026-02-17 09:56:07 +02:00
parent 2b48426fe5
commit 59c5049305
16 changed files with 1588 additions and 609 deletions

View File

@@ -4,6 +4,7 @@ from config import settings
from shared.firebase import init_firebase, firebase_initialized from shared.firebase import init_firebase, firebase_initialized
from auth.router import router as auth_router from auth.router import router as auth_router
from melodies.router import router as melodies_router from melodies.router import router as melodies_router
from settings.router import router as settings_router
app = FastAPI( app = FastAPI(
title="BellSystems Admin Panel", title="BellSystems Admin Panel",
@@ -22,6 +23,7 @@ app.add_middleware(
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(melodies_router) app.include_router(melodies_router)
app.include_router(settings_router)
@app.on_event("startup") @app.on_event("startup")

View File

@@ -1,5 +1,5 @@
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import List, Optional from typing import Dict, List, Optional
from enum import Enum from enum import Enum
@@ -17,13 +17,13 @@ class MelodyTone(str, Enum):
class MelodyInfo(BaseModel): class MelodyInfo(BaseModel):
name: str name: Dict[str, str] = {}
description: str = "" description: Dict[str, str] = {}
melodyTone: MelodyTone = MelodyTone.normal melodyTone: MelodyTone = MelodyTone.normal
customTags: List[str] = [] customTags: List[str] = []
minSpeed: int = 0 minSpeed: int = 0
maxSpeed: int = 0 maxSpeed: int = 0
totalNotes: int = 1 totalNotes: int = Field(default=1, ge=1, le=16)
steps: int = 0 steps: int = 0
color: str = "" color: str = ""
isTrueRing: bool = False isTrueRing: bool = False
@@ -32,7 +32,7 @@ class MelodyInfo(BaseModel):
class MelodyAttributes(BaseModel): class MelodyAttributes(BaseModel):
speed: int = 0 speed: int = 50
duration: int = 0 duration: int = 0
totalRunDuration: int = 0 totalRunDuration: int = 0
pauseDuration: int = 0 pauseDuration: int = 0

View File

@@ -8,6 +8,13 @@ COLLECTION = "melodies"
def _doc_to_melody(doc) -> MelodyInDB: def _doc_to_melody(doc) -> MelodyInDB:
"""Convert a Firestore document snapshot to a MelodyInDB model.""" """Convert a Firestore document snapshot to a MelodyInDB model."""
data = doc.to_dict() data = doc.to_dict()
# Backward compat: if name/description are plain strings, wrap as dict
info = data.get("information", {})
if isinstance(info.get("name"), str):
info["name"] = {"en": info["name"]} if info["name"] else {}
if isinstance(info.get("description"), str):
info["description"] = {"en": info["description"]} if info["description"] else {}
data["information"] = info
return MelodyInDB(id=doc.id, **data) return MelodyInDB(id=doc.id, **data)
@@ -41,8 +48,16 @@ def list_melodies(
continue continue
if search: if search:
search_lower = search.lower() search_lower = search.lower()
name_match = search_lower in melody.information.name.lower() name_match = any(
desc_match = search_lower in melody.information.description.lower() search_lower in v.lower()
for v in melody.information.name.values()
if isinstance(v, str)
)
desc_match = any(
search_lower in v.lower()
for v in melody.information.description.values()
if isinstance(v, str)
)
tag_match = any(search_lower in t.lower() for t in melody.information.customTags) tag_match = any(search_lower in t.lower() for t in melody.information.customTags)
if not (name_match or desc_match or tag_match): if not (name_match or desc_match or tag_match):
continue continue

View File

@@ -7,3 +7,4 @@ paho-mqtt==2.1.0
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
python-multipart==0.0.20 python-multipart==0.0.20
bcrypt==4.0.1

View File

View File

@@ -0,0 +1,19 @@
from pydantic import BaseModel
from typing import List, Optional
class MelodySettings(BaseModel):
available_languages: List[str] = ["en", "el", "sr"]
primary_language: str = "en"
quick_colors: List[str] = ["#FF5733", "#33FF57", "#3357FF", "#FFD700", "#FF69B4", "#8B4513"]
duration_values: List[int] = [
0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 180,
240, 300, 360, 420, 480, 540, 600, 900,
]
class MelodySettingsUpdate(BaseModel):
available_languages: Optional[List[str]] = None
primary_language: Optional[str] = None
quick_colors: Optional[List[str]] = None
duration_values: Optional[List[int]] = None

View File

@@ -0,0 +1,22 @@
from fastapi import APIRouter, Depends
from auth.models import TokenPayload
from auth.dependencies import require_melody_access, require_viewer
from settings.models import MelodySettings, MelodySettingsUpdate
from settings import service
router = APIRouter(prefix="/api/settings", tags=["settings"])
@router.get("/melody", response_model=MelodySettings)
async def get_melody_settings(
_user: TokenPayload = Depends(require_viewer),
):
return service.get_melody_settings()
@router.put("/melody", response_model=MelodySettings)
async def update_melody_settings(
body: MelodySettingsUpdate,
_user: TokenPayload = Depends(require_melody_access),
):
return service.update_melody_settings(body)

View File

@@ -0,0 +1,39 @@
from shared.firebase import get_db
from settings.models import MelodySettings, MelodySettingsUpdate
COLLECTION = "admin_settings"
DOC_ID = "melody_settings"
def get_melody_settings() -> MelodySettings:
"""Get melody settings from Firestore. Creates defaults if not found."""
db = get_db()
doc = db.collection(COLLECTION).document(DOC_ID).get()
if doc.exists:
return MelodySettings(**doc.to_dict())
# Create with defaults
defaults = MelodySettings()
db.collection(COLLECTION).document(DOC_ID).set(defaults.model_dump())
return defaults
def update_melody_settings(data: MelodySettingsUpdate) -> MelodySettings:
"""Update melody settings. Only provided fields are updated."""
db = get_db()
doc_ref = db.collection(COLLECTION).document(DOC_ID)
doc = doc_ref.get()
if doc.exists:
existing = doc.to_dict()
else:
existing = MelodySettings().model_dump()
update_data = data.model_dump(exclude_none=True)
existing.update(update_data)
# Sort duration values
if "duration_values" in existing:
existing["duration_values"] = sorted(existing["duration_values"])
doc_ref.set(existing)
return MelodySettings(**existing)

View File

@@ -5,6 +5,7 @@ import MainLayout from "./layout/MainLayout";
import MelodyList from "./melodies/MelodyList"; import MelodyList from "./melodies/MelodyList";
import MelodyDetail from "./melodies/MelodyDetail"; import MelodyDetail from "./melodies/MelodyDetail";
import MelodyForm from "./melodies/MelodyForm"; import MelodyForm from "./melodies/MelodyForm";
import MelodySettings from "./melodies/MelodySettings";
function ProtectedRoute({ children }) { function ProtectedRoute({ children }) {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@@ -50,6 +51,7 @@ export default function App() {
> >
<Route index element={<DashboardPage />} /> <Route index element={<DashboardPage />} />
<Route path="melodies" element={<MelodyList />} /> <Route path="melodies" element={<MelodyList />} />
<Route path="melodies/settings" element={<MelodySettings />} />
<Route path="melodies/new" element={<MelodyForm />} /> <Route path="melodies/new" element={<MelodyForm />} />
<Route path="melodies/:id" element={<MelodyDetail />} /> <Route path="melodies/:id" element={<MelodyDetail />} />
<Route path="melodies/:id/edit" element={<MelodyForm />} /> <Route path="melodies/:id/edit" element={<MelodyForm />} />

View File

@@ -1,16 +1,32 @@
import { NavLink } from "react-router-dom"; import { useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
const navItems = [ const navItems = [
{ to: "/", label: "Dashboard", roles: null }, { to: "/", label: "Dashboard", roles: null },
{ to: "/melodies", label: "Melodies", roles: ["superadmin", "melody_editor", "viewer"] }, {
label: "Melodies",
roles: ["superadmin", "melody_editor", "viewer"],
children: [
{ to: "/melodies", label: "Editor" },
{ to: "/melodies/settings", label: "Settings" },
],
},
{ to: "/devices", label: "Devices", roles: ["superadmin", "device_manager", "viewer"] }, { to: "/devices", label: "Devices", roles: ["superadmin", "device_manager", "viewer"] },
{ to: "/users", label: "Users", roles: ["superadmin", "user_manager", "viewer"] }, { to: "/users", label: "Users", roles: ["superadmin", "user_manager", "viewer"] },
{ to: "/mqtt", label: "MQTT", roles: ["superadmin", "device_manager", "viewer"] }, { to: "/mqtt", label: "MQTT", roles: ["superadmin", "device_manager", "viewer"] },
]; ];
const linkClass = (isActive) =>
`block px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? "bg-gray-700 text-white"
: "text-gray-300 hover:bg-gray-800 hover:text-white"
}`;
export default function Sidebar() { export default function Sidebar() {
const { hasRole } = useAuth(); const { hasRole } = useAuth();
const location = useLocation();
const visibleItems = navItems.filter( const visibleItems = navItems.filter(
(item) => item.roles === null || hasRole(...item.roles) (item) => item.roles === null || hasRole(...item.roles)
@@ -20,23 +36,82 @@ export default function Sidebar() {
<aside className="w-56 bg-gray-900 text-white min-h-screen p-4"> <aside className="w-56 bg-gray-900 text-white min-h-screen p-4">
<div className="text-xl font-bold mb-8 px-2">BellSystems</div> <div className="text-xl font-bold mb-8 px-2">BellSystems</div>
<nav className="space-y-1"> <nav className="space-y-1">
{visibleItems.map((item) => ( {visibleItems.map((item) =>
item.children ? (
<CollapsibleGroup
key={item.label}
label={item.label}
children={item.children}
currentPath={location.pathname}
/>
) : (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
end={item.to === "/"} end={item.to === "/"}
className={({ isActive }) => className={({ isActive }) => linkClass(isActive)}
`block px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? "bg-gray-700 text-white"
: "text-gray-300 hover:bg-gray-800 hover:text-white"
}`
}
> >
{item.label} {item.label}
</NavLink> </NavLink>
))} )
)}
</nav> </nav>
</aside> </aside>
); );
} }
function CollapsibleGroup({ label, children, currentPath }) {
const isChildActive = children.some(
(child) =>
currentPath === child.to ||
(child.to !== "/" && currentPath.startsWith(child.to + "/"))
);
const [open, setOpen] = useState(isChildActive);
// Auto-expand when a child becomes active
const shouldBeOpen = open || isChildActive;
return (
<div>
<button
type="button"
onClick={() => setOpen(!shouldBeOpen)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
isChildActive
? "text-white"
: "text-gray-300 hover:bg-gray-800 hover:text-white"
}`}
>
<span>{label}</span>
<svg
className={`w-4 h-4 transition-transform ${shouldBeOpen ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
{shouldBeOpen && (
<div className="ml-3 mt-1 space-y-1">
{children.map((child) => (
<NavLink
key={child.to}
to={child.to}
end
className={({ isActive }) =>
`block pl-4 pr-3 py-1.5 rounded-md text-sm transition-colors ${
isActive
? "bg-gray-700 text-white"
: "text-gray-400 hover:bg-gray-800 hover:text-white"
}`
}
>
{child.label}
</NavLink>
))}
</div>
)}
</div>
);
}

View File

@@ -3,6 +3,12 @@ import { useParams, useNavigate } from "react-router-dom";
import api from "../api/client"; import api from "../api/client";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import ConfirmDialog from "../components/ConfirmDialog"; import ConfirmDialog from "../components/ConfirmDialog";
import {
getLocalizedValue,
getLanguageName,
normalizeColor,
formatDuration,
} from "./melodyUtils";
function Field({ label, children }) { function Field({ label, children }) {
return ( return (
@@ -26,6 +32,15 @@ export default function MelodyDetail() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [showDelete, setShowDelete] = useState(false); const [showDelete, setShowDelete] = useState(false);
const [displayLang, setDisplayLang] = useState("en");
const [melodySettings, setMelodySettings] = useState(null);
useEffect(() => {
api.get("/settings/melody").then((ms) => {
setMelodySettings(ms);
setDisplayLang(ms.primary_language || "en");
});
}, []);
useEffect(() => { useEffect(() => {
loadData(); loadData();
@@ -73,9 +88,11 @@ export default function MelodyDetail() {
const info = melody.information || {}; const info = melody.information || {};
const settings = melody.default_settings || {}; const settings = melody.default_settings || {};
const languages = melodySettings?.available_languages || ["en"];
const displayName = getLocalizedValue(info.name, displayLang, "Untitled Melody");
return ( return (
<div className="max-w-3xl"> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<button <button
@@ -84,9 +101,22 @@ export default function MelodyDetail() {
> >
&larr; Back to Melodies &larr; Back to Melodies
</button> </button>
<h1 className="text-2xl font-bold text-gray-900"> <div className="flex items-center gap-3">
{info.name || "Untitled Melody"} <h1 className="text-2xl font-bold text-gray-900">{displayName}</h1>
</h1> {languages.length > 1 && (
<select
value={displayLang}
onChange={(e) => setDisplayLang(e.target.value)}
className="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>
)}
</div>
</div> </div>
{canEdit && ( {canEdit && (
<div className="flex gap-2"> <div className="flex gap-2">
@@ -106,8 +136,11 @@ export default function MelodyDetail() {
)} )}
</div> </div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Left column */}
<div className="space-y-6">
{/* Melody Information */} {/* Melody Information */}
<section className="bg-white rounded-lg border border-gray-200 p-6 mb-6"> <section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4"> <h2 className="text-lg font-semibold text-gray-800 mb-4">
Melody Information Melody Information
</h2> </h2>
@@ -118,7 +151,7 @@ export default function MelodyDetail() {
<Field label="Tone"> <Field label="Tone">
<span className="capitalize">{info.melodyTone}</span> <span className="capitalize">{info.melodyTone}</span>
</Field> </Field>
<Field label="Total Notes">{info.totalNotes}</Field> <Field label="Total Active Notes (bells)">{info.totalNotes}</Field>
<Field label="Steps">{info.steps}</Field> <Field label="Steps">{info.steps}</Field>
<Field label="Min Speed">{info.minSpeed}</Field> <Field label="Min Speed">{info.minSpeed}</Field>
<Field label="Max Speed">{info.maxSpeed}</Field> <Field label="Max Speed">{info.maxSpeed}</Field>
@@ -127,7 +160,7 @@ export default function MelodyDetail() {
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<span <span
className="w-4 h-4 rounded border border-gray-300 inline-block" className="w-4 h-4 rounded border border-gray-300 inline-block"
style={{ backgroundColor: info.color.startsWith("0x") ? `#${info.color.slice(4)}` : info.color }} style={{ backgroundColor: normalizeColor(info.color) }}
/> />
{info.color} {info.color}
</span> </span>
@@ -147,7 +180,9 @@ export default function MelodyDetail() {
</span> </span>
</Field> </Field>
<div className="col-span-2 md:col-span-3"> <div className="col-span-2 md:col-span-3">
<Field label="Description">{info.description}</Field> <Field label="Description">
{getLocalizedValue(info.description, displayLang)}
</Field>
</div> </div>
<div className="col-span-2 md:col-span-3"> <div className="col-span-2 md:col-span-3">
<Field label="Notes"> <Field label="Notes">
@@ -175,14 +210,32 @@ export default function MelodyDetail() {
</dl> </dl>
</section> </section>
{/* Identifiers */}
<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>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Document ID">{melody.id}</Field>
<Field label="UID">{melody.uid}</Field>
<Field label="PID (Playback ID)">{melody.pid}</Field>
<div className="col-span-2 md:col-span-3">
<Field label="URL">{melody.url}</Field>
</div>
</dl>
</section>
</div>
{/* Right column */}
<div className="space-y-6">
{/* Default Settings */} {/* Default Settings */}
<section className="bg-white rounded-lg border border-gray-200 p-6 mb-6"> <section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4"> <h2 className="text-lg font-semibold text-gray-800 mb-4">
Default Settings Default Settings
</h2> </h2>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4"> <dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Speed">{settings.speed}</Field> <Field label="Speed">{settings.speed}%</Field>
<Field label="Duration">{settings.duration}</Field> <Field label="Duration">{formatDuration(settings.duration)}</Field>
<Field label="Total Run Duration">{settings.totalRunDuration}</Field> <Field label="Total Run Duration">{settings.totalRunDuration}</Field>
<Field label="Pause Duration">{settings.pauseDuration}</Field> <Field label="Pause Duration">{settings.pauseDuration}</Field>
<Field label="Infinite Loop"> <Field label="Infinite Loop">
@@ -213,23 +266,8 @@ export default function MelodyDetail() {
</dl> </dl>
</section> </section>
{/* Identifiers */}
<section className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
Identifiers
</h2>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Document ID">{melody.id}</Field>
<Field label="UID">{melody.uid}</Field>
<Field label="PID">{melody.pid}</Field>
<div className="col-span-2 md:col-span-3">
<Field label="URL">{melody.url}</Field>
</div>
</dl>
</section>
{/* Files */} {/* Files */}
<section className="bg-white rounded-lg border border-gray-200 p-6 mb-6"> <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> <h2 className="text-lg font-semibold text-gray-800 mb-4">Files</h2>
<dl className="space-y-4"> <dl className="space-y-4">
<Field label="Binary File"> <Field label="Binary File">
@@ -265,11 +303,13 @@ export default function MelodyDetail() {
</Field> </Field>
</dl> </dl>
</section> </section>
</div>
</div>
<ConfirmDialog <ConfirmDialog
open={showDelete} open={showDelete}
title="Delete Melody" title="Delete Melody"
message={`Are you sure you want to delete "${info.name}"? This will also delete any uploaded files. This action cannot be undone.`} message={`Are you sure you want to delete "${displayName}"? This will also delete any uploaded files. This action cannot be undone.`}
onConfirm={handleDelete} onConfirm={handleDelete}
onCancel={() => setShowDelete(false)} onCancel={() => setShowDelete(false)}
/> />

View File

@@ -1,13 +1,20 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import api from "../api/client"; import api from "../api/client";
import TranslationModal from "./TranslationModal";
import {
getLocalizedValue,
getLanguageName,
normalizeColor,
formatDuration,
} from "./melodyUtils";
const MELODY_TYPES = ["orthodox", "catholic", "all"]; const MELODY_TYPES = ["orthodox", "catholic", "all"];
const MELODY_TONES = ["normal", "festive", "cheerful", "lamentation"]; const MELODY_TONES = ["normal", "festive", "cheerful", "lamentation"];
const defaultInfo = { const defaultInfo = {
name: "", name: {},
description: "", description: {},
melodyTone: "normal", melodyTone: "normal",
customTags: [], customTags: [],
minSpeed: 0, minSpeed: 0,
@@ -21,7 +28,7 @@ const defaultInfo = {
}; };
const defaultSettings = { const defaultSettings = {
speed: 0, speed: 50,
duration: 0, duration: 0,
totalRunDuration: 0, totalRunDuration: 0,
pauseDuration: 0, pauseDuration: 0,
@@ -54,12 +61,45 @@ export default function MelodyForm() {
// Tag input state // Tag input state
const [tagInput, setTagInput] = useState(""); 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: "",
fieldKey: "",
multiline: false,
});
useEffect(() => {
api.get("/settings/melody").then((ms) => {
setMelodySettings(ms);
setEditLang(ms.primary_language || "en");
});
}, []);
useEffect(() => { useEffect(() => {
if (isEdit) { if (isEdit) {
loadMelody(); loadMelody();
} }
}, [id]); }, [id]);
// Sync noteAssignments length when totalNotes changes
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 () => { const loadMelody = async () => {
setLoading(true); setLoading(true);
try { try {
@@ -113,6 +153,11 @@ export default function MelodyForm() {
.filter((n) => !isNaN(n)); .filter((n) => !isNaN(n));
}; };
// Update a localized field for the current edit language
const updateLocalizedField = (fieldKey, text) => {
updateInfo(fieldKey, { ...information[fieldKey], [editLang]: text });
};
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setSaving(true); setSaving(true);
@@ -162,8 +207,19 @@ export default function MelodyForm() {
return <div className="text-center py-8 text-gray-500">Loading...</div>; return <div className="text-center py-8 text-gray-500">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";
return ( return (
<div className="max-w-3xl"> <div>
<h1 className="text-2xl font-bold text-gray-900 mb-6"> <h1 className="text-2xl font-bold text-gray-900 mb-6">
{isEdit ? "Edit Melody" : "Add Melody"} {isEdit ? "Edit Melody" : "Add Melody"}
</h1> </h1>
@@ -174,35 +230,91 @@ export default function MelodyForm() {
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-8"> <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 --- */} {/* --- Melody Info Section --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6"> <section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4"> <h2 className="text-lg font-semibold text-gray-800 mb-4">
Melody Information Melody Information
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Name (localized) */}
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1"> <div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
Name * Name *
</label> </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"
>
<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>
)}
</div>
<input <input
type="text" type="text"
required required
value={information.name} value={getLocalizedValue(information.name, editLang, "")}
onChange={(e) => updateInfo("name", e.target.value)} onChange={(e) => updateLocalizedField("name", e.target.value)}
className="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" className={inputClass}
/> />
</div> </div>
{/* Description (localized) */}
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1"> <div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
Description Description
</label> </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"
>
<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 <textarea
value={information.description} value={getLocalizedValue(information.description, editLang, "")}
onChange={(e) => updateInfo("description", e.target.value)} onChange={(e) => updateLocalizedField("description", e.target.value)}
rows={3} rows={3}
className="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" className={inputClass}
/> />
</div> </div>
@@ -213,7 +325,7 @@ export default function MelodyForm() {
<select <select
value={information.melodyTone} value={information.melodyTone}
onChange={(e) => updateInfo("melodyTone", e.target.value)} onChange={(e) => updateInfo("melodyTone", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" className={inputClass}
> >
{MELODY_TONES.map((t) => ( {MELODY_TONES.map((t) => (
<option key={t} value={t}> <option key={t} value={t}>
@@ -230,7 +342,7 @@ export default function MelodyForm() {
<select <select
value={type} value={type}
onChange={(e) => setType(e.target.value)} onChange={(e) => setType(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" className={inputClass}
> >
{MELODY_TYPES.map((t) => ( {MELODY_TYPES.map((t) => (
<option key={t} value={t}> <option key={t} value={t}>
@@ -242,16 +354,18 @@ export default function MelodyForm() {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Total Notes Total Active Notes (bells)
</label> </label>
<input <input
type="number" type="number"
min="1" min="1"
max="16"
value={information.totalNotes} value={information.totalNotes}
onChange={(e) => onChange={(e) => {
updateInfo("totalNotes", parseInt(e.target.value, 10) || 1) const val = parseInt(e.target.value, 10);
} updateInfo("totalNotes", Math.max(1, Math.min(16, val || 1)));
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" }}
className={inputClass}
/> />
</div> </div>
@@ -266,7 +380,7 @@ export default function MelodyForm() {
onChange={(e) => onChange={(e) =>
updateInfo("steps", parseInt(e.target.value, 10) || 0) updateInfo("steps", parseInt(e.target.value, 10) || 0)
} }
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" className={inputClass}
/> />
</div> </div>
@@ -281,7 +395,7 @@ export default function MelodyForm() {
onChange={(e) => onChange={(e) =>
updateInfo("minSpeed", parseInt(e.target.value, 10) || 0) updateInfo("minSpeed", parseInt(e.target.value, 10) || 0)
} }
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" className={inputClass}
/> />
</div> </div>
@@ -296,24 +410,59 @@ export default function MelodyForm() {
onChange={(e) => onChange={(e) =>
updateInfo("maxSpeed", parseInt(e.target.value, 10) || 0) updateInfo("maxSpeed", parseInt(e.target.value, 10) || 0)
} }
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" className={inputClass}
/> />
</div> </div>
<div> {/* Color with picker, preview, and quick colors */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Color Color
</label> </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="color"
value={information.color ? normalizeColor(information.color) : "#000000"}
onChange={(e) => updateInfo("color", e.target.value)}
className="w-8 h-8 cursor-pointer border border-gray-300 rounded flex-shrink-0"
/>
<input <input
type="text" type="text"
value={information.color} value={information.color}
onChange={(e) => updateInfo("color", e.target.value)} onChange={(e) => updateInfo("color", e.target.value)}
placeholder="e.g. #FF5733 or 0xFF5733" placeholder="e.g. #FF5733 or 0xFF5733"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" 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"
/> />
</div> </div>
{quickColors.length > 0 && (
<div className="flex flex-wrap gap-2">
{quickColors.map((color) => (
<button
key={color}
type="button"
onClick={() => updateInfo("color", color)}
className={`w-6 h-6 rounded border-2 transition-all ${
information.color === color
? "border-blue-500 ring-2 ring-blue-200"
: "border-gray-200 hover:border-gray-400"
}`}
style={{ backgroundColor: normalizeColor(color) }}
title={color}
/>
))}
</div>
)}
</div>
<div className="flex items-center gap-2 pt-6"> <div className="flex items-center gap-2 pt-2">
<input <input
type="checkbox" type="checkbox"
id="isTrueRing" id="isTrueRing"
@@ -338,7 +487,7 @@ export default function MelodyForm() {
value={information.notes.join(", ")} value={information.notes.join(", ")}
onChange={(e) => updateInfo("notes", parseIntList(e.target.value))} onChange={(e) => updateInfo("notes", parseIntList(e.target.value))}
placeholder="e.g. 1, 2, 3, 4" placeholder="e.g. 1, 2, 3, 4"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" className={inputClass}
/> />
</div> </div>
@@ -391,40 +540,106 @@ export default function MelodyForm() {
</div> </div>
</section> </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>
<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}
/>
</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}
/>
</div>
</div>
</section>
</div>
{/* ===== Right Column ===== */}
<div className="space-y-6">
{/* --- Default Settings Section --- */} {/* --- Default Settings Section --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6"> <section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4"> <h2 className="text-lg font-semibold text-gray-800 mb-4">
Default Settings Default Settings
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> {/* Speed slider */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Speed Speed
</label> </label>
<div className="flex items-center gap-3">
<input <input
type="number" type="range"
min="0" min="1"
max="100"
value={settings.speed} value={settings.speed}
onChange={(e) => onChange={(e) =>
updateSettings("speed", parseInt(e.target.value, 10) || 0) updateSettings("speed", parseInt(e.target.value, 10))
} }
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" 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>
</div>
</div> </div>
<div> {/* Duration slider */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Duration Duration
</label> </label>
<div className="flex items-center gap-3">
<input <input
type="number" type="range"
min="0" min="0"
value={settings.duration} max={Math.max(0, durationValues.length - 1)}
value={currentDurationIdx}
onChange={(e) => onChange={(e) =>
updateSettings("duration", parseInt(e.target.value, 10) || 0) updateSettings(
"duration",
durationValues[parseInt(e.target.value, 10)] ?? 0
)
} }
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" 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
</div>
</div> </div>
<div> <div>
@@ -441,7 +656,7 @@ export default function MelodyForm() {
parseInt(e.target.value, 10) || 0 parseInt(e.target.value, 10) || 0
) )
} }
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" className={inputClass}
/> />
</div> </div>
@@ -459,7 +674,7 @@ export default function MelodyForm() {
parseInt(e.target.value, 10) || 0 parseInt(e.target.value, 10) || 0
) )
} }
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" className={inputClass}
/> />
</div> </div>
@@ -492,68 +707,47 @@ export default function MelodyForm() {
updateSettings("echoRing", parseIntList(e.target.value)) updateSettings("echoRing", parseIntList(e.target.value))
} }
placeholder="e.g. 0, 1, 0, 1" placeholder="e.g. 0, 1, 0, 1"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" className={inputClass}
/> />
</div> </div>
{/* Note Assignments - dynamic fields */}
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-2">
Note Assignments (comma-separated integers) 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-center">
N{i + 1}
</label> </label>
<input <input
type="text" type="number"
value={settings.noteAssignments.join(", ")} min="0"
onChange={(e) => required
updateSettings( value={settings.noteAssignments[i] ?? 0}
"noteAssignments", onChange={(e) => {
parseIntList(e.target.value) 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>
) )
} )}
placeholder="e.g. 1, 2, 3"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
</div> </div>
</div> <p className="text-xs text-gray-400 mt-1">
</section> Assign which bell rings for each note (0 = none)
</p>
{/* --- 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>
<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
</label>
<input
type="text"
value={uid}
onChange={(e) => setUid(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
PID
</label>
<input
type="text"
value={pid}
onChange={(e) => setPid(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
</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="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
</div> </div>
</div> </div>
</section> </section>
@@ -570,7 +764,8 @@ export default function MelodyForm() {
</label> </label>
{existingFiles.binary_url && ( {existingFiles.binary_url && (
<p className="text-xs text-green-600 mb-1"> <p className="text-xs text-green-600 mb-1">
Current file uploaded. Selecting a new file will replace it. Current file uploaded. Selecting a new file will replace
it.
</p> </p>
)} )}
<input <input
@@ -587,9 +782,14 @@ export default function MelodyForm() {
{existingFiles.preview_url && ( {existingFiles.preview_url && (
<div className="mb-1"> <div className="mb-1">
<p className="text-xs text-green-600 mb-1"> <p className="text-xs text-green-600 mb-1">
Current preview uploaded. Selecting a new file will replace it. Current preview uploaded. Selecting a new file will
replace it.
</p> </p>
<audio controls src={existingFiles.preview_url} className="h-8" /> <audio
controls
src={existingFiles.preview_url}
className="h-8"
/>
</div> </div>
)} )}
<input <input
@@ -601,9 +801,11 @@ export default function MelodyForm() {
</div> </div>
</div> </div>
</section> </section>
</div>
</div>
{/* --- Actions --- */} {/* --- Actions --- */}
<div className="flex gap-3"> <div className="flex gap-3 mt-6">
<button <button
type="submit" type="submit"
disabled={saving || uploading} disabled={saving || uploading}
@@ -626,6 +828,19 @@ export default function MelodyForm() {
</button> </button>
</div> </div>
</form> </form>
{/* Translation Modal */}
<TranslationModal
open={translationModal.open}
onClose={() =>
setTranslationModal((prev) => ({ ...prev, open: false }))
}
field={translationModal.field}
value={information[translationModal.fieldKey] || {}}
onChange={(updated) => updateInfo(translationModal.fieldKey, updated)}
languages={languages}
multiline={translationModal.multiline}
/>
</div> </div>
); );
} }

View File

@@ -5,6 +5,7 @@ import { useAuth } from "../auth/AuthContext";
import SearchBar from "../components/SearchBar"; import SearchBar from "../components/SearchBar";
import DataTable from "../components/DataTable"; import DataTable from "../components/DataTable";
import ConfirmDialog from "../components/ConfirmDialog"; import ConfirmDialog from "../components/ConfirmDialog";
import { getLocalizedValue, getLanguageName } from "./melodyUtils";
const MELODY_TYPES = ["", "orthodox", "catholic", "all"]; const MELODY_TYPES = ["", "orthodox", "catholic", "all"];
const MELODY_TONES = ["", "normal", "festive", "cheerful", "lamentation"]; const MELODY_TONES = ["", "normal", "festive", "cheerful", "lamentation"];
@@ -17,11 +18,20 @@ export default function MelodyList() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState(""); const [typeFilter, setTypeFilter] = useState("");
const [toneFilter, setToneFilter] = useState(""); const [toneFilter, setToneFilter] = useState("");
const [displayLang, setDisplayLang] = useState("en");
const [melodySettings, setMelodySettings] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
const { hasRole } = useAuth(); const { hasRole } = useAuth();
const canEdit = hasRole("superadmin", "melody_editor"); const canEdit = hasRole("superadmin", "melody_editor");
useEffect(() => {
api.get("/settings/melody").then((ms) => {
setMelodySettings(ms);
setDisplayLang(ms.primary_language || "en");
});
}, []);
const fetchMelodies = async () => { const fetchMelodies = async () => {
setLoading(true); setLoading(true);
setError(""); setError("");
@@ -57,13 +67,17 @@ export default function MelodyList() {
} }
}; };
const getDisplayName = (nameDict) => {
return getLocalizedValue(nameDict, displayLang, "Untitled");
};
const columns = [ const columns = [
{ {
key: "name", key: "name",
label: "Name", label: "Name",
render: (row) => ( render: (row) => (
<span className="font-medium text-gray-900"> <span className="font-medium text-gray-900">
{row.information?.name || "Untitled"} {getDisplayName(row.information?.name)}
</span> </span>
), ),
}, },
@@ -133,6 +147,8 @@ export default function MelodyList() {
: []), : []),
]; ];
const languages = melodySettings?.available_languages || ["en"];
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
@@ -152,7 +168,7 @@ export default function MelodyList() {
onSearch={setSearch} onSearch={setSearch}
placeholder="Search by name, description, or tags..." placeholder="Search by name, description, or tags..."
/> />
<div className="flex gap-3"> <div className="flex flex-wrap gap-3">
<select <select
value={typeFilter} value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)} onChange={(e) => setTypeFilter(e.target.value)}
@@ -177,6 +193,19 @@ export default function MelodyList() {
</option> </option>
))} ))}
</select> </select>
{languages.length > 1 && (
<select
value={displayLang}
onChange={(e) => setDisplayLang(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{languages.map((l) => (
<option key={l} value={l}>
{getLanguageName(l)}
</option>
))}
</select>
)}
<span className="flex items-center text-sm text-gray-500"> <span className="flex items-center text-sm text-gray-500">
{total} {total === 1 ? "melody" : "melodies"} {total} {total === 1 ? "melody" : "melodies"}
</span> </span>
@@ -203,7 +232,7 @@ export default function MelodyList() {
<ConfirmDialog <ConfirmDialog
open={!!deleteTarget} open={!!deleteTarget}
title="Delete Melody" title="Delete Melody"
message={`Are you sure you want to delete "${deleteTarget?.information?.name}"? This will also delete any uploaded files.`} message={`Are you sure you want to delete "${getDisplayName(deleteTarget?.information?.name)}"? This will also delete any uploaded files.`}
onConfirm={handleDelete} onConfirm={handleDelete}
onCancel={() => setDeleteTarget(null)} onCancel={() => setDeleteTarget(null)}
/> />

View File

@@ -0,0 +1,341 @@
import { useState, useEffect } from "react";
import api from "../api/client";
import {
LANGUAGE_MASTER_LIST,
getLanguageName,
formatDuration,
normalizeColor,
} from "./melodyUtils";
export default function MelodySettings() {
const [settings, setSettings] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
// Add language state
const [langToAdd, setLangToAdd] = useState("");
// Add color state
const [colorToAdd, setColorToAdd] = useState("#FF0000");
const [colorHexInput, setColorHexInput] = useState("#FF0000");
// Add duration state
const [durationToAdd, setDurationToAdd] = useState("");
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
setLoading(true);
try {
const data = await api.get("/settings/melody");
setSettings(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const saveSettings = async (updated) => {
setSaving(true);
setError("");
setSuccess("");
try {
const result = await api.put("/settings/melody", updated);
setSettings(result);
setSuccess("Settings saved.");
setTimeout(() => setSuccess(""), 2000);
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const addLanguage = () => {
if (!langToAdd || settings.available_languages.includes(langToAdd)) return;
const updated = {
...settings,
available_languages: [...settings.available_languages, langToAdd],
};
setLangToAdd("");
saveSettings(updated);
};
const removeLanguage = (code) => {
if (settings.available_languages.length <= 1) return;
const updated = {
...settings,
available_languages: settings.available_languages.filter((c) => c !== code),
primary_language:
settings.primary_language === code
? settings.available_languages.find((c) => c !== code)
: settings.primary_language,
};
saveSettings(updated);
};
const setPrimaryLanguage = (code) => {
saveSettings({ ...settings, primary_language: code });
};
const addColor = () => {
const color = colorHexInput.startsWith("#") ? colorHexInput : `#${colorHexInput}`;
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) return;
if (settings.quick_colors.includes(color)) return;
const updated = {
...settings,
quick_colors: [...settings.quick_colors, color],
};
saveSettings(updated);
};
const removeColor = (color) => {
const updated = {
...settings,
quick_colors: settings.quick_colors.filter((c) => c !== color),
};
saveSettings(updated);
};
const addDuration = () => {
const val = parseInt(durationToAdd, 10);
if (isNaN(val) || val < 0) return;
if (settings.duration_values.includes(val)) return;
const updated = {
...settings,
duration_values: [...settings.duration_values, val].sort((a, b) => a - b),
};
setDurationToAdd("");
saveSettings(updated);
};
const removeDuration = (val) => {
const updated = {
...settings,
duration_values: settings.duration_values.filter((v) => v !== val),
};
saveSettings(updated);
};
if (loading) {
return <div className="text-center py-8 text-gray-500">Loading...</div>;
}
if (!settings) {
return (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-md p-3">
{error || "Failed to load settings."}
</div>
);
}
const availableLangsToAdd = LANGUAGE_MASTER_LIST.filter(
(l) => !settings.available_languages.includes(l.code)
);
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Melody Settings</h1>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-md p-3 mb-4">
{error}
</div>
)}
{success && (
<div className="bg-green-50 border border-green-200 text-green-700 text-sm rounded-md p-3 mb-4">
{success}
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* --- Languages Section --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
Available Languages
</h2>
<div className="space-y-2 mb-4">
{settings.available_languages.map((code) => (
<div
key={code}
className="flex items-center justify-between px-3 py-2 bg-gray-50 rounded-md"
>
<div className="flex items-center gap-3">
<span className="text-sm font-mono text-gray-500 uppercase w-8">
{code}
</span>
<span className="text-sm text-gray-900">
{getLanguageName(code)}
</span>
{settings.primary_language === code && (
<span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded-full">
Primary
</span>
)}
</div>
<div className="flex items-center gap-2">
{settings.primary_language !== code && (
<button
type="button"
onClick={() => setPrimaryLanguage(code)}
className="text-xs text-blue-600 hover:text-blue-800"
disabled={saving}
>
Set Primary
</button>
)}
{settings.available_languages.length > 1 && (
<button
type="button"
onClick={() => removeLanguage(code)}
className="text-xs text-red-600 hover:text-red-800"
disabled={saving}
>
Remove
</button>
)}
</div>
</div>
))}
</div>
<div className="flex gap-2">
<select
value={langToAdd}
onChange={(e) => setLangToAdd(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select language...</option>
{availableLangsToAdd.map((l) => (
<option key={l.code} value={l.code}>
{l.name} ({l.code})
</option>
))}
</select>
<button
type="button"
onClick={addLanguage}
disabled={!langToAdd || saving}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add
</button>
</div>
</section>
{/* --- Quick Colors Section --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
Quick Selection Colors
</h2>
<div className="flex flex-wrap gap-3 mb-4">
{settings.quick_colors.map((color) => (
<div key={color} className="relative group">
<div
className="w-10 h-10 rounded-lg border-2 border-gray-200 shadow-sm cursor-default"
style={{ backgroundColor: normalizeColor(color) }}
title={color}
/>
<button
type="button"
onClick={() => removeColor(color)}
disabled={saving}
className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-red-500 text-white rounded-full text-xs opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
&times;
</button>
</div>
))}
</div>
<div className="flex items-center gap-2">
<input
type="color"
value={colorToAdd}
onChange={(e) => {
setColorToAdd(e.target.value);
setColorHexInput(e.target.value);
}}
className="w-10 h-10 rounded cursor-pointer border border-gray-300"
/>
<input
type="text"
value={colorHexInput}
onChange={(e) => {
setColorHexInput(e.target.value);
if (/^#[0-9A-Fa-f]{6}$/.test(e.target.value)) {
setColorToAdd(e.target.value);
}
}}
placeholder="#FF0000"
className="w-28 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
/>
<button
type="button"
onClick={addColor}
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add
</button>
</div>
</section>
{/* --- Duration Presets Section --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6 xl:col-span-2">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
Duration Presets (seconds)
</h2>
<div className="flex flex-wrap gap-2 mb-4">
{settings.duration_values.map((val) => (
<div
key={val}
className="group flex items-center gap-1 px-3 py-1.5 bg-gray-50 border border-gray-200 rounded-full"
>
<span className="text-sm text-gray-900">
{formatDuration(val)}
</span>
<span className="text-xs text-gray-400 ml-1">({val}s)</span>
<button
type="button"
onClick={() => removeDuration(val)}
disabled={saving}
className="ml-1 text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity text-xs"
>
&times;
</button>
</div>
))}
</div>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
value={durationToAdd}
onChange={(e) => setDurationToAdd(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addDuration();
}
}}
placeholder="Seconds (e.g. 45)"
className="w-40 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
onClick={addDuration}
disabled={saving || !durationToAdd}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add
</button>
</div>
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { useState, useEffect } from "react";
import { getLanguageName } from "./melodyUtils";
/**
* Modal for editing translations of a field (Name or Description).
* Props:
* open - boolean
* onClose - function
* field - string label ("Name" or "Description")
* value - dict { lang_code: text }
* onChange - function(updatedDict)
* languages - array of lang codes ["en", "el", "sr"]
* multiline - boolean (use textarea instead of input)
*/
export default function TranslationModal({
open,
onClose,
field,
value,
onChange,
languages,
multiline = false,
}) {
const [draft, setDraft] = useState({});
useEffect(() => {
if (open) {
setDraft({ ...value });
}
}, [open, value]);
if (!open) return null;
const updateDraft = (lang, text) => {
setDraft((prev) => ({ ...prev, [lang]: text }));
};
const handleSave = () => {
onChange(draft);
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto">
<div className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Translations {field}
</h3>
<div className="space-y-3">
{languages.map((lang) => (
<div key={lang}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{getLanguageName(lang)}{" "}
<span className="text-gray-400 font-mono text-xs uppercase">
({lang})
</span>
</label>
{multiline ? (
<textarea
value={draft[lang] || ""}
onChange={(e) => updateDraft(lang, e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
) : (
<input
type="text"
value={draft[lang] || ""}
onChange={(e) => updateDraft(lang, e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
)}
</div>
))}
</div>
<div className="flex justify-end gap-3 mt-6">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-gray-100 text-gray-700 text-sm rounded-md hover:bg-gray-200 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors"
>
Save Translations
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
/**
* Format duration in seconds to human-readable string.
* 0 → "Single Run", 45 → "0:45", 75 → "1:15", 300 → "5:00"
*/
export function formatDuration(seconds) {
if (seconds === 0) return "Single Run";
const min = Math.floor(seconds / 60);
const sec = seconds % 60;
return `${min}:${sec.toString().padStart(2, "0")}`;
}
/**
* Get a localized value from a dict, with fallback chain:
* selected lang → "en" → first available value → fallback
*/
export function getLocalizedValue(dict, lang, fallback = "") {
if (!dict || typeof dict !== "object") return fallback;
return dict[lang] || dict["en"] || Object.values(dict)[0] || fallback;
}
/**
* Normalize color string for HTML color input.
* Converts "0xFFRRGGBB" or "0xRRGGBB" format to "#RRGGBB".
*/
export function normalizeColor(val) {
if (!val) return "#000000";
if (val.startsWith("0x") || val.startsWith("0X")) {
const hex = val.slice(2);
// If 8 chars (AARRGGBB), skip alpha
return `#${hex.length === 8 ? hex.slice(2) : hex}`;
}
if (val.startsWith("#")) return val;
return `#${val}`;
}
/**
* Master list of common ISO 639-1 language codes.
*/
export const LANGUAGE_MASTER_LIST = [
{ code: "en", name: "English" },
{ code: "el", name: "Greek" },
{ code: "sr", name: "Serbian" },
{ code: "bg", name: "Bulgarian" },
{ code: "ro", name: "Romanian" },
{ code: "ru", name: "Russian" },
{ code: "uk", name: "Ukrainian" },
{ code: "ka", name: "Georgian" },
{ code: "ar", name: "Arabic" },
{ code: "de", name: "German" },
{ code: "fr", name: "French" },
{ code: "es", name: "Spanish" },
{ code: "it", name: "Italian" },
{ code: "pt", name: "Portuguese" },
{ code: "nl", name: "Dutch" },
{ code: "pl", name: "Polish" },
{ code: "cs", name: "Czech" },
{ code: "sk", name: "Slovak" },
{ code: "hu", name: "Hungarian" },
{ code: "hr", name: "Croatian" },
{ code: "sl", name: "Slovenian" },
{ code: "mk", name: "Macedonian" },
{ code: "sq", name: "Albanian" },
{ code: "tr", name: "Turkish" },
{ code: "ja", name: "Japanese" },
{ code: "zh", name: "Chinese" },
{ code: "ko", name: "Korean" },
{ code: "hi", name: "Hindi" },
{ code: "he", name: "Hebrew" },
{ code: "fi", name: "Finnish" },
{ code: "sv", name: "Swedish" },
{ code: "da", name: "Danish" },
{ code: "no", name: "Norwegian" },
];
/**
* Get display name for a language code.
*/
export function getLanguageName(code) {
const lang = LANGUAGE_MASTER_LIST.find((l) => l.code === code);
return lang ? lang.name : code.toUpperCase();
}