update: overhauled firmware ui. Added public flash page.

This commit is contained in:
2026-03-18 17:49:40 +02:00
parent 4381a6681d
commit d0ac4f1d91
45 changed files with 6798 additions and 1723 deletions

View File

@@ -5,23 +5,34 @@ from database import get_db
logger = logging.getLogger("builder.database")
async def insert_built_melody(melody_id: str, name: str, pid: str, steps: str) -> None:
async def insert_built_melody(melody_id: str, name: str, pid: str, steps: str, is_builtin: bool = False) -> None:
db = await get_db()
await db.execute(
"""INSERT INTO built_melodies (id, name, pid, steps, assigned_melody_ids)
VALUES (?, ?, ?, ?, ?)""",
(melody_id, name, pid, steps, json.dumps([])),
"""INSERT INTO built_melodies (id, name, pid, steps, assigned_melody_ids, is_builtin)
VALUES (?, ?, ?, ?, ?, ?)""",
(melody_id, name, pid, steps, json.dumps([]), 1 if is_builtin else 0),
)
await db.commit()
async def update_built_melody(melody_id: str, name: str, pid: str, steps: str) -> None:
async def update_built_melody(melody_id: str, name: str, pid: str, steps: str, is_builtin: bool = False) -> None:
db = await get_db()
await db.execute(
"""UPDATE built_melodies
SET name = ?, pid = ?, steps = ?, updated_at = datetime('now')
SET name = ?, pid = ?, steps = ?, is_builtin = ?, updated_at = datetime('now')
WHERE id = ?""",
(name, pid, steps, melody_id),
(name, pid, steps, 1 if is_builtin else 0, melody_id),
)
await db.commit()
async def update_builtin_flag(melody_id: str, is_builtin: bool) -> None:
db = await get_db()
await db.execute(
"""UPDATE built_melodies
SET is_builtin = ?, updated_at = datetime('now')
WHERE id = ?""",
(1 if is_builtin else 0, melody_id),
)
await db.commit()
@@ -68,6 +79,7 @@ async def get_built_melody(melody_id: str) -> dict | None:
return None
row = dict(rows[0])
row["assigned_melody_ids"] = json.loads(row["assigned_melody_ids"] or "[]")
row["is_builtin"] = bool(row.get("is_builtin", 0))
return row
@@ -80,6 +92,7 @@ async def list_built_melodies() -> list[dict]:
for row in rows:
r = dict(row)
r["assigned_melody_ids"] = json.loads(r["assigned_melody_ids"] or "[]")
r["is_builtin"] = bool(r.get("is_builtin", 0))
results.append(r)
return results

View File

