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.
) : (
-
+
-
+
| Time |
Level |
@@ -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 */}
+
+
+ {/* 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}
+
+
+
+ {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 */}
-
-
- {/* 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}
-
-
-
- {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 && (
diff --git a/frontend/src/melodies/MelodyList.jsx b/frontend/src/melodies/MelodyList.jsx
index fa95248..3b0b0d0 100644
--- a/frontend/src/melodies/MelodyList.jsx
+++ b/frontend/src/melodies/MelodyList.jsx
@@ -16,6 +16,7 @@ const MELODY_TONES = ["", "normal", "festive", "cheerful", "lamentation"];
// All available columns with their defaults
const ALL_COLUMNS = [
+ { key: "status", label: "Status", defaultOn: true },
{ key: "color", label: "Color", defaultOn: true },
{ key: "name", label: "Name", defaultOn: true, alwaysOn: true },
{ key: "description", label: "Description", defaultOn: false },
@@ -56,9 +57,12 @@ export default function MelodyList() {
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [toneFilter, setToneFilter] = useState("");
+ const [statusFilter, setStatusFilter] = useState("");
const [displayLang, setDisplayLang] = useState("en");
const [melodySettings, setMelodySettings] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
+ const [unpublishTarget, setUnpublishTarget] = useState(null);
+ const [actionLoading, setActionLoading] = useState(null);
const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns);
const [showColumnPicker, setShowColumnPicker] = useState(false);
const columnPickerRef = useRef(null);
@@ -94,6 +98,7 @@ export default function MelodyList() {
if (search) params.set("search", search);
if (typeFilter) params.set("type", typeFilter);
if (toneFilter) params.set("tone", toneFilter);
+ if (statusFilter) params.set("status", statusFilter);
const qs = params.toString();
const data = await api.get(`/melodies${qs ? `?${qs}` : ""}`);
setMelodies(data.melodies);
@@ -107,7 +112,7 @@ export default function MelodyList() {
useEffect(() => {
fetchMelodies();
- }, [search, typeFilter, toneFilter]);
+ }, [search, typeFilter, toneFilter, statusFilter]);
const handleDelete = async () => {
if (!deleteTarget) return;
@@ -121,6 +126,33 @@ export default function MelodyList() {
}
};
+ const handlePublish = async (row) => {
+ setActionLoading(row.id);
+ try {
+ await api.post(`/melodies/${row.id}/publish`);
+ fetchMelodies();
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ const handleUnpublish = async () => {
+ if (!unpublishTarget) return;
+ setActionLoading(unpublishTarget.id);
+ try {
+ await api.post(`/melodies/${unpublishTarget.id}/unpublish`);
+ setUnpublishTarget(null);
+ fetchMelodies();
+ } catch (err) {
+ setError(err.message);
+ setUnpublishTarget(null);
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
const toggleColumn = (key) => {
const col = ALL_COLUMNS.find((c) => c.key === key);
if (col?.alwaysOn) return;
@@ -142,6 +174,18 @@ export default function MelodyList() {
const info = row.information || {};
const ds = row.default_settings || {};
switch (key) {
+ case "status":
+ return (
+
+ {row.status === "published" ? "Live" : "Draft"}
+
+ );
case "color":
return info.color ? (
))}
+
{languages.length > 1 && (