From 59c50493051a078ee0c8e90c5a2664f647759fee Mon Sep 17 00:00:00 2001 From: bonamin Date: Tue, 17 Feb 2026 09:56:07 +0200 Subject: [PATCH] Phase 2 UI Adjustments/Edits by bonamin --- backend/main.py | 2 + backend/melodies/models.py | 12 +- backend/melodies/service.py | 19 +- backend/requirements.txt | 1 + backend/settings/__init__.py | 0 backend/settings/models.py | 19 + backend/settings/router.py | 22 + backend/settings/service.py | 39 + frontend/src/App.jsx | 2 + frontend/src/layout/Sidebar.jsx | 111 ++- frontend/src/melodies/MelodyDetail.jsx | 362 ++++--- frontend/src/melodies/MelodyForm.jsx | 1053 ++++++++++++-------- frontend/src/melodies/MelodyList.jsx | 35 +- frontend/src/melodies/MelodySettings.jsx | 341 +++++++ frontend/src/melodies/TranslationModal.jsx | 98 ++ frontend/src/melodies/melodyUtils.js | 81 ++ 16 files changed, 1588 insertions(+), 609 deletions(-) create mode 100644 backend/settings/__init__.py create mode 100644 backend/settings/models.py create mode 100644 backend/settings/router.py create mode 100644 backend/settings/service.py create mode 100644 frontend/src/melodies/MelodySettings.jsx create mode 100644 frontend/src/melodies/TranslationModal.jsx create mode 100644 frontend/src/melodies/melodyUtils.js 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.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() {
)} -
- {/* --- Melody Info Section --- */} -
-

- Melody Information -

-
-
- - updateInfo("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" - /> -
- -
- -