fix: Various fixes. Mail, UI, Flash etc

This commit is contained in:
2026-02-27 14:32:24 +02:00
parent 7585e43b52
commit 810e81b323
9 changed files with 930 additions and 612 deletions

View File

@@ -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",
}) })
send_device_assignment_invite( hw_type = doc_data.get("hw_type", "")
customer_email=data.customer_email, device_name = BOARD_TYPE_LABELS.get(hw_type, hw_type or "Device")
serial_number=sn,
customer_name=data.customer_name, try:
) send_device_assignment_invite(
customer_email=data.customer_email,
serial_number=sn,
device_name=device_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())

View File

@@ -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">
</p> <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%;">
<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;" /> <!-- Header -->
<p style="color: #6b7280; font-size: 12px;"> <tr>
If you did not expect this email, please contact your system administrator. <td style="background-color:#0f172a; padding: 32px 40px; text-align:center;">
</p> <h1 style="color:#ffffff; margin:0; font-size:22px; font-weight:700; letter-spacing:1px;">BELLSYSTEMS</h1>
</div> <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 style="margin:0 0 24px; font-size:15px; color:#334155; line-height:1.7;">
To get started, download our controller application from the Google Play Store and follow the in-app setup instructions.
</p>
<!-- 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,
) )

View File

@@ -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}"

View File

@@ -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>

View File

@@ -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)" }}>

View File

@@ -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 && (

File diff suppressed because it is too large Load Diff

View File

@@ -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) {
try { function loadColumnPrefs() {
return JSON.parse(saved); try {
} catch { const vis = JSON.parse(localStorage.getItem(MEL_COL_VIS_KEY) || "null");
// ignore 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 {
return {
visible: Object.fromEntries(ALL_COLUMNS.map((c) => [c.key, c.defaultOn])),
orderedIds: ALL_COLUMNS.map((c) => c.key),
};
} }
return ALL_COLUMNS.filter((c) => c.defaultOn).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);
}
};
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>

View 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,
},
},
},
})