fix: Various fixes. Mail, UI, Flash etc
This commit is contained in:
@@ -1,12 +1,15 @@
|
|||||||
|
import logging
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from shared.firebase import get_db
|
from shared.firebase import get_db
|
||||||
from shared.exceptions import NotFoundError
|
from shared.exceptions import NotFoundError
|
||||||
from utils.serial_number import generate_serial
|
from utils.serial_number import generate_serial
|
||||||
from utils.nvs_generator import generate as generate_nvs_binary
|
from utils.nvs_generator import generate as generate_nvs_binary
|
||||||
from manufacturing.models import BatchCreate, BatchResponse, DeviceInventoryItem, DeviceStatusUpdate, DeviceAssign, ManufacturingStats, RecentActivityItem
|
from manufacturing.models import BatchCreate, BatchResponse, DeviceInventoryItem, DeviceStatusUpdate, DeviceAssign, ManufacturingStats, RecentActivityItem, BOARD_TYPE_LABELS
|
||||||
|
|
||||||
COLLECTION = "devices"
|
COLLECTION = "devices"
|
||||||
_BATCH_ID_CHARS = string.ascii_uppercase + string.digits
|
_BATCH_ID_CHARS = string.ascii_uppercase + string.digits
|
||||||
@@ -162,6 +165,7 @@ def assign_device(sn: str, data: DeviceAssign) -> DeviceInventoryItem:
|
|||||||
if not docs:
|
if not docs:
|
||||||
raise NotFoundError("Device")
|
raise NotFoundError("Device")
|
||||||
|
|
||||||
|
doc_data = docs[0].to_dict() or {}
|
||||||
doc_ref = docs[0].reference
|
doc_ref = docs[0].reference
|
||||||
doc_ref.update({
|
doc_ref.update({
|
||||||
"owner": data.customer_email,
|
"owner": data.customer_email,
|
||||||
@@ -169,11 +173,18 @@ def assign_device(sn: str, data: DeviceAssign) -> DeviceInventoryItem:
|
|||||||
"mfg_status": "sold",
|
"mfg_status": "sold",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
hw_type = doc_data.get("hw_type", "")
|
||||||
|
device_name = BOARD_TYPE_LABELS.get(hw_type, hw_type or "Device")
|
||||||
|
|
||||||
|
try:
|
||||||
send_device_assignment_invite(
|
send_device_assignment_invite(
|
||||||
customer_email=data.customer_email,
|
customer_email=data.customer_email,
|
||||||
serial_number=sn,
|
serial_number=sn,
|
||||||
|
device_name=device_name,
|
||||||
customer_name=data.customer_name,
|
customer_name=data.customer_name,
|
||||||
)
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Assignment succeeded but email failed for %s → %s: %s", sn, data.customer_email, exc)
|
||||||
|
|
||||||
return _doc_to_inventory_item(doc_ref.get())
|
return _doc_to_inventory_item(doc_ref.get())
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,11 @@ from config import settings
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _get_client() -> resend.Resend:
|
|
||||||
return resend.Resend(api_key=settings.resend_api_key)
|
|
||||||
|
|
||||||
|
|
||||||
def send_email(to: str, subject: str, html: str) -> None:
|
def send_email(to: str, subject: str, html: str) -> None:
|
||||||
"""Send a transactional email via Resend. Logs errors but does not raise."""
|
"""Send a transactional email via Resend. Logs errors but does not raise."""
|
||||||
try:
|
try:
|
||||||
client = _get_client()
|
resend.api_key = settings.resend_api_key
|
||||||
client.emails.send({
|
resend.Emails.send({
|
||||||
"from": settings.email_from,
|
"from": settings.email_from,
|
||||||
"to": to,
|
"to": to,
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
@@ -28,29 +24,99 @@ def send_email(to: str, subject: str, html: str) -> None:
|
|||||||
def send_device_assignment_invite(
|
def send_device_assignment_invite(
|
||||||
customer_email: str,
|
customer_email: str,
|
||||||
serial_number: str,
|
serial_number: str,
|
||||||
|
device_name: str,
|
||||||
customer_name: str | None = None,
|
customer_name: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Notify a customer that a Vesper device has been assigned to them."""
|
"""Notify a customer that a Bell Systems device has been assigned and shipped to them."""
|
||||||
greeting = f"Hi {customer_name}," if customer_name else "Hello,"
|
greeting = f"Dear {customer_name}," if customer_name else "Dear Customer,"
|
||||||
html = f"""
|
html = f"""
|
||||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
<!DOCTYPE html>
|
||||||
<h2 style="color: #111827;">Your Vesper device is ready</h2>
|
<html lang="en">
|
||||||
<p>{greeting}</p>
|
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
|
||||||
<p>A Vesper bell automation device has been registered and assigned to you.</p>
|
<body style="margin:0; padding:0; background-color:#f4f4f7; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;">
|
||||||
<p>
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7; padding: 40px 0;">
|
||||||
<strong>Serial Number:</strong>
|
<tr>
|
||||||
<code style="background: #f3f4f6; padding: 2px 6px; border-radius: 4px; font-size: 14px;">{serial_number}</code>
|
<td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); max-width:600px; width:100%;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#0f172a; padding: 32px 40px; text-align:center;">
|
||||||
|
<h1 style="color:#ffffff; margin:0; font-size:22px; font-weight:700; letter-spacing:1px;">BELLSYSTEMS</h1>
|
||||||
|
<p style="color:#94a3b8; margin:6px 0 0; font-size:13px; letter-spacing:2px; text-transform:uppercase;">Device Shipment Confirmation</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 40px 32px;">
|
||||||
|
<p style="margin:0 0 20px; font-size:16px; color:#1e293b;">{greeting}</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 16px; font-size:15px; color:#334155; line-height:1.7;">
|
||||||
|
Your <strong>Bell Systems {device_name}</strong> device has been successfully manufactured and shipped.
|
||||||
|
We are delighted to have it on its way to you!
|
||||||
</p>
|
</p>
|
||||||
<p>Open the Vesper app and enter this serial number to get started.</p>
|
|
||||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;" />
|
<p style="margin:0 0 24px; font-size:15px; color:#334155; line-height:1.7;">
|
||||||
<p style="color: #6b7280; font-size: 12px;">
|
To get started, download our controller application from the Google Play Store and follow the in-app setup instructions.
|
||||||
If you did not expect this email, please contact your system administrator.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table cellpadding="0" cellspacing="0" width="100%" style="margin: 28px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://play.google.com/store/apps/details?id=com.bellsystems.vesper"
|
||||||
|
style="display:inline-block; background-color:#0f172a; color:#ffffff; text-decoration:none;
|
||||||
|
padding:14px 32px; border-radius:6px; font-size:15px; font-weight:600; letter-spacing:0.5px;">
|
||||||
|
Download on Google Play
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Device info card -->
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border:1px solid #e2e8f0; border-radius:6px; margin-bottom:28px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 20px; border-bottom:1px solid #e2e8f0;">
|
||||||
|
<span style="font-size:12px; color:#64748b; text-transform:uppercase; letter-spacing:1px; font-weight:600;">Device</span><br>
|
||||||
|
<span style="font-size:15px; color:#0f172a; font-weight:600;">Bell Systems {device_name}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 20px;">
|
||||||
|
<span style="font-size:12px; color:#64748b; text-transform:uppercase; letter-spacing:1px; font-weight:600;">Serial Number</span><br>
|
||||||
|
<code style="font-size:14px; color:#0f172a; background:#e2e8f0; padding:3px 8px; border-radius:4px; font-family:monospace;">{serial_number}</code>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:0; font-size:15px; color:#334155; line-height:1.7;">
|
||||||
|
Thank you very much. We greatly appreciate your choice in our products.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#f8fafc; border-top:1px solid #e2e8f0; padding:24px 40px; text-align:center;">
|
||||||
|
<p style="margin:0 0 4px; font-size:14px; color:#0f172a; font-weight:700;">BellSystems.gr</p>
|
||||||
|
<p style="margin:0; font-size:12px; color:#94a3b8;">
|
||||||
|
If you did not expect this email, please contact us at
|
||||||
|
<a href="mailto:support@bellsystems.gr" style="color:#64748b;">support@bellsystems.gr</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
"""
|
"""
|
||||||
send_email(
|
send_email(
|
||||||
to=customer_email,
|
to=customer_email,
|
||||||
subject=f"Your Vesper device is ready — {serial_number}",
|
subject=f"Your Bell Systems {device_name} is on its way! 🎉",
|
||||||
html=html,
|
html=html,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ def generate_serial(board_type: str, board_version: str) -> str:
|
|||||||
month = MONTH_CODES[now.month - 1]
|
month = MONTH_CODES[now.month - 1]
|
||||||
day = now.strftime("%d")
|
day = now.strftime("%d")
|
||||||
suffix = "".join(random.choices(SAFE_CHARS, k=5))
|
suffix = "".join(random.choices(SAFE_CHARS, k=5))
|
||||||
return f"PV-{year}{month}{day}-{board_type.upper()}{board_version}R-{suffix}"
|
version_clean = board_version.replace(".", "")
|
||||||
|
return f"PV-{year}{month}{day}-{board_type.upper()}{version_clean}R-{suffix}"
|
||||||
|
|||||||
@@ -3,15 +3,51 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import api from "../api/client";
|
import api from "../api/client";
|
||||||
|
|
||||||
const BOARD_TYPES = [
|
const BOARD_TYPES = [
|
||||||
{ value: "vs", name: "VESPER", codename: "vesper-basic" },
|
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" },
|
||||||
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus" },
|
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" },
|
||||||
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro" },
|
{ value: "vs", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" },
|
||||||
{ value: "cb", name: "CHRONOS", codename: "chronos-basic" },
|
{ value: "ab", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" },
|
||||||
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro" },
|
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" },
|
||||||
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini" },
|
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos" },
|
||||||
{ value: "ab", name: "AGNUS", codename: "agnus-basic" },
|
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const BOARD_FAMILY_COLORS = {
|
||||||
|
vesper: { selectedBg: "#0a1929", selectedBorder: "#3b82f6", selectedText: "#60a5fa", hoverBorder: "#3b82f6", glowColor: "rgba(59,130,246,0.35)", idleBorder: "#1d3a5c", idleText: "#7ca8d4" },
|
||||||
|
agnus: { selectedBg: "#1a1400", selectedBorder: "#f59e0b", selectedText: "#fbbf24", hoverBorder: "#f59e0b", glowColor: "rgba(245,158,11,0.35)", idleBorder: "#4a3800", idleText: "#c79d3a" },
|
||||||
|
chronos: { selectedBg: "#1a0808", selectedBorder: "#ef4444", selectedText: "#f87171", hoverBorder: "#ef4444", glowColor: "rgba(239,68,68,0.35)", idleBorder: "#5c1a1a", idleText: "#d47a7a" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function BoardTile({ bt, isSelected, onClick }) {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const pal = BOARD_FAMILY_COLORS[bt.family];
|
||||||
|
const borderColor = isSelected ? pal.selectedBorder : hovered ? pal.hoverBorder : pal.idleBorder;
|
||||||
|
const boxShadow = isSelected
|
||||||
|
? `0 0 0 1px ${pal.selectedBorder}, 0 0 14px 4px ${pal.glowColor}`
|
||||||
|
: hovered ? `0 0 12px 3px ${pal.glowColor}` : "none";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
className="rounded-lg border p-3 text-left cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected ? pal.selectedBg : "var(--bg-card)",
|
||||||
|
borderColor, boxShadow,
|
||||||
|
transition: "border-color 0.15s, box-shadow 0.15s, background-color 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="font-bold text-xs tracking-wide"
|
||||||
|
style={{ color: isSelected ? pal.selectedText : hovered ? pal.idleText : "var(--text-heading)" }}>
|
||||||
|
{bt.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-0.5 font-mono opacity-60" style={{ color: "var(--text-muted)" }}>{bt.codename}</p>
|
||||||
|
<p className="text-xs mt-1 leading-snug" style={{ color: "var(--text-muted)", opacity: 0.75 }}>{bt.desc}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function BatchCreator() {
|
export default function BatchCreator() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [boardType, setBoardType] = useState(null);
|
const [boardType, setBoardType] = useState(null);
|
||||||
@@ -57,7 +93,7 @@ export default function BatchCreator() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 640 }}>
|
<div style={{ maxWidth: 640 }}>
|
||||||
<h1 className="text-xl font-bold mb-6" style={{ color: "var(--text-heading)" }}>
|
<h1 className="text-2xl font-bold mb-6" style={{ color: "var(--text-heading)" }}>
|
||||||
New Batch
|
New Batch
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -78,36 +114,34 @@ export default function BatchCreator() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
{/* Board Type tiles */}
|
{/* Board Type tiles — Vesper: 3 col, Agnus: 2 col, Chronos: 2 col */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: "var(--text-secondary)" }}>
|
<label className="block text-sm font-medium mb-2" style={{ color: "var(--text-secondary)" }}>
|
||||||
Board Type
|
Board Type
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="space-y-2">
|
||||||
{BOARD_TYPES.map((bt) => {
|
{/* Row 1: Vesper family */}
|
||||||
const isSel = boardType === bt.value;
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
|
||||||
return (
|
{BOARD_TYPES.filter((b) => b.family === "vesper").map((bt) => (
|
||||||
<button
|
<BoardTile key={bt.value} bt={bt} isSelected={boardType === bt.value} onClick={() => setBoardType(bt.value)} />
|
||||||
key={bt.value}
|
))}
|
||||||
type="button"
|
</div>
|
||||||
onClick={() => setBoardType(bt.value)}
|
{/* Row 2: Agnus family — 2 cols left-aligned */}
|
||||||
className="rounded-lg border p-3 text-left cursor-pointer transition-all"
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
|
||||||
style={{
|
<div style={{ gridColumn: "1 / 3", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||||
backgroundColor: isSel ? "#0a1f0a" : "var(--bg-card-hover)",
|
{BOARD_TYPES.filter((b) => b.family === "agnus").map((bt) => (
|
||||||
borderColor: isSel ? "#22c55e" : "var(--border-primary)",
|
<BoardTile key={bt.value} bt={bt} isSelected={boardType === bt.value} onClick={() => setBoardType(bt.value)} />
|
||||||
boxShadow: isSel ? "0 0 0 1px #22c55e" : "none",
|
))}
|
||||||
}}
|
</div>
|
||||||
>
|
</div>
|
||||||
<p className="text-xs font-bold tracking-wide"
|
{/* Row 3: Chronos family — 2 cols left-aligned */}
|
||||||
style={{ color: isSel ? "#22c55e" : "var(--text-heading)" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
|
||||||
{bt.name}
|
<div style={{ gridColumn: "1 / 3", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||||
</p>
|
{BOARD_TYPES.filter((b) => b.family === "chronos").map((bt) => (
|
||||||
<p className="text-xs font-mono mt-0.5" style={{ color: "var(--text-muted)" }}>
|
<BoardTile key={bt.value} bt={bt} isSelected={boardType === bt.value} onClick={() => setBoardType(bt.value)} />
|
||||||
{bt.codename}
|
))}
|
||||||
</p>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,23 @@ import api from "../api/client";
|
|||||||
|
|
||||||
// ─── constants ────────────────────────────────────────────────────────────────
|
// ─── constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
|
||||||
const BOARD_TYPES = [
|
const BOARD_TYPES = [
|
||||||
{ value: "vs", name: "VESPER", codename: "vesper-basic" },
|
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", family: "vesper" },
|
||||||
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus" },
|
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", family: "vesper" },
|
||||||
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro" },
|
{ value: "vs", name: "VESPER", codename: "vesper-basic", family: "vesper" },
|
||||||
{ value: "cb", name: "CHRONOS", codename: "chronos-basic" },
|
{ value: "ab", name: "AGNUS", codename: "agnus-basic", family: "agnus" },
|
||||||
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro" },
|
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", family: "agnus" },
|
||||||
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini" },
|
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", family: "chronos" },
|
||||||
{ value: "ab", name: "AGNUS", codename: "agnus-basic" },
|
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", family: "chronos" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const BOARD_FAMILY_COLORS = {
|
||||||
|
vesper: { selectedBg: "#0a1929", selectedBorder: "#3b82f6", selectedText: "#60a5fa", hoverBorder: "#3b82f6", glowColor: "rgba(59,130,246,0.35)", idleBorder: "#1d3a5c", idleText: "#7ca8d4" },
|
||||||
|
agnus: { selectedBg: "#1a1400", selectedBorder: "#f59e0b", selectedText: "#fbbf24", hoverBorder: "#f59e0b", glowColor: "rgba(245,158,11,0.35)", idleBorder: "#4a3800", idleText: "#c79d3a" },
|
||||||
|
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 = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b.name]));
|
||||||
|
|
||||||
const STATUS_STYLES = {
|
const STATUS_STYLES = {
|
||||||
@@ -188,6 +195,35 @@ function ColumnToggle({ visible, orderedIds, onChange, onReorder }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Board Type Tile ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function BoardTypeTile({ bt, isSelected, pal, onClick }) {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const borderColor = isSelected ? pal.selectedBorder : hovered ? pal.hoverBorder : pal.idleBorder;
|
||||||
|
const boxShadow = isSelected
|
||||||
|
? `0 0 0 1px ${pal.selectedBorder}, 0 0 14px 4px ${pal.glowColor}`
|
||||||
|
: hovered ? `0 0 12px 3px ${pal.glowColor}` : "none";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
className="rounded-lg border p-3 text-left cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected ? pal.selectedBg : "var(--bg-card-hover)",
|
||||||
|
borderColor, boxShadow,
|
||||||
|
transition: "border-color 0.15s, box-shadow 0.15s, background-color 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-bold tracking-wide"
|
||||||
|
style={{ color: isSelected ? pal.selectedText : hovered ? pal.idleText : "var(--text-heading)" }}>
|
||||||
|
{bt.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-mono mt-0.5 opacity-60" style={{ color: "var(--text-muted)" }}>{bt.codename}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Add Device Modal ─────────────────────────────────────────────────────────
|
// ─── Add Device Modal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function AddDeviceModal({ onClose, onCreated }) {
|
function AddDeviceModal({ onClose, onCreated }) {
|
||||||
@@ -222,26 +258,29 @@ function AddDeviceModal({ onClose, onCreated }) {
|
|||||||
<h2 className="text-base font-bold mb-4" style={{ color: "var(--text-heading)" }}>Add Single Device</h2>
|
<h2 className="text-base font-bold mb-4" style={{ color: "var(--text-heading)" }}>Add Single Device</h2>
|
||||||
|
|
||||||
<p className="text-sm mb-3" style={{ color: "var(--text-secondary)" }}>Board Type</p>
|
<p className="text-sm mb-3" style={{ color: "var(--text-secondary)" }}>Board Type</p>
|
||||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
<div className="space-y-2" style={{ marginBottom: 16 }}>
|
||||||
{BOARD_TYPES.map((bt) => {
|
{/* Row 1: Vesper family — 3 columns */}
|
||||||
const isSel = boardType === bt.value;
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
|
||||||
return (
|
{BOARD_TYPES.filter((b) => b.family === "vesper").map((bt) => (
|
||||||
<button
|
<BoardTypeTile key={bt.value} bt={bt} isSelected={boardType === bt.value} pal={BOARD_FAMILY_COLORS[bt.family]} onClick={() => setBoardType(bt.value)} />
|
||||||
key={bt.value}
|
))}
|
||||||
onClick={() => setBoardType(bt.value)}
|
</div>
|
||||||
className="rounded-lg border p-3 text-left cursor-pointer transition-all"
|
{/* Row 2: Agnus family — 2 columns (left-aligned in 3-col grid) */}
|
||||||
style={{
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
|
||||||
backgroundColor: isSel ? "#0a1f0a" : "var(--bg-card-hover)",
|
<div style={{ gridColumn: "1 / 3", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||||
borderColor: isSel ? "#22c55e" : "var(--border-primary)",
|
{BOARD_TYPES.filter((b) => b.family === "agnus").map((bt) => (
|
||||||
}}
|
<BoardTypeTile key={bt.value} bt={bt} isSelected={boardType === bt.value} pal={BOARD_FAMILY_COLORS[bt.family]} onClick={() => setBoardType(bt.value)} />
|
||||||
>
|
))}
|
||||||
<p className="text-xs font-bold tracking-wide" style={{ color: isSel ? "#22c55e" : "var(--text-heading)" }}>
|
</div>
|
||||||
{bt.name}
|
</div>
|
||||||
</p>
|
{/* Row 3: Chronos family — 2 columns (left-aligned in 3-col grid) */}
|
||||||
<p className="text-xs font-mono mt-0.5" style={{ color: "var(--text-muted)" }}>{bt.codename}</p>
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
|
||||||
</button>
|
<div style={{ gridColumn: "1 / 3", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||||
);
|
{BOARD_TYPES.filter((b) => b.family === "chronos").map((bt) => (
|
||||||
})}
|
<BoardTypeTile key={bt.value} bt={bt} isSelected={boardType === bt.value} pal={BOARD_FAMILY_COLORS[bt.family]} onClick={() => setBoardType(bt.value)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ export default function DeviceInventoryDetail() {
|
|||||||
{/* Title row */}
|
{/* Title row */}
|
||||||
<div className="flex items-start justify-between mb-6">
|
<div className="flex items-start justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold font-mono" style={{ color: "var(--text-heading)" }}>
|
<h1 className="text-2xl font-bold font-mono" style={{ color: "var(--text-heading)" }}>
|
||||||
{device?.serial_number}
|
{device?.serial_number}
|
||||||
</h1>
|
</h1>
|
||||||
{device?.device_name && (
|
{device?.device_name && (
|
||||||
|
|||||||
@@ -5,16 +5,48 @@ import api from "../api/client";
|
|||||||
|
|
||||||
// ─── constants ───────────────────────────────────────────────────────────────
|
// ─── constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
|
||||||
const BOARD_TYPES = [
|
const BOARD_TYPES = [
|
||||||
{ value: "vs", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller" },
|
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" },
|
||||||
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller" },
|
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" },
|
||||||
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller" },
|
{ value: "vs", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" },
|
||||||
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller" },
|
{ value: "ab", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" },
|
||||||
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller" },
|
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" },
|
||||||
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module" },
|
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos"},
|
||||||
{ value: "ab", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module" },
|
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos"},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Color palette per board family (idle → selected → hover glow)
|
||||||
|
const BOARD_FAMILY_COLORS = {
|
||||||
|
vesper: {
|
||||||
|
selectedBg: "#0a1929",
|
||||||
|
selectedBorder:"#3b82f6",
|
||||||
|
selectedText: "#60a5fa",
|
||||||
|
hoverBorder: "#3b82f6",
|
||||||
|
glowColor: "rgba(59,130,246,0.35)",
|
||||||
|
idleBorder: "#1d3a5c",
|
||||||
|
idleText: "#7ca8d4",
|
||||||
|
},
|
||||||
|
agnus: {
|
||||||
|
selectedBg: "#1a1400",
|
||||||
|
selectedBorder:"#f59e0b",
|
||||||
|
selectedText: "#fbbf24",
|
||||||
|
hoverBorder: "#f59e0b",
|
||||||
|
glowColor: "rgba(245,158,11,0.35)",
|
||||||
|
idleBorder: "#4a3800",
|
||||||
|
idleText: "#c79d3a",
|
||||||
|
},
|
||||||
|
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 = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b.name]));
|
||||||
|
|
||||||
// Display board version stored as semver string ("1.0", "2.1") as "Rev 1.0"
|
// Display board version stored as semver string ("1.0", "2.1") as "Rev 1.0"
|
||||||
@@ -97,11 +129,11 @@ function inputStyle() {
|
|||||||
// Steps: 0=Mode, 1=Select, 2=Flash, 3=Verify, 4=Done
|
// Steps: 0=Mode, 1=Select, 2=Flash, 3=Verify, 4=Done
|
||||||
// current is 1-based to match step state (step 0 = mode picker, shown differently)
|
// current is 1-based to match step state (step 0 = mode picker, shown differently)
|
||||||
|
|
||||||
const STEP_LABELS = ["Select Device", "Flash", "Verify", "Done"];
|
const STEP_LABELS = ["Begin", "Device", "Flash", "Verify", "Done"];
|
||||||
|
|
||||||
function CheckeredFlagIcon() {
|
function CheckeredFlagIcon() {
|
||||||
return (
|
return (
|
||||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M4 2v20h2V13h14V3H4zm2 2h3v3H6V4zm0 5h3v3H6V9zm5-5h3v3h-3V4zm0 5h3v3h-3V9zm5-5h3v3h-3V4zm0 5h3v3h-3V9z" />
|
<path d="M4 2v20h2V13h14V3H4zm2 2h3v3H6V4zm0 5h3v3H6V9zm5-5h3v3h-3V4zm0 5h3v3h-3V9zm5-5h3v3h-3V4zm0 5h3v3h-3V9z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -119,15 +151,18 @@ function StepIndicator({ current }) {
|
|||||||
const isLast = i === STEP_LABELS.length - 1;
|
const isLast = i === STEP_LABELS.length - 1;
|
||||||
|
|
||||||
// Color tokens
|
// Color tokens
|
||||||
const dotBg = done ? "#1a5c4a"
|
const dotBg = done ? "#53b15786"
|
||||||
: active ? "#22c55e"
|
: active ? "#22c55e"
|
||||||
: "#2a2a2a";
|
: "#251a1a";
|
||||||
const dotColor = done ? "#4dd6c8"
|
const dotColor = done ? "#cbedb9"
|
||||||
: active ? "#fff"
|
: active ? "#ffffff"
|
||||||
: "#555";
|
: "#555";
|
||||||
const labelColor = active ? "#22c55e" : done ? "#4dd6c8" : "#555";
|
const labelColor = active ? "#22c55e" : done ? "#53b15786" : "#555";
|
||||||
const labelGlow = active ? "0 0 8px rgba(34,197,94,0.45)" : "none";
|
const labelGlow = active ? "0 0 8px rgba(34,197,94,0.45)" : "none";
|
||||||
const lineColor = done ? "#4dd6c8" : "var(--border-primary)";
|
const lineColor = done ? "#22c55e" : "var(--border-primary)";
|
||||||
|
|
||||||
|
// Active dot is 38px, others are 30px
|
||||||
|
const dotSize = active ? 38 : 30;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={label} className="flex items-center" style={{ minWidth: 0 }}>
|
<div key={label} className="flex items-center" style={{ minWidth: 0 }}>
|
||||||
@@ -135,20 +170,20 @@ function StepIndicator({ current }) {
|
|||||||
<div className="flex flex-col items-center" style={{ flexShrink: 0 }}>
|
<div className="flex flex-col items-center" style={{ flexShrink: 0 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 32, height: 32,
|
width: dotSize, height: dotSize,
|
||||||
borderRadius: "50%",
|
borderRadius: "60%",
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
backgroundColor: dotBg,
|
backgroundColor: dotBg,
|
||||||
color: dotColor,
|
color: dotColor,
|
||||||
fontSize: "0.8rem",
|
fontSize: active ? "1.0rem" : "0.85rem",
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
border: active ? "2px solid #22c55e" : done ? "2px solid #4dd6c8" : "2px solid #333",
|
border: active ? "2px solid #22c55e" : done ? "2px solid #6d9b78c0" : "2px solid #333",
|
||||||
boxShadow: active ? "0 0 10px rgba(34,197,94,0.4)" : "none",
|
boxShadow: active ? "0 0 18px 8px rgba(34,197,94,0.4)" : "none",
|
||||||
transition: "all 0.2s",
|
transition: "all 0.2s",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{done ? (
|
{done ? (
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
) : isLast ? (
|
) : isLast ? (
|
||||||
@@ -175,11 +210,11 @@ function StepIndicator({ current }) {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: 2,
|
height: 2,
|
||||||
flex: "1 1 24px",
|
flex: "1 1 16px",
|
||||||
minWidth: 16,
|
minWidth: 28,
|
||||||
maxWidth: 48,
|
maxWidth: 40,
|
||||||
backgroundColor: lineColor,
|
backgroundColor: lineColor,
|
||||||
marginBottom: 20,
|
marginBottom: 18,
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
marginRight: 4,
|
marginRight: 4,
|
||||||
transition: "background-color 0.2s",
|
transition: "background-color 0.2s",
|
||||||
@@ -194,47 +229,6 @@ function StepIndicator({ current }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Wizard top bar (title + indicator + Back/Abort) ─────────────────────────
|
|
||||||
|
|
||||||
function WizardTopBar({ step, onBack, onAbort, showBack, showAbort }) {
|
|
||||||
return (
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center justify-between gap-4 mb-6">
|
|
||||||
{/* Left: Back button (or spacer) */}
|
|
||||||
<div style={{ minWidth: 90 }}>
|
|
||||||
{showBack && (
|
|
||||||
<button
|
|
||||||
onClick={onBack}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-opacity hover:opacity-80 cursor-pointer"
|
|
||||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
← Back
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Center: Step indicator */}
|
|
||||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
|
||||||
{step >= 1 && <StepIndicator current={step} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: Abort button (or spacer) */}
|
|
||||||
<div style={{ minWidth: 90, display: "flex", justifyContent: "flex-end" }}>
|
|
||||||
{showAbort && (
|
|
||||||
<button
|
|
||||||
onClick={onAbort}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-opacity hover:opacity-80 cursor-pointer"
|
|
||||||
style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)", border: "1px solid var(--danger)" }}
|
|
||||||
>
|
|
||||||
Abort ✕
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Step 0 — Mode picker ─────────────────────────────────────────────────────
|
// ─── Step 0 — Mode picker ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StepModePicker({ onPick }) {
|
function StepModePicker({ onPick }) {
|
||||||
@@ -250,13 +244,20 @@ function StepModePicker({ onPick }) {
|
|||||||
{/* Flash Existing */}
|
{/* Flash Existing */}
|
||||||
<button
|
<button
|
||||||
onClick={() => onPick("existing")}
|
onClick={() => onPick("existing")}
|
||||||
className="rounded-xl border p-6 text-left transition-all hover:opacity-90 cursor-pointer"
|
className="rounded-xl border p-6 text-left cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--bg-card)",
|
backgroundColor: "var(--bg-card)",
|
||||||
borderColor: "var(--border-primary)",
|
borderColor: "var(--border-primary)",
|
||||||
|
transition: "border-color 0.15s, box-shadow 0.15s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--accent)";
|
||||||
|
e.currentTarget.style.boxShadow = "0 0 14px 4px rgba(34,197,94,0.25)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border-primary)";
|
||||||
|
e.currentTarget.style.boxShadow = "none";
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "var(--accent)")}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "var(--border-primary)")}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-10 h-10 rounded-lg flex items-center justify-center mb-4"
|
className="w-10 h-10 rounded-lg flex items-center justify-center mb-4"
|
||||||
@@ -272,22 +273,29 @@ function StepModePicker({ onPick }) {
|
|||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Deploy New */}
|
{/* Deploy New — same style as Flash Existing */}
|
||||||
<button
|
<button
|
||||||
onClick={() => onPick("new")}
|
onClick={() => onPick("new")}
|
||||||
className="rounded-xl border p-6 text-left transition-all hover:opacity-90 cursor-pointer"
|
className="rounded-xl border p-6 text-left cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--bg-card)",
|
backgroundColor: "var(--bg-card)",
|
||||||
borderColor: "var(--border-primary)",
|
borderColor: "var(--border-primary)",
|
||||||
|
transition: "border-color 0.15s, box-shadow 0.15s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--accent)";
|
||||||
|
e.currentTarget.style.boxShadow = "0 0 14px 4px rgba(34,197,94,0.25)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border-primary)";
|
||||||
|
e.currentTarget.style.boxShadow = "none";
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "#22c55e")}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "var(--border-primary)")}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-10 h-10 rounded-lg flex items-center justify-center mb-4"
|
className="w-10 h-10 rounded-lg flex items-center justify-center mb-4"
|
||||||
style={{ backgroundColor: "#0a2e0a" }}
|
style={{ backgroundColor: "var(--bg-card-hover)" }}
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: "#22c55e" }}>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: "var(--accent)" }}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -454,6 +462,52 @@ function StepSelectExisting({ onSelected }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Board Type Tile (shared between StepDeployNew and AddDeviceModal) ────────
|
||||||
|
|
||||||
|
function BoardTypeTile({ bt, isSelected, pal, onClick }) {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
const borderColor = isSelected
|
||||||
|
? pal.selectedBorder
|
||||||
|
: hovered
|
||||||
|
? pal.hoverBorder
|
||||||
|
: pal.idleBorder;
|
||||||
|
|
||||||
|
const boxShadow = isSelected
|
||||||
|
? `0 0 0 1px ${pal.selectedBorder}, 0 0 14px 4px ${pal.glowColor}`
|
||||||
|
: hovered
|
||||||
|
? `0 0 12px 3px ${pal.glowColor}`
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
className="rounded-lg border p-3 text-left transition-all cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected ? pal.selectedBg : "var(--bg-card)",
|
||||||
|
borderColor,
|
||||||
|
boxShadow,
|
||||||
|
transition: "border-color 0.15s, box-shadow 0.15s, background-color 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="font-bold text-xs tracking-wide"
|
||||||
|
style={{ color: isSelected ? pal.selectedText : hovered ? pal.idleText : "var(--text-heading)" }}
|
||||||
|
>
|
||||||
|
{bt.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-0.5 font-mono opacity-60" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{bt.codename}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1 leading-snug" style={{ color: "var(--text-muted)", opacity: 0.75 }}>
|
||||||
|
{bt.desc}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Step 1b — Deploy New: pick board type + revision ─────────────────────────
|
// ─── Step 1b — Deploy New: pick board type + revision ─────────────────────────
|
||||||
|
|
||||||
function StepDeployNew({ onSelected, onCreatedSn }) {
|
function StepDeployNew({ onSelected, onCreatedSn }) {
|
||||||
@@ -483,44 +537,63 @@ function StepDeployNew({ onSelected, onCreatedSn }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
const chronosBoards = BOARD_TYPES.filter((b) => b.family === "chronos");
|
||||||
|
|
||||||
|
const renderTileRow = (boards, cols) => (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: 10, justifyItems: "stretch" }}>
|
||||||
|
{boards.map((bt) => (
|
||||||
|
<BoardTypeTile
|
||||||
|
key={bt.value}
|
||||||
|
bt={bt}
|
||||||
|
isSelected={boardType === bt.value}
|
||||||
|
pal={BOARD_FAMILY_COLORS[bt.family]}
|
||||||
|
onClick={() => setBoardType(bt.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Board type tiles */}
|
{/* Board type tiles — Vesper: 3 col, Agnus: 2 col centered, Chronos: 2 col centered */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium mb-3" style={{ color: "var(--text-secondary)" }}>Board Type</p>
|
<p className="text-sm font-medium mb-3" style={{ color: "var(--text-secondary)" }}>Board Type</p>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-2">
|
||||||
{BOARD_TYPES.map((bt) => {
|
{renderTileRow(vesperBoards, 3)}
|
||||||
const isSelected = boardType === bt.value;
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10 }}>
|
||||||
return (
|
<div style={{ gridColumn: "1 / 3", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||||||
<button
|
{agnusBoards.map((bt) => (
|
||||||
|
<BoardTypeTile
|
||||||
key={bt.value}
|
key={bt.value}
|
||||||
|
bt={bt}
|
||||||
|
isSelected={boardType === bt.value}
|
||||||
|
pal={BOARD_FAMILY_COLORS[bt.family]}
|
||||||
onClick={() => setBoardType(bt.value)}
|
onClick={() => setBoardType(bt.value)}
|
||||||
className="rounded-lg border p-4 text-left transition-all cursor-pointer"
|
/>
|
||||||
style={{
|
))}
|
||||||
backgroundColor: isSelected ? "#0a1f0a" : "var(--bg-card)",
|
</div>
|
||||||
borderColor: isSelected ? "#22c55e" : "var(--border-primary)",
|
</div>
|
||||||
boxShadow: isSelected ? "0 0 0 1px #22c55e" : "none",
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10 }}>
|
||||||
}}
|
<div style={{ gridColumn: "1 / 3", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||||||
>
|
{chronosBoards.map((bt) => (
|
||||||
<p
|
<BoardTypeTile
|
||||||
className="font-bold text-sm tracking-wide"
|
key={bt.value}
|
||||||
style={{ color: isSelected ? "#22c55e" : "var(--text-heading)" }}
|
bt={bt}
|
||||||
>
|
isSelected={boardType === bt.value}
|
||||||
{bt.name}
|
pal={BOARD_FAMILY_COLORS[bt.family]}
|
||||||
</p>
|
onClick={() => setBoardType(bt.value)}
|
||||||
<p className="text-xs mt-0.5 font-mono" style={{ color: "var(--text-muted)" }}>
|
/>
|
||||||
{bt.codename}
|
))}
|
||||||
</p>
|
</div>
|
||||||
<p className="text-xs mt-1.5" style={{ color: "var(--text-muted)", opacity: 0.8 }}>
|
</div>
|
||||||
{bt.desc}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Board revision */}
|
{/* Board revision (left) + Generate button (right) */}
|
||||||
|
<div className="flex items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
|
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
|
||||||
Board Revision
|
Board Revision
|
||||||
@@ -537,12 +610,12 @@ function StepDeployNew({ onSelected, onCreatedSn }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
|
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
Use semantic versioning: 1.0, 1.1, 2.0, etc.
|
Semantic versioning: 1.0, 1.1, 2.0…
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
<ErrorBox msg={error} />
|
<ErrorBox msg={error} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
disabled={!boardType || creating}
|
disabled={!boardType || creating}
|
||||||
@@ -552,6 +625,8 @@ function StepDeployNew({ onSelected, onCreatedSn }) {
|
|||||||
{creating ? "Creating…" : "Generate Serial & Continue →"}
|
{creating ? "Creating…" : "Generate Serial & Continue →"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,14 +660,21 @@ function StepFlash({ device, onFlashed }) {
|
|||||||
const portRef = useRef(null);
|
const portRef = useRef(null);
|
||||||
const serialReaderRef = useRef(null);
|
const serialReaderRef = useRef(null);
|
||||||
const serialActiveRef = useRef(false);
|
const serialActiveRef = useRef(false);
|
||||||
|
const serialAutoScrollRef = useRef(true); // mirrors state — readable inside async loops
|
||||||
const logEndRef = useRef(null);
|
const logEndRef = useRef(null);
|
||||||
const serialEndRef = useRef(null);
|
const serialEndRef = useRef(null);
|
||||||
|
|
||||||
|
// Keep the ref in sync with state so async loops always see the current value
|
||||||
|
const handleSetSerialAutoScroll = (val) => {
|
||||||
|
serialAutoScrollRef.current = val;
|
||||||
|
setSerialAutoScroll(val);
|
||||||
|
};
|
||||||
|
|
||||||
const appendLog = (msg) => setLog((prev) => [...prev, String(msg)]);
|
const appendLog = (msg) => setLog((prev) => [...prev, String(msg)]);
|
||||||
const appendSerial = (msg) => setSerial((prev) => [...prev, String(msg)]);
|
const appendSerial = (msg) => setSerial((prev) => [...prev, String(msg)]);
|
||||||
|
|
||||||
const scrollLog = () => logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
const scrollLog = () => logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
const scrollSerial = () => { if (serialAutoScroll) serialEndRef.current?.scrollIntoView({ behavior: "smooth" }); };
|
const scrollSerial = () => { if (serialAutoScrollRef.current) serialEndRef.current?.scrollIntoView({ behavior: "smooth" }); };
|
||||||
|
|
||||||
// When auto-scroll is re-enabled, jump to bottom immediately
|
// When auto-scroll is re-enabled, jump to bottom immediately
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -789,19 +871,17 @@ function StepFlash({ device, onFlashed }) {
|
|||||||
const webSerialAvailable = "serial" in navigator;
|
const webSerialAvailable = "serial" in navigator;
|
||||||
const busy = connecting || flashing;
|
const busy = connecting || flashing;
|
||||||
|
|
||||||
return (
|
// ── Row 1: Info+buttons (left) | Flash Output (right) ──────────────────────
|
||||||
<div className="space-y-4">
|
const InfoPanel = (
|
||||||
{/* Main card */}
|
|
||||||
<div
|
<div
|
||||||
className="rounded-lg border p-5"
|
className="rounded-lg border p-5 flex flex-col"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
>
|
>
|
||||||
{/* Header row: title + COM status icon */}
|
{/* Header: title + COM status */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
<h3 className="text-sm font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||||
Device to Flash
|
Device to Flash
|
||||||
</h3>
|
</h3>
|
||||||
{/* COM port status icon — click to disconnect */}
|
|
||||||
<button
|
<button
|
||||||
onClick={portConnected ? disconnectPort : undefined}
|
onClick={portConnected ? disconnectPort : undefined}
|
||||||
title={portConnected ? `${portName} — Click to disconnect` : "No port connected"}
|
title={portConnected ? `${portName} — Click to disconnect` : "No port connected"}
|
||||||
@@ -812,46 +892,66 @@ function StepFlash({ device, onFlashed }) {
|
|||||||
border: `1px solid ${portConnected ? "#4dd6c8" : "var(--border-primary)"}`,
|
border: `1px solid ${portConnected ? "#4dd6c8" : "var(--border-primary)"}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span className="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: portConnected ? "#22c55e" : "#444" }} />
|
||||||
className="inline-block w-2 h-2 rounded-full"
|
|
||||||
style={{ backgroundColor: portConnected ? "#22c55e" : "#444" }}
|
|
||||||
/>
|
|
||||||
{portConnected ? portName || "Connected" : "No Port"}
|
{portConnected ? portName || "Connected" : "No Port"}
|
||||||
{portConnected && <span className="ml-1 opacity-60">✕</span>}
|
{portConnected && <span className="ml-1 opacity-60">✕</span>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||||
<InfoCell label="Serial Number" value={device.serial_number} mono />
|
<InfoCell label="Serial Number" value={device.serial_number} mono />
|
||||||
<InfoCell label="Board" value={`${BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} ${formatHwVersion(device.hw_version)}`} />
|
<InfoCell label="Board" value={`${BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} ${formatHwVersion(device.hw_version)}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!webSerialAvailable && (
|
{!webSerialAvailable && (
|
||||||
<div className="text-sm rounded-md p-3 mb-4 border"
|
<div className="text-sm rounded-md p-3 mb-3 border"
|
||||||
style={{ backgroundColor: "#2e1a00", borderColor: "#fb923c", color: "#fb923c" }}>
|
style={{ backgroundColor: "#2e1a00", borderColor: "#fb923c", color: "#fb923c" }}>
|
||||||
Web Serial API not available. Use Chrome or Edge on a desktop system.
|
Web Serial API not available. Use Chrome or Edge on a desktop system.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ErrorBox msg={error} />
|
<ErrorBox msg={error} />
|
||||||
{error && <div className="h-3" />}
|
{error && <div className="h-2" />}
|
||||||
|
|
||||||
{(flashing || nvsProgress > 0) && (
|
{(flashing || nvsProgress > 0) && (
|
||||||
<div className="space-y-3 mb-5">
|
<div className="space-y-3 mb-4">
|
||||||
<ProgressBar label="NVS Partition (0x9000)" percent={nvsProgress} />
|
<ProgressBar label="NVS Partition (0x9000)" percent={nvsProgress} />
|
||||||
<ProgressBar label="Firmware (0x10000)" percent={fwProgress} />
|
<ProgressBar label="Firmware (0x10000)" percent={fwProgress} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Spacer — pushes bottom bar to the actual bottom of the card */}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{/* Bottom bar: info (left) | buttons (right) */}
|
||||||
|
<div
|
||||||
|
className="flex items-end justify-between gap-3 pt-3 mt-2"
|
||||||
|
style={{ borderTop: "1px solid var(--border-secondary)" }}
|
||||||
|
>
|
||||||
|
{/* Left: status hint + tech info */}
|
||||||
|
<div>
|
||||||
|
{portConnected && !flashing && !done && log.length === 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm mb-1.5" style={{ color: "#4dd6c8" }}>
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: "#22c55e" }} />
|
||||||
|
Ready to flash.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{flashing && (
|
||||||
|
<p className="text-sm mb-1.5" style={{ color: "var(--text-muted)" }}>Flashing — do not disconnect…</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs" style={{ color: "var(--text-muted)", opacity: 0.6 }}>
|
||||||
|
NVS 0x9000 · FW 0x10000 · {FLASH_BAUD} baud
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: action buttons */}
|
||||||
{!busy && (
|
{!busy && (
|
||||||
<div className="flex items-center flex-wrap gap-3">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{/* Connect port button — only if not yet connected */}
|
|
||||||
{!portConnected && (
|
{!portConnected && (
|
||||||
<button
|
<button
|
||||||
onClick={handleConnectPort}
|
onClick={handleConnectPort}
|
||||||
disabled={!webSerialAvailable}
|
disabled={!webSerialAvailable}
|
||||||
className="flex items-center gap-2 px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
|
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
|
||||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -860,8 +960,24 @@ function StepFlash({ device, onFlashed }) {
|
|||||||
Select COM Port
|
Select COM Port
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{portConnected && done && (
|
||||||
{/* Start Flashing button — only after port connected */}
|
<button
|
||||||
|
onClick={handleStartFlash}
|
||||||
|
className="flex items-center gap-2 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)" }}
|
||||||
|
>
|
||||||
|
Flash Again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{done && (
|
||||||
|
<button
|
||||||
|
onClick={onFlashed}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--accent)", color: "var(--bg-primary)" }}
|
||||||
|
>
|
||||||
|
Proceed to Verify →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{portConnected && !done && (
|
{portConnected && !done && (
|
||||||
<button
|
<button
|
||||||
onClick={handleStartFlash}
|
onClick={handleStartFlash}
|
||||||
@@ -874,54 +990,14 @@ function StepFlash({ device, onFlashed }) {
|
|||||||
Start Flashing
|
Start Flashing
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Re-flash button after done */}
|
|
||||||
{portConnected && done && (
|
|
||||||
<button
|
|
||||||
onClick={handleStartFlash}
|
|
||||||
className="flex items-center gap-2 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)" }}
|
|
||||||
>
|
|
||||||
Flash Again
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Proceed to Verify — available after flash completes */}
|
|
||||||
{done && (
|
|
||||||
<button
|
|
||||||
onClick={onFlashed}
|
|
||||||
className="flex items-center gap-2 px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
|
||||||
style={{ backgroundColor: "var(--accent)", color: "var(--bg-primary)" }}
|
|
||||||
>
|
|
||||||
Proceed to Verify →
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Port connected confirmation (before flashing starts) */}
|
|
||||||
{portConnected && !flashing && !done && log.length === 0 && (
|
|
||||||
<div className="mt-3 flex items-center gap-2 text-sm" style={{ color: "#4dd6c8" }}>
|
|
||||||
<span className="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: "#22c55e" }} />
|
|
||||||
COM port connected — ready to flash.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{flashing && (
|
|
||||||
<p className="text-sm mt-3" style={{ color: "var(--text-muted)" }}>
|
|
||||||
Flashing in progress — do not disconnect…
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
{/* Output panels — always shown once there's any content */}
|
const FlashOutputPanel = (
|
||||||
{(log.length > 0 || serial.length > 0 || portConnected) && (
|
<div className="rounded-lg border overflow-hidden flex flex-col" style={{ borderColor: "var(--border-primary)", height: 320 }}>
|
||||||
<div>
|
|
||||||
{/* Top row: Flash Output + main section (side by side) */}
|
|
||||||
{log.length > 0 && (
|
|
||||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
|
||||||
{/* Flash Output */}
|
|
||||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
|
|
||||||
<div
|
<div
|
||||||
className="px-3 py-2 text-xs font-semibold uppercase tracking-wide border-b"
|
className="px-3 py-2 text-xs font-semibold uppercase tracking-wide border-b"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
|
||||||
@@ -930,38 +1006,45 @@ function StepFlash({ device, onFlashed }) {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="p-3 font-mono text-xs overflow-y-auto space-y-0.5"
|
className="p-3 font-mono text-xs overflow-y-auto space-y-0.5"
|
||||||
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-secondary)", height: "220px" }}
|
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-secondary)", flex: 1, minHeight: 0 }}
|
||||||
>
|
>
|
||||||
{log.map((line, i) => <div key={i}>{line}</div>)}
|
{log.length === 0 ? (
|
||||||
|
<span style={{ color: "var(--text-muted)", opacity: 0.5 }}>
|
||||||
|
{flashing ? "Connecting…" : "Output will appear here once flashing starts."}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
log.map((line, i) => <div key={i}>{line}</div>)
|
||||||
|
)}
|
||||||
<div ref={logEndRef} />
|
<div ref={logEndRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
{/* Placeholder / status on the right during flash */}
|
return (
|
||||||
<div className="rounded-lg border p-4 flex items-center justify-center"
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-card)", height: 264 }}>
|
{/* Row 1: Info | Flash Output */}
|
||||||
{flashing ? (
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, alignItems: "stretch" }}>
|
||||||
<div className="text-center">
|
{InfoPanel}
|
||||||
<svg className="w-8 h-8 animate-spin mx-auto mb-2" style={{ color: "var(--accent)" }} fill="none" viewBox="0 0 24 24">
|
{FlashOutputPanel}
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
||||||
</svg>
|
|
||||||
<p className="text-xs" style={{ color: "var(--text-muted)" }}>Flashing…</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<p className="text-xs text-center" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{done ? "Flash complete." : "Waiting to start."}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Serial Output — full width below */}
|
{/* Row 2: Serial Output — full width, resizable by drag.
|
||||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
|
↓ EDIT THIS VALUE to adjust the serial monitor height ↓ */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg border"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
minHeight: 180,
|
||||||
|
height: 320, /* ← change this number to adjust height */
|
||||||
|
resize: "vertical",
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="px-3 py-2 text-xs font-semibold uppercase tracking-wide border-b flex items-center justify-between"
|
className="px-3 py-2 text-xs font-semibold uppercase tracking-wide border-b flex items-center justify-between"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)", flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>Serial Output</span>
|
<span>Serial Output</span>
|
||||||
@@ -972,11 +1055,10 @@ function StepFlash({ device, onFlashed }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Auto-scroll toggle */}
|
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>Auto-scroll</span>
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>Auto-scroll</span>
|
||||||
<span
|
<span
|
||||||
onClick={() => setSerialAutoScroll((v) => !v)}
|
onClick={() => handleSetSerialAutoScroll(!serialAutoScrollRef.current)}
|
||||||
className="relative inline-flex items-center"
|
className="relative inline-flex items-center"
|
||||||
style={{
|
style={{
|
||||||
width: 32, height: 18,
|
width: 32, height: 18,
|
||||||
@@ -1002,8 +1084,8 @@ function StepFlash({ device, onFlashed }) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="p-3 font-mono text-xs overflow-y-auto space-y-0.5"
|
className="p-3 font-mono text-xs space-y-0.5"
|
||||||
style={{ backgroundColor: "var(--bg-primary)", color: "#a3e635", height: "200px" }}
|
style={{ backgroundColor: "var(--bg-primary)", color: "#a3e635", flex: 1, overflowY: "auto", minHeight: 0 }}
|
||||||
>
|
>
|
||||||
{serial.length === 0 ? (
|
{serial.length === 0 ? (
|
||||||
<span style={{ color: "var(--text-muted)" }}>
|
<span style={{ color: "var(--text-muted)" }}>
|
||||||
@@ -1016,12 +1098,6 @@ function StepFlash({ device, onFlashed }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
|
|
||||||
Flash addresses: NVS at 0x9000 · Firmware at 0x10000 · Baud: {FLASH_BAUD}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1196,14 +1272,8 @@ function StepVerify({ device, onVerified }) {
|
|||||||
|
|
||||||
// ─── Step 4 — Done ────────────────────────────────────────────────────────────
|
// ─── Step 4 — Done ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StepDone({ device, startedAt, onProvisionNext }) {
|
function StepDone({ device, onProvisionNext }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const elapsed = startedAt ? Math.round((Date.now() - startedAt) / 1000) : null;
|
|
||||||
|
|
||||||
const formatElapsed = (sec) => {
|
|
||||||
if (sec < 60) return `${sec}s`;
|
|
||||||
return `${Math.floor(sec / 60)}m ${sec % 60}s`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -1225,17 +1295,29 @@ function StepDone({ device, startedAt, onProvisionNext }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 rounded-md p-4 mb-5" style={{ backgroundColor: "var(--bg-primary)" }}>
|
<div className="rounded-md mb-5" style={{ backgroundColor: "var(--bg-primary)" }}>
|
||||||
<InfoCell label="Serial Number" value={device.serial_number} mono />
|
{/* Row 1: Serial Number (left) | Status (right) */}
|
||||||
<InfoCell label="Board Type" value={BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} />
|
<div className="grid grid-cols-2" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
|
||||||
<InfoCell label="HW Version" value={formatHwVersion(device.hw_version)} />
|
<div className="p-4" style={{ borderRight: "1px solid var(--border-secondary)" }}>
|
||||||
<div>
|
<p className="text-xs uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>Serial Number</p>
|
||||||
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Status</p>
|
<p className="text-sm font-mono" style={{ color: "var(--text-primary)" }}>{device.serial_number}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 flex flex-col items-end">
|
||||||
|
<p className="text-xs uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>Status</p>
|
||||||
<StatusBadge status={device.mfg_status} />
|
<StatusBadge status={device.mfg_status} />
|
||||||
</div>
|
</div>
|
||||||
{elapsed !== null && (
|
</div>
|
||||||
<InfoCell label="Time Taken" value={formatElapsed(elapsed)} />
|
{/* Row 2: Board Type (left) | HW Version (right) */}
|
||||||
)}
|
<div className="grid grid-cols-2">
|
||||||
|
<div className="p-4" style={{ borderRight: "1px solid var(--border-secondary)" }}>
|
||||||
|
<p className="text-xs uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>Board Type</p>
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-primary)" }}>{BOARD_TYPE_LABELS[device.hw_type] || device.hw_type}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 flex flex-col items-end">
|
||||||
|
<p className="text-xs uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>HW Version</p>
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-primary)" }}>{formatHwVersion(device.hw_version)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
@@ -1271,7 +1353,6 @@ export default function ProvisioningWizard() {
|
|||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [mode, setMode] = useState(null); // "existing" | "new"
|
const [mode, setMode] = useState(null); // "existing" | "new"
|
||||||
const [device, setDevice] = useState(null);
|
const [device, setDevice] = useState(null);
|
||||||
const [startedAt, setStartedAt] = useState(null);
|
|
||||||
const [createdSn, setCreatedSn] = useState(null); // SN created in Deploy New, for abort cleanup
|
const [createdSn, setCreatedSn] = useState(null); // SN created in Deploy New, for abort cleanup
|
||||||
|
|
||||||
const handleModePicked = (m) => {
|
const handleModePicked = (m) => {
|
||||||
@@ -1281,7 +1362,6 @@ export default function ProvisioningWizard() {
|
|||||||
|
|
||||||
const handleDeviceSelected = (dev) => {
|
const handleDeviceSelected = (dev) => {
|
||||||
setDevice(dev);
|
setDevice(dev);
|
||||||
setStartedAt(Date.now());
|
|
||||||
setStep(2);
|
setStep(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1318,12 +1398,10 @@ export default function ProvisioningWizard() {
|
|||||||
setStep(0);
|
setStep(0);
|
||||||
setMode(null);
|
setMode(null);
|
||||||
setDevice(null);
|
setDevice(null);
|
||||||
setStartedAt(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProvisionNext = () => {
|
const handleProvisionNext = () => {
|
||||||
setDevice(null);
|
setDevice(null);
|
||||||
setStartedAt(null);
|
|
||||||
setCreatedSn(null);
|
setCreatedSn(null);
|
||||||
setStep(0);
|
setStep(0);
|
||||||
setMode(null);
|
setMode(null);
|
||||||
@@ -1333,20 +1411,69 @@ export default function ProvisioningWizard() {
|
|||||||
const showBack = step >= 1 && step <= 3;
|
const showBack = step >= 1 && step <= 3;
|
||||||
const showAbort = step >= 1 && step <= 3;
|
const showAbort = step >= 1 && step <= 3;
|
||||||
|
|
||||||
|
// Flash step takes full available width; all others are centered at 720px
|
||||||
|
const isFlashStep = step === 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 720 }}>
|
<div style={{ display: "flex", flexDirection: "column", minHeight: "100%" }}>
|
||||||
<h1 className="text-xl font-bold mb-2" style={{ color: "var(--text-heading)" }}>
|
{/* ── Sticky top bar ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
backgroundColor: "var(--bg-primary)",
|
||||||
|
borderBottom: "1px solid var(--border-primary)",
|
||||||
|
padding: "12px 24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Title + controls row */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
{/* Left: Title */}
|
||||||
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)", minWidth: 0, flexShrink: 0 }}>
|
||||||
Provisioning Wizard
|
Provisioning Wizard
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<WizardTopBar
|
{/* Center: StepIndicator — always visible, current=step+1 to account for Begin as step 1 */}
|
||||||
step={step}
|
<div style={{ flex: 1, display: "flex", justifyContent: "center", paddingBottom: 8 }}>
|
||||||
onBack={handleBack}
|
<StepIndicator current={step + 1} />
|
||||||
onAbort={handleAbort}
|
</div>
|
||||||
showBack={showBack}
|
|
||||||
showAbort={showAbort}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
{/* Right: Back + Abort grouped together */}
|
||||||
|
<div className="flex items-center gap-2" style={{ flexShrink: 0 }}>
|
||||||
|
{showBack && (
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-opacity hover:opacity-80 cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showAbort && (
|
||||||
|
<button
|
||||||
|
onClick={handleAbort}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-opacity hover:opacity-80 cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)", border: "1px solid var(--danger)" }}
|
||||||
|
>
|
||||||
|
Abort ✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Step content ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: isFlashStep ? "24px" : "32px 24px",
|
||||||
|
...(isFlashStep
|
||||||
|
? {}
|
||||||
|
: { display: "flex", justifyContent: "center" }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={isFlashStep ? {} : { width: "100%", maxWidth: 720 }}>
|
||||||
{step === 0 && <StepModePicker onPick={handleModePicked} />}
|
{step === 0 && <StepModePicker onPick={handleModePicked} />}
|
||||||
|
|
||||||
{step === 1 && mode === "existing" && (
|
{step === 1 && mode === "existing" && (
|
||||||
@@ -1366,9 +1493,11 @@ export default function ProvisioningWizard() {
|
|||||||
<StepVerify device={device} onVerified={handleVerified} />
|
<StepVerify device={device} onVerified={handleVerified} />
|
||||||
)}
|
)}
|
||||||
{step === 4 && device && (
|
{step === 4 && device && (
|
||||||
<StepDone device={device} startedAt={startedAt} onProvisionNext={handleProvisionNext} />
|
<StepDone device={device} onProvisionNext={handleProvisionNext} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,16 +51,128 @@ const ALL_COLUMNS = [
|
|||||||
{ key: "pid", label: "PID", defaultOn: false },
|
{ key: "pid", label: "PID", defaultOn: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getDefaultVisibleColumns() {
|
const MEL_COL_VIS_KEY = "melodyListColumns";
|
||||||
const saved = localStorage.getItem("melodyListColumns");
|
const MEL_COL_ORDER_KEY = "melodyListColumnsOrder";
|
||||||
if (saved) {
|
|
||||||
|
function loadColumnPrefs() {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(saved);
|
const vis = JSON.parse(localStorage.getItem(MEL_COL_VIS_KEY) || "null");
|
||||||
|
const order = JSON.parse(localStorage.getItem(MEL_COL_ORDER_KEY) || "null");
|
||||||
|
const visible = vis
|
||||||
|
? Object.fromEntries(ALL_COLUMNS.map((c) => [c.key, vis.includes ? vis.includes(c.key) : Boolean(vis[c.key])]))
|
||||||
|
: Object.fromEntries(ALL_COLUMNS.map((c) => [c.key, c.defaultOn]));
|
||||||
|
const orderedIds = order || ALL_COLUMNS.map((c) => c.key);
|
||||||
|
// always-on columns
|
||||||
|
for (const c of ALL_COLUMNS) {
|
||||||
|
if (c.alwaysOn) visible[c.key] = true;
|
||||||
|
if (!orderedIds.includes(c.key)) orderedIds.push(c.key);
|
||||||
|
}
|
||||||
|
return { visible, orderedIds };
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
return {
|
||||||
|
visible: Object.fromEntries(ALL_COLUMNS.map((c) => [c.key, c.defaultOn])),
|
||||||
|
orderedIds: ALL_COLUMNS.map((c) => c.key),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveColumnPrefs(visible, orderedIds) {
|
||||||
|
localStorage.setItem(MEL_COL_VIS_KEY, JSON.stringify(visible));
|
||||||
|
localStorage.setItem(MEL_COL_ORDER_KEY, JSON.stringify(orderedIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Melody Column Toggle (drag-and-drop) ─────────────────────────────────────
|
||||||
|
|
||||||
|
function MelodyColumnToggle({ visible, orderedIds, onChange, onReorder }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [dragging, setDragging] = useState(null);
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragStart = (id) => setDragging(id);
|
||||||
|
const handleDragOver = (e, id) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragging && dragging !== id) {
|
||||||
|
const next = [...orderedIds];
|
||||||
|
const from = next.indexOf(dragging);
|
||||||
|
const to = next.indexOf(id);
|
||||||
|
next.splice(from, 1);
|
||||||
|
next.splice(to, 0, dragging);
|
||||||
|
onReorder(next);
|
||||||
}
|
}
|
||||||
return ALL_COLUMNS.filter((c) => c.defaultOn).map((c) => c.key);
|
};
|
||||||
|
const handleDragEnd = () => setDragging(null);
|
||||||
|
|
||||||
|
const visibleCount = Object.values(visible).filter(Boolean).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md transition-colors cursor-pointer border"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||||
|
</svg>
|
||||||
|
Columns ({visibleCount})
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-1 z-[9999] rounded-lg border shadow-lg p-2 overflow-y-auto"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", width: 220, maxHeight: 420 }}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-medium px-2 py-1 mb-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Drag to reorder
|
||||||
|
</p>
|
||||||
|
{orderedIds.map((key) => {
|
||||||
|
const col = ALL_COLUMNS.find((c) => c.key === key);
|
||||||
|
if (!col) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
draggable={!col.alwaysOn}
|
||||||
|
onDragStart={() => !col.alwaysOn && handleDragStart(key)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, key)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 rounded select-none"
|
||||||
|
style={{
|
||||||
|
cursor: col.alwaysOn ? "default" : "grab",
|
||||||
|
backgroundColor: dragging === key ? "var(--bg-card-hover)" : "transparent",
|
||||||
|
opacity: dragging === key ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--text-muted)", fontSize: 10, opacity: col.alwaysOn ? 0.3 : 1 }}>⠿</span>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer flex-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!visible[key]}
|
||||||
|
disabled={col.alwaysOn}
|
||||||
|
onChange={(e) => onChange(key, e.target.checked)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-sm"
|
||||||
|
style={{ color: col.alwaysOn ? "var(--text-muted)" : "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function speedBarColor(speedPercent) {
|
function speedBarColor(speedPercent) {
|
||||||
@@ -258,15 +370,35 @@ export default function MelodyList() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
const [unpublishTarget, setUnpublishTarget] = useState(null);
|
const [unpublishTarget, setUnpublishTarget] = useState(null);
|
||||||
const [actionLoading, setActionLoading] = useState(null);
|
const [actionLoading, setActionLoading] = useState(null);
|
||||||
const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns);
|
const [colPrefs, setColPrefs] = useState(loadColumnPrefs);
|
||||||
const [showColumnPicker, setShowColumnPicker] = useState(false);
|
|
||||||
const [showCreatorPicker, setShowCreatorPicker] = useState(false);
|
const [showCreatorPicker, setShowCreatorPicker] = useState(false);
|
||||||
const [showOfflineModal, setShowOfflineModal] = useState(false);
|
const [showOfflineModal, setShowOfflineModal] = useState(false);
|
||||||
const [builtInSavingId, setBuiltInSavingId] = useState(null);
|
const [builtInSavingId, setBuiltInSavingId] = useState(null);
|
||||||
const [viewRow, setViewRow] = useState(null);
|
const [viewRow, setViewRow] = useState(null);
|
||||||
const [builtMap, setBuiltMap] = useState({});
|
const [builtMap, setBuiltMap] = useState({});
|
||||||
const columnPickerRef = useRef(null);
|
|
||||||
const creatorPickerRef = useRef(null);
|
const creatorPickerRef = useRef(null);
|
||||||
|
|
||||||
|
// Derived helpers from colPrefs
|
||||||
|
const visibleColumns = colPrefs.orderedIds.filter((k) => colPrefs.visible[k]);
|
||||||
|
const isVisible = (key) => Boolean(colPrefs.visible[key]);
|
||||||
|
|
||||||
|
const handleColVisChange = (key, checked) => {
|
||||||
|
const col = ALL_COLUMNS.find((c) => c.key === key);
|
||||||
|
if (col?.alwaysOn) return;
|
||||||
|
setColPrefs((prev) => {
|
||||||
|
const next = { ...prev, visible: { ...prev.visible, [key]: checked } };
|
||||||
|
saveColumnPrefs(next.visible, next.orderedIds);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColReorder = (orderedIds) => {
|
||||||
|
setColPrefs((prev) => {
|
||||||
|
const next = { ...prev, orderedIds };
|
||||||
|
saveColumnPrefs(next.visible, next.orderedIds);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canEdit = hasPermission("melodies", "edit");
|
const canEdit = hasPermission("melodies", "edit");
|
||||||
@@ -278,35 +410,18 @@ export default function MelodyList() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
// Close creator picker on outside click
|
||||||
setVisibleColumns((prev) => {
|
|
||||||
const known = new Set(ALL_COLUMNS.map((c) => c.key));
|
|
||||||
let next = prev.filter((k) => known.has(k));
|
|
||||||
for (const col of ALL_COLUMNS) {
|
|
||||||
if (col.alwaysOn && !next.includes(col.key)) next.push(col.key);
|
|
||||||
}
|
|
||||||
if (JSON.stringify(next) !== JSON.stringify(prev)) {
|
|
||||||
localStorage.setItem("melodyListColumns", JSON.stringify(next));
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Close dropdowns on outside click
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClick = (e) => {
|
const handleClick = (e) => {
|
||||||
if (columnPickerRef.current && !columnPickerRef.current.contains(e.target)) {
|
|
||||||
setShowColumnPicker(false);
|
|
||||||
}
|
|
||||||
if (creatorPickerRef.current && !creatorPickerRef.current.contains(e.target)) {
|
if (creatorPickerRef.current && !creatorPickerRef.current.contains(e.target)) {
|
||||||
setShowCreatorPicker(false);
|
setShowCreatorPicker(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (showColumnPicker || showCreatorPicker) {
|
if (showCreatorPicker) {
|
||||||
document.addEventListener("mousedown", handleClick);
|
document.addEventListener("mousedown", handleClick);
|
||||||
return () => document.removeEventListener("mousedown", handleClick);
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
}
|
}
|
||||||
}, [showColumnPicker, showCreatorPicker]);
|
}, [showCreatorPicker]);
|
||||||
|
|
||||||
const fetchMelodies = async () => {
|
const fetchMelodies = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -519,45 +634,12 @@ export default function MelodyList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleColumn = (key) => {
|
|
||||||
const col = ALL_COLUMNS.find((c) => c.key === key);
|
|
||||||
if (col?.alwaysOn) return;
|
|
||||||
setVisibleColumns((prev) => {
|
|
||||||
const next = prev.includes(key)
|
|
||||||
? prev.filter((k) => k !== key)
|
|
||||||
: [...prev, key];
|
|
||||||
localStorage.setItem("melodyListColumns", JSON.stringify(next));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveColumn = (key, direction) => {
|
|
||||||
setVisibleColumns((prev) => {
|
|
||||||
const idx = prev.indexOf(key);
|
|
||||||
if (idx < 0) return prev;
|
|
||||||
const swapIdx = direction === "up" ? idx - 1 : idx + 1;
|
|
||||||
if (swapIdx < 0 || swapIdx >= prev.length) return prev;
|
|
||||||
const next = [...prev];
|
|
||||||
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
|
|
||||||
localStorage.setItem("melodyListColumns", JSON.stringify(next));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleCreator = (creator) => {
|
const toggleCreator = (creator) => {
|
||||||
setCreatedByFilter((prev) =>
|
setCreatedByFilter((prev) =>
|
||||||
prev.includes(creator) ? prev.filter((c) => c !== creator) : [...prev, creator]
|
prev.includes(creator) ? prev.filter((c) => c !== creator) : [...prev, creator]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isVisible = (key) => visibleColumns.includes(key);
|
|
||||||
const orderedColumnPickerColumns = useMemo(() => {
|
|
||||||
const byKey = new Map(ALL_COLUMNS.map((c) => [c.key, c]));
|
|
||||||
const visibleOrdered = visibleColumns.map((k) => byKey.get(k)).filter(Boolean);
|
|
||||||
const hidden = ALL_COLUMNS.filter((c) => !visibleColumns.includes(c.key));
|
|
||||||
return [...visibleOrdered, ...hidden];
|
|
||||||
}, [visibleColumns]);
|
|
||||||
|
|
||||||
const getDisplayName = (nameVal) => getLocalizedValue(nameVal, displayLang, "Untitled");
|
const getDisplayName = (nameVal) => getLocalizedValue(nameVal, displayLang, "Untitled");
|
||||||
|
|
||||||
const allCreators = useMemo(() => {
|
const allCreators = useMemo(() => {
|
||||||
@@ -1091,74 +1173,13 @@ export default function MelodyList() {
|
|||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative" ref={columnPickerRef} style={{ zIndex: 60 }}>
|
<div style={{ zIndex: 60, position: "relative" }}>
|
||||||
<button
|
<MelodyColumnToggle
|
||||||
type="button"
|
visible={colPrefs.visible}
|
||||||
onClick={() => setShowColumnPicker((prev) => !prev)}
|
orderedIds={colPrefs.orderedIds}
|
||||||
className="px-3 py-2 rounded-md text-sm transition-colors cursor-pointer flex items-center gap-1.5 border"
|
onChange={handleColVisChange}
|
||||||
style={{
|
onReorder={handleColReorder}
|
||||||
borderColor: "var(--border-primary)",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
|
||||||
</svg>
|
|
||||||
Columns
|
|
||||||
</button>
|
|
||||||
{showColumnPicker && (
|
|
||||||
<div
|
|
||||||
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg py-2 w-56 border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--bg-card)",
|
|
||||||
borderColor: "var(--border-primary)",
|
|
||||||
zIndex: 9999,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{orderedColumnPickerColumns.map((col) => {
|
|
||||||
const orderIdx = visibleColumns.indexOf(col.key);
|
|
||||||
const canMove = orderIdx >= 0;
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={col.key}
|
|
||||||
className="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer"
|
|
||||||
style={{ color: col.alwaysOn ? "var(--text-muted)" : "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isVisible(col.key)}
|
|
||||||
onChange={() => toggleColumn(col.key)}
|
|
||||||
disabled={col.alwaysOn}
|
|
||||||
className="h-3.5 w-3.5 rounded cursor-pointer"
|
|
||||||
/>
|
/>
|
||||||
<span className="flex-1">{col.label}</span>
|
|
||||||
{canMove && (
|
|
||||||
<span className="inline-flex gap-1" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => moveColumn(col.key, "up")}
|
|
||||||
className="text-[10px] px-1 rounded border"
|
|
||||||
style={{ borderColor: "var(--border-primary)", color: "var(--text-muted)", background: "transparent" }}
|
|
||||||
title="Move up"
|
|
||||||
>
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => moveColumn(col.key, "down")}
|
|
||||||
className="text-[10px] px-1 rounded border"
|
|
||||||
style={{ borderColor: "var(--border-primary)", color: "var(--text-muted)", background: "transparent" }}
|
|
||||||
title="Move down"
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
17
frontend/vite.config.local.js
Normal file
17
frontend/vite.config.local.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user