@@ -6,12 +6,14 @@ class BuiltMelodyCreate(BaseModel):
name: str
pid: str
steps: str # raw step string e.g. "1,2,2+1,1,2,3+1"
is_builtin: bool = False
class BuiltMelodyUpdate(BaseModel):
name: Optional[str] = None
pid: Optional[str] = None
steps: Optional[str] = None
is_builtin: Optional[bool] = None
class BuiltMelodyInDB(BaseModel):
@@ -19,6 +21,7 @@ class BuiltMelodyInDB(BaseModel):
name: str
pid: str
steps: str
is_builtin: bool = False
binary_path: Optional[str] = None
binary_url: Optional[str] = None
progmem_code: Optional[str] = None

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, PlainTextResponse
from auth.models import TokenPayload
from auth.dependencies import require_permission
from builder.models import (
@@ -20,6 +20,7 @@ async def list_built_melodies(
melodies = await service.list_built_melodies()
return BuiltMelodyListResponse(melodies=melodies, total=len(melodies))
@router.get("/for-melody/{firestore_melody_id}")
async def get_for_firestore_melody(
firestore_melody_id: str,
@@ -32,6 +33,14 @@ async def get_for_firestore_melody(
return result.model_dump()
@router.get("/generate-builtin-list")
async def generate_builtin_list(
_user: TokenPayload = Depends(require_permission("melodies", "view")),
):
"""Generate a C++ header with PROGMEM arrays for all is_builtin archetypes."""
code = await service.generate_builtin_list()
return PlainTextResponse(content=code, media_type="text/plain")
@router.get("/{melody_id}", response_model=BuiltMelodyInDB)
async def get_built_melody(
@@ -66,6 +75,15 @@ async def delete_built_melody(
await service.delete_built_melody(melody_id)
@router.post("/{melody_id}/toggle-builtin", response_model=BuiltMelodyInDB)
async def toggle_builtin(
melody_id: str,
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
):
"""Toggle the is_builtin flag for an archetype."""
return await service.toggle_builtin(melody_id)
@router.post("/{melody_id}/build-binary", response_model=BuiltMelodyInDB)
async def build_binary(
melody_id: str,

View File

@@ -32,6 +32,7 @@ def _row_to_built_melody(row: dict) -> BuiltMelodyInDB:
name=row["name"],
pid=row["pid"],
steps=row["steps"],
is_builtin=row.get("is_builtin", False),
binary_path=binary_path,
binary_url=binary_url,
progmem_code=row.get("progmem_code"),
@@ -151,8 +152,12 @@ async def create_built_melody(data: BuiltMelodyCreate) -> BuiltMelodyInDB:
name=data.name,
pid=data.pid,
steps=data.steps,
is_builtin=data.is_builtin,
)
return await get_built_melody(melody_id)
# Auto-build binary and builtin code on creation
result = await get_built_melody(melody_id)
result = await _do_build(melody_id)
return result
async def update_built_melody(melody_id: str, data: BuiltMelodyUpdate) -> BuiltMelodyInDB:
@@ -163,11 +168,22 @@ async def update_built_melody(melody_id: str, data: BuiltMelodyUpdate) -> BuiltM
new_name = data.name if data.name is not None else row["name"]
new_pid = data.pid if data.pid is not None else row["pid"]
new_steps = data.steps if data.steps is not None else row["steps"]
new_is_builtin = data.is_builtin if data.is_builtin is not None else row.get("is_builtin", False)
await _check_unique(new_name, new_pid or "", exclude_id=melody_id)
await db.update_built_melody(melody_id, name=new_name, pid=new_pid, steps=new_steps)
return await get_built_melody(melody_id)
steps_changed = (data.steps is not None) and (data.steps != row["steps"])
await db.update_built_melody(melody_id, name=new_name, pid=new_pid, steps=new_steps, is_builtin=new_is_builtin)
# If steps changed, flag all assigned melodies as outdated, then rebuild
if steps_changed:
assigned_ids = row.get("assigned_melody_ids", [])
if assigned_ids:
await _flag_melodies_outdated(assigned_ids, True)
# Auto-rebuild binary and builtin code on every save
return await _do_build(melody_id)
async def delete_built_melody(melody_id: str) -> None:
@@ -175,6 +191,11 @@ async def delete_built_melody(melody_id: str) -> None:
if not row:
raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found")
# Flag all assigned melodies as outdated before deleting
assigned_ids = row.get("assigned_melody_ids", [])
if assigned_ids:
await _flag_melodies_outdated(assigned_ids, True)
# Delete the .bsm file if it exists
if row.get("binary_path"):
bsm_path = Path(row["binary_path"])
@@ -184,10 +205,26 @@ async def delete_built_melody(melody_id: str) -> None:
await db.delete_built_melody(melody_id)
async def toggle_builtin(melody_id: str) -> BuiltMelodyInDB:
"""Toggle the is_builtin flag for an archetype."""
row = await db.get_built_melody(melody_id)
if not row:
raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found")
new_value = not row.get("is_builtin", False)
await db.update_builtin_flag(melody_id, new_value)
return await get_built_melody(melody_id)
# ============================================================================
# Build Actions
# ============================================================================
async def _do_build(melody_id: str) -> BuiltMelodyInDB:
"""Internal: build both binary and PROGMEM code, return updated record."""
await build_binary(melody_id)
return await build_builtin_code(melody_id)
async def build_binary(melody_id: str) -> BuiltMelodyInDB:
"""Parse steps and write a .bsm binary file to storage."""
row = await db.get_built_melody(melody_id)
@@ -236,6 +273,48 @@ async def get_binary_path(melody_id: str) -> Optional[Path]:
return path
async def generate_builtin_list() -> str:
"""Generate a C++ header with PROGMEM arrays for all is_builtin archetypes."""
rows = await db.list_built_melodies()
builtin_rows = [r for r in rows if r.get("is_builtin")]
if not builtin_rows:
return "// No built-in archetypes defined.\n"
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
parts = [
f"// Auto-generated Built-in Archetype List",
f"// Generated: {timestamp}",
f"// Total built-ins: {len(builtin_rows)}",
"",
"#pragma once",
"#include <avr/pgmspace.h>",
"",
]
entry_refs = []
for row in builtin_rows:
values = steps_string_to_values(row["steps"])
array_name = f"melody_builtin_{row['name'].lower().replace(' ', '_')}"
display_name = row["name"].replace("_", " ").title()
pid = row.get("pid") or f"builtin_{row['name'].lower()}"
parts.append(f"// {display_name} | PID: {pid} | Steps: {len(values)}")
parts.append(format_melody_array(row["name"].lower().replace(" ", "_"), values))
parts.append("")
entry_refs.append((display_name, pid, array_name, len(values)))
# Generate MELODY_LIBRARY array
parts.append("// --- MELODY_LIBRARY entries ---")
parts.append("// Add these to your firmware's MELODY_LIBRARY[] array:")
parts.append("// {")
for display_name, pid, array_name, step_count in entry_refs:
parts.append(f'// {{ "{display_name}", "{pid}", {array_name}, {step_count} }},')
parts.append("// };")
return "\n".join(parts)
# ============================================================================
# Assignment
# ============================================================================
@@ -251,6 +330,9 @@ async def assign_to_melody(built_id: str, firestore_melody_id: str) -> BuiltMelo
assigned.append(firestore_melody_id)
await db.update_assigned_melody_ids(built_id, assigned)
# Clear outdated flag on the melody being assigned
await _flag_melodies_outdated([firestore_melody_id], False)
return await get_built_melody(built_id)
@@ -262,6 +344,10 @@ async def unassign_from_melody(built_id: str, firestore_melody_id: str) -> Built
assigned = [mid for mid in row.get("assigned_melody_ids", []) if mid != firestore_melody_id]
await db.update_assigned_melody_ids(built_id, assigned)
# Flag the melody as outdated since it no longer has an archetype
await _flag_melodies_outdated([firestore_melody_id], True)
return await get_built_melody(built_id)
@@ -272,3 +358,48 @@ async def get_built_melody_for_firestore_id(firestore_melody_id: str) -> Optiona
if firestore_melody_id in row.get("assigned_melody_ids", []):
return _row_to_built_melody(row)
return None
# ============================================================================
# Outdated Flag Helpers
# ============================================================================
async def _flag_melodies_outdated(melody_ids: List[str], outdated: bool) -> None:
"""Set or clear the outdated_archetype flag on a list of Firestore melody IDs.
This updates both SQLite (melody_drafts) and Firestore (published melodies).
We import inline to avoid circular imports.
"""
if not melody_ids:
return
try:
from melodies import database as melody_db
from shared.firebase import get_db as get_firestore
except ImportError:
logger.warning("Could not import melody/firebase modules — skipping outdated flag update")
return
firestore_db = get_firestore()
for melody_id in melody_ids:
try:
row = await melody_db.get_melody(melody_id)
if not row:
continue
data = row["data"]
info = dict(data.get("information", {}))
info["outdated_archetype"] = outdated
data["information"] = info
await melody_db.update_melody(melody_id, data)
# If published, also update Firestore
if row.get("status") == "published":
doc_ref = firestore_db.collection("melodies").document(melody_id)
doc_ref.update({"information.outdated_archetype": outdated})
logger.info(f"Set outdated_archetype={outdated} on melody {melody_id}")
except Exception as e:
logger.error(f"Failed to set outdated flag on melody {melody_id}: {e}")

View File

@@ -207,6 +207,7 @@ async def init_db():
"ALTER TABLE crm_media ADD COLUMN thumbnail_path TEXT",
"ALTER TABLE crm_quotation_items ADD COLUMN description_en TEXT",
"ALTER TABLE crm_quotation_items ADD COLUMN description_gr TEXT",
"ALTER TABLE built_melodies ADD COLUMN is_builtin INTEGER NOT NULL DEFAULT 0",
]
for m in _migrations:
try:

View File

@@ -126,6 +126,12 @@ class DeviceCreate(BaseModel):
websocket_url: str = ""
churchAssistantURL: str = ""
staffNotes: str = ""
hw_family: str = ""
hw_revision: str = ""
tags: List[str] = []
serial_number: str = ""
customer_id: str = ""
mfg_status: str = ""
class DeviceUpdate(BaseModel):
@@ -145,10 +151,16 @@ class DeviceUpdate(BaseModel):
websocket_url: Optional[str] = None
churchAssistantURL: Optional[str] = None
staffNotes: Optional[str] = None
hw_family: Optional[str] = None
hw_revision: Optional[str] = None
tags: Optional[List[str]] = None
customer_id: Optional[str] = None
mfg_status: Optional[str] = None
class DeviceInDB(DeviceCreate):
id: str
# Legacy field — kept for backwards compat; new docs use serial_number
device_id: str = ""
@@ -157,6 +169,15 @@ class DeviceListResponse(BaseModel):
total: int
class DeviceNoteCreate(BaseModel):
content: str
created_by: str = ""
class DeviceNoteUpdate(BaseModel):
content: str
class DeviceUserInfo(BaseModel):
"""User info resolved from device_users sub-collection or user_list."""
user_id: str = ""

View File

@@ -1,17 +1,25 @@
from fastapi import APIRouter, Depends, Query
from typing import Optional
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, Query, HTTPException
from typing import Optional, List
from pydantic import BaseModel
from auth.models import TokenPayload
from auth.dependencies import require_permission
from devices.models import (
DeviceCreate, DeviceUpdate, DeviceInDB, DeviceListResponse,
DeviceUsersResponse, DeviceUserInfo,
DeviceNoteCreate, DeviceNoteUpdate,
)
from devices import service
import database as mqtt_db
from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse
from shared.firebase import get_db as get_firestore
router = APIRouter(prefix="/api/devices", tags=["devices"])
NOTES_COLLECTION = "notes"
CRM_COLLECTION = "crm_customers"
@router.get("", response_model=DeviceListResponse)
async def list_devices(
@@ -79,3 +87,375 @@ async def get_device_alerts(
"""Return the current active alert set for a device. Empty list means fully healthy."""
rows = await mqtt_db.get_alerts(device_id)
return DeviceAlertsResponse(alerts=[DeviceAlertEntry(**r) for r in rows])
# ─────────────────────────────────────────────────────────────────────────────
# Device Notes
# ─────────────────────────────────────────────────────────────────────────────
@router.get("/{device_id}/notes")
async def list_device_notes(
device_id: str,
_user: TokenPayload = Depends(require_permission("devices", "view")),
):
"""List all notes for a device."""
db = get_firestore()
docs = db.collection(NOTES_COLLECTION).where("device_id", "==", device_id).order_by("created_at").stream()
notes = []
for doc in docs:
note = doc.to_dict()
note["id"] = doc.id
# Convert Firestore Timestamps to ISO strings
for f in ("created_at", "updated_at"):
if hasattr(note.get(f), "isoformat"):
note[f] = note[f].isoformat()
notes.append(note)
return {"notes": notes, "total": len(notes)}
@router.post("/{device_id}/notes", status_code=201)
async def create_device_note(
device_id: str,
body: DeviceNoteCreate,
_user: TokenPayload = Depends(require_permission("devices", "edit")),
):
"""Create a new note for a device."""
db = get_firestore()
now = datetime.utcnow()
note_id = str(uuid.uuid4())
note_data = {
"device_id": device_id,
"content": body.content,
"created_by": body.created_by or _user.name or "",
"created_at": now,
"updated_at": now,
}
db.collection(NOTES_COLLECTION).document(note_id).set(note_data)
note_data["id"] = note_id
note_data["created_at"] = now.isoformat()
note_data["updated_at"] = now.isoformat()
return note_data
@router.put("/{device_id}/notes/{note_id}")
async def update_device_note(
device_id: str,
note_id: str,
body: DeviceNoteUpdate,
_user: TokenPayload = Depends(require_permission("devices", "edit")),
):
"""Update an existing device note."""
db = get_firestore()
doc_ref = db.collection(NOTES_COLLECTION).document(note_id)
doc = doc_ref.get()
if not doc.exists or doc.to_dict().get("device_id") != device_id:
raise HTTPException(status_code=404, detail="Note not found")
now = datetime.utcnow()
doc_ref.update({"content": body.content, "updated_at": now})
updated = doc.to_dict()
updated["id"] = note_id
updated["content"] = body.content
updated["updated_at"] = now.isoformat()
if hasattr(updated.get("created_at"), "isoformat"):
updated["created_at"] = updated["created_at"].isoformat()
return updated
@router.delete("/{device_id}/notes/{note_id}", status_code=204)
async def delete_device_note(
device_id: str,
note_id: str,
_user: TokenPayload = Depends(require_permission("devices", "edit")),
):
"""Delete a device note."""
db = get_firestore()
doc_ref = db.collection(NOTES_COLLECTION).document(note_id)
doc = doc_ref.get()
if not doc.exists or doc.to_dict().get("device_id") != device_id:
raise HTTPException(status_code=404, detail="Note not found")
doc_ref.delete()
# ─────────────────────────────────────────────────────────────────────────────
# Device Tags
# ─────────────────────────────────────────────────────────────────────────────
class TagsUpdate(BaseModel):
tags: List[str]
@router.put("/{device_id}/tags", response_model=DeviceInDB)
async def update_device_tags(
device_id: str,
body: TagsUpdate,
_user: TokenPayload = Depends(require_permission("devices", "edit")),
):
"""Replace the tags list for a device."""
return service.update_device(device_id, DeviceUpdate(tags=body.tags))
# ─────────────────────────────────────────────────────────────────────────────
# Assign Device to Customer
# ─────────────────────────────────────────────────────────────────────────────
class CustomerSearchResult(BaseModel):
id: str
name: str
email: str
organization: str = ""
class AssignCustomerBody(BaseModel):
customer_id: str
label: str = ""
@router.get("/{device_id}/customer-search")
async def search_customers_for_device(
device_id: str,
q: str = Query(""),
_user: TokenPayload = Depends(require_permission("devices", "view")),
):
"""Search customers by name, email, phone, org, or tags, returning top 20 matches."""
db = get_firestore()
docs = db.collection(CRM_COLLECTION).stream()
results = []
q_lower = q.lower().strip()
for doc in docs:
data = doc.to_dict()
name = data.get("name", "") or ""
surname = data.get("surname", "") or ""
email = data.get("email", "") or ""
organization = data.get("organization", "") or ""
phone = data.get("phone", "") or ""
tags = " ".join(data.get("tags", []) or [])
location = data.get("location") or {}
city = location.get("city", "") or ""
searchable = f"{name} {surname} {email} {organization} {phone} {tags} {city}".lower()
if not q_lower or q_lower in searchable:
results.append({
"id": doc.id,
"name": name,
"surname": surname,
"email": email,
"organization": organization,
"city": city,
})
if len(results) >= 20:
break
return {"results": results}
@router.post("/{device_id}/assign-customer")
async def assign_device_to_customer(
device_id: str,
body: AssignCustomerBody,
_user: TokenPayload = Depends(require_permission("devices", "edit")),
):
"""Assign a device to a customer.
- Sets owner field on the device document.
- Adds a console_device entry to the customer's owned_items list.
"""
db = get_firestore()
# Verify device exists
device = service.get_device(device_id)
# Get customer
customer_ref = db.collection(CRM_COLLECTION).document(body.customer_id)
customer_doc = customer_ref.get()
if not customer_doc.exists:
raise HTTPException(status_code=404, detail="Customer not found")
customer_data = customer_doc.to_dict()
customer_email = customer_data.get("email", "")
# Update device: owner email + customer_id
device_ref = db.collection("devices").document(device_id)
device_ref.update({"owner": customer_email, "customer_id": body.customer_id})
# Add to customer owned_items (avoid duplicates)
owned_items = customer_data.get("owned_items", []) or []
already_assigned = any(
item.get("type") == "console_device" and item.get("console_device", {}).get("device_id") == device_id
for item in owned_items
)
if not already_assigned:
owned_items.append({
"type": "console_device",
"console_device": {
"device_id": device_id,
"label": body.label or device.device_name or device_id,
}
})
customer_ref.update({"owned_items": owned_items})
return {"status": "assigned", "device_id": device_id, "customer_id": body.customer_id}
@router.delete("/{device_id}/assign-customer", status_code=204)
async def unassign_device_from_customer(
device_id: str,
customer_id: str = Query(...),
_user: TokenPayload = Depends(require_permission("devices", "edit")),
):
"""Remove device assignment from a customer."""
db = get_firestore()
# Clear customer_id on device
device_ref = db.collection("devices").document(device_id)
device_ref.update({"customer_id": ""})
# Remove from customer owned_items
customer_ref = db.collection(CRM_COLLECTION).document(customer_id)
customer_doc = customer_ref.get()
if customer_doc.exists:
customer_data = customer_doc.to_dict()
owned_items = [
item for item in (customer_data.get("owned_items") or [])
if not (item.get("type") == "console_device" and item.get("console_device", {}).get("device_id") == device_id)
]
customer_ref.update({"owned_items": owned_items})
# ─────────────────────────────────────────────────────────────────────────────
# Customer detail (for Owner display in fleet)
# ─────────────────────────────────────────────────────────────────────────────
@router.get("/{device_id}/customer")
async def get_device_customer(
device_id: str,
_user: TokenPayload = Depends(require_permission("devices", "view")),
):
"""Return basic customer details for a device's assigned customer_id."""
db = get_firestore()
device_ref = db.collection("devices").document(device_id)
device_doc = device_ref.get()
if not device_doc.exists:
raise HTTPException(status_code=404, detail="Device not found")
device_data = device_doc.to_dict() or {}
customer_id = device_data.get("customer_id")
if not customer_id:
return {"customer": None}
customer_doc = db.collection(CRM_COLLECTION).document(customer_id).get()
if not customer_doc.exists:
return {"customer": None}
cd = customer_doc.to_dict() or {}
return {
"customer": {
"id": customer_doc.id,
"name": cd.get("name") or "",
"email": cd.get("email") or "",
"organization": cd.get("organization") or "",
"phone": cd.get("phone") or "",
}
}
# ─────────────────────────────────────────────────────────────────────────────
# User list management (for Manage tab — assign/remove users from user_list)
# ─────────────────────────────────────────────────────────────────────────────
class UserSearchResult(BaseModel):
id: str
display_name: str = ""
email: str = ""
phone: str = ""
photo_url: str = ""
@router.get("/{device_id}/user-search")
async def search_users_for_device(
device_id: str,
q: str = Query(""),
_user: TokenPayload = Depends(require_permission("devices", "view")),
):
"""Search the users collection by name, email, or phone."""
db = get_firestore()
docs = db.collection("users").stream()
results = []
q_lower = q.lower().strip()
for doc in docs:
data = doc.to_dict() or {}
name = (data.get("display_name") or "").lower()
email = (data.get("email") or "").lower()
phone = (data.get("phone") or "").lower()
if not q_lower or q_lower in name or q_lower in email or q_lower in phone:
results.append({
"id": doc.id,
"display_name": data.get("display_name") or "",
"email": data.get("email") or "",
"phone": data.get("phone") or "",
"photo_url": data.get("photo_url") or "",
})
if len(results) >= 20:
break
return {"results": results}
class AddUserBody(BaseModel):
user_id: str
@router.post("/{device_id}/user-list", status_code=200)
async def add_user_to_device(
device_id: str,
body: AddUserBody,
_user: TokenPayload = Depends(require_permission("devices", "edit")),
):
"""Add a user reference to the device's user_list field."""
db = get_firestore()
device_ref = db.collection("devices").document(device_id)
device_doc = device_ref.get()
if not device_doc.exists:
raise HTTPException(status_code=404, detail="Device not found")
# Verify user exists
user_doc = db.collection("users").document(body.user_id).get()
if not user_doc.exists:
raise HTTPException(status_code=404, detail="User not found")
data = device_doc.to_dict() or {}
user_list = data.get("user_list", []) or []
# Avoid duplicates — check both string paths and DocumentReferences
from google.cloud.firestore_v1 import DocumentReference as DocRef
existing_ids = set()
for entry in user_list:
if isinstance(entry, DocRef):
existing_ids.add(entry.id)
elif isinstance(entry, str):
existing_ids.add(entry.split("/")[-1])
if body.user_id not in existing_ids:
user_ref = db.collection("users").document(body.user_id)
user_list.append(user_ref)
device_ref.update({"user_list": user_list})
return {"status": "added", "user_id": body.user_id}
@router.delete("/{device_id}/user-list/{user_id}", status_code=200)
async def remove_user_from_device(
device_id: str,
user_id: str,
_user: TokenPayload = Depends(require_permission("devices", "edit")),
):
"""Remove a user reference from the device's user_list field."""
db = get_firestore()
device_ref = db.collection("devices").document(device_id)
device_doc = device_ref.get()
if not device_doc.exists:
raise HTTPException(status_code=404, detail="Device not found")
data = device_doc.to_dict() or {}
user_list = data.get("user_list", []) or []
# Remove any entry that resolves to this user_id
new_list = [
entry for entry in user_list
if not (isinstance(entry, str) and entry.split("/")[-1] == user_id)
]
device_ref.update({"user_list": new_list})
return {"status": "removed", "user_id": user_id}

View File

@@ -52,10 +52,11 @@ def _generate_serial_number() -> str:
def _ensure_unique_serial(db) -> str:
"""Generate a serial number and verify it doesn't already exist in Firestore."""
existing_sns = set()
for doc in db.collection(COLLECTION).select(["device_id"]).stream():
for doc in db.collection(COLLECTION).select(["serial_number"]).stream():
data = doc.to_dict()
if data.get("device_id"):
existing_sns.add(data["device_id"])
sn = data.get("serial_number") or data.get("device_id")
if sn:
existing_sns.add(sn)
for _ in range(100): # safety limit
sn = _generate_serial_number()
@@ -95,18 +96,40 @@ def _sanitize_dict(d: dict) -> dict:
return result
def _auto_upgrade_claimed(doc_ref, data: dict) -> dict:
"""If the device has entries in user_list and isn't already claimed/decommissioned,
upgrade mfg_status to 'claimed' automatically and return the updated data dict."""
current_status = data.get("mfg_status", "")
if current_status in ("claimed", "decommissioned"):
return data
user_list = data.get("user_list", []) or []
if user_list:
doc_ref.update({"mfg_status": "claimed"})
data = dict(data)
data["mfg_status"] = "claimed"
return data
def _doc_to_device(doc) -> DeviceInDB:
"""Convert a Firestore document snapshot to a DeviceInDB model."""
data = _sanitize_dict(doc.to_dict())
"""Convert a Firestore document snapshot to a DeviceInDB model.
Also auto-upgrades mfg_status to 'claimed' if user_list is non-empty.
"""
raw = doc.to_dict()
raw = _auto_upgrade_claimed(doc.reference, raw)
data = _sanitize_dict(raw)
return DeviceInDB(id=doc.id, **data)
FLEET_STATUSES = {"sold", "claimed"}
def list_devices(
search: str | None = None,
online_only: bool | None = None,
subscription_tier: str | None = None,
) -> list[DeviceInDB]:
"""List devices with optional filters."""
"""List fleet devices (sold + claimed only) with optional filters."""
db = get_db()
ref = db.collection(COLLECTION)
query = ref
@@ -118,6 +141,14 @@ def list_devices(
results = []
for doc in docs:
raw = doc.to_dict() or {}
# Only include sold/claimed devices in the fleet view.
# Legacy devices without mfg_status are included to avoid breaking old data.
mfg_status = raw.get("mfg_status")
if mfg_status and mfg_status not in FLEET_STATUSES:
continue
device = _doc_to_device(doc)
# Client-side filters
@@ -128,7 +159,7 @@ def list_devices(
search_lower = search.lower()
name_match = search_lower in (device.device_name or "").lower()
location_match = search_lower in (device.device_location or "").lower()
sn_match = search_lower in (device.device_id or "").lower()
sn_match = search_lower in (device.serial_number or "").lower()
if not (name_match or location_match or sn_match):
continue

View File

@@ -4,7 +4,7 @@ from shared.firebase import get_db
from shared.exceptions import NotFoundError
from equipment.models import NoteCreate, NoteUpdate, NoteInDB
COLLECTION = "equipment_notes"
COLLECTION = "notes"
VALID_CATEGORIES = {"general", "maintenance", "installation", "issue", "action_item", "other"}

View File

@@ -11,7 +11,7 @@ class UpdateType(str, Enum):
class FirmwareVersion(BaseModel):
id: str
hw_type: str # e.g. "vesper", "vesper_plus", "vesper_pro"
hw_type: str # e.g. "vesper", "vesper_plus", "vesper_pro", "bespoke"
channel: str # "stable", "beta", "alpha", "testing"
version: str # semver e.g. "1.5"
filename: str
@@ -20,8 +20,10 @@ class FirmwareVersion(BaseModel):
update_type: UpdateType = UpdateType.mandatory
min_fw_version: Optional[str] = None # minimum fw version required to install this
uploaded_at: str
notes: Optional[str] = None
changelog: Optional[str] = None
release_note: Optional[str] = None
is_latest: bool = False
bespoke_uid: Optional[str] = None # only set when hw_type == "bespoke"
class FirmwareListResponse(BaseModel):
@@ -57,7 +59,7 @@ class FirmwareMetadataResponse(BaseModel):
min_fw_version: Optional[str] = None
download_url: str
uploaded_at: str
notes: Optional[str] = None
release_note: Optional[str] = None
# Keep backwards-compatible alias

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form, HTTPException
from fastapi.responses import FileResponse, PlainTextResponse
from pydantic import BaseModel
from typing import Optional
import logging
@@ -22,7 +22,9 @@ async def upload_firmware(
version: str = Form(...),
update_type: UpdateType = Form(UpdateType.mandatory),
min_fw_version: Optional[str] = Form(None),
notes: Optional[str] = Form(None),
changelog: Optional[str] = Form(None),
release_note: Optional[str] = Form(None),
bespoke_uid: Optional[str] = Form(None),
file: UploadFile = File(...),
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
):
@@ -34,7 +36,9 @@ async def upload_firmware(
file_bytes=file_bytes,
update_type=update_type,
min_fw_version=min_fw_version,
notes=notes,
changelog=changelog,
release_note=release_note,
bespoke_uid=bespoke_uid,
)
@@ -61,6 +65,18 @@ def get_latest_firmware(
return service.get_latest(hw_type, channel, hw_version=hw_version, current_version=current_version)
@router.get("/{hw_type}/{channel}/latest/changelog", response_class=PlainTextResponse)
def get_latest_changelog(hw_type: str, channel: str):
"""Returns the full changelog for the latest firmware. Plain text."""
return service.get_latest_changelog(hw_type, channel)
@router.get("/{hw_type}/{channel}/{version}/info/changelog", response_class=PlainTextResponse)
def get_version_changelog(hw_type: str, channel: str, version: str):
"""Returns the full changelog for a specific firmware version. Plain text."""
return service.get_version_changelog(hw_type, channel, version)
@router.get("/{hw_type}/{channel}/{version}/info", response_model=FirmwareMetadataResponse)
def get_firmware_info(hw_type: str, channel: str, version: str):
"""Returns metadata for a specific firmware version.
@@ -80,6 +96,33 @@ def download_firmware(hw_type: str, channel: str, version: str):
)
@router.put("/{firmware_id}", response_model=FirmwareVersion)
async def edit_firmware(
firmware_id: str,
channel: Optional[str] = Form(None),
version: Optional[str] = Form(None),
update_type: Optional[UpdateType] = Form(None),
min_fw_version: Optional[str] = Form(None),
changelog: Optional[str] = Form(None),
release_note: Optional[str] = Form(None),
bespoke_uid: Optional[str] = Form(None),
file: Optional[UploadFile] = File(None),
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
):
file_bytes = await file.read() if file and file.filename else None
return service.edit_firmware(
doc_id=firmware_id,
channel=channel,
version=version,
update_type=update_type,
min_fw_version=min_fw_version,
changelog=changelog,
release_note=release_note,
bespoke_uid=bespoke_uid,
file_bytes=file_bytes,
)
@router.delete("/{firmware_id}", status_code=204)
def delete_firmware(
firmware_id: str,

View File

@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
COLLECTION = "firmware_versions"
VALID_HW_TYPES = {"vesper", "vesper_plus", "vesper_pro", "chronos", "chronos_pro", "agnus", "agnus_mini"}
VALID_HW_TYPES = {"vesper", "vesper_plus", "vesper_pro", "chronos", "chronos_pro", "agnus", "agnus_mini", "bespoke"}
VALID_CHANNELS = {"stable", "beta", "alpha", "testing"}
@@ -43,8 +43,10 @@ def _doc_to_firmware_version(doc) -> FirmwareVersion:
update_type=data.get("update_type", UpdateType.mandatory),
min_fw_version=data.get("min_fw_version"),
uploaded_at=uploaded_str,
notes=data.get("notes"),
changelog=data.get("changelog"),
release_note=data.get("release_note"),
is_latest=data.get("is_latest", False),
bespoke_uid=data.get("bespoke_uid"),
)
@@ -65,7 +67,7 @@ def _fw_to_metadata_response(fw: FirmwareVersion) -> FirmwareMetadataResponse:
min_fw_version=fw.min_fw_version,
download_url=download_url,
uploaded_at=fw.uploaded_at,
notes=fw.notes,
release_note=fw.release_note,
)
@@ -76,24 +78,50 @@ def upload_firmware(
file_bytes: bytes,
update_type: UpdateType = UpdateType.mandatory,
min_fw_version: str | None = None,
notes: str | None = None,
changelog: str | None = None,
release_note: str | None = None,
bespoke_uid: str | None = None,
) -> FirmwareVersion:
if hw_type not in VALID_HW_TYPES:
raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(sorted(VALID_HW_TYPES))}")
if channel not in VALID_CHANNELS:
raise HTTPException(status_code=400, detail=f"Invalid channel. Must be one of: {', '.join(sorted(VALID_CHANNELS))}")
if hw_type == "bespoke" and not bespoke_uid:
raise HTTPException(status_code=400, detail="bespoke_uid is required when hw_type is 'bespoke'")
db = get_db()
sha256 = hashlib.sha256(file_bytes).hexdigest()
now = datetime.now(timezone.utc)
# For bespoke firmware: if a firmware with the same bespoke_uid already exists,
# overwrite it (delete old doc + file, reuse same storage path keyed by uid).
if hw_type == "bespoke" and bespoke_uid:
existing_docs = list(
db.collection(COLLECTION)
.where("hw_type", "==", "bespoke")
.where("bespoke_uid", "==", bespoke_uid)
.stream()
)
for old_doc in existing_docs:
old_data = old_doc.to_dict() or {}
old_path = _storage_path("bespoke", old_data.get("channel", channel), old_data.get("version", version))
if old_path.exists():
old_path.unlink()
try:
old_path.parent.rmdir()
except OSError:
pass
old_doc.reference.delete()
dest = _storage_path(hw_type, channel, version)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(file_bytes)
sha256 = hashlib.sha256(file_bytes).hexdigest()
now = datetime.now(timezone.utc)
doc_id = str(uuid.uuid4())
db = get_db()
# Mark previous latest for this hw_type+channel as no longer latest
# (skip for bespoke — each bespoke_uid is its own independent firmware)
if hw_type != "bespoke":
prev_docs = (
db.collection(COLLECTION)
.where("hw_type", "==", hw_type)
@@ -115,8 +143,10 @@ def upload_firmware(
"update_type": update_type.value,
"min_fw_version": min_fw_version,
"uploaded_at": now,
"notes": notes,
"changelog": changelog,
"release_note": release_note,
"is_latest": True,
"bespoke_uid": bespoke_uid,
})
return _doc_to_firmware_version(doc_ref.get())
@@ -142,6 +172,8 @@ def list_firmware(
def get_latest(hw_type: str, channel: str, hw_version: str | None = None, current_version: str | None = None) -> FirmwareMetadataResponse:
if hw_type not in VALID_HW_TYPES:
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
if hw_type == "bespoke":
raise HTTPException(status_code=400, detail="Bespoke firmware is not served via auto-update. Use the direct download URL.")
if channel not in VALID_CHANNELS:
raise HTTPException(status_code=400, detail=f"Invalid channel '{channel}'")
@@ -182,6 +214,52 @@ def get_version_info(hw_type: str, channel: str, version: str) -> FirmwareMetada
return _fw_to_metadata_response(_doc_to_firmware_version(docs[0]))
def get_latest_changelog(hw_type: str, channel: str) -> str:
if hw_type not in VALID_HW_TYPES:
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
if channel not in VALID_CHANNELS:
raise HTTPException(status_code=400, detail=f"Invalid channel '{channel}'")
db = get_db()
docs = list(
db.collection(COLLECTION)
.where("hw_type", "==", hw_type)
.where("channel", "==", channel)
.where("is_latest", "==", True)
.limit(1)
.stream()
)
if not docs:
raise NotFoundError("Firmware")
fw = _doc_to_firmware_version(docs[0])
if not fw.changelog:
raise NotFoundError("Changelog")
return fw.changelog
def get_version_changelog(hw_type: str, channel: str, version: str) -> str:
if hw_type not in VALID_HW_TYPES:
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
if channel not in VALID_CHANNELS:
raise HTTPException(status_code=400, detail=f"Invalid channel '{channel}'")
db = get_db()
docs = list(
db.collection(COLLECTION)
.where("hw_type", "==", hw_type)
.where("channel", "==", channel)
.where("version", "==", version)
.limit(1)
.stream()
)
if not docs:
raise NotFoundError("Firmware version")
fw = _doc_to_firmware_version(docs[0])
if not fw.changelog:
raise NotFoundError("Changelog")
return fw.changelog
def get_firmware_path(hw_type: str, channel: str, version: str) -> Path:
path = _storage_path(hw_type, channel, version)
if not path.exists():
@@ -205,6 +283,82 @@ def record_ota_event(event_type: str, payload: dict[str, Any]) -> None:
logger.warning("Failed to persist OTA event (%s): %s", event_type, exc)
def edit_firmware(
doc_id: str,
channel: str | None = None,
version: str | None = None,
update_type: UpdateType | None = None,
min_fw_version: str | None = None,
changelog: str | None = None,
release_note: str | None = None,
bespoke_uid: str | None = None,
file_bytes: bytes | None = None,
) -> FirmwareVersion:
db = get_db()
doc_ref = db.collection(COLLECTION).document(doc_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("Firmware")
data = doc.to_dict() or {}
hw_type = data["hw_type"]
old_channel = data.get("channel", "")
old_version = data.get("version", "")
effective_channel = channel if channel is not None else old_channel
effective_version = version if version is not None else old_version
if channel is not None and channel not in VALID_CHANNELS:
raise HTTPException(status_code=400, detail=f"Invalid channel. Must be one of: {', '.join(sorted(VALID_CHANNELS))}")
updates: dict = {}
if channel is not None:
updates["channel"] = channel
if version is not None:
updates["version"] = version
if update_type is not None:
updates["update_type"] = update_type.value
if min_fw_version is not None:
updates["min_fw_version"] = min_fw_version if min_fw_version else None
if changelog is not None:
updates["changelog"] = changelog if changelog else None
if release_note is not None:
updates["release_note"] = release_note if release_note else None
if bespoke_uid is not None:
updates["bespoke_uid"] = bespoke_uid if bespoke_uid else None
if file_bytes is not None:
# Move binary if path changed
old_path = _storage_path(hw_type, old_channel, old_version)
new_path = _storage_path(hw_type, effective_channel, effective_version)
if old_path != new_path and old_path.exists():
old_path.unlink()
try:
old_path.parent.rmdir()
except OSError:
pass
new_path.parent.mkdir(parents=True, exist_ok=True)
new_path.write_bytes(file_bytes)
updates["sha256"] = hashlib.sha256(file_bytes).hexdigest()
updates["size_bytes"] = len(file_bytes)
elif (channel is not None and channel != old_channel) or (version is not None and version != old_version):
# Path changed but no new file — move existing binary
old_path = _storage_path(hw_type, old_channel, old_version)
new_path = _storage_path(hw_type, effective_channel, effective_version)
if old_path.exists() and old_path != new_path:
new_path.parent.mkdir(parents=True, exist_ok=True)
old_path.rename(new_path)
try:
old_path.parent.rmdir()
except OSError:
pass
if updates:
doc_ref.update(updates)
return _doc_to_firmware_version(doc_ref.get())
def delete_firmware(doc_id: str) -> None:
db = get_db()
doc_ref = db.collection(COLLECTION).document(doc_id)

View File

@@ -24,6 +24,7 @@ from crm.comms_router import router as crm_comms_router
from crm.media_router import router as crm_media_router
from crm.nextcloud_router import router as crm_nextcloud_router
from crm.quotations_router import router as crm_quotations_router
from public.router import router as public_router
from crm.nextcloud import close_client as close_nextcloud_client, keepalive_ping as nextcloud_keepalive
from crm.mail_accounts import get_mail_accounts
from mqtt.client import mqtt_manager
@@ -67,6 +68,7 @@ app.include_router(crm_comms_router)
app.include_router(crm_media_router)
app.include_router(crm_nextcloud_router)
app.include_router(crm_quotations_router)
app.include_router(public_router)
async def nextcloud_keepalive_loop():

View File

@@ -55,6 +55,13 @@ class MfgStatus(str, Enum):
decommissioned = "decommissioned"
class LifecycleEntry(BaseModel):
status_id: str
date: str # ISO 8601 UTC string
note: Optional[str] = None
set_by: Optional[str] = None
class BatchCreate(BaseModel):
board_type: BoardType
board_version: str = Field(
@@ -84,6 +91,9 @@ class DeviceInventoryItem(BaseModel):
owner: Optional[str] = None
assigned_to: Optional[str] = None
device_name: Optional[str] = None
lifecycle_history: Optional[List["LifecycleEntry"]] = None
customer_id: Optional[str] = None
user_list: Optional[List[str]] = None
class DeviceInventoryListResponse(BaseModel):
@@ -94,11 +104,19 @@ class DeviceInventoryListResponse(BaseModel):
class DeviceStatusUpdate(BaseModel):
status: MfgStatus
note: Optional[str] = None
force_claimed: bool = False
class DeviceAssign(BaseModel):
customer_email: str
customer_name: Optional[str] = None
customer_id: str
class CustomerSearchResult(BaseModel):
id: str
name: str = ""
email: str = ""
organization: str = ""
phone: str = ""
class RecentActivityItem(BaseModel):

View File

@@ -1,7 +1,8 @@
from fastapi import APIRouter, Depends, Query, HTTPException, UploadFile, File, Form
from fastapi import APIRouter, Depends, Query, HTTPException, UploadFile, File
from fastapi.responses import Response
from fastapi.responses import RedirectResponse
from typing import Optional
from pydantic import BaseModel
from auth.models import TokenPayload
from auth.dependencies import require_permission
@@ -14,9 +15,23 @@ from manufacturing.models import (
from manufacturing import service
from manufacturing import audit
from shared.exceptions import NotFoundError
from shared.firebase import get_db as get_firestore
class LifecycleEntryPatch(BaseModel):
index: int
date: Optional[str] = None
note: Optional[str] = None
class LifecycleEntryCreate(BaseModel):
status_id: str
date: Optional[str] = None
note: Optional[str] = None
VALID_FLASH_ASSETS = {"bootloader.bin", "partitions.bin"}
VALID_HW_TYPES_MFG = {"vesper", "vesper_plus", "vesper_pro", "agnus", "agnus_mini", "chronos", "chronos_pro"}
# Bespoke UIDs are dynamic — we allow any non-empty slug that doesn't clash with
# a standard hw_type name. The flash-asset upload endpoint checks this below.
router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"])
@@ -83,13 +98,75 @@ def get_device(
return service.get_device_by_sn(sn)
@router.get("/customers/search")
def search_customers(
q: str = Query(""),
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
):
"""Search CRM customers by name, email, phone, organization, or tags."""
results = service.search_customers(q)
return {"results": results}
@router.get("/customers/{customer_id}")
def get_customer(
customer_id: str,
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
):
"""Get a single CRM customer by ID."""
db = get_firestore()
doc = db.collection("crm_customers").document(customer_id).get()
if not doc.exists:
raise HTTPException(status_code=404, detail="Customer not found")
data = doc.to_dict() or {}
loc = data.get("location") or {}
city = loc.get("city") if isinstance(loc, dict) else None
return {
"id": doc.id,
"name": data.get("name") or "",
"surname": data.get("surname") or "",
"email": data.get("email") or "",
"organization": data.get("organization") or "",
"phone": data.get("phone") or "",
"city": city or "",
}
@router.patch("/devices/{sn}/status", response_model=DeviceInventoryItem)
async def update_status(
sn: str,
body: DeviceStatusUpdate,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
):
result = service.update_device_status(sn, body)
# Guard: claimed requires at least one user in user_list
# (allow if explicitly force_claimed=true, which the mfg UI sets after adding a user manually)
if body.status.value == "claimed":
db = get_firestore()
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
if docs:
data = docs[0].to_dict() or {}
user_list = data.get("user_list", []) or []
if not user_list and not getattr(body, "force_claimed", False):
raise HTTPException(
status_code=400,
detail="Cannot set status to 'claimed': device has no users in user_list. "
"Assign a user first, then set to Claimed.",
)
# Guard: sold requires a customer assigned
if body.status.value == "sold":
db = get_firestore()
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
if docs:
data = docs[0].to_dict() or {}
if not data.get("customer_id"):
raise HTTPException(
status_code=400,
detail="Cannot set status to 'sold' without an assigned customer. "
"Use the 'Assign to Customer' action first.",
)
result = service.update_device_status(sn, body, set_by=user.email)
await audit.log_action(
admin_user=user.email,
action="status_updated",
@@ -99,12 +176,91 @@ async def update_status(
return result
@router.patch("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem)
async def patch_lifecycle_entry(
sn: str,
body: LifecycleEntryPatch,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
):
"""Edit the date and/or note of a lifecycle history entry by index."""
db = get_firestore()
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
if not docs:
raise HTTPException(status_code=404, detail="Device not found")
doc_ref = docs[0].reference
data = docs[0].to_dict() or {}
history = data.get("lifecycle_history") or []
if body.index < 0 or body.index >= len(history):
raise HTTPException(status_code=400, detail="Invalid lifecycle entry index")
if body.date is not None:
history[body.index]["date"] = body.date
if body.note is not None:
history[body.index]["note"] = body.note
doc_ref.update({"lifecycle_history": history})
from manufacturing.service import _doc_to_inventory_item
return _doc_to_inventory_item(doc_ref.get())
@router.post("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem, status_code=201)
async def create_lifecycle_entry(
sn: str,
body: LifecycleEntryCreate,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
):
"""Create a lifecycle history entry for a step that has no entry yet (on-the-fly)."""
from datetime import datetime, timezone
db = get_firestore()
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
if not docs:
raise HTTPException(status_code=404, detail="Device not found")
doc_ref = docs[0].reference
data = docs[0].to_dict() or {}
history = data.get("lifecycle_history") or []
new_entry = {
"status_id": body.status_id,
"date": body.date or datetime.now(timezone.utc).isoformat(),
"note": body.note,
"set_by": user.email,
}
history.append(new_entry)
doc_ref.update({"lifecycle_history": history})
from manufacturing.service import _doc_to_inventory_item
return _doc_to_inventory_item(doc_ref.get())
@router.delete("/devices/{sn}/lifecycle/{index}", response_model=DeviceInventoryItem)
async def delete_lifecycle_entry(
sn: str,
index: int,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
):
"""Delete a lifecycle history entry by index. Cannot delete the entry for the current status."""
db = get_firestore()
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
if not docs:
raise HTTPException(status_code=404, detail="Device not found")
doc_ref = docs[0].reference
data = docs[0].to_dict() or {}
history = data.get("lifecycle_history") or []
if index < 0 or index >= len(history):
raise HTTPException(status_code=400, detail="Invalid lifecycle entry index")
current_status = data.get("mfg_status", "")
if history[index].get("status_id") == current_status:
raise HTTPException(status_code=400, detail="Cannot delete the entry for the current status. Change the status first.")
history.pop(index)
doc_ref.update({"lifecycle_history": history})
from manufacturing.service import _doc_to_inventory_item
return _doc_to_inventory_item(doc_ref.get())
@router.get("/devices/{sn}/nvs.bin")
async def download_nvs(
sn: str,
hw_type_override: Optional[str] = Query(None, description="Override hw_type written to NVS (for bespoke firmware)"),
hw_revision_override: Optional[str] = Query(None, description="Override hw_revision written to NVS (for bespoke firmware)"),
user: TokenPayload = Depends(require_permission("manufacturing", "view")),
):
binary = service.get_nvs_binary(sn)
binary = service.get_nvs_binary(sn, hw_type_override=hw_type_override, hw_revision_override=hw_revision_override)
await audit.log_action(
admin_user=user.email,
action="device_flashed",
@@ -123,12 +279,15 @@ async def assign_device(
body: DeviceAssign,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
):
try:
result = service.assign_device(sn, body)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
await audit.log_action(
admin_user=user.email,
action="device_assigned",
serial_number=sn,
detail={"customer_email": body.customer_email, "customer_name": body.customer_name},
detail={"customer_id": body.customer_id},
)
return result
@@ -201,8 +360,9 @@ async def upload_flash_asset(
and .pio/build/{env}/partitions.bin). Upload them once per hw_type after
each PlatformIO build that changes the partition layout.
"""
if hw_type not in VALID_HW_TYPES_MFG:
raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(sorted(VALID_HW_TYPES_MFG))}")
# hw_type can be a standard board type OR a bespoke UID (any non-empty slug)
if not hw_type or len(hw_type) > 128:
raise HTTPException(status_code=400, detail="Invalid hw_type/bespoke UID.")
if asset not in VALID_FLASH_ASSETS:
raise HTTPException(status_code=400, detail=f"Invalid asset. Must be one of: {', '.join(sorted(VALID_FLASH_ASSETS))}")
data = await file.read()
@@ -212,34 +372,38 @@ async def upload_flash_asset(
@router.get("/devices/{sn}/bootloader.bin")
def download_bootloader(
sn: str,
hw_type_override: Optional[str] = Query(None, description="Override hw_type for flash asset lookup (for bespoke firmware)"),
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
):
"""Return the bootloader.bin for this device's hw_type (flashed at 0x1000)."""
item = service.get_device_by_sn(sn)
hw_type = hw_type_override or item.hw_type
try:
data = service.get_flash_asset(item.hw_type, "bootloader.bin")
data = service.get_flash_asset(hw_type, "bootloader.bin")
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
return Response(
content=data,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="bootloader_{item.hw_type}.bin"'},
headers={"Content-Disposition": f'attachment; filename="bootloader_{hw_type}.bin"'},
)
@router.get("/devices/{sn}/partitions.bin")
def download_partitions(
sn: str,
hw_type_override: Optional[str] = Query(None, description="Override hw_type for flash asset lookup (for bespoke firmware)"),
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
):
"""Return the partitions.bin for this device's hw_type (flashed at 0x8000)."""
item = service.get_device_by_sn(sn)
hw_type = hw_type_override or item.hw_type
try:
data = service.get_flash_asset(item.hw_type, "partitions.bin")
data = service.get_flash_asset(hw_type, "partitions.bin")
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
return Response(
content=data,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="partitions_{item.hw_type}.bin"'},
headers={"Content-Disposition": f'attachment; filename="partitions_{hw_type}.bin"'},
)

View File

@@ -33,6 +33,18 @@ def _get_existing_sns(db) -> set:
return existing
def _resolve_user_list(raw_list: list) -> list[str]:
"""Convert user_list entries (DocumentReferences or path strings) to plain user ID strings."""
from google.cloud.firestore_v1 import DocumentReference
result = []
for entry in raw_list:
if isinstance(entry, DocumentReference):
result.append(entry.id)
elif isinstance(entry, str):
result.append(entry.split("/")[-1])
return result
def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
data = doc.to_dict() or {}
created_raw = data.get("created_at")
@@ -52,6 +64,9 @@ def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
owner=data.get("owner"),
assigned_to=data.get("assigned_to"),
device_name=data.get("device_name") or None,
lifecycle_history=data.get("lifecycle_history") or [],
customer_id=data.get("customer_id"),
user_list=_resolve_user_list(data.get("user_list") or []),
)
@@ -80,11 +95,19 @@ def create_batch(data: BatchCreate) -> BatchResponse:
"created_at": now,
"owner": None,
"assigned_to": None,
"users_list": [],
"user_list": [],
# Legacy fields left empty so existing device views don't break
"device_name": "",
"device_location": "",
"is_Online": False,
"lifecycle_history": [
{
"status_id": "manufactured",
"date": now.isoformat(),
"note": None,
"set_by": None,
}
],
})
serial_numbers.append(sn)
@@ -135,14 +158,31 @@ def get_device_by_sn(sn: str) -> DeviceInventoryItem:
return _doc_to_inventory_item(docs[0])
def update_device_status(sn: str, data: DeviceStatusUpdate) -> DeviceInventoryItem:
def update_device_status(sn: str, data: DeviceStatusUpdate, set_by: str | None = None) -> DeviceInventoryItem:
db = get_db()
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
if not docs:
raise NotFoundError("Device")
doc_ref = docs[0].reference
update = {"mfg_status": data.status.value}
doc_data = docs[0].to_dict() or {}
now = datetime.now(timezone.utc).isoformat()
history = doc_data.get("lifecycle_history") or []
# Append new lifecycle entry
new_entry = {
"status_id": data.status.value,
"date": now,
"note": data.note if data.note else None,
"set_by": set_by,
}
history.append(new_entry)
update = {
"mfg_status": data.status.value,
"lifecycle_history": history,
}
if data.note:
update["mfg_status_note"] = data.note
doc_ref.update(update)
@@ -150,47 +190,114 @@ def update_device_status(sn: str, data: DeviceStatusUpdate) -> DeviceInventoryIt
return _doc_to_inventory_item(doc_ref.get())
def get_nvs_binary(sn: str) -> bytes:
def get_nvs_binary(sn: str, hw_type_override: str | None = None, hw_revision_override: str | None = None) -> bytes:
item = get_device_by_sn(sn)
return generate_nvs_binary(
serial_number=item.serial_number,
hw_type=item.hw_type,
hw_version=item.hw_version,
hw_family=hw_type_override if hw_type_override else item.hw_type,
hw_revision=hw_revision_override if hw_revision_override else item.hw_version,
)
def assign_device(sn: str, data: DeviceAssign) -> DeviceInventoryItem:
from utils.email import send_device_assignment_invite
"""Assign a device to a customer by customer_id.
- Stores customer_id on the device doc.
- Adds the device to the customer's owned_items list.
- Sets mfg_status to 'sold' unless device is already 'claimed'.
"""
db = get_db()
CRM_COLLECTION = "crm_customers"
# Get device doc
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
if not docs:
raise NotFoundError("Device")
doc_data = docs[0].to_dict() or {}
doc_ref = docs[0].reference
doc_ref.update({
"owner": data.customer_email,
"assigned_to": data.customer_email,
"mfg_status": "sold",
current_status = doc_data.get("mfg_status", "manufactured")
# Get customer doc
customer_ref = db.collection(CRM_COLLECTION).document(data.customer_id)
customer_doc = customer_ref.get()
if not customer_doc.exists:
raise NotFoundError("Customer")
customer_data = customer_doc.to_dict() or {}
# Determine new status: don't downgrade claimed → sold
new_status = current_status if current_status == "claimed" else "sold"
now = datetime.now(timezone.utc).isoformat()
history = doc_data.get("lifecycle_history") or []
history.append({
"status_id": new_status,
"date": now,
"note": "Assigned to customer",
"set_by": None,
})
hw_type = doc_data.get("hw_type", "")
device_name = BOARD_TYPE_LABELS.get(hw_type, hw_type or "Device")
doc_ref.update({
"customer_id": data.customer_id,
"mfg_status": new_status,
"lifecycle_history": history,
})
try:
send_device_assignment_invite(
customer_email=data.customer_email,
serial_number=sn,
device_name=device_name,
customer_name=data.customer_name,
# Add to customer's owned_items (avoid duplicates)
owned_items = customer_data.get("owned_items", []) or []
device_doc_id = docs[0].id
already_assigned = any(
item.get("type") == "console_device"
and item.get("console_device", {}).get("device_id") == device_doc_id
for item in owned_items
)
except Exception as exc:
logger.error("Assignment succeeded but email failed for %s%s: %s", sn, data.customer_email, exc)
if not already_assigned:
device_name = doc_data.get("device_name") or BOARD_TYPE_LABELS.get(doc_data.get("hw_type", ""), sn)
owned_items.append({
"type": "console_device",
"console_device": {
"device_id": device_doc_id,
"serial_number": sn,
"label": device_name,
},
})
customer_ref.update({"owned_items": owned_items})
return _doc_to_inventory_item(doc_ref.get())
def search_customers(q: str) -> list:
"""Search crm_customers by name, email, phone, organization, or tags."""
db = get_db()
CRM_COLLECTION = "crm_customers"
docs = db.collection(CRM_COLLECTION).stream()
results = []
q_lower = q.lower().strip()
for doc in docs:
data = doc.to_dict() or {}
loc = data.get("location") or {}
loc = loc if isinstance(loc, dict) else {}
city = loc.get("city") or ""
searchable = " ".join(filter(None, [
data.get("name"), data.get("surname"),
data.get("email"), data.get("phone"), data.get("organization"),
loc.get("address"), loc.get("city"), loc.get("postal_code"),
loc.get("region"), loc.get("country"),
" ".join(data.get("tags") or []),
])).lower()
if not q_lower or q_lower in searchable:
results.append({
"id": doc.id,
"name": data.get("name") or "",
"surname": data.get("surname") or "",
"email": data.get("email") or "",
"organization": data.get("organization") or "",
"phone": data.get("phone") or "",
"city": city or "",
})
return results
def get_stats() -> ManufacturingStats:
db = get_db()
docs = list(db.collection(COLLECTION).stream())

View File

@@ -30,6 +30,7 @@ class MelodyInfo(BaseModel):
isTrueRing: bool = False
previewURL: str = ""
archetype_csv: Optional[str] = None
outdated_archetype: bool = False
class MelodyAttributes(BaseModel):

View File

@@ -146,6 +146,23 @@ async def get_files(
return service.get_storage_files(melody_id, melody.uid)
@router.patch("/{melody_id}/set-outdated", response_model=MelodyInDB)
async def set_outdated(
melody_id: str,
outdated: bool = Query(...),
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
):
"""Manually set or clear the outdated_archetype flag on a melody."""
melody = await service.get_melody(melody_id)
info = melody.information.model_dump()
info["outdated_archetype"] = outdated
return await service.update_melody(
melody_id,
MelodyUpdate(information=MelodyInfo(**info)),
actor_name=_user.name,
)
@router.get("/{melody_id}/download/binary")
async def download_binary_file(
melody_id: str,

View File

208
backend/public/router.py Normal file
View File

@@ -0,0 +1,208 @@
"""
Public (no-auth) endpoints for CloudFlash and feature gate checks.
"""
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from typing import List, Optional
from settings.public_features_service import get_public_features
from firmware.service import list_firmware
from utils.nvs_generator import generate as generate_nvs
from manufacturing.service import get_device_by_sn
from shared.exceptions import NotFoundError
router = APIRouter(prefix="/api/public", tags=["public"])
# ── Feature gate ──────────────────────────────────────────────────────────────
class CloudFlashStatus(BaseModel):
enabled: bool
@router.get("/cloudflash/status", response_model=CloudFlashStatus)
async def cloudflash_status():
"""Returns whether the CloudFlash public page is currently enabled."""
settings = get_public_features()
return CloudFlashStatus(enabled=settings.cloudflash_enabled)
def _require_cloudflash_enabled():
"""Raises 403 if CloudFlash is disabled."""
settings = get_public_features()
if not settings.cloudflash_enabled:
raise HTTPException(status_code=403, detail="CloudFlash is currently disabled.")
# ── Public firmware list ───────────────────────────────────────────────────────
class PublicFirmwareOption(BaseModel):
hw_type: str
hw_type_label: str
channel: str
version: str
download_url: str
HW_TYPE_LABELS = {
"vesper": "Vesper",
"vesper_plus": "Vesper Plus",
"vesper_pro": "Vesper Pro",
"agnus": "Agnus",
"agnus_mini": "Agnus Mini",
"chronos": "Chronos",
"chronos_pro": "Chronos Pro",
}
@router.get("/cloudflash/firmware", response_model=List[PublicFirmwareOption])
async def list_public_firmware():
"""
Returns all available firmware options (is_latest=True, non-bespoke, stable channel only).
No authentication required — used by the public CloudFlash page.
"""
_require_cloudflash_enabled()
all_fw = list_firmware()
options = []
for fw in all_fw:
if not fw.is_latest:
continue
if fw.hw_type == "bespoke":
continue
if fw.channel != "stable":
continue
options.append(PublicFirmwareOption(
hw_type=fw.hw_type,
hw_type_label=HW_TYPE_LABELS.get(fw.hw_type, fw.hw_type.replace("_", " ").title()),
channel=fw.channel,
version=fw.version,
download_url=f"/api/firmware/{fw.hw_type}/{fw.channel}/{fw.version}/firmware.bin",
))
# Sort by hw_type label
options.sort(key=lambda x: x.hw_type_label)
return options
# ── Public serial number validation ──────────────────────────────────────────
class SerialValidationResult(BaseModel):
valid: bool
hw_type: Optional[str] = None
hw_type_label: Optional[str] = None
hw_version: Optional[str] = None
@router.get("/cloudflash/validate-serial/{serial_number}", response_model=SerialValidationResult)
async def validate_serial(serial_number: str):
"""
Check whether a serial number exists in the device database.
Returns hw_type info if found so the frontend can confirm it matches the user's selection.
No sensitive device data is returned.
"""
_require_cloudflash_enabled()
sn = serial_number.strip().upper()
try:
device = get_device_by_sn(sn)
return SerialValidationResult(
valid=True,
hw_type=device.hw_type,
hw_type_label=HW_TYPE_LABELS.get(device.hw_type, device.hw_type.replace("_", " ").title()),
hw_version=device.hw_version,
)
except Exception:
return SerialValidationResult(valid=False)
# ── Public NVS generation ─────────────────────────────────────────────────────
class NvsRequest(BaseModel):
serial_number: str
hw_type: str
hw_revision: str
@router.post("/cloudflash/nvs.bin")
async def generate_public_nvs(body: NvsRequest):
"""
Generate an NVS binary for a given serial number + hardware info.
No authentication required — used by the public CloudFlash page for Full Wipe flash.
The serial number is provided by the user (they read it from the sticker on their device).
"""
_require_cloudflash_enabled()
sn = body.serial_number.strip().upper()
if not sn:
raise HTTPException(status_code=422, detail="Serial number is required.")
hw_type = body.hw_type.strip().lower()
hw_revision = body.hw_revision.strip()
if not hw_type or not hw_revision:
raise HTTPException(status_code=422, detail="hw_type and hw_revision are required.")
try:
nvs_bytes = generate_nvs(
serial_number=sn,
hw_family=hw_type,
hw_revision=hw_revision,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"NVS generation failed: {str(e)}")
return Response(
content=nvs_bytes,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{sn}_nvs.bin"'},
)
# ── Public flash assets (bootloader + partitions) ─────────────────────────────
@router.get("/cloudflash/{hw_type}/bootloader.bin")
async def get_public_bootloader(hw_type: str):
"""
Serve the bootloader binary for a given hw_type.
No authentication required — used by the public CloudFlash page.
"""
_require_cloudflash_enabled()
import os
from config import settings as cfg
from pathlib import Path
asset_path = Path(cfg.flash_assets_storage_path) / hw_type / "bootloader.bin"
if not asset_path.exists():
raise HTTPException(status_code=404, detail=f"Bootloader not found for {hw_type}.")
return Response(
content=asset_path.read_bytes(),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="bootloader_{hw_type}.bin"'},
)
@router.get("/cloudflash/{hw_type}/partitions.bin")
async def get_public_partitions(hw_type: str):
"""
Serve the partition table binary for a given hw_type.
No authentication required — used by the public CloudFlash page.
"""
_require_cloudflash_enabled()
import os
from config import settings as cfg
from pathlib import Path
asset_path = Path(cfg.flash_assets_storage_path) / hw_type / "partitions.bin"
if not asset_path.exists():
raise HTTPException(status_code=404, detail=f"Partition table not found for {hw_type}.")
return Response(
content=asset_path.read_bytes(),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="partitions_{hw_type}.bin"'},
)

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel
from typing import Optional
class PublicFeaturesSettings(BaseModel):
cloudflash_enabled: bool = False
class PublicFeaturesSettingsUpdate(BaseModel):
cloudflash_enabled: Optional[bool] = None

View File

@@ -0,0 +1,31 @@
from shared.firebase import get_db
from settings.public_features_models import PublicFeaturesSettings, PublicFeaturesSettingsUpdate
COLLECTION = "admin_settings"
DOC_ID = "public_features"
def get_public_features() -> PublicFeaturesSettings:
"""Get public features settings from Firestore. Creates defaults if not found."""
db = get_db()
doc = db.collection(COLLECTION).document(DOC_ID).get()
if doc.exists:
return PublicFeaturesSettings(**doc.to_dict())
defaults = PublicFeaturesSettings()
db.collection(COLLECTION).document(DOC_ID).set(defaults.model_dump())
return defaults
def update_public_features(data: PublicFeaturesSettingsUpdate) -> PublicFeaturesSettings:
"""Update public features settings. Only provided fields are updated."""
db = get_db()
doc_ref = db.collection(COLLECTION).document(DOC_ID)
doc = doc_ref.get()
existing = doc.to_dict() if doc.exists else PublicFeaturesSettings().model_dump()
update_data = data.model_dump(exclude_none=True)
existing.update(update_data)
normalized = PublicFeaturesSettings(**existing)
doc_ref.set(normalized.model_dump())
return normalized

View File

@@ -1,8 +1,11 @@
from fastapi import APIRouter, Depends
from auth.models import TokenPayload
from auth.dependencies import require_permission
from auth.dependencies import require_permission, require_roles
from auth.models import Role
from settings.models import MelodySettings, MelodySettingsUpdate
from settings.public_features_models import PublicFeaturesSettings, PublicFeaturesSettingsUpdate
from settings import service
from settings import public_features_service
router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -20,3 +23,20 @@ async def update_melody_settings(
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
):
return service.update_melody_settings(body)
# ── Public Features Settings (sysadmin / admin only) ─────────────────────────
@router.get("/public-features", response_model=PublicFeaturesSettings)
async def get_public_features(
_user: TokenPayload = Depends(require_roles(Role.sysadmin, Role.admin)),
):
return public_features_service.get_public_features()
@router.put("/public-features", response_model=PublicFeaturesSettings)
async def update_public_features(
body: PublicFeaturesSettingsUpdate,
_user: TokenPayload = Depends(require_roles(Role.sysadmin, Role.admin)),
):
return public_features_service.update_public_features(body)

View File

@@ -177,16 +177,16 @@ def _build_page(entries: List[bytes], slot_counts: List[int], seq: int = 0) -> b
return page
def generate(serial_number: str, hw_type: str, hw_version: str) -> bytes:
def generate(serial_number: str, hw_family: str, hw_revision: str) -> bytes:
"""Generate a 0x5000-byte NVS partition binary for a Vesper device.
serial_number: full SN string e.g. 'BSVSPR-26C13X-STD01R-X7KQA'
hw_type: board family e.g. 'vesper', 'vesper_plus', 'vesper_pro'
hw_version: zero-padded revision e.g. '01'
hw_family: board family e.g. 'vesper-standard', 'vesper-plus'
hw_revision: hardware revision string e.g. '1.0'
Writes the NEW schema keys (2.0+) expected by ConfigManager:
Writes the schema keys expected by ConfigManager (struct DeviceConfig):
serial ← full serial number
hw_family ← board family (hw_type value, lowercase)
hw_family ← board family (lowercase)
hw_revision ← hardware revision string
Returns raw bytes ready to flash at 0x9000.
@@ -196,8 +196,8 @@ def generate(serial_number: str, hw_type: str, hw_version: str) -> bytes:
# Build entries for namespace "device_id"
ns_entry, ns_span = _build_namespace_entry("device_id", ns_index)
uid_entry, uid_span = _build_string_entry(ns_index, "serial", serial_number)
hwt_entry, hwt_span = _build_string_entry(ns_index, "hw_family", hw_type.lower())
hwv_entry, hwv_span = _build_string_entry(ns_index, "hw_revision", hw_version)
hwt_entry, hwt_span = _build_string_entry(ns_index, "hw_family", hw_family.lower())
hwv_entry, hwv_span = _build_string_entry(ns_index, "hw_revision", hw_revision)
entries = [ns_entry, uid_entry, hwt_entry, hwv_entry]
spans = [ns_span, uid_span, hwt_span, hwv_span]

View File

@@ -1,5 +1,7 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "./auth/AuthContext";
import CloudFlashPage from "./cloudflash/CloudFlashPage";
import PublicFeaturesSettings from "./settings/PublicFeaturesSettings";
import LoginPage from "./auth/LoginPage";
import MainLayout from "./layout/MainLayout";
import MelodyList from "./melodies/MelodyList";
@@ -106,6 +108,9 @@ function RoleGate({ roles, children }) {
export default function App() {
return (
<Routes>
{/* Public routes — no login required */}
<Route path="/cloudflash" element={<CloudFlashPage />} />
<Route path="/login" element={<LoginPage />} />
<Route
element={
@@ -188,6 +193,9 @@ export default function App() {
<Route path="settings/staff/:id" element={<RoleGate roles={["sysadmin", "admin"]}><StaffDetail /></RoleGate>} />
<Route path="settings/staff/:id/edit" element={<RoleGate roles={["sysadmin", "admin"]}><StaffForm /></RoleGate>} />
{/* Settings - Public Features */}
<Route path="settings/public-features" element={<RoleGate roles={["sysadmin", "admin"]}><PublicFeaturesSettings /></RoleGate>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1188,7 +1188,7 @@ export default function CustomerDetail() {
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{customer.notes.map((note, i) => (
<div key={i} className="px-3 py-2 rounded-md text-sm" style={{ backgroundColor: "var(--bg-primary)" }}>
<p style={{ color: "var(--text-primary)" }}>{note.text}</p>
<p style={{ color: "var(--text-primary)", whiteSpace: "pre-wrap" }}>{note.text}</p>
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
{note.by} · {note.at ? new Date(note.at).toLocaleDateString() : ""}
</p>

View File

@@ -1556,6 +1556,7 @@ const TAB_DEFS = [
{ id: "bells", label: "Bell Mechanisms", tone: "bells" },
{ id: "clock", label: "Clock & Alerts", tone: "clock" },
{ id: "warranty", label: "Warranty & Subscription", tone: "warranty" },
{ id: "manage", label: "Manage", tone: "manage" },
{ id: "control", label: "Control", tone: "control" },
];
@@ -1574,6 +1575,104 @@ function calcMaintenanceProgress(lastDate, periodDays) {
return Math.max(0, Math.min(100, (elapsed / total) * 100));
}
// ─── Customer Assign Modal ────────────────────────────────────────────────────
function CustomerAssignModal({ deviceId, onSelect, onCancel }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [searching, setSearching] = useState(false);
const inputRef = useRef(null);
useEffect(() => { inputRef.current?.focus(); }, []);
const search = useCallback(async (q) => {
setSearching(true);
try {
const data = await api.get(`/devices/${deviceId}/customer-search?q=${encodeURIComponent(q)}`);
setResults(data.results || []);
} catch {
setResults([]);
} finally {
setSearching(false);
}
}, [deviceId]);
useEffect(() => {
const t = setTimeout(() => search(query), 250);
return () => clearTimeout(t);
}, [query, search]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
<div
className="rounded-xl border w-full max-w-lg flex flex-col"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxHeight: "80vh" }}
>
<div className="flex items-center justify-between px-6 pt-5 pb-4 border-b" style={{ borderColor: "var(--border-secondary)" }}>
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Assign to Customer</h3>
<button type="button" onClick={onCancel} className="text-lg leading-none hover:opacity-70 cursor-pointer" style={{ color: "var(--text-muted)" }}>&#x2715;</button>
</div>
<div className="px-6 pt-4 pb-2">
<div style={{ position: "relative" }}>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name, email, phone, org, tags…"
className="w-full px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
{searching && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs" style={{ color: "var(--text-muted)" }}></span>
)}
</div>
</div>
<div className="overflow-y-auto flex-1 px-6 pb-4">
<div className="rounded-md border overflow-hidden" style={{ borderColor: "var(--border-secondary)", minHeight: 60 }}>
{results.length === 0 ? (
<p className="px-3 py-3 text-sm" style={{ color: "var(--text-muted)" }}>
{searching ? "Searching…" : query ? "No customers found." : "Type to search customers…"}
</p>
) : (
results.map((c) => (
<button
key={c.id}
type="button"
onClick={() => onSelect(c)}
className="w-full text-left px-3 py-2.5 text-sm hover:opacity-80 cursor-pointer border-b last:border-b-0 transition-colors"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-secondary)", color: "var(--text-primary)" }}
>
<span className="font-medium block">
{[c.name, c.surname].filter(Boolean).join(" ")}
{c.city && (
<>
<span className="mx-1.5" style={{ color: "var(--text-muted)", fontSize: "8px", verticalAlign: "middle" }}></span>
<span style={{ color: "var(--text-muted)", fontWeight: 400 }}>{c.city}</span>
</>
)}
</span>
{c.organization && (
<span className="text-xs block" style={{ color: "var(--text-muted)" }}>{c.organization}</span>
)}
</button>
))
)}
</div>
</div>
<div className="flex justify-end px-6 py-4 border-t" style={{ borderColor: "var(--border-secondary)" }}>
<button
onClick={onCancel}
className="px-4 py-1.5 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
</div>
);
}
export default function DeviceDetail() {
const { id } = useParams();
const navigate = useNavigate();
@@ -1630,18 +1729,29 @@ export default function DeviceDetail() {
const d = await api.get(`/devices/${id}`);
setDevice(d);
if (d.staffNotes) setStaffNotes(d.staffNotes);
if (Array.isArray(d.tags)) setTags(d.tags);
setLoading(false);
// Phase 2: fire async background fetches — do not block the render
if (d.device_id) {
const deviceSN = d.serial_number || d.device_id;
if (deviceSN) {
api.get("/mqtt/status").then((mqttData) => {
if (mqttData?.devices) {
const match = mqttData.devices.find((s) => s.device_serial === d.device_id);
const match = mqttData.devices.find((s) => s.device_serial === deviceSN);
setMqttStatus(match || null);
}
}).catch(() => {});
}
// Fetch owner customer details
if (d.customer_id) {
api.get(`/devices/${id}/customer`).then((res) => {
setOwnerCustomer(res.customer || null);
}).catch(() => setOwnerCustomer(null));
} else {
setOwnerCustomer(null);
}
setUsersLoading(true);
api.get(`/devices/${id}/users`).then((data) => {
setDeviceUsers(data.users || []);
@@ -1650,9 +1760,9 @@ export default function DeviceDetail() {
}).finally(() => setUsersLoading(false));
// Fetch manufacturing record + product catalog to resolve hw image
if (d.device_id) {
if (deviceSN) {
Promise.all([
api.get(`/manufacturing/devices/${d.device_id}`).catch(() => null),
api.get(`/manufacturing/devices/${deviceSN}`).catch(() => null),
api.get("/crm/products").catch(() => null),
]).then(([mfgItem, productsRes]) => {
const hwType = mfgItem?.hw_type || "";
@@ -1719,6 +1829,85 @@ export default function DeviceDetail() {
}
};
// --- Device Notes handlers ---
const handleAddNote = async () => {
if (!newNoteText.trim()) return;
setSavingNote(true);
try {
const data = await api.post(`/devices/${id}/notes`, {
content: newNoteText.trim(),
created_by: "admin",
});
setDeviceNotes((prev) => [data, ...prev]);
setNewNoteText("");
setAddingNote(false);
} catch {} finally { setSavingNote(false); }
};
const handleUpdateNote = async (noteId) => {
if (!editingNoteText.trim()) return;
setSavingNote(true);
try {
const data = await api.put(`/devices/${id}/notes/${noteId}`, { content: editingNoteText.trim() });
setDeviceNotes((prev) => prev.map((n) => n.id === noteId ? data : n));
setEditingNoteId(null);
} catch {} finally { setSavingNote(false); }
};
const handleDeleteNote = async (noteId) => {
if (!window.confirm("Delete this note?")) return;
try {
await api.delete(`/devices/${id}/notes/${noteId}`);
setDeviceNotes((prev) => prev.filter((n) => n.id !== noteId));
} catch {}
};
// --- Tags handlers ---
const handleAddTag = async (tag) => {
const trimmed = tag.trim();
if (!trimmed || tags.includes(trimmed)) return;
const next = [...tags, trimmed];
setSavingTags(true);
try {
await api.put(`/devices/${id}/tags`, { tags: next });
setTags(next);
setTagInput("");
} catch {} finally { setSavingTags(false); }
};
const handleRemoveTag = async (tag) => {
const next = tags.filter((t) => t !== tag);
setSavingTags(true);
try {
await api.put(`/devices/${id}/tags`, { tags: next });
setTags(next);
} catch {} finally { setSavingTags(false); }
};
// --- Customer assign handlers ---
const handleAssignCustomer = async (customer) => {
setAssigningCustomer(true);
try {
await api.post(`/devices/${id}/assign-customer`, { customer_id: customer.id });
setDevice((prev) => ({ ...prev, customer_id: customer.id }));
setOwnerCustomer(customer);
setShowAssignSearch(false);
setCustomerSearch("");
setCustomerResults([]);
} catch {} finally { setAssigningCustomer(false); }
};
const handleUnassignCustomer = async () => {
if (!window.confirm("Remove customer assignment?")) return;
setAssigningCustomer(true);
try {
const cid = device?.customer_id;
await api.delete(`/devices/${id}/assign-customer${cid ? `?customer_id=${cid}` : ""}`);
setDevice((prev) => ({ ...prev, customer_id: "" }));
setOwnerCustomer(null);
} catch {} finally { setAssigningCustomer(false); }
};
const requestStrikeCounters = useCallback(async (force = false) => {
if (!device?.device_id) return;
const now = Date.now();
@@ -1800,6 +1989,66 @@ export default function DeviceDetail() {
return () => clearInterval(interval);
}, [ctrlCmdAutoRefresh, device?.device_id, fetchCtrlCmdHistory]);
// --- Device Notes state (MUST be before early returns) ---
const [deviceNotes, setDeviceNotes] = useState([]);
const [notesLoaded, setNotesLoaded] = useState(false);
const [addingNote, setAddingNote] = useState(false);
const [newNoteText, setNewNoteText] = useState("");
const [savingNote, setSavingNote] = useState(false);
const [editingNoteId, setEditingNoteId] = useState(null);
const [editingNoteText, setEditingNoteText] = useState("");
const loadDeviceNotes = useCallback(async () => {
try {
const data = await api.get(`/devices/${id}/notes`);
setDeviceNotes(data.notes || []);
setNotesLoaded(true);
} catch {
setNotesLoaded(true);
}
}, [id]);
useEffect(() => {
if (id) loadDeviceNotes();
}, [id, loadDeviceNotes]);
// --- Tags state (MUST be before early returns) ---
const [tags, setTags] = useState([]);
const [tagInput, setTagInput] = useState("");
const [savingTags, setSavingTags] = useState(false);
// --- Customer assign state (MUST be before early returns) ---
const [assigningCustomer, setAssigningCustomer] = useState(false);
const [showAssignSearch, setShowAssignSearch] = useState(false);
const [ownerCustomer, setOwnerCustomer] = useState(null);
// --- User assignment state (MUST be before early returns) ---
const [showUserSearch, setShowUserSearch] = useState(false);
const [userSearchQuery, setUserSearchQuery] = useState("");
const [userSearchResults, setUserSearchResults] = useState([]);
const [userSearching, setUserSearching] = useState(false);
const [addingUser, setAddingUser] = useState(null);
const [removingUser, setRemovingUser] = useState(null);
const userSearchInputRef = useRef(null);
const searchUsers = useCallback(async (q) => {
setUserSearching(true);
try {
const data = await api.get(`/devices/${id}/user-search?q=${encodeURIComponent(q)}`);
setUserSearchResults(data.results || []);
} catch {
setUserSearchResults([]);
} finally {
setUserSearching(false);
}
}, [id]);
useEffect(() => {
if (!showUserSearch) return;
const t = setTimeout(() => searchUsers(userSearchQuery), 250);
return () => clearTimeout(t);
}, [userSearchQuery, searchUsers, showUserSearch]);
if (loading) return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
if (error) return (
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
@@ -1952,7 +2201,7 @@ export default function DeviceDetail() {
<div className="db-row">
<div className="db-info-field">
<span className="db-info-label">SERIAL NUMBER</span>
<span className="db-info-value">{device.device_id || "-"}</span>
<span className="db-info-value">{device.serial_number || device.device_id || "-"}</span>
</div>
</div>
<div className="db-row">
@@ -2115,7 +2364,7 @@ export default function DeviceDetail() {
</div>
<DeviceLogsPanel deviceSerial={device.device_id} />
<DeviceLogsPanel deviceSerial={device.serial_number || device.device_id} />
<div className="dashboard-bottom-grid">
<div className="dashboard-bottom-grid__notes" ref={notesPanelRef}>
@@ -2253,6 +2502,260 @@ export default function DeviceDetail() {
<EmptyCell />
</FieldRow>
</SectionCard>
{/* ── Tags ── */}
<SectionCard title="Tags">
<div className="flex flex-wrap gap-2 mb-3">
{tags.length === 0 && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>No tags yet.</span>
)}
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)", border: "1px solid var(--badge-blue-text)" }}
>
{tag}
{canEdit && (
<button
type="button"
onClick={() => handleRemoveTag(tag)}
disabled={savingTags}
className="ml-0.5 hover:opacity-70 cursor-pointer disabled:opacity-40"
style={{ lineHeight: 1 }}
>
×
</button>
)}
</span>
))}
</div>
{canEdit && (
<div className="flex gap-2">
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddTag(tagInput); } }}
placeholder="Add tag and press Enter…"
className="px-3 py-1.5 rounded-md text-sm border flex-1"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
<button
type="button"
onClick={() => handleAddTag(tagInput)}
disabled={!tagInput.trim() || savingTags}
className="px-3 py-1.5 text-sm rounded-md disabled:opacity-50 cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Add
</button>
</div>
)}
</SectionCard>
{/* ── Owner ── */}
<SectionCard title="Owner">
{device.customer_id ? (
<div>
{ownerCustomer ? (
<div
className="flex items-center gap-3 p-3 rounded-md border mb-3 cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}
onClick={() => navigate(`/crm/customers/${device.customer_id}`)}
title="View customer"
>
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shrink-0"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
{(ownerCustomer.name || "?")[0].toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>{ownerCustomer.name || "—"}</p>
{ownerCustomer.organization && (
<p className="text-xs" style={{ color: "var(--text-muted)" }}>{ownerCustomer.organization}</p>
)}
</div>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: "var(--text-muted)" }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
) : (
<p className="text-sm mb-3" style={{ color: "var(--text-muted)" }}>Customer assigned (loading details)</p>
)}
{canEdit && (
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowAssignSearch(true)}
className="text-xs px-3 py-1.5 rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Reassign
</button>
<button
type="button"
onClick={handleUnassignCustomer}
disabled={assigningCustomer}
className="text-xs px-3 py-1.5 rounded-md cursor-pointer disabled:opacity-50 hover:opacity-80"
style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)", border: "1px solid var(--danger)" }}
>
Remove
</button>
</div>
)}
</div>
) : (
<div>
<p className="text-sm mb-3" style={{ color: "var(--text-muted)" }}>No customer assigned yet.</p>
{canEdit && (
<button
type="button"
onClick={() => setShowAssignSearch(true)}
className="text-sm px-3 py-1.5 rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Assign to Customer
</button>
)}
</div>
)}
{showAssignSearch && (
<CustomerAssignModal
deviceId={id}
onSelect={(c) => { setShowAssignSearch(false); handleAssignCustomer(c); }}
onCancel={() => setShowAssignSearch(false)}
/>
)}
</SectionCard>
{/* ── Device Notes ── */}
<SectionCard title="Device Notes">
{!notesLoaded ? (
<p className="text-xs" style={{ color: "var(--text-muted)" }}>Loading</p>
) : (
<>
{deviceNotes.length === 0 && !addingNote && (
<p className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>No notes for this device.</p>
)}
<div className="space-y-3 mb-3">
{deviceNotes.map((note) => (
<div
key={note.id}
className="p-3 rounded-md border"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}
>
{editingNoteId === note.id ? (
<div className="space-y-2">
<textarea
value={editingNoteText}
onChange={(e) => setEditingNoteText(e.target.value)}
autoFocus
rows={3}
className="w-full px-2 py-1.5 rounded text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)", resize: "vertical" }}
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleUpdateNote(note.id)}
disabled={savingNote}
className="text-xs px-2.5 py-1 rounded-md cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{savingNote ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={() => setEditingNoteId(null)}
className="text-xs px-2.5 py-1 rounded-md cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--text-secondary)" }}>{note.content}</p>
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
{note.created_by}{note.created_at ? ` · ${new Date(note.created_at).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}` : ""}
</p>
</div>
{canEdit && (
<div className="flex gap-1.5 shrink-0">
<button
type="button"
onClick={() => { setEditingNoteId(note.id); setEditingNoteText(note.content); }}
className="text-xs px-2 py-0.5 rounded cursor-pointer hover:opacity-80"
style={{ color: "var(--text-muted)" }}
>
Edit
</button>
<button
type="button"
onClick={() => handleDeleteNote(note.id)}
className="text-xs px-2 py-0.5 rounded cursor-pointer hover:opacity-80"
style={{ color: "var(--danger-text)" }}
>
Delete
</button>
</div>
)}
</div>
)}
</div>
))}
</div>
{canEdit && (
addingNote ? (
<div className="space-y-2">
<textarea
value={newNoteText}
onChange={(e) => setNewNoteText(e.target.value)}
autoFocus
rows={3}
placeholder="Write a note…"
className="w-full px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)", resize: "vertical" }}
/>
<div className="flex gap-2">
<button
type="button"
onClick={handleAddNote}
disabled={savingNote || !newNoteText.trim()}
className="text-sm px-3 py-1.5 rounded-md cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{savingNote ? "Saving…" : "Add Note"}
</button>
<button
type="button"
onClick={() => { setAddingNote(false); setNewNoteText(""); }}
className="text-sm px-3 py-1.5 rounded-md cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setAddingNote(true)}
className="text-sm px-3 py-1.5 rounded-md cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
+ Add Note
</button>
)
)}
</>
)}
</SectionCard>
</div>
</div>
);
@@ -3039,12 +3542,182 @@ export default function DeviceDetail() {
</div>
);
// ── Manage tab ──────────────────────────────────────────────────────────────
const manageTab = (
<div className="device-tab-stack">
{/* Issues & Notes — full width */}
<NotesPanel key={`manage-${id}`} deviceId={id} />
{/* User Assignment */}
<section className="rounded-lg border p-6 mt-4" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
App Users ({deviceUsers.length})
</h2>
{canEdit && (
<button
type="button"
onClick={() => { setShowUserSearch(true); setUserSearchQuery(""); setUserSearchResults([]); setTimeout(() => userSearchInputRef.current?.focus(), 50); }}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md cursor-pointer hover:opacity-90 transition-opacity"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
+ Add User
</button>
)}
</div>
{usersLoading ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading users</p>
) : deviceUsers.length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No users assigned. Users are added when they claim the device via the app, or you can add them manually.</p>
) : (
<div className="space-y-2">
{deviceUsers.map((u) => (
<div
key={u.user_id}
className="flex items-center gap-3 px-3 py-2.5 rounded-md border"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}
>
{u.photo_url ? (
<img src={u.photo_url} alt="" className="w-8 h-8 rounded-full object-cover shrink-0" />
) : (
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shrink-0"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
{(u.display_name || u.email || "?")[0].toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>{u.display_name || "—"}</p>
{u.email && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{u.email}</p>}
</div>
{u.role && (
<span className="text-xs px-2 py-0.5 rounded-full shrink-0"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
{u.role}
</span>
)}
{canEdit && (
<button
type="button"
disabled={removingUser === u.user_id}
onClick={async () => {
setRemovingUser(u.user_id);
try {
await api.delete(`/devices/${id}/user-list/${u.user_id}`);
setDeviceUsers((prev) => prev.filter((x) => x.user_id !== u.user_id));
} catch (err) {
setError(err.message);
} finally {
setRemovingUser(null);
}
}}
className="text-xs px-2 py-0.5 rounded cursor-pointer hover:opacity-80 disabled:opacity-40 shrink-0"
style={{ color: "var(--danger-text)" }}
>
{removingUser === u.user_id ? "…" : "Remove"}
</button>
)}
</div>
))}
</div>
)}
{/* User search modal */}
{showUserSearch && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
>
<div
className="rounded-xl border p-6 w-full max-w-md"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-base font-bold mb-4" style={{ color: "var(--text-heading)" }}>
Add User
</h2>
<div style={{ position: "relative" }} className="mb-3">
<input
ref={userSearchInputRef}
type="text"
value={userSearchQuery}
onChange={(e) => setUserSearchQuery(e.target.value)}
placeholder="Search by name, email, or phone…"
className="w-full px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
{userSearching && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs" style={{ color: "var(--text-muted)" }}></span>
)}
</div>
<div
className="rounded-md border overflow-y-auto mb-4"
style={{ borderColor: "var(--border-secondary)", maxHeight: 260, minHeight: 48 }}
>
{userSearchResults.length === 0 ? (
<p className="px-3 py-3 text-sm" style={{ color: "var(--text-muted)" }}>
{userSearching ? "Searching…" : userSearchQuery ? "No users found." : "Type to search users…"}
</p>
) : (
userSearchResults.map((u) => {
const alreadyAdded = deviceUsers.some((du) => du.user_id === u.id);
return (
<button
key={u.id}
type="button"
disabled={alreadyAdded || addingUser === u.id}
onClick={async () => {
setAddingUser(u.id);
try {
await api.post(`/devices/${id}/user-list`, { user_id: u.id });
setDeviceUsers((prev) => [...prev, {
user_id: u.id,
display_name: u.display_name,
email: u.email,
photo_url: u.photo_url,
role: "",
}]);
setShowUserSearch(false);
} catch (err) {
setError(err.message);
} finally {
setAddingUser(null);
}
}}
className="w-full text-left px-3 py-2.5 text-sm border-b last:border-b-0 transition-colors cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-secondary)", color: "var(--text-primary)" }}
>
<span className="font-medium">{u.display_name || u.email || u.id}</span>
{u.email && <span className="block text-xs" style={{ color: "var(--text-muted)" }}>{u.email}</span>}
{alreadyAdded && <span className="ml-2 text-xs" style={{ color: "var(--success-text)" }}>Already added</span>}
</button>
);
})
)}
</div>
<div className="flex justify-end">
<button
type="button"
onClick={() => setShowUserSearch(false)}
className="px-4 py-1.5 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
</div>
)}
</section>
</div>
);
const renderTabContent = () => {
if (activeTab === "dashboard") return dashboardTab;
if (activeTab === "general") return generalInfoTab;
if (activeTab === "bells") return bellMechanismsTab;
if (activeTab === "clock") return clockAlertsTab;
if (activeTab === "warranty") return warrantySubscriptionTab;
if (activeTab === "manage") return manageTab;
return controlTab;
};
@@ -3136,7 +3809,7 @@ export default function DeviceDetail() {
<ConfirmDialog
open={showDelete}
title="Delete Device"
message={`Are you sure you want to delete "${device.device_name || "this device"}" (${device.device_id})? This action cannot be undone.`}
message={`Are you sure you want to delete "${device.device_name || "this device"}" (${device.serial_number || device.device_id})? This action cannot be undone.`}
onConfirm={handleDelete}
onCancel={() => setShowDelete(false)}
/>

View File

@@ -31,6 +31,9 @@ const ALL_COLUMNS = [
{ key: "warrantyActive", label: "Warranty Active", defaultOn: false },
{ key: "totalMelodies", label: "Total Melodies", defaultOn: false },
{ key: "assignedUsers", label: "Assigned Users", defaultOn: true },
{ key: "tags", label: "Tags", defaultOn: false },
{ key: "hw_family", label: "HW Family", defaultOn: false },
{ key: "hw_revision", label: "HW Revision", defaultOn: false },
];
function getDefaultVisibleColumns() {
@@ -199,7 +202,8 @@ export default function DeviceList() {
switch (key) {
case "status": {
const mqtt = mqttStatusMap[device.device_id];
const sn = device.serial_number || device.device_id;
const mqtt = mqttStatusMap[sn];
const isOnline = mqtt ? mqtt.online : device.is_Online;
return (
<span
@@ -216,7 +220,7 @@ export default function DeviceList() {
</span>
);
case "serialNumber":
return <span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.device_id || "-"}</span>;
return <span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.serial_number || device.device_id || "-"}</span>;
case "location":
return device.device_location || "-";
case "subscrTier":
@@ -260,8 +264,31 @@ export default function DeviceList() {
return <BoolBadge value={stats.warrantyActive} yesLabel="Active" noLabel="Expired" />;
case "totalMelodies":
return device.device_melodies_all?.length ?? 0;
case "assignedUsers":
return device.user_list?.length ?? 0;
case "assignedUsers": {
const ul = Array.isArray(device.user_list) ? device.user_list : [];
return ul.length;
}
case "tags": {
const tagList = Array.isArray(device.tags) ? device.tags : [];
if (tagList.length === 0) return <span style={{ color: "var(--text-muted)" }}></span>;
return (
<div className="flex flex-wrap gap-1">
{tagList.map((t) => (
<span
key={t}
className="px-1.5 py-0.5 text-xs rounded-full"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
>
{t}
</span>
))}
</div>
);
}
case "hw_family":
return device.hw_family || <span style={{ color: "var(--text-muted)" }}></span>;
case "hw_revision":
return device.hw_revision || <span style={{ color: "var(--text-muted)" }}></span>;
default:
return "-";
}
@@ -566,7 +593,7 @@ export default function DeviceList() {
<ConfirmDialog
open={!!deleteTarget}
title="Delete Device"
message={`Are you sure you want to delete "${deleteTarget?.device_name || "this device"}" (${deleteTarget?.device_id || ""})? This action cannot be undone.`}
message={`Are you sure you want to delete "${deleteTarget?.device_name || "this device"}" (${deleteTarget?.serial_number || deleteTarget?.device_id || ""})? This action cannot be undone.`}
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(null)}
/>

File diff suppressed because it is too large Load Diff

View File

@@ -146,6 +146,7 @@ const navSections = [
// Bottom settings — always rendered separately at the bottom
const settingsChildren = [
{ to: "/settings/staff", label: "Staff Management", icon: StaffIcon },
{ to: "/settings/public-features", label: "Public Features", icon: SettingsIcon },
{ to: "/settings/pages", label: "Page Settings", icon: PlaceholderIcon, placeholder: true },
];

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import api from "../api/client";
@@ -7,13 +8,13 @@ import api from "../api/client";
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
const BOARD_TYPES = [
{ value: "vesper_pro", name: "VESPER PRO", codename: "vesper-pro", family: "vesper" },
{ value: "vesper_plus", name: "VESPER PLUS", codename: "vesper-plus", family: "vesper" },
{ value: "vesper", name: "VESPER", codename: "vesper-basic", family: "vesper" },
{ value: "agnus", name: "AGNUS", codename: "agnus-basic", family: "agnus" },
{ value: "agnus_mini", name: "AGNUS MINI", codename: "agnus-mini", family: "agnus" },
{ value: "chronos_pro", name: "CHRONOS PRO", codename: "chronos-pro", family: "chronos" },
{ value: "chronos", name: "CHRONOS", codename: "chronos-basic", family: "chronos" },
{ value: "vesper_pro", name: "Vesper Pro", codename: "vesper-pro", family: "vesper" },
{ value: "vesper_plus", name: "Vesper+", codename: "vesper-plus", family: "vesper" },
{ value: "vesper", name: "Vesper", codename: "vesper-basic", family: "vesper" },
{ value: "agnus", name: "Agnus", codename: "agnus-basic", family: "agnus" },
{ value: "agnus_mini", name: "Agnus Mini", codename: "agnus-mini", family: "agnus" },
{ value: "chronos_pro", name: "Chronos Pro", codename: "chronos-pro", family: "chronos" },
{ value: "chronos", name: "Chronos", codename: "chronos-basic", family: "chronos" },
];
const BOARD_FAMILY_COLORS = {
@@ -22,7 +23,11 @@ const BOARD_FAMILY_COLORS = {
chronos: { selectedBg: "#1a0808", selectedBorder: "#ef4444", selectedText: "#f87171", hoverBorder: "#ef4444", glowColor: "rgba(239,68,68,0.35)", idleBorder: "#5c1a1a", idleText: "#d47a7a" },
};
const BOARD_TYPE_LABELS = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b.name]));
const BOARD_TYPE_LABELS = {
vesper: "Vesper", vesper_plus: "Vesper+", vesper_pro: "Vesper Pro",
agnus: "Agnus", agnus_mini: "Agnus Mini",
chronos: "Chronos", chronos_pro: "Chronos Pro",
};
const STATUS_STYLES = {
manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
@@ -44,7 +49,8 @@ const ALL_COLUMNS = [
{ id: "status", label: "Status", default: true },
{ id: "batch", label: "Batch", default: true },
{ id: "created", label: "Created", default: true },
{ id: "owner", label: "Owner", default: true },
{ id: "owner", label: "Customer", default: true },
{ id: "users", label: "Device Users", default: true },
{ id: "name", label: "Device Name", default: false },
];
@@ -89,6 +95,80 @@ function StatusBadge({ status }) {
);
}
function UsersHoverCell({ userList, resolvedUsersMap }) {
const [popupPos, setPopupPos] = useState(null);
const triggerRef = useRef(null);
const count = userList.length;
const handleMouseEnter = () => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
setPopupPos({ top: rect.top + window.scrollY, left: rect.left + window.scrollX });
};
const handleMouseLeave = () => setPopupPos(null);
if (count === 0) return <span className="text-xs" style={{ color: "var(--text-muted)" }}></span>;
return (
<>
<span
ref={triggerRef}
className="px-2 py-0.5 text-xs rounded-full font-medium cursor-default"
style={{ backgroundColor: "#431407", color: "#fb923c" }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{count} user{count !== 1 ? "s" : ""}
</span>
{popupPos && typeof document !== "undefined" &&
ReactDOM.createPortal(
<div
onMouseEnter={() => setPopupPos(popupPos)}
onMouseLeave={() => setPopupPos(null)}
className="rounded-lg border shadow-xl p-3"
style={{
position: "absolute",
top: popupPos.top - 8,
left: popupPos.left,
transform: "translateY(-100%)",
zIndex: 9999,
minWidth: 220, maxWidth: 300,
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
pointerEvents: "none",
}}
>
<p className="text-xs font-semibold mb-2" style={{ color: "var(--text-muted)" }}>
Assigned Users
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{userList.map((uid) => {
const u = resolvedUsersMap[uid];
const initials = (u?.display_name || u?.email || uid)[0]?.toUpperCase() || "U";
return (
<div key={uid} style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{
width: 26, height: 26, borderRadius: "50%", flexShrink: 0,
display: "flex", alignItems: "center", justifyContent: "center",
backgroundColor: "#431407", color: "#fb923c", fontSize: 10, fontWeight: 700,
}}>{initials}</div>
<div style={{ minWidth: 0 }}>
<p className="text-xs font-medium" style={{ color: "var(--text-primary)" }}>
{u?.display_name || uid}
</p>
{u?.email && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{u.email}</p>}
</div>
</div>
);
})}
</div>
</div>,
document.body
)
}
</>
);
}
function formatHwVersion(v) {
if (!v) return "—";
if (/^\d+\.\d+/.test(v)) return `Rev ${v}`;
@@ -100,7 +180,7 @@ function formatHwVersion(v) {
function formatDate(iso) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
return new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
} catch { return iso; }
}
@@ -676,6 +756,18 @@ export default function DeviceInventory() {
// Column preferences
const [colPrefs, setColPrefs] = useState(loadColumnPrefs);
// Pagination
const [pageSize, setPageSize] = useState(20);
const [page, setPage] = useState(1);
// Customer name lookup: { [customer_id]: { name, surname } }
const [customerMap, setCustomerMap] = useState({});
// User display lookup: { [uid]: { display_name, email } }
const [resolvedUsersMap, setResolvedUsersMap] = useState({});
// Hover state for checkbox reveal: rowId | null
const [hoveredRow, setHoveredRow] = useState(null);
// Selection
const [selected, setSelected] = useState(new Set());
const allIds = devices.map((d) => d.id);
@@ -695,12 +787,38 @@ export default function DeviceInventory() {
if (search) params.set("search", search);
if (statusFilter) params.set("status", statusFilter);
if (hwTypeFilter) params.set("hw_type", hwTypeFilter);
params.set("limit", "200");
params.set("limit", "500");
const qs = params.toString();
const data = await api.get(`/manufacturing/devices${qs ? `?${qs}` : ""}`);
setDevices(data.devices);
// Clear selection on refresh
const devs = data.devices || [];
setDevices(devs);
setSelected(new Set());
// Fetch customer names for assigned devices
const customerIds = [...new Set(devs.map((d) => d.customer_id).filter(Boolean))];
if (customerIds.length) {
const entries = await Promise.all(
customerIds.map((id) =>
api.get(`/manufacturing/customers/${id}`)
.then((c) => [id, c])
.catch(() => [id, { name: "", surname: "" }])
)
);
setCustomerMap(Object.fromEntries(entries));
}
// Fetch user display info for all user_list UIDs
const allUids = [...new Set(devs.flatMap((d) => d.user_list || []))];
if (allUids.length) {
const entries = await Promise.all(
allUids.map((uid) =>
api.get(`/users/${uid}`)
.then((u) => [uid, { display_name: u.display_name || "", email: u.email || "" }])
.catch(() => [uid, { display_name: "", email: "" }])
)
);
setResolvedUsersMap(Object.fromEntries(entries));
}
} catch (err) {
setError(err.message);
} finally {
@@ -708,7 +826,7 @@ export default function DeviceInventory() {
}
};
useEffect(() => { fetchDevices(); }, [search, statusFilter, hwTypeFilter]);
useEffect(() => { fetchDevices(); setPage(1); }, [search, statusFilter, hwTypeFilter]);
const updateColVisible = (id, visible) => {
const next = { ...colPrefs.visible, [id]: visible };
@@ -739,6 +857,12 @@ export default function DeviceInventory() {
.map((id) => ALL_COLUMNS.find((c) => c.id === id))
.filter((c) => c && colPrefs.visible[c.id]);
const totalPages = pageSize > 0 ? Math.ceil(devices.length / pageSize) : 1;
const safePage = Math.min(page, Math.max(1, totalPages));
const pagedDevices = pageSize > 0
? devices.slice((safePage - 1) * pageSize, safePage * pageSize)
: devices;
const renderCell = (col, device) => {
switch (col.id) {
case "serial": return (
@@ -756,7 +880,18 @@ export default function DeviceInventory() {
case "status": return <StatusBadge status={device.mfg_status} />;
case "batch": return <span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.mfg_batch_id || "—"}</span>;
case "created": return <span className="text-xs" style={{ color: "var(--text-muted)" }}>{formatDate(device.created_at)}</span>;
case "owner": return <span className="text-xs" style={{ color: "var(--text-muted)" }}>{device.owner || "—"}</span>;
case "owner": {
if (!device.customer_id) return <span className="text-xs" style={{ color: "var(--text-muted)" }}></span>;
const cust = customerMap[device.customer_id];
const fullName = cust ? [cust.name, cust.surname].filter(Boolean).join(" ") : "";
return (
<span className="text-sm font-medium" style={{ color: fullName ? "var(--text-primary)" : "var(--text-muted)", whiteSpace: "nowrap" }}>
{fullName || "—"}
</span>
);
}
case "users":
return <UsersHoverCell userList={device.user_list || []} resolvedUsersMap={resolvedUsersMap} />;
case "name": return <span className="text-xs" style={{ color: "var(--text-muted)" }}>{device.device_name || "—"}</span>;
default: return null;
}
@@ -845,6 +980,17 @@ export default function DeviceInventory() {
onChange={updateColVisible}
onReorder={updateColOrder}
/>
<select
value={String(pageSize)}
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
className="px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
>
<option value="10">Show 10</option>
<option value="20">Show 20</option>
<option value="50">Show 50</option>
<option value="0">Show All</option>
</select>
</div>
{/* Multi-select action bar */}
@@ -884,24 +1030,29 @@ export default function DeviceInventory() {
{/* Table */}
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
<div className="overflow-x-auto">
<div className="overflow-x-auto" style={{ overflowX: "auto" }}>
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
{/* Checkbox column */}
<th className="px-3 py-3 w-10">
<input
type="checkbox"
checked={allSelected}
onChange={toggleAll}
className="cursor-pointer"
/>
</th>
{visibleCols.map((col) => (
<th key={col.id} className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>
{visibleCols.map((col, colIdx) => (
<th key={col.id}
className="py-3 font-medium"
style={{
color: "var(--text-muted)",
textAlign: col.id === "status" ? "center" : "left",
whiteSpace: "nowrap",
paddingLeft: 16,
paddingRight: colIdx === visibleCols.length - 1 ? 4 : 16,
}}>
{col.label}
</th>
))}
{/* Checkbox header — only shown when something is selected */}
<th style={{ width: 36, minWidth: 36, padding: "0 8px", textAlign: "center" }}>
{selected.size > 0 && (
<input type="checkbox" checked={allSelected} onChange={toggleAll} className="cursor-pointer" />
)}
</th>
</tr>
</thead>
<tbody>
@@ -918,38 +1069,54 @@ export default function DeviceInventory() {
</td>
</tr>
) : (
devices.map((device) => {
pagedDevices.map((device, rowIdx) => {
const isSelected = selected.has(device.id);
const isAlt = rowIdx % 2 === 1;
const isHovered = hoveredRow === device.id;
const showCheckbox = isSelected || selected.size > 0 || isHovered;
return (
<tr
key={device.id}
className="cursor-pointer transition-colors"
style={{
borderBottom: "1px solid var(--border-secondary)",
backgroundColor: isSelected ? "var(--bg-card)" : "",
backgroundColor: isSelected ? "var(--bg-card)" : isAlt ? "var(--bg-row-alt)" : "",
}}
onMouseEnter={(e) => {
setHoveredRow(device.id);
if (!isSelected) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)";
}}
onMouseLeave={(e) => {
setHoveredRow(null);
if (!isSelected) e.currentTarget.style.backgroundColor = isAlt ? "var(--bg-row-alt)" : "";
}}
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = ""; }}
>
{/* Checkbox */}
<td className="px-3 py-3" onClick={(e) => toggleRow(device.id, e)}>
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="cursor-pointer"
/>
</td>
{/* Data cells */}
{visibleCols.map((col) => (
{visibleCols.map((col, colIdx) => (
<td
key={col.id}
className="px-4 py-3"
className="py-3"
style={{
textAlign: col.id === "status" ? "center" : "left",
paddingLeft: 16,
paddingRight: colIdx === visibleCols.length - 1 ? 4 : 16,
}}
onClick={() => navigate(`/manufacturing/devices/${device.serial_number}`)}
>
{renderCell(col, device)}
</td>
))}
{/* Hover-reveal checkbox on the right */}
<td style={{ width: 36, minWidth: 36, padding: "0 8px", textAlign: "center" }}
onClick={(e) => toggleRow(device.id, e)}>
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="cursor-pointer"
style={{ opacity: showCheckbox ? 1 : 0, transition: "opacity 0.15s" }}
/>
</td>
</tr>
);
})
@@ -957,6 +1124,42 @@ export default function DeviceInventory() {
</tbody>
</table>
</div>
{pageSize > 0 && totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: "var(--border-primary)" }}>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
Page {safePage} of {totalPages} {devices.length} total
</span>
<div className="flex items-center gap-1">
<button onClick={() => setPage(1)} disabled={safePage === 1}
className="px-2 py-1 text-xs rounded disabled:opacity-40 cursor-pointer"
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}>«</button>
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={safePage === 1}
className="px-2 py-1 text-xs rounded disabled:opacity-40 cursor-pointer"
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}></button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === totalPages || Math.abs(p - safePage) <= 2)
.reduce((acc, p, idx, arr) => {
if (idx > 0 && p - arr[idx - 1] > 1) acc.push("…");
acc.push(p);
return acc;
}, [])
.map((p, idx) => p === "…"
? <span key={`e${idx}`} className="px-1 text-xs" style={{ color: "var(--text-muted)" }}></span>
: <button key={p} onClick={() => setPage(p)}
className="px-2 py-1 text-xs rounded cursor-pointer"
style={{ backgroundColor: p === safePage ? "var(--accent)" : "var(--bg-card-hover)", color: p === safePage ? "var(--bg-primary)" : "var(--text-secondary)", fontWeight: p === safePage ? 700 : 400 }}>
{p}
</button>
)}
<button onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={safePage === totalPages}
className="px-2 py-1 text-xs rounded disabled:opacity-40 cursor-pointer"
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}></button>
<button onClick={() => setPage(totalPages)} disabled={safePage === totalPages}
className="px-2 py-1 text-xs rounded disabled:opacity-40 cursor-pointer"
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}>»</button>
</div>
</div>
)}
</div>
{/* Modals */}

File diff suppressed because it is too large Load Diff

View File

@@ -508,13 +508,168 @@ function BoardTypeTile({ bt, isSelected, pal, onClick }) {
);
}
// ─── Bespoke Picker Modal ─────────────────────────────────────────────────────
function BespokePickerModal({ onConfirm, onClose }) {
const [firmwares, setFirmwares] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [selected, setSelected] = useState(null);
const [hwFamily, setHwFamily] = useState("");
useEffect(() => {
api.get("/firmware?hw_type=bespoke")
.then((data) => setFirmwares(data.firmware || []))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
const handleConfirm = () => {
if (!selected) return;
onConfirm({ firmware: selected, hwFamily });
};
return (
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
<div
className="rounded-lg border flex flex-col"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
width: "100%", maxWidth: 560,
maxHeight: "80vh",
margin: "1rem",
}}
>
{/* Header */}
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: "var(--border-primary)" }}>
<div>
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Select Bespoke Firmware</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Choose a bespoke firmware and the hardware family to register in NVS.
</p>
</div>
<button onClick={onClose} className="cursor-pointer hover:opacity-70 transition-opacity" style={{ color: "var(--text-muted)" }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-5 space-y-4" style={{ minHeight: 0 }}>
{loading && (
<p className="text-sm text-center py-6" style={{ color: "var(--text-muted)" }}>Loading bespoke firmwares</p>
)}
{error && (
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{!loading && !error && firmwares.length === 0 && (
<p className="text-sm text-center py-6" style={{ color: "var(--text-muted)" }}>
No bespoke firmwares uploaded yet. Upload one from the Firmware Manager.
</p>
)}
{firmwares.length > 0 && (
<div className="rounded-md border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>UID</th>
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>Version</th>
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>Channel</th>
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>Size</th>
</tr>
</thead>
<tbody>
{firmwares.map((fw) => {
const isSelected = selected?.id === fw.id;
return (
<tr
key={fw.id}
onClick={() => setSelected(fw)}
className="cursor-pointer transition-colors"
style={{
borderBottom: "1px solid var(--border-secondary)",
backgroundColor: isSelected ? "var(--badge-blue-bg)" : "",
outline: isSelected ? "1px solid var(--badge-blue-text)" : "none",
}}
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = ""; }}
>
<td className="px-3 py-2.5 font-mono text-xs font-medium" style={{ color: "var(--text-primary)" }}>
{fw.bespoke_uid || "—"}
</td>
<td className="px-3 py-2.5 font-mono text-xs" style={{ color: "var(--text-secondary)" }}>{fw.version}</td>
<td className="px-3 py-2.5 text-xs" style={{ color: "var(--text-muted)" }}>{fw.channel}</td>
<td className="px-3 py-2.5 text-xs" style={{ color: "var(--text-muted)" }}>
{fw.size_bytes ? `${(fw.size_bytes / 1024).toFixed(1)} KB` : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* HW Family — free text */}
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
Hardware Family for NVS
</label>
<p className="text-xs mb-2" style={{ color: "var(--text-muted)" }}>
This value will be written to NVS as <span style={{ fontFamily: "monospace" }}>hw_type</span>. The device will identify as this family.
</p>
<input
type="text"
value={hwFamily}
onChange={(e) => setHwFamily(e.target.value)}
placeholder="e.g. vesper_plus"
className="w-full px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
</div>
</div>
{/* Footer */}
<div className="px-5 py-4 border-t flex items-center justify-between gap-3" style={{ borderColor: "var(--border-primary)" }}>
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
hw_revision will be set to <span style={{ fontFamily: "monospace" }}>1.0</span> for bespoke devices.
</p>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={!selected || !hwFamily.trim()}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-40"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Continue
</button>
</div>
</div>
</div>
</div>
);
}
// ─── Step 1b — Deploy New: pick board type + revision ─────────────────────────
function StepDeployNew({ onSelected, onCreatedSn }) {
function StepDeployNew({ onSelected, onCreatedSn, onBespokeSelected }) {
const [boardType, setBoardType] = useState(null);
const [boardVersion, setBoardVersion] = useState("1.0");
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
const [showBespokePicker, setShowBespokePicker] = useState(false);
const handleCreate = async () => {
if (!boardType || !boardVersion.trim()) return;
@@ -537,6 +692,11 @@ function StepDeployNew({ onSelected, onCreatedSn }) {
}
};
const handleBespokeConfirm = ({ firmware, hwFamily }) => {
setShowBespokePicker(false);
onBespokeSelected({ firmware, hwFamily });
};
// Group boards by family for row layout
const vesperBoards = BOARD_TYPES.filter((b) => b.family === "vesper");
const agnusBoards = BOARD_TYPES.filter((b) => b.family === "agnus");
@@ -626,6 +786,40 @@ function StepDeployNew({ onSelected, onCreatedSn }) {
</button>
</div>
</div>
{/* Bespoke divider */}
<div className="flex items-center gap-3 pt-1">
<div style={{ flex: 1, height: 1, backgroundColor: "var(--border-secondary)" }} />
<span className="text-xs" style={{ color: "var(--text-muted)" }}>or</span>
<div style={{ flex: 1, height: 1, backgroundColor: "var(--border-secondary)" }} />
</div>
{/* Bespoke option */}
<div
className="rounded-lg border p-4 flex items-center justify-between gap-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div>
<p className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Select Bespoke Firmware</p>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Flash a one-off bespoke firmware with a custom hardware family written to NVS.
</p>
</div>
<button
onClick={() => setShowBespokePicker(true)}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)", flexShrink: 0 }}
>
Select Bespoke
</button>
</div>
{showBespokePicker && (
<BespokePickerModal
onConfirm={handleBespokeConfirm}
onClose={() => setShowBespokePicker(false)}
/>
)}
</div>
);
}
@@ -643,7 +837,7 @@ function InfoCell({ label, value, mono = false }) {
// ─── Step 2 — Flash ────────────────────────────────────────────────────────────
function StepFlash({ device, onFlashed }) {
function StepFlash({ device, bespokeOverride, onFlashed }) {
const [portConnected, setPortConnected] = useState(false);
const [portName, setPortName] = useState("");
const [connecting, setConnecting] = useState(false);
@@ -791,20 +985,35 @@ function StepFlash({ device, onFlashed }) {
// 1. Fetch binaries
const sn = device.serial_number;
// For bespoke: use the hwFamily's flash assets and override NVS params.
// The bootloader/partitions endpoints accept an optional hw_type_override query param.
const blUrl = bespokeOverride
? `/api/manufacturing/devices/${sn}/bootloader.bin?hw_type_override=${bespokeOverride.hwFamily}`
: `/api/manufacturing/devices/${sn}/bootloader.bin`;
const partUrl = bespokeOverride
? `/api/manufacturing/devices/${sn}/partitions.bin?hw_type_override=${bespokeOverride.hwFamily}`
: `/api/manufacturing/devices/${sn}/partitions.bin`;
const nvsUrl = bespokeOverride
? `/api/manufacturing/devices/${sn}/nvs.bin?hw_type_override=${bespokeOverride.hwFamily}&hw_revision_override=1.0`
: `/api/manufacturing/devices/${sn}/nvs.bin`;
appendLog("Fetching bootloader binary…");
const blBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/bootloader.bin`);
const blBuffer = await fetchBinary(blUrl);
appendLog(`Bootloader: ${blBuffer.byteLength} bytes`);
appendLog("Fetching partition table binary…");
const partBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/partitions.bin`);
const partBuffer = await fetchBinary(partUrl);
appendLog(`Partition table: ${partBuffer.byteLength} bytes`);
appendLog("Fetching NVS binary…");
const nvsBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/nvs.bin`);
const nvsBuffer = await fetchBinary(nvsUrl);
appendLog(`NVS: ${nvsBuffer.byteLength} bytes`);
appendLog("Fetching firmware binary…");
const fwBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/firmware.bin`);
const fwUrl = bespokeOverride
? `/api/firmware/bespoke/${bespokeOverride.firmware.channel}/${bespokeOverride.firmware.version}/firmware.bin`
: `/api/manufacturing/devices/${sn}/firmware.bin`;
const fwBuffer = await fetchBinary(fwUrl);
appendLog(`Firmware: ${fwBuffer.byteLength} bytes`);
// 2. Connect ESPLoader
@@ -924,7 +1133,23 @@ function StepFlash({ device, onFlashed }) {
<div className="grid grid-cols-2 gap-3 mb-4">
<InfoCell label="Serial Number" value={device.serial_number} mono />
{bespokeOverride ? (
<>
<InfoCell label="NVS hw_type" value={bespokeOverride.hwFamily} />
<InfoCell label="NVS hw_revision" value="1.0" />
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Firmware</p>
<p className="text-xs font-mono" style={{ color: "#fb923c" }}>
BESPOKE · {bespokeOverride.firmware.bespoke_uid}
</p>
<p className="text-xs font-mono mt-0.5" style={{ color: "var(--text-muted)" }}>
v{bespokeOverride.firmware.version} / {bespokeOverride.firmware.channel}
</p>
</div>
</>
) : (
<InfoCell label="Board" value={`${BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} ${formatHwVersion(device.hw_version)}`} />
)}
</div>
{!webSerialAvailable && (
@@ -1382,6 +1607,8 @@ export default function ProvisioningWizard() {
const [mode, setMode] = useState(null); // "existing" | "new"
const [device, setDevice] = useState(null);
const [createdSn, setCreatedSn] = useState(null); // SN created in Deploy New, for abort cleanup
// Bespoke override: { firmware: FirmwareVersion, hwFamily: string } | null
const [bespokeOverride, setBespokeOverride] = useState(null);
const handleModePicked = (m) => {
setMode(m);
@@ -1389,10 +1616,38 @@ export default function ProvisioningWizard() {
};
const handleDeviceSelected = (dev) => {
setBespokeOverride(null);
setDevice(dev);
setStep(2);
};
// Called from StepDeployNew when user picks a bespoke firmware.
// We skip serial generation and go straight to flash with a synthetic device stub.
const handleBespokeSelected = ({ firmware, hwFamily }) => {
setBespokeOverride({ firmware, hwFamily });
// Create a minimal device-like object — serial will be generated on this step
// but for bespoke we still need a real serial. Trigger normal Deploy New flow
// with a placeholder boardType that maps to hwFamily, then override at flash time.
// For simplicity: generate a serial with board_type=hwFamily, board_version="1.0".
(async () => {
try {
const batch = await api.post("/manufacturing/batch", {
board_type: "vesper", // placeholder — NVS will be overridden with the bespoke hwFamily
board_version: "1.0",
quantity: 1,
});
const sn = batch.serial_numbers[0];
setCreatedSn(sn);
const dev = await api.get(`/manufacturing/devices/${sn}`);
setDevice(dev);
setStep(2);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to create bespoke serial:", err);
}
})();
};
const handleFlashed = () => setStep(3);
const handleVerified = (updatedDevice) => {
@@ -1410,9 +1665,11 @@ export default function ProvisioningWizard() {
setStep(0);
setMode(null);
setDevice(null);
setBespokeOverride(null);
} else if (step === 2) {
setStep(1);
setDevice(null);
setBespokeOverride(null);
} else if (step === 3) {
setStep(2);
}
@@ -1426,11 +1683,13 @@ export default function ProvisioningWizard() {
setStep(0);
setMode(null);
setDevice(null);
setBespokeOverride(null);
};
const handleProvisionNext = () => {
setDevice(null);
setCreatedSn(null);
setBespokeOverride(null);
setStep(0);
setMode(null);
};
@@ -1511,11 +1770,12 @@ export default function ProvisioningWizard() {
<StepDeployNew
onSelected={handleDeviceSelected}
onCreatedSn={(sn) => setCreatedSn(sn)}
onBespokeSelected={handleBespokeSelected}
/>
)}
{step === 2 && device && (
<StepFlash device={device} onFlashed={handleFlashed} />
<StepFlash device={device} bespokeOverride={bespokeOverride} onFlashed={handleFlashed} />
)}
{step === 3 && device && (
<StepVerify device={device} onVerified={handleVerified} />

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, useLocation } from "react-router-dom";
import api from "../api/client";
const MAX_NOTES = 16;
@@ -106,10 +106,48 @@ function playStep(audioCtx, stepValue, noteDurationMs) {
}
}
function csvToSteps(csv) {
if (!csv || !csv.trim()) return null;
return csv.trim().split(",").map((token) => {
const parts = token.split("+");
let val = 0;
for (const p of parts) {
const n = parseInt(p.trim(), 10);
if (!isNaN(n) && n >= 1 && n <= 16) val |= (1 << (n - 1));
}
return val;
});
}
export default function MelodyComposer() {
const navigate = useNavigate();
const [steps, setSteps] = useState(Array.from({ length: 16 }, () => 0));
const [noteCount, setNoteCount] = useState(8);
const { state: routeState } = useLocation();
const loadedArchetype = routeState?.archetype || null;
const initialSteps = () => {
if (loadedArchetype?.steps) {
const parsed = csvToSteps(loadedArchetype.steps);
if (parsed?.length) return parsed;
}
return Array.from({ length: 16 }, () => 0);
};
const [steps, setSteps] = useState(initialSteps);
const [noteCount, setNoteCount] = useState(() => {
if (loadedArchetype?.steps) {
const parsed = csvToSteps(loadedArchetype.steps);
if (parsed?.length) {
let maxBit = 0;
for (const v of parsed) {
for (let b = 15; b >= 0; b--) {
if (v & (1 << b)) { maxBit = Math.max(maxBit, b + 1); break; }
}
}
return Math.max(8, maxBit);
}
}
return 8;
});
const [stepDelayMs, setStepDelayMs] = useState(280);
const [noteDurationMs, setNoteDurationMs] = useState(110);
const [measureEvery, setMeasureEvery] = useState(4);
@@ -123,6 +161,7 @@ export default function MelodyComposer() {
const [deployPid, setDeployPid] = useState("");
const [deployError, setDeployError] = useState("");
const [deploying, setDeploying] = useState(false);
const [deployMode, setDeployMode] = useState("new"); // "new" | "update"
const [noteColors, setNoteColors] = useState([]);
const [stepMenuIndex, setStepMenuIndex] = useState(null);
@@ -290,10 +329,18 @@ export default function MelodyComposer() {
scheduleStep(0);
};
const openDeployModal = () => {
const openDeployModal = (mode = "new") => {
setError("");
setSuccessMsg("");
setDeployError("");
setDeployMode(mode);
if (mode === "update" && loadedArchetype) {
setDeployName(loadedArchetype.name || "");
setDeployPid(loadedArchetype.pid || "");
} else {
setDeployName("");
setDeployPid("");
}
setShowDeployModal(true);
};
@@ -306,45 +353,34 @@ export default function MelodyComposer() {
const handleDeploy = async () => {
const name = deployName.trim();
const pid = deployPid.trim();
if (!name) {
setDeployError("Name is required.");
return;
}
if (!pid) {
setDeployError("PID is required.");
return;
}
if (!name) { setDeployError("Name is required."); return; }
if (!pid) { setDeployError("PID is required."); return; }
setDeploying(true);
setDeployError("");
setSuccessMsg("");
try {
const stepsStr = steps.map(stepToNotation).join(",");
if (deployMode === "update" && loadedArchetype?.id) {
const updated = await api.put(`/builder/melodies/${loadedArchetype.id}`, { name, pid, steps: stepsStr });
setSuccessMsg(`Archetype "${name}" updated successfully.`);
setShowDeployModal(false);
if (updated?.id) navigate(`/melodies/archetypes/${updated.id}`);
} else {
const existing = await api.get("/builder/melodies");
const list = existing.melodies || [];
const dupName = list.find((m) => m.name.toLowerCase() === name.toLowerCase());
if (dupName) {
setDeployError(`An archetype with the name "${name}" already exists.`);
return;
}
if (dupName) { setDeployError(`An archetype with the name "${name}" already exists.`); return; }
const dupPid = list.find((m) => m.pid && m.pid.toLowerCase() === pid.toLowerCase());
if (dupPid) {
setDeployError(`An archetype with the PID "${pid}" already exists.`);
return;
}
const stepsStr = steps.map(stepToNotation).join(",");
const created = await api.post("/builder/melodies", {
name,
pid,
steps: stepsStr,
});
if (dupPid) { setDeployError(`An archetype with the PID "${pid}" already exists.`); return; }
const created = await api.post("/builder/melodies", { name, pid, steps: stepsStr });
setSuccessMsg(`Archetype "${name}" deployed successfully.`);
setShowDeployModal(false);
setDeployName("");
setDeployPid("");
if (created?.id) {
navigate(`/melodies/archetypes/${created.id}`);
if (created?.id) navigate(`/melodies/archetypes/${created.id}`);
}
} catch (err) {
setDeployError(err.message);
@@ -375,6 +411,24 @@ export default function MelodyComposer() {
</p>
</div>
{loadedArchetype && (
<div className="rounded-lg border px-4 py-3 flex items-center gap-3"
style={{ backgroundColor: "rgba(139,92,246,0.08)", borderColor: "rgba(139,92,246,0.3)" }}>
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: "#a78bfa" }}>Editing Archetype</span>
<span className="text-sm font-medium" style={{ color: "var(--text-heading)" }}>{loadedArchetype.name}</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>·</span>
<span className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{loadedArchetype.id}</span>
<button
type="button"
onClick={() => navigate("/melodies/composer", { replace: true, state: null })}
className="ml-auto text-xs px-2 py-1 rounded"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
>
Clear
</button>
</div>
)}
{error && (
<div
className="text-sm rounded-md p-3 border"
@@ -422,7 +476,14 @@ export default function MelodyComposer() {
)}
<span>{steps.length} steps, {noteCount} notes</span>
</div>
<button type="button" onClick={openDeployModal} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}>Deploy Archetype</button>
{loadedArchetype ? (
<>
<button type="button" onClick={() => openDeployModal("new")} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--btn-neutral)", color: "var(--text-white)" }}>Deploy as New Archetype</button>
<button type="button" onClick={() => openDeployModal("update")} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}>Update Current Archetype</button>
</>
) : (
<button type="button" onClick={() => openDeployModal("new")} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}>Deploy Archetype</button>
)}
</div>
</div>
@@ -666,10 +727,12 @@ export default function MelodyComposer() {
>
<div>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
Deploy Archetype
{deployMode === "update" ? "Update Archetype" : "Deploy Archetype"}
</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Create a new archetype from this composer pattern.
{deployMode === "update"
? "Rebuild the existing archetype with the current composer pattern."
: "Create a new archetype from this composer pattern."}
</p>
</div>
<button
@@ -744,7 +807,7 @@ export default function MelodyComposer() {
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
disabled={deploying}
>
{deploying ? "Deploying..." : "Deploy"}
{deploying ? (deployMode === "update" ? "Updating..." : "Deploying...") : (deployMode === "update" ? "Update" : "Deploy")}
</button>
</div>
</div>

View File

@@ -120,7 +120,6 @@ export default function MelodyDetail() {
const [showSpeedCalc, setShowSpeedCalc] = useState(false);
const [showPlayback, setShowPlayback] = useState(false);
const [showBinaryView, setShowBinaryView] = useState(false);
const [offlineSaving, setOfflineSaving] = useState(false);
useEffect(() => {
api.get("/settings/melody").then((ms) => {
@@ -192,31 +191,6 @@ export default function MelodyDetail() {
}
};
const handleToggleAvailableOffline = async (nextValue) => {
if (!canEdit || !melody) return;
setOfflineSaving(true);
setError("");
try {
const body = {
information: { ...(melody.information || {}), available_offline: nextValue },
default_settings: melody.default_settings || {},
type: melody.type || "all",
uid: melody.uid || "",
pid: melody.pid || "",
metadata: melody.metadata || {},
};
if (melody.url) body.url = melody.url;
await api.put(`/melodies/${id}`, body);
setMelody((prev) => ({
...prev,
information: { ...(prev?.information || {}), available_offline: nextValue },
}));
} catch (err) {
setError(err.message);
} finally {
setOfflineSaving(false);
}
};
if (loading) {
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
@@ -345,6 +319,26 @@ export default function MelodyDetail() {
)}
</div>
{info.outdated_archetype && (
<div
className="flex items-start gap-3 rounded-md px-4 py-3 text-sm border mb-6"
style={{
backgroundColor: "rgba(245,158,11,0.08)",
borderColor: "rgba(245,158,11,0.35)",
color: "#f59e0b",
}}
>
<svg className="mt-0.5 shrink-0" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span>
<strong>Outdated Archetype</strong> The archetype assigned to this melody has been changed or removed.
Re-assign an archetype in the editor to update and clear this warning.
</span>
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Left column */}
<div className="space-y-6">
@@ -511,20 +505,6 @@ export default function MelodyDetail() {
<h2 className="ui-section-card__title">Files</h2>
</div>
<dl className="space-y-4">
<Field label="Available as Built-In">
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(info.available_offline)}
disabled={!canEdit || offlineSaving}
onChange={(e) => handleToggleAvailableOffline(e.target.checked)}
className="h-4 w-4 rounded"
/>
<span style={{ color: "var(--text-secondary)" }}>
{info.available_offline ? "Enabled" : "Disabled"}
</span>
</label>
</Field>
<Field label="Binary File">
{(() => {
// Common source of truth: assigned archetype binary first, then melody URL, then uploaded file URL.

View File

@@ -32,7 +32,6 @@ const defaultInfo = {
steps: 0,
color: "",
isTrueRing: false,
available_offline: false,
previewURL: "",
};
@@ -757,18 +756,6 @@ export default function MelodyForm() {
<h2 className="ui-section-card__title">Files</h2>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="available-offline"
checked={Boolean(information.available_offline)}
onChange={(e) => updateInfo("available_offline", e.target.checked)}
className="h-4 w-4 rounded"
/>
<label htmlFor="available-offline" className="text-sm font-medium" style={labelStyle}>
Available as Built-In
</label>
</div>
<div>
<label className="ui-form-label">Binary File (.bsm)</label>
{(() => {
@@ -1024,6 +1011,7 @@ export default function MelodyForm() {
if (archetype.steps != null) updateInfo("steps", archetype.steps);
if (archetype.totalNotes != null) updateInfo("totalNotes", archetype.totalNotes);
if (archetype.archetype_csv != null) updateInfo("archetype_csv", archetype.archetype_csv);
updateInfo("outdated_archetype", false);
if (isEdit) {
loadMelody();
} else {
@@ -1059,6 +1047,7 @@ export default function MelodyForm() {
if (archetype.steps != null) updateInfo("steps", archetype.steps);
if (archetype.totalNotes != null) updateInfo("totalNotes", archetype.totalNotes);
if (archetype.archetype_csv != null) updateInfo("archetype_csv", archetype.archetype_csv);
updateInfo("outdated_archetype", false);
if (isEdit) {
loadMelody();
} else {

View File

@@ -40,7 +40,6 @@ const ALL_COLUMNS = [
{ key: "pauseDuration", label: "Pause", defaultOn: false },
{ key: "infiniteLoop", label: "Infinite", defaultOn: false },
{ key: "noteAssignments", label: "Note Assignments", defaultOn: false },
{ key: "builtIn", label: "Built-in", defaultOn: false },
{ key: "binaryFile", label: "Binary File", defaultOn: false },
{ key: "dateCreated", label: "Date Created", defaultOn: false },
{ key: "dateEdited", label: "Last Edited", defaultOn: false },
@@ -300,57 +299,6 @@ function formatHex16(value) {
return `0x${(Number(value || 0) & 0xffff).toString(16).toUpperCase().padStart(4, "0")}`;
}
function buildOfflineCppCode(rows) {
const selected = (rows || []).filter((row) => Boolean(row?.information?.available_offline));
const generatedAt = new Date().toISOString().replace("T", " ").slice(0, 19);
if (selected.length === 0) {
return `// Generated: ${generatedAt}\n// No melodies marked as built-in.\n`;
}
const arrays = [];
const libraryEntries = [];
for (const row of selected) {
const info = row?.information || {};
const displayName = getLocalizedValue(info.name, "en", getLocalizedValue(info.name, "en", "Untitled Melody"));
const uid = row?.uid || "";
const symbol = `melody_builtin_${toSafeCppSymbol(uid || displayName)}`;
const steps = parseArchetypeCsv(info.archetype_csv);
const stepCount = Number(info.steps || 0);
arrays.push(`// Melody: ${escapeCppString(displayName)} | UID: ${escapeCppString(uid || "missing_uid")}`);
arrays.push(`const uint16_t PROGMEM ${symbol}[] = {`);
if (steps.length === 0) {
arrays.push(" // No archetype_csv step data found");
} else {
for (let i = 0; i < steps.length; i += 8) {
const chunk = steps.slice(i, i + 8).map(formatHex16).join(", ");
arrays.push(` ${chunk}${i + 8 < steps.length ? "," : ""}`);
}
}
arrays.push("};");
arrays.push("");
libraryEntries.push(" {");
libraryEntries.push(` "${escapeCppString(displayName)}",`);
libraryEntries.push(` "${escapeCppString(uid || toSafeCppSymbol(displayName))}",`);
libraryEntries.push(` ${symbol},`);
libraryEntries.push(` ${stepCount > 0 ? stepCount : steps.length}`);
libraryEntries.push(" }");
}
return [
`// Generated: ${generatedAt}`,
"",
...arrays,
"// --- Add or replace your MELODY_LIBRARY[] with this: ---",
"const MelodyInfo MELODY_LIBRARY[] = {",
libraryEntries.map((entry, idx) => `${entry}${idx < libraryEntries.length - 1 ? "," : ""}`).join("\n"),
"};",
"",
].join("\n");
}
export default function MelodyList() {
const [melodies, setMelodies] = useState([]);
@@ -372,8 +320,6 @@ export default function MelodyList() {
const [actionLoading, setActionLoading] = useState(null);
const [colPrefs, setColPrefs] = useState(loadColumnPrefs);
const [showCreatorPicker, setShowCreatorPicker] = useState(false);
const [showOfflineModal, setShowOfflineModal] = useState(false);
const [builtInSavingId, setBuiltInSavingId] = useState(null);
const [viewRow, setViewRow] = useState(null);
const [builtMap, setBuiltMap] = useState({});
const [pageSize, setPageSize] = useState(20);
@@ -606,36 +552,6 @@ export default function MelodyList() {
setViewRow(row);
};
const updateBuiltInState = async (e, row, nextValue) => {
e.stopPropagation();
if (!canEdit) return;
setBuiltInSavingId(row.id);
setError("");
try {
const body = {
information: { ...(row.information || {}), available_offline: nextValue },
default_settings: row.default_settings || {},
type: row.type || "all",
uid: row.uid || "",
pid: row.pid || "",
metadata: row.metadata || {},
};
if (row.url) body.url = row.url;
await api.put(`/melodies/${row.id}`, body);
setMelodies((prev) =>
prev.map((m) =>
m.id === row.id
? { ...m, information: { ...(m.information || {}), available_offline: nextValue } }
: m
)
);
} catch (err) {
setError(err.message);
} finally {
setBuiltInSavingId(null);
}
};
const toggleCreator = (creator) => {
setCreatedByFilter((prev) =>
prev.includes(creator) ? prev.filter((c) => c !== creator) : [...prev, creator]
@@ -695,16 +611,11 @@ export default function MelodyList() {
? displayRows.slice((safePage - 1) * pageSize, safePage * pageSize)
: displayRows;
const offlineTaggedCount = useMemo(
() => displayRows.filter((row) => Boolean(row?.information?.available_offline)).length,
[displayRows]
);
const hasAnyFilter = Boolean(
search || typeFilter || toneFilter || statusFilter || createdByFilter.length > 0
);
const offlineCode = useMemo(() => buildOfflineCppCode(displayRows), [displayRows]);
const handleSortClick = (columnKey) => {
const nextSortKey = SORTABLE_COLUMN_TO_KEY[columnKey];
if (!nextSortKey) return;
@@ -729,8 +640,10 @@ export default function MelodyList() {
const metadata = row.metadata || {};
switch (key) {
case "status":
case "status": {
const isOutdated = Boolean(info.outdated_archetype);
return (
<div className="flex flex-col items-start gap-1">
<span
className="px-2 py-0.5 text-xs font-semibold rounded-full"
style={
@@ -741,7 +654,16 @@ export default function MelodyList() {
>
{row.status === "published" ? "Live" : "Draft"}
</span>
{isOutdated && (
<span className="px-1.5 py-0.5 text-xs rounded-full font-medium"
style={{ backgroundColor: "rgba(245,158,11,0.15)", color: "#f59e0b", border: "1px solid rgba(245,158,11,0.3)" }}
title="This melody's archetype has been changed or removed. Re-assign an archetype to clear this.">
Outdated
</span>
)}
</div>
);
}
case "color":
return info.color ? (
<span
@@ -754,11 +676,14 @@ export default function MelodyList() {
);
case "name": {
const description = getLocalizedValue(info.description, displayLang) || "-";
const isOutdated = Boolean(info.outdated_archetype);
return (
<div>
<span className="font-medium" style={{ color: "var(--text-heading)" }}>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium" style={{ color: isOutdated ? "#f59e0b" : "var(--text-heading)" }}>
{getDisplayName(info.name)}
</span>
</div>
{isVisible("description") && (
<p
className="text-xs mt-0.5 truncate max-w-xs"
@@ -928,36 +853,6 @@ export default function MelodyList() {
) : (
"-"
);
case "builtIn": {
const enabled = Boolean(info.available_offline);
const saving = builtInSavingId === row.id;
return (
<button
type="button"
onClick={(e) => updateBuiltInState(e, row, !enabled)}
disabled={!canEdit || saving}
className="inline-flex items-center gap-2 cursor-pointer disabled:opacity-50"
style={{ background: "none", border: "none", padding: 0, color: "var(--text-secondary)" }}
title={canEdit ? "Click to toggle built-in availability" : "Built-in availability"}
>
<span
className="w-4 h-4 rounded-full border inline-flex items-center justify-center"
style={{
borderColor: enabled ? "rgba(34,197,94,0.7)" : "var(--border-primary)",
backgroundColor: enabled ? "rgba(34,197,94,0.15)" : "transparent",
}}
>
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: enabled ? "#22c55e" : "transparent" }}
/>
</span>
<span className="text-xs" style={{ color: enabled ? "var(--success-text)" : "var(--text-muted)" }}>
{enabled ? "Yes" : "No"}
</span>
</button>
);
}
case "binaryFile": {
const resolved = resolveEffectiveBinary(row);
const binaryUrl = resolved.url;
@@ -1068,23 +963,9 @@ export default function MelodyList() {
<div className="flex items-center gap-3 flex-wrap">
<span className="text-sm whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
{hasAnyFilter
? `Filtered — ${displayRows.length} / ${allMelodyCount || melodies.length} | ${offlineTaggedCount} offline-tagged`
: `Showing all (${allMelodyCount || melodies.length}) | ${offlineTaggedCount} tagged for Offline`}
? `Filtered — ${displayRows.length} / ${allMelodyCount || melodies.length}`
: `Showing all (${allMelodyCount || melodies.length})`}
</span>
{canEdit && (
<button
type="button"
onClick={() => setShowOfflineModal(true)}
className="px-3 py-2 text-sm rounded-md border transition-colors cursor-pointer whitespace-nowrap"
style={{
borderColor: "var(--border-primary)",
color: "var(--text-secondary)",
backgroundColor: "var(--bg-card)",
}}
>
Build Offline List
</button>
)}
{canEdit && (
<button
onClick={() => navigate("/melodies/new")}
@@ -1427,67 +1308,6 @@ export default function MelodyList() {
onCancel={() => setUnpublishTarget(null)}
/>
{showOfflineModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: "rgba(0,0,0,0.55)" }}
onClick={() => setShowOfflineModal(false)}
>
<div
className="w-full max-w-5xl max-h-[85vh] rounded-lg border shadow-xl flex flex-col"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: "var(--border-primary)" }}>
<div>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Offline Built-In Code</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Includes melodies where Built-in = Yes
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(offlineCode);
} catch {
// ignore
}
}}
className="px-3 py-1.5 text-xs rounded border"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
>
Copy
</button>
<button
type="button"
onClick={() => setShowOfflineModal(false)}
className="px-3 py-1.5 text-xs rounded border"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
>
Close
</button>
</div>
</div>
<div className="p-5 overflow-auto">
<pre
className="text-xs rounded-lg p-4 overflow-auto"
style={{
backgroundColor: "var(--bg-primary)",
color: "var(--text-primary)",
border: "1px solid var(--border-primary)",
maxHeight: "62vh",
whiteSpace: "pre",
}}
>
{offlineCode}
</pre>
</div>
</div>
</div>
)}
<BinaryTableModal
open={!!viewRow}
melody={viewRow || null}

View File

@@ -1,7 +1,9 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, Link } from "react-router-dom";
import api from "../../api/client";
import ConfirmDialog from "../../components/ConfirmDialog";
import PlaybackModal from "../PlaybackModal";
import { getLocalizedValue } from "../melodyUtils";
const mutedStyle = { color: "var(--text-muted)" };
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
@@ -68,12 +70,11 @@ export default function ArchetypeForm() {
const [name, setName] = useState("");
const [pid, setPid] = useState("");
const [steps, setSteps] = useState("");
const [isBuiltin, setIsBuiltin] = useState(false);
const [savedPid, setSavedPid] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [buildingBinary, setBuildingBinary] = useState(false);
const [buildingBuiltin, setBuildingBuiltin] = useState(false);
const [error, setError] = useState("");
const [successMsg, setSuccessMsg] = useState("");
@@ -86,10 +87,19 @@ export default function ArchetypeForm() {
const [showDelete, setShowDelete] = useState(false);
const [deleteWarningConfirmed, setDeleteWarningConfirmed] = useState(false);
const [assignedCount, setAssignedCount] = useState(0);
const [assignedMelodies, setAssignedMelodies] = useState([]); // [{id, nameRaw}]
const [loadingAssigned, setLoadingAssigned] = useState(false);
const [primaryLang, setPrimaryLang] = useState("en");
// Playback
const [showPlayback, setShowPlayback] = useState(false);
// currentBuiltMelody: the live archetype object (for playback + binary_url)
const [currentBuiltMelody, setCurrentBuiltMelody] = useState(null);
const codeRef = useRef(null);
useEffect(() => {
api.get("/settings/melody").then((ms) => setPrimaryLang(ms.primary_language || "en")).catch(() => {});
if (isEdit) loadArchetype();
}, [id]);
@@ -100,12 +110,18 @@ export default function ArchetypeForm() {
setName(data.name || "");
setPid(data.pid || "");
setSteps(data.steps || "");
setIsBuiltin(Boolean(data.is_builtin));
setSavedPid(data.pid || "");
setBinaryBuilt(Boolean(data.binary_path));
setBinaryUrl(data.binary_url || null);
setProgmemCode(data.progmem_code || "");
setAssignedCount(data.assigned_melody_ids?.length || 0);
setHasUnsavedChanges(false);
setCurrentBuiltMelody(data);
// Load assigned melody details
if (data.assigned_melody_ids?.length > 0) {
fetchAssignedMelodies(data.assigned_melody_ids);
}
} catch (err) {
setError(err.message);
} finally {
@@ -113,14 +129,25 @@ export default function ArchetypeForm() {
}
};
const fetchAssignedMelodies = async (ids) => {
setLoadingAssigned(true);
try {
const results = await Promise.allSettled(ids.map((mid) => api.get(`/melodies/${mid}`)));
const details = results
.filter((r) => r.status === "fulfilled" && r.value)
.map((r) => ({ id: r.value.id, nameRaw: r.value.information?.name }));
setAssignedMelodies(details);
} catch { /* best-effort */ }
finally { setLoadingAssigned(false); }
};
const handleNameChange = (v) => { setName(v); setHasUnsavedChanges(true); };
const handlePidChange = (v) => { setPid(v); setHasUnsavedChanges(true); };
const handlePidChange = (v) => { setPid(v.toLowerCase().replace(/[^a-z0-9_]/g, "")); setHasUnsavedChanges(true); };
const handleStepsChange = (v) => { setSteps(v); setHasUnsavedChanges(true); };
const handleSave = async () => {
if (!name.trim()) { setError("Name is required."); return; }
if (!pid.trim()) { setError("PID is required."); return; }
const stepsError = validateSteps(steps);
if (stepsError) { setError(stepsError); return; }
@@ -130,22 +157,27 @@ export default function ArchetypeForm() {
try {
const existing = await api.get("/builder/melodies");
const list = existing.melodies || [];
const dupName = list.find((m) => m.id !== id && m.name.toLowerCase() === name.trim().toLowerCase());
if (dupName) { setError(`An archetype with the name "${name.trim()}" already exists.`); return; }
const dupPid = list.find((m) => m.id !== id && m.pid && m.pid.toLowerCase() === pid.trim().toLowerCase());
if (dupPid) { setError(`An archetype with the PID "${pid.trim()}" already exists.`); return; }
const body = { name: name.trim(), pid: pid.trim(), steps: steps.trim() };
const body = { name: name.trim(), pid: pid.trim(), steps: steps.trim(), is_builtin: isBuiltin };
if (isEdit) {
await api.put(`/builder/melodies/${id}`, body);
// PUT triggers auto-rebuild + outdated flagging on the backend
const updated = await api.put(`/builder/melodies/${id}`, body);
setSavedPid(pid.trim());
setBinaryBuilt(Boolean(updated.binary_path));
setBinaryUrl(updated.binary_url || null);
setProgmemCode(updated.progmem_code || "");
setCurrentBuiltMelody(updated);
setHasUnsavedChanges(false);
setSuccessMsg("Saved.");
setSuccessMsg("Rebuilt and saved successfully.");
} else {
// POST triggers auto-build on the backend
const created = await api.post("/builder/melodies", body);
navigate(`/melodies/archetypes/${created.id}`, { replace: true });
navigate(`/melodies/archetypes/${created.id}`);
}
} catch (err) {
setError(err.message);
@@ -154,41 +186,6 @@ export default function ArchetypeForm() {
}
};
const handleBuildBinary = async () => {
if (!isEdit) { setError("Save the archetype first before building."); return; }
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before rebuilding."); return; }
setBuildingBinary(true);
setError("");
setSuccessMsg("");
try {
const data = await api.post(`/builder/melodies/${id}/build-binary`);
setBinaryBuilt(Boolean(data.binary_path));
setBinaryUrl(data.binary_url || null);
setSuccessMsg("Binary built successfully.");
} catch (err) {
setError(err.message);
} finally {
setBuildingBinary(false);
}
};
const handleBuildBuiltin = async () => {
if (!isEdit) { setError("Save the archetype first before building."); return; }
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before regenerating."); return; }
setBuildingBuiltin(true);
setError("");
setSuccessMsg("");
try {
const data = await api.post(`/builder/melodies/${id}/build-builtin`);
setProgmemCode(data.progmem_code || "");
setSuccessMsg("PROGMEM code generated.");
} catch (err) {
setError(err.message);
} finally {
setBuildingBuiltin(false);
}
};
const handleCopy = () => {
if (!progmemCode) return;
copyText(progmemCode, () => { setCopied(true); setTimeout(() => setCopied(false), 2000); });
@@ -205,12 +202,18 @@ export default function ArchetypeForm() {
}
};
// Build a "virtual" builtMelody for playback using current steps (even if unsaved)
const playbackBuiltMelody = currentBuiltMelody
? { ...currentBuiltMelody, steps: steps }
: { id: "preview", name: name || "Preview", pid: pid || "", steps, binary_url: null };
if (loading) {
return <div className="text-center py-8" style={mutedStyle}>Loading...</div>;
}
return (
<div>
{/* Header row */}
<div className="flex items-center justify-between mb-6">
<div>
<button onClick={() => navigate("/melodies/archetypes")} className="text-sm mb-2 inline-block" style={{ color: "var(--accent)" }}>
@@ -220,30 +223,62 @@ export default function ArchetypeForm() {
{isEdit ? "Edit Archetype" : "New Archetype"}
</h1>
</div>
<div className="flex gap-3">
<div className="flex items-center gap-2">
{/* Section 1: Built-in + Play */}
<button
onClick={() => setIsBuiltin((v) => { setHasUnsavedChanges(true); return !v; })}
className="px-3 py-2 text-sm rounded-md transition-colors flex items-center gap-1.5"
style={isBuiltin
? { backgroundColor: "rgba(59,130,246,0.15)", color: "#60a5fa", border: "1px solid rgba(59,130,246,0.3)" }
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }
}
title="Toggle built-in flag"
>
{isBuiltin ? "Built-in ✓" : "Built-in"}
</button>
<button
onClick={() => setShowPlayback(true)}
disabled={!steps.trim()}
title="Preview current steps"
className="px-3 py-2 text-sm rounded-md transition-colors disabled:opacity-40 flex items-center gap-1.5"
style={{ backgroundColor: "var(--bg-card-hover)", color: "#4ade80", border: "1px solid rgba(74,222,128,0.3)" }}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ flexShrink: 0 }}>
<path d="M3 5C3 2.23858 5.23858 0 8 0C10.7614 0 13 2.23858 13 5V8L15 10V12H1V10L3 8V5Z" fill="currentColor"/>
<path d="M7.99999 16C6.69378 16 5.58254 15.1652 5.1707 14H10.8293C10.4175 15.1652 9.30621 16 7.99999 16Z" fill="currentColor"/>
</svg> Play
</button>
{/* Divider */}
<div style={{ width: 1, height: 24, backgroundColor: "var(--border-primary)", margin: "0 2px" }} />
{/* Section 2: Cancel + Delete */}
<button
onClick={() => navigate("/melodies/archetypes")}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
>
Cancel
</button>
{isEdit && (
<button
onClick={() => setShowDelete(true)}
<button onClick={() => setShowDelete(true)}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
>
style={{ backgroundColor: "var(--danger)", color: "#fff" }}>
Delete
</button>
)}
{/* Divider */}
<div style={{ width: 1, height: 24, backgroundColor: "var(--border-primary)", margin: "0 2px" }} />
{/* Section 3: Save */}
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors"
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{saving ? "Saving..." : isEdit ? "Save Changes" : "Create"}
{saving ? "Saving..." : isEdit ? "Rebuild and Save" : "Create and Build"}
</button>
</div>
</div>
@@ -260,6 +295,7 @@ export default function ArchetypeForm() {
)}
<div className="space-y-6">
{/* ── Archetype Info ────────────────────────────────────────── */}
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Archetype Info</h2>
@@ -267,12 +303,14 @@ export default function ArchetypeForm() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="ui-form-label">Name *</label>
<input type="text" value={name} onChange={(e) => handleNameChange(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} />
<input type="text" value={name} onChange={(e) => handleNameChange(e.target.value)}
placeholder="e.g. Doksologia_3k" className={inputClass} />
</div>
<div>
<label className="ui-form-label">PID (Playback ID) *</label>
<input type="text" value={pid} onChange={(e) => handlePidChange(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} />
<p className="text-xs mt-1" style={mutedStyle}>Used as the built-in firmware identifier. Must be unique.</p>
<input type="text" value={pid} onChange={(e) => handlePidChange(e.target.value)}
placeholder="e.g. builtin_doksologia_3k" className={inputClass} />
<p className="text-xs mt-1" style={mutedStyle}>Lowercase letters, numbers, and underscores only. Must be unique.</p>
</div>
<div className="md:col-span-2">
<div className="flex items-center justify-between mb-1">
@@ -294,13 +332,43 @@ export default function ArchetypeForm() {
</div>
</section>
{/* ── Melodies using this Archetype ─────────────────────────── */}
{isEdit && (
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Build</h2>
<h2 className="ui-section-card__title">Melodies Using This Archetype</h2>
<span className="text-xs px-2 py-0.5 rounded-full ml-2"
style={{ backgroundColor: "rgba(59,130,246,0.12)", color: "#60a5fa" }}>
{assignedCount}
</span>
</div>
<p className="text-sm mb-4" style={mutedStyle}>
Save any changes above before building. Rebuilding will overwrite previous output.
{loadingAssigned ? (
<p className="text-sm" style={mutedStyle}>Loading...</p>
) : assignedMelodies.length === 0 ? (
<p className="text-sm" style={mutedStyle}>None</p>
) : (
<div className="space-y-1 max-h-44 overflow-y-auto">
{assignedMelodies.slice(0, 20).map((m) => (
<Link key={m.id} to={`/melodies/${m.id}`} target="_blank" rel="noopener noreferrer"
className="flex items-center justify-between px-3 py-2 rounded-lg border transition-colors hover:bg-[var(--bg-card-hover)]"
style={{ borderColor: "var(--border-primary)", color: "var(--text-heading)", textDecoration: "none" }}>
<span className="text-sm font-medium">{getLocalizedValue(m.nameRaw, primaryLang, m.id)}</span>
<span className="text-xs" style={{ color: "var(--accent)" }}>Open </span>
</Link>
))}
</div>
)}
</section>
)}
{/* ── Build Output ─────────────────────────────────────────── */}
{isEdit && (
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Build Output</h2>
</div>
<p className="text-sm mb-3" style={mutedStyle}>
Binary and PROGMEM code are auto-rebuilt on every save.
{hasUnsavedChanges && (
<span style={{ color: "var(--warning, #f59e0b)", fontWeight: 600 }}> You have unsaved changes.</span>
)}
@@ -308,94 +376,64 @@ export default function ArchetypeForm() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Binary */}
<div className="rounded-lg p-4 border space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
<div className="rounded-lg p-4 border space-y-2" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Binary (.bsm)</h3>
<p className="text-xs mt-0.5" style={mutedStyle}>For SD card playback on the controller</p>
</div>
{binaryBuilt && (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
Built
<span className={`px-2 py-0.5 text-xs rounded-full ${binaryBuilt ? "" : "opacity-50"}`}
style={binaryBuilt
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
{binaryBuilt ? "Built" : "Not built"}
</span>
)}
</div>
<button
onClick={handleBuildBinary}
disabled={buildingBinary || hasUnsavedChanges}
className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
title={hasUnsavedChanges ? "Save changes first" : undefined}
>
{buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"}
</button>
{binaryUrl && (
<button
type="button"
<button type="button"
onClick={() => downloadBinary(binaryUrl, `${savedPid}.bsm`).catch((e) => setError(e.message))}
className="block w-full text-center text-xs underline cursor-pointer"
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
>
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}>
Download {savedPid}.bsm
</button>
)}
</div>
{/* Builtin Code */}
<div className="rounded-lg p-4 border space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
{/* PROGMEM */}
<div className="rounded-lg p-4 border space-y-2" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>PROGMEM Code</h3>
<p className="text-xs mt-0.5" style={mutedStyle}>For built-in firmware (ESP32 PROGMEM)</p>
</div>
{progmemCode && (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
Generated
<span className={`px-2 py-0.5 text-xs rounded-full ${progmemCode ? "" : "opacity-50"}`}
style={progmemCode
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
{progmemCode ? "Generated" : "Not generated"}
</span>
)}
</div>
<button
onClick={handleBuildBuiltin}
disabled={buildingBuiltin || hasUnsavedChanges}
className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
title={hasUnsavedChanges ? "Save changes first" : undefined}
>
{buildingBuiltin ? "Generating..." : progmemCode ? "Regenerate Code" : "Build Builtin Code"}
</button>
</div>
</div>
{progmemCode && (
<div className="mt-4 rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
<div className="flex items-center justify-between px-4 py-2 border-b" style={{ backgroundColor: "var(--bg-card-hover)", borderColor: "var(--border-primary)" }}>
<div className="flex items-center justify-between px-4 py-2 border-b"
style={{ backgroundColor: "var(--bg-card-hover)", borderColor: "var(--border-primary)" }}>
<span className="text-xs font-medium font-mono" style={{ color: "var(--text-muted)" }}>
PROGMEM C Code copy into your firmware
</span>
<button
onClick={handleCopy}
className="text-xs px-3 py-1 rounded transition-colors"
<button onClick={handleCopy} className="text-xs px-3 py-1 rounded transition-colors"
style={{
backgroundColor: copied ? "var(--success-bg)" : "var(--bg-primary)",
color: copied ? "var(--success-text)" : "var(--text-secondary)",
border: "1px solid var(--border-primary)",
}}
>
}}>
{copied ? "Copied!" : "Copy"}
</button>
</div>
<pre
ref={codeRef}
className="p-4 text-xs overflow-x-auto"
style={{
backgroundColor: "var(--bg-primary)",
color: "var(--text-primary)",
fontFamily: "monospace",
whiteSpace: "pre",
maxHeight: "400px",
overflowY: "auto",
}}
>
<pre ref={codeRef} className="p-4 text-xs overflow-x-auto"
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)", fontFamily: "monospace", whiteSpace: "pre", maxHeight: "400px", overflowY: "auto" }}>
{progmemCode}
</pre>
</div>
@@ -405,7 +443,7 @@ export default function ArchetypeForm() {
{!isEdit && (
<div className="ui-section-card text-sm" style={{ color: "var(--text-muted)" }}>
Build actions (Binary + PROGMEM Code) will be available after saving.
Binary and PROGMEM code will be generated automatically when you click "Create and Build".
</div>
)}
</div>
@@ -413,27 +451,23 @@ export default function ArchetypeForm() {
{/* Delete: two-stage if assigned */}
{showDelete && !deleteWarningConfirmed && assignedCount > 0 && (
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
<div className="w-full max-w-md rounded-lg border shadow-xl p-6 space-y-4" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<div className="w-full max-w-md rounded-lg border shadow-xl p-6 space-y-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Archetype In Use</h3>
<div className="rounded-md p-3 border text-sm" style={{ backgroundColor: "var(--warning-bg, rgba(245,158,11,0.1))", borderColor: "var(--warning, #f59e0b)", color: "var(--warning-text, #f59e0b)" }}>
<div className="rounded-md p-3 border text-sm"
style={{ backgroundColor: "var(--warning-bg, rgba(245,158,11,0.1))", borderColor: "var(--warning, #f59e0b)", color: "var(--warning-text, #f59e0b)" }}>
<strong>"{name}"</strong> is currently assigned to{" "}
<strong>{assignedCount} {assignedCount === 1 ? "melody" : "melodies"}</strong>.
Deleting it will remove the archetype and its binary file, but existing melodies will keep their uploaded binary.
Deleting it will flag those melodies as <strong>outdated</strong>.
</div>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Do you still want to delete this archetype?</p>
<div className="flex justify-end gap-3">
<button
onClick={() => { setShowDelete(false); setDeleteWarningConfirmed(false); }}
<button onClick={() => { setShowDelete(false); setDeleteWarningConfirmed(false); }}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Cancel
</button>
<button
onClick={() => setDeleteWarningConfirmed(true)}
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}>Cancel</button>
<button onClick={() => setDeleteWarningConfirmed(true)}
className="px-4 py-2 text-sm rounded-md transition-colors font-medium"
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
>
style={{ backgroundColor: "var(--danger)", color: "#fff" }}>
Yes, Delete Anyway
</button>
</div>
@@ -448,6 +482,15 @@ export default function ArchetypeForm() {
onConfirm={handleDelete}
onCancel={() => { setShowDelete(false); setDeleteWarningConfirmed(false); }}
/>
<PlaybackModal
open={showPlayback}
melody={null}
builtMelody={playbackBuiltMelody}
files={null}
archetypeCsv={steps}
onClose={() => setShowPlayback(false)}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -121,6 +121,7 @@ export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, curr
archetype_csv: csv,
steps: stepCount,
totalNotes,
outdated_archetype: false,
},
default_settings: currentMelody.default_settings,
type: currentMelody.type,

View File

@@ -69,6 +69,7 @@ export default function SelectArchetypeModal({ open, melodyId, currentMelody, cu
archetype_csv: csv,
steps,
totalNotes,
outdated_archetype: false,
},
default_settings: currentMelody.default_settings,
type: currentMelody.type,

View File

@@ -0,0 +1,133 @@
import { useState, useEffect } from "react";
import api from "../api/client";
export default function PublicFeaturesSettings() {
const [settings, setSettings] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
useEffect(() => {
api.get("/settings/public-features")
.then(setSettings)
.catch((e) => setError(e.message || "Failed to load settings."))
.finally(() => setLoading(false));
}, []);
const handleToggle = async (key, value) => {
setError("");
setSuccess("");
setSaving(true);
try {
const updated = await api.put("/settings/public-features", { [key]: value });
setSettings(updated);
setSuccess("Settings saved.");
setTimeout(() => setSuccess(""), 3000);
} catch (e) {
setError(e.message || "Failed to save settings.");
} finally {
setSaving(false);
}
};
return (
<div>
{/* Page header */}
<div className="mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
Public Features
</h1>
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
Control which public-facing pages are accessible to end-users without requiring a login.
</p>
</div>
{/* Error / success feedback */}
{error && (
<div
className="text-sm rounded-md p-3 border mb-4"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
)}
{success && (
<div
className="text-sm rounded-md p-3 border mb-4"
style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success)", color: "var(--success-text)" }}
>
{success}
</div>
)}
{loading ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading</p>
) : settings ? (
<div className="space-y-3">
<FeatureToggleRow
title="CloudFlash"
description="Allows end-users to flash their own devices via USB directly from the browser. When disabled, the CloudFlash page is inaccessible and displays a maintenance message."
enabled={settings.cloudflash_enabled}
saving={saving}
onToggle={(val) => handleToggle("cloudflash_enabled", val)}
url="/cloudflash"
/>
</div>
) : null}
</div>
);
}
function FeatureToggleRow({ title, description, enabled, saving, onToggle, url }) {
return (
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex items-start justify-between gap-6">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>
{title}
</span>
<span
className="px-2 py-0.5 text-xs rounded-full font-medium"
style={
enabled
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }
}
>
{enabled ? "Live" : "Disabled"}
</span>
</div>
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
{description}
</p>
{url && (
<p className="text-xs mt-1.5 font-mono" style={{ color: "var(--text-link)" }}>
{url}
</p>
)}
</div>
{/* Toggle switch */}
<button
type="button"
disabled={saving}
onClick={() => onToggle(!enabled)}
className="flex-shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
style={{ backgroundColor: enabled ? "var(--accent)" : "var(--border-primary)" }}
aria-checked={enabled}
role="switch"
>
<span
className="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
style={{ transform: enabled ? "translateX(20px)" : "translateX(4px)" }}
/>
</button>
</div>
</div>
);
}