diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 947397a..29d6d98 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,8 @@ "Bash(npm run build:*)", "Bash(python -c:*)", "Bash(npx vite build:*)", - "Bash(wc:*)" + "Bash(wc:*)", + "Bash(ls:*)" ] } } diff --git a/backend/main.py b/backend/main.py index 3cbbdf8..ef30f90 100644 --- a/backend/main.py +++ b/backend/main.py @@ -13,6 +13,7 @@ from equipment.router import router as equipment_router from staff.router import router as staff_router from mqtt.client import mqtt_manager from mqtt import database as mqtt_db +from melodies import service as melody_service app = FastAPI( title="BellSystems Admin Panel", @@ -43,6 +44,7 @@ app.include_router(staff_router) async def startup(): init_firebase() await mqtt_db.init_db() + await melody_service.migrate_from_firestore() mqtt_manager.start(asyncio.get_event_loop()) asyncio.create_task(mqtt_db.purge_loop()) diff --git a/backend/melodies/database.py b/backend/melodies/database.py new file mode 100644 index 0000000..a45afde --- /dev/null +++ b/backend/melodies/database.py @@ -0,0 +1,75 @@ +import json +import logging +from mqtt.database import get_db + +logger = logging.getLogger("melodies.database") + + +async def insert_melody(melody_id: str, status: str, data: dict) -> None: + db = await get_db() + await db.execute( + "INSERT INTO melody_drafts (id, status, data) VALUES (?, ?, ?)", + (melody_id, status, json.dumps(data)), + ) + await db.commit() + + +async def update_melody(melody_id: str, data: dict) -> None: + db = await get_db() + await db.execute( + "UPDATE melody_drafts SET data = ?, updated_at = datetime('now') WHERE id = ?", + (json.dumps(data), melody_id), + ) + await db.commit() + + +async def update_status(melody_id: str, status: str) -> None: + db = await get_db() + await db.execute( + "UPDATE melody_drafts SET status = ?, updated_at = datetime('now') WHERE id = ?", + (status, melody_id), + ) + await db.commit() + + +async def get_melody(melody_id: str) -> dict | None: + db = await get_db() + rows = await db.execute_fetchall( + "SELECT * FROM melody_drafts WHERE id = ?", (melody_id,) + ) + if not rows: + return None + row = dict(rows[0]) + row["data"] = json.loads(row["data"]) + return row + + +async def list_melodies(status: str | None = None) -> list[dict]: + db = await get_db() + if status and status != "all": + rows = await db.execute_fetchall( + "SELECT * FROM melody_drafts WHERE status = ? ORDER BY updated_at DESC", + (status,), + ) + else: + rows = await db.execute_fetchall( + "SELECT * FROM melody_drafts ORDER BY updated_at DESC" + ) + results = [] + for row in rows: + r = dict(row) + r["data"] = json.loads(r["data"]) + results.append(r) + return results + + +async def delete_melody(melody_id: str) -> None: + db = await get_db() + await db.execute("DELETE FROM melody_drafts WHERE id = ?", (melody_id,)) + await db.commit() + + +async def count_melodies() -> int: + db = await get_db() + rows = await db.execute_fetchall("SELECT COUNT(*) FROM melody_drafts") + return rows[0][0] diff --git a/backend/melodies/models.py b/backend/melodies/models.py index 5b87936..20ef422 100644 --- a/backend/melodies/models.py +++ b/backend/melodies/models.py @@ -62,6 +62,7 @@ class MelodyUpdate(BaseModel): class MelodyInDB(MelodyCreate): id: str + status: str = "published" class MelodyListResponse(BaseModel): diff --git a/backend/melodies/router.py b/backend/melodies/router.py index 7f22009..15b7281 100644 --- a/backend/melodies/router.py +++ b/backend/melodies/router.py @@ -16,13 +16,15 @@ async def list_melodies( type: Optional[str] = Query(None), tone: Optional[str] = Query(None), total_notes: Optional[int] = Query(None), + status: Optional[str] = Query(None), _user: TokenPayload = Depends(require_permission("melodies", "view")), ): - melodies = service.list_melodies( + melodies = await service.list_melodies( search=search, melody_type=type, tone=tone, total_notes=total_notes, + status=status, ) return MelodyListResponse(melodies=melodies, total=len(melodies)) @@ -32,15 +34,16 @@ async def get_melody( melody_id: str, _user: TokenPayload = Depends(require_permission("melodies", "view")), ): - return service.get_melody(melody_id) + return await service.get_melody(melody_id) @router.post("", response_model=MelodyInDB, status_code=201) async def create_melody( body: MelodyCreate, + publish: bool = Query(False), _user: TokenPayload = Depends(require_permission("melodies", "add")), ): - return service.create_melody(body) + return await service.create_melody(body, publish=publish) @router.put("/{melody_id}", response_model=MelodyInDB) @@ -49,7 +52,7 @@ async def update_melody( body: MelodyUpdate, _user: TokenPayload = Depends(require_permission("melodies", "edit")), ): - return service.update_melody(melody_id, body) + return await service.update_melody(melody_id, body) @router.delete("/{melody_id}", status_code=204) @@ -57,7 +60,23 @@ async def delete_melody( melody_id: str, _user: TokenPayload = Depends(require_permission("melodies", "delete")), ): - service.delete_melody(melody_id) + await service.delete_melody(melody_id) + + +@router.post("/{melody_id}/publish", response_model=MelodyInDB) +async def publish_melody( + melody_id: str, + _user: TokenPayload = Depends(require_permission("melodies", "edit")), +): + return await service.publish_melody(melody_id) + + +@router.post("/{melody_id}/unpublish", response_model=MelodyInDB) +async def unpublish_melody( + melody_id: str, + _user: TokenPayload = Depends(require_permission("melodies", "edit")), +): + return await service.unpublish_melody(melody_id) @router.post("/{melody_id}/upload/{file_type}") @@ -72,7 +91,7 @@ async def upload_file( raise HTTPException(status_code=400, detail="file_type must be 'binary' or 'preview'") # Verify melody exists - melody = service.get_melody(melody_id) + melody = await service.get_melody(melody_id) contents = await file.read() content_type = file.content_type or "application/octet-stream" @@ -84,14 +103,14 @@ async def upload_file( # Update the melody document with the file URL if file_type == "preview": - service.update_melody(melody_id, MelodyUpdate( + await service.update_melody(melody_id, MelodyUpdate( information=MelodyInfo( name=melody.information.name, previewURL=url, ) )) elif file_type == "binary": - service.update_melody(melody_id, MelodyUpdate(url=url)) + await service.update_melody(melody_id, MelodyUpdate(url=url)) return {"url": url, "file_type": file_type} @@ -106,7 +125,7 @@ async def delete_file( if file_type not in ("binary", "preview"): raise HTTPException(status_code=400, detail="file_type must be 'binary' or 'preview'") - service.get_melody(melody_id) + await service.get_melody(melody_id) service.delete_file(melody_id, file_type) @@ -116,5 +135,5 @@ async def get_files( _user: TokenPayload = Depends(require_permission("melodies", "view")), ): """Get storage file URLs for a melody.""" - service.get_melody(melody_id) + await service.get_melody(melody_id) return service.get_storage_files(melody_id) diff --git a/backend/melodies/service.py b/backend/melodies/service.py index 9ffcda7..eeeede7 100644 --- a/backend/melodies/service.py +++ b/backend/melodies/service.py @@ -1,10 +1,14 @@ import json +import uuid +import logging -from shared.firebase import get_db, get_bucket +from shared.firebase import get_db as get_firestore, get_bucket from shared.exceptions import NotFoundError from melodies.models import MelodyCreate, MelodyUpdate, MelodyInDB +from melodies import database as melody_db COLLECTION = "melodies" +logger = logging.getLogger("melodies.service") def _parse_localized_string(value: str) -> dict: @@ -26,109 +30,171 @@ def _doc_to_melody(doc) -> MelodyInDB: return MelodyInDB(id=doc.id, **data) -def list_melodies( +def _row_to_melody(row: dict) -> MelodyInDB: + """Convert a SQLite row (with parsed data) to a MelodyInDB model.""" + data = row["data"] + return MelodyInDB(id=row["id"], status=row["status"], **data) + + +def _matches_filters(melody: MelodyInDB, search: str | None, tone: str | None, + total_notes: int | None) -> bool: + """Apply client-side filters to a melody.""" + if tone and melody.information.melodyTone.value != tone: + return False + if total_notes is not None and melody.information.totalNotes != total_notes: + return False + if search: + search_lower = search.lower() + name_dict = _parse_localized_string(melody.information.name) + desc_dict = _parse_localized_string(melody.information.description) + name_match = any( + search_lower in v.lower() for v in name_dict.values() if isinstance(v, str) + ) + desc_match = any( + search_lower in v.lower() for v in desc_dict.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): + return False + return True + + +async def list_melodies( search: str | None = None, melody_type: str | None = None, tone: str | None = None, total_notes: int | None = None, + status: str | None = None, ) -> list[MelodyInDB]: - """List melodies with optional filters.""" - db = get_db() - ref = db.collection(COLLECTION) - - # Firestore doesn't support full-text search, so we fetch and filter in-memory - # for the name search. Type/tone/totalNotes can be queried server-side. - query = ref - - if melody_type: - query = query.where("type", "==", melody_type) - - docs = query.stream() + """List melodies from SQLite with optional filters.""" + rows = await melody_db.list_melodies(status=status) results = [] - for doc in docs: - melody = _doc_to_melody(doc) + for row in rows: + melody = _row_to_melody(row) - # Client-side filters - if tone and melody.information.melodyTone.value != tone: + # Server-side type filter + if melody_type and melody.type.value != melody_type: continue - if total_notes is not None and melody.information.totalNotes != total_notes: + + if not _matches_filters(melody, search, tone, total_notes): continue - if search: - search_lower = search.lower() - name_dict = _parse_localized_string(melody.information.name) - desc_dict = _parse_localized_string(melody.information.description) - name_match = any( - search_lower in v.lower() - for v in name_dict.values() - if isinstance(v, str) - ) - desc_match = any( - search_lower in v.lower() - for v in desc_dict.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 results.append(melody) return results -def get_melody(melody_id: str) -> MelodyInDB: - """Get a single melody by document ID.""" - db = get_db() - doc = db.collection(COLLECTION).document(melody_id).get() - if not doc.exists: - raise NotFoundError("Melody") - return _doc_to_melody(doc) +async def get_melody(melody_id: str) -> MelodyInDB: + """Get a single melody by ID. Checks SQLite first.""" + row = await melody_db.get_melody(melody_id) + if row: + return _row_to_melody(row) + raise NotFoundError("Melody") -def create_melody(data: MelodyCreate) -> MelodyInDB: - """Create a new melody document in Firestore.""" - db = get_db() +async def create_melody(data: MelodyCreate, publish: bool = False) -> MelodyInDB: + """Create a new melody. If publish=True, also push to Firestore.""" + melody_id = str(uuid.uuid4()) doc_data = data.model_dump() - _, doc_ref = db.collection(COLLECTION).add(doc_data) - return MelodyInDB(id=doc_ref.id, **doc_data) + status = "published" if publish else "draft" + + # Always save to SQLite + await melody_db.insert_melody(melody_id, status, doc_data) + + # If publishing, also save to Firestore + if publish: + db = get_firestore() + db.collection(COLLECTION).document(melody_id).set(doc_data) + + return MelodyInDB(id=melody_id, status=status, **doc_data) -def update_melody(melody_id: str, data: MelodyUpdate) -> MelodyInDB: - """Update an existing melody document. Only provided fields are updated.""" - db = get_db() - doc_ref = db.collection(COLLECTION).document(melody_id) - doc = doc_ref.get() - if not doc.exists: +async def update_melody(melody_id: str, data: MelodyUpdate) -> MelodyInDB: + """Update an existing melody. If published, also update Firestore.""" + row = await melody_db.get_melody(melody_id) + if not row: raise NotFoundError("Melody") + existing_data = row["data"] update_data = data.model_dump(exclude_none=True) - # For nested structs, merge with existing data rather than replacing - existing = doc.to_dict() + # Merge nested structs for key in ("information", "default_settings"): - if key in update_data and key in existing: - merged = {**existing[key], **update_data[key]} + if key in update_data and key in existing_data: + merged = {**existing_data[key], **update_data[key]} update_data[key] = merged - doc_ref.update(update_data) + merged_data = {**existing_data, **update_data} - updated_doc = doc_ref.get() - return _doc_to_melody(updated_doc) + # Update SQLite + await melody_db.update_melody(melody_id, merged_data) + + # If published, also update Firestore + if row["status"] == "published": + db = get_firestore() + doc_ref = db.collection(COLLECTION).document(melody_id) + doc_ref.set(merged_data) + + return MelodyInDB(id=melody_id, status=row["status"], **merged_data) -def delete_melody(melody_id: str) -> None: - """Delete a melody document and its associated storage files.""" - db = get_db() - doc_ref = db.collection(COLLECTION).document(melody_id) - doc = doc_ref.get() - if not doc.exists: +async def publish_melody(melody_id: str) -> MelodyInDB: + """Publish a draft melody to Firestore.""" + row = await melody_db.get_melody(melody_id) + if not row: raise NotFoundError("Melody") - # Delete associated storage files + doc_data = row["data"] + + # Write to Firestore + db = get_firestore() + db.collection(COLLECTION).document(melody_id).set(doc_data) + + # Update status in SQLite + await melody_db.update_status(melody_id, "published") + + return MelodyInDB(id=melody_id, status="published", **doc_data) + + +async def unpublish_melody(melody_id: str) -> MelodyInDB: + """Remove melody from Firestore but keep in SQLite as draft.""" + row = await melody_db.get_melody(melody_id) + if not row: + raise NotFoundError("Melody") + + # Delete from Firestore + db = get_firestore() + doc_ref = db.collection(COLLECTION).document(melody_id) + doc = doc_ref.get() + if doc.exists: + doc_ref.delete() + + # Update status in SQLite + await melody_db.update_status(melody_id, "draft") + + return MelodyInDB(id=melody_id, status="draft", **row["data"]) + + +async def delete_melody(melody_id: str) -> None: + """Delete a melody from SQLite and Firestore (if published), plus storage files.""" + row = await melody_db.get_melody(melody_id) + if not row: + raise NotFoundError("Melody") + + # Delete from Firestore if published + if row["status"] == "published": + db = get_firestore() + doc_ref = db.collection(COLLECTION).document(melody_id) + doc = doc_ref.get() + if doc.exists: + doc_ref.delete() + + # Delete storage files _delete_storage_files(melody_id) - doc_ref.delete() + # Delete from SQLite + await melody_db.delete_melody(melody_id) def upload_file(melody_id: str, file_bytes: bytes, filename: str, content_type: str) -> str: @@ -137,11 +203,9 @@ def upload_file(melody_id: str, file_bytes: bytes, filename: str, content_type: if not bucket: raise RuntimeError("Firebase Storage not initialized") - # Determine subfolder based on content type if content_type in ("application/octet-stream", "application/macbinary"): storage_path = f"melodies/{melody_id}/binary.bin" else: - # Audio preview files ext = filename.rsplit(".", 1)[-1] if "." in filename else "mp3" storage_path = f"melodies/{melody_id}/preview.{ext}" @@ -197,3 +261,24 @@ def get_storage_files(melody_id: str) -> dict: result["preview_url"] = blob.public_url return result + + +async def migrate_from_firestore() -> int: + """One-time migration: import existing Firestore melodies into SQLite as published. + Only runs if the melody_drafts table is empty.""" + count = await melody_db.count_melodies() + if count > 0: + return 0 + + db = get_firestore() + docs = db.collection(COLLECTION).stream() + imported = 0 + + for doc in docs: + data = doc.to_dict() + await melody_db.insert_melody(doc.id, "published", data) + imported += 1 + + if imported > 0: + logger.info(f"Migrated {imported} melodies from Firestore to SQLite") + return imported diff --git a/backend/mqtt/database.py b/backend/mqtt/database.py index cfe5667..455ab22 100644 --- a/backend/mqtt/database.py +++ b/backend/mqtt/database.py @@ -44,6 +44,15 @@ SCHEMA_STATEMENTS = [ "CREATE INDEX IF NOT EXISTS idx_heartbeats_serial_time ON heartbeats(device_serial, received_at)", "CREATE INDEX IF NOT EXISTS idx_commands_serial_time ON commands(device_serial, sent_at)", "CREATE INDEX IF NOT EXISTS idx_commands_status ON commands(status)", + # Melody drafts table + """CREATE TABLE IF NOT EXISTS melody_drafts ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'draft', + data TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )""", + "CREATE INDEX IF NOT EXISTS idx_melody_drafts_status ON melody_drafts(status)", ] diff --git a/frontend/src/devices/DeviceDetail.jsx b/frontend/src/devices/DeviceDetail.jsx index 9313e64..2467740 100644 --- a/frontend/src/devices/DeviceDetail.jsx +++ b/frontend/src/devices/DeviceDetail.jsx @@ -178,7 +178,7 @@ function DeviceLogsPanel({ deviceSerial }) { const [searchText, setSearchText] = useState(""); const [autoRefresh, setAutoRefresh] = useState(false); const [liveLogs, setLiveLogs] = useState([]); - const limit = 15; + const limit = 25; const fetchLogs = useCallback(async () => { if (!deviceSerial) return; @@ -278,9 +278,9 @@ function DeviceLogsPanel({ deviceSerial }) {

No logs found.

) : (
-
+
- + @@ -322,6 +322,24 @@ function DeviceLogsPanel({ deviceSerial }) { ); } +// --- Breakpoint hook --- + +function useBreakpoint() { + const [cols, setCols] = useState(() => { + const w = window.innerWidth; + return w >= 2100 ? 3 : w >= 900 ? 2 : 1; + }); + useEffect(() => { + const onResize = () => { + const w = window.innerWidth; + setCols(w >= 2100 ? 3 : w >= 900 ? 2 : 1); + }; + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + return cols; +} + // --- Main component --- export default function DeviceDetail() { @@ -429,6 +447,456 @@ export default function DeviceDetail() { const nextMaintenance = maintainedOn && stats.maintainancePeriod ? addDays(maintainedOn, stats.maintainancePeriod) : null; const maintenanceDaysLeft = nextMaintenance ? daysUntil(nextMaintenance) : null; + const cols = useBreakpoint(); + + // ===== Section definitions ===== + + const deviceInfoSection = ( +
+
+ {/* Status */} +
+
+ +
+
+
Status
+
+ {isOnline ? "Online" : "Offline"} + {mqttStatus && ( + + {mqttStatus.seconds_since_heartbeat}s ago + + )} +
+
+
+ + {/* Serial + Hardware Variant */} +
+
+
Serial Number
+
{device.device_id}
+
HW: VesperCore
+
+
+ + {/* Admin Note */} +
+
+
Admin Note
+
-
+
+
+ + {/* Document ID */} +
+
+
Document ID
+
{device.id}
+
+
+
+
+ ); + + const subscriptionSection = ( + +
+ + + {sub.subscrTier} + + + {formatDate(sub.subscrStart)} + {sub.subscrDuration ? daysToDisplay(sub.subscrDuration) : "-"} + {subscrEnd ? formatDateNice(subscrEnd) : "-"} + + {subscrDaysLeft === null ? "-" : subscrDaysLeft <= 0 ? ( + + Subscription Expired + + ) : ( + + {subscrDaysLeft} days left + + )} + + {sub.maxUsers} + {sub.maxOutputs} +
+
+ ); + + const locationSection = ( + + {coords ? ( +
+
+
+ {device.device_location} + +
+ {formatCoordinates(coords)} + e.stopPropagation()} + > + Open in Maps + +
+
+ {locationName && {locationName}} +
+
+
+
+ + + + +
+
+
+ ) : ( +
+ {device.device_location} + {device.device_location_coordinates || "-"} +
+ )} +
+ ); + + const deviceSettingsSection = ( + +
+ {/* Left Column */} +
+ + + + + + + + + + + {clock.ringAlerts || "-"} + {clock.ringIntervals} + + + {clock.hourAlertsBell} + {clock.halfhourAlertsBell} + {clock.quarterAlertsBell} + + + + + {formatTimestamp(clock.daySilenceFrom)} - {formatTimestamp(clock.daySilenceTo)} + + + + + + {formatTimestamp(clock.nightSilenceFrom)} - {formatTimestamp(clock.nightSilenceTo)} + + + + + + + {clock.backlightOutput} + + {formatTimestamp(clock.backlightTurnOnTime)} - {formatTimestamp(clock.backlightTurnOffTime)} + + + + + + {attr.serialLogLevel} + {attr.sdLogLevel} + {attr.mqttLogLevel ?? 0} + + +
+ {/* Right Column */} +
+ + + {net.hostname} + + + + + + + {clock.ringIntervals} + + + {clock.clockOutputs?.[0] ?? "-"} + {clock.clockOutputs?.[1] ?? "-"} + + + {clock.clockTimings?.[0] != null ? msToSeconds(clock.clockTimings[0]) : "-"} + {clock.clockTimings?.[1] != null ? msToSeconds(clock.clockTimings[1]) : "-"} + + + + + + {attr.totalBells ?? "-"} + + {attr.bellOutputs?.length > 0 && ( +
+
+ Output & Timing Map +
+
+ {attr.bellOutputs.map((output, i) => ( +
+ + {i + 1} + +
+
+ Output {output} +
+
+ {attr.hammerTimings?.[i] != null ? ( + <>{attr.hammerTimings[i]} ms + ) : "-"} +
+
+
+ ))} +
+
+ )} +
+
+
+
+ ); + + const warrantySection = ( + + +
+ + {warrantyDaysLeft !== null ? ( + warrantyDaysLeft > 0 ? ( + Active + ) : ( + Expired + ) + ) : ( + + )} + + {formatDate(stats.warrantyStart)} + {stats.warrantyPeriod ? daysToDisplay(stats.warrantyPeriod) : "-"} + {warrantyEnd ? formatDateNice(warrantyEnd) : "-"} + + {warrantyDaysLeft === null ? "-" : warrantyDaysLeft <= 0 ? ( + Expired + ) : ( + `${warrantyDaysLeft} days` + )} + +
+
+ +
+ {formatDate(stats.maintainedOn)} + {stats.maintainancePeriod ? daysToDisplay(stats.maintainancePeriod) : "-"} + + {nextMaintenance ? ( + + {formatDateNice(nextMaintenance)} + {maintenanceDaysLeft !== null && ( + + ({maintenanceDaysLeft <= 0 ? "overdue" : formatRelativeTime(maintenanceDaysLeft)}) + + )} + + ) : "-"} + +
+
+ +
+ {stats.totalPlaybacks} + {stats.totalHammerStrikes} + {stats.totalWarningsGiven} + {device.device_melodies_all?.length ?? 0} + {device.device_melodies_favorites?.length ?? 0} + {stats.perBellStrikes?.length > 0 && ( +
+ +
+ {stats.perBellStrikes.slice(0, attr.totalBells || stats.perBellStrikes.length).map((count, i) => ( + + Bell {i + 1}: {count} + + ))} +
+
+
+ )} +
+
+
+ ); + + const miscSection = ( + +
+ + {attr.deviceLocale || "-"} + {device.websocket_url} + +
+ {device.churchAssistantURL} +
+
+
+ ); + + const appUsersSection = ( + + {usersLoading ? ( +

Loading users...

+ ) : deviceUsers.length === 0 ? ( +

No users assigned to this device.

+ ) : ( +
+ {deviceUsers.map((user, i) => ( +
user.user_id && navigate(`/users/${user.user_id}`)} + > +
+
+

+ {user.display_name || user.email || "Unknown User"} +

+ {user.email && user.display_name && ( +

{user.email}

+ )} + {user.user_id && ( +

{user.user_id}

+ )} +
+ {user.role && ( + + {user.role} + + )} +
+
+ ))} +
+ )} +
+ ); + + const notesSection = ; + const logsSection = ; + + // ===== Layout rendering ===== + + const renderSingleColumn = () => ( +
+ {deviceInfoSection} + {locationSection} + {notesSection} + {subscriptionSection} + {deviceSettingsSection} + {warrantySection} + {miscSection} + {appUsersSection} + {logsSection} +
+ ); + + const renderDoubleColumn = () => ( +
+ {/* Row 1: Device Info + Subscription — equal height */} +
+ {deviceInfoSection} + {subscriptionSection} +
+ {/* Row 2: Device Settings — full width */} + {deviceSettingsSection} + {/* Row 3: Location+Misc (left) vs Warranty (right) */} +
+
+
{locationSection}
+ {miscSection} +
+
+ {warrantySection} +
+
+ {/* Row 4: Notes vs App Users */} +
+
{notesSection}
+
{appUsersSection}
+
+ {/* Latest Logs */} + {logsSection} +
+ ); + + const renderTripleColumn = () => ( +
+ {/* Row 1: DevInfo+Subscription (cols 1-2 equal height) + Location (col 3) */} +
+
+ {deviceInfoSection} + {subscriptionSection} +
+
{locationSection}
+
+ {/* Row 2: Device Settings (cols 1-2) + Warranty (col 3) */} +
+
{deviceSettingsSection}
+
{warrantySection}
+
+ {/* Row 3: Misc (col1) + Notes (col2) + App Users (col3) */} +
+
{miscSection}
+
{notesSection}
+
{appUsersSection}
+
+ {/* Latest Logs */} + {logsSection} +
+ ); + return (
{/* Header */} @@ -460,393 +928,7 @@ export default function DeviceDetail() { )}
-
- {/* ===== BASIC INFORMATION (double-width) ===== */} -
-
-
- {/* Status — fancy */} -
-
- -
-
-
Status
-
- {isOnline ? "Online" : "Offline"} - {mqttStatus && ( - - (MQTT {mqttStatus.seconds_since_heartbeat}s ago) - - )} -
-
-
- - {/* Serial + Hardware Variant */} -
-
Serial Number
-
- {device.device_id} - HW: - -
-
- - {/* Quick Admin Notes */} -
-
Admin Notes
-
-
-
- - {/* Document ID */} -
-
Document ID
-
{device.id}
-
-
-
-
- - {/* ===== DEVICE SETTINGS (double-width) ===== */} -
- -
- {/* Left Column */} -
- {/* Basic Attributes */} - - - - - - - - - {/* Alert Settings */} - - - - {clock.ringAlerts || "-"} - {clock.ringIntervals} - - - {clock.hourAlertsBell} - {clock.halfhourAlertsBell} - {clock.quarterAlertsBell} - - - - - {formatTimestamp(clock.daySilenceFrom)} - {formatTimestamp(clock.daySilenceTo)} - - - - - - {formatTimestamp(clock.nightSilenceFrom)} - {formatTimestamp(clock.nightSilenceTo)} - - - - - {/* Backlight Settings */} - - - - {clock.backlightOutput} - - {formatTimestamp(clock.backlightTurnOnTime)} - {formatTimestamp(clock.backlightTurnOffTime)} - - - - - {/* Logging */} - - - {attr.serialLogLevel} - {attr.sdLogLevel} - {attr.mqttLogLevel ?? 0} - - -
- - {/* Right Column */} -
- {/* Network */} - - - {net.hostname} - - - - - {/* Clock Settings */} - - - - {clock.ringIntervals} - - - {clock.clockOutputs?.[0] ?? "-"} - {clock.clockOutputs?.[1] ?? "-"} - - - {clock.clockTimings?.[0] != null ? msToSeconds(clock.clockTimings[0]) : "-"} - {clock.clockTimings?.[1] != null ? msToSeconds(clock.clockTimings[1]) : "-"} - - - - {/* Bell Settings */} - - - - {attr.totalBells ?? "-"} - - {/* Bell Output-to-Timing mapping with watermark numbers */} - {attr.bellOutputs?.length > 0 && ( -
-
- Output & Timing Map -
-
- {attr.bellOutputs.map((output, i) => ( -
- {/* Watermark number */} - - {i + 1} - - {/* Content */} -
-
- Output {output} -
-
- {attr.hammerTimings?.[i] != null ? ( - <>{attr.hammerTimings[i]} ms - ) : "-"} -
-
-
- ))} -
-
- )} -
-
-
-
-
- - {/* ===== SINGLE-WIDTH SECTIONS ===== */} - - {/* Misc */} - -
- - {attr.deviceLocale || "-"} - {device.websocket_url} - -
- {device.churchAssistantURL} -
-
-
- - {/* Subscription */} - -
- - - {sub.subscrTier} - - - {formatDate(sub.subscrStart)} - {sub.subscrDuration ? daysToDisplay(sub.subscrDuration) : "-"} - {subscrEnd ? formatDateNice(subscrEnd) : "-"} - - {subscrDaysLeft === null ? "-" : subscrDaysLeft <= 0 ? ( - - Subscription Expired - - ) : ( - - {subscrDaysLeft} days left - - )} - - {sub.maxUsers} - {sub.maxOutputs} -
-
- - {/* Location */} - -
-
- {device.device_location} - -
- {coords ? formatCoordinates(coords) : device.device_location_coordinates || "-"} - {coords && ( - e.stopPropagation()} - > - Open in Maps - - )} -
-
- {locationName && ( - {locationName} - )} -
- {coords && ( -
- - - - -
- )} -
-
- - {/* Warranty, Maintenance & Statistics */} - - -
- - {warrantyDaysLeft !== null ? ( - warrantyDaysLeft > 0 ? ( - Active - ) : ( - Expired - ) - ) : ( - - )} - - {formatDate(stats.warrantyStart)} - {stats.warrantyPeriod ? daysToDisplay(stats.warrantyPeriod) : "-"} - {warrantyEnd ? formatDateNice(warrantyEnd) : "-"} - - {warrantyDaysLeft === null ? "-" : warrantyDaysLeft <= 0 ? ( - Expired - ) : ( - `${warrantyDaysLeft} days` - )} - -
-
- - -
- {formatDate(stats.maintainedOn)} - {stats.maintainancePeriod ? daysToDisplay(stats.maintainancePeriod) : "-"} - - {nextMaintenance ? ( - - {formatDateNice(nextMaintenance)} - {maintenanceDaysLeft !== null && ( - - ({maintenanceDaysLeft <= 0 ? "overdue" : formatRelativeTime(maintenanceDaysLeft)}) - - )} - - ) : "-"} - -
-
- - -
- {stats.totalPlaybacks} - {stats.totalHammerStrikes} - {stats.totalWarningsGiven} - {device.device_melodies_all?.length ?? 0} - {device.device_melodies_favorites?.length ?? 0} - {stats.perBellStrikes?.length > 0 && ( -
- -
- {stats.perBellStrikes.slice(0, attr.totalBells || stats.perBellStrikes.length).map((count, i) => ( - - Bell {i + 1}: {count} - - ))} -
-
-
- )} -
-
-
- - {/* Users */} - - {usersLoading ? ( -

Loading users...

- ) : deviceUsers.length === 0 ? ( -

No users assigned to this device.

- ) : ( -
- {deviceUsers.map((user, i) => ( -
user.user_id && navigate(`/users/${user.user_id}`)} - > -
-
-

- {user.display_name || user.email || "Unknown User"} -

- {user.email && user.display_name && ( -

{user.email}

- )} - {user.user_id && ( -

{user.user_id}

- )} -
- {user.role && ( - - {user.role} - - )} -
-
- ))} -
- )} -
- - {/* Equipment Notes */} - - - {/* Latest Logs */} - -
+ {cols === 1 ? renderSingleColumn() : cols === 2 ? renderDoubleColumn() : renderTripleColumn()} .section-wide { - grid-column: 1 / -1; +.device-column { + display: flex; + flex-direction: column; + gap: 1.5rem; + flex: 1; + min-width: 0; } -@media (min-width: 900px) { - .device-sections { - grid-template-columns: repeat(2, 1fr); - grid-auto-flow: dense; - } +.device-full-row { + width: 100%; + margin-bottom: 1.5rem; } -@media (min-width: 2100px) { - .device-sections { - grid-template-columns: repeat(3, 1fr); - } - .device-sections > .section-wide { - grid-column: span 2; - } +.device-equal-row { + display: flex; + gap: 1.5rem; + align-items: stretch; +} +.device-equal-row > * { + flex: 1; + min-width: 0; +} +.device-flex-fill { + display: flex; + flex-direction: column; +} +.device-flex-fill > .flex-grow { + flex: 1; +} +/* Device info horizontal subsections */ +.device-info-row { + display: flex; + flex-wrap: wrap; + gap: 0; +} +.device-info-row > .device-info-item { + padding: 0 1.5rem; + border-left: 1px solid var(--border-secondary); + display: flex; + align-items: center; + gap: 0.75rem; +} +.device-info-row > .device-info-item:first-child { + padding-left: 0; + border-left: none; +} +/* Location 2-column internal layout */ +.location-split { + display: flex; + gap: 1.5rem; + align-items: stretch; +} +.location-split > .location-fields { + flex: 0 0 auto; + min-width: 200px; +} +.location-split > .location-map { + flex: 1; + display: flex; + align-items: center; } /* File input */ diff --git a/frontend/src/melodies/MelodyDetail.jsx b/frontend/src/melodies/MelodyDetail.jsx index ff52cfe..015307e 100644 --- a/frontend/src/melodies/MelodyDetail.jsx +++ b/frontend/src/melodies/MelodyDetail.jsx @@ -32,6 +32,8 @@ export default function MelodyDetail() { const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [showDelete, setShowDelete] = useState(false); + const [showUnpublish, setShowUnpublish] = useState(false); + const [actionLoading, setActionLoading] = useState(false); const [displayLang, setDisplayLang] = useState("en"); const [melodySettings, setMelodySettings] = useState(null); @@ -72,6 +74,32 @@ export default function MelodyDetail() { } }; + const handlePublish = async () => { + setActionLoading(true); + try { + await api.post(`/melodies/${id}/publish`); + await loadData(); + } catch (err) { + setError(err.message); + } finally { + setActionLoading(false); + } + }; + + const handleUnpublish = async () => { + setActionLoading(true); + try { + await api.post(`/melodies/${id}/unpublish`); + setShowUnpublish(false); + await loadData(); + } catch (err) { + setError(err.message); + setShowUnpublish(false); + } finally { + setActionLoading(false); + } + }; + if (loading) { return
Loading...
; } @@ -116,6 +144,15 @@ export default function MelodyDetail() {

{displayName}

+ + {melody.status === "published" ? "LIVE" : "DRAFT"} + {languages.length > 1 && ( + {languages.length > 1 && (
Time Level