diff --git a/backend/main.py b/backend/main.py
index 451eab1..625da28 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -4,6 +4,7 @@ from config import settings
from shared.firebase import init_firebase, firebase_initialized
from auth.router import router as auth_router
from melodies.router import router as melodies_router
+from settings.router import router as settings_router
app = FastAPI(
title="BellSystems Admin Panel",
@@ -22,6 +23,7 @@ app.add_middleware(
app.include_router(auth_router)
app.include_router(melodies_router)
+app.include_router(settings_router)
@app.on_event("startup")
diff --git a/backend/melodies/models.py b/backend/melodies/models.py
index dfcf320..dfd5177 100644
--- a/backend/melodies/models.py
+++ b/backend/melodies/models.py
@@ -1,5 +1,5 @@
-from pydantic import BaseModel
-from typing import List, Optional
+from pydantic import BaseModel, Field
+from typing import Dict, List, Optional
from enum import Enum
@@ -17,13 +17,13 @@ class MelodyTone(str, Enum):
class MelodyInfo(BaseModel):
- name: str
- description: str = ""
+ name: Dict[str, str] = {}
+ description: Dict[str, str] = {}
melodyTone: MelodyTone = MelodyTone.normal
customTags: List[str] = []
minSpeed: int = 0
maxSpeed: int = 0
- totalNotes: int = 1
+ totalNotes: int = Field(default=1, ge=1, le=16)
steps: int = 0
color: str = ""
isTrueRing: bool = False
@@ -32,7 +32,7 @@ class MelodyInfo(BaseModel):
class MelodyAttributes(BaseModel):
- speed: int = 0
+ speed: int = 50
duration: int = 0
totalRunDuration: int = 0
pauseDuration: int = 0
diff --git a/backend/melodies/service.py b/backend/melodies/service.py
index bd3ccf9..fa780e3 100644
--- a/backend/melodies/service.py
+++ b/backend/melodies/service.py
@@ -8,6 +8,13 @@ COLLECTION = "melodies"
def _doc_to_melody(doc) -> MelodyInDB:
"""Convert a Firestore document snapshot to a MelodyInDB model."""
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)
@@ -41,8 +48,16 @@ def list_melodies(
continue
if search:
search_lower = search.lower()
- name_match = search_lower in melody.information.name.lower()
- desc_match = search_lower in melody.information.description.lower()
+ name_match = any(
+ 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)
if not (name_match or desc_match or tag_match):
continue
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 4ac54d8..aa9b63e 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -7,3 +7,4 @@ paho-mqtt==2.1.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.20
+bcrypt==4.0.1
\ No newline at end of file
diff --git a/backend/settings/__init__.py b/backend/settings/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/settings/models.py b/backend/settings/models.py
new file mode 100644
index 0000000..5d89fab
--- /dev/null
+++ b/backend/settings/models.py
@@ -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
diff --git a/backend/settings/router.py b/backend/settings/router.py
new file mode 100644
index 0000000..0425d9d
--- /dev/null
+++ b/backend/settings/router.py
@@ -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)
diff --git a/backend/settings/service.py b/backend/settings/service.py
new file mode 100644
index 0000000..cf0d079
--- /dev/null
+++ b/backend/settings/service.py
@@ -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)
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 23eecf0..03c4edb 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -5,6 +5,7 @@ import MainLayout from "./layout/MainLayout";
import MelodyList from "./melodies/MelodyList";
import MelodyDetail from "./melodies/MelodyDetail";
import MelodyForm from "./melodies/MelodyForm";
+import MelodySettings from "./melodies/MelodySettings";
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
@@ -50,6 +51,7 @@ export default function App() {
>
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/layout/Sidebar.jsx b/frontend/src/layout/Sidebar.jsx
index adddc29..5e3da56 100644
--- a/frontend/src/layout/Sidebar.jsx
+++ b/frontend/src/layout/Sidebar.jsx
@@ -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";
const navItems = [
{ 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: "/users", label: "Users", roles: ["superadmin", "user_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() {
const { hasRole } = useAuth();
+ const location = useLocation();
const visibleItems = navItems.filter(
(item) => item.roles === null || hasRole(...item.roles)
@@ -20,23 +36,82 @@ export default function Sidebar() {
);
}
+
+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 (
+
+
+ {shouldBeOpen && (
+
+ {children.map((child) => (
+
+ `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}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/melodies/MelodyDetail.jsx b/frontend/src/melodies/MelodyDetail.jsx
index 70a92c9..7ccbc5c 100644
--- a/frontend/src/melodies/MelodyDetail.jsx
+++ b/frontend/src/melodies/MelodyDetail.jsx
@@ -3,6 +3,12 @@ import { useParams, useNavigate } from "react-router-dom";
import api from "../api/client";
import { useAuth } from "../auth/AuthContext";
import ConfirmDialog from "../components/ConfirmDialog";
+import {
+ getLocalizedValue,
+ getLanguageName,
+ normalizeColor,
+ formatDuration,
+} from "./melodyUtils";
function Field({ label, children }) {
return (
@@ -26,6 +32,15 @@ export default function MelodyDetail() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
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(() => {
loadData();
@@ -73,9 +88,11 @@ export default function MelodyDetail() {
const info = melody.information || {};
const settings = melody.default_settings || {};
+ const languages = melodySettings?.available_languages || ["en"];
+ const displayName = getLocalizedValue(info.name, displayLang, "Untitled Melody");
return (
-
+
-
- {info.name || "Untitled Melody"}
-
+
+
{displayName}
+ {languages.length > 1 && (
+
+ )}
+
{canEdit && (
@@ -106,170 +136,180 @@ export default function MelodyDetail() {
)}
- {/* Melody Information */}
-
-
- Melody Information
-
-
-
- {melody.type}
-
-
- {info.melodyTone}
-
- {info.totalNotes}
- {info.steps}
- {info.minSpeed}
- {info.maxSpeed}
-
- {info.color ? (
-
-
- {info.color}
-
- ) : (
- "-"
- )}
-
-
-
- {info.isTrueRing ? "Yes" : "No"}
-
-
-
- {info.description}
-
-
-
- {info.notes?.length > 0 ? info.notes.join(", ") : "-"}
-
-
-
-
- {info.customTags?.length > 0 ? (
-
- {info.customTags.map((tag) => (
+
+ {/* Left column */}
+
+ {/* Melody Information */}
+
+
+ Melody Information
+
+
+
+ {melody.type}
+
+
+ {info.melodyTone}
+
+ {info.totalNotes}
+ {info.steps}
+ {info.minSpeed}
+ {info.maxSpeed}
+
+ {info.color ? (
+
- {tag}
-
- ))}
-
- ) : (
- "-"
- )}
-
-
-
-
-
- {/* Default Settings */}
-
-
- Default Settings
-
-
- {settings.speed}
- {settings.duration}
- {settings.totalRunDuration}
- {settings.pauseDuration}
-
-
- {settings.infiniteLoop ? "Yes" : "No"}
-
-
-
-
- {settings.echoRing?.length > 0
- ? settings.echoRing.join(", ")
- : "-"}
-
-
-
-
- {settings.noteAssignments?.length > 0
- ? settings.noteAssignments.join(", ")
- : "-"}
-
-
-
-
-
- {/* Identifiers */}
-
-
- Identifiers
-
-
- {melody.id}
- {melody.uid}
- {melody.pid}
-
- {melody.url}
-
-
-
-
- {/* Files */}
-
- Files
-
-
- {files.binary_url ? (
-
- Download binary
-
- ) : (
- Not uploaded
- )}
-
-
- {files.preview_url ? (
-
-
-
+ {info.color}
+
+ ) : (
+ "-"
+ )}
+
+
+
- Download preview
-
+ {info.isTrueRing ? "Yes" : "No"}
+
+
+
+
+ {getLocalizedValue(info.description, displayLang)}
+
- ) : (
-
Not uploaded
- )}
-
-
-
+
+
+ {info.notes?.length > 0 ? info.notes.join(", ") : "-"}
+
+
+
+
+ {info.customTags?.length > 0 ? (
+
+ {info.customTags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ ) : (
+ "-"
+ )}
+
+
+
+
+
+ {/* Identifiers */}
+
+
+ Identifiers
+
+
+ {melody.id}
+ {melody.uid}
+ {melody.pid}
+
+ {melody.url}
+
+
+
+
+
+ {/* Right column */}
+
+ {/* Default Settings */}
+
+
+ Default Settings
+
+
+ {settings.speed}%
+ {formatDuration(settings.duration)}
+ {settings.totalRunDuration}
+ {settings.pauseDuration}
+
+
+ {settings.infiniteLoop ? "Yes" : "No"}
+
+
+
+
+ {settings.echoRing?.length > 0
+ ? settings.echoRing.join(", ")
+ : "-"}
+
+
+
+
+ {settings.noteAssignments?.length > 0
+ ? settings.noteAssignments.join(", ")
+ : "-"}
+
+
+
+
+
+ {/* Files */}
+
+ Files
+
+
+ {files.binary_url ? (
+
+ Download binary
+
+ ) : (
+ Not uploaded
+ )}
+
+
+ {files.preview_url ? (
+
+ ) : (
+ Not uploaded
+ )}
+
+
+
+
+
setShowDelete(false)}
/>
diff --git a/frontend/src/melodies/MelodyForm.jsx b/frontend/src/melodies/MelodyForm.jsx
index 1253c45..46dded6 100644
--- a/frontend/src/melodies/MelodyForm.jsx
+++ b/frontend/src/melodies/MelodyForm.jsx
@@ -1,13 +1,20 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import api from "../api/client";
+import TranslationModal from "./TranslationModal";
+import {
+ getLocalizedValue,
+ getLanguageName,
+ normalizeColor,
+ formatDuration,
+} from "./melodyUtils";
const MELODY_TYPES = ["orthodox", "catholic", "all"];
const MELODY_TONES = ["normal", "festive", "cheerful", "lamentation"];
const defaultInfo = {
- name: "",
- description: "",
+ name: {},
+ description: {},
melodyTone: "normal",
customTags: [],
minSpeed: 0,
@@ -21,7 +28,7 @@ const defaultInfo = {
};
const defaultSettings = {
- speed: 0,
+ speed: 50,
duration: 0,
totalRunDuration: 0,
pauseDuration: 0,
@@ -54,12 +61,45 @@ export default function MelodyForm() {
// 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: "",
+ fieldKey: "",
+ multiline: false,
+ });
+
+ useEffect(() => {
+ api.get("/settings/melody").then((ms) => {
+ setMelodySettings(ms);
+ setEditLang(ms.primary_language || "en");
+ });
+ }, []);
+
useEffect(() => {
if (isEdit) {
loadMelody();
}
}, [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 () => {
setLoading(true);
try {
@@ -113,6 +153,11 @@ export default function MelodyForm() {
.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) => {
e.preventDefault();
setSaving(true);
@@ -162,8 +207,19 @@ export default function MelodyForm() {
return Loading...
;
}
+ 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 (
-
+
{isEdit ? "Edit Melody" : "Add Melody"}
@@ -174,436 +230,582 @@ export default function MelodyForm() {
)}
-