Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
// frontend/src/pages/bellcloud/devices/DeviceDetail.jsx
|
||||
// Parent shell — data fetching, tab routing, modals. Tab content lives in tabs/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import api from '@/lib/api'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Tabs from '@/components/ui/Tabs'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||||
|
||||
import EditLocationModal from '@/modals/bellcloud/devices/EditLocationModal'
|
||||
import EditAttributesModal from '@/modals/bellcloud/devices/EditAttributesModal'
|
||||
import EditLoggingModal from '@/modals/bellcloud/devices/EditLoggingModal'
|
||||
import EditMiscModal from '@/modals/bellcloud/devices/EditMiscModal'
|
||||
import EditBellOutputsModal from '@/modals/bellcloud/devices/EditBellOutputsModal'
|
||||
import EditClockSettingsModal from '@/modals/bellcloud/devices/EditClockSettingsModal'
|
||||
import EditAlertsModal from '@/modals/bellcloud/devices/EditAlertsModal'
|
||||
import EditBacklightModal from '@/modals/bellcloud/devices/EditBacklightModal'
|
||||
import EditSubscriptionModal from '@/modals/bellcloud/devices/EditSubscriptionModal'
|
||||
import EditWarrantyModal from '@/modals/bellcloud/devices/EditWarrantyModal'
|
||||
import AssignCustomerModal from '@/modals/bellcloud/devices/AssignCustomerModal'
|
||||
|
||||
import OverviewTab from '@/pages/bellcloud/devices/tabs/OverviewTab'
|
||||
import GeneralTab from '@/pages/bellcloud/devices/tabs/GeneralTab'
|
||||
import BellsTab from '@/pages/bellcloud/devices/tabs/BellsTab'
|
||||
import ClockTab from '@/pages/bellcloud/devices/tabs/ClockTab'
|
||||
import WarrantyTab from '@/pages/bellcloud/devices/tabs/WarrantyTab'
|
||||
import ManageTab from '@/pages/bellcloud/devices/tabs/ManageTab'
|
||||
import ControlTab from '@/pages/bellcloud/devices/tabs/ControlTab'
|
||||
|
||||
// ─── Tab config ───────────────────────────────────────────────────────────────
|
||||
|
||||
const TABS = [
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
{ key: 'general', label: 'General' },
|
||||
{ key: 'bells', label: 'Bell Mechanisms' },
|
||||
{ key: 'clock', label: 'Clock & Alerts' },
|
||||
{ key: 'warranty', label: 'Warranty & Subscription' },
|
||||
{ key: 'manage', label: 'Manage' },
|
||||
{ key: 'control', label: 'Control' },
|
||||
]
|
||||
|
||||
function resolveInitialTab(searchParams) {
|
||||
const raw = searchParams.get('tab')
|
||||
if (!raw) return 'overview'
|
||||
const match = TABS.find(t => t.key === raw.toLowerCase())
|
||||
return match ? match.key : 'overview'
|
||||
}
|
||||
|
||||
// ─── DeviceDetail ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DeviceDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const { hasPermission } = useAuth()
|
||||
const { toast } = useToast()
|
||||
const canEdit = hasPermission('devices', 'edit')
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
const [device, setDevice] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [mqttStatus, setMqttStatus] = useState(null)
|
||||
const [activeTab, setActiveTab] = useState(() => resolveInitialTab(searchParams))
|
||||
|
||||
const [deviceUsers, setDeviceUsers] = useState([])
|
||||
const [usersLoading, setUsersLoading] = useState(false)
|
||||
const [ownerCustomer, setOwnerCustomer] = useState(null)
|
||||
const [assigningCustomer, setAssigningCustomer] = useState(false)
|
||||
|
||||
const [tags, setTags] = useState([])
|
||||
const [staffNotes, setStaffNotes] = useState('')
|
||||
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const [cmdHistory, setCmdHistory] = useState([])
|
||||
const [cmdHistoryLoading, setCmdHistoryLoading] = useState(false)
|
||||
const [sendingCmd, setSendingCmd] = useState('')
|
||||
|
||||
// Modal open flags
|
||||
const [editingLocation, setEditingLocation] = useState(false)
|
||||
const [editingAttributes, setEditingAttributes] = useState(false)
|
||||
const [editingLogging, setEditingLogging] = useState(false)
|
||||
const [editingMisc, setEditingMisc] = useState(false)
|
||||
const [editingBellOutputs, setEditingBellOutputs] = useState(false)
|
||||
const [editingClockSettings, setEditingClockSettings] = useState(false)
|
||||
const [editingAlerts, setEditingAlerts] = useState(false)
|
||||
const [editingBacklight, setEditingBacklight] = useState(false)
|
||||
const [editingSubscription, setEditingSubscription] = useState(false)
|
||||
const [editingWarranty, setEditingWarranty] = useState(false)
|
||||
const [showAssignCustomer, setShowAssignCustomer] = useState(false)
|
||||
|
||||
// ── Data fetching ──────────────────────────────────────────────────────────
|
||||
|
||||
const loadDevice = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const d = await api.get(`/devices/${id}`)
|
||||
setDevice(d)
|
||||
if (d.staffNotes) setStaffNotes(d.staffNotes)
|
||||
if (Array.isArray(d.tags)) setTags(d.tags)
|
||||
setLoading(false)
|
||||
|
||||
const sn = d.serial_number || d.device_id
|
||||
if (sn) {
|
||||
api.get('/mqtt/status').then(mqttData => {
|
||||
if (mqttData?.devices) {
|
||||
const match = mqttData.devices.find(s => s.device_serial === sn)
|
||||
setMqttStatus(match || null)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
if (d.customer_id) {
|
||||
api.get(`/devices/${id}/customer`).then(res => {
|
||||
setOwnerCustomer(res.customer || null)
|
||||
}).catch(() => setOwnerCustomer(null))
|
||||
} else {
|
||||
setOwnerCustomer(null)
|
||||
}
|
||||
|
||||
setUsersLoading(true)
|
||||
api.get(`/devices/${id}/users`).then(data => {
|
||||
setDeviceUsers(data.users || [])
|
||||
}).catch(() => setDeviceUsers([])).finally(() => setUsersLoading(false))
|
||||
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load device.')
|
||||
setLoading(false)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => { loadDevice() }, [loadDevice])
|
||||
|
||||
const loadCmdHistory = useCallback(async () => {
|
||||
const sn = device?.serial_number || device?.device_id
|
||||
if (!sn) return
|
||||
setCmdHistoryLoading(true)
|
||||
try {
|
||||
const data = await api.get(`/mqtt/commands/${sn}?limit=50`)
|
||||
setCmdHistory(data.commands || [])
|
||||
} catch {
|
||||
setCmdHistory([])
|
||||
} finally {
|
||||
setCmdHistoryLoading(false)
|
||||
}
|
||||
}, [device])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'control' && device) loadCmdHistory()
|
||||
}, [activeTab, device, loadCmdHistory])
|
||||
|
||||
// ── Tab sync ───────────────────────────────────────────────────────────────
|
||||
|
||||
const handleTabChange = key => {
|
||||
setActiveTab(key)
|
||||
setSearchParams(prev => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.set('tab', key)
|
||||
return next
|
||||
}, { replace: true })
|
||||
}
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
await api.delete(`/devices/${id}`)
|
||||
toast.success('Deleted', 'Device removed.')
|
||||
navigate('/devices')
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to delete device.')
|
||||
setDeleting(false)
|
||||
setShowDelete(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAssignCustomer = async customer => {
|
||||
setAssigningCustomer(true)
|
||||
try {
|
||||
await api.post(`/devices/${id}/assign-customer`, { customer_id: customer.id })
|
||||
setOwnerCustomer(customer)
|
||||
setDevice(prev => ({ ...prev, customer_id: customer.id }))
|
||||
setShowAssignCustomer(false)
|
||||
toast.success('Assigned', `Assigned to ${[customer.name, customer.surname].filter(Boolean).join(' ')}.`)
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to assign customer.')
|
||||
} finally {
|
||||
setAssigningCustomer(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnassignCustomer = async () => {
|
||||
setAssigningCustomer(true)
|
||||
try {
|
||||
const cid = device?.customer_id
|
||||
await api.delete(`/devices/${id}/assign-customer${cid ? `?customer_id=${cid}` : ''}`)
|
||||
setOwnerCustomer(null)
|
||||
setDevice(prev => ({ ...prev, customer_id: '' }))
|
||||
toast.success('Removed', 'Customer unassigned.')
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to unassign.')
|
||||
} finally {
|
||||
setAssigningCustomer(false)
|
||||
}
|
||||
}
|
||||
|
||||
const sendMqttCommand = async (cmd, contents = {}) => {
|
||||
const deviceId = device?.device_id || device?.serial_number
|
||||
if (!deviceId) return
|
||||
setSendingCmd(cmd)
|
||||
try {
|
||||
await api.post(`/mqtt/command/${deviceId}`, { cmd, contents })
|
||||
toast.success('Sent', `Command "${cmd}" sent.`)
|
||||
loadCmdHistory()
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to send command.')
|
||||
} finally {
|
||||
setSendingCmd('')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
|
||||
const attr = device?.device_attributes || {}
|
||||
const clock = attr.clockSettings || {}
|
||||
const sub = device?.device_subscription || {}
|
||||
const stats = device?.device_stats || {}
|
||||
const sn = device?.serial_number || device?.device_id
|
||||
const isOnline = mqttStatus ? mqttStatus.online : device?.is_Online
|
||||
|
||||
// ── Render states ──────────────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 'var(--space-16)' }}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<div role="alert" style={{ backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', borderRadius: 'var(--radius-md)', padding: 'var(--space-4)', color: 'var(--color-danger)' }}>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!device) return null
|
||||
|
||||
// Props bundle passed to every tab
|
||||
const tabProps = {
|
||||
device,
|
||||
canEdit,
|
||||
onDeviceUpdated: setDevice,
|
||||
attr,
|
||||
clock,
|
||||
sub,
|
||||
stats,
|
||||
sn,
|
||||
isOnline,
|
||||
tags,
|
||||
setTags,
|
||||
staffNotes,
|
||||
setStaffNotes,
|
||||
deviceUsers,
|
||||
usersLoading,
|
||||
ownerCustomer,
|
||||
assigningCustomer,
|
||||
cmdHistory,
|
||||
cmdHistoryLoading,
|
||||
sendingCmd,
|
||||
loadDevice,
|
||||
loadCmdHistory,
|
||||
sendMqttCommand,
|
||||
onShowDelete: () => setShowDelete(true),
|
||||
onAssignCustomer: () => setShowAssignCustomer(true),
|
||||
onUnassignCustomer: handleUnassignCustomer,
|
||||
onEditLocation: () => setEditingLocation(true),
|
||||
onEditAttributes: () => setEditingAttributes(true),
|
||||
onEditLogging: () => setEditingLogging(true),
|
||||
onEditMisc: () => setEditingMisc(true),
|
||||
onEditBellOutputs: () => setEditingBellOutputs(true),
|
||||
onEditClockSettings: () => setEditingClockSettings(true),
|
||||
onEditAlerts: () => setEditingAlerts(true),
|
||||
onEditBacklight: () => setEditingBacklight(true),
|
||||
onEditSubscription: () => setEditingSubscription(true),
|
||||
onEditWarranty: () => setEditingWarranty(true),
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
|
||||
<PageHeader
|
||||
title={device.device_name || 'Unnamed Device'}
|
||||
subtitle={sn}
|
||||
>
|
||||
{canEdit && (
|
||||
<Button variant="secondary" onClick={() => navigate(`/devices/${id}/edit`)}>
|
||||
Edit Device
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<Tabs
|
||||
tabs={TABS}
|
||||
active={activeTab}
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{activeTab === 'overview' && <OverviewTab {...tabProps} />}
|
||||
{activeTab === 'general' && <GeneralTab {...tabProps} />}
|
||||
{activeTab === 'bells' && <BellsTab {...tabProps} />}
|
||||
{activeTab === 'clock' && <ClockTab {...tabProps} />}
|
||||
{activeTab === 'warranty' && <WarrantyTab {...tabProps} />}
|
||||
{activeTab === 'manage' && <ManageTab {...tabProps} />}
|
||||
{activeTab === 'control' && <ControlTab {...tabProps} />}
|
||||
</div>
|
||||
|
||||
{/* ── Modals ────────────────────────────────────────────────────────── */}
|
||||
|
||||
<EditLocationModal
|
||||
open={editingLocation}
|
||||
onClose={() => setEditingLocation(false)}
|
||||
onSaved={loadDevice}
|
||||
device={device}
|
||||
id={id}
|
||||
/>
|
||||
<EditAttributesModal
|
||||
open={editingAttributes}
|
||||
onClose={() => setEditingAttributes(false)}
|
||||
onSaved={loadDevice}
|
||||
attr={attr}
|
||||
id={id}
|
||||
/>
|
||||
<EditLoggingModal
|
||||
open={editingLogging}
|
||||
onClose={() => setEditingLogging(false)}
|
||||
onSaved={loadDevice}
|
||||
attr={attr}
|
||||
id={id}
|
||||
/>
|
||||
<EditMiscModal
|
||||
open={editingMisc}
|
||||
onClose={() => setEditingMisc(false)}
|
||||
onSaved={loadDevice}
|
||||
device={device}
|
||||
attr={attr}
|
||||
id={id}
|
||||
/>
|
||||
<EditBellOutputsModal
|
||||
open={editingBellOutputs}
|
||||
onClose={() => setEditingBellOutputs(false)}
|
||||
onSaved={loadDevice}
|
||||
attr={attr}
|
||||
sub={sub}
|
||||
id={id}
|
||||
/>
|
||||
<EditClockSettingsModal
|
||||
open={editingClockSettings}
|
||||
onClose={() => setEditingClockSettings(false)}
|
||||
onSaved={loadDevice}
|
||||
attr={attr}
|
||||
sub={sub}
|
||||
id={id}
|
||||
/>
|
||||
<EditAlertsModal
|
||||
open={editingAlerts}
|
||||
onClose={() => setEditingAlerts(false)}
|
||||
onSaved={loadDevice}
|
||||
clock={clock}
|
||||
attr={attr}
|
||||
id={id}
|
||||
/>
|
||||
<EditBacklightModal
|
||||
open={editingBacklight}
|
||||
onClose={() => setEditingBacklight(false)}
|
||||
onSaved={loadDevice}
|
||||
attr={attr}
|
||||
clock={clock}
|
||||
sub={sub}
|
||||
id={id}
|
||||
/>
|
||||
<EditSubscriptionModal
|
||||
open={editingSubscription}
|
||||
onClose={() => setEditingSubscription(false)}
|
||||
onSaved={loadDevice}
|
||||
sub={sub}
|
||||
id={id}
|
||||
/>
|
||||
<EditWarrantyModal
|
||||
open={editingWarranty}
|
||||
onClose={() => setEditingWarranty(false)}
|
||||
onSaved={loadDevice}
|
||||
stats={stats}
|
||||
id={id}
|
||||
/>
|
||||
<AssignCustomerModal
|
||||
open={showAssignCustomer}
|
||||
onClose={() => setShowAssignCustomer(false)}
|
||||
onSelect={handleAssignCustomer}
|
||||
deviceId={id}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={showDelete}
|
||||
variant="danger"
|
||||
title="Delete Device"
|
||||
message={`Are you sure you want to delete "${device.device_name || sn}"? This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setShowDelete(false)}
|
||||
loading={deleting}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// TODO: implement
|
||||
export default function DeviceForm() {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
// frontend/src/pages/devices/DeviceList.jsx
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '@/lib/api'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
import Button from '@/components/ui/Button'
|
||||
import DataTable from '@/components/ui/DataTable'
|
||||
import StatusBadge from '@/components/ui/StatusBadge'
|
||||
import Pagination from '@/components/ui/Pagination'
|
||||
import SearchBar from '@/components/ui/SearchBar'
|
||||
import RowActions from '@/components/ui/RowActions'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
import SegmentedControl from '@/components/ui/SegmentedControl'
|
||||
import DeleteDeviceModal from '@/modals/bellcloud/devices/DeleteDeviceModal'
|
||||
import DeviceFiltersModal from '@/modals/bellcloud/devices/DeviceFiltersModal'
|
||||
import DeviceListCardView from './DeviceListCardView'
|
||||
import DeviceListMapView from './DeviceListMapView'
|
||||
|
||||
// ─── Column definitions ───────────────────────────────────────────────────────
|
||||
|
||||
// Columns marked sortable: name, location, totalBells, assignedUsers, maxOutputs,
|
||||
// totalPlaybacks, totalHammerStrikes, totalWarnings, totalMelodies
|
||||
const ALL_COLUMNS = [
|
||||
{ key: 'status', label: '', pickerLabel: 'Status', defaultOn: true, width: '44px' },
|
||||
{ key: 'name', label: 'Name', defaultOn: true, alwaysOn: true, sortable: true },
|
||||
{ key: 'serialNumber', label: 'Serial Number', defaultOn: true },
|
||||
{ key: 'location', label: 'Location', defaultOn: true, sortable: true },
|
||||
{ key: 'subscrTier', label: 'Tier', defaultOn: true },
|
||||
{ key: 'totalBells', label: 'Total Bells', defaultOn: true, sortable: true },
|
||||
{ key: 'assignedUsers', label: 'Users', defaultOn: true, sortable: true },
|
||||
{ key: 'maxOutputs', label: 'Max Outputs', defaultOn: false, sortable: true },
|
||||
{ key: 'hasClock', label: 'Has Clock', defaultOn: false },
|
||||
{ key: 'hasBells', label: 'Has Bells', defaultOn: false },
|
||||
{ key: 'bellGuard', label: 'Bell Guard', defaultOn: false },
|
||||
{ key: 'warningsOn', label: 'Warnings On', defaultOn: false },
|
||||
{ key: 'serialLogLevel', label: 'Serial Log Level', defaultOn: false },
|
||||
{ key: 'sdLogLevel', label: 'SD Log Level', defaultOn: false },
|
||||
{ key: 'bellOutputs', label: 'Bell Outputs', defaultOn: false },
|
||||
{ key: 'hammerTimings', label: 'Hammer Timings', defaultOn: false },
|
||||
{ key: 'ringAlertsMaster', label: 'Ring Alerts', defaultOn: false },
|
||||
{ key: 'totalPlaybacks', label: 'Playbacks', defaultOn: false, sortable: true },
|
||||
{ key: 'totalHammerStrikes', label: 'Hammer Strikes', defaultOn: false, sortable: true },
|
||||
{ key: 'totalWarnings', label: 'Warnings Given', defaultOn: false, sortable: true },
|
||||
{ key: 'warrantyActive', label: 'Warranty', defaultOn: false },
|
||||
{ key: 'totalMelodies', label: 'Melodies', defaultOn: false, sortable: true },
|
||||
{ key: 'tags', label: 'Tags', defaultOn: false },
|
||||
{ key: 'hw_family', label: 'HW Family', defaultOn: false },
|
||||
{ key: 'hw_revision', label: 'HW Revision', defaultOn: false },
|
||||
]
|
||||
|
||||
const ALL_KEYS = ALL_COLUMNS.map((c) => c.key)
|
||||
|
||||
function loadColumnPrefs() {
|
||||
try {
|
||||
const raw = localStorage.getItem('deviceListColumnPrefs')
|
||||
if (raw) {
|
||||
const prefs = JSON.parse(raw)
|
||||
// prefs = { order: [...keys], visible: [...keys] }
|
||||
if (prefs.order && prefs.visible) return prefs
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return {
|
||||
order: ALL_KEYS,
|
||||
visible: ALL_COLUMNS.filter((c) => c.defaultOn).map((c) => c.key),
|
||||
}
|
||||
}
|
||||
|
||||
function saveColumnPrefs(order, visible) {
|
||||
try {
|
||||
localStorage.setItem('deviceListColumnPrefs', JSON.stringify({ order, visible }))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseFirestoreDate(str) {
|
||||
if (!str) return null
|
||||
const cleaned = str.replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+\d{4}/, 'UTC')
|
||||
const d = new Date(cleaned)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
function isSubscriptionActive(device) {
|
||||
const sub = device.device_subscription
|
||||
if (!sub?.subscrStart || !sub?.subscrDuration) return false
|
||||
try {
|
||||
const start = parseFirestoreDate(sub.subscrStart)
|
||||
if (!start) return false
|
||||
return new Date(start.getTime() + sub.subscrDuration * 86400000) > new Date()
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
function isWarrantyActive(device) {
|
||||
const stats = device.device_stats
|
||||
if (!stats?.warrantyStart || !stats?.warrantyPeriod) return !!stats?.warrantyActive
|
||||
try {
|
||||
const start = parseFirestoreDate(stats.warrantyStart)
|
||||
if (!start) return !!stats?.warrantyActive
|
||||
return new Date(start.getTime() + stats.warrantyPeriod * 86400000) > new Date()
|
||||
} catch { return !!stats?.warrantyActive }
|
||||
}
|
||||
|
||||
// ─── Cell sub-components ──────────────────────────────────────────────────────
|
||||
|
||||
function OnlineDot({ isOnline }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
backgroundColor: isOnline ? 'var(--color-success)' : 'var(--color-bg-island)',
|
||||
boxShadow: isOnline ? '0 0 6px var(--color-success)' : 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title={isOnline ? 'Online' : 'Offline'}
|
||||
aria-label={isOnline ? 'Online' : 'Offline'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BoolBadge({ value, yesLabel = 'Yes', noLabel = 'No' }) {
|
||||
return (
|
||||
<StatusBadge variant={value ? 'success' : 'neutral'} dot={false} size="sm">
|
||||
{value ? yesLabel : noLabel}
|
||||
</StatusBadge>
|
||||
)
|
||||
}
|
||||
|
||||
function TierBadge({ tier }) {
|
||||
const t = (tier || 'basic').toLowerCase()
|
||||
const variant = t === 'vip' || t === 'premium' ? 'primary' : t === 'custom' ? 'info' : 'neutral'
|
||||
return (
|
||||
<StatusBadge variant={variant} dot={false} size="sm">
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</StatusBadge>
|
||||
)
|
||||
}
|
||||
|
||||
function TagList({ tags }) {
|
||||
if (!tags?.length) return <span style={{ color: 'var(--color-text-muted)' }}>—</span>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-1)' }}>
|
||||
{tags.map((t) => (
|
||||
<StatusBadge key={t} variant="info" dot={false} size="sm">{t}</StatusBadge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Muted({ children }) {
|
||||
return <span style={{ color: 'var(--color-text-muted)' }}>{children}</span>
|
||||
}
|
||||
|
||||
// ─── Sorting helper ───────────────────────────────────────────────────────────
|
||||
|
||||
function getSortValue(device, key) {
|
||||
const attr = device.device_attributes || {}
|
||||
const sub = device.device_subscription || {}
|
||||
const stats = device.device_stats || {}
|
||||
switch (key) {
|
||||
case 'name': return (device.device_name || '').toLowerCase()
|
||||
case 'location': return (device.device_location || '').toLowerCase()
|
||||
case 'totalBells': return attr.totalBells ?? -1
|
||||
case 'assignedUsers': return Array.isArray(device.user_list) ? device.user_list.length : 0
|
||||
case 'maxOutputs': return sub.maxOutputs ?? -1
|
||||
case 'totalPlaybacks': return stats.totalPlaybacks ?? -1
|
||||
case 'totalHammerStrikes': return stats.totalHammerStrikes ?? -1
|
||||
case 'totalWarnings': return stats.totalWarningsGiven ?? -1
|
||||
case 'totalMelodies': return device.device_melodies_all?.length ?? -1
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
function sortDevices(devices, key, dir) {
|
||||
if (!key) return devices
|
||||
return [...devices].sort((a, b) => {
|
||||
const va = getSortValue(a, key)
|
||||
const vb = getSortValue(b, key)
|
||||
if (va < vb) return dir === 'asc' ? -1 : 1
|
||||
if (va > vb) return dir === 'asc' ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
// ─── DeviceList ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DeviceList() {
|
||||
const navigate = useNavigate()
|
||||
const { hasPermission } = useAuth()
|
||||
const canEdit = hasPermission('devices', 'edit')
|
||||
|
||||
// Data
|
||||
const [devices, setDevices] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [mqttStatusMap, setMqttStatusMap] = useState({})
|
||||
const [mqttLoaded, setMqttLoaded] = useState(false)
|
||||
|
||||
// Server-side filters
|
||||
const [search, setSearch] = useState('')
|
||||
const [onlineFilter, setOnlineFilter] = useState('')
|
||||
const [tierFilter, setTierFilter] = useState('')
|
||||
|
||||
// Client-side filters
|
||||
const [subscrStatusFilter, setSubscrStatusFilter] = useState('')
|
||||
const [warrantyStatusFilter, setWarrantyStatusFilter] = useState('')
|
||||
const [hasClockFilter, setHasClockFilter] = useState('')
|
||||
const [hasBellsFilter, setHasBellsFilter] = useState('')
|
||||
|
||||
// Column prefs (visibility + order)
|
||||
const [colPrefs, setColPrefs] = useState(loadColumnPrefs)
|
||||
|
||||
// Sort
|
||||
const [sortKey, setSortKey] = useState('')
|
||||
const [sortDir, setSortDir] = useState('asc')
|
||||
|
||||
// Pagination
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
// View mode: 'table' | 'card' (map is temporarily disabled — access via /devices/map-preview)
|
||||
const [viewMode, setViewMode] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('deviceListViewMode')
|
||||
// Reset 'map' to 'table' while map view is under construction
|
||||
return (saved === 'map' ? 'table' : saved) || 'table'
|
||||
} catch { return 'table' }
|
||||
})
|
||||
|
||||
// Filters modal
|
||||
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||
|
||||
// Delete modal
|
||||
const [deleteTarget, setDeleteTarget] = useState(null)
|
||||
const [deleteError, setDeleteError] = useState('')
|
||||
|
||||
// ── Column change handler ──────────────────────────────────────────────────
|
||||
// Accepts either a new visible array (from toggle) or a new order array (from drag)
|
||||
// DataTable calls onColumnChange with the new visibleKeys array for both operations
|
||||
const handleColumnChange = useCallback((newVisibleKeys) => {
|
||||
setColPrefs((prev) => {
|
||||
// Detect if this is a reorder (same keys, different order) or visibility toggle
|
||||
const sortedPrev = [...prev.visible].sort()
|
||||
const sortedNext = [...newVisibleKeys].sort()
|
||||
const isReorder = JSON.stringify(sortedPrev) === JSON.stringify(sortedNext)
|
||||
|
||||
let nextOrder = prev.order
|
||||
let nextVisible = prev.visible
|
||||
|
||||
if (isReorder) {
|
||||
// Drag reorder: update the order of visible keys, keeping hidden keys appended
|
||||
const hiddenKeys = prev.order.filter((k) => !prev.visible.includes(k))
|
||||
nextOrder = [...newVisibleKeys, ...hiddenKeys]
|
||||
nextVisible = newVisibleKeys
|
||||
} else {
|
||||
// Visibility toggle: preserve order, just update visible set
|
||||
nextVisible = newVisibleKeys
|
||||
}
|
||||
|
||||
saveColumnPrefs(nextOrder, nextVisible)
|
||||
return { order: nextOrder, visible: nextVisible }
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ── Fetch devices ──────────────────────────────────────────────────────────
|
||||
const fetchDevices = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setMqttLoaded(false)
|
||||
setError('')
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set('search', search)
|
||||
if (onlineFilter === 'true') params.set('online', 'true')
|
||||
if (onlineFilter === 'false') params.set('online', 'false')
|
||||
if (tierFilter) params.set('tier', tierFilter)
|
||||
// Note: online filter is server-side — we pass it as a query param but
|
||||
// the server returns ALL devices when the param is absent, so we also
|
||||
// apply a client-side guard to ensure ONLINE-only actually filters.
|
||||
const qs = params.toString()
|
||||
const data = await api.get(`/devices${qs ? `?${qs}` : ''}`)
|
||||
setDevices(data.devices ?? [])
|
||||
api.get('/mqtt/status').then((mqttData) => {
|
||||
if (mqttData?.devices) {
|
||||
const map = {}
|
||||
for (const s of mqttData.devices) map[s.device_serial] = s
|
||||
setMqttStatusMap(map)
|
||||
}
|
||||
setMqttLoaded(true)
|
||||
}).catch(() => { setMqttLoaded(true) })
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load devices.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [search, onlineFilter, tierFilter])
|
||||
|
||||
useEffect(() => { fetchDevices() }, [fetchDevices])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [search, onlineFilter, tierFilter, subscrStatusFilter, warrantyStatusFilter, hasClockFilter, hasBellsFilter])
|
||||
|
||||
// ── Delete ─────────────────────────────────────────────────────────────────
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
setDeleteError('')
|
||||
try {
|
||||
await api.delete(`/devices/${deleteTarget.id}`)
|
||||
setDeleteTarget(null)
|
||||
fetchDevices()
|
||||
} catch (err) {
|
||||
setDeleteError(err.message || 'Failed to delete device.')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Client-side filtering + sorting ───────────────────────────────────────
|
||||
const filteredDevices = devices.filter((device) => {
|
||||
// Online/offline — wait for MQTT data before applying so we don't show
|
||||
// false-negative "no devices" while the status map is still loading.
|
||||
const sn = device.serial_number || device.device_id
|
||||
const mqtt = mqttStatusMap[sn]
|
||||
const isOnline = mqtt ? mqtt.online : (device.is_Online ?? false)
|
||||
// Only apply online/offline filter once MQTT status has resolved —
|
||||
// prevents false-negative empty table while the status map is still loading.
|
||||
if (onlineFilter === 'true' && mqttLoaded && !isOnline) return false
|
||||
if (onlineFilter === 'false' && mqttLoaded && isOnline) return false
|
||||
|
||||
if (subscrStatusFilter === 'active' && !isSubscriptionActive(device)) return false
|
||||
if (subscrStatusFilter === 'expired' && isSubscriptionActive(device)) return false
|
||||
if (warrantyStatusFilter === 'active' && !isWarrantyActive(device)) return false
|
||||
if (warrantyStatusFilter === 'expired' && isWarrantyActive(device)) return false
|
||||
if (hasClockFilter === 'yes' && !device.device_attributes?.hasClock) return false
|
||||
if (hasClockFilter === 'no' && device.device_attributes?.hasClock) return false
|
||||
if (hasBellsFilter === 'yes' && !device.device_attributes?.hasBells) return false
|
||||
if (hasBellsFilter === 'no' && device.device_attributes?.hasBells) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const sortedDevices = sortDevices(filteredDevices, sortKey, sortDir)
|
||||
const total = sortedDevices.length
|
||||
const pagedDevices = sortedDevices.slice((page - 1) * pageSize, page * pageSize)
|
||||
|
||||
// ── Active filter count (for badge on Filters button) ─────────────────────
|
||||
const activeFilterCount = [onlineFilter, tierFilter, subscrStatusFilter, warrantyStatusFilter, hasClockFilter, hasBellsFilter].filter(Boolean).length
|
||||
|
||||
// ── Middle-click handler ───────────────────────────────────────────────────
|
||||
const handleRowMiddleClick = (device, e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault()
|
||||
window.open(`/devices/${device.id}`, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build DataTable column definitions ────────────────────────────────────
|
||||
// Respect the saved column order
|
||||
const orderedVisible = colPrefs.order.filter((k) => colPrefs.visible.includes(k))
|
||||
|
||||
const tableColumns = orderedVisible
|
||||
.map((key) => {
|
||||
const c = ALL_COLUMNS.find((col) => col.key === key)
|
||||
if (!c) return null
|
||||
return {
|
||||
key: c.key,
|
||||
label: c.label,
|
||||
width: c.width,
|
||||
sortable: c.sortable,
|
||||
render: (device) => {
|
||||
const attr = device.device_attributes || {}
|
||||
const clock = attr.clockSettings || {}
|
||||
const sub = device.device_subscription || {}
|
||||
const stats = device.device_stats || {}
|
||||
const sn = device.serial_number || device.device_id
|
||||
const mqtt = mqttStatusMap[sn]
|
||||
const isOnline = mqtt ? mqtt.online : device.is_Online
|
||||
|
||||
switch (c.key) {
|
||||
case 'status':
|
||||
return <OnlineDot isOnline={isOnline} />
|
||||
case 'name':
|
||||
return (
|
||||
<span style={{ fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
|
||||
{device.device_name || 'Unnamed Device'}
|
||||
</span>
|
||||
)
|
||||
case 'serialNumber':
|
||||
return (
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>
|
||||
{sn || '—'}
|
||||
</span>
|
||||
)
|
||||
case 'location':
|
||||
return device.device_location || <Muted>—</Muted>
|
||||
case 'subscrTier':
|
||||
return <TierBadge tier={sub.subscrTier} />
|
||||
case 'totalBells':
|
||||
return attr.totalBells ?? 0
|
||||
case 'assignedUsers':
|
||||
return Array.isArray(device.user_list) ? device.user_list.length : 0
|
||||
case 'maxOutputs':
|
||||
return sub.maxOutputs ?? <Muted>—</Muted>
|
||||
case 'hasClock':
|
||||
return <BoolBadge value={attr.hasClock} />
|
||||
case 'hasBells':
|
||||
return <BoolBadge value={attr.hasBells} />
|
||||
case 'bellGuard':
|
||||
return <BoolBadge value={attr.bellGuardOn} />
|
||||
case 'warningsOn':
|
||||
return <BoolBadge value={attr.warningsOn} />
|
||||
case 'serialLogLevel':
|
||||
return attr.serialLogLevel ?? 0
|
||||
case 'sdLogLevel':
|
||||
return attr.sdLogLevel ?? 0
|
||||
case 'bellOutputs':
|
||||
return attr.bellOutputs?.length ? attr.bellOutputs.join(', ') : <Muted>—</Muted>
|
||||
case 'hammerTimings':
|
||||
return attr.hammerTimings?.length ? attr.hammerTimings.join(', ') : <Muted>—</Muted>
|
||||
case 'ringAlertsMaster':
|
||||
return <BoolBadge value={clock.ringAlertsMasterOn} yesLabel="ON" noLabel="OFF" />
|
||||
case 'totalPlaybacks':
|
||||
return stats.totalPlaybacks ?? 0
|
||||
case 'totalHammerStrikes':
|
||||
return stats.totalHammerStrikes ?? 0
|
||||
case 'totalWarnings':
|
||||
return stats.totalWarningsGiven ?? 0
|
||||
case 'warrantyActive':
|
||||
return <BoolBadge value={stats.warrantyActive} yesLabel="Active" noLabel="Expired" />
|
||||
case 'totalMelodies':
|
||||
return device.device_melodies_all?.length ?? 0
|
||||
case 'tags':
|
||||
return <TagList tags={device.tags} />
|
||||
case 'hw_family':
|
||||
return device.hw_family || <Muted>—</Muted>
|
||||
case 'hw_revision':
|
||||
return device.hw_revision || <Muted>—</Muted>
|
||||
default:
|
||||
return <Muted>—</Muted>
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
// Actions column (not part of ALL_COLUMNS — appended, never draggable)
|
||||
tableColumns.push({
|
||||
key: '__actions',
|
||||
label: '',
|
||||
width: '110px',
|
||||
align: 'right',
|
||||
render: (device) => (
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'View Details',
|
||||
icon: <Icon name="eye" size={14} />,
|
||||
onClick: () => navigate(`/devices/${device.id}`),
|
||||
},
|
||||
...(canEdit ? [
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Icon name="edit" size={14} />,
|
||||
color: 'var(--color-info)',
|
||||
onClick: () => navigate(`/devices/${device.id}/edit`),
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: <Icon name="delete" size={14} />,
|
||||
color: 'var(--color-danger)',
|
||||
divider: true,
|
||||
onClick: () => { setDeleteError(''); setDeleteTarget(device) },
|
||||
},
|
||||
] : []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
|
||||
{/* Page header */}
|
||||
<PageHeader
|
||||
title="Device Fleet"
|
||||
subtitle={!loading
|
||||
? `${filteredDevices.length} ${filteredDevices.length === 1 ? 'device' : 'devices'}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{canEdit && (
|
||||
<Button variant="primary" onClick={() => navigate('/devices/new')}>
|
||||
Add Device
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
{/* Toolbar + pagination + content — single tight-gap column so all three
|
||||
items are visually grouped and equidistant from each other */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)', minWidth: 0 }}>
|
||||
|
||||
{/* Filter toolbar */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-3)', alignItems: 'center', minWidth: 0 }}>
|
||||
|
||||
<div style={{ flex: '1 1 0', minWidth: '180px' }}>
|
||||
<SearchBar
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by name, location, or serial…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={activeFilterCount > 0 ? 'secondary' : 'ghost'}
|
||||
size="md"
|
||||
onClick={() => setFiltersOpen(true)}
|
||||
style={{ paddingTop: 'var(--space-3)', paddingBottom: 'var(--space-3)', lineHeight: 'var(--line-height-base)' }}
|
||||
icon={
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Filters{activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}
|
||||
</Button>
|
||||
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: 'table', label: 'Table', icon: <Icon name="more_horizontal" size={14} /> },
|
||||
{ value: 'card', label: 'Cards', icon: <Icon name="dashboard" size={14} /> },
|
||||
// Map option temporarily hidden — access via /devices/map-preview while under construction
|
||||
]}
|
||||
value={viewMode}
|
||||
onChange={(v) => {
|
||||
setViewMode(v)
|
||||
try { localStorage.setItem('deviceListViewMode', v) } catch { /* ignore */ }
|
||||
}}
|
||||
size="md"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-danger-bg)',
|
||||
border: '1px solid var(--color-danger-container)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
color: 'var(--color-danger)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{total > 0 && viewMode !== 'map' && (
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
onSizeChange={(s) => { setPageSize(s); setPage(1) }}
|
||||
pageSizes={[10, 20, 50, 100]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table, Card, or Map view */}
|
||||
{viewMode === 'map' ? (
|
||||
<DeviceListMapView
|
||||
devices={filteredDevices}
|
||||
loading={loading}
|
||||
mqttStatusMap={mqttStatusMap}
|
||||
onView={(device) => navigate(`/devices/${device.id}`)}
|
||||
/>
|
||||
) : viewMode === 'card' ? (
|
||||
<DeviceListCardView
|
||||
devices={pagedDevices}
|
||||
loading={loading}
|
||||
mqttStatusMap={mqttStatusMap}
|
||||
onView={(device) => navigate(`/devices/${device.id}`)}
|
||||
onMiddleClick={(device, e) => handleRowMiddleClick(device, e)}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
data={pagedDevices}
|
||||
loading={loading}
|
||||
keyField="id"
|
||||
emptyMessage="No devices found"
|
||||
emptyDescription="Try adjusting your search or filters."
|
||||
onRowClick={(device) => navigate(`/devices/${device.id}`)}
|
||||
onRowMiddleClick={(device, e) => handleRowMiddleClick(device, e)}
|
||||
skeletonRows={8}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={(key, dir) => { setSortKey(key); setSortDir(dir); setPage(1) }}
|
||||
allColumns={ALL_COLUMNS}
|
||||
visibleKeys={colPrefs.visible}
|
||||
onColumnChange={handleColumnChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Filters modal */}
|
||||
<DeviceFiltersModal
|
||||
open={filtersOpen}
|
||||
onClose={() => setFiltersOpen(false)}
|
||||
onlineFilter={onlineFilter} setOnlineFilter={setOnlineFilter}
|
||||
tierFilter={tierFilter} setTierFilter={setTierFilter}
|
||||
subscrStatusFilter={subscrStatusFilter} setSubscrStatusFilter={setSubscrStatusFilter}
|
||||
warrantyStatusFilter={warrantyStatusFilter} setWarrantyStatusFilter={setWarrantyStatusFilter}
|
||||
hasClockFilter={hasClockFilter} setHasClockFilter={setHasClockFilter}
|
||||
hasBellsFilter={hasBellsFilter} setHasBellsFilter={setHasBellsFilter}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<DeleteDeviceModal
|
||||
device={deleteTarget}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => { setDeleteTarget(null); setDeleteError('') }}
|
||||
error={deleteError}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
// frontend/src/pages/bellcloud/devices/DeviceListCardView.jsx
|
||||
//
|
||||
// Card-based view of the Device Fleet list.
|
||||
// Glassmorphism + gradient glow border (online=green, offline=grey)
|
||||
// + real OSM tile map pulled from device_location_coordinates.
|
||||
// All colours via design tokens only.
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
|
||||
// ─── Tier config ──────────────────────────────────────────────────────────────
|
||||
|
||||
const TIER_META = {
|
||||
vip: { label: 'VIP' },
|
||||
premium: { label: 'Premium' },
|
||||
custom: { label: 'Custom' },
|
||||
small: { label: 'Small' },
|
||||
mini: { label: 'Mini' },
|
||||
basic: { label: 'Basic' },
|
||||
}
|
||||
|
||||
function getTierLabel(tier) {
|
||||
return TIER_META[(tier || 'basic').toLowerCase()]?.label ?? 'Basic'
|
||||
}
|
||||
|
||||
// ─── OSM tile helpers ─────────────────────────────────────────────────────────
|
||||
// Convert lat/lng to OSM tile x/y at a given zoom level.
|
||||
|
||||
// Returns the exact tile coords AND the sub-tile pixel offset (0–255) of the coordinate.
|
||||
function latLngToTile(lat, lng, zoom) {
|
||||
const n = Math.pow(2, zoom)
|
||||
const xFrac = ((lng + 180) / 360) * n
|
||||
const latRad = (lat * Math.PI) / 180
|
||||
const yFrac = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n
|
||||
const x = Math.floor(xFrac)
|
||||
const y = Math.floor(yFrac)
|
||||
// px/py: how far into the focal tile the coordinate sits, in pixels (0–256)
|
||||
const px = (xFrac - x) * 256
|
||||
const py = (yFrac - y) * 256
|
||||
return { x, y, px, py }
|
||||
}
|
||||
|
||||
// Build a 4-wide × 3-tall grid of Stamen Toner tiles centred on the coordinate.
|
||||
// Using 4 columns (dx = -2..+1) guarantees the grid is 1024px wide, which always
|
||||
// exceeds the card width (~500px) even when the marker sits near the right edge
|
||||
// of the focal tile (markerOffset.x up to 512px, anchor = 80% - markerOffset.x).
|
||||
// markerOffset is the pixel position of the coordinate within the 1024×768 grid,
|
||||
// measured from the grid's top-left. The grid is anchored via inline style so
|
||||
// markerOffset lands at exactly 80%/60% of the card.
|
||||
function buildTileGrid(lat, lng, zoom = 13) {
|
||||
const { x, y, px, py } = latLngToTile(lat, lng, zoom)
|
||||
const tiles = []
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -2; dx <= 1; dx++) {
|
||||
const tx = x + dx
|
||||
const ty = y + dy
|
||||
tiles.push({
|
||||
url: `https://tiles.stadiamaps.com/tiles/stamen_toner/${zoom}/${tx}/${ty}.png`,
|
||||
col: dx + 2 + 1, // cols 1–4
|
||||
row: dy + 1 + 1, // rows 1–3
|
||||
})
|
||||
}
|
||||
}
|
||||
// The focal tile is at grid column index 2 (dx=0), so its left edge is at
|
||||
// 2 × 256 = 512px from the grid's left. markerOffset.x = 512 + px.
|
||||
return { tiles, markerOffset: { x: 512 + px, y: 256 + py } }
|
||||
}
|
||||
|
||||
// Normalise GeoPoint from Firestore — can arrive as {lat,lng}, {latitude,longitude},
|
||||
// or a Firestore GeoPoint object with .latitude / .longitude getters.
|
||||
function parseCoords(raw) {
|
||||
if (!raw) return null
|
||||
const lat = raw.latitude ?? raw.lat ?? null
|
||||
const lng = raw.longitude ?? raw.lng ?? null
|
||||
if (lat == null || lng == null) return null
|
||||
return { lat: Number(lat), lng: Number(lng) }
|
||||
}
|
||||
|
||||
// ─── Inline SVGs ─────────────────────────────────────────────────────────────
|
||||
|
||||
function BellIcon({ size = 13, color = 'currentColor' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||||
stroke={color} strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 01-3.46 0" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ClockIcon({ size = 13, color = 'currentColor' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||||
stroke={color} strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTriangleIcon({ size = 13, color = 'currentColor' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||||
stroke={color} strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ─── Real OSM tile map background ────────────────────────────────────────────
|
||||
// Renders a 3×3 grid of OSM tiles centred on device_location_coordinates,
|
||||
// tinted dark to match the card theme, with a left-fade gradient overlay.
|
||||
|
||||
function MapBackground({ coords }) {
|
||||
const ZOOM = 13
|
||||
|
||||
const { tiles, markerOffset } = useMemo(() => {
|
||||
if (!coords) return { tiles: null, markerOffset: null }
|
||||
return buildTileGrid(coords.lat, coords.lng, ZOOM)
|
||||
}, [coords])
|
||||
|
||||
return (
|
||||
<div className="dcard__map-bg" aria-hidden="true">
|
||||
{tiles ? (
|
||||
// 4×3 tile grid. Anchored so markerOffset lands at exactly 80%/60% of
|
||||
// the card. The grid is 1024px wide so it always covers the full card.
|
||||
<div
|
||||
className="dcard__map-tiles"
|
||||
style={{
|
||||
top: `calc(60% - ${markerOffset.y}px)`,
|
||||
left: `calc(80% - ${markerOffset.x}px)`,
|
||||
}}
|
||||
>
|
||||
{tiles.map((t, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={t.url}
|
||||
alt=""
|
||||
className="dcard__map-tile"
|
||||
style={{ gridColumn: t.col, gridRow: t.row }}
|
||||
draggable={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="dcard__map-fallback" />
|
||||
)}
|
||||
|
||||
{/* Marker pinned at exactly 80%/60% of the card */}
|
||||
{tiles && markerOffset && (
|
||||
<div aria-hidden="true" style={{
|
||||
position: 'absolute',
|
||||
top: '60%',
|
||||
left: '80%',
|
||||
width: 0,
|
||||
height: 0,
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(250,204,21,1)',
|
||||
boxShadow: '0 0 0 2px rgba(8,12,18,0.95), 0 0 12px rgba(250,204,21,0.8)',
|
||||
top: -4.5,
|
||||
left: -4.5,
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
border: '1.5px solid rgba(250,204,21,0.6)',
|
||||
top: -12,
|
||||
left: -12,
|
||||
animation: 'dcard-marker-pulse 2.2s ease-out infinite',
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dcard__map-tint" />
|
||||
<div className="dcard__map-fade" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Online status block (rectangular, spans both identity rows) ──────────────
|
||||
|
||||
function OnlineBlock({ online }) {
|
||||
return (
|
||||
<div
|
||||
className={`dcard__online-block ${online ? 'dcard__online-block--on' : 'dcard__online-block--off'}`}
|
||||
aria-label={online ? 'Online' : 'Offline'}
|
||||
>
|
||||
<span className="dcard__online-block-dot" aria-hidden="true" />
|
||||
<span className="dcard__online-block-label">{online ? 'Online' : 'Offline'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Inline pill (used for tier, warranty, clock, bells) ─────────────────────
|
||||
|
||||
function InlinePill({ icon, label, variant = 'default', title }) {
|
||||
return (
|
||||
<div className={`dcard__pill dcard__pill--${variant}`} title={title || label}>
|
||||
{icon && <span className="dcard__pill-icon" aria-hidden="true">{icon}</span>}
|
||||
<span className="dcard__pill-label">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Warning count badge ──────────────────────────────────────────────────────
|
||||
|
||||
function WarningsBadge({ count }) {
|
||||
if (!count) return null
|
||||
return (
|
||||
<div className="dcard__warn-badge" title={`${count} warning${count > 1 ? 's' : ''} given`}>
|
||||
<AlertTriangleIcon size={11} />
|
||||
<span>{count}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ─── Single Device Card ───────────────────────────────────────────────────────
|
||||
|
||||
function DeviceCard({ device, mqttStatusMap, onView, onMiddleClick }) {
|
||||
const attr = device.device_attributes || {}
|
||||
const sub = device.device_subscription || {}
|
||||
const stats = device.device_stats || {}
|
||||
const sn = device.serial_number || device.device_id || ''
|
||||
const mqtt = mqttStatusMap[sn]
|
||||
const isOnline = mqtt ? mqtt.online : device.is_Online
|
||||
|
||||
const name = device.device_name || 'Unnamed Device'
|
||||
const location = device.device_location
|
||||
const coords = useMemo(() => parseCoords(device.device_location_coordinates), [device.device_location_coordinates])
|
||||
const tierLabel = getTierLabel(sub.subscrTier)
|
||||
const hasClock = !!attr.hasClock
|
||||
const hasBells = !!attr.hasBells
|
||||
const totalWarnings = stats.totalWarningsGiven ?? 0
|
||||
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`dcard ${isOnline ? 'dcard--online' : 'dcard--offline'}`}
|
||||
onClick={onView}
|
||||
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); onMiddleClick?.(e) } }}
|
||||
>
|
||||
{/* ── Tier badge — absolute top-right corner ── */}
|
||||
<div className="dcard__tier-badge">{tierLabel}</div>
|
||||
|
||||
{/* ── Card content (above map) ── */}
|
||||
<div className="dcard__content">
|
||||
{/* ── Real OSM map background — fills card, pinned top-left ── */}
|
||||
<MapBackground coords={coords} />
|
||||
|
||||
{/* ── Header: status block + identity ── */}
|
||||
<header className="dcard__header">
|
||||
<OnlineBlock online={isOnline} />
|
||||
|
||||
<div className="dcard__identity">
|
||||
<div className="dcard__name-row">
|
||||
<span className="dcard__name">{name}</span>
|
||||
</div>
|
||||
<div className="dcard__serial-row">
|
||||
<span className="dcard__serial-hash">#</span>
|
||||
<span className="dcard__serial">{sn || '—'}</span>
|
||||
{totalWarnings > 0 && <WarningsBadge count={totalWarnings} />}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Feature pills: clock + bells — absolute bottom-left ── */}
|
||||
<div className="dcard__pills">
|
||||
{hasClock && (
|
||||
<InlinePill icon={<ClockIcon size={11} />} label="Clock" variant="feature" title="Has Clock" />
|
||||
)}
|
||||
{hasBells && (
|
||||
<InlinePill icon={<BellIcon size={11} />} label="Bells" variant="feature" title="Has Bells" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Location label — under marker at 80%/60%, or bottom-right corner ── */}
|
||||
{location && (
|
||||
<div
|
||||
className="dcard__location"
|
||||
style={coords
|
||||
? { top: 'calc(60% + 16px)', left: '80%', transform: 'translateX(-50%)' }
|
||||
: { bottom: 'var(--space-4)', right: 'var(--space-4)' }
|
||||
}
|
||||
aria-label={`Location: ${location}`}
|
||||
>
|
||||
<span className="dcard__location-label">{location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Skeleton card ────────────────────────────────────────────────────────────
|
||||
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div className="dcard dcard--offline dcard--skeleton" aria-hidden="true">
|
||||
<div className="dcard__content">
|
||||
<div className="dcard__header">
|
||||
<div className="dcard-skel" style={{ width: 56, alignSelf: 'stretch', borderRadius: 8, flexShrink: 0 }} />
|
||||
<div className="dcard__identity">
|
||||
<div className="dcard__name-row">
|
||||
<div className="dcard-skel" style={{ flex: 1, height: 22 }} />
|
||||
</div>
|
||||
<div className="dcard__serial-row">
|
||||
<div className="dcard-skel" style={{ width: '65%', height: 14 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dcard__pills">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="dcard-skel" style={{ width: 64, height: 26, borderRadius: 999 }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── DeviceListCardView (exported) ────────────────────────────────────────────
|
||||
|
||||
export default function DeviceListCardView({
|
||||
devices,
|
||||
loading,
|
||||
mqttStatusMap,
|
||||
onView,
|
||||
onMiddleClick,
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<style>{CARD_STYLES}</style>
|
||||
<div className="dcard-grid">
|
||||
{Array.from({ length: 9 }).map((_, i) => <SkeletonCard key={i} />)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!devices.length) {
|
||||
return (
|
||||
<>
|
||||
<style>{CARD_STYLES}</style>
|
||||
<div className="dcard-empty">
|
||||
<div className="dcard-empty__icon" aria-hidden="true">
|
||||
<Icon name="devices" size={36} color="var(--color-text-muted)" />
|
||||
</div>
|
||||
<p className="dcard-empty__title">No devices found</p>
|
||||
<p className="dcard-empty__sub">Try adjusting your search or filters.</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{CARD_STYLES}</style>
|
||||
<div className="dcard-grid">
|
||||
{devices.map((device) => (
|
||||
<DeviceCard
|
||||
key={device.id}
|
||||
device={device}
|
||||
mqttStatusMap={mqttStatusMap}
|
||||
onView={() => onView(device)}
|
||||
onMiddleClick={onMiddleClick ? (e) => onMiddleClick(device, e) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const CARD_STYLES = `
|
||||
|
||||
/* ── Grid ─────────────────────────────────────────────────────────────────── */
|
||||
.dcard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(500px, 1fr));
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ── Card wrapper — gradient border that changes with online state ────────── */
|
||||
.dcard {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 16px;
|
||||
padding: 1px; /* the 1px holds the gradient border colour */
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
transition: transform 220ms ease, box-shadow 220ms ease, background-image 220ms ease;
|
||||
/* min-height ensures border shows on all 4 sides even with short content */
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* Online — green glow border */
|
||||
.dcard--online {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
rgba(74,222,128,0.55) 0%,
|
||||
rgba(255,255,255,0.08) 40%,
|
||||
rgba(255,255,255,0.05) 65%,
|
||||
rgba(74,222,128,0.70) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0,0,0,0.45),
|
||||
0 0 28px rgba(74,222,128,0.10);
|
||||
}
|
||||
.dcard--online:hover {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
rgba(74,222,128,0.80) 0%,
|
||||
rgba(255,255,255,0.10) 40%,
|
||||
rgba(255,255,255,0.07) 65%,
|
||||
rgba(74,222,128,0.95) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 12px 40px rgba(0,0,0,0.55),
|
||||
0 0 48px rgba(74,222,128,0.18);
|
||||
}
|
||||
|
||||
/* Offline — neutral grey border */
|
||||
.dcard--offline {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
rgba(120,130,145,0.35) 0%,
|
||||
rgba(255,255,255,0.05) 40%,
|
||||
rgba(255,255,255,0.03) 65%,
|
||||
rgba(90,100,115,0.45) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0,0,0,0.45),
|
||||
0 0 20px rgba(100,110,125,0.06);
|
||||
}
|
||||
.dcard--offline:hover {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
rgba(140,155,175,0.50) 0%,
|
||||
rgba(255,255,255,0.08) 40%,
|
||||
rgba(255,255,255,0.05) 65%,
|
||||
rgba(110,125,145,0.60) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 12px 40px rgba(0,0,0,0.55),
|
||||
0 0 32px rgba(120,135,155,0.10);
|
||||
}
|
||||
|
||||
.dcard:active { transform: translateY(0) !important; }
|
||||
.dcard:hover { transform: translateY(-2px); }
|
||||
|
||||
/* ── Card inner surface ──────────────────────────────────────────────────── */
|
||||
.dcard__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
background: rgba(10, 15, 22, 0.92);
|
||||
border-radius: 15px;
|
||||
padding: var(--space-5) var(--space-5) var(--space-4);
|
||||
overflow: hidden;
|
||||
/* flex: 1 makes content fill the full card height so all 4 border edges show */
|
||||
flex: 1;
|
||||
min-height: 210px;
|
||||
}
|
||||
|
||||
/* ── Real OSM tile map background — full card width, fade handles transition ── */
|
||||
.dcard__map-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 15px;
|
||||
}
|
||||
/* 4×3 tile grid — anchored via inline style so the coordinate lands at 80%/60%.
|
||||
1024px wide guarantees full card coverage regardless of sub-tile offset. */
|
||||
.dcard__map-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 256px);
|
||||
grid-template-rows: repeat(3, 256px);
|
||||
position: absolute;
|
||||
width: 1024px;
|
||||
height: 768px;
|
||||
}
|
||||
.dcard__map-tile {
|
||||
width: 256px;
|
||||
height: 256px;
|
||||
display: block;
|
||||
/* Stamen Toner is already B&W — invert so roads are dark lines on dark bg,
|
||||
then reduce brightness so it sits quietly behind the card content */
|
||||
filter: invert(1) brightness(0.30);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
/* Fallback grid pattern when no coordinates */
|
||||
.dcard__map-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(99,179,237,0.07) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(99,179,237,0.07) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
/* Tint — very light, map is meant to be visible */
|
||||
.dcard__map-tint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(10, 15, 22, 0.05);
|
||||
}
|
||||
/* Fade: solid left edge, starts dissolving after 15% */
|
||||
.dcard__map-fade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(10, 15, 22, 1) 0%,
|
||||
rgba(10, 15, 22, 1) 15%,
|
||||
rgba(10, 15, 22, 0.60) 40%,
|
||||
rgba(10, 15, 22, 0.15) 65%,
|
||||
rgba(10, 15, 22, 0) 80%
|
||||
);
|
||||
}
|
||||
@keyframes dcard-marker-pulse {
|
||||
0% { transform: scale(0.5); opacity: 1; }
|
||||
70% { transform: scale(1.6); opacity: 0; }
|
||||
100% { transform: scale(1.6); opacity: 0; }
|
||||
}
|
||||
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────────────── */
|
||||
.dcard__header {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: var(--space-3);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.dcard__identity {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
justify-content: center;
|
||||
}
|
||||
.dcard__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.dcard__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-family-display);
|
||||
font-size: var(--font-size-xl, 1.25rem);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Serial row ──────────────────────────────────────────────────────────── */
|
||||
.dcard__serial-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.dcard__serial-hash {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dcard__serial {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: rgba(99, 179, 237, 0.80);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── Online status block ─────────────────────────────────────────────────── */
|
||||
.dcard__online-block {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 58px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
padding: var(--space-2) var(--space-1);
|
||||
transition: background 200ms ease;
|
||||
}
|
||||
.dcard__online-block--on {
|
||||
background: rgba(74, 222, 128, 0.09);
|
||||
border-color: rgba(74, 222, 128, 0.28);
|
||||
}
|
||||
.dcard__online-block--off {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
.dcard__online-block-dot {
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dcard__online-block--on .dcard__online-block-dot {
|
||||
background: #4ade80;
|
||||
box-shadow: 0 0 8px rgba(74,222,128,0.75);
|
||||
animation: dcard-pulse 2s ease-out infinite;
|
||||
}
|
||||
.dcard__online-block--off .dcard__online-block-dot {
|
||||
background: rgba(160,170,185,0.40);
|
||||
}
|
||||
.dcard__online-block-label {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dcard__online-block--on .dcard__online-block-label { color: #4ade80; }
|
||||
.dcard__online-block--off .dcard__online-block-label { color: rgba(160,170,185,0.55); }
|
||||
|
||||
@keyframes dcard-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.65); }
|
||||
70% { box-shadow: 0 0 0 8px rgba(74, 222, 128, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0); }
|
||||
}
|
||||
|
||||
/* ── Warning badge ───────────────────────────────────────────────────────── */
|
||||
.dcard__warn-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px var(--space-2);
|
||||
background: var(--color-warning-bg);
|
||||
border: 1px solid rgba(251, 191, 36, 0.20);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-warning);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Tier badge — absolute top-right of card ─────────────────────────────── */
|
||||
/* Uses var(--space-4) = 16px to match the bottom-left pills and bottom-right
|
||||
location corner — all three shared the same corner inset. */
|
||||
.dcard__tier-badge {
|
||||
position: absolute;
|
||||
top: var(--space-4);
|
||||
right: var(--space-4);
|
||||
z-index: 10;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(255,255,255,0.07);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
color: rgba(200,210,220,0.85);
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(4px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Pills row — absolute bottom-left, same inset as tier badge and location corner ── */
|
||||
.dcard__pills {
|
||||
position: absolute;
|
||||
bottom: var(--space-4);
|
||||
left: var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Base pill style */
|
||||
.dcard__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 11px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
transition: border-color 150ms ease, background 150ms ease;
|
||||
}
|
||||
.dcard__pill-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dcard__pill-label { line-height: 1; }
|
||||
|
||||
/* Tier pill — subtle neutral */
|
||||
.dcard__pill--tier {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
color: rgba(200,210,220,0.80);
|
||||
}
|
||||
|
||||
/* Feature pills (clock, bells) — cyan/blue */
|
||||
.dcard__pill--feature {
|
||||
background: rgba(99,179,237,0.07);
|
||||
border-color: rgba(99,179,237,0.28);
|
||||
color: rgba(99,179,237,0.90);
|
||||
}
|
||||
.dcard__pill--feature:hover {
|
||||
background: rgba(99,179,237,0.13);
|
||||
border-color: rgba(99,179,237,0.50);
|
||||
}
|
||||
|
||||
/* Warranty active — soft green */
|
||||
.dcard__pill--warranty-on {
|
||||
background: rgba(74,222,128,0.07);
|
||||
border-color: rgba(74,222,128,0.28);
|
||||
color: rgba(74,222,128,0.90);
|
||||
}
|
||||
|
||||
/* No warranty — muted red/amber */
|
||||
.dcard__pill--warranty-off {
|
||||
background: rgba(251,113,133,0.06);
|
||||
border-color: rgba(251,113,133,0.22);
|
||||
color: rgba(251,113,133,0.80);
|
||||
}
|
||||
|
||||
/* ── Location label ───────────────────────────────────────────────────────── */
|
||||
/* Position set via inline style in JSX — follows the marker when coords exist,
|
||||
falls back to bottom-right corner otherwise. */
|
||||
.dcard__location {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
max-width: 170px;
|
||||
}
|
||||
|
||||
.dcard__location-label {
|
||||
display: inline-block;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(8, 12, 18, 0.72);
|
||||
border: 1px solid rgba(250, 204, 21, 0.22);
|
||||
backdrop-filter: blur(4px);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(250, 204, 21, 0.88);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Empty state ──────────────────────────────────────────────────────────── */
|
||||
.dcard-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-16) var(--space-8);
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
.dcard-empty__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-bg-elevated);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.dcard-empty__title {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
.dcard-empty__sub {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Skeleton shimmer ─────────────────────────────────────────────────────── */
|
||||
.dcard--skeleton {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dcard-skel {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255,255,255,0.04) 0%,
|
||||
rgba(255,255,255,0.09) 50%,
|
||||
rgba(255,255,255,0.04) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: dcard-shimmer 1.4s ease-in-out infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
@keyframes dcard-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* ── Responsive ───────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.dcard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
// frontend/src/pages/bellcloud/devices/DeviceMapPage.jsx
|
||||
//
|
||||
// Standalone route for the map view while it's under construction.
|
||||
// Access via /devices/map-preview — not linked from the main UI.
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '@/lib/api'
|
||||
import DeviceListMapView from './DeviceListMapView'
|
||||
|
||||
export default function DeviceMapPage() {
|
||||
const navigate = useNavigate()
|
||||
const [devices, setDevices] = useState([])
|
||||
const [mqttStatusMap, setMqttStatusMap] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/devices').then((data) => {
|
||||
setDevices(data.devices ?? [])
|
||||
api.get('/mqtt/status').then((mqttData) => {
|
||||
if (mqttData?.devices) {
|
||||
const map = {}
|
||||
for (const s of mqttData.devices) map[s.device_serial] = s
|
||||
setMqttStatusMap(map)
|
||||
}
|
||||
setLoading(false)
|
||||
}).catch(() => setLoading(false))
|
||||
}).catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DeviceListMapView
|
||||
devices={devices}
|
||||
loading={loading}
|
||||
mqttStatusMap={mqttStatusMap}
|
||||
onView={(device) => navigate(`/devices/${device.id}`)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
// frontend/src/pages/bellcloud/devices/tabs/BellsTab.jsx
|
||||
// Bell Mechanisms tab — displays Bell Commander/Guard status, connected bell count,
|
||||
// master switch, and per-bell glass cards with output, timing, size, and strike stats.
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import api from '@/lib/api'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import Button from '@/components/ui/Button'
|
||||
import StatusBadge from '@/components/ui/StatusBadge'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||||
|
||||
import certificationImg from '@/assets/striker-mechanisms/certification.png'
|
||||
import strikerSize1 from '@/assets/striker-mechanisms/size-1.jpg'
|
||||
import strikerSize2 from '@/assets/striker-mechanisms/size-2.jpg'
|
||||
import strikerSize3 from '@/assets/striker-mechanisms/size-3.jpg'
|
||||
import strikerSize4 from '@/assets/striker-mechanisms/size-4.jpg'
|
||||
import strikerSize5 from '@/assets/striker-mechanisms/size-5.jpg'
|
||||
import strikerSize6 from '@/assets/striker-mechanisms/size-6.jpg'
|
||||
|
||||
const STRIKER_IMAGES = { 1: strikerSize1, 2: strikerSize2, 3: strikerSize3, 4: strikerSize4, 5: strikerSize5, 6: strikerSize6 }
|
||||
|
||||
// ─── Timing → Size mapping (from archive) ────────────────────────────────────
|
||||
|
||||
function getStrikerSize(ms) {
|
||||
if (!Number.isFinite(ms)) return null
|
||||
if (ms <= 89) return 1
|
||||
if (ms <= 94) return 2
|
||||
if (ms <= 99) return 3
|
||||
if (ms <= 109) return 4
|
||||
if (ms <= 119) return 5
|
||||
return 6
|
||||
}
|
||||
|
||||
const ORDINAL_NAMES = [
|
||||
'First', 'Second', 'Third', 'Fourth', 'Fifth',
|
||||
'Sixth', 'Seventh', 'Eighth', 'Ninth', 'Tenth',
|
||||
]
|
||||
|
||||
// ─── Indicator pill ───────────────────────────────────────────────────────────
|
||||
|
||||
function Indicator({ label, value, yesLabel = 'Operational', noLabel = 'Disabled' }) {
|
||||
const isUnknown = typeof value !== 'boolean'
|
||||
const variant = isUnknown ? 'neutral' : value ? 'success' : 'danger'
|
||||
const text = isUnknown ? 'Unknown' : value ? yesLabel : noLabel
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)', alignItems: 'flex-start' }}>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: 'var(--color-text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
<StatusBadge variant={variant}>{text}</StatusBadge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Bell card (glass) ────────────────────────────────────────────────────────
|
||||
|
||||
const BELL_GLASS_CARD = {
|
||||
background: 'rgba(28, 32, 38, 0.30)',
|
||||
backdropFilter: 'var(--blur-modal)',
|
||||
WebkitBackdropFilter: 'var(--blur-modal)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
boxShadow: 'var(--shadow-card), var(--shadow-md)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
breakInside: 'avoid',
|
||||
}
|
||||
|
||||
const SPEC_TILE = {
|
||||
background: 'rgba(28, 32, 38, 0.40)',
|
||||
backdropFilter: 'var(--blur-modal)',
|
||||
WebkitBackdropFilter: 'var(--blur-modal)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: 'var(--space-3)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 'var(--space-1)',
|
||||
textAlign: 'center',
|
||||
flex: 1,
|
||||
}
|
||||
|
||||
function BellCard({ index, output, hammingMs, strikeCount, requestingCounters, isBellSystems }) {
|
||||
const ordinal = ORDINAL_NAMES[index] ?? `${index + 1}th`
|
||||
const strikerSize = getStrikerSize(hammingMs)
|
||||
const isDisabled = Number.isFinite(output) && Number(output) === 0
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const strikerImg = strikerSize ? STRIKER_IMAGES[strikerSize] : null
|
||||
|
||||
return (
|
||||
<article
|
||||
style={{
|
||||
...BELL_GLASS_CARD,
|
||||
boxShadow: hovered
|
||||
? 'var(--shadow-card), var(--shadow-lg), 0 0 0 1px rgba(192, 193, 255, 0.35), 0 0 32px rgba(192, 193, 255, 0.22)'
|
||||
: 'var(--shadow-card), var(--shadow-md)',
|
||||
transition: 'box-shadow 0.25s ease',
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
|
||||
{/* Hero image — fades into card body */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
height: '220px',
|
||||
background: 'rgba(10, 14, 20, 0.80)',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
borderRadius: 'var(--radius-xl) var(--radius-xl) 0 0',
|
||||
}}>
|
||||
{strikerImg ? (
|
||||
<img
|
||||
src={strikerImg}
|
||||
alt={`Size ${strikerSize} striker mechanism`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center bottom',
|
||||
opacity: 1,
|
||||
transform: hovered ? 'scale(1.15)' : 'scale(1.05)',
|
||||
transition: 'transform 0.4s ease',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<Icon name="bell" size={48} color="var(--color-text-muted)" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom fade — bleeds over the name below */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(to top, rgba(28, 32, 38, 1) 0%, rgba(28, 32, 38, 0.55) 40%, transparent 70%)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
{/* Name overlaid at the bottom of the image */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 'var(--space-4)',
|
||||
left: 'var(--space-4)',
|
||||
right: 'var(--space-4)',
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontFamily: 'var(--font-family-display)',
|
||||
letterSpacing: 'var(--tracking-tight)',
|
||||
color: 'var(--color-text-primary)',
|
||||
lineHeight: 1.2,
|
||||
margin: 0,
|
||||
}}>
|
||||
<span style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-medium)' }}>
|
||||
{strikerSize ? 'Size ' : (isBellSystems ? 'Striker' : `${ordinal} Bell`)}
|
||||
</span>
|
||||
{strikerSize && (
|
||||
<span style={{ fontSize: 'var(--font-size-xl)', fontWeight: 'var(--font-weight-bold)' }}>
|
||||
{strikerSize}
|
||||
</span>
|
||||
)}
|
||||
{strikerSize && (
|
||||
<span style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-medium)' }}>
|
||||
{' Striker'}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: 'var(--color-text-muted)',
|
||||
marginTop: '2px',
|
||||
}}>
|
||||
{ordinal} Bell
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card body */}
|
||||
<div style={{ padding: 'var(--space-4)', display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', flex: 1 }}>
|
||||
|
||||
{/* Spec tiles: Output · Timing · Size */}
|
||||
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
|
||||
<div style={SPEC_TILE}>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
|
||||
Output
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-bold)', color: isDisabled ? 'var(--color-danger)' : 'var(--color-text-secondary)' }}>
|
||||
{isDisabled ? 'Disabled' : Number.isFinite(output) ? `Out ${output}` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={SPEC_TILE}>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
|
||||
Timing
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-bold)', color: 'var(--color-text-secondary)', fontFamily: 'var(--font-family-mono)' }}>
|
||||
{Number.isFinite(hammingMs) ? `${hammingMs} ms` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={SPEC_TILE}>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
|
||||
Size
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-bold)', color: 'var(--color-text-secondary)' }}>
|
||||
{strikerSize ? `Size ${strikerSize}` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Strike count footer */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: 'var(--color-text-more-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
opacity: 0.7,
|
||||
}}>
|
||||
Lifetime Strikes
|
||||
</span>
|
||||
{requestingCounters ? (
|
||||
<span style={{ fontSize: 'var(--font-size-base)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>…</span>
|
||||
) : Number.isFinite(strikeCount) ? (
|
||||
<span style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)', fontFamily: 'var(--font-family-mono)' }}>
|
||||
{strikeCount.toLocaleString()}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
|
||||
No data available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isBellSystems && (
|
||||
<img
|
||||
src={certificationImg}
|
||||
alt="BellSystems Certified"
|
||||
style={{ width: 36, height: 36, objectFit: 'contain', opacity: 0.90 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── BellsTab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
export default function BellsTab({
|
||||
device,
|
||||
attr,
|
||||
canEdit,
|
||||
loadDevice,
|
||||
sendMqttCommand,
|
||||
onEditBellOutputs,
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
|
||||
const [togglingCommander, setTogglingCommander] = useState(false)
|
||||
const [requestingCounters, setRequestingCounters] = useState(false)
|
||||
const [liveCounters] = useState(null)
|
||||
const [confirmDisable, setConfirmDisable] = useState(false)
|
||||
|
||||
// Derived data
|
||||
const bellOutputs = attr?.bellOutputs || []
|
||||
const hammerTimings = attr?.hammerTimings || []
|
||||
const totalBells = attr?.totalBells || 0
|
||||
const hasBells = attr?.hasBells ?? false
|
||||
const bellGuardOn = attr?.bellGuardOn ?? false
|
||||
|
||||
// Request strike counters via MQTT
|
||||
const requestStrikeCounters = useCallback(async () => {
|
||||
if (!sendMqttCommand) return
|
||||
setRequestingCounters(true)
|
||||
try {
|
||||
await sendMqttCommand('system_info', { action: 'report_status' })
|
||||
} finally {
|
||||
// Will be cleared when live data arrives, or after timeout
|
||||
setTimeout(() => setRequestingCounters(false), 8000)
|
||||
}
|
||||
}, [sendMqttCommand])
|
||||
|
||||
// Toggle Bell Commander (master switch)
|
||||
const handleToggleCommander = async (newVal) => {
|
||||
setTogglingCommander(true)
|
||||
try {
|
||||
await api.put(`/devices/${device.id || device.device_id}`, {
|
||||
device_attributes: { hasBells: newVal },
|
||||
})
|
||||
await loadDevice()
|
||||
toast.success(
|
||||
newVal ? 'Bell Commander Enabled' : 'Bell Commander Disabled',
|
||||
newVal ? 'All bell mechanisms are now active.' : 'All bell mechanisms have been disabled.'
|
||||
)
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to update Bell Commander.')
|
||||
} finally {
|
||||
setTogglingCommander(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCommanderToggleRequest = () => {
|
||||
if (hasBells) {
|
||||
// Disabling — confirm first
|
||||
setConfirmDisable(true)
|
||||
} else {
|
||||
handleToggleCommander(true)
|
||||
}
|
||||
}
|
||||
|
||||
const hasData = bellOutputs.length > 0
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
|
||||
|
||||
{/* ── Status overview bar ───────────────────────────────────────────── */}
|
||||
<div style={{
|
||||
background: 'rgba(28, 32, 38, 0.30)',
|
||||
backdropFilter: 'var(--blur-modal)',
|
||||
WebkitBackdropFilter: 'var(--blur-modal)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
padding: 'var(--space-5) var(--space-6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 'var(--space-6)',
|
||||
}}>
|
||||
{/* Indicators */}
|
||||
<div style={{ display: 'flex', gap: 'var(--space-12)', flex: 1, flexWrap: 'wrap' }}>
|
||||
<Indicator label="Bell Commander" value={hasBells} yesLabel="Operational" noLabel="Disabled" />
|
||||
<Indicator label="Bell Guard" value={bellGuardOn} yesLabel="Operational" noLabel="Disabled" />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)', alignItems: 'flex-start' }}>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: 'var(--color-text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
}}>
|
||||
Connected Bells
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-base)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}>
|
||||
{Number.isFinite(totalBells) ? (totalBells === 1 ? '1 Bell' : `${totalBells} Bells`) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexShrink: 0 }}>
|
||||
{/* Master toggle */}
|
||||
<Button
|
||||
variant={hasBells ? 'danger' : 'success'}
|
||||
size="md"
|
||||
loading={togglingCommander}
|
||||
onClick={handleCommanderToggleRequest}
|
||||
>
|
||||
{hasBells ? 'Disable Commander' : 'Enable Commander'}
|
||||
</Button>
|
||||
|
||||
{/* Edit Outputs */}
|
||||
<Button variant="primary" size="md" onClick={onEditBellOutputs}>
|
||||
Edit Outputs
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Bell cards ────────────────────────────────────────────────────── */}
|
||||
{!hasData ? (
|
||||
/* Empty state */
|
||||
<div style={{
|
||||
background: 'rgba(28, 32, 38, 0.30)',
|
||||
backdropFilter: 'var(--blur-modal)',
|
||||
WebkitBackdropFilter: 'var(--blur-modal)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
padding: 'var(--space-12)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 'var(--space-4)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 'var(--radius-full)',
|
||||
background: 'var(--color-bg-elevated)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<Icon name="bell" size={24} color="var(--color-text-muted)" />
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-secondary)' }}>
|
||||
No Bell Mechanisms Configured
|
||||
</p>
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', marginTop: 'var(--space-1)' }}>
|
||||
Use Edit Outputs to assign bells and configure their output channels.
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<Button variant="primary" size="sm" onClick={onEditBellOutputs}>
|
||||
Configure Bell Outputs
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Strike counter refresh */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 'var(--space-4)',
|
||||
}}>
|
||||
<h2 style={{
|
||||
fontFamily: 'var(--font-family-display)',
|
||||
fontSize: 'var(--font-size-xl)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
letterSpacing: 'var(--tracking-tight)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}>
|
||||
Bell Mechanisms
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={requestStrikeCounters}
|
||||
loading={requestingCounters}
|
||||
>
|
||||
Refresh Strike Counts
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Grid of bell cards */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))',
|
||||
gap: 'var(--space-5)',
|
||||
}}>
|
||||
{bellOutputs.map((output, i) => (
|
||||
<BellCard
|
||||
key={i}
|
||||
index={i}
|
||||
output={output}
|
||||
hammingMs={hammerTimings[i]}
|
||||
strikeCount={liveCounters?.[i] ?? null}
|
||||
requestingCounters={requestingCounters}
|
||||
isBellSystems={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Confirm disable dialog ────────────────────────────────────────── */}
|
||||
<ConfirmDialog
|
||||
open={confirmDisable}
|
||||
variant="danger"
|
||||
title="Disable Bell Commander"
|
||||
message="Are you sure you want to disable the Bell Commander? This will deactivate all bell mechanisms on this device."
|
||||
confirmLabel="Disable"
|
||||
onConfirm={() => {
|
||||
setConfirmDisable(false)
|
||||
handleToggleCommander(false)
|
||||
}}
|
||||
onCancel={() => setConfirmDisable(false)}
|
||||
loading={togglingCommander}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
// frontend/src/pages/bellcloud/devices/tabs/ClockTab.jsx
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import api from '@/lib/api'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import StatusBadge from '@/components/ui/StatusBadge'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import SyncClockModal from '@/modals/bellcloud/devices/SyncClockModal'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function extractTime(tsStr) {
|
||||
if (!tsStr) return null
|
||||
const d = new Date(
|
||||
tsStr.replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+(\d{4})/, 'UTC')
|
||||
)
|
||||
if (!isNaN(d.getTime())) {
|
||||
return `${d.getUTCHours().toString().padStart(2, '0')}:${d.getUTCMinutes().toString().padStart(2, '0')}`
|
||||
}
|
||||
const match = tsStr.match(/(\d{1,2}):(\d{2})/)
|
||||
return match ? `${match[1].padStart(2, '0')}:${match[2]}` : null
|
||||
}
|
||||
|
||||
function msToSeconds(ms) {
|
||||
if (ms == null || ms === 0) return null
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
function bellLabel(n) {
|
||||
if (!n || n === 0) return 'Disabled'
|
||||
return `Bell ${n}`
|
||||
}
|
||||
|
||||
function outputLabel(n) {
|
||||
if (!n || n === 0) return 'Disabled'
|
||||
return `Output ${n}`
|
||||
}
|
||||
|
||||
function silenceLabel(isOn, from, to) {
|
||||
if (!isOn) return 'Off'
|
||||
const f = extractTime(from)
|
||||
const t = extractTime(to)
|
||||
if (f && t) return `${f} – ${t}`
|
||||
return 'On'
|
||||
}
|
||||
|
||||
// ─── Analog Clock Face ────────────────────────────────────────────────────────
|
||||
|
||||
function AnalogClock({ time, label, variant = 'primary' }) {
|
||||
const canvasRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
const size = canvas.width
|
||||
const cx = size / 2
|
||||
const cy = size / 2
|
||||
const r = size / 2 - 4
|
||||
|
||||
const h = time.getHours() % 12
|
||||
const m = time.getMinutes()
|
||||
const s = time.getSeconds()
|
||||
|
||||
const isPrimary = variant === 'primary'
|
||||
const accentColor = isPrimary ? 'rgba(192,193,255,0.9)' : 'rgba(192,193,255,0.35)'
|
||||
const faceColor = isPrimary ? 'rgba(192,193,255,0.06)' : 'rgba(192,193,255,0.03)'
|
||||
const rimColor = isPrimary ? 'rgba(192,193,255,0.25)' : 'rgba(192,193,255,0.10)'
|
||||
const tickColor = isPrimary ? 'rgba(192,193,255,0.30)' : 'rgba(192,193,255,0.12)'
|
||||
const hourHandColor = isPrimary ? 'rgba(192,193,255,1)' : 'rgba(192,193,255,0.45)'
|
||||
const minuteHandColor = isPrimary ? 'rgba(210,187,255,0.85)' : 'rgba(210,187,255,0.35)'
|
||||
const secondHandColor = isPrimary ? 'rgba(255,92,92,0.85)' : 'rgba(255,92,92,0.4)'
|
||||
const centerDotColor = isPrimary ? '#c0c1ff' : 'rgba(192,193,255,0.4)'
|
||||
|
||||
ctx.clearRect(0, 0, size, size)
|
||||
|
||||
// Face
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2)
|
||||
ctx.fillStyle = faceColor
|
||||
ctx.fill()
|
||||
|
||||
// Outer glow for primary
|
||||
if (isPrimary) {
|
||||
const grad = ctx.createRadialGradient(cx, cy, r - 2, cx, cy, r + 8)
|
||||
grad.addColorStop(0, 'rgba(192,193,255,0.12)')
|
||||
grad.addColorStop(1, 'rgba(192,193,255,0)')
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, r + 4, 0, Math.PI * 2)
|
||||
ctx.fillStyle = grad
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// Rim
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2)
|
||||
ctx.strokeStyle = rimColor
|
||||
ctx.lineWidth = isPrimary ? 1.5 : 1
|
||||
ctx.stroke()
|
||||
|
||||
// Ticks
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const angle = (i / 60) * Math.PI * 2 - Math.PI / 2
|
||||
const isHour = i % 5 === 0
|
||||
const inner = isHour ? r - 12 : r - 7
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner)
|
||||
ctx.lineTo(cx + Math.cos(angle) * (r - 2), cy + Math.sin(angle) * (r - 2))
|
||||
ctx.strokeStyle = isHour ? accentColor : tickColor
|
||||
ctx.lineWidth = isHour ? 1.5 : 0.75
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Hour hand
|
||||
const hourAngle = ((h + m / 60) / 12) * Math.PI * 2 - Math.PI / 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx, cy)
|
||||
ctx.lineTo(cx + Math.cos(hourAngle) * r * 0.52, cy + Math.sin(hourAngle) * r * 0.52)
|
||||
ctx.strokeStyle = hourHandColor
|
||||
ctx.lineWidth = isPrimary ? 3 : 2
|
||||
ctx.lineCap = 'round'
|
||||
ctx.stroke()
|
||||
|
||||
// Minute hand
|
||||
const minAngle = ((m + s / 60) / 60) * Math.PI * 2 - Math.PI / 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx, cy)
|
||||
ctx.lineTo(cx + Math.cos(minAngle) * r * 0.72, cy + Math.sin(minAngle) * r * 0.72)
|
||||
ctx.strokeStyle = minuteHandColor
|
||||
ctx.lineWidth = isPrimary ? 2 : 1.5
|
||||
ctx.lineCap = 'round'
|
||||
ctx.stroke()
|
||||
|
||||
// Second hand
|
||||
const secAngle = (s / 60) * Math.PI * 2 - Math.PI / 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx, cy)
|
||||
ctx.lineTo(cx + Math.cos(secAngle) * r * 0.82, cy + Math.sin(secAngle) * r * 0.82)
|
||||
ctx.strokeStyle = secondHandColor
|
||||
ctx.lineWidth = 1
|
||||
ctx.lineCap = 'round'
|
||||
ctx.stroke()
|
||||
|
||||
// Center dot
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, isPrimary ? 4 : 3, 0, Math.PI * 2)
|
||||
ctx.fillStyle = centerDotColor
|
||||
ctx.fill()
|
||||
}, [time, variant])
|
||||
|
||||
const digitalTime = `${time.getHours().toString().padStart(2, '0')}:${time.getMinutes().toString().padStart(2, '0')}:${time.getSeconds().toString().padStart(2, '0')}`
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
textTransform: 'uppercase',
|
||||
color: variant === 'primary' ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={160}
|
||||
height={160}
|
||||
style={{ display: 'block' }}
|
||||
aria-label={`${label}: ${digitalTime}`}
|
||||
/>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
fontSize: 'var(--font-size-base)',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
color: variant === 'primary' ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
|
||||
letterSpacing: '0.04em',
|
||||
}}>
|
||||
{digitalTime}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── SettingRow ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SettingRow({ label, value, mono = false, badge }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--space-3)', minHeight: 32 }}>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', flexShrink: 0 }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: value === '—' || value === 'Disabled' || value === 'Off' ? 'var(--color-text-muted)' : 'var(--color-text-primary)',
|
||||
fontFamily: mono ? 'var(--font-family-mono)' : 'inherit',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
textAlign: 'right',
|
||||
}}>
|
||||
{badge ? badge : value ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return (
|
||||
<div style={{ height: 1, backgroundColor: 'var(--color-border)', margin: 'var(--space-1) 0' }} />
|
||||
)
|
||||
}
|
||||
|
||||
// ─── HeroTile ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function HeroTile({ label, value, flash = false }) {
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: 'rgba(28, 32, 38, 0.40)',
|
||||
backdropFilter: 'var(--blur-modal)',
|
||||
WebkitBackdropFilter: 'var(--blur-modal)',
|
||||
border: `1px solid ${flash ? 'var(--color-warning)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--space-1)',
|
||||
transition: 'border-color 0.3s',
|
||||
animation: flash ? 'tile-flash 0.6s ease 3' : 'none',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
textTransform: 'uppercase',
|
||||
color: flash ? 'var(--color-warning)' : 'var(--color-text-muted)',
|
||||
transition: 'color 0.3s',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xl)',
|
||||
fontWeight: 'var(--font-weight-bold)',
|
||||
color: value ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
|
||||
fontFamily: 'var(--font-family-display)',
|
||||
letterSpacing: 'var(--tracking-tight)',
|
||||
lineHeight: 1.1,
|
||||
fontStyle: value ? 'normal' : 'italic',
|
||||
}}>
|
||||
{value ?? 'not set'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── ClockTab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ClockTab({
|
||||
device,
|
||||
canEdit,
|
||||
clock,
|
||||
attr,
|
||||
onEditClockSettings,
|
||||
onEditAlerts,
|
||||
onEditBacklight,
|
||||
onDeviceUpdated,
|
||||
}) {
|
||||
const [now, setNow] = useState(new Date())
|
||||
const [showSync, setShowSync] = useState(false)
|
||||
const [flashFields, setFlashFields] = useState(false)
|
||||
const [togglingClock, setTogglingClock] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// ── Derived values ────────────────────────────────────────────────────────
|
||||
|
||||
const hasClock = attr?.hasClock ?? false
|
||||
const clockOutputs = clock?.clockOutputs || []
|
||||
const clockTimings = clock?.clockTimings || []
|
||||
const oddOut = clockOutputs[0] || 0
|
||||
const evenOut = clockOutputs[1] || 0
|
||||
const runPulse = clockTimings[0]
|
||||
const pauseTime = clockTimings[1]
|
||||
|
||||
const alertType = clock?.ringAlerts || 'disabled'
|
||||
const alertMasterOn = clock?.ringAlertsMasterOn ?? false
|
||||
const ringIntervals = clock?.ringIntervals
|
||||
const hourBell = clock?.hourAlertsBell || 0
|
||||
const halfBell = clock?.halfhourAlertsBell || 0
|
||||
const quarterBell = clock?.quarterAlertsBell || 0
|
||||
const isDaySilenceOn = clock?.isDaySilenceOn ?? false
|
||||
const isNightSilenceOn = clock?.isNightSilenceOn ?? false
|
||||
|
||||
const backlightOn = clock?.isBacklightAutomationOn ?? false
|
||||
const backlightOutput = clock?.backlightOutput || 0
|
||||
const backlightOnTime = extractTime(clock?.backlightTurnOnTime)
|
||||
const backlightOffTime = extractTime(clock?.backlightTurnOffTime)
|
||||
|
||||
const timezone = attr?.timezone || device?.timezone || null
|
||||
const ntpServer = attr?.ntpServer || attr?.networkSettings?.ntpServer || null
|
||||
const autoDst = attr?.autoDST ?? false
|
||||
|
||||
const alertTypeLabel =
|
||||
alertType === 'single' ? 'Single Fire' :
|
||||
alertType === 'multi' ? 'Hour Indicating' :
|
||||
'Disabled'
|
||||
|
||||
const alertVariant = alertMasterOn ? 'success' : 'neutral'
|
||||
|
||||
// All 4 hero fields must be set before ENABLING the master switch
|
||||
const allFieldsSet = oddOut > 0 && evenOut > 0 && runPulse > 0 && pauseTime > 0
|
||||
|
||||
const handleMasterToggleClick = async () => {
|
||||
if (!canEdit || togglingClock) return
|
||||
|
||||
// Trying to enable but required fields are missing — flash tiles and block
|
||||
if (!hasClock && !allFieldsSet) {
|
||||
setFlashFields(true)
|
||||
setTimeout(() => setFlashFields(false), 2200)
|
||||
return
|
||||
}
|
||||
|
||||
const deviceId = device?.id || device?.device_id
|
||||
const newValue = !hasClock
|
||||
setTogglingClock(true)
|
||||
try {
|
||||
const updated = await api.put(`/devices/${deviceId}`, {
|
||||
device_attributes: { ...(attr || {}), hasClock: newValue },
|
||||
})
|
||||
if (onDeviceUpdated) {
|
||||
// If API returns the full device object, use it; otherwise patch manually
|
||||
if (updated?.device_attributes !== undefined) {
|
||||
onDeviceUpdated(updated)
|
||||
} else {
|
||||
onDeviceUpdated(prev => ({
|
||||
...prev,
|
||||
device_attributes: { ...(prev?.device_attributes || {}), hasClock: newValue },
|
||||
}))
|
||||
}
|
||||
}
|
||||
toast.success(newValue ? 'Clock enabled' : 'Clock disabled', '')
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to update clock status.')
|
||||
} finally {
|
||||
setTogglingClock(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Backlight period string
|
||||
const backlightPeriod = backlightOn && backlightOnTime && backlightOffTime
|
||||
? `${backlightOnTime} – ${backlightOffTime}`
|
||||
: null
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Flash keyframe injected once */}
|
||||
<style>{`
|
||||
@keyframes tile-flash {
|
||||
0%, 100% { border-color: var(--color-border); }
|
||||
50% { border-color: var(--color-warning); box-shadow: 0 0 0 2px rgba(251,191,36,0.18); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
|
||||
|
||||
{/* ── Hero ──────────────────────────────────────────────────────────── */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
backgroundColor: 'rgba(28, 32, 38, 0.30)',
|
||||
backdropFilter: 'var(--blur-modal)',
|
||||
WebkitBackdropFilter: 'var(--blur-modal)',
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-card), var(--shadow-md)',
|
||||
overflow: 'hidden',
|
||||
padding: 'var(--space-6) var(--space-8)',
|
||||
}}>
|
||||
<div aria-hidden="true" style={{
|
||||
position: 'absolute', inset: 0, pointerEvents: 'none',
|
||||
background: 'radial-gradient(ellipse 50% 80% at 85% 50%, rgba(192,193,255,0.05) 0%, transparent 70%)',
|
||||
}} />
|
||||
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-8)',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
|
||||
{/* Left — settings panel */}
|
||||
<div style={{ flex: 1, minWidth: 260, display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
|
||||
{/* Title + toggle */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-family-display)',
|
||||
fontSize: 'var(--font-size-xl)',
|
||||
fontWeight: 'var(--font-weight-bold)',
|
||||
letterSpacing: 'var(--tracking-tight)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}>
|
||||
Master Control
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={hasClock}
|
||||
aria-label="Toggle clock"
|
||||
onClick={handleMasterToggleClick}
|
||||
disabled={togglingClock}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 36,
|
||||
height: 20,
|
||||
borderRadius: 'var(--radius-full)',
|
||||
backgroundColor: hasClock ? 'var(--color-success)' : 'var(--color-bg-island)',
|
||||
border: `1px solid ${hasClock ? 'var(--color-success)' : 'var(--color-border-strong)'}`,
|
||||
flexShrink: 0,
|
||||
cursor: canEdit && !togglingClock ? 'pointer' : 'default',
|
||||
transition: 'background-color 0.2s, border-color 0.2s',
|
||||
padding: 0,
|
||||
opacity: togglingClock ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
left: hasClock ? 17 : 2,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 'var(--radius-full)',
|
||||
backgroundColor: hasClock ? 'var(--color-text-inverse)' : 'var(--color-text-muted)',
|
||||
transition: 'left 0.2s',
|
||||
}} />
|
||||
</button>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: hasClock ? 'var(--color-success)' : 'var(--color-text-muted)',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
}}>
|
||||
{hasClock ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 2×2 tile grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
|
||||
<HeroTile label="Pulse Duration" value={msToSeconds(runPulse)} flash={flashFields && !runPulse} />
|
||||
<HeroTile label="Pause Duration" value={msToSeconds(pauseTime)} flash={flashFields && !pauseTime} />
|
||||
<HeroTile label="ODD Output" value={oddOut ? outputLabel(oddOut) : null} flash={flashFields && !oddOut} />
|
||||
<HeroTile label="EVEN Output" value={evenOut ? outputLabel(evenOut) : null} flash={flashFields && !evenOut} />
|
||||
</div>
|
||||
|
||||
{/* Actions row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="sm" onClick={onEditClockSettings}>Edit Settings</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSync(true)}
|
||||
style={{
|
||||
background: 'none', border: 'none',
|
||||
padding: '0 var(--space-1)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: 'var(--color-primary)',
|
||||
cursor: 'pointer',
|
||||
opacity: 0.75,
|
||||
transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.opacity = 1)}
|
||||
onMouseLeave={e => (e.currentTarget.style.opacity = 0.75)}
|
||||
>
|
||||
Sync Clocktower Time
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — clocks, with equal breathing room on all sides */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-12)', padding: '0 var(--space-8)' }}>
|
||||
<AnalogClock time={now} label="RTC Time" variant="primary" />
|
||||
<AnalogClock time={now} label="Clocktower" variant="primary" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Cards grid — 3 columns ────────────────────────────────────────── */}
|
||||
<div className="masonry-grid masonry-grid--3">
|
||||
|
||||
{/* Alert Settings */}
|
||||
<Card
|
||||
title="Alert Settings"
|
||||
action={canEdit && (
|
||||
<Button variant="ghost" size="sm" onClick={onEditAlerts}>Edit</Button>
|
||||
)}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
|
||||
{/* Master + type inline */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
|
||||
<StatusBadge variant={alertVariant}>{alertMasterOn ? 'Enabled' : 'Disabled'}</StatusBadge>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>·</span>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', fontWeight: 'var(--font-weight-medium)' }}>
|
||||
{alertTypeLabel}
|
||||
</span>
|
||||
{alertType === 'multi' && ringIntervals && (
|
||||
<>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>·</span>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
||||
Tempo {msToSeconds(ringIntervals)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Bell assignments + silence periods — shared 3-column grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--space-2) var(--space-2)', rowGap: 'var(--space-4)' }}>
|
||||
{[
|
||||
{ label: 'Hours', value: bellLabel(hourBell) },
|
||||
{ label: 'Half-Hours', value: bellLabel(halfBell) },
|
||||
{ label: 'Quarters', value: bellLabel(quarterBell) },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: 'var(--color-text-muted)',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-base)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: value === 'Disabled' ? 'var(--color-text-muted)' : 'var(--color-text-primary)',
|
||||
fontFamily: 'var(--font-family-display)',
|
||||
letterSpacing: 'var(--tracking-tight)',
|
||||
}}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Silence periods in cols 1–2, col 3 intentionally empty */}
|
||||
{[
|
||||
{ label: 'Day Silence', isOn: isDaySilenceOn, from: clock?.daySilenceFrom, to: clock?.daySilenceTo },
|
||||
{ label: 'Night Silence', isOn: isNightSilenceOn, from: clock?.nightSilenceFrom, to: clock?.nightSilenceTo },
|
||||
].map(({ label, isOn, from, to }) => (
|
||||
<div key={label} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: 'var(--color-text-muted)',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-base)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: isOn ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
|
||||
fontFamily: isOn ? 'var(--font-family-mono)' : 'inherit',
|
||||
letterSpacing: isOn ? '0.03em' : 0,
|
||||
}}>
|
||||
{silenceLabel(isOn, from, to)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Backlight Automation */}
|
||||
<Card
|
||||
title="Backlight Automation"
|
||||
action={canEdit && (
|
||||
<Button variant="ghost" size="sm" onClick={onEditBacklight}>Edit</Button>
|
||||
)}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||
<SettingRow
|
||||
label="Automation"
|
||||
badge={<StatusBadge variant={backlightOn ? 'success' : 'neutral'}>{backlightOn ? 'Automated' : 'Disabled'}</StatusBadge>}
|
||||
/>
|
||||
<SettingRow label="Output" value={outputLabel(backlightOutput)} />
|
||||
{backlightOn && (
|
||||
<SettingRow
|
||||
label="Period"
|
||||
value={backlightPeriod || '—'}
|
||||
mono={!!backlightPeriod}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Network & Time */}
|
||||
<Card title="Network & Time">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||
<SettingRow label="Timezone" value={timezone || '—'} mono={!!timezone} />
|
||||
<SettingRow label="NTP Server" value={ntpServer || '—'} mono={!!ntpServer} />
|
||||
<SettingRow
|
||||
label="Auto-DST"
|
||||
badge={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||
<StatusBadge variant="neutral">{autoDst ? 'Enabled' : 'Disabled'}</StatusBadge>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: 'var(--color-text-muted)',
|
||||
backgroundColor: 'rgba(28, 32, 38, 0.40)',
|
||||
padding: '2px var(--space-2)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}>
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ marginTop: 'var(--space-4)', paddingTop: 'var(--space-4)', borderTop: '1px solid var(--color-border)' }}>
|
||||
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
Timezone and NTP configuration are managed via device attributes. Auto-DST adjustment is pending implementation.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SyncClockModal open={showSync} onClose={() => setShowSync(false)} device={device} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx
|
||||
|
||||
export default function ControlTab({ device, canEdit, onDeviceUpdated }) {
|
||||
return null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,683 @@
|
||||
// frontend/src/pages/bellcloud/devices/tabs/ManageTab.jsx
|
||||
// Manage tab — Device Users + Device Notes + Linked Issues
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '@/lib/api'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import Button from '@/components/ui/Button'
|
||||
import StatusBadge from '@/components/ui/StatusBadge'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
import { fmtDateMedium, fmtRelative } from '@/lib/formatters'
|
||||
|
||||
import AddDeviceUserModal from '@/modals/bellcloud/devices/AddDeviceUserModal'
|
||||
import DeviceNoteModal from '@/modals/bellcloud/devices/DeviceNoteModal'
|
||||
import EntryFormModal from '@/modals/crm/helpdesk/EntryFormModal'
|
||||
|
||||
// ─── Visual constants (match OverviewTab's GlassCard aesthetic) ───────────────
|
||||
|
||||
const GLASS = 'rgba(28, 32, 38, 0.30)'
|
||||
const GLASS_INNER = 'rgba(28, 32, 38, 0.40)'
|
||||
const GLASS_HOVER = 'rgba(49, 53, 60, 0.55)'
|
||||
const BLUR = 'blur(12px)'
|
||||
|
||||
// ─── Status/severity maps (match IssuesTab constants) ─────────────────────────
|
||||
|
||||
const STATUS_META = {
|
||||
open: { label: 'Open', color: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
|
||||
researching: { label: 'Researching', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
|
||||
resolved: { label: 'Resolved', color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
|
||||
}
|
||||
|
||||
const SEVERITY_META = {
|
||||
low: { label: 'Low', color: 'var(--color-info)' },
|
||||
medium: { label: 'Medium', color: 'var(--color-warning)' },
|
||||
high: { label: 'High', color: 'var(--color-danger)' },
|
||||
critical: { label: 'Critical', color: 'var(--color-danger)', breathe: true },
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function SectionLabel({ children }) {
|
||||
return (
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: 'var(--color-text-muted)',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function GlassCard({ children, style = {} }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: GLASS,
|
||||
backdropFilter: BLUR,
|
||||
WebkitBackdropFilter: BLUR,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
boxShadow: 'var(--shadow-card), var(--shadow-md)',
|
||||
...style,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ label, count, action }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 'var(--space-4) var(--space-5)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||
<SectionLabel>{label}</SectionLabel>
|
||||
{count != null && (
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: 'var(--color-text-muted)',
|
||||
background: GLASS_INNER,
|
||||
padding: '2px var(--space-2)',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
border: '1px solid var(--color-border)',
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptySlate({ icon, message, action }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-3)',
|
||||
padding: 'var(--space-8) var(--space-6)',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}>
|
||||
<div style={{ opacity: 0.35 }}>{icon}</div>
|
||||
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', textAlign: 'center' }}>{message}</p>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusPill({ status }) {
|
||||
const m = STATUS_META[status] || { label: status, color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)' }
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
backgroundColor: m.bg,
|
||||
border: `1px solid ${m.color}33`,
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: m.color,
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ width: 5, height: 5, borderRadius: '50%', backgroundColor: m.color, flexShrink: 0 }} />
|
||||
{m.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SeverityBars({ severity }) {
|
||||
if (!severity) return null
|
||||
const m = SEVERITY_META[severity] || { label: severity, color: 'var(--color-text-muted)' }
|
||||
const bars = { low: 1, medium: 2, high: 3, critical: 4 }[severity] || 1
|
||||
return (
|
||||
<span
|
||||
className={m.breathe ? 'severity-critical' : undefined}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0 }}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 12 }}>
|
||||
{[1, 2, 3, 4].map(b => (
|
||||
<span key={b} style={{
|
||||
width: 3,
|
||||
height: 2 + b * 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: b <= bars ? m.color : 'var(--color-bg-island)',
|
||||
display: 'block',
|
||||
}} />
|
||||
))}
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-medium)', color: m.color }}>
|
||||
{m.label}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── ManageTab ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ManageTab({ device, canEdit, deviceUsers: propUsers, usersLoading: propUsersLoading }) {
|
||||
const navigate = useNavigate()
|
||||
const { toast } = useToast()
|
||||
const id = device?.id || device?.device_id
|
||||
|
||||
// ── Users state ────────────────────────────────────────────────────────────
|
||||
// We manage our own copy so we can add/remove without refetching the entire device
|
||||
const [users, setUsers] = useState(propUsers || [])
|
||||
const [usersLoading, setUsersLoading] = useState(propUsersLoading || false)
|
||||
const [removingUser, setRemovingUser] = useState(null)
|
||||
const [confirmRemoveUser, setConfirmRemoveUser] = useState(null)
|
||||
const [showAddUser, setShowAddUser] = useState(false)
|
||||
|
||||
// Sync from parent prop on mount / prop change
|
||||
useEffect(() => {
|
||||
if (propUsers) setUsers(propUsers)
|
||||
}, [propUsers])
|
||||
|
||||
useEffect(() => {
|
||||
setUsersLoading(propUsersLoading)
|
||||
}, [propUsersLoading])
|
||||
|
||||
const reloadUsers = useCallback(async () => {
|
||||
if (!id) return
|
||||
setUsersLoading(true)
|
||||
try {
|
||||
const data = await api.get(`/devices/${id}/users`)
|
||||
setUsers(data.users || [])
|
||||
} catch {
|
||||
// silent — parent has already shown this device's users
|
||||
} finally {
|
||||
setUsersLoading(false)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const handleUserAdded = (user) => {
|
||||
setUsers(prev => [...prev, user])
|
||||
toast.success('User added', `${user.display_name || user.email} now has access.`)
|
||||
}
|
||||
|
||||
const handleRemoveUser = async () => {
|
||||
if (!confirmRemoveUser) return
|
||||
const targetId = confirmRemoveUser.user_id || confirmRemoveUser.id
|
||||
setRemovingUser(targetId)
|
||||
try {
|
||||
await api.delete(`/devices/${id}/user-list/${targetId}`)
|
||||
setUsers(prev => prev.filter(u => (u.user_id || u.id) !== targetId))
|
||||
toast.success('Removed', `${confirmRemoveUser.display_name || confirmRemoveUser.email} removed.`)
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to remove user.')
|
||||
} finally {
|
||||
setRemovingUser(null)
|
||||
setConfirmRemoveUser(null)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Device notes (Firestore) ──────────────────────────────────────────────
|
||||
const [notes, setNotes] = useState([])
|
||||
const [notesLoading, setNotesLoading] = useState(false)
|
||||
const [noteModal, setNoteModal] = useState({ open: false, note: null })
|
||||
const [deletingNote, setDeletingNote] = useState(null)
|
||||
const [confirmDeleteNote, setConfirmDeleteNote] = useState(null)
|
||||
|
||||
const loadNotes = useCallback(async () => {
|
||||
if (!id) return
|
||||
setNotesLoading(true)
|
||||
try {
|
||||
const data = await api.get(`/devices/${id}/notes`)
|
||||
setNotes((data.notes || []).slice().reverse()) // newest first
|
||||
} catch {
|
||||
setNotes([])
|
||||
} finally {
|
||||
setNotesLoading(false)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => { loadNotes() }, [loadNotes])
|
||||
|
||||
const handleNoteSaved = () => {
|
||||
setNoteModal({ open: false, note: null })
|
||||
loadNotes()
|
||||
}
|
||||
|
||||
const handleDeleteNote = async () => {
|
||||
if (!confirmDeleteNote) return
|
||||
setDeletingNote(confirmDeleteNote.id)
|
||||
try {
|
||||
await api.delete(`/devices/${id}/notes/${confirmDeleteNote.id}`)
|
||||
setNotes(prev => prev.filter(n => n.id !== confirmDeleteNote.id))
|
||||
toast.success('Deleted', 'Note removed.')
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to delete note.')
|
||||
} finally {
|
||||
setDeletingNote(null)
|
||||
setConfirmDeleteNote(null)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Linked issues (global /notes system, entity_type=device) ─────────────
|
||||
const [issues, setIssues] = useState([])
|
||||
const [issuesLoading, setIssuesLoading] = useState(false)
|
||||
const [issueModal, setIssueModal] = useState({ open: false, entry: null })
|
||||
const [confirmDeleteIssue, setConfirmDeleteIssue] = useState(null)
|
||||
const [deletingIssue, setDeletingIssue] = useState(false)
|
||||
|
||||
const loadIssues = useCallback(async () => {
|
||||
if (!id) return
|
||||
setIssuesLoading(true)
|
||||
try {
|
||||
const data = await api.get(`/notes/by-entity/device/${id}`)
|
||||
setIssues(Array.isArray(data) ? data : [])
|
||||
} catch {
|
||||
setIssues([])
|
||||
} finally {
|
||||
setIssuesLoading(false)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => { loadIssues() }, [loadIssues])
|
||||
|
||||
const openNewIssue = () => setIssueModal({ open: true, entry: null })
|
||||
const openEditIssue = (entry) => setIssueModal({ open: true, entry })
|
||||
|
||||
const handleIssueSaved = () => {
|
||||
setIssueModal({ open: false, entry: null })
|
||||
loadIssues()
|
||||
}
|
||||
|
||||
const handleDeleteIssue = async () => {
|
||||
if (!confirmDeleteIssue) return
|
||||
setDeletingIssue(true)
|
||||
try {
|
||||
await api.delete(`/notes/${confirmDeleteIssue}`)
|
||||
toast.success('Deleted', 'Issue removed.')
|
||||
setConfirmDeleteIssue(null)
|
||||
loadIssues()
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to delete issue.')
|
||||
} finally {
|
||||
setDeletingIssue(false)
|
||||
}
|
||||
}
|
||||
|
||||
const sn = device?.serial_number || device?.device_id
|
||||
const existingUserIds = users.map(u => u.user_id || u.id).filter(Boolean)
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.manage-user-row:hover .manage-user-remove { opacity: 1; }
|
||||
.manage-user-row .manage-user-remove { opacity: 0; transition: opacity 0.15s; }
|
||||
.manage-issue-row { cursor: pointer; transition: background 0.12s; }
|
||||
.manage-issue-row:hover { background: ${GLASS_HOVER} !important; }
|
||||
.manage-note-card { transition: box-shadow 0.15s; }
|
||||
.manage-note-card:hover { box-shadow: var(--shadow-md) !important; }
|
||||
@keyframes severity-critical-glow {
|
||||
0%, 100% { filter: drop-shadow(0 0 0px var(--color-danger)); }
|
||||
50% { filter: drop-shadow(0 0 5px var(--color-danger)); }
|
||||
}
|
||||
.severity-critical { animation: severity-critical-glow 0.9s ease-in-out infinite; }
|
||||
`}</style>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
|
||||
|
||||
{/* ── 1. DEVICE ACCESS (Users) ──────────────────────────────────────── */}
|
||||
<GlassCard>
|
||||
<CardHeader
|
||||
label="Device Access"
|
||||
count={users.length || undefined}
|
||||
action={canEdit && (
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowAddUser(true)}>
|
||||
+ Add User
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div style={{ padding: 'var(--space-4) var(--space-5)', display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||
{usersLoading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-6)' }}>
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<EmptySlate
|
||||
icon={
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
}
|
||||
message="No users have access to this device."
|
||||
action={canEdit && (
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowAddUser(true)}>Add first user</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
users.map((user) => {
|
||||
const userId = user.user_id || user.id
|
||||
const initials = (user.display_name || user.email || '?').charAt(0).toUpperCase()
|
||||
return (
|
||||
<div
|
||||
key={userId}
|
||||
className="manage-user-row"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 'var(--space-3)',
|
||||
padding: 'var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: GLASS_INNER,
|
||||
border: '1px solid var(--color-border)',
|
||||
cursor: userId ? 'pointer' : 'default',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onClick={() => userId && navigate(`/users/${userId}`)}
|
||||
onMouseEnter={e => e.currentTarget.style.background = GLASS_HOVER}
|
||||
onMouseLeave={e => e.currentTarget.style.background = GLASS_INNER}
|
||||
>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: '50%', overflow: 'hidden',
|
||||
background: 'var(--color-primary-subtle)', flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)',
|
||||
color: 'var(--color-primary)', border: '1px solid rgba(192,193,255,0.20)',
|
||||
}}>
|
||||
{user.photo_url ? <img src={user.photo_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : initials}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{user.display_name || user.email || 'Unknown User'}
|
||||
</p>
|
||||
{user.email && user.display_name && (
|
||||
<p style={{ margin: 0, fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{user.role && <StatusBadge variant="info">{user.role}</StatusBadge>}
|
||||
{canEdit && (
|
||||
<button
|
||||
className="manage-user-remove"
|
||||
onClick={e => { e.stopPropagation(); setConfirmRemoveUser(user) }}
|
||||
disabled={removingUser === userId}
|
||||
title="Remove access" aria-label="Remove user"
|
||||
style={{
|
||||
width: 28, height: 28, borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-border-strong)',
|
||||
background: 'var(--color-danger-bg)', color: 'var(--color-danger)',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0, transition: 'background 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-danger)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--color-danger-bg)'}
|
||||
>
|
||||
<Icon name="delete" size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* ── 2. DEVICE NOTES ───────────────────────────────────────────────── */}
|
||||
<GlassCard>
|
||||
<CardHeader
|
||||
label="Device Notes"
|
||||
count={notes.length || undefined}
|
||||
action={canEdit && (
|
||||
<Button variant="secondary" size="sm" onClick={() => setNoteModal({ open: true, note: null })}>
|
||||
+ Add Note
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div style={{ padding: notesLoading || notes.length === 0 ? 0 : 'var(--space-4) var(--space-5)' }}>
|
||||
{notesLoading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}>
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
) : notes.length === 0 ? (
|
||||
<EmptySlate
|
||||
icon={
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
}
|
||||
message="No notes recorded for this device yet."
|
||||
action={canEdit && (
|
||||
<Button variant="secondary" size="sm" onClick={() => setNoteModal({ open: true, note: null })}>
|
||||
Add first note
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 'var(--space-3)' }}>
|
||||
{notes.map(note => (
|
||||
<div
|
||||
key={note.id}
|
||||
className="manage-note-card"
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 'var(--space-3)',
|
||||
padding: 'var(--space-4)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
background: GLASS_INNER,
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap', flex: 1 }}>
|
||||
{note.content || '—'}
|
||||
</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-2)' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }} title={fmtDateMedium(note.created_at)}>
|
||||
{fmtRelative(note.created_at)}
|
||||
</span>
|
||||
{note.created_by && (
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{note.created_by}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 'var(--space-1)', flexShrink: 0 }}>
|
||||
<button onClick={() => setNoteModal({ open: true, note })} title="Edit note" aria-label="Edit note"
|
||||
style={{ width: 26, height: 26, borderRadius: 'var(--radius-sm)', border: '1px solid transparent', background: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'color 0.12s, background 0.12s, border-color 0.12s' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-primary)'; e.currentTarget.style.background = 'var(--color-primary-subtle)'; e.currentTarget.style.borderColor = 'rgba(192,193,255,0.25)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'transparent' }}
|
||||
>
|
||||
<Icon name="edit" size={13} />
|
||||
</button>
|
||||
<button onClick={() => setConfirmDeleteNote(note)} title="Delete note" aria-label="Delete note"
|
||||
style={{ width: 26, height: 26, borderRadius: 'var(--radius-sm)', border: '1px solid transparent', background: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'color 0.12s, background 0.12s, border-color 0.12s' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-danger)'; e.currentTarget.style.background = 'var(--color-danger-bg)'; e.currentTarget.style.borderColor = 'var(--color-danger)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'transparent' }}
|
||||
>
|
||||
<Icon name="delete" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* ── 3. LINKED ISSUES ──────────────────────────────────────────────── */}
|
||||
<GlassCard>
|
||||
<CardHeader
|
||||
label="Linked Issues"
|
||||
count={
|
||||
issues.filter(i => i.status !== 'resolved').length > 0
|
||||
? `${issues.filter(i => i.status !== 'resolved').length} open`
|
||||
: issues.length > 0 ? `${issues.length} total` : undefined
|
||||
}
|
||||
action={canEdit && (
|
||||
<Button variant="secondary" size="sm" onClick={openNewIssue}>
|
||||
+ New Issue
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{issuesLoading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}>
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
) : issues.length === 0 ? (
|
||||
<EmptySlate
|
||||
icon={
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
}
|
||||
message="No issues linked to this device."
|
||||
action={canEdit && (
|
||||
<Button variant="secondary" size="sm" onClick={openNewIssue}>Log first issue</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
issues.map((issue, idx) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="manage-issue-row"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto 1fr auto auto auto',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-3)',
|
||||
padding: 'var(--space-3) var(--space-5)',
|
||||
borderBottom: idx < issues.length - 1 ? '1px solid var(--color-border)' : 'none',
|
||||
}}
|
||||
onClick={() => canEdit && openEditIssue(issue)}
|
||||
>
|
||||
<StatusPill status={issue.status} />
|
||||
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{issue.title}
|
||||
</p>
|
||||
{issue.body && (
|
||||
<p style={{ margin: '2px 0 0', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{issue.body.replace(/<[^>]*>/g, '').slice(0, 90)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SeverityBars severity={issue.severity} />
|
||||
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }} title={fmtDateMedium(issue.created_at)}>
|
||||
{fmtRelative(issue.created_at)}
|
||||
</span>
|
||||
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setConfirmDeleteIssue(issue.id) }}
|
||||
title="Delete issue" aria-label="Delete issue"
|
||||
style={{ width: 26, height: 26, borderRadius: 'var(--radius-sm)', border: '1px solid transparent', background: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, transition: 'color 0.12s, background 0.12s, border-color 0.12s' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-danger)'; e.currentTarget.style.background = 'var(--color-danger-bg)'; e.currentTarget.style.borderColor = 'var(--color-danger)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'transparent' }}
|
||||
>
|
||||
<Icon name="delete" size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Modals ─────────────────────────────────────────────────────────── */}
|
||||
|
||||
<AddDeviceUserModal
|
||||
open={showAddUser}
|
||||
deviceId={id}
|
||||
existingUserIds={existingUserIds}
|
||||
onClose={() => setShowAddUser(false)}
|
||||
onAdded={handleUserAdded}
|
||||
/>
|
||||
|
||||
<DeviceNoteModal
|
||||
open={noteModal.open}
|
||||
deviceId={id}
|
||||
note={noteModal.note}
|
||||
onClose={() => setNoteModal({ open: false, note: null })}
|
||||
onSaved={handleNoteSaved}
|
||||
/>
|
||||
|
||||
{/* Issue form — pre-link to this device (locked — cannot be removed) */}
|
||||
<EntryFormModal
|
||||
open={issueModal.open}
|
||||
entry={issueModal.entry}
|
||||
defaultType="issue"
|
||||
prefilledLinks={issueModal.entry
|
||||
? undefined
|
||||
: [{ entity_type: 'device', entity_id: id, display_name: device?.device_name || sn, locked: true }]
|
||||
}
|
||||
knownEntities={id ? { [id]: device?.device_name || sn } : undefined}
|
||||
onClose={() => setIssueModal({ open: false, entry: null })}
|
||||
onSaved={handleIssueSaved}
|
||||
onDelete={canEdit ? (entryId) => {
|
||||
setIssueModal({ open: false, entry: null })
|
||||
setConfirmDeleteIssue(entryId)
|
||||
} : null}
|
||||
/>
|
||||
|
||||
{/* Confirm: remove user */}
|
||||
<ConfirmDialog
|
||||
open={!!confirmRemoveUser}
|
||||
variant="danger"
|
||||
title="Remove Access"
|
||||
message={`Remove ${confirmRemoveUser?.display_name || confirmRemoveUser?.email || 'this user'} from the device?`}
|
||||
confirmLabel="Remove"
|
||||
onConfirm={handleRemoveUser}
|
||||
onClose={() => setConfirmRemoveUser(null)}
|
||||
loading={!!removingUser}
|
||||
/>
|
||||
|
||||
{/* Confirm: delete note */}
|
||||
<ConfirmDialog
|
||||
open={!!confirmDeleteNote}
|
||||
variant="danger"
|
||||
title="Delete Note"
|
||||
message="This note will be permanently deleted."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={handleDeleteNote}
|
||||
onClose={() => setConfirmDeleteNote(null)}
|
||||
loading={deletingNote === confirmDeleteNote?.id}
|
||||
/>
|
||||
|
||||
{/* Confirm: delete issue */}
|
||||
<ConfirmDialog
|
||||
open={!!confirmDeleteIssue}
|
||||
variant="danger"
|
||||
title="Delete Issue"
|
||||
message="This issue and all its entity links will be permanently removed."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={handleDeleteIssue}
|
||||
onClose={() => setConfirmDeleteIssue(null)}
|
||||
loading={deletingIssue}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,857 @@
|
||||
// frontend/src/pages/bellcloud/devices/tabs/WarrantyTab.jsx
|
||||
|
||||
import Button from '@/components/ui/Button'
|
||||
import StatusBadge from '@/components/ui/StatusBadge'
|
||||
import { fmtDateMedium, fmtDateLong } from '@/lib/formatters'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseDate(val) {
|
||||
if (!val) return null
|
||||
// Firestore Timestamp object
|
||||
if (typeof val === 'object' && typeof val.seconds === 'number') {
|
||||
return new Date(val.seconds * 1000)
|
||||
}
|
||||
const d = new Date(val)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
function addDays(date, days) {
|
||||
if (!date || !days) return null
|
||||
const d = new Date(date)
|
||||
d.setDate(d.getDate() + Number(days))
|
||||
return d
|
||||
}
|
||||
|
||||
function daysUntil(date) {
|
||||
if (!date) return null
|
||||
return Math.ceil((date.getTime() - Date.now()) / 86400000)
|
||||
}
|
||||
|
||||
/** 0–100 how much of the period has ELAPSED */
|
||||
function calcProgress(start, end) {
|
||||
if (!start || !end) return null
|
||||
const total = end.getTime() - start.getTime()
|
||||
if (total <= 0) return 100
|
||||
const elapsed = Date.now() - start.getTime()
|
||||
return Math.min(100, Math.max(0, Math.round((elapsed / total) * 100)))
|
||||
}
|
||||
|
||||
/** Remaining portion (inverse of elapsed) */
|
||||
function calcRemaining(start, end) {
|
||||
const p = calcProgress(start, end)
|
||||
return p === null ? null : 100 - p
|
||||
}
|
||||
|
||||
function daysLabel(days) {
|
||||
if (days == null || days === 0) return '—'
|
||||
if (days % 365 === 0) {
|
||||
const y = days / 365
|
||||
return `${y} year${y !== 1 ? 's' : ''}`
|
||||
}
|
||||
if (days % 30 === 0) {
|
||||
const m = days / 30
|
||||
return `${m} month${m !== 1 ? 's' : ''}`
|
||||
}
|
||||
return `${days} day${days !== 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
// ─── Glass surface styles ─────────────────────────────────────────────────────
|
||||
|
||||
const GLASS = {
|
||||
background: 'rgba(28, 32, 38, 0.30)',
|
||||
backdropFilter: 'var(--blur-modal)',
|
||||
WebkitBackdropFilter: 'var(--blur-modal)',
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-card), var(--shadow-md)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
}
|
||||
|
||||
const GLASS_ELEVATED = {
|
||||
background: 'rgba(28, 32, 38, 0.40)',
|
||||
backdropFilter: 'var(--blur-modal)',
|
||||
WebkitBackdropFilter: 'var(--blur-modal)',
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
}
|
||||
|
||||
// ─── Primitives ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionLabel({ children }) {
|
||||
return (
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoCell({ label, children }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
||||
<SectionLabel>{label}</SectionLabel>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-base)',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
color: 'var(--color-text-primary)',
|
||||
marginTop: 'var(--space-1)',
|
||||
}}>
|
||||
{children ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressBar({ value, color = 'var(--color-primary)', label, showPct = true }) {
|
||||
const pct = Math.max(0, Math.min(100, value ?? 0))
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||
{(label || showPct) && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{label && (
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{showPct && (
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}>
|
||||
{pct}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
height: 6,
|
||||
borderRadius: 'var(--radius-full)',
|
||||
backgroundColor: 'var(--color-bg-island)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${pct}%`,
|
||||
backgroundColor: color,
|
||||
borderRadius: 'var(--radius-full)',
|
||||
transition: 'width 0.6s ease',
|
||||
boxShadow: `0 0 8px ${color}60`,
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div style={{ height: 1, backgroundColor: 'var(--color-border)', margin: 'var(--space-1) 0' }} />
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, valueColor }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--space-4)', minHeight: 28 }}>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', flexShrink: 0 }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
color: valueColor ?? (!value || value === '—' ? 'var(--color-text-muted)' : 'var(--color-text-primary)'),
|
||||
textAlign: 'right',
|
||||
}}>
|
||||
{value ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Subscription Hero ────────────────────────────────────────────────────────
|
||||
|
||||
function SubscriptionHero({ sub, canEdit, onEditSubscription, deviceUsers }) {
|
||||
const subscrStart = parseDate(sub?.subscrStart)
|
||||
const subscrEnd = addDays(subscrStart, sub?.subscrDuration)
|
||||
const daysLeft = daysUntil(subscrEnd)
|
||||
const remaining = calcRemaining(subscrStart, subscrEnd)
|
||||
|
||||
const isActive = daysLeft === null || daysLeft > 0
|
||||
const tier = sub?.subscrTier || null
|
||||
|
||||
const progressColor = daysLeft === null
|
||||
? 'var(--color-primary)'
|
||||
: daysLeft > 60
|
||||
? 'var(--color-success)'
|
||||
: daysLeft > 14
|
||||
? 'var(--color-warning)'
|
||||
: 'var(--color-danger)'
|
||||
|
||||
// Registered users = actual device users count passed from parent
|
||||
const regUsers = Array.isArray(deviceUsers) ? deviceUsers.length : null
|
||||
const maxUsers = sub?.maxUsers ?? null
|
||||
const hwOutputs = sub?.hw_outputs ?? null
|
||||
const maxOutputs = sub?.maxOutputs ?? null
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
...GLASS,
|
||||
padding: 'var(--space-6) var(--space-8)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderLeft: '3px solid var(--color-primary)',
|
||||
}}>
|
||||
|
||||
{/* Left accent glow */}
|
||||
<div aria-hidden="true" style={{
|
||||
position: 'absolute',
|
||||
left: 0, top: 0, bottom: 0,
|
||||
width: 140,
|
||||
pointerEvents: 'none',
|
||||
background: 'linear-gradient(90deg, rgba(192,193,255,0.06) 0%, transparent 100%)',
|
||||
}} />
|
||||
{/* Right ambient glow */}
|
||||
<div aria-hidden="true" style={{
|
||||
position: 'absolute', inset: 0, pointerEvents: 'none',
|
||||
background: 'radial-gradient(ellipse 60% 120% at 90% 50%, rgba(192,193,255,0.03) 0%, transparent 70%)',
|
||||
}} />
|
||||
|
||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: 'var(--space-6)', flexWrap: 'wrap' }}>
|
||||
|
||||
{/* ── Column 1: Tier ── */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)', minWidth: 180, flexShrink: 0 }}>
|
||||
<div style={{
|
||||
width: 48, height: 48,
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
backgroundColor: 'var(--color-primary-subtle)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-family-display)',
|
||||
fontSize: 'var(--font-size-lg)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
letterSpacing: 'var(--tracking-tight)',
|
||||
color: 'var(--color-text-primary)',
|
||||
textTransform: 'capitalize',
|
||||
}}>
|
||||
{tier ? `${tier} Tier` : 'No Subscription'}
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<StatusBadge variant={isActive ? 'success' : 'danger'}>
|
||||
{isActive ? 'Active License' : 'Expired'}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Column 2: Progress bar ── */}
|
||||
<div style={{
|
||||
flex: 1, minWidth: 220,
|
||||
paddingLeft: 'var(--space-6)',
|
||||
borderLeft: '1px solid var(--color-border)',
|
||||
display: 'flex', flexDirection: 'column', gap: 'var(--space-3)',
|
||||
}}>
|
||||
{/* Top label row */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}>
|
||||
{daysLeft === null
|
||||
? 'No expiry set'
|
||||
: daysLeft <= 0
|
||||
? 'Subscription expired'
|
||||
: `${daysLeft} days remaining`}
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
{daysLabel(sub?.subscrDuration)} plan
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<ProgressBar value={remaining ?? 0} color={progressColor} showPct={false} />
|
||||
|
||||
{/* Bottom: inline Start / Renewal */}
|
||||
<div style={{ display: 'flex', gap: 'var(--space-8)', alignItems: 'center' }}>
|
||||
{subscrStart && (
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
Start:{' '}
|
||||
<span style={{ color: 'var(--color-text-secondary)', fontWeight: 'var(--font-weight-medium)' }}>
|
||||
{fmtDateMedium(subscrStart.toISOString())}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{subscrEnd && (
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
Renewal:{' '}
|
||||
<span style={{ color: 'var(--color-text-secondary)', fontWeight: 'var(--font-weight-medium)' }}>
|
||||
{fmtDateMedium(subscrEnd.toISOString())}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Column 3: Users + Outputs + Edit — side by side ── */}
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 'var(--space-6)',
|
||||
paddingLeft: 'var(--space-6)',
|
||||
borderLeft: '1px solid var(--color-border)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
|
||||
{/* Users block */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)' }}>
|
||||
<div style={{
|
||||
width: 48, height: 48,
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
backgroundColor: 'var(--color-primary-subtle)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}>
|
||||
Device Users
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
Max:{' '}
|
||||
<span style={{ color: 'var(--color-text-primary)', fontWeight: 'var(--font-weight-medium)' }}>
|
||||
{maxUsers ?? '—'}
|
||||
</span>
|
||||
{regUsers !== null && (
|
||||
<>
|
||||
{' '}<span style={{ color: 'var(--color-border-strong)' }}>|</span>{' '}
|
||||
Reg:{' '}
|
||||
<span style={{ color: 'var(--color-text-primary)', fontWeight: 'var(--font-weight-medium)' }}>
|
||||
{regUsers}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outputs block */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)' }}>
|
||||
<div style={{
|
||||
width: 48, height: 48,
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
backgroundColor: 'var(--color-primary-subtle)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.07 4.93a10 10 0 010 14.14M4.93 4.93a10 10 0 000 14.14" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}>
|
||||
Outputs
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
HW:{' '}
|
||||
<span style={{ color: 'var(--color-text-primary)', fontWeight: 'var(--font-weight-medium)' }}>
|
||||
{hwOutputs ?? '—'}
|
||||
</span>
|
||||
{maxOutputs !== null && (
|
||||
<>
|
||||
{' '}<span style={{ color: 'var(--color-border-strong)' }}>|</span>{' '}
|
||||
Max:{' '}
|
||||
<span style={{ color: 'var(--color-text-primary)', fontWeight: 'var(--font-weight-medium)' }}>
|
||||
{maxOutputs}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit pencil icon button */}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditSubscription}
|
||||
aria-label="Edit subscription plan"
|
||||
title="Edit subscription plan"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-text-muted)',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s, color 0.15s',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.background = 'rgba(49,53,60,0.50)'
|
||||
e.currentTarget.style.color = 'var(--color-text-primary)'
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
e.currentTarget.style.color = 'var(--color-text-muted)'
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Warranty Card ────────────────────────────────────────────────────────────
|
||||
|
||||
function WarrantyCard({ stats, canEdit, onEditWarranty }) {
|
||||
const warrantyStart = parseDate(stats?.warrantyStart)
|
||||
const warrantyEnd = addDays(warrantyStart, stats?.warrantyPeriod)
|
||||
const daysLeft = daysUntil(warrantyEnd)
|
||||
const remaining = calcRemaining(warrantyStart, warrantyEnd)
|
||||
|
||||
const isVoid = stats?.warrantyActive === false
|
||||
const isExpired = !isVoid && daysLeft !== null && daysLeft <= 0
|
||||
const isActive = !isVoid && !isExpired && (daysLeft === null || daysLeft > 0)
|
||||
|
||||
const statusLabel = isVoid ? 'Warranty Void' : isExpired ? 'Expired' : isActive ? 'Active' : '—'
|
||||
const statusVariant = isVoid ? 'warning' : isExpired ? 'danger' : isActive ? 'success' : 'neutral'
|
||||
|
||||
const borderAccent = isVoid
|
||||
? 'var(--color-warning)'
|
||||
: isExpired
|
||||
? 'var(--color-danger)'
|
||||
: 'var(--color-success)'
|
||||
|
||||
const progressColor = isVoid || isExpired ? 'var(--color-danger)' : 'var(--color-success)'
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
...GLASS,
|
||||
padding: 'var(--space-6)',
|
||||
borderLeft: `3px solid ${borderAccent}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
|
||||
{/* Accent side glow */}
|
||||
<div aria-hidden="true" style={{
|
||||
position: 'absolute',
|
||||
left: 0, top: 0, bottom: 0,
|
||||
width: 140,
|
||||
pointerEvents: 'none',
|
||||
background: `linear-gradient(90deg, ${borderAccent}12 0%, transparent 100%)`,
|
||||
}} />
|
||||
|
||||
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 'var(--space-4)', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)' }}>
|
||||
<div style={{
|
||||
width: 44, height: 44,
|
||||
borderRadius: 'var(--radius-full)',
|
||||
backgroundColor: `${borderAccent}18`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{isActive ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={borderAccent} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
<polyline points="9 12 11 14 15 10" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={borderAccent} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-family-display)',
|
||||
fontSize: 'var(--font-size-lg)',
|
||||
fontWeight: 'var(--font-weight-bold)',
|
||||
letterSpacing: 'var(--tracking-tight)',
|
||||
color: borderAccent,
|
||||
}}>
|
||||
{statusLabel}
|
||||
</div>
|
||||
<div style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', marginTop: 3 }}>
|
||||
{isVoid
|
||||
? 'Warranty has been manually voided'
|
||||
: isExpired
|
||||
? warrantyEnd
|
||||
? `Coverage ended on ${fmtDateLong(warrantyEnd.toISOString())}`
|
||||
: 'Coverage has expired'
|
||||
: warrantyEnd
|
||||
? `Active until ${fmtDateMedium(warrantyEnd.toISOString())}`
|
||||
: 'Warranty is currently active'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="sm" onClick={onEditWarranty}>
|
||||
Edit Warranty
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<ProgressBar
|
||||
value={remaining ?? 0}
|
||||
color={progressColor}
|
||||
label={
|
||||
daysLeft === null
|
||||
? 'Coverage period'
|
||||
: daysLeft <= 0
|
||||
? 'Coverage elapsed'
|
||||
: `${daysLeft} day${daysLeft !== 1 ? 's' : ''} remaining`
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Detail grid */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))',
|
||||
gap: 'var(--space-4) var(--space-6)',
|
||||
}}>
|
||||
<InfoCell label="Start Date">
|
||||
{warrantyStart ? fmtDateMedium(warrantyStart.toISOString()) : '—'}
|
||||
</InfoCell>
|
||||
<InfoCell label="Expiry Date">
|
||||
{warrantyEnd ? fmtDateMedium(warrantyEnd.toISOString()) : '—'}
|
||||
</InfoCell>
|
||||
<InfoCell label="Coverage Period">
|
||||
{daysLabel(stats?.warrantyPeriod)}
|
||||
</InfoCell>
|
||||
<InfoCell label="Status">
|
||||
<StatusBadge variant={statusVariant}>{statusLabel}</StatusBadge>
|
||||
</InfoCell>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Maintenance Card ─────────────────────────────────────────────────────────
|
||||
|
||||
function MaintenanceCard({ stats }) {
|
||||
const maintainedOn = parseDate(stats?.maintainedOn)
|
||||
const nextMaint = addDays(maintainedOn, stats?.maintainancePeriod)
|
||||
const daysLeft = daysUntil(nextMaint)
|
||||
const remaining = calcRemaining(maintainedOn, nextMaint)
|
||||
|
||||
const isOverdue = daysLeft !== null && daysLeft <= 0
|
||||
const isDueSoon = daysLeft !== null && daysLeft > 0 && daysLeft <= 30
|
||||
const statusVariant = isOverdue ? 'danger' : isDueSoon ? 'warning' : 'neutral'
|
||||
const progressColor = isOverdue
|
||||
? 'var(--color-danger)'
|
||||
: isDueSoon
|
||||
? 'var(--color-warning)'
|
||||
: 'var(--color-info)'
|
||||
|
||||
return (
|
||||
<div style={{ ...GLASS, padding: 'var(--space-6)', display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
|
||||
|
||||
{/* Title */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z" />
|
||||
</svg>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-family-display)',
|
||||
fontSize: 'var(--font-size-md)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
letterSpacing: 'var(--tracking-tight)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}>
|
||||
Maintenance Schedule
|
||||
</span>
|
||||
{(isOverdue || isDueSoon) && (
|
||||
<StatusBadge variant={statusVariant}>
|
||||
{isOverdue ? 'Overdue' : 'Due Soon'}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Next service highlight */}
|
||||
{nextMaint ? (
|
||||
<div style={{
|
||||
...GLASS_ELEVATED,
|
||||
padding: 'var(--space-4)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-4)', flexWrap: 'wrap',
|
||||
}}>
|
||||
<div>
|
||||
<SectionLabel>Next Scheduled Service</SectionLabel>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-family-display)',
|
||||
fontSize: 'var(--font-size-xl)',
|
||||
fontWeight: 'var(--font-weight-bold)',
|
||||
letterSpacing: 'var(--tracking-tight)',
|
||||
color: isOverdue ? 'var(--color-danger)' : isDueSoon ? 'var(--color-warning)' : 'var(--color-text-primary)',
|
||||
marginTop: 'var(--space-1)',
|
||||
}}>
|
||||
{fmtDateLong(nextMaint.toISOString())}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<SectionLabel>{isOverdue ? 'Overdue by' : 'In'}</SectionLabel>
|
||||
<div style={{
|
||||
fontSize: 'var(--font-size-lg)',
|
||||
fontWeight: 'var(--font-weight-bold)',
|
||||
color: isOverdue ? 'var(--color-danger)' : isDueSoon ? 'var(--color-warning)' : 'var(--color-text-secondary)',
|
||||
marginTop: 'var(--space-1)',
|
||||
}}>
|
||||
{daysLeft != null ? `${Math.abs(daysLeft)} day${Math.abs(daysLeft) !== 1 ? 's' : ''}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
...GLASS_ELEVATED,
|
||||
padding: 'var(--space-4)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
|
||||
No maintenance schedule set
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<ProgressBar
|
||||
value={remaining ?? 0}
|
||||
color={progressColor}
|
||||
label="Progress to next service"
|
||||
/>
|
||||
|
||||
{/* Detail rows */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||
<DetailRow
|
||||
label="Last Maintained"
|
||||
value={maintainedOn ? fmtDateMedium(maintainedOn.toISOString()) : null}
|
||||
/>
|
||||
<Divider />
|
||||
<DetailRow
|
||||
label="Service Interval"
|
||||
value={daysLabel(stats?.maintainancePeriod)}
|
||||
/>
|
||||
<Divider />
|
||||
<DetailRow
|
||||
label="Next Service"
|
||||
value={nextMaint ? fmtDateMedium(nextMaint.toISOString()) : null}
|
||||
valueColor={isOverdue ? 'var(--color-danger)' : isDueSoon ? 'var(--color-warning)' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Performance Metrics ──────────────────────────────────────────────────────
|
||||
|
||||
function MetricTile({ icon, label, value, accent = 'var(--color-primary)', tag, tagVariant = 'info' }) {
|
||||
return (
|
||||
<div style={{
|
||||
...GLASS_ELEVATED,
|
||||
padding: 'var(--space-5)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
gap: 'var(--space-4)',
|
||||
minHeight: 100,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ color: accent, display: 'flex', alignItems: 'center' }}>
|
||||
{icon}
|
||||
</div>
|
||||
{tag != null && (
|
||||
<StatusBadge variant={tagVariant}>{tag}</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-family-display)',
|
||||
fontSize: 'var(--font-size-2xl)',
|
||||
fontWeight: 'var(--font-weight-bold)',
|
||||
letterSpacing: 'var(--tracking-display)',
|
||||
color: 'var(--color-text-primary)',
|
||||
lineHeight: 1,
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
marginTop: 'var(--space-1)',
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PerformanceMetrics({ device, stats }) {
|
||||
const totalWarnings = Number.isFinite(stats?.totalWarningsGiven) ? stats.totalWarningsGiven : null
|
||||
const melodiesCount = Array.isArray(device?.device_melodies_all) ? device.device_melodies_all.length : null
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||
</svg>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-family-display)',
|
||||
fontSize: 'var(--font-size-md)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
letterSpacing: 'var(--tracking-tight)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}>
|
||||
Device Performance
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 'var(--space-3)' }}>
|
||||
|
||||
{/* System Warnings */}
|
||||
<MetricTile
|
||||
accent={totalWarnings !== null && totalWarnings > 0 ? 'var(--color-danger)' : 'var(--color-success)'}
|
||||
tag={totalWarnings !== null ? (totalWarnings === 0 ? 'Clean' : 'Flagged') : null}
|
||||
tagVariant={totalWarnings === 0 ? 'success' : 'danger'}
|
||||
value={totalWarnings !== null ? totalWarnings : '—'}
|
||||
label="System Warnings"
|
||||
icon={
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* On-board Melodies */}
|
||||
<MetricTile
|
||||
accent="var(--color-info)"
|
||||
tag={melodiesCount !== null ? 'Loaded' : null}
|
||||
tagVariant="info"
|
||||
value={melodiesCount !== null ? melodiesCount : '—'}
|
||||
label="On-board Melodies"
|
||||
icon={
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Firmware Version */}
|
||||
<MetricTile
|
||||
accent="var(--color-primary)"
|
||||
tag={device?.device_firmware_version ? 'Current' : null}
|
||||
tagVariant="neutral"
|
||||
value={device?.device_firmware_version ?? '—'}
|
||||
label="Firmware Version"
|
||||
icon={
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<rect x="9" y="9" width="6" height="6" />
|
||||
<path d="M15 2v2M9 2v2M15 20v2M9 20v2M20 9h2M20 14h2M2 9h2M2 14h2" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Total Melodies (favorites) */}
|
||||
<MetricTile
|
||||
accent="var(--color-warning)"
|
||||
tag={Array.isArray(device?.device_melodies_favorites) && device.device_melodies_favorites.length > 0 ? 'Saved' : null}
|
||||
tagVariant="warning"
|
||||
value={Array.isArray(device?.device_melodies_favorites) ? device.device_melodies_favorites.length : '—'}
|
||||
label="Favourite Melodies"
|
||||
icon={
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── WarrantyTab ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function WarrantyTab({
|
||||
device,
|
||||
canEdit,
|
||||
sub,
|
||||
stats,
|
||||
deviceUsers,
|
||||
onEditSubscription,
|
||||
onEditWarranty,
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
|
||||
|
||||
{/* Subscription hero — no section label above */}
|
||||
<SubscriptionHero
|
||||
sub={sub}
|
||||
canEdit={canEdit}
|
||||
onEditSubscription={onEditSubscription}
|
||||
deviceUsers={deviceUsers}
|
||||
/>
|
||||
|
||||
{/* Warranty card — no section label above */}
|
||||
<WarrantyCard stats={stats} canEdit={canEdit} onEditWarranty={onEditWarranty} />
|
||||
|
||||
{/* Masonry: Maintenance + Metrics side by side */}
|
||||
<div className="masonry-grid masonry-grid--2">
|
||||
<MaintenanceCard stats={stats} />
|
||||
<div style={{ ...GLASS, padding: 'var(--space-6)' }}>
|
||||
<PerformanceMetrics device={device} stats={stats} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// frontend/src/pages/bellcloud/devices/tabs/shared.jsx
|
||||
// Shared helpers, sub-components, and constants used across device tabs
|
||||
|
||||
import Button from '@/components/ui/Button'
|
||||
import Card from '@/components/ui/Card'
|
||||
|
||||
// ─── Date / time helpers ──────────────────────────────────────────────────────
|
||||
|
||||
export function parseFirestoreDate(str) {
|
||||
if (!str) return null
|
||||
if (typeof str === 'object') {
|
||||
const secs = str._seconds ?? str.seconds
|
||||
if (typeof secs === 'number') return new Date(secs * 1000)
|
||||
return null
|
||||
}
|
||||
const cleaned = str.replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+(\d{4})/, 'UTC')
|
||||
const d = new Date(cleaned)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
export function formatDate(str) {
|
||||
const d = parseFirestoreDate(str)
|
||||
if (!d) return str || '—'
|
||||
const day = d.getUTCDate().toString().padStart(2, '0')
|
||||
const month = (d.getUTCMonth() + 1).toString().padStart(2, '0')
|
||||
return `${day}-${month}-${d.getUTCFullYear()}`
|
||||
}
|
||||
|
||||
export function formatDateNice(d) {
|
||||
if (!d) return '—'
|
||||
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
||||
return `${d.getUTCDate()} ${months[d.getUTCMonth()]} ${d.getUTCFullYear()}`
|
||||
}
|
||||
|
||||
export function daysUntil(targetDate) {
|
||||
return Math.ceil((targetDate.getTime() - Date.now()) / 86400000)
|
||||
}
|
||||
|
||||
export function addDays(date, days) {
|
||||
return new Date(date.getTime() + days * 86400000)
|
||||
}
|
||||
|
||||
export function daysToDisplay(days) {
|
||||
if (days >= 365 && days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? 's' : ''} (${days} days)`
|
||||
if (days >= 30) {
|
||||
const months = Math.floor(days / 30)
|
||||
const rem = days % 30
|
||||
if (rem === 0) return `${months} month${months > 1 ? 's' : ''} (${days} days)`
|
||||
return `${days} days (~${months} month${months > 1 ? 's' : ''})`
|
||||
}
|
||||
return `${days} days`
|
||||
}
|
||||
|
||||
export function formatTimestamp(str) {
|
||||
if (!str) return '—'
|
||||
const d = parseFirestoreDate(str)
|
||||
if (d) return `${d.getUTCHours().toString().padStart(2, '0')}:${d.getUTCMinutes().toString().padStart(2, '0')}`
|
||||
const match = str.match(/(\d{1,2}):(\d{2})/)
|
||||
return match ? `${match[1].padStart(2, '0')}:${match[2]}` : str
|
||||
}
|
||||
|
||||
export function parseCoordinates(coordStr) {
|
||||
if (!coordStr) return null
|
||||
if (typeof coordStr === 'object') {
|
||||
const lat = parseFloat(coordStr.lat ?? coordStr.latitude)
|
||||
const lng = parseFloat(coordStr.lng ?? coordStr.longitude)
|
||||
if (!isNaN(lat) && !isNaN(lng)) return { lat, lng }
|
||||
return null
|
||||
}
|
||||
const numbers = coordStr.match(/-?\d+(?:\.\d+)?/g)
|
||||
if (numbers?.length >= 2) {
|
||||
let lat = parseFloat(numbers[0])
|
||||
let lng = parseFloat(numbers[1])
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
if (/\bS\b/.test(coordStr)) lat = -Math.abs(lat)
|
||||
if (/\bW\b/.test(coordStr)) lng = -Math.abs(lng)
|
||||
return { lat, lng }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function formatCoordinates(coords) {
|
||||
if (!coords) return '—'
|
||||
const latDir = coords.lat >= 0 ? 'N' : 'S'
|
||||
const lngDir = coords.lng >= 0 ? 'E' : 'W'
|
||||
return `${Math.abs(coords.lat).toFixed(6)}° ${latDir}, ${Math.abs(coords.lng).toFixed(6)}° ${lngDir}`
|
||||
}
|
||||
|
||||
export function formatLocale(v) {
|
||||
if (!v || v === 'all') return 'Global'
|
||||
if (v === 'orthodox') return 'Orthodox'
|
||||
if (v === 'catholic') return 'Catholic'
|
||||
return v
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const LOG_LEVEL_LABELS = { 0: 'Disabled', 1: 'Error', 2: 'Warning', 3: 'Info', 4: 'Debug', 5: 'Verbose' }
|
||||
export const LOG_LEVEL_VARIANTS = { 0: 'neutral', 1: 'danger', 2: 'warning', 3: 'info', 4: 'neutral', 5: 'neutral' }
|
||||
|
||||
// ─── Shared sub-components ────────────────────────────────────────────────────
|
||||
|
||||
export function Field({ label, children, mono = false }) {
|
||||
const isEmpty = children == null || children === ''
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
||||
<dt style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-medium)' }}>
|
||||
{label}
|
||||
</dt>
|
||||
<dd style={{
|
||||
fontSize: 'var(--font-size-base)',
|
||||
color: isEmpty ? 'var(--color-text-muted)' : 'var(--color-text-primary)',
|
||||
fontFamily: mono ? 'var(--font-family-mono)' : 'inherit',
|
||||
}}>
|
||||
{isEmpty ? '—' : children}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FieldGrid({ children, cols = 2 }) {
|
||||
return (
|
||||
<dl style={{ display: 'grid', gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, gap: 'var(--space-4)' }}>
|
||||
{children}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
export function SectionTitle({ title, onEdit, canEdit }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
|
||||
<h3 style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>
|
||||
{title}
|
||||
</h3>
|
||||
{canEdit && onEdit && (
|
||||
<Button variant="ghost" size="sm" onClick={onEdit}>Edit</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function KpiCard({ label, value, variant }) {
|
||||
const valueColor = variant === 'success'
|
||||
? 'var(--color-success)'
|
||||
: variant === 'danger'
|
||||
? 'var(--color-danger)'
|
||||
: variant === 'warning'
|
||||
? 'var(--color-warning)'
|
||||
: 'var(--color-text-primary)'
|
||||
return (
|
||||
<Card padding>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{label}</span>
|
||||
<span style={{ fontSize: 'var(--font-size-2xl)', fontWeight: 'var(--font-weight-bold)', letterSpacing: 'var(--tracking-display)', color: valueColor, lineHeight: 1, fontFamily: 'var(--font-family-display)' }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProgressBar({ value, variant = 'primary' }) {
|
||||
const width = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
|
||||
const color = variant === 'success' ? 'var(--color-success)' : variant === 'danger' ? 'var(--color-danger)' : variant === 'warning' ? 'var(--color-warning)' : 'var(--color-primary)'
|
||||
return (
|
||||
<div style={{ height: '6px', borderRadius: 'var(--radius-full)', backgroundColor: 'var(--color-bg-elevated)', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${width}%`, backgroundColor: color, borderRadius: 'var(--radius-full)', transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
// frontend/src/pages/melodies/MelodyComposer.jsx
|
||||
// Visual step-sequencer for building bell melodies.
|
||||
// Logic ported from _archive/melodies/MelodyComposer.jsx — no styling copied.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Card from '@/components/ui/Card'
|
||||
import DeployArchetypeModal from '@/modals/bellcloud/melodies/DeployArchetypeModal'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import api from '@/lib/api'
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_NOTES = 16
|
||||
const NOTE_LABELS = 'ABCDEFGHIJKLMNOP'
|
||||
|
||||
// ─── Pure helpers (logic only) ────────────────────────────────────────────────
|
||||
|
||||
function bellFrequency(bellNumber) {
|
||||
return 880 * Math.pow(2, -((bellNumber - 1) / 8))
|
||||
}
|
||||
|
||||
function stepToNotation(stepValue) {
|
||||
if (!stepValue) return '0'
|
||||
const active = []
|
||||
for (let bit = 0; bit < 16; bit++) {
|
||||
if (stepValue & (1 << bit)) active.push(bit + 1)
|
||||
}
|
||||
return active.join('+')
|
||||
}
|
||||
|
||||
function stepToHex(stepValue) {
|
||||
return `0x${(stepValue >>> 0).toString(16).toUpperCase().padStart(4, '0')}`
|
||||
}
|
||||
|
||||
function msToBpm(ms) {
|
||||
return Math.max(1, Math.round(60000 / Math.max(1, Number(ms) || 1)))
|
||||
}
|
||||
|
||||
function bpmToMs(bpm) {
|
||||
return Math.max(20, Math.round(60000 / Math.max(1, Number(bpm) || 1)))
|
||||
}
|
||||
|
||||
function interpolateHue(t) {
|
||||
const stops = [[0.0, 190], [0.24, 140], [0.5, 56], [0.82, 30], [1.0, 0]]
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
const [aPos, aHue] = stops[i]
|
||||
const [bPos, bHue] = stops[i + 1]
|
||||
if (t >= aPos && t <= bPos) {
|
||||
const local = (t - aPos) / (bPos - aPos || 1)
|
||||
return aHue + (bHue - aHue) * local
|
||||
}
|
||||
}
|
||||
return stops[stops.length - 1][1]
|
||||
}
|
||||
|
||||
function noteDotColor(noteNumber, palette) {
|
||||
const n = Number(noteNumber || 1)
|
||||
const custom = palette?.[n - 1]
|
||||
if (custom) return custom
|
||||
const t = Math.min(1, Math.max(0, (n - 1) / 15))
|
||||
return `hsl(${interpolateHue(t)}, 78%, 68%)`
|
||||
}
|
||||
|
||||
function noteDotGlow(noteNumber, palette) {
|
||||
const n = Number(noteNumber || 1)
|
||||
const custom = palette?.[n - 1]
|
||||
if (custom) return `${custom}66`
|
||||
const t = Math.min(1, Math.max(0, (n - 1) / 15))
|
||||
return `hsla(${interpolateHue(t)}, 78%, 56%, 0.45)`
|
||||
}
|
||||
|
||||
function playStep(audioCtx, stepValue, noteDurationMs) {
|
||||
if (!audioCtx) return
|
||||
const now = audioCtx.currentTime
|
||||
const duration = Math.max(10, noteDurationMs) / 1000
|
||||
const fadeIn = 0.005
|
||||
const fadeOut = Math.min(0.03, duration / 2)
|
||||
|
||||
for (let bit = 0; bit < 16; bit++) {
|
||||
if (stepValue & (1 << bit)) {
|
||||
const freq = bellFrequency(bit + 1)
|
||||
const osc = audioCtx.createOscillator()
|
||||
const gain = audioCtx.createGain()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(freq, now)
|
||||
gain.gain.setValueAtTime(0, now)
|
||||
gain.gain.linearRampToValueAtTime(0.3, now + fadeIn)
|
||||
gain.gain.setValueAtTime(0.3, now + Math.max(duration - fadeOut, fadeIn + 0.001))
|
||||
gain.gain.linearRampToValueAtTime(0, now + duration)
|
||||
osc.connect(gain)
|
||||
gain.connect(audioCtx.destination)
|
||||
osc.start(now)
|
||||
osc.stop(now + duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function csvToSteps(csv) {
|
||||
if (!csv?.trim()) return null
|
||||
return csv.trim().split(',').map((token) => {
|
||||
const parts = token.split('+')
|
||||
let val = 0
|
||||
for (const p of parts) {
|
||||
const n = parseInt(p.trim(), 10)
|
||||
if (!isNaN(n) && n >= 1 && n <= 16) val |= (1 << (n - 1))
|
||||
}
|
||||
return val
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function MelodyComposer() {
|
||||
const navigate = useNavigate()
|
||||
const { state: routeState } = useLocation()
|
||||
const { toast } = useToast()
|
||||
|
||||
const loadedArchetype = routeState?.archetype ?? null
|
||||
|
||||
// ── Initial state from archetype ──────────────────────────────────────────
|
||||
const initialSteps = () => {
|
||||
if (loadedArchetype?.steps) {
|
||||
const parsed = csvToSteps(loadedArchetype.steps)
|
||||
if (parsed?.length) return parsed
|
||||
}
|
||||
return Array.from({ length: 16 }, () => 0)
|
||||
}
|
||||
|
||||
const initialNoteCount = () => {
|
||||
if (loadedArchetype?.steps) {
|
||||
const parsed = csvToSteps(loadedArchetype.steps)
|
||||
if (parsed?.length) {
|
||||
let maxBit = 0
|
||||
for (const v of parsed) {
|
||||
for (let b = 15; b >= 0; b--) {
|
||||
if (v & (1 << b)) { maxBit = Math.max(maxBit, b + 1); break }
|
||||
}
|
||||
}
|
||||
return Math.max(8, maxBit)
|
||||
}
|
||||
}
|
||||
return 8
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
const [steps, setSteps] = useState(initialSteps)
|
||||
const [noteCount, setNoteCount] = useState(initialNoteCount)
|
||||
const [stepDelayMs, setStepDelayMs] = useState(280)
|
||||
const [noteDurationMs, setNoteDurationMs] = useState(110)
|
||||
const [measureEvery, setMeasureEvery] = useState(4)
|
||||
const [loopEnabled, setLoopEnabled] = useState(true)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentStep, setCurrentStep] = useState(-1)
|
||||
const [noteColors, setNoteColors] = useState([])
|
||||
const [stepMenuIndex, setStepMenuIndex] = useState(null)
|
||||
const [deployOpen, setDeployOpen] = useState(false)
|
||||
const [deployMode, setDeployMode] = useState('new')
|
||||
|
||||
// ── Refs ──────────────────────────────────────────────────────────────────
|
||||
const audioCtxRef = useRef(null)
|
||||
const playbackRef = useRef(null)
|
||||
const stepsRef = useRef(steps)
|
||||
const stepDelayRef = useRef(stepDelayMs)
|
||||
const noteDurationRef = useRef(noteDurationMs)
|
||||
const loopEnabledRef = useRef(loopEnabled)
|
||||
const stepMenuRef = useRef(null)
|
||||
|
||||
// Keep refs in sync
|
||||
useEffect(() => { stepsRef.current = steps }, [steps])
|
||||
useEffect(() => { stepDelayRef.current = stepDelayMs }, [stepDelayMs])
|
||||
useEffect(() => { noteDurationRef.current = noteDurationMs}, [noteDurationMs])
|
||||
useEffect(() => { loopEnabledRef.current = loopEnabled }, [loopEnabled])
|
||||
|
||||
// Close step context menu on outside click
|
||||
useEffect(() => {
|
||||
if (stepMenuIndex == null) return
|
||||
const onDocClick = (e) => {
|
||||
if (stepMenuRef.current && !stepMenuRef.current.contains(e.target)) {
|
||||
setStepMenuIndex(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDocClick)
|
||||
return () => document.removeEventListener('mousedown', onDocClick)
|
||||
}, [stepMenuIndex])
|
||||
|
||||
// Load custom note colours from melody settings
|
||||
useEffect(() => {
|
||||
let canceled = false
|
||||
api.get('/settings/melody')
|
||||
.then((s) => { if (!canceled) setNoteColors((s?.note_assignment_colors || []).slice(0, 16)) })
|
||||
.catch(() => { if (!canceled) setNoteColors([]) })
|
||||
return () => { canceled = true }
|
||||
}, [])
|
||||
|
||||
// ── Playback ──────────────────────────────────────────────────────────────
|
||||
const ensureAudioContext = useCallback(() => {
|
||||
if (!audioCtxRef.current || audioCtxRef.current.state === 'closed') {
|
||||
const AudioCtx = window.AudioContext || /** @type {any} */ (window).webkitAudioContext
|
||||
audioCtxRef.current = new AudioCtx()
|
||||
}
|
||||
if (audioCtxRef.current.state === 'suspended') audioCtxRef.current.resume()
|
||||
return audioCtxRef.current
|
||||
}, [])
|
||||
|
||||
const stopPlayback = useCallback(() => {
|
||||
if (playbackRef.current?.timer) clearTimeout(playbackRef.current.timer)
|
||||
playbackRef.current = null
|
||||
setIsPlaying(false)
|
||||
setCurrentStep(-1)
|
||||
}, [])
|
||||
|
||||
const scheduleStep = useCallback((stepIndex) => {
|
||||
const currentSteps = stepsRef.current
|
||||
if (!currentSteps.length) { stopPlayback(); return }
|
||||
|
||||
const ctx = ensureAudioContext()
|
||||
const nextIndex = stepIndex % currentSteps.length
|
||||
setCurrentStep(nextIndex)
|
||||
playStep(ctx, currentSteps[nextIndex], noteDurationRef.current)
|
||||
|
||||
const isLastStep = nextIndex >= currentSteps.length - 1
|
||||
const shouldContinue = !isLastStep || loopEnabledRef.current
|
||||
|
||||
if (!shouldContinue) {
|
||||
playbackRef.current = { timer: setTimeout(() => stopPlayback(), stepDelayRef.current) }
|
||||
return
|
||||
}
|
||||
playbackRef.current = {
|
||||
timer: setTimeout(() => scheduleStep(isLastStep ? 0 : nextIndex + 1), stepDelayRef.current),
|
||||
}
|
||||
}, [ensureAudioContext, stopPlayback])
|
||||
|
||||
useEffect(() => () => stopPlayback(), [stopPlayback])
|
||||
|
||||
const handlePlay = () => {
|
||||
if (!stepsRef.current.length) return
|
||||
setIsPlaying(true)
|
||||
scheduleStep(0)
|
||||
}
|
||||
|
||||
// ── Grid editing ──────────────────────────────────────────────────────────
|
||||
const toggleCell = (noteIndex, stepIndex) => {
|
||||
const bit = 1 << noteIndex
|
||||
setSteps((prev) => {
|
||||
const next = [...prev]
|
||||
next[stepIndex] = (next[stepIndex] || 0) ^ bit
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const addStep = () => setSteps((prev) => [...prev, 0])
|
||||
const removeStep = () => setSteps((prev) => {
|
||||
if (prev.length <= 1) return prev
|
||||
const next = prev.slice(0, prev.length - 1)
|
||||
if (currentStep >= next.length) setCurrentStep(next.length - 1)
|
||||
return next
|
||||
})
|
||||
const addNote = () => setNoteCount((prev) => Math.min(MAX_NOTES, prev + 1))
|
||||
const removeNote = () => setNoteCount((prev) => {
|
||||
if (prev <= 1) return prev
|
||||
const next = prev - 1
|
||||
setSteps((s) => s.map((v) => v & ((1 << next) - 1)))
|
||||
return next
|
||||
})
|
||||
const clearAll = () => setSteps((prev) => prev.map(() => 0))
|
||||
const insertStepAt = (index) => {
|
||||
setSteps((prev) => { const n = [...prev]; n.splice(index, 0, 0); return n })
|
||||
setCurrentStep((prev) => (prev >= index ? prev + 1 : prev))
|
||||
}
|
||||
const deleteStepAt = (index) => {
|
||||
setSteps((prev) => {
|
||||
if (prev.length <= 1) return prev
|
||||
const n = [...prev]; n.splice(index, 1); return n
|
||||
})
|
||||
setCurrentStep((prev) => {
|
||||
if (prev < 0) return prev
|
||||
if (prev === index) return -1
|
||||
if (prev > index) return prev - 1
|
||||
return prev
|
||||
})
|
||||
}
|
||||
|
||||
// ── Deploy ────────────────────────────────────────────────────────────────
|
||||
const openDeploy = (mode) => { setDeployMode(mode); setDeployOpen(true) }
|
||||
|
||||
const handleDeploySuccess = (result, mode) => {
|
||||
setDeployOpen(false)
|
||||
toast.success(
|
||||
mode === 'update' ? 'Archetype Updated' : 'Archetype Deployed',
|
||||
`"${result.name}" saved successfully.`,
|
||||
)
|
||||
if (result?.id) navigate(`/melodies/archetypes/${result.id}`)
|
||||
}
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────────────────
|
||||
const speedBpm = msToBpm(stepDelayMs)
|
||||
|
||||
const activeBells = useMemo(() => {
|
||||
if (currentStep < 0 || !steps[currentStep]) return []
|
||||
const active = []
|
||||
for (let bit = 0; bit < noteCount; bit++) {
|
||||
if (steps[currentStep] & (1 << bit)) active.push(bit + 1)
|
||||
}
|
||||
return active
|
||||
}, [currentStep, noteCount, steps])
|
||||
|
||||
const csvOutput = `{${steps.map(stepToNotation).join(',')}}`
|
||||
const progmemOutput = `const uint16_t PROGMEM melody_builtin_custom[] = {\n ${steps.map(stepToHex).join(', ')}\n};`
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
|
||||
{/* ── Page header ─────────────────────────────────────────────────── */}
|
||||
<PageHeader
|
||||
title="Melody Composer"
|
||||
subtitle="Build bell-step melodies visually. Notes map to bell numbers (1–16)."
|
||||
>
|
||||
{loadedArchetype ? (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => openDeploy('new')}>Deploy as New</Button>
|
||||
<Button variant="primary" onClick={() => openDeploy('update')}>Update Archetype</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="primary" onClick={() => openDeploy('new')}>Deploy Archetype</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
{/* ── Editing-archetype banner ─────────────────────────────────────── */}
|
||||
{loadedArchetype && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-3)',
|
||||
backgroundColor: 'var(--color-primary-subtle)',
|
||||
border: '1px solid var(--color-primary)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
color: 'var(--color-primary)',
|
||||
}}>
|
||||
Editing Archetype
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
|
||||
{loadedArchetype.name}
|
||||
</span>
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs)' }}>·</span>
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
{loadedArchetype.id}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
style={{ marginLeft: 'auto' }}
|
||||
onClick={() => navigate('/melodies/composer', { replace: true, state: null })}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Transport + controls ─────────────────────────────────────────── */}
|
||||
<Card padding={false}>
|
||||
<div style={{ padding: 'var(--space-6) var(--space-8)', display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Row 1 — step/note controls + status + deploy */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||
{/* Step controls */}
|
||||
<div style={{ display: 'flex', gap: 'var(--space-1)', alignItems: 'center' }}>
|
||||
<Button variant="secondary" onClick={addStep}>+ Step</Button>
|
||||
<Button variant="secondary" onClick={removeStep}>− Step</Button>
|
||||
</div>
|
||||
|
||||
<span style={{ color: 'var(--color-border-strong)', fontSize: 'var(--font-size-sm)' }}>|</span>
|
||||
|
||||
{/* Note controls */}
|
||||
<div style={{ display: 'flex', gap: 'var(--space-1)', alignItems: 'center' }}>
|
||||
<Button variant="secondary" onClick={addNote} disabled={noteCount >= MAX_NOTES}>+ Note</Button>
|
||||
<Button variant="secondary" onClick={removeNote} disabled={noteCount <= 1}>− Note</Button>
|
||||
</div>
|
||||
|
||||
<span style={{ color: 'var(--color-border-strong)', fontSize: 'var(--font-size-sm)' }}>|</span>
|
||||
|
||||
<Button variant="danger" onClick={clearAll}>Clear All</Button>
|
||||
|
||||
{/* Status readout */}
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
|
||||
{currentStep >= 0
|
||||
? `Step ${currentStep + 1}/${steps.length}${activeBells.length ? ` · bells ${activeBells.join(', ')}` : ' · silence'}`
|
||||
: `${steps.length} steps · ${noteCount} notes`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ height: '1px', backgroundColor: 'var(--color-border)', margin: 'var(--space-4) 0' }} />
|
||||
|
||||
{/* Row 2 — playback + sliders */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 'var(--space-8)' }}>
|
||||
{/* Play / Stop + Loop */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||
{isPlaying ? (
|
||||
<Button variant="danger" size="md" onClick={stopPlayback}
|
||||
icon={<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><rect x="2" y="2" width="4" height="10" rx="1"/><rect x="8" y="2" width="4" height="10" rx="1"/></svg>}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="success" size="md" onClick={handlePlay}
|
||||
icon={<svg width="12" height="14" viewBox="0 0 12 14" fill="currentColor"><path d="M0 0l12 7-12 7V0z"/></svg>}
|
||||
>
|
||||
Play
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={loopEnabled ? 'success' : 'ghost'}
|
||||
onClick={() => setLoopEnabled((v) => !v)}
|
||||
>
|
||||
Loop
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<span style={{ color: 'var(--color-border-strong)', fontSize: 'var(--font-size-sm)', alignSelf: 'center' }}>|</span>
|
||||
|
||||
{/* BPM slider */}
|
||||
<SliderControl
|
||||
label="Speed"
|
||||
valueLabel={`${speedBpm} bpm`}
|
||||
secondaryLabel={`${stepDelayMs} ms`}
|
||||
min={30} max={240} step={1}
|
||||
value={speedBpm}
|
||||
onChange={(v) => setStepDelayMs(bpmToMs(v))}
|
||||
style={{ minWidth: 160, flex: '2 1 160px' }}
|
||||
/>
|
||||
|
||||
{/* Duration slider */}
|
||||
<SliderControl
|
||||
label="Note Duration"
|
||||
valueLabel={`${noteDurationMs} ms`}
|
||||
min={20} max={500} step={10}
|
||||
value={noteDurationMs}
|
||||
onChange={(v) => setNoteDurationMs(v)}
|
||||
style={{ minWidth: 140, flex: '2 1 140px' }}
|
||||
/>
|
||||
|
||||
{/* Measure slider */}
|
||||
<SliderControl
|
||||
label="Measure"
|
||||
valueLabel={`every ${measureEvery}`}
|
||||
min={1} max={16} step={1}
|
||||
value={measureEvery}
|
||||
onChange={(v) => setMeasureEvery(v)}
|
||||
style={{ minWidth: 100, flex: '1 1 100px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ── Step-sequencer grid ──────────────────────────────────────────── */}
|
||||
<Card padding={false}>
|
||||
<div style={{ overflowX: 'auto', padding: 'var(--space-4)' }}>
|
||||
<table style={{ borderCollapse: 'separate', borderSpacing: 0, width: 'max-content' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{/* Corner cell */}
|
||||
<th style={{
|
||||
position: 'sticky', top: 0, left: 0, zIndex: 30,
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
borderRight: '1px solid var(--color-border)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
textAlign: 'center',
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
Note / Step
|
||||
</th>
|
||||
|
||||
{/* Step headers */}
|
||||
{steps.map((_, stepIndex) => {
|
||||
const isCurrent = stepIndex === currentStep
|
||||
const measureHit = measureEvery > 0 && (stepIndex + 1) % measureEvery === 0
|
||||
const measureOdd = measureEvery > 0 && Math.floor(stepIndex / measureEvery) % 2 === 1
|
||||
|
||||
return (
|
||||
<th
|
||||
key={stepIndex}
|
||||
style={{
|
||||
position: 'sticky', top: 0, zIndex: 20,
|
||||
minWidth: 44, width: 44,
|
||||
padding: 0,
|
||||
backgroundColor: isCurrent
|
||||
? 'rgba(74,222,128,0.18)'
|
||||
: measureOdd
|
||||
? 'rgba(192,193,255,0.03)'
|
||||
: 'var(--color-bg-surface)',
|
||||
borderBottom: isCurrent
|
||||
? '2px solid var(--color-success)'
|
||||
: '1px solid var(--color-border)',
|
||||
borderRight: measureHit
|
||||
? '2px solid var(--color-border-strong)'
|
||||
: '1px solid var(--color-border)',
|
||||
color: isCurrent ? 'var(--color-success)' : 'var(--color-text-muted)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
}}
|
||||
>
|
||||
{/* Clickable step number — opens context menu */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Step ${stepIndex + 1} options`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setStepMenuIndex((prev) => (prev === stepIndex ? null : stepIndex))
|
||||
}}
|
||||
style={{
|
||||
display: 'block', width: '100%',
|
||||
padding: 'var(--space-2) var(--space-1)',
|
||||
background: 'transparent', border: 'none',
|
||||
color: 'inherit', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 'inherit',
|
||||
fontWeight: 'inherit',
|
||||
}}
|
||||
>
|
||||
{stepIndex + 1}
|
||||
</button>
|
||||
|
||||
{/* Context menu */}
|
||||
{stepMenuIndex === stepIndex && (
|
||||
<div
|
||||
ref={stepMenuRef}
|
||||
style={{
|
||||
position: 'absolute', top: '100%', right: 0,
|
||||
zIndex: 50, width: 112,
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border-strong)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: 'Add After', action: () => { insertStepAt(stepIndex + 1); setStepMenuIndex(null) }, danger: false },
|
||||
{ label: 'Add Before', action: () => { insertStepAt(stepIndex); setStepMenuIndex(null) }, danger: false },
|
||||
{ label: 'Delete', action: () => { deleteStepAt(stepIndex); setStepMenuIndex(null) }, danger: true },
|
||||
].map(({ label, action, danger }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={action}
|
||||
style={{
|
||||
display: 'block', width: '100%',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
background: 'transparent', border: 'none',
|
||||
textAlign: 'left',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
color: danger ? 'var(--color-danger)' : 'var(--color-text-primary)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--color-bg-island)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent'
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{Array.from({ length: noteCount }, (_, noteIndex) => (
|
||||
<tr key={noteIndex}>
|
||||
{/* Note label */}
|
||||
<th style={{
|
||||
position: 'sticky', left: 0, zIndex: 10,
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
borderRight: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
textAlign: 'center',
|
||||
userSelect: 'none',
|
||||
width: 48,
|
||||
}}>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 20, height: 20,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: `${noteDotColor(noteIndex + 1, noteColors)}22`,
|
||||
color: noteDotColor(noteIndex + 1, noteColors),
|
||||
}}>
|
||||
{NOTE_LABELS[noteIndex]}
|
||||
</span>
|
||||
</th>
|
||||
|
||||
{/* Step cells */}
|
||||
{steps.map((stepValue, stepIndex) => {
|
||||
const enabled = Boolean(stepValue & (1 << noteIndex))
|
||||
const isCurrent = stepIndex === currentStep
|
||||
const measureHit = measureEvery > 0 && (stepIndex + 1) % measureEvery === 0
|
||||
const measureOdd = measureEvery > 0 && Math.floor(stepIndex / measureEvery) % 2 === 1
|
||||
const dotColor = noteDotColor(noteIndex + 1, noteColors)
|
||||
const dotGlow = noteDotGlow(noteIndex + 1, noteColors)
|
||||
|
||||
return (
|
||||
<td
|
||||
key={`${noteIndex}-${stepIndex}`}
|
||||
style={{
|
||||
width: 44, height: 44,
|
||||
padding: 0,
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
borderRight: measureHit ? '2px solid var(--color-border-strong)' : '1px solid var(--color-border)',
|
||||
backgroundColor: isCurrent
|
||||
? 'rgba(74,222,128,0.06)'
|
||||
: measureOdd
|
||||
? 'rgba(192,193,255,0.015)'
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Toggle note ${noteIndex + 1} on step ${stepIndex + 1}`}
|
||||
aria-pressed={enabled}
|
||||
onClick={() => toggleCell(noteIndex, stepIndex)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '100%', height: '100%',
|
||||
background: 'transparent', border: 'none',
|
||||
cursor: 'pointer', outline: 'none',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '52%', height: '52%',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: dotColor,
|
||||
opacity: enabled ? 1 : 0,
|
||||
transform: enabled ? 'scale(1)' : 'scale(0.35)',
|
||||
boxShadow: enabled ? `0 0 10px 3px ${dotGlow}` : 'none',
|
||||
transition: 'opacity 120ms ease, transform 120ms ease, box-shadow 160ms ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ── Output snippets ──────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 'var(--space-6)' }}>
|
||||
<Card title="CSV Notation" subtitle="Paste into melody step field">
|
||||
<pre style={{
|
||||
margin: 0,
|
||||
padding: 'var(--space-3)',
|
||||
backgroundColor: 'var(--color-bg-abyss)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
color: 'var(--color-text-primary)',
|
||||
overflowX: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}>
|
||||
{csvOutput}
|
||||
</pre>
|
||||
</Card>
|
||||
|
||||
<Card title="PROGMEM Values" subtitle="Copy into firmware melody array">
|
||||
<pre style={{
|
||||
margin: 0,
|
||||
padding: 'var(--space-3)',
|
||||
backgroundColor: 'var(--color-bg-abyss)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
color: 'var(--color-text-primary)',
|
||||
overflowX: 'auto',
|
||||
whiteSpace: 'pre',
|
||||
}}>
|
||||
{progmemOutput}
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── Deploy modal ─────────────────────────────────────────────────── */}
|
||||
<DeployArchetypeModal
|
||||
open={deployOpen}
|
||||
onClose={() => setDeployOpen(false)}
|
||||
onSuccess={handleDeploySuccess}
|
||||
mode={deployMode}
|
||||
archetype={loadedArchetype}
|
||||
stepsStr={steps.map(stepToNotation).join(',')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── SliderControl sub-component ─────────────────────────────────────────────
|
||||
// Inline because it's only used here; not a reusable design system component.
|
||||
|
||||
function SliderControl({ label, valueLabel, secondaryLabel, min, max, step, value, onChange, style }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)', ...style }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 'var(--space-2)' }}>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-secondary)', letterSpacing: 'var(--tracking-wide)', textTransform: 'uppercase' }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'baseline', gap: 'var(--space-2)' }}>
|
||||
{secondaryLabel && (
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
|
||||
{secondaryLabel}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-primary)', fontFamily: 'var(--font-family-mono)' }}>
|
||||
{valueLabel}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
style={{ width: '100%', accentColor: 'var(--color-primary)', cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
// frontend/src/pages/melodies/MelodyDetail.jsx
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import StatusBadge from '@/components/ui/StatusBadge'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import api from '@/lib/api'
|
||||
import {
|
||||
getLocalizedValue,
|
||||
getLanguageName,
|
||||
normalizeColor,
|
||||
formatDuration,
|
||||
} from '@/lib/melodyUtils'
|
||||
import RowActions from '@/components/ui/RowActions'
|
||||
import PillButton from '@/components/ui/PillButton'
|
||||
import SpeedCalculatorModal from '@/modals/bellcloud/melodies/SpeedCalculatorModal'
|
||||
import SpeedIcon from '@/assets/global-icons/speed.svg?react'
|
||||
import PlaybackModal from '@/modals/bellcloud/melodies/PlaybackModal'
|
||||
import BinaryTableModal from '@/modals/bellcloud/melodies/BinaryTableModal'
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBpm(ms) {
|
||||
const v = Number(ms)
|
||||
if (!v || v <= 0) return null
|
||||
return Math.round(60000 / v)
|
||||
}
|
||||
|
||||
function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) {
|
||||
const p = Math.max(0, Math.min(100, Number(percent || 0)))
|
||||
const t = p / 100
|
||||
const a = Number(minSpeed)
|
||||
const b = Number(maxSpeed)
|
||||
if (a <= 0 || b <= 0) return Math.round(a + (b - a) * t)
|
||||
return Math.round(a * Math.pow(b / a, t))
|
||||
}
|
||||
|
||||
function normalizeFileUrl(url) {
|
||||
if (!url || typeof url !== 'string') return null
|
||||
if (url.startsWith('http') || url.startsWith('/api')) return url
|
||||
if (url.startsWith('/')) return `/api${url}`
|
||||
return `/api/${url}`
|
||||
}
|
||||
|
||||
function copyText(text, onSuccess) {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).then(onSuccess).catch(() => fallbackCopy(text, onSuccess))
|
||||
} else {
|
||||
fallbackCopy(text, onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text, onSuccess) {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0'
|
||||
document.body.appendChild(ta)
|
||||
ta.focus(); ta.select()
|
||||
try { document.execCommand('copy'); onSuccess?.() } catch (_) {}
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
|
||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
function DetailField({ label, children, span = 1 }) {
|
||||
return (
|
||||
<div style={{ gridColumn: `span ${span}` }}>
|
||||
<div style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', color: 'var(--color-text-more-muted)', marginBottom: 'var(--space-1)' }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>
|
||||
{children ?? <span style={{ color: 'var(--color-text-muted)' }}>—</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MonoField({ label, value, span = 1 }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
if (!value) return <DetailField label={label} span={span} />
|
||||
return (
|
||||
<div style={{ gridColumn: `span ${span}` }}>
|
||||
<div style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', color: 'var(--color-text-more-muted)', marginBottom: 'var(--space-1)' }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
||||
{value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(value, () => { setCopied(true); setTimeout(() => setCopied(false), 2000) })}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
fontSize: '10px',
|
||||
padding: '1px var(--space-2)',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
backgroundColor: copied ? 'var(--color-success-bg)' : 'var(--color-bg-island)',
|
||||
color: copied ? 'var(--color-success)' : 'var(--color-text-muted)',
|
||||
border: `1px solid ${copied ? 'rgba(74,222,128,0.3)' : 'var(--color-border)'}`,
|
||||
cursor: 'pointer',
|
||||
transition: 'background 120ms, color 120ms',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function MelodyDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { toast } = useToast()
|
||||
const { hasPermission } = useAuth()
|
||||
const canEdit = hasPermission('melodies', 'edit')
|
||||
|
||||
const [melody, setMelody] = useState(null)
|
||||
const [files, setFiles] = useState({})
|
||||
const [builtMelody, setBuiltMelody] = useState(null)
|
||||
const [melodySettings, setMelodySettings] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [displayLang, setDisplayLang] = useState('en')
|
||||
|
||||
// Modal state
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [showUnpublish, setShowUnpublish] = useState(false)
|
||||
const [showSpeedCalc, setShowSpeedCalc] = useState(false)
|
||||
const [showPlayback, setShowPlayback] = useState(false)
|
||||
const [showBinaryView, setShowBinaryView] = useState(false)
|
||||
const [codeCopied, setCodeCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/settings/melody').then((ms) => {
|
||||
setMelodySettings(ms)
|
||||
setDisplayLang(ms.primary_language || 'en')
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadData() }, [id])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [m, f] = await Promise.all([
|
||||
api.get(`/melodies/${id}`),
|
||||
api.get(`/melodies/${id}/files`),
|
||||
])
|
||||
setMelody(m)
|
||||
setFiles(f)
|
||||
try {
|
||||
const bm = await api.get(`/builder/melodies/for-melody/${id}`)
|
||||
setBuiltMelody(bm || null)
|
||||
} catch { setBuiltMelody(null) }
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load melody.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await api.delete(`/melodies/${id}`)
|
||||
toast.success('Deleted', 'Melody removed.')
|
||||
navigate('/melodies')
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message)
|
||||
setShowDelete(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePublish = async () => {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
await api.post(`/melodies/${id}/publish`)
|
||||
await loadData()
|
||||
toast.success('Published', 'Melody is now live.')
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message)
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnpublish = async () => {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
await api.post(`/melodies/${id}/unpublish`)
|
||||
setShowUnpublish(false)
|
||||
await loadData()
|
||||
toast.success('Unpublished', 'Melody reverted to draft.')
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message)
|
||||
setShowUnpublish(false)
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loading / error states ────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<PageHeader title="Melody" />
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-16)' }}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !melody) {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<PageHeader title="Melody" />
|
||||
<div style={{ padding: 'var(--space-4)', backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', borderRadius: 'var(--radius-md)', color: 'var(--color-danger)', fontSize: 'var(--font-size-sm)' }}>
|
||||
{error || 'Melody not found.'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Derived values ────────────────────────────────────────────────────────
|
||||
|
||||
const info = melody.information || {}
|
||||
const settings = melody.default_settings || {}
|
||||
const languages = melodySettings?.available_languages || ['en']
|
||||
const displayName = getLocalizedValue(info.name, displayLang, 'Untitled Melody')
|
||||
const isPublished = melody.status === 'published'
|
||||
const speedMs = mapPercentageToStepDelay(settings.speed, info.minSpeed, info.maxSpeed)
|
||||
const speedBpm = formatBpm(speedMs)
|
||||
const minBpm = formatBpm(info.minSpeed)
|
||||
const maxBpm = formatBpm(info.maxSpeed)
|
||||
const missingArchetype = Boolean(melody.pid) && !builtMelody?.id
|
||||
|
||||
const binaryUrl = normalizeFileUrl(
|
||||
(builtMelody?.binary_url ? `/api${builtMelody.binary_url}` : null) ||
|
||||
melody.url ||
|
||||
files.binary_url ||
|
||||
null
|
||||
)
|
||||
const binaryPid = builtMelody?.pid || melody.pid || 'binary'
|
||||
const binaryFilename = `${binaryPid}.bsm`
|
||||
|
||||
const handleBinaryDownload = async (e) => {
|
||||
e.preventDefault()
|
||||
const endpoint = `/api/melodies/${melody.id}/download/binary`
|
||||
try {
|
||||
const token = localStorage.getItem('access_token')
|
||||
const res = await fetch(endpoint, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
|
||||
if (!res.ok) throw new Error(res.statusText)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a'); a.href = url; a.download = binaryFilename; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
if (binaryUrl) { const a = document.createElement('a'); a.href = binaryUrl; a.download = binaryFilename; a.rel = 'noopener noreferrer'; document.body.appendChild(a); a.click(); document.body.removeChild(a) }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
|
||||
{/* ── Header ──────────────────────────────────────────────────────── */}
|
||||
<PageHeader
|
||||
title={
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||
{displayName}
|
||||
<StatusBadge variant={isPublished ? 'success' : 'neutral'}>
|
||||
{isPublished ? 'Live' : 'Draft'}
|
||||
</StatusBadge>
|
||||
{languages.length > 1 && (
|
||||
<PillButton
|
||||
variant="neutral"
|
||||
value={displayLang}
|
||||
options={languages.map((l) => ({ label: getLanguageName(l), value: l }))}
|
||||
onChange={(val) => setDisplayLang(val)}
|
||||
>
|
||||
{getLanguageName(displayLang)}
|
||||
</PillButton>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
|
||||
{canEdit && (
|
||||
<>
|
||||
{/* Playback — success icon-only */}
|
||||
<Button
|
||||
variant="success"
|
||||
size="md"
|
||||
aria-label="Playback"
|
||||
onClick={() => setShowPlayback(true)}
|
||||
icon={
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style={{ transform: 'scale(1.150)', transformOrigin: 'center' }}>
|
||||
<path d="M5 3l14 9-14 9V3z"/>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Speed Calc — info icon-only (rabbit) */}
|
||||
<Button
|
||||
variant="info"
|
||||
size="md"
|
||||
aria-label="Speed Calculator"
|
||||
onClick={() => setShowSpeedCalc(true)}
|
||||
icon={
|
||||
<SpeedIcon width="16" height="16" aria-hidden="true" style={{ transform: 'scale(1.25)', transformOrigin: 'center' }} />
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Actions dropdown — Edit, Publish/Unpublish, Delete */}
|
||||
<RowActions
|
||||
variant="primary"
|
||||
size="md"
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>,
|
||||
onClick: () => navigate(`/melodies/${id}/edit`),
|
||||
},
|
||||
isPublished
|
||||
? {
|
||||
label: 'Unpublish',
|
||||
icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>,
|
||||
color: 'var(--color-warning)',
|
||||
onClick: () => setShowUnpublish(true),
|
||||
}
|
||||
: {
|
||||
label: 'Publish',
|
||||
icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2v20M2 12h20"/></svg>,
|
||||
color: 'var(--color-success)',
|
||||
onClick: handlePublish,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>,
|
||||
color: 'var(--color-danger)',
|
||||
divider: true,
|
||||
onClick: () => setShowDelete(true),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
{/* ── Outdated archetype warning ───────────────────────────────────── */}
|
||||
{info.outdated_archetype && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-3)',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
backgroundColor: 'var(--color-warning-bg)',
|
||||
border: '1px solid var(--color-warning)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
color: 'var(--color-warning)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
}}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, marginTop: 2 }}>
|
||||
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
<span>
|
||||
<strong>Outdated Archetype</strong> — The archetype assigned to this melody has changed or been removed. Re-assign one in the editor to clear this warning.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Main grid — masonry layout ──────────────────────────────────── */}
|
||||
<div className="masonry-grid masonry-grid--2">
|
||||
|
||||
{/* Melody Information — masonry item 1 */}
|
||||
<Card title="Melody Information">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 'var(--space-4)' }}>
|
||||
{/* Row 1: Color | Tone | Steps */}
|
||||
<DetailField label="Color" span={1}>
|
||||
{info.color ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||
<span style={{ width: 16, height: 16, borderRadius: 'var(--radius-sm)', backgroundColor: normalizeColor(info.color), border: '1px solid var(--color-border)', display: 'inline-block', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)' }}>{info.color}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</DetailField>
|
||||
<DetailField label="Tone">
|
||||
<span style={{ textTransform: 'capitalize' }}>{info.melodyTone ?? '—'}</span>
|
||||
</DetailField>
|
||||
<DetailField label="Steps">{info.steps}</DetailField>
|
||||
|
||||
{/* Row 2: Min Speed | Max Speed | Unique Bells */}
|
||||
<DetailField label="Min Speed">
|
||||
{info.minSpeed ? (
|
||||
<span>{minBpm} bpm <span style={{ color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs)' }}>· {info.minSpeed} ms</span></span>
|
||||
) : null}
|
||||
</DetailField>
|
||||
<DetailField label="Max Speed">
|
||||
{info.maxSpeed ? (
|
||||
<span>{maxBpm} bpm <span style={{ color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs)' }}>· {info.maxSpeed} ms</span></span>
|
||||
) : null}
|
||||
</DetailField>
|
||||
<DetailField label="Unique Bells">{info.totalActiveBells}</DetailField>
|
||||
|
||||
{/* Row 3: True Ring | Type | Tags */}
|
||||
<DetailField label="True Ring">
|
||||
<StatusBadge variant={info.isTrueRing ? 'success' : 'neutral'}>{info.isTrueRing ? 'Yes' : 'No'}</StatusBadge>
|
||||
</DetailField>
|
||||
<DetailField label="Type">
|
||||
<span style={{ textTransform: 'capitalize' }}>{melody.type ?? '—'}</span>
|
||||
</DetailField>
|
||||
{info.customTags?.length > 0 ? (
|
||||
<DetailField label="Tags">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-1)', marginTop: 'var(--space-1)' }}>
|
||||
{info.customTags.map((tag) => (
|
||||
<span key={tag} style={{
|
||||
fontSize: 'var(--font-size-xs)', padding: '2px var(--space-2)',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
backgroundColor: 'var(--color-primary-subtle)',
|
||||
color: 'var(--color-primary)',
|
||||
border: '1px solid var(--color-primary)',
|
||||
}}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</DetailField>
|
||||
) : (
|
||||
<DetailField label="Tags">
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>—</span>
|
||||
</DetailField>
|
||||
)}
|
||||
|
||||
{/* Row 4: Description (full width) */}
|
||||
<DetailField label="Description" span={3}>
|
||||
{getLocalizedValue(info.description, displayLang) || null}
|
||||
</DetailField>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Files — masonry item 2 */}
|
||||
<Card title="Files">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
|
||||
{/* Binary */}
|
||||
<div>
|
||||
<div style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', color: 'var(--color-text-more-muted)', marginBottom: 'var(--space-2)' }}>
|
||||
Binary File
|
||||
</div>
|
||||
{binaryUrl ? (
|
||||
<div>
|
||||
{/* Main row: name + pills + note count */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>
|
||||
{builtMelody?.name || binaryFilename}
|
||||
</span>
|
||||
{missingArchetype && (
|
||||
<span title="This binary does not exist in the Archetypes" style={{ color: 'var(--color-warning)', fontSize: 'var(--font-size-xs)', cursor: 'help' }}>⚠ Missing</span>
|
||||
)}
|
||||
{/* Download — blue pill */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBinaryDownload}
|
||||
style={{
|
||||
padding: '2px 10px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
color: 'var(--color-primary)',
|
||||
backgroundColor: 'var(--color-primary-subtle)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
{/* View — neutral pill */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBinaryView(true)}
|
||||
style={{
|
||||
padding: '2px 10px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
{info.totalNotes ?? 0} active notes
|
||||
</span>
|
||||
</div>
|
||||
{/* Subtitle: file: archetypename.bsm */}
|
||||
<div style={{ marginTop: 'var(--space-1)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
|
||||
file: {binaryFilename}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>Not uploaded</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Audio preview */}
|
||||
<div>
|
||||
<div style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', color: 'var(--color-text-more-muted)', marginBottom: 'var(--space-2)' }}>
|
||||
Audio Preview
|
||||
</div>
|
||||
{normalizeFileUrl(files.preview_url) ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||
<audio controls src={normalizeFileUrl(files.preview_url)} style={{ height: 32, width: '100%' }} />
|
||||
<a href={normalizeFileUrl(files.preview_url)} target="_blank" rel="noopener noreferrer"
|
||||
style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-primary)', textDecoration: 'underline' }}>
|
||||
Download preview
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>Not uploaded</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Default Settings — masonry item 3 */}
|
||||
<Card title="Default Settings">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 'var(--space-4)' }}>
|
||||
{/* Row 1: Speed | Duration | Total Run Duration */}
|
||||
<DetailField label="Speed">
|
||||
{settings.speed != null ? (
|
||||
<span>
|
||||
{settings.speed}%
|
||||
{speedBpm && <span style={{ color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs)' }}> · {speedBpm} bpm</span>}
|
||||
{speedMs && <span style={{ color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs)' }}> · {speedMs} ms</span>}
|
||||
</span>
|
||||
) : null}
|
||||
</DetailField>
|
||||
<DetailField label="Duration">{formatDuration(settings.duration ?? 0)}</DetailField>
|
||||
<DetailField label="Total Run Duration">{settings.totalRunDuration}</DetailField>
|
||||
|
||||
{/* Row 2: Pause Duration | Infinite Loop */}
|
||||
<DetailField label="Pause Duration">{settings.pauseDuration}</DetailField>
|
||||
<DetailField label="Infinite Loop">
|
||||
<StatusBadge variant={settings.infiniteLoop ? 'success' : 'neutral'}>{settings.infiniteLoop ? 'Yes' : 'No'}</StatusBadge>
|
||||
</DetailField>
|
||||
|
||||
{/* Row 3: Echo Ring */}
|
||||
<DetailField label="Echo Ring">
|
||||
{settings.echoRing?.length ? (
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)' }}>
|
||||
{settings.echoRing.join(', ')}
|
||||
</span>
|
||||
) : null}
|
||||
</DetailField>
|
||||
</div>
|
||||
|
||||
{/* Note assignments */}
|
||||
{settings.noteAssignments?.length > 0 && (
|
||||
<div style={{ marginTop: 'var(--space-4)', paddingTop: 'var(--space-4)', borderTop: '1px solid var(--color-border)' }}>
|
||||
<div style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', color: 'var(--color-text-more-muted)', marginBottom: 'var(--space-2)' }}>
|
||||
Note Assignments
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-1)' }}>
|
||||
{settings.noteAssignments.map((bell, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
minWidth: 36, padding: 'var(--space-1) var(--space-2)',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-secondary)' }}>
|
||||
{String.fromCharCode(65 + i)}
|
||||
</span>
|
||||
<div style={{ width: '100%', height: 1, backgroundColor: 'var(--color-border)', margin: '2px 0' }} />
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: bell > 0 ? 'var(--color-text-primary)' : 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
|
||||
{bell > 0 ? bell : '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ marginTop: 'var(--space-1)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
Top = Note label · Bottom = Assigned bell number
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Identifiers — masonry item 4 */}
|
||||
<Card title="Identifiers">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
|
||||
<MonoField label="Document ID" value={melody.id} />
|
||||
<MonoField label="PID" value={melody.pid} />
|
||||
{melody.url && <MonoField label="URL" value={melody.url} span={2} />}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* History — masonry item 5 */}
|
||||
{melody.metadata && (
|
||||
<Card title="History">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 'var(--space-4)' }}>
|
||||
{melody.metadata.dateCreated && (
|
||||
<DetailField label="Created">{melody.metadata.dateCreated}</DetailField>
|
||||
)}
|
||||
{melody.metadata.createdBy && (
|
||||
<DetailField label="Created By">{melody.metadata.createdBy}</DetailField>
|
||||
)}
|
||||
{melody.metadata.dateEdited && (
|
||||
<DetailField label="Last Edited">{melody.metadata.dateEdited}</DetailField>
|
||||
)}
|
||||
{melody.metadata.lastEditedBy && (
|
||||
<DetailField label="Last Edited By">{melody.metadata.lastEditedBy}</DetailField>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Admin Notes — masonry item 5 */}
|
||||
<Card title="Admin Notes">
|
||||
{(melody.metadata?.adminNotes?.length || 0) > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||
{melody.metadata.adminNotes.map((note, i) => (
|
||||
<div key={i} style={{
|
||||
padding: 'var(--space-3)',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}>
|
||||
{note}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', margin: 0 }}>
|
||||
No admin notes yet. Add them in the editor.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Firmware code ────────────────────────────────────────────────── */}
|
||||
{builtMelody?.progmem_code && (
|
||||
<Card
|
||||
title="Firmware Code"
|
||||
subtitle={`PROGMEM code for built-in firmware playback · PID: ${builtMelody.pid}`}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 'var(--space-2)' }}>
|
||||
<Button
|
||||
variant={codeCopied ? 'success' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => copyText(builtMelody.progmem_code, () => { setCodeCopied(true); setTimeout(() => setCodeCopied(false), 2000) })}
|
||||
>
|
||||
{codeCopied ? 'Copied!' : 'Copy Code'}
|
||||
</Button>
|
||||
</div>
|
||||
<pre style={{
|
||||
margin: 0, padding: 'var(--space-4)',
|
||||
backgroundColor: 'var(--color-bg-abyss)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
color: 'var(--color-text-primary)',
|
||||
overflowX: 'auto', maxHeight: 360, overflowY: 'auto',
|
||||
whiteSpace: 'pre',
|
||||
}}>
|
||||
{builtMelody.progmem_code}
|
||||
</pre>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Modals ───────────────────────────────────────────────────────── */}
|
||||
<ConfirmDialog
|
||||
open={showDelete}
|
||||
onClose={() => setShowDelete(false)}
|
||||
onConfirm={handleDelete}
|
||||
variant="danger"
|
||||
title="Delete Melody"
|
||||
message={`Are you sure you want to delete "${displayName}"? This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showUnpublish}
|
||||
onClose={() => setShowUnpublish(false)}
|
||||
onConfirm={handleUnpublish}
|
||||
variant="danger"
|
||||
title="Unpublish Melody"
|
||||
message={`Unpublish "${displayName}"? It will revert to draft and become unavailable to devices.`}
|
||||
confirmLabel="Unpublish"
|
||||
loading={actionLoading}
|
||||
/>
|
||||
|
||||
<SpeedCalculatorModal
|
||||
open={showSpeedCalc}
|
||||
onClose={() => setShowSpeedCalc(false)}
|
||||
melody={melody}
|
||||
builtMelody={builtMelody}
|
||||
archetypeCsv={info.archetype_csv || null}
|
||||
onSaved={() => { setShowSpeedCalc(false); loadData() }}
|
||||
/>
|
||||
|
||||
<PlaybackModal
|
||||
open={showPlayback}
|
||||
onClose={() => setShowPlayback(false)}
|
||||
melody={melody}
|
||||
builtMelody={builtMelody}
|
||||
files={files}
|
||||
archetypeCsv={info.archetype_csv || null}
|
||||
/>
|
||||
|
||||
<BinaryTableModal
|
||||
open={showBinaryView}
|
||||
onClose={() => setShowBinaryView(false)}
|
||||
melody={melody}
|
||||
builtMelody={builtMelody}
|
||||
files={files}
|
||||
archetypeCsv={info.archetype_csv || null}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
// frontend/src/pages/melodies/MelodyForm.jsx
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import FormField from '@/components/ui/FormField'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import api from '@/lib/api'
|
||||
import {
|
||||
getLocalizedValue,
|
||||
getLanguageName,
|
||||
normalizeColor,
|
||||
formatDuration,
|
||||
parseLocalizedString,
|
||||
serializeLocalizedString,
|
||||
} from '@/lib/melodyUtils'
|
||||
import TranslationModal from '@/modals/bellcloud/melodies/TranslationModal'
|
||||
import SelectArchetypeModal from '@/modals/bellcloud/melodies/SelectArchetypeModal'
|
||||
import BuildOnTheFlyModal from '@/modals/bellcloud/melodies/BuildOnTheFlyModal'
|
||||
import SpeedCalculatorModal from '@/modals/bellcloud/melodies/SpeedCalculatorModal'
|
||||
import PlaybackModal from '@/modals/bellcloud/melodies/PlaybackModal'
|
||||
import BinaryTableModal from '@/modals/bellcloud/melodies/BinaryTableModal'
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const MELODY_TYPES = ['orthodox', 'catholic', 'all']
|
||||
const MELODY_TONES = ['normal', 'festive', 'cheerful', 'lamentation']
|
||||
|
||||
const defaultInfo = {
|
||||
name: '', description: '', melodyTone: 'normal', customTags: [],
|
||||
minSpeed: 0, maxSpeed: 0, totalNotes: 1, totalActiveBells: 0,
|
||||
steps: 0, color: '', isTrueRing: false, previewURL: '',
|
||||
}
|
||||
|
||||
const defaultSettings = {
|
||||
speed: 50, duration: 0, totalRunDuration: 0, pauseDuration: 0,
|
||||
infiniteLoop: false, echoRing: [], noteAssignments: [],
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBpm(ms) {
|
||||
const v = Number(ms)
|
||||
if (!v || v <= 0) return null
|
||||
return Math.round(60000 / v)
|
||||
}
|
||||
|
||||
function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) {
|
||||
const p = Math.max(0, Math.min(100, Number(percent || 0)))
|
||||
const t = p / 100
|
||||
const a = Number(minSpeed); const b = Number(maxSpeed)
|
||||
if (a <= 0 || b <= 0) return Math.round(a + (b - a) * t)
|
||||
return Math.round(a * Math.pow(b / a, t))
|
||||
}
|
||||
|
||||
function normalizeFileUrl(url) {
|
||||
if (!url || typeof url !== 'string') return null
|
||||
if (url.startsWith('http') || url.startsWith('/api')) return url
|
||||
if (url.startsWith('/')) return `/api${url}`
|
||||
return `/api/${url}`
|
||||
}
|
||||
|
||||
function computeActiveBells(assignments) {
|
||||
return new Set((assignments || []).filter((v) => v > 0)).size
|
||||
}
|
||||
|
||||
// ─── Section header helper ────────────────────────────────────────────────────
|
||||
|
||||
function SectionLabel({ children }) {
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: 'var(--space-2)',
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function MelodyForm() {
|
||||
const { id } = useParams()
|
||||
const isEdit = Boolean(id)
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
|
||||
const [information, setInformation] = useState({ ...defaultInfo })
|
||||
const [settings, setSettings] = useState({ ...defaultSettings })
|
||||
const [type, setType] = useState('all')
|
||||
const [url, setUrl] = useState('')
|
||||
const [pid, setPid] = useState('')
|
||||
const [melodyStatus, setMelodyStatus] = useState('draft')
|
||||
const [previewFile, setPreviewFile] = useState(null)
|
||||
const [existingFiles, setExistingFiles] = useState({})
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
const [melodySettings, setMelodySettings] = useState(null)
|
||||
const [editLang, setEditLang] = useState('en')
|
||||
const [adminNotes, setAdminNotes] = useState([])
|
||||
const [newNote, setNewNote] = useState('')
|
||||
const [savedMelodyId, setSavedMelodyId] = useState(null)
|
||||
const [builtMelody, setBuiltMelody] = useState(null)
|
||||
const [assignedBinaryPid, setAssignedBinaryPid] = useState(null)
|
||||
|
||||
// Modal state
|
||||
const [translationModal, setTranslationModal] = useState({ open: false, field: '', fieldKey: '', multiline: false })
|
||||
const [showSelectBuilt, setShowSelectBuilt] = useState(false)
|
||||
const [showBuildOnTheFly,setShowBuildOnTheFly]= useState(false)
|
||||
const [showSpeedCalc, setShowSpeedCalc] = useState(false)
|
||||
const [showPlayback, setShowPlayback] = useState(false)
|
||||
const [showBinaryView, setShowBinaryView] = useState(false)
|
||||
|
||||
const previewInputRef = useRef(null)
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/settings/melody').then((ms) => {
|
||||
setMelodySettings(ms)
|
||||
setEditLang(ms.primary_language || 'en')
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) loadMelody()
|
||||
}, [id])
|
||||
|
||||
// Keep noteAssignments array in sync with totalNotes
|
||||
useEffect(() => {
|
||||
const count = information.totalNotes || 1
|
||||
setSettings((prev) => {
|
||||
const current = [...(prev.noteAssignments || [])]
|
||||
if (current.length < count) {
|
||||
while (current.length < count) current.push(0)
|
||||
} else if (current.length > count) {
|
||||
current.length = count
|
||||
}
|
||||
return { ...prev, noteAssignments: current }
|
||||
})
|
||||
}, [information.totalNotes])
|
||||
|
||||
const loadMelody = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [melody, files] = await Promise.all([
|
||||
api.get(`/melodies/${id}`),
|
||||
api.get(`/melodies/${id}/files`),
|
||||
])
|
||||
setInformation({ ...defaultInfo, ...melody.information })
|
||||
setSettings({ ...defaultSettings, ...melody.default_settings })
|
||||
setType(melody.type || 'all')
|
||||
setUrl(melody.url || '')
|
||||
setPid(melody.pid || '')
|
||||
setMelodyStatus(melody.status || 'published')
|
||||
setExistingFiles(files)
|
||||
setAdminNotes(melody.metadata?.adminNotes || [])
|
||||
try {
|
||||
const bm = await api.get(`/builder/melodies/for-melody/${id}`)
|
||||
setBuiltMelody(bm || null)
|
||||
setAssignedBinaryPid(bm?.pid || null)
|
||||
} catch { setBuiltMelody(null) }
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load melody.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Field helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const updateInfo = (field, value) => setInformation((prev) => ({ ...prev, [field]: value }))
|
||||
const updateSettings = (field, value) => setSettings((prev) => ({ ...prev, [field]: value }))
|
||||
|
||||
const updateLocalizedField = (fieldKey, text) => {
|
||||
const dict = parseLocalizedString(information[fieldKey])
|
||||
dict[editLang] = text
|
||||
updateInfo(fieldKey, serializeLocalizedString(dict))
|
||||
}
|
||||
|
||||
const addTag = () => {
|
||||
const tag = tagInput.trim()
|
||||
if (tag && !information.customTags.includes(tag))
|
||||
updateInfo('customTags', [...information.customTags, tag])
|
||||
setTagInput('')
|
||||
}
|
||||
|
||||
const removeTag = (tag) =>
|
||||
updateInfo('customTags', information.customTags.filter((t) => t !== tag))
|
||||
|
||||
const parseIntList = (str) => {
|
||||
if (!str.trim()) return []
|
||||
return str.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n))
|
||||
}
|
||||
|
||||
// ── File helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const getEffectiveBinary = () => {
|
||||
const effectiveUrl = normalizeFileUrl(
|
||||
(builtMelody?.binary_url ? `/api${builtMelody.binary_url}` : null) ||
|
||||
url || existingFiles.binary_url || null
|
||||
)
|
||||
const fixedName = builtMelody?.pid || assignedBinaryPid || pid || null
|
||||
const effectiveName = fixedName ? `${fixedName}.bsm` : (effectiveUrl ? 'binary.bsm' : null)
|
||||
return { effectiveUrl, effectiveName }
|
||||
}
|
||||
|
||||
const uploadFiles = async (melodyId) => {
|
||||
if (previewFile) {
|
||||
setUploading(true)
|
||||
await api.upload(`/melodies/${melodyId}/upload/preview`, previewFile)
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ensure melody exists before opening binary modals ─────────────────────
|
||||
|
||||
const ensureSaved = async () => {
|
||||
if (isEdit || savedMelodyId) return isEdit ? id : savedMelodyId
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const now = new Date().toISOString()
|
||||
const userName = user?.name || 'Unknown'
|
||||
const body = buildBody({ dateCreated: now, dateEdited: now, createdBy: userName, lastEditedBy: userName, adminNotes })
|
||||
const created = await api.post('/melodies?publish=false', body)
|
||||
setSavedMelodyId(created.id)
|
||||
return created.id
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
return null
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build request body ────────────────────────────────────────────────────
|
||||
|
||||
const buildBody = (metadata) => {
|
||||
const { notes: _notes, ...infoWithoutNotes } = information
|
||||
const totalActiveBells = computeActiveBells(settings.noteAssignments)
|
||||
const body = {
|
||||
information: { ...infoWithoutNotes, totalActiveBells },
|
||||
default_settings: settings,
|
||||
type, pid,
|
||||
metadata,
|
||||
}
|
||||
if (url) body.url = url
|
||||
return body
|
||||
}
|
||||
|
||||
// ── Save / publish ────────────────────────────────────────────────────────
|
||||
|
||||
const handleSave = async (publish) => {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const now = new Date().toISOString()
|
||||
const userName = user?.name || 'Unknown'
|
||||
let melodyId = id
|
||||
|
||||
if (isEdit) {
|
||||
await api.put(`/melodies/${id}`, buildBody({ dateEdited: now, lastEditedBy: userName, adminNotes }))
|
||||
} else if (savedMelodyId) {
|
||||
melodyId = savedMelodyId
|
||||
await api.put(`/melodies/${savedMelodyId}`, buildBody({ dateEdited: now, lastEditedBy: userName, adminNotes }))
|
||||
if (publish) await api.post(`/melodies/${savedMelodyId}/publish`)
|
||||
} else {
|
||||
const body = buildBody({ dateCreated: now, dateEdited: now, createdBy: userName, lastEditedBy: userName, adminNotes })
|
||||
const created = await api.post(`/melodies?publish=${publish}`, body)
|
||||
melodyId = created.id
|
||||
setSavedMelodyId(melodyId)
|
||||
}
|
||||
await uploadFiles(melodyId)
|
||||
navigate(`/melodies/${melodyId}`)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Save failed.')
|
||||
setUploading(false)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePublishAction = async () => {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const now = new Date().toISOString()
|
||||
const userName = user?.name || 'Unknown'
|
||||
await api.put(`/melodies/${id}`, buildBody({ dateEdited: now, lastEditedBy: userName, adminNotes }))
|
||||
await uploadFiles(id)
|
||||
await api.post(`/melodies/${id}/publish`)
|
||||
navigate(`/melodies/${id}`)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setUploading(false)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnpublishAction = async () => {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.post(`/melodies/${id}/unpublish`)
|
||||
navigate(`/melodies/${id}`)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────────────────
|
||||
|
||||
const languages = melodySettings?.available_languages || ['en']
|
||||
const durationValues = melodySettings?.duration_values || [0]
|
||||
const quickColors = melodySettings?.quick_colors || []
|
||||
const durationIndex = durationValues.indexOf(settings.duration)
|
||||
const currentDurIdx = durationIndex >= 0 ? durationIndex : 0
|
||||
const speedMs = mapPercentageToStepDelay(settings.speed, information.minSpeed, information.maxSpeed)
|
||||
const speedBpm = formatBpm(speedMs)
|
||||
const minBpm = formatBpm(information.minSpeed)
|
||||
const maxBpm = formatBpm(information.maxSpeed)
|
||||
const { effectiveUrl: binaryUrl, effectiveName: binaryName } = getEffectiveBinary()
|
||||
const missingArchetype = Boolean(pid) && !builtMelody?.id
|
||||
const isBusy = saving || uploading
|
||||
|
||||
// ── Loading state ─────────────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<PageHeader title={isEdit ? 'Edit Melody' : 'New Melody'} />
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-16)' }}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
|
||||
{/* ── Header ──────────────────────────────────────────────────────── */}
|
||||
<PageHeader title={isEdit ? 'Edit Melody' : 'New Melody'}>
|
||||
{isEdit ? (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setShowPlayback(true)}>Playback</Button>
|
||||
<Button variant="secondary" onClick={() => setShowSpeedCalc(true)}>Speed Calc</Button>
|
||||
<Button variant="secondary" onClick={() => navigate(`/melodies/${id}`)}>Cancel</Button>
|
||||
{melodyStatus === 'draft' ? (
|
||||
<>
|
||||
<Button variant="secondary" disabled={isBusy} loading={saving} onClick={() => handleSave(false)}>
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button variant="primary" disabled={isBusy} loading={uploading || saving} onClick={handlePublishAction}>
|
||||
Publish
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="secondary" disabled={isBusy} loading={saving} onClick={() => handleSave(false)}>
|
||||
{uploading ? 'Uploading…' : 'Update'}
|
||||
</Button>
|
||||
<Button variant="danger" disabled={isBusy} onClick={handleUnpublishAction}>
|
||||
Unpublish
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" onClick={async () => {
|
||||
if (savedMelodyId) {
|
||||
try { await api.delete(`/melodies/${savedMelodyId}`) } catch { /* best-effort */ }
|
||||
}
|
||||
navigate('/melodies')
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={isBusy} loading={saving} onClick={() => handleSave(false)}>
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button variant="primary" disabled={isBusy} loading={isBusy} onClick={() => handleSave(true)}>
|
||||
Publish Melody
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
{/* ── Error banner ────────────────────────────────────────────────── */}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
backgroundColor: 'var(--color-danger-bg)',
|
||||
border: '1px solid var(--color-danger)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: 'var(--color-danger)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Form body ───────────────────────────────────────────────────── */}
|
||||
<form id="melody-form" onSubmit={(e) => { e.preventDefault(); handleSave(false) }}>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
|
||||
{/* ── Left column ─────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)' }}>
|
||||
|
||||
{/* Melody Information */}
|
||||
<Card title="Melody Information">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
|
||||
{/* Name — localized */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-1)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-secondary)' }}>
|
||||
Name <span style={{ color: 'var(--color-danger)' }}>*</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
title="Edit translations"
|
||||
onClick={() => setTranslationModal({ open: true, field: 'Name', fieldKey: 'name', multiline: false })}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{languages.length > 1 && (
|
||||
<select
|
||||
value={editLang}
|
||||
onChange={(e) => setEditLang(e.target.value)}
|
||||
style={{ fontSize: 'var(--font-size-xs)', padding: '2px var(--space-2)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', color: 'var(--color-text-primary)', cursor: 'pointer' }}
|
||||
>
|
||||
{languages.map((l) => <option key={l} value={l}>{getLanguageName(l)}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<FormField
|
||||
label=""
|
||||
name="melody-name"
|
||||
type="text"
|
||||
required
|
||||
value={getLocalizedValue(information.name, editLang, '')}
|
||||
onChange={(e) => updateLocalizedField('name', e.target.value)}
|
||||
placeholder="Melody name…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description — localized */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-1)' }}>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-secondary)' }}>
|
||||
Description
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
title="Edit translations"
|
||||
onClick={() => setTranslationModal({ open: true, field: 'Description', fieldKey: 'description', multiline: true })}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<FormField
|
||||
label=""
|
||||
name="melody-description"
|
||||
type="textarea"
|
||||
rows={3}
|
||||
value={getLocalizedValue(information.description, editLang, '')}
|
||||
onChange={(e) => updateLocalizedField('description', e.target.value)}
|
||||
placeholder="Optional description…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tone + Type */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Melody Tone" name="tone" type="select"
|
||||
value={information.melodyTone} onChange={(e) => updateInfo('melodyTone', e.target.value)}>
|
||||
{MELODY_TONES.map((t) => <option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>)}
|
||||
</FormField>
|
||||
<FormField label="Type" name="type" type="select"
|
||||
value={type} onChange={(e) => setType(e.target.value)}>
|
||||
{MELODY_TYPES.map((t) => <option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>)}
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* Notes + Steps */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Total Archetype Notes" name="totalNotes" type="number"
|
||||
value={information.totalNotes}
|
||||
onChange={(e) => updateInfo('totalNotes', Math.max(1, Math.min(16, parseInt(e.target.value, 10) || 1)))}
|
||||
inputProps={{ min: 1, max: 16 }}
|
||||
/>
|
||||
<FormField label="Steps" name="steps" type="number"
|
||||
value={information.steps}
|
||||
onChange={(e) => updateInfo('steps', parseInt(e.target.value, 10) || 0)}
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Min + Max speed */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Min Speed (ms)" name="minSpeed" type="number"
|
||||
value={information.minSpeed}
|
||||
onChange={(e) => updateInfo('minSpeed', parseInt(e.target.value, 10) || 0)}
|
||||
hint={information.minSpeed > 0 ? `${minBpm} bpm` : undefined}
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
<FormField label="Max Speed (ms)" name="maxSpeed" type="number"
|
||||
value={information.maxSpeed}
|
||||
onChange={(e) => updateInfo('maxSpeed', parseInt(e.target.value, 10) || 0)}
|
||||
hint={information.maxSpeed > 0 ? `${maxBpm} bpm` : undefined}
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color */}
|
||||
<div>
|
||||
<SectionLabel>Color</SectionLabel>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-2)' }}>
|
||||
<span style={{ width: 32, height: 32, borderRadius: 'var(--radius-sm)', flexShrink: 0, border: '1px solid var(--color-border)', backgroundColor: information.color ? normalizeColor(information.color) : 'var(--color-bg-island)', display: 'inline-block' }} />
|
||||
<FormField label="" name="color" type="text"
|
||||
value={information.color}
|
||||
onChange={(e) => updateInfo('color', e.target.value)}
|
||||
placeholder="#FF5733 or 0xFF5733"
|
||||
inputProps={{ style: { fontFamily: 'var(--font-family-mono)' } }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2)', alignItems: 'center' }}>
|
||||
{quickColors.map((color) => (
|
||||
<button key={color} type="button"
|
||||
onClick={() => updateInfo('color', color)}
|
||||
style={{
|
||||
width: 28, height: 28, borderRadius: 'var(--radius-sm)',
|
||||
backgroundColor: normalizeColor(color),
|
||||
border: `2px solid ${information.color === color ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||
cursor: 'pointer', padding: 0, transition: 'border-color 120ms',
|
||||
}}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
<label style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 'var(--space-1)', padding: 'var(--space-1) var(--space-2)', border: '2px dashed var(--color-border)', borderRadius: 'var(--radius-sm)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', cursor: 'pointer' }} title="Custom color">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2v20M2 12h20"/></svg>
|
||||
Custom
|
||||
<input type="color" value={normalizeColor(information.color)} onChange={(e) => updateInfo('color', e.target.value)} style={{ position: 'absolute', inset: 0, opacity: 0, width: '100%', height: '100%', cursor: 'pointer' }} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* True Ring + Tags */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||
<input type="checkbox" id="isTrueRing" checked={information.isTrueRing} onChange={(e) => updateInfo('isTrueRing', e.target.checked)} style={{ width: 16, height: 16, cursor: 'pointer', accentColor: 'var(--color-primary)' }} />
|
||||
<label htmlFor="isTrueRing" style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-secondary)', cursor: 'pointer' }}>
|
||||
True Ring
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Custom Tags */}
|
||||
<div>
|
||||
<SectionLabel>Custom Tags</SectionLabel>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-2)', marginBottom: 'var(--space-2)' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<FormField label="" name="tag-input" type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="Add tag…"
|
||||
inputProps={{ onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); addTag() } } }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ alignSelf: 'flex-end', paddingBottom: 1 }}>
|
||||
<Button variant="secondary" type="button" onClick={addTag}>Add</Button>
|
||||
</div>
|
||||
</div>
|
||||
{information.customTags.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-1)' }}>
|
||||
{information.customTags.map((tag) => (
|
||||
<span key={tag} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 'var(--space-1)',
|
||||
padding: '2px var(--space-2)',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
backgroundColor: 'var(--color-primary-subtle)',
|
||||
color: 'var(--color-primary)',
|
||||
border: '1px solid var(--color-primary)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
}}>
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag(tag)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', lineHeight: 1, padding: 0, fontSize: 12 }}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Identifiers */}
|
||||
<Card title="Identifiers">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
<FormField label="PID (Playback ID)" name="pid" type="text"
|
||||
value={pid} onChange={(e) => setPid(e.target.value)}
|
||||
placeholder="e.g. builtin_festive_vesper"
|
||||
/>
|
||||
{url && (
|
||||
<FormField label="URL (auto-set from binary upload)" name="url" type="text"
|
||||
value={url} disabled
|
||||
inputProps={{ readOnly: true, style: { fontFamily: 'var(--font-family-mono)', opacity: 0.7 } }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Admin Notes */}
|
||||
<Card title="Admin Notes">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||||
{adminNotes.map((note, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-2)' }}>
|
||||
<div style={{ flex: 1, padding: 'var(--space-3)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>
|
||||
{note}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" type="button" onClick={() => setAdminNotes((prev) => prev.filter((_, j) => j !== i))}>×</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<FormField label="" name="new-note" type="textarea" rows={2}
|
||||
value={newNote} onChange={(e) => setNewNote(e.target.value)}
|
||||
placeholder="Add a note…"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ alignSelf: 'flex-end', paddingBottom: 1 }}>
|
||||
<Button variant="secondary" type="button" onClick={() => { if (newNote.trim()) { setAdminNotes((prev) => [...prev, newNote.trim()]); setNewNote('') } }}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── Right column ────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)' }}>
|
||||
|
||||
{/* Default Settings */}
|
||||
<Card title="Default Settings">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
|
||||
|
||||
{/* Speed slider */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 'var(--space-2)' }}>
|
||||
<SectionLabel>Speed</SectionLabel>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-primary)' }}>
|
||||
{settings.speed}%{speedBpm ? ` · ${speedBpm} bpm` : ''}{speedMs ? ` · ${speedMs} ms` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<input type="range" min="1" max="100" value={settings.speed}
|
||||
onChange={(e) => updateSettings('speed', parseInt(e.target.value, 10))}
|
||||
style={{ width: '100%', accentColor: 'var(--color-primary)', cursor: 'pointer' }}
|
||||
/>
|
||||
{!speedBpm && (
|
||||
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', marginTop: 'var(--space-1)' }}>
|
||||
Set Min/Max speed above to compute BPM
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Duration slider */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 'var(--space-2)' }}>
|
||||
<SectionLabel>Duration</SectionLabel>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-primary)' }}>
|
||||
{formatDuration(settings.duration)} ({settings.duration}s)
|
||||
</span>
|
||||
</div>
|
||||
<input type="range" min="0" max={Math.max(0, durationValues.length - 1)}
|
||||
value={currentDurIdx}
|
||||
onChange={(e) => updateSettings('duration', durationValues[parseInt(e.target.value, 10)] ?? 0)}
|
||||
style={{ width: '100%', accentColor: 'var(--color-primary)', cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Run / Pause duration */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Total Run Duration" name="totalRunDuration" type="number"
|
||||
value={settings.totalRunDuration}
|
||||
onChange={(e) => updateSettings('totalRunDuration', parseInt(e.target.value, 10) || 0)}
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
<FormField label="Pause Duration" name="pauseDuration" type="number"
|
||||
value={settings.pauseDuration}
|
||||
onChange={(e) => updateSettings('pauseDuration', parseInt(e.target.value, 10) || 0)}
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Infinite loop */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||
<input type="checkbox" id="infiniteLoop" checked={settings.infiniteLoop} onChange={(e) => updateSettings('infiniteLoop', e.target.checked)} style={{ width: 16, height: 16, cursor: 'pointer', accentColor: 'var(--color-primary)' }} />
|
||||
<label htmlFor="infiniteLoop" style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-secondary)', cursor: 'pointer' }}>
|
||||
Infinite Loop
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Echo ring */}
|
||||
<FormField label="Echo Ring (comma-separated)" name="echoRing" type="text"
|
||||
value={settings.echoRing.join(', ')}
|
||||
onChange={(e) => updateSettings('echoRing', parseIntList(e.target.value))}
|
||||
placeholder="e.g. 0, 1, 0, 1"
|
||||
/>
|
||||
|
||||
{/* Note assignments */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-2)' }}>
|
||||
<SectionLabel>Note Assignments</SectionLabel>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
|
||||
{computeActiveBells(settings.noteAssignments)} active bell{computeActiveBells(settings.noteAssignments) !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 'var(--space-2)' }}>
|
||||
{Array.from({ length: information.totalNotes }, (_, i) => (
|
||||
<div key={i}>
|
||||
<div style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', marginBottom: 2, textAlign: 'center' }}>
|
||||
{String.fromCharCode(65 + i)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={settings.noteAssignments[i] ?? 0}
|
||||
onChange={(e) => {
|
||||
const na = [...settings.noteAssignments]
|
||||
while (na.length <= i) na.push(0)
|
||||
na[i] = parseInt(e.target.value, 10) || 0
|
||||
updateSettings('noteAssignments', na)
|
||||
}}
|
||||
className="input"
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
MozAppearance: 'textfield',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ marginTop: 'var(--space-1)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
0 = unassigned. Unique non-zero values count as active bells.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Files */}
|
||||
<Card title="Files">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
|
||||
{/* Binary */}
|
||||
<div>
|
||||
<SectionLabel>Binary File (.bsm)</SectionLabel>
|
||||
{binaryUrl ? (
|
||||
<div style={{ marginBottom: 'var(--space-2)' }}>
|
||||
{/* Main row: archetype name + Download pill + View pill + note count */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>
|
||||
{builtMelody?.name || binaryName}
|
||||
</span>
|
||||
{missingArchetype && (
|
||||
<span title="Binary not found in Archetypes" style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-warning)', cursor: 'help' }}>⚠ Missing</span>
|
||||
)}
|
||||
{/* Download — blue pill */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault()
|
||||
const melodyId = isEdit ? id : savedMelodyId
|
||||
if (!melodyId) return
|
||||
const endpoint = `/api/melodies/${melodyId}/download/binary`
|
||||
try {
|
||||
const token = localStorage.getItem('access_token')
|
||||
const res = await fetch(endpoint, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
|
||||
if (!res.ok) throw new Error(res.statusText)
|
||||
const blob = await res.blob()
|
||||
const objUrl = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a'); a.href = objUrl; a.download = binaryName; a.click()
|
||||
URL.revokeObjectURL(objUrl)
|
||||
} catch {
|
||||
if (binaryUrl) { const a = document.createElement('a'); a.href = binaryUrl; a.download = binaryName; a.rel = 'noopener noreferrer'; document.body.appendChild(a); a.click(); document.body.removeChild(a) }
|
||||
}
|
||||
}}
|
||||
style={{ padding: '2px 10px', borderRadius: 'var(--radius-full)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-primary)', backgroundColor: 'var(--color-primary-subtle)', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
{/* View — neutral pill */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBinaryView(true)}
|
||||
style={{ padding: '2px 10px', borderRadius: 'var(--radius-full)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-secondary)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)', cursor: 'pointer' }}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
{information.totalNotes ?? 0} active notes
|
||||
</span>
|
||||
</div>
|
||||
{/* Subtitle: file: archetypename.bsm */}
|
||||
<div style={{ marginTop: 'var(--space-1)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
|
||||
file: {binaryName}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', marginBottom: 'var(--space-2)' }}>
|
||||
No binary assigned
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
|
||||
<Button variant="secondary" size="sm" type="button"
|
||||
onClick={async () => { const mid = await ensureSaved(); if (mid) setShowSelectBuilt(true) }}>
|
||||
Select Archetype
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" type="button"
|
||||
onClick={async () => { const mid = await ensureSaved(); if (mid) setShowBuildOnTheFly(true) }}>
|
||||
Build on the Fly
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audio preview upload */}
|
||||
<div>
|
||||
<SectionLabel>Audio Preview</SectionLabel>
|
||||
{normalizeFileUrl(existingFiles.preview_url) ? (
|
||||
<div style={{ marginBottom: 'var(--space-2)' }}>
|
||||
<audio controls src={normalizeFileUrl(existingFiles.preview_url)} style={{ height: 32, width: '100%' }} />
|
||||
</div>
|
||||
) : previewFile ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-2)' }}>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>{previewFile.name}</span>
|
||||
<Button variant="ghost" size="sm" type="button" onClick={() => setPreviewFile(null)}>Remove</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<Button variant="secondary" size="sm" type="button" onClick={() => previewInputRef.current?.click()}>
|
||||
{existingFiles.preview_url ? 'Replace Preview' : 'Upload Preview'}
|
||||
</Button>
|
||||
<input ref={previewInputRef} type="file" accept="audio/*" style={{ display: 'none' }} onChange={(e) => setPreviewFile(e.target.files?.[0] || null)} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* ── Modals ───────────────────────────────────────────────────────── */}
|
||||
<TranslationModal
|
||||
open={translationModal.open}
|
||||
onClose={() => setTranslationModal((p) => ({ ...p, open: false }))}
|
||||
field={translationModal.field}
|
||||
fieldKey={translationModal.fieldKey}
|
||||
multiline={translationModal.multiline}
|
||||
value={information[translationModal.fieldKey]}
|
||||
languages={languages}
|
||||
primaryLanguage={melodySettings?.primary_language || 'en'}
|
||||
onChange={(fieldKey, serialized) => updateInfo(fieldKey, serialized)}
|
||||
/>
|
||||
|
||||
<SelectArchetypeModal
|
||||
open={showSelectBuilt}
|
||||
onClose={() => setShowSelectBuilt(false)}
|
||||
melodyId={isEdit ? id : savedMelodyId}
|
||||
currentMelody={{ id: isEdit ? id : savedMelodyId, information, default_settings: settings, type, pid, url }}
|
||||
currentBuiltMelody={builtMelody}
|
||||
onAssigned={(bm) => {
|
||||
setBuiltMelody(bm)
|
||||
setAssignedBinaryPid(bm?.pid || null)
|
||||
setShowSelectBuilt(false)
|
||||
if (isEdit) loadMelody()
|
||||
}}
|
||||
/>
|
||||
|
||||
<BuildOnTheFlyModal
|
||||
open={showBuildOnTheFly}
|
||||
onClose={() => setShowBuildOnTheFly(false)}
|
||||
melodyId={isEdit ? id : savedMelodyId}
|
||||
currentMelody={{ id: isEdit ? id : savedMelodyId, information, default_settings: settings, type, pid, url }}
|
||||
currentBuiltMelody={builtMelody}
|
||||
defaultName={getLocalizedValue(information.name, 'en', '')}
|
||||
defaultPid={pid}
|
||||
onAssigned={(bm) => {
|
||||
setBuiltMelody(bm)
|
||||
setAssignedBinaryPid(bm?.pid || null)
|
||||
setShowBuildOnTheFly(false)
|
||||
if (isEdit) loadMelody()
|
||||
}}
|
||||
/>
|
||||
|
||||
<SpeedCalculatorModal
|
||||
open={showSpeedCalc}
|
||||
onClose={() => setShowSpeedCalc(false)}
|
||||
melody={{ id: isEdit ? id : savedMelodyId, information, default_settings: settings, type, pid, url }}
|
||||
builtMelody={builtMelody}
|
||||
archetypeCsv={information.archetype_csv || null}
|
||||
onSaved={() => { setShowSpeedCalc(false); if (isEdit) loadMelody() }}
|
||||
/>
|
||||
|
||||
<PlaybackModal
|
||||
open={showPlayback}
|
||||
onClose={() => setShowPlayback(false)}
|
||||
melody={{ id: isEdit ? id : savedMelodyId, information, default_settings: settings, type, pid, url }}
|
||||
builtMelody={builtMelody}
|
||||
files={existingFiles}
|
||||
archetypeCsv={information.archetype_csv || null}
|
||||
/>
|
||||
|
||||
<BinaryTableModal
|
||||
open={showBinaryView}
|
||||
onClose={() => setShowBinaryView(false)}
|
||||
melody={{ id: isEdit ? id : savedMelodyId, information, default_settings: settings, type, pid, url }}
|
||||
builtMelody={builtMelody}
|
||||
files={existingFiles}
|
||||
archetypeCsv={information.archetype_csv || null}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,498 @@
|
||||
// frontend/src/pages/melodies/MelodySettings.jsx
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import FormField from '@/components/ui/FormField'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import api from '@/lib/api'
|
||||
import {
|
||||
LANGUAGE_MASTER_LIST,
|
||||
getLanguageName,
|
||||
formatDuration,
|
||||
normalizeColor,
|
||||
} from '@/lib/melodyUtils'
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_NOTE_COLORS = [
|
||||
'#67E8F9', '#5EEAD4', '#6EE7B7', '#86EFAC',
|
||||
'#BEF264', '#FDE68A', '#FCD34D', '#FBBF24',
|
||||
'#FDBA74', '#FB923C', '#F97316', '#FB7185',
|
||||
'#F87171', '#EF4444', '#DC2626', '#B91C1C',
|
||||
]
|
||||
|
||||
const NOTE_LABELS = 'ABCDEFGHIJKLMNOP'
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function MelodySettings() {
|
||||
const { toast } = useToast()
|
||||
|
||||
const [settings, setSettings] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [fetchError, setFetchError] = useState('')
|
||||
|
||||
// Note color modal
|
||||
const [colorModalBell, setColorModalBell] = useState(null)
|
||||
const [modalColor, setModalColor] = useState('#67E8F9')
|
||||
const [modalHexInput, setModalHexInput] = useState('#67E8F9')
|
||||
|
||||
// Add language
|
||||
const [langToAdd, setLangToAdd] = useState('')
|
||||
|
||||
// Add quick color
|
||||
const [quickColorPicker, setQuickColorPicker] = useState('#c0c1ff')
|
||||
const [quickColorHex, setQuickColorHex] = useState('#c0c1ff')
|
||||
|
||||
// Add duration
|
||||
const [durationInput, setDurationInput] = useState('')
|
||||
|
||||
useEffect(() => { loadSettings() }, [])
|
||||
|
||||
const loadSettings = async () => {
|
||||
setLoading(true)
|
||||
setFetchError('')
|
||||
try {
|
||||
const data = await api.get('/settings/melody')
|
||||
setSettings({
|
||||
...data,
|
||||
note_assignment_colors: (data.note_assignment_colors || DEFAULT_NOTE_COLORS).slice(0, 16),
|
||||
})
|
||||
} catch (err) {
|
||||
setFetchError(err.message || 'Failed to load settings.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const save = async (updated) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await api.put('/settings/melody', updated)
|
||||
setSettings(result)
|
||||
toast.success('Saved', 'Settings updated.')
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to save.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Languages ─────────────────────────────────────────────────────────────
|
||||
|
||||
const addLanguage = () => {
|
||||
if (!langToAdd || settings.available_languages.includes(langToAdd)) return
|
||||
save({ ...settings, available_languages: [...settings.available_languages, langToAdd] })
|
||||
setLangToAdd('')
|
||||
}
|
||||
|
||||
const removeLanguage = (code) => {
|
||||
if (settings.available_languages.length <= 1) return
|
||||
save({
|
||||
...settings,
|
||||
available_languages: settings.available_languages.filter((c) => c !== code),
|
||||
primary_language:
|
||||
settings.primary_language === code
|
||||
? settings.available_languages.find((c) => c !== code)
|
||||
: settings.primary_language,
|
||||
})
|
||||
}
|
||||
|
||||
const setPrimaryLanguage = (code) => save({ ...settings, primary_language: code })
|
||||
|
||||
// ── Quick colors ──────────────────────────────────────────────────────────
|
||||
|
||||
const addQuickColor = () => {
|
||||
const color = quickColorHex.startsWith('#') ? quickColorHex : `#${quickColorHex}`
|
||||
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) return
|
||||
if (settings.quick_colors.includes(color)) return
|
||||
save({ ...settings, quick_colors: [...settings.quick_colors, color] })
|
||||
}
|
||||
|
||||
const removeQuickColor = (color) =>
|
||||
save({ ...settings, quick_colors: settings.quick_colors.filter((c) => c !== color) })
|
||||
|
||||
// ── Duration presets ──────────────────────────────────────────────────────
|
||||
|
||||
const addDuration = () => {
|
||||
const val = parseInt(durationInput, 10)
|
||||
if (isNaN(val) || val < 0) return
|
||||
if (settings.duration_values.includes(val)) return
|
||||
save({ ...settings, duration_values: [...settings.duration_values, val].sort((a, b) => a - b) })
|
||||
setDurationInput('')
|
||||
}
|
||||
|
||||
const removeDuration = (val) =>
|
||||
save({ ...settings, duration_values: settings.duration_values.filter((v) => v !== val) })
|
||||
|
||||
// ── Note assignment colors ────────────────────────────────────────────────
|
||||
|
||||
const openNoteColorModal = (index) => {
|
||||
const current = settings.note_assignment_colors?.[index] || DEFAULT_NOTE_COLORS[index]
|
||||
setModalColor(current)
|
||||
setModalHexInput(current)
|
||||
setColorModalBell(index)
|
||||
}
|
||||
|
||||
const applyNoteColor = () => {
|
||||
if (colorModalBell == null) return
|
||||
const candidate = modalHexInput.startsWith('#') ? modalHexInput : `#${modalHexInput}`
|
||||
if (!/^#[0-9A-Fa-f]{6}$/.test(candidate)) return
|
||||
const next = [...(settings.note_assignment_colors || DEFAULT_NOTE_COLORS)]
|
||||
next[colorModalBell] = candidate
|
||||
save({ ...settings, note_assignment_colors: next.slice(0, 16) })
|
||||
setColorModalBell(null)
|
||||
}
|
||||
|
||||
const resetNoteColor = () => {
|
||||
const fallback = DEFAULT_NOTE_COLORS[colorModalBell]
|
||||
setModalColor(fallback)
|
||||
setModalHexInput(fallback)
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<PageHeader title="Melody Settings" subtitle="Configure global melody behaviour and defaults." />
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-16)' }}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (fetchError || !settings) {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<PageHeader title="Melody Settings" />
|
||||
<div style={{
|
||||
padding: 'var(--space-4)',
|
||||
backgroundColor: 'var(--color-danger-bg)',
|
||||
border: '1px solid var(--color-danger)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: 'var(--color-danger)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
}}>
|
||||
{fetchError || 'Failed to load settings.'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const availableToAdd = LANGUAGE_MASTER_LIST.filter(
|
||||
(l) => !settings.available_languages.includes(l.code)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<PageHeader title="Melody Settings" subtitle="Configure global melody behaviour and defaults." />
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
|
||||
{/* ── Languages ─────────────────────────────────────────────────── */}
|
||||
<Card title="Available Languages" subtitle="Languages shown in the melody editor and detail view.">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', marginBottom: 'var(--space-4)' }}>
|
||||
{settings.available_languages.map((code) => (
|
||||
<div key={code} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', width: 24 }}>
|
||||
{code}
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>
|
||||
{getLanguageName(code)}
|
||||
</span>
|
||||
{settings.primary_language === code && (
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)',
|
||||
padding: '1px var(--space-2)', borderRadius: 'var(--radius-full)',
|
||||
backgroundColor: 'var(--color-primary-subtle)', color: 'var(--color-primary)',
|
||||
}}>
|
||||
Primary
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
|
||||
{settings.primary_language !== code && (
|
||||
<Button variant="ghost" size="sm" disabled={saving} onClick={() => setPrimaryLanguage(code)}>
|
||||
Set Primary
|
||||
</Button>
|
||||
)}
|
||||
{settings.available_languages.length > 1 && (
|
||||
<Button variant="danger" size="sm" disabled={saving} onClick={() => removeLanguage(code)}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<FormField
|
||||
label="Add Language"
|
||||
name="lang-add"
|
||||
type="select"
|
||||
value={langToAdd}
|
||||
onChange={(e) => setLangToAdd(e.target.value)}
|
||||
>
|
||||
<option value="">Select language…</option>
|
||||
{availableToAdd.map((l) => (
|
||||
<option key={l.code} value={l.code}>{l.name} ({l.code})</option>
|
||||
))}
|
||||
</FormField>
|
||||
</div>
|
||||
<div style={{ alignSelf: 'flex-end', paddingBottom: 1 }}>
|
||||
<Button variant="primary" disabled={!langToAdd || saving} loading={saving} onClick={addLanguage}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ── Quick Colors ──────────────────────────────────────────────── */}
|
||||
<Card title="Quick Selection Colors" subtitle="Color swatches shown in the melody color picker.">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2)', marginBottom: 'var(--space-4)' }}>
|
||||
{settings.quick_colors.map((color) => (
|
||||
<div key={color} style={{ position: 'relative' }} className="group">
|
||||
<div
|
||||
style={{
|
||||
width: 40, height: 40,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: normalizeColor(color),
|
||||
border: '2px solid var(--color-border)',
|
||||
boxShadow: `0 0 8px 1px ${color}44`,
|
||||
cursor: 'default',
|
||||
}}
|
||||
title={color}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => removeQuickColor(color)}
|
||||
style={{
|
||||
position: 'absolute', top: -6, right: -6,
|
||||
width: 18, height: 18,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--color-danger)',
|
||||
color: 'var(--color-text-inverse)',
|
||||
border: 'none', cursor: 'pointer',
|
||||
fontSize: 11, lineHeight: 1,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
opacity: 0,
|
||||
transition: 'opacity 120ms',
|
||||
}}
|
||||
className="group-hover:opacity-100"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 'var(--space-2)' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={quickColorPicker}
|
||||
onChange={(e) => { setQuickColorPicker(e.target.value); setQuickColorHex(e.target.value) }}
|
||||
style={{ width: 42, height: 42, borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border)', cursor: 'pointer', padding: 2, backgroundColor: 'var(--color-bg-abyss)' }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<FormField
|
||||
label="Hex code"
|
||||
name="quick-color-hex"
|
||||
type="text"
|
||||
value={quickColorHex}
|
||||
onChange={(e) => {
|
||||
setQuickColorHex(e.target.value)
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(e.target.value)) setQuickColorPicker(e.target.value)
|
||||
}}
|
||||
placeholder="#RRGGBB"
|
||||
inputProps={{ style: { fontFamily: 'var(--font-family-mono)' } }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ paddingBottom: 1 }}>
|
||||
<Button variant="primary" disabled={saving} onClick={addQuickColor}>Add</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ── Duration Presets ──────────────────────────────────────────── */}
|
||||
<Card title="Duration Presets" subtitle="Available durations (in seconds) shown in the melody editor." className="xl:col-span-2">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2)', marginBottom: 'var(--space-4)' }}>
|
||||
{settings.duration_values.map((val) => (
|
||||
<div
|
||||
key={val}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>
|
||||
{formatDuration(val)}
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
|
||||
{val}s
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => removeDuration(val)}
|
||||
style={{
|
||||
background: 'transparent', border: 'none',
|
||||
color: 'var(--color-danger)', cursor: 'pointer',
|
||||
fontSize: 'var(--font-size-sm)', lineHeight: 1,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 'var(--space-2)' }}>
|
||||
<div style={{ width: 180 }}>
|
||||
<FormField
|
||||
label="Seconds"
|
||||
name="duration-add"
|
||||
type="number"
|
||||
value={durationInput}
|
||||
onChange={(e) => setDurationInput(e.target.value)}
|
||||
placeholder="e.g. 45"
|
||||
inputProps={{
|
||||
min: 0,
|
||||
onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); addDuration() } },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ paddingBottom: 1 }}>
|
||||
<Button variant="primary" disabled={saving || !durationInput} onClick={addDuration}>Add</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ── Note Assignment Colors ─────────────────────────────────────── */}
|
||||
<Card
|
||||
title="Note Assignment Colors"
|
||||
subtitle="Colors used in the Composer grid and playback visualiser. Click a bell to customize."
|
||||
className="xl:col-span-2"
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(80px, 1fr))', gap: 'var(--space-3)' }}>
|
||||
{Array.from({ length: 16 }, (_, idx) => {
|
||||
const color = settings.note_assignment_colors?.[idx] || DEFAULT_NOTE_COLORS[idx]
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => openNoteColorModal(idx)}
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--space-2)',
|
||||
padding: 'var(--space-3) var(--space-2)',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 120ms, border-color 120ms',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-bg-island)'; e.currentTarget.style.borderColor = 'var(--color-border-strong)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'; e.currentTarget.style.borderColor = 'var(--color-border)' }}
|
||||
title={`Bell ${idx + 1} — ${color}`}
|
||||
>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 32, height: 32, borderRadius: '50%',
|
||||
backgroundColor: color,
|
||||
color: '#111827',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-bold)',
|
||||
boxShadow: `0 0 10px 2px ${color}55`,
|
||||
}}>
|
||||
{NOTE_LABELS[idx]}
|
||||
</span>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
fontSize: 10,
|
||||
color: 'var(--color-text-muted)',
|
||||
letterSpacing: 0,
|
||||
}}>
|
||||
{color.toLowerCase()}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Note Color Modal ──────────────────────────────────────────────── */}
|
||||
<Modal
|
||||
open={colorModalBell != null}
|
||||
onClose={() => setColorModalBell(null)}
|
||||
title={colorModalBell != null ? `Bell ${colorModalBell + 1} — ${NOTE_LABELS[colorModalBell]} Color` : ''}
|
||||
size="sm"
|
||||
footer={
|
||||
<div style={{ display: 'flex', gap: 'var(--space-2)', justifyContent: 'space-between', width: '100%' }}>
|
||||
<Button variant="ghost" size="sm" onClick={resetNoteColor}>Reset Default</Button>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
|
||||
<Button variant="secondary" onClick={() => setColorModalBell(null)}>Cancel</Button>
|
||||
<Button variant="primary" onClick={applyNoteColor} loading={saving}>Apply</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
||||
Pick a custom color for this bell note in the composer and playback views.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={modalColor}
|
||||
onChange={(e) => { setModalColor(e.target.value); setModalHexInput(e.target.value) }}
|
||||
style={{ width: 56, height: 44, borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border)', cursor: 'pointer', padding: 2, backgroundColor: 'var(--color-bg-abyss)', flexShrink: 0 }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<FormField
|
||||
label="Hex code"
|
||||
name="modal-color-hex"
|
||||
type="text"
|
||||
value={modalHexInput}
|
||||
onChange={(e) => {
|
||||
setModalHexInput(e.target.value)
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(e.target.value)) setModalColor(e.target.value)
|
||||
}}
|
||||
inputProps={{ style: { fontFamily: 'var(--font-family-mono)' } }}
|
||||
/>
|
||||
</div>
|
||||
{/* Preview swatch */}
|
||||
<span style={{
|
||||
flexShrink: 0,
|
||||
width: 40, height: 40, borderRadius: '50%',
|
||||
backgroundColor: modalColor,
|
||||
boxShadow: `0 0 12px 3px ${modalColor}66`,
|
||||
display: 'inline-block',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
// frontend/src/pages/melodies/archetypes/ArchetypeForm.jsx
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import FormField from '@/components/ui/FormField'
|
||||
import StatusBadge from '@/components/ui/StatusBadge'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import api from '@/lib/api'
|
||||
import { getLocalizedValue } from '@/lib/melodyUtils'
|
||||
import PlaybackModal from '@/modals/bellcloud/melodies/PlaybackModal'
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function countSteps(stepsStr) {
|
||||
if (!stepsStr?.trim()) return 0
|
||||
return stepsStr.trim().split(',').length
|
||||
}
|
||||
|
||||
function validateSteps(stepsStr) {
|
||||
if (!stepsStr?.trim()) return 'Steps are required.'
|
||||
const tokens = stepsStr.trim().split(',')
|
||||
for (const token of tokens) {
|
||||
const parts = token.split('+')
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim()
|
||||
if (trimmed === '') return `Invalid token near: "${token.trim()}" — empty part found.`
|
||||
const n = parseInt(trimmed, 10)
|
||||
if (isNaN(n)) return `Invalid step value: "${trimmed}" — must be a number 0–16.`
|
||||
if (n < 0 || n > 16) return `Step value "${trimmed}" is out of range (0–16).`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function downloadBinary(binaryUrl, filename) {
|
||||
const token = localStorage.getItem('access_token')
|
||||
const res = await fetch(`/api${binaryUrl}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new Error(`Download failed: ${res.statusText}`)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function copyText(text, onSuccess) {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).then(onSuccess).catch(() => fallbackCopy(text, onSuccess))
|
||||
} else {
|
||||
fallbackCopy(text, onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text, onSuccess) {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0'
|
||||
document.body.appendChild(ta)
|
||||
ta.focus(); ta.select()
|
||||
try { document.execCommand('copy'); onSuccess?.() } catch (_) {}
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ArchetypeForm() {
|
||||
const { id } = useParams()
|
||||
const isEdit = Boolean(id)
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { hasPermission } = useAuth()
|
||||
const canEdit = hasPermission('melodies', 'edit')
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState('')
|
||||
const [pid, setPid] = useState('')
|
||||
const [steps, setSteps] = useState('')
|
||||
const [isBuiltin, setIsBuiltin] = useState(false)
|
||||
const [savedPid, setSavedPid] = useState('')
|
||||
|
||||
// Load state
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Build output
|
||||
const [binaryBuilt, setBinaryBuilt] = useState(false)
|
||||
const [binaryUrl, setBinaryUrl] = useState(null)
|
||||
const [progmemCode, setProgmemCode] = useState('')
|
||||
const [codeCopied, setCodeCopied] = useState(false)
|
||||
const [hasUnsaved, setHasUnsaved] = useState(false)
|
||||
|
||||
// Assigned melodies
|
||||
const [assignedMelodies, setAssignedMelodies] = useState([])
|
||||
const [loadingAssigned, setLoadingAssigned] = useState(false)
|
||||
const [primaryLang, setPrimaryLang] = useState('en')
|
||||
|
||||
// Delete
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [showDeleteWarning, setShowDeleteWarning] = useState(false)
|
||||
|
||||
// Playback
|
||||
const [showPlayback, setShowPlayback] = useState(false)
|
||||
const [currentBuiltMelody, setCurrentBuiltMelody] = useState(null)
|
||||
|
||||
const codeRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/settings/melody')
|
||||
.then((ms) => setPrimaryLang(ms.primary_language || 'en'))
|
||||
.catch(() => {})
|
||||
if (isEdit) loadArchetype()
|
||||
}, [id])
|
||||
|
||||
const loadArchetype = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await api.get(`/builder/melodies/${id}`)
|
||||
setName(data.name || '')
|
||||
setPid(data.pid || '')
|
||||
setSteps(data.steps || '')
|
||||
setIsBuiltin(Boolean(data.is_builtin))
|
||||
setSavedPid(data.pid || '')
|
||||
setBinaryBuilt(Boolean(data.binary_path))
|
||||
setBinaryUrl(data.binary_url || null)
|
||||
setProgmemCode(data.progmem_code || '')
|
||||
setCurrentBuiltMelody(data)
|
||||
setHasUnsaved(false)
|
||||
if (data.assigned_melody_ids?.length > 0) {
|
||||
fetchAssignedMelodies(data.assigned_melody_ids)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load archetype.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAssignedMelodies = async (ids) => {
|
||||
setLoadingAssigned(true)
|
||||
try {
|
||||
const results = await Promise.allSettled(ids.map((mid) => api.get(`/melodies/${mid}`)))
|
||||
setAssignedMelodies(
|
||||
results
|
||||
.filter((r) => r.status === 'fulfilled' && r.value)
|
||||
.map((r) => ({ id: r.value.id, nameRaw: r.value.information?.name }))
|
||||
)
|
||||
} catch { /* best-effort */ }
|
||||
finally { setLoadingAssigned(false) }
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setError('')
|
||||
if (!name.trim()) { setError('Name is required.'); return }
|
||||
if (!pid.trim()) { setError('PID is required.'); return }
|
||||
const stepsError = validateSteps(steps)
|
||||
if (stepsError) { setError(stepsError); return }
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
// Duplicate check
|
||||
const { melodies: list = [] } = await api.get('/builder/melodies')
|
||||
const dupName = list.find((m) => m.id !== id && m.name.toLowerCase() === name.trim().toLowerCase())
|
||||
if (dupName) { setError(`An archetype named "${name.trim()}" already exists.`); return }
|
||||
const dupPid = list.find((m) => m.id !== id && m.pid?.toLowerCase() === pid.trim().toLowerCase())
|
||||
if (dupPid) { setError(`A PID "${pid.trim()}" is already taken.`); return }
|
||||
|
||||
const body = { name: name.trim(), pid: pid.trim(), steps: steps.trim(), is_builtin: isBuiltin }
|
||||
|
||||
if (isEdit) {
|
||||
const updated = await api.put(`/builder/melodies/${id}`, body)
|
||||
setSavedPid(pid.trim())
|
||||
setBinaryBuilt(Boolean(updated.binary_path))
|
||||
setBinaryUrl(updated.binary_url || null)
|
||||
setProgmemCode(updated.progmem_code || '')
|
||||
setCurrentBuiltMelody(updated)
|
||||
setHasUnsaved(false)
|
||||
toast.success('Saved', 'Archetype rebuilt and saved.')
|
||||
} else {
|
||||
const created = await api.post('/builder/melodies', body)
|
||||
toast.success('Created', 'Archetype built successfully.')
|
||||
navigate(`/melodies/archetypes/${created.id}`)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Save failed.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await api.delete(`/builder/melodies/${id}`)
|
||||
toast.success('Deleted', 'Archetype removed.')
|
||||
navigate('/melodies/archetypes')
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message)
|
||||
setShowDelete(false)
|
||||
setShowDeleteWarning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const assignedCount = assignedMelodies.length
|
||||
|
||||
const playbackBuiltMelody = currentBuiltMelody
|
||||
? { ...currentBuiltMelody, steps }
|
||||
: { id: 'preview', name: name || 'Preview', pid: pid || '', steps, binary_url: null }
|
||||
|
||||
// ── Loading ───────────────────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<PageHeader title={isEdit ? 'Edit Archetype' : 'New Archetype'} />
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-16)' }}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="page-wrapper page-wrapper--centered" style={{ '--page-content-max-width': 'var(--content-max-width-md)' }}>
|
||||
|
||||
<PageHeader
|
||||
title={isEdit ? name || 'Edit Archetype' : 'New Archetype'}
|
||||
subtitle={isEdit ? `PID: ${savedPid || pid}` : 'Define a step sequence that melodies can reference'}
|
||||
>
|
||||
{/* Built-in toggle */}
|
||||
<Button
|
||||
variant={isBuiltin ? 'info' : 'ghost'}
|
||||
onClick={() => { setIsBuiltin((v) => !v); setHasUnsaved(true) }}
|
||||
icon={
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
{isBuiltin ? 'Built-in' : 'Built-in'}
|
||||
</Button>
|
||||
|
||||
{/* Play preview */}
|
||||
<Button
|
||||
variant="success"
|
||||
disabled={!steps.trim()}
|
||||
onClick={() => setShowPlayback(true)}
|
||||
icon={
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M5 3l14 9-14 9V3z"/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
|
||||
{/* Cancel */}
|
||||
<Button variant="ghost" onClick={() => navigate('/melodies/archetypes')}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
{/* Delete */}
|
||||
{isEdit && canEdit && (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => assignedCount > 0 ? setShowDeleteWarning(true) : setShowDelete(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Save */}
|
||||
{canEdit && (
|
||||
<Button variant="primary" loading={saving} onClick={handleSave}>
|
||||
{isEdit ? 'Rebuild & Save' : 'Create & Build'}
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
{/* ── Error banner ──────────────────────────────────────────────────── */}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
backgroundColor: 'var(--color-danger-bg)',
|
||||
border: '1px solid var(--color-danger)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
color: 'var(--color-danger)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Archetype Info ────────────────────────────────────────────────── */}
|
||||
<Card title="Archetype Info">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
|
||||
<FormField
|
||||
label="Name"
|
||||
name="arch-name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setHasUnsaved(true) }}
|
||||
placeholder="e.g. Doksologia_3k"
|
||||
/>
|
||||
<FormField
|
||||
label="PID (Playback ID)"
|
||||
name="arch-pid"
|
||||
required
|
||||
value={pid}
|
||||
onChange={(e) => { setPid(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '')); setHasUnsaved(true) }}
|
||||
placeholder="e.g. builtin_doksologia_3k"
|
||||
hint="Lowercase letters, numbers, and underscores only. Must be unique."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-1)' }}>
|
||||
<label style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-secondary)' }}>
|
||||
Steps <span style={{ color: 'var(--color-danger)' }}>*</span>
|
||||
</label>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)' }}>
|
||||
{countSteps(steps)} steps
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={steps}
|
||||
onChange={(e) => { setSteps(e.target.value); setHasUnsaved(true) }}
|
||||
rows={6}
|
||||
placeholder={'e.g. 1,2,2+1,1,2,3+1,0,3\n\n• Comma-separated values\n• Single bell: 1, 2, 3…\n• Multiple bells at once: 2+3+1\n• Silence: 0'}
|
||||
className="input"
|
||||
style={{ fontFamily: 'var(--font-family-mono)', resize: 'vertical', width: '100%' }}
|
||||
/>
|
||||
<p style={{ marginTop: 'var(--space-1)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
Each comma-separated value = one step. Bell numbers 1–16 (1 = highest pitch). Combine simultaneous bells with <code style={{ fontFamily: 'var(--font-family-mono)' }}>+</code>. Silence = 0.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ── Build Output ─────────────────────────────────────────────────── */}
|
||||
{isEdit && (
|
||||
<Card
|
||||
title="Build Output"
|
||||
subtitle={
|
||||
hasUnsaved
|
||||
? 'You have unsaved changes — binary reflects the last saved version.'
|
||||
: 'Binary and PROGMEM code are auto-rebuilt on every save.'
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)', marginBottom: progmemCode ? 'var(--space-4)' : 0 }}>
|
||||
|
||||
{/* Binary */}
|
||||
<div style={{
|
||||
padding: 'var(--space-4)',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
display: 'flex', flexDirection: 'column', gap: 'var(--space-3)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>
|
||||
Binary (.bsm)
|
||||
</div>
|
||||
<div style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', marginTop: 2 }}>
|
||||
For SD card playback on the controller
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge variant={binaryBuilt ? 'success' : 'neutral'} dot={false}>
|
||||
{binaryBuilt ? 'Built' : 'Not built'}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
{binaryUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => downloadBinary(binaryUrl, `${savedPid}.bsm`).catch((e) => setError(e.message))}
|
||||
icon={
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Download {savedPid}.bsm
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PROGMEM */}
|
||||
<div style={{
|
||||
padding: 'var(--space-4)',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
display: 'flex', flexDirection: 'column', gap: 'var(--space-2)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>
|
||||
PROGMEM Code
|
||||
</div>
|
||||
<div style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', marginTop: 2 }}>
|
||||
For built-in firmware (ESP32 PROGMEM)
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge variant={progmemCode ? 'success' : 'neutral'} dot={false}>
|
||||
{progmemCode ? 'Generated' : 'Not generated'}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PROGMEM code block */}
|
||||
{progmemCode && (
|
||||
<div style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: 'var(--space-2) var(--space-4)',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)' }}>
|
||||
PROGMEM C code — copy into firmware
|
||||
</span>
|
||||
<Button
|
||||
variant={codeCopied ? 'success' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => copyText(progmemCode, () => { setCodeCopied(true); setTimeout(() => setCodeCopied(false), 2000) })}
|
||||
>
|
||||
{codeCopied ? 'Copied!' : 'Copy Code'}
|
||||
</Button>
|
||||
</div>
|
||||
<pre
|
||||
ref={codeRef}
|
||||
style={{
|
||||
margin: 0, padding: 'var(--space-4)',
|
||||
backgroundColor: 'var(--color-bg-abyss)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
color: 'var(--color-text-primary)',
|
||||
overflowX: 'auto', maxHeight: 400, overflowY: 'auto',
|
||||
whiteSpace: 'pre',
|
||||
}}
|
||||
>
|
||||
{progmemCode}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Assigned Melodies ─────────────────────────────────────────────── */}
|
||||
{isEdit && (
|
||||
<Card
|
||||
title="Melodies Using This Archetype"
|
||||
subtitle={`${assignedCount} ${assignedCount === 1 ? 'melody' : 'melodies'} reference this archetype`}
|
||||
>
|
||||
{loadingAssigned ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-6)' }}>
|
||||
<Spinner />
|
||||
</div>
|
||||
) : assignedMelodies.length === 0 ? (
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', margin: 0 }}>
|
||||
No melodies are currently using this archetype.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)', maxHeight: 280, overflowY: 'auto' }}>
|
||||
{assignedMelodies.map((m) => (
|
||||
<Link
|
||||
key={m.id}
|
||||
to={`/melodies/${m.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
textDecoration: 'none',
|
||||
transition: 'background var(--transition-fast)',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-bg-island)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'}
|
||||
>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
|
||||
{getLocalizedValue(m.nameRaw, primaryLang, m.id)}
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-primary)' }}>
|
||||
Open →
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── New: build note ───────────────────────────────────────────────── */}
|
||||
{!isEdit && (
|
||||
<div style={{
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}>
|
||||
Binary and PROGMEM code will be generated automatically when you click "Create & Build".
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Modals ───────────────────────────────────────────────────────── */}
|
||||
|
||||
{/* Delete warning — archetype is in use */}
|
||||
{showDeleteWarning && (
|
||||
<div style={{ position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50, backgroundColor: 'rgba(0,0,0,0.6)' }}>
|
||||
<div style={{
|
||||
width: '100%', maxWidth: 440,
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
boxShadow: 'var(--shadow-xl)',
|
||||
padding: 'var(--space-6)',
|
||||
display: 'flex', flexDirection: 'column', gap: 'var(--space-4)',
|
||||
}}>
|
||||
<h3 style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)', margin: 0 }}>
|
||||
Archetype In Use
|
||||
</h3>
|
||||
<div style={{
|
||||
padding: 'var(--space-3)',
|
||||
backgroundColor: 'var(--color-warning-bg)',
|
||||
border: '1px solid var(--color-warning)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: 'var(--color-warning)',
|
||||
}}>
|
||||
<strong>"{name}"</strong> is assigned to <strong>{assignedCount} {assignedCount === 1 ? 'melody' : 'melodies'}</strong>. Deleting it will flag those melodies as <strong>outdated</strong>.
|
||||
</div>
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', margin: 0 }}>
|
||||
Do you still want to delete this archetype?
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}>
|
||||
<Button variant="ghost" onClick={() => setShowDeleteWarning(false)}>Cancel</Button>
|
||||
<Button variant="danger" onClick={() => { setShowDeleteWarning(false); setShowDelete(true) }}>
|
||||
Yes, Delete Anyway
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDelete}
|
||||
onClose={() => setShowDelete(false)}
|
||||
onConfirm={handleDelete}
|
||||
variant="danger"
|
||||
title="Delete Archetype"
|
||||
message={`Are you sure you want to permanently delete "${name}"? This will also delete the .bsm binary file. This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
/>
|
||||
|
||||
<PlaybackModal
|
||||
open={showPlayback}
|
||||
onClose={() => setShowPlayback(false)}
|
||||
melody={null}
|
||||
builtMelody={playbackBuiltMelody}
|
||||
files={null}
|
||||
archetypeCsv={steps}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
// frontend/src/pages/melodies/archetypes/ArchetypeList.jsx
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import api from '@/lib/api'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
import Button from '@/components/ui/Button'
|
||||
import DataTable from '@/components/ui/DataTable'
|
||||
import Pagination from '@/components/ui/Pagination'
|
||||
import SearchBar from '@/components/ui/SearchBar'
|
||||
import RowActions from '@/components/ui/RowActions'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import UploadArchetypeModal from '@/modals/bellcloud/melodies/UploadArchetypeModal'
|
||||
import CodeSnippetModal from '@/modals/bellcloud/melodies/CodeSnippetModal'
|
||||
import GenerateBuiltinModal from '@/modals/bellcloud/melodies/GenerateBuiltinModal'
|
||||
import { fmtRelative, fmtDateTime as fmtDateTimeCentral } from '@/lib/formatters'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function countSteps(s) {
|
||||
if (!s) return 0
|
||||
return s.split(',').length
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoValue) { return fmtRelative(isoValue) }
|
||||
|
||||
function getLocalizedValue(value, lang, fallback = '') {
|
||||
if (!value) return fallback
|
||||
if (typeof value === 'object') return value[lang] || value['en'] || Object.values(value)[0] || fallback
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (typeof parsed === 'object') return parsed[lang] || parsed['en'] || Object.values(parsed)[0] || fallback
|
||||
} catch { return value || fallback }
|
||||
return fallback
|
||||
}
|
||||
|
||||
// ─── Column definitions ───────────────────────────────────────────────────────
|
||||
|
||||
const ALL_COLUMNS = [
|
||||
{ key: 'name', label: 'Name', defaultOn: true, alwaysOn: true, sortable: true },
|
||||
{ key: 'pid', label: 'PID', defaultOn: true, sortable: true },
|
||||
{ key: 'steps', label: 'Steps', defaultOn: true, sortable: true, align: 'center' },
|
||||
{ key: 'builtin', label: 'Built-in', defaultOn: true, align: 'center' },
|
||||
{ key: 'binary', label: 'Binary', defaultOn: true, align: 'center' },
|
||||
{ key: 'progmem', label: 'C++ Code', defaultOn: true, align: 'center' },
|
||||
{ key: 'assigned', label: 'Assigned', defaultOn: true, sortable: true, align: 'center' },
|
||||
{ key: 'updated', label: 'Updated', defaultOn: true, sortable: true },
|
||||
]
|
||||
|
||||
const STORAGE_KEY = 'archetypeListColumnPrefs_v2'
|
||||
|
||||
function loadColumnPrefs() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const prefs = JSON.parse(raw)
|
||||
if (prefs.order && prefs.visible) return prefs
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return {
|
||||
order: ALL_COLUMNS.map((c) => c.key),
|
||||
visible: ALL_COLUMNS.filter((c) => c.defaultOn).map((c) => c.key),
|
||||
}
|
||||
}
|
||||
|
||||
function saveColumnPrefs(order, visible) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ order, visible })) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ─── Assigned Melodies Modal ──────────────────────────────────────────────────
|
||||
|
||||
function AssignedMelodiesModal({ data, primaryLang, onClose }) {
|
||||
if (!data) return null
|
||||
const { archetype, melodyDetails } = data
|
||||
return (
|
||||
<Modal
|
||||
open={Boolean(data)}
|
||||
onClose={onClose}
|
||||
title={`Melodies using "${archetype.name}"`}
|
||||
size="sm"
|
||||
footer={<Button variant="secondary" onClick={onClose}>Close</Button>}
|
||||
>
|
||||
{melodyDetails.length === 0 ? (
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', textAlign: 'center', padding: 'var(--space-8) 0' }}>
|
||||
No melodies assigned.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', maxHeight: '320px', overflowY: 'auto' }}>
|
||||
{melodyDetails.map((m) => (
|
||||
<Link
|
||||
key={m.id}
|
||||
to={`/melodies/${m.id}`}
|
||||
onClick={onClose}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 'var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-border)',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
textDecoration: 'none',
|
||||
transition: 'background var(--transition-fast)',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--color-bg-surface)'}
|
||||
>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
|
||||
{getLocalizedValue(m.nameRaw, primaryLang, m.id)}
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-accent)' }}>View →</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── ArchetypeList ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ArchetypeList() {
|
||||
const navigate = useNavigate()
|
||||
const { hasPermission } = useAuth()
|
||||
const canEdit = hasPermission('melodies', 'edit')
|
||||
|
||||
// Data
|
||||
const [archetypes, setArchetypes] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [primaryLang, setPrimaryLang] = useState('en')
|
||||
|
||||
// Filters / sort / pagination
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortKey, setSortKey] = useState('updated')
|
||||
const [sortDir, setSortDir] = useState('desc')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
// Columns
|
||||
const [colPrefs, setColPrefs] = useState(loadColumnPrefs)
|
||||
|
||||
// Modals / actions
|
||||
const [deleteTarget, setDeleteTarget] = useState(null)
|
||||
const [assignedModal, setAssignedModal] = useState(null)
|
||||
const [codeSnippetTarget, setCodeSnippetTarget] = useState(null)
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
const [showGenerate, setShowGenerate] = useState(false)
|
||||
const [togglingBuiltin, setTogglingBuiltin] = useState(null)
|
||||
const [loadingAssigned, setLoadingAssigned] = useState(false)
|
||||
|
||||
// ── Load ───────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
api.get('/settings/melody').then((ms) => setPrimaryLang(ms?.primary_language || 'en')).catch(() => {})
|
||||
loadArchetypes({ verify: true })
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadArchetypes = async ({ verify = false } = {}) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await api.get('/builder/melodies')
|
||||
const list = data.melodies || []
|
||||
setArchetypes(list)
|
||||
|
||||
if (verify) {
|
||||
let didPrune = false
|
||||
await Promise.all(list.map(async (archetype) => {
|
||||
const ids = archetype.assigned_melody_ids || []
|
||||
if (!ids.length) return
|
||||
const results = await Promise.allSettled(ids.map((mid) => api.get(`/melodies/${mid}`)))
|
||||
const missingIds = results.map((r, i) => (!r.value || r.status === 'rejected' ? ids[i] : null)).filter(Boolean)
|
||||
for (const mid of missingIds) {
|
||||
try { await api.post(`/builder/melodies/${archetype.id}/unassign?firestore_melody_id=${mid}`); didPrune = true }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
}))
|
||||
if (didPrune) {
|
||||
const refreshed = await api.get('/builder/melodies')
|
||||
setArchetypes(refreshed.melodies || [])
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load archetypes')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────────────
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
try {
|
||||
await api.delete(`/builder/melodies/${deleteTarget.id}`)
|
||||
setDeleteTarget(null)
|
||||
loadArchetypes()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleBuiltin = async (e, archetype) => {
|
||||
e.stopPropagation()
|
||||
if (togglingBuiltin === archetype.id) return
|
||||
setTogglingBuiltin(archetype.id)
|
||||
try {
|
||||
const updated = await api.post(`/builder/melodies/${archetype.id}/toggle-builtin`)
|
||||
setArchetypes((prev) => prev.map((a) => a.id === archetype.id ? { ...a, is_builtin: updated.is_builtin } : a))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setTogglingBuiltin(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowAssigned = async (e, archetype) => {
|
||||
e.stopPropagation()
|
||||
const ids = archetype.assigned_melody_ids || []
|
||||
if (!ids.length) return
|
||||
setLoadingAssigned(true)
|
||||
try {
|
||||
const results = await Promise.allSettled(ids.map((mid) => api.get(`/melodies/${mid}`)))
|
||||
const melodyDetails = results
|
||||
.filter((r) => r.status === 'fulfilled' && r.value)
|
||||
.map((r) => ({ id: r.value.id, nameRaw: r.value.information?.name }))
|
||||
const missingIds = results.map((r, i) => (r.status === 'rejected' || !r.value ? ids[i] : null)).filter(Boolean)
|
||||
for (const mid of missingIds) {
|
||||
try { await api.post(`/builder/melodies/${archetype.id}/unassign?firestore_melody_id=${mid}`) }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
if (missingIds.length) loadArchetypes()
|
||||
setAssignedModal({ archetype, melodyDetails })
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoadingAssigned(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadBinary = async (e, archetype) => {
|
||||
e.stopPropagation()
|
||||
const token = localStorage.getItem('access_token')
|
||||
try {
|
||||
const res = await fetch(`/api/builder/melodies/${archetype.id}/download`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new Error(`Download failed: ${res.statusText}`)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = `${archetype.name || archetype.id}.bsm`; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Column management ──────────────────────────────────────────────────────
|
||||
const handleColumnChange = (nextVisible) => {
|
||||
setColPrefs(() => {
|
||||
const next = { order: nextVisible, visible: nextVisible }
|
||||
saveColumnPrefs(next.order, next.visible)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// ── Sort & filter ──────────────────────────────────────────────────────────
|
||||
const handleSort = (key, dir) => { setSortKey(key); setSortDir(dir) }
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
let rows = archetypes
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
rows = rows.filter((a) => (a.name || '').toLowerCase().includes(q) || (a.pid || '').toLowerCase().includes(q))
|
||||
}
|
||||
return [...rows].sort((a, b) => {
|
||||
let av, bv
|
||||
switch (sortKey) {
|
||||
case 'name': av = (a.name || '').toLowerCase(); bv = (b.name || '').toLowerCase(); break
|
||||
case 'pid': av = (a.pid || '').toLowerCase(); bv = (b.pid || '').toLowerCase(); break
|
||||
case 'steps': av = countSteps(a.steps); bv = countSteps(b.steps); break
|
||||
case 'assigned': av = (a.assigned_melody_ids || []).length; bv = (b.assigned_melody_ids || []).length; break
|
||||
case 'updated':
|
||||
default: av = a.updated_at || ''; bv = b.updated_at || ''
|
||||
}
|
||||
if (av < bv) return sortDir === 'asc' ? -1 : 1
|
||||
if (av > bv) return sortDir === 'asc' ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
}, [archetypes, search, sortKey, sortDir])
|
||||
|
||||
const total = filteredRows.length
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const safePage = Math.min(page, totalPages)
|
||||
const pagedRows = filteredRows.slice((safePage - 1) * pageSize, safePage * pageSize)
|
||||
|
||||
// ── Cell renderer ──────────────────────────────────────────────────────────
|
||||
const renderCell = (key, row) => {
|
||||
switch (key) {
|
||||
case 'name':
|
||||
return (
|
||||
<span style={{ fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
|
||||
{row.name || '—'}
|
||||
</span>
|
||||
)
|
||||
|
||||
case 'pid':
|
||||
return (
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}>
|
||||
{row.pid || '—'}
|
||||
</span>
|
||||
)
|
||||
|
||||
case 'steps':
|
||||
return (
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>
|
||||
{countSteps(row.steps)}
|
||||
</span>
|
||||
)
|
||||
|
||||
case 'builtin':
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={(e) => handleToggleBuiltin(e, row)}
|
||||
disabled={togglingBuiltin === row.id}
|
||||
title={row.is_builtin ? 'Click to unmark as built-in' : 'Click to mark as built-in'}
|
||||
style={{
|
||||
padding: '2px 10px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
cursor: 'pointer',
|
||||
border: row.is_builtin ? '1px solid rgba(139,92,246,0.35)' : '1px solid var(--color-border)',
|
||||
backgroundColor: row.is_builtin ? 'rgba(139,92,246,0.12)' : 'var(--color-bg-elevated)',
|
||||
color: row.is_builtin ? '#a78bfa' : 'var(--color-text-muted)',
|
||||
opacity: togglingBuiltin === row.id ? 0.5 : 1,
|
||||
transition: 'opacity var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
{row.is_builtin ? 'Built-in' : '—'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'binary':
|
||||
if (!row.binary_path) return <span style={{ color: 'var(--color-text-muted)' }}>—</span>
|
||||
return (
|
||||
<Button
|
||||
variant="success"
|
||||
size="sm"
|
||||
onClick={(e) => handleDownloadBinary(e, row)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)
|
||||
|
||||
case 'progmem':
|
||||
if (!row.progmem_code) return <span style={{ color: 'var(--color-text-muted)' }}>—</span>
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); setCodeSnippetTarget(row) }}
|
||||
>
|
||||
View Code
|
||||
</Button>
|
||||
)
|
||||
|
||||
case 'assigned': {
|
||||
const count = (row.assigned_melody_ids || []).length
|
||||
if (count === 0) return <span style={{ color: 'var(--color-text-muted)' }}>—</span>
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
loading={loadingAssigned}
|
||||
onClick={(e) => handleShowAssigned(e, row)}
|
||||
>
|
||||
{count} {count === 1 ? 'melody' : 'melodies'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
case 'updated':
|
||||
return (
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }} title={row.updated_at ? fmtDateTimeCentral(row.updated_at) : ''}>
|
||||
{formatRelativeTime(row.updated_at)}
|
||||
</span>
|
||||
)
|
||||
|
||||
default:
|
||||
return <span style={{ color: 'var(--color-text-muted)' }}>—</span>
|
||||
}
|
||||
}
|
||||
|
||||
// ── Table columns ──────────────────────────────────────────────────────────
|
||||
const tableColumns = [
|
||||
...colPrefs.visible.map((key) => {
|
||||
const col = ALL_COLUMNS.find((c) => c.key === key)
|
||||
return col ? { ...col, render: (row) => renderCell(key, row) } : null
|
||||
}).filter(Boolean),
|
||||
{
|
||||
key: '__actions',
|
||||
label: '',
|
||||
width: '90px',
|
||||
align: 'right',
|
||||
render: (row) => (
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Icon name="edit" size={14} />,
|
||||
onClick: () => navigate(`/melodies/archetypes/${row.id}`),
|
||||
},
|
||||
{
|
||||
label: 'Load to Composer',
|
||||
icon: <Icon name="settings" size={14} />,
|
||||
onClick: () => navigate('/melodies/composer', { state: { archetype: row } }),
|
||||
},
|
||||
{
|
||||
label: 'Download Binary',
|
||||
icon: <Icon name="download" size={14} />,
|
||||
onClick: (e) => handleDownloadBinary(e || { stopPropagation: () => {} }, row),
|
||||
},
|
||||
...(canEdit ? [
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: <Icon name="delete" size={14} />,
|
||||
color: 'var(--color-danger)',
|
||||
onClick: () => setDeleteTarget(row),
|
||||
divider: true,
|
||||
},
|
||||
] : []),
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<PageHeader
|
||||
title="Archetype Manager"
|
||||
subtitle={search ? `${total} result${total !== 1 ? 's' : ''} of ${archetypes.length}` : `${archetypes.length} archetype${archetypes.length !== 1 ? 's' : ''}`}
|
||||
>
|
||||
<Button variant="secondary" onClick={() => setShowGenerate(true)}>
|
||||
Generate Built-in List
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowUpload(true)}>
|
||||
Upload Archetype
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button variant="primary" onClick={() => navigate('/melodies/archetypes/new')}>
|
||||
<Icon name="add" size={14} />
|
||||
New Archetype
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
{/* Filter toolbar */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-3)', alignItems: 'center', minWidth: 0 }}>
|
||||
<div style={{ flex: '1 1 0', minWidth: '180px' }}>
|
||||
<SearchBar
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by name or PID…"
|
||||
/>
|
||||
</div>
|
||||
{search && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setSearch('')}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: 'var(--space-4)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
backgroundColor: 'var(--color-danger-bg)',
|
||||
border: '1px solid var(--color-danger)',
|
||||
color: 'var(--color-danger)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
data={pagedRows}
|
||||
loading={loading}
|
||||
keyField="id"
|
||||
emptyMessage="No archetypes found"
|
||||
emptyDescription={search ? 'Try adjusting your search.' : 'Create your first archetype to get started.'}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
onRowClick={(row) => navigate(`/melodies/archetypes/${row.id}`)}
|
||||
allColumns={[...ALL_COLUMNS, { key: '__actions', label: '', pickerLabel: 'Actions', alwaysOn: true }]}
|
||||
visibleKeys={colPrefs.visible}
|
||||
onColumnChange={handleColumnChange}
|
||||
footer={
|
||||
<Pagination
|
||||
page={safePage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
onSizeChange={(size) => { setPageSize(size); setPage(1) }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Modals */}
|
||||
<UploadArchetypeModal
|
||||
open={showUpload}
|
||||
existingArchetypes={archetypes}
|
||||
onClose={() => setShowUpload(false)}
|
||||
onUploaded={() => loadArchetypes()}
|
||||
/>
|
||||
|
||||
<GenerateBuiltinModal
|
||||
open={showGenerate}
|
||||
onClose={() => setShowGenerate(false)}
|
||||
/>
|
||||
|
||||
<CodeSnippetModal
|
||||
archetype={codeSnippetTarget}
|
||||
onClose={() => setCodeSnippetTarget(null)}
|
||||
/>
|
||||
|
||||
<AssignedMelodiesModal
|
||||
data={assignedModal}
|
||||
primaryLang={primaryLang}
|
||||
onClose={() => setAssignedModal(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
variant="danger"
|
||||
title="Delete Archetype"
|
||||
message={
|
||||
deleteTarget
|
||||
? `"${deleteTarget.name}" will be permanently deleted. Any melodies assigned to this archetype will lose their binary reference.`
|
||||
: ''
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={handleDelete}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// TODO: implement
|
||||
export default function CommandPanel() {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// TODO: implement
|
||||
export default function LogViewer() {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// TODO: implement
|
||||
export default function MqttDashboard() {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,892 @@
|
||||
// frontend/src/pages/users/UserDetail.jsx
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { createPortal } from 'react-dom'
|
||||
import api from '@/lib/api'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import { ProfilePageHeaderActions } from '@/components/ui/ProfilePageHeader'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Card from '@/components/ui/Card'
|
||||
import StatusBadge from '@/components/ui/StatusBadge'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import Select from '@/components/ui/Select'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||||
import { fmtDateMedium } from '@/lib/formatters'
|
||||
import DeleteUserModal from '@/modals/bellcloud/users/DeleteUserModal'
|
||||
import EntryFormModal from '@/modals/crm/helpdesk/EntryFormModal'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function statusVariant(status) {
|
||||
if (status === 'active') return 'success'
|
||||
if (status === 'blocked') return 'danger'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
// ─── ActionMenu — primary "Actions" dropdown for page headers ─────────────────
|
||||
// Reuses the existing .row-actions-menu / .row-actions-item CSS classes.
|
||||
|
||||
function ActionMenu({ actions = [], loading = false }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, right: 0 })
|
||||
const btnRef = useRef(null)
|
||||
const menuRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onMouseDown(e) {
|
||||
if (btnRef.current?.contains(e.target)) return
|
||||
if (menuRef.current?.contains(e.target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
function onScroll() { setOpen(false) }
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
document.addEventListener('scroll', onScroll, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onMouseDown)
|
||||
document.removeEventListener('scroll', onScroll, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function handleToggle(e) {
|
||||
e.stopPropagation()
|
||||
if (!open && btnRef.current) {
|
||||
const r = btnRef.current.getBoundingClientRect()
|
||||
setPos({ top: r.bottom + 6, right: window.innerWidth - r.right })
|
||||
}
|
||||
setOpen((v) => !v)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
ref={btnRef}
|
||||
variant="primary"
|
||||
size="md"
|
||||
loading={loading}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
onClick={handleToggle}
|
||||
iconRight={
|
||||
<svg
|
||||
width="13" height="13" viewBox="0 0 12 12"
|
||||
fill="none" stroke="currentColor" strokeWidth="1.75"
|
||||
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
|
||||
style={{ transition: 'transform 0.15s', transform: open ? 'rotate(180deg)' : 'none', marginLeft: '-2px' }}
|
||||
>
|
||||
<path d="M2 4l4 4 4-4" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
className="row-actions-menu"
|
||||
style={{ position: 'fixed', top: pos.top, right: pos.right, zIndex: 9999, minWidth: '180px' }}
|
||||
>
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
role="menuitem"
|
||||
className="row-actions-item"
|
||||
style={{
|
||||
color: action.color ?? 'var(--color-text-secondary)',
|
||||
borderTop: action.divider ? '1px solid var(--color-border)' : 'none',
|
||||
marginTop: action.divider ? 'var(--space-1)' : 0,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
action.onClick?.()
|
||||
}}
|
||||
>
|
||||
{action.icon && <span className="row-actions-item-icon">{action.icon}</span>}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Page-internal sub-components ─────────────────────────────────────────────
|
||||
|
||||
function Field({ label, children, mono = false }) {
|
||||
const isEmpty = children == null || children === ''
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
||||
<dt className="card-field-label">{label}</dt>
|
||||
<dd
|
||||
style={{
|
||||
fontSize: 'var(--font-size-base)',
|
||||
color: isEmpty ? 'var(--color-text-muted)' : 'var(--color-text-primary)',
|
||||
fontFamily: mono ? 'var(--font-family-mono)' : 'inherit',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
}}
|
||||
>
|
||||
{isEmpty ? '—' : children}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Eye / EyeOff inline SVGs — Icon component doesn't have these
|
||||
function EyeIcon({ off = false, size = 16 }) {
|
||||
return off ? (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94" />
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PinField({ label, value }) {
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 'var(--space-4)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
backgroundColor: 'var(--color-bg-abyss)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<p className="card-field-label" style={{ marginBottom: 'var(--space-2)' }}>
|
||||
{label}
|
||||
</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{value ? (
|
||||
<>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
fontSize: 'var(--font-size-md)',
|
||||
letterSpacing: revealed ? '0.25em' : '0.4em',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
{revealed ? value : '•'.repeat(String(value).length)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setRevealed((v) => !v)}
|
||||
aria-label={revealed ? 'Hide PIN' : 'Show PIN'}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: revealed ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
padding: 'var(--space-1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'color 0.15s',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<EyeIcon off={revealed} size={16} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>Not set</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityDot({ color }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
backgroundColor: color,
|
||||
boxShadow: `0 0 0 4px ${color}26`,
|
||||
flexShrink: 0,
|
||||
marginTop: '4px',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function OnlineDot({ isOnline }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
backgroundColor: isOnline ? 'var(--color-success)' : 'var(--color-bg-island)',
|
||||
boxShadow: isOnline ? '0 0 6px var(--color-success)' : 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title={isOnline ? 'Online' : 'Offline'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Status pill (reused from IssuesTab pattern) ──────────────────────────────
|
||||
|
||||
const STATUS_META = {
|
||||
open: { label: 'Open', color: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
|
||||
researching: { label: 'Researching', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
|
||||
resolved: { label: 'Resolved', color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
|
||||
}
|
||||
|
||||
function StatusPill({ status }) {
|
||||
const m = STATUS_META[status] || { label: status, color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)' }
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
backgroundColor: m.bg,
|
||||
border: `1px solid ${m.color}33`,
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: m.color,
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ width: 5, height: 5, borderRadius: '50%', backgroundColor: m.color, flexShrink: 0 }} />
|
||||
{m.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Notes & Issues card (Postgres /notes system) ─────────────────────────────
|
||||
|
||||
function NotesCard({ userId, userName, canEdit }) {
|
||||
const { toast } = useToast()
|
||||
const [entries, setEntries] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [entryModal, setEntryModal] = useState({ open: false, entry: null, defaultType: 'note' })
|
||||
const [deleteId, setDeleteId] = useState(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.get(`/notes/by-entity/app_user/${userId}`)
|
||||
// newest first
|
||||
setEntries(Array.isArray(data) ? [...data].reverse() : [])
|
||||
} catch { setEntries([]) }
|
||||
finally { setLoading(false) }
|
||||
}, [userId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
await api.delete(`/notes/${deleteId}`)
|
||||
toast.success('Deleted', 'Entry removed.')
|
||||
setDeleteId(null)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to delete.')
|
||||
} finally { setDeleting(false) }
|
||||
}
|
||||
|
||||
const openCreate = (defaultType) => setEntryModal({ open: true, entry: null, defaultType })
|
||||
const openEdit = (entry) => setEntryModal({ open: true, entry, defaultType: entry.type })
|
||||
|
||||
const issues = entries.filter(e => e.type === 'issue')
|
||||
const notes = entries.filter(e => e.type === 'note')
|
||||
const total = entries.length
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
style={{ height: '100%' }}
|
||||
title={`Notes & Issues (${total})`}
|
||||
icon={<Icon name="info" size={15} color="var(--color-primary)" />}
|
||||
padding={false}
|
||||
action={canEdit && (
|
||||
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
|
||||
<Button variant="ghost" size="sm" onClick={() => openCreate('note')}>+ Note</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openCreate('issue')}>+ Issue</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{/* card-body-flush makes this a flex:1 container — we manage all padding */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
|
||||
{/* Summary pills */}
|
||||
{issues.length > 0 && notes.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 'var(--space-2)', flexWrap: 'wrap', padding: 'var(--space-3) var(--space-8) 0' }}>
|
||||
<StatusBadge variant={issues.some(i => i.status !== 'resolved') ? 'danger' : 'success'}>
|
||||
{issues.length} issue{issues.length !== 1 ? 's' : ''}
|
||||
</StatusBadge>
|
||||
<StatusBadge variant="neutral">{notes.length} note{notes.length !== 1 ? 's' : ''}</StatusBadge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable list */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--space-2)',
|
||||
minHeight: 0,
|
||||
padding: 'var(--space-3) var(--space-8) var(--space-8)',
|
||||
}}>
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-6)' }}>
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', textAlign: 'center', padding: 'var(--space-6) 0', fontStyle: 'italic' }}>
|
||||
No notes or issues linked to this user.
|
||||
</p>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
onClick={() => canEdit && openEdit(entry)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 'var(--space-3)',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
backgroundColor: 'var(--color-bg-abyss)',
|
||||
border: '1px solid var(--color-border)',
|
||||
cursor: canEdit ? 'pointer' : 'default',
|
||||
transition: 'background-color 0.15s',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={e => { if (canEdit) e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'var(--color-bg-abyss)' }}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-1)', flexWrap: 'wrap' }}>
|
||||
{entry.type === 'issue'
|
||||
? <StatusPill status={entry.status} />
|
||||
: <StatusBadge variant="neutral">Note</StatusBadge>
|
||||
}
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: 'var(--color-text-primary)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{entry.title}
|
||||
</span>
|
||||
</div>
|
||||
{entry.body && (
|
||||
<p style={{
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
marginBottom: 'var(--space-1)',
|
||||
lineHeight: 1.5,
|
||||
margin: '0 0 var(--space-1)',
|
||||
}}>
|
||||
{entry.body.replace(/<[^>]*>/g, '')}
|
||||
</p>
|
||||
)}
|
||||
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', margin: 0 }}>
|
||||
{[entry.author_name, entry.created_at ? fmtDateMedium(entry.created_at) : null].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setDeleteId(entry.id) }}
|
||||
aria-label="Delete entry"
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--color-text-muted)', padding: 'var(--space-1)',
|
||||
display: 'flex', alignItems: 'center', flexShrink: 0,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-danger)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)' }}
|
||||
>
|
||||
<Icon name="delete" size={14} color="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Entry form modal */}
|
||||
<EntryFormModal
|
||||
open={entryModal.open}
|
||||
entry={entryModal.entry}
|
||||
defaultType={entryModal.defaultType}
|
||||
prefilledLinks={entryModal.entry ? undefined : [{ entity_type: 'app_user', entity_id: userId, display_name: userName, locked: true }]}
|
||||
knownEntities={userId ? { [userId]: userName } : undefined}
|
||||
onClose={() => setEntryModal({ open: false, entry: null, defaultType: 'note' })}
|
||||
onSaved={() => { setEntryModal({ open: false, entry: null, defaultType: 'note' }); load() }}
|
||||
onDelete={canEdit ? (id) => { setEntryModal({ open: false, entry: null, defaultType: 'note' }); setDeleteId(id) } : null}
|
||||
/>
|
||||
|
||||
{/* Delete confirm */}
|
||||
<ConfirmDialog
|
||||
open={!!deleteId}
|
||||
variant="danger"
|
||||
title="Delete entry?"
|
||||
message="This entry and all its linked entities will be permanently removed."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={handleDelete}
|
||||
onClose={() => setDeleteId(null)}
|
||||
loading={deleting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── UserDetail ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function UserDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { hasPermission } = useAuth()
|
||||
const { toast } = useToast()
|
||||
const canEdit = hasPermission('app_users', 'edit')
|
||||
const photoInputRef = useRef(null)
|
||||
|
||||
const [user, setUser] = useState(null)
|
||||
const [devices, setDevices] = useState([])
|
||||
const [allDevices, setAllDevices] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [blocking, setBlocking] = useState(false)
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState('')
|
||||
const [uploadingPhoto, setUploadingPhoto] = useState(false)
|
||||
|
||||
// Device assignment
|
||||
const [showAssignPanel, setShowAssignPanel] = useState(false)
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState('')
|
||||
const [assigningDevice, setAssigningDevice] = useState(false)
|
||||
|
||||
// ── Load ───────────────────────────────────────────────────────────────────
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [u, d] = await Promise.all([
|
||||
api.get(`/users/${id}`),
|
||||
api.get(`/users/${id}/devices`),
|
||||
])
|
||||
setUser(u)
|
||||
setDevices(Array.isArray(d) ? d : [])
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load user.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
const loadAllDevices = async () => {
|
||||
try {
|
||||
const data = await api.get('/devices')
|
||||
setAllDevices(data.devices ?? [])
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────────────
|
||||
const handleDelete = async () => {
|
||||
setDeleteError('')
|
||||
try {
|
||||
await api.delete(`/users/${id}`)
|
||||
toast.success('User deleted')
|
||||
navigate('/users')
|
||||
} catch (err) {
|
||||
setDeleteError(err.message || 'Failed to delete user.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlockToggle = async () => {
|
||||
setBlocking(true)
|
||||
try {
|
||||
const endpoint = user.status === 'blocked' ? `/users/${id}/unblock` : `/users/${id}/block`
|
||||
const updated = await api.post(endpoint)
|
||||
setUser(updated)
|
||||
toast.success(user.status === 'blocked' ? 'User unblocked' : 'User blocked')
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to update user status.')
|
||||
} finally {
|
||||
setBlocking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAssignDevice = async () => {
|
||||
if (!selectedDeviceId) return
|
||||
setAssigningDevice(true)
|
||||
try {
|
||||
await api.post(`/users/${id}/devices/${selectedDeviceId}`)
|
||||
const d = await api.get(`/users/${id}/devices`)
|
||||
setDevices(Array.isArray(d) ? d : [])
|
||||
setSelectedDeviceId('')
|
||||
setShowAssignPanel(false)
|
||||
toast.success('Device assigned')
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to assign device.')
|
||||
} finally {
|
||||
setAssigningDevice(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnassignDevice = async (deviceId) => {
|
||||
try {
|
||||
await api.delete(`/users/${id}/devices/${deviceId}`)
|
||||
const d = await api.get(`/users/${id}/devices`)
|
||||
setDevices(Array.isArray(d) ? d : [])
|
||||
toast.success('Device removed')
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to remove device.')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePhotoUpload = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploadingPhoto(true)
|
||||
try {
|
||||
const result = await api.upload(`/users/${id}/photo`, file)
|
||||
setUser((prev) => ({ ...prev, photo_url: result.photo_url }))
|
||||
toast.success('Photo updated')
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to upload photo.')
|
||||
} finally {
|
||||
setUploadingPhoto(false)
|
||||
if (photoInputRef.current) photoInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loading / error states ─────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-16)' }}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !user) {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<div role="alert" style={{ backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', borderRadius: 'var(--radius-md)', padding: 'var(--space-4)', color: 'var(--color-danger)', fontSize: 'var(--font-size-sm)' }}>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) return null
|
||||
|
||||
const isBlocked = user.status === 'blocked'
|
||||
const assignedDeviceIds = new Set(devices.map((d) => d.id))
|
||||
const availableDevices = allDevices.filter((d) => !assignedDeviceIds.has(d.id))
|
||||
const friendCount = user.friendsList?.length ?? 0
|
||||
const invitedCount = user.friendsInvited?.length ?? 0
|
||||
|
||||
// Actions menu items
|
||||
const actionItems = canEdit ? [
|
||||
{
|
||||
label: isBlocked ? 'Unblock User' : 'Block User',
|
||||
icon: <Icon name={isBlocked ? 'check' : 'warning'} size={14} />,
|
||||
onClick: handleBlockToggle,
|
||||
},
|
||||
{
|
||||
label: 'Edit User',
|
||||
icon: <Icon name="edit" size={14} />,
|
||||
onClick: () => navigate(`/users/${id}/edit`),
|
||||
},
|
||||
{
|
||||
label: 'Change Photo',
|
||||
icon: <Icon name="upload" size={14} />,
|
||||
onClick: () => photoInputRef.current?.click(),
|
||||
},
|
||||
{
|
||||
label: 'Delete User',
|
||||
icon: <Icon name="delete" size={14} />,
|
||||
color: 'var(--color-danger)',
|
||||
divider: true,
|
||||
onClick: () => { setDeleteError(''); setShowDelete(true) },
|
||||
},
|
||||
] : []
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
|
||||
{/* Page header — no breadcrumbs */}
|
||||
<ProfilePageHeaderActions
|
||||
name={user.display_name || 'Unnamed User'}
|
||||
subtitle={[user.userTitle, user.email].filter(Boolean).join(' • ') || undefined}
|
||||
photoUrl={user.photo_url}
|
||||
onPhotoClick={canEdit ? () => photoInputRef.current?.click() : undefined}
|
||||
badge={
|
||||
<StatusBadge variant={statusVariant(user.status)} dot={false}>
|
||||
{user.status ? user.status.charAt(0).toUpperCase() + user.status.slice(1) : 'Unknown'}
|
||||
</StatusBadge>
|
||||
}
|
||||
>
|
||||
{canEdit && <ActionMenu actions={actionItems} loading={blocking} />}
|
||||
</ProfilePageHeaderActions>
|
||||
|
||||
{/* Hidden photo input */}
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handlePhotoUpload}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{/* Inline error banner */}
|
||||
{error && (
|
||||
<div role="alert" style={{ backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', borderRadius: 'var(--radius-md)', padding: 'var(--space-3) var(--space-4)', color: 'var(--color-danger)', fontSize: 'var(--font-size-sm)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Layout grid ────────────────────────────────────────────────────── */}
|
||||
<div
|
||||
className="user-detail-grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gridTemplateRows: 'auto 320px auto',
|
||||
gap: 'var(--space-6)',
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
|
||||
{/* ── ROW 1 ── Account Information (span 2) + Security Access (span 1) */}
|
||||
{/* Both cells use display:flex+flex-col+height:100% to match heights */}
|
||||
|
||||
<div style={{ gridColumn: 'span 2', display: 'flex', flexDirection: 'column' }}>
|
||||
<Card style={{ flex: 1 }} title="Account Information" icon={<Icon name="staff" size={15} color="var(--color-primary)" />}>
|
||||
<dl
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: 'var(--space-5)',
|
||||
}}
|
||||
>
|
||||
<Field label="Phone Number">{user.phone_number}</Field>
|
||||
<Field label="Login Email">{user.email}</Field>
|
||||
<Field label="Title">{user.userTitle}</Field>
|
||||
<Field label="Unique Identifier (UID)">
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: 'var(--color-primary)',
|
||||
backgroundColor: 'var(--color-primary-subtle)',
|
||||
padding: '2px var(--space-2)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
{user.uid || user.id || '—'}
|
||||
</span>
|
||||
</Field>
|
||||
{/* Bio — always present, full width, 3rd row */}
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<Field label="Bio">
|
||||
{user.bio ? (
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', lineHeight: '1.6', color: 'var(--color-text-secondary)', fontWeight: 'var(--font-weight-normal)' }}>
|
||||
{user.bio}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic', fontWeight: 'var(--font-weight-normal)' }}>
|
||||
No bio
|
||||
</span>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Card style={{ flex: 1 }} title="Security Access" icon={<Icon name="settings" size={15} color="var(--color-primary)" />}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
<PinField label="Master Settings PIN" value={user.settingsPIN} />
|
||||
<PinField label="Quick Settings PIN" value={user.quickSettingsPIN} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── ROW 2 ── System Activity (span 1) + Notes (span 2, scrollable) ── */}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Card style={{ flex: 1 }} title="System Activity" icon={<Icon name="refresh" size={15} color="var(--color-primary)" />}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-4)', alignItems: 'flex-start' }}>
|
||||
<ActivityDot color="var(--color-primary)" />
|
||||
<div>
|
||||
<p className="card-field-label" style={{ marginBottom: 'var(--space-1)' }}>
|
||||
Created
|
||||
</p>
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
|
||||
{user.created_time || user.createdAt || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-4)', alignItems: 'flex-start' }}>
|
||||
<ActivityDot color="var(--color-success)" />
|
||||
<div>
|
||||
<p className="card-field-label" style={{ marginBottom: 'var(--space-1)' }}>
|
||||
Last Active
|
||||
</p>
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
|
||||
{user.lastActive || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Notes — spans 2 cols, row 2 is 320px tall, card fills and scrolls */}
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<NotesCard userId={id} userName={user?.display_name || user?.email || ''} canEdit={canEdit} />
|
||||
</div>
|
||||
|
||||
{/* ── ROW 3 ── Social (span 1) + Linked Devices (span 2) ─────────────── */}
|
||||
|
||||
<Card title="Social" icon={<Icon name="customers" size={15} color="var(--color-primary)" />}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 'var(--space-4)' }}>
|
||||
<span style={{ fontSize: 'var(--font-size-xl)', fontWeight: 'var(--font-weight-bold)', fontFamily: 'var(--font-family-display)', color: 'var(--color-primary)', lineHeight: 1 }}>
|
||||
{friendCount}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ paddingTop: 'var(--space-4)', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>Invited Members</span>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>{invitedCount}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<Card
|
||||
title={`Linked Devices (${devices.length})`}
|
||||
icon={<Icon name="devices" size={15} color="var(--color-primary)" />}
|
||||
action={canEdit && (
|
||||
<Button variant="ghost" size="sm" onClick={() => { setShowAssignPanel((v) => !v); if (!showAssignPanel) loadAllDevices() }}>
|
||||
{showAssignPanel ? 'Cancel' : 'Assign Device'}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
|
||||
{/* Assign device inline panel */}
|
||||
{showAssignPanel && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 'var(--space-4)',
|
||||
padding: 'var(--space-4)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
backgroundColor: 'var(--color-bg-abyss)',
|
||||
border: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
gap: 'var(--space-3)',
|
||||
alignItems: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Select value={selectedDeviceId} onChange={(e) => setSelectedDeviceId(e.target.value)} placeholder="Choose a device…">
|
||||
<option value="">Choose a device…</option>
|
||||
{availableDevices.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.device_name || 'Unnamed'} ({d.device_id || d.id})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant="primary" size="sm" loading={assigningDevice} disabled={!selectedDeviceId} onClick={handleAssignDevice}>
|
||||
Assign
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device list */}
|
||||
{devices.length === 0 ? (
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>No devices assigned.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||||
{devices.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 'var(--space-3)', borderRadius: 'var(--radius-lg)', backgroundColor: 'var(--color-bg-abyss)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', minWidth: 0 }}>
|
||||
<OnlineDot isOnline={device.is_Online} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<button
|
||||
onClick={() => navigate(`/devices/${device.id}`)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-accent)', textAlign: 'left', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '160px' }}
|
||||
>
|
||||
{device.device_name || 'Unnamed Device'}
|
||||
</button>
|
||||
<p style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', marginTop: '2px' }}>
|
||||
{device.device_id || device.id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<Button variant="danger" size="sm" onClick={() => handleUnassignDevice(device.id)}>Remove</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Responsive grid */}
|
||||
<style>{`
|
||||
@media (max-width: 1024px) {
|
||||
.user-detail-grid { grid-template-columns: repeat(2, 1fr) !important; }
|
||||
.user-detail-grid > [style*="span 2"] { grid-column: span 2 !important; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.user-detail-grid { grid-template-columns: 1fr !important; }
|
||||
.user-detail-grid > [style*="span 2"] { grid-column: span 1 !important; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Delete modal */}
|
||||
<DeleteUserModal
|
||||
user={showDelete ? user : null}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => { setShowDelete(false); setDeleteError('') }}
|
||||
error={deleteError}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
// frontend/src/pages/users/UserForm.jsx
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import api from '@/lib/api'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Card from '@/components/ui/Card'
|
||||
import FormField from '@/components/ui/FormField'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
|
||||
// ─── Status dual-button ──────────────────────────────────────────────────────
|
||||
|
||||
function StatusToggle({ value, onChange }) {
|
||||
const opts = [
|
||||
{ value: 'active', label: 'Active', activeBg: 'var(--color-success-bg)', activeFg: 'var(--color-success)' },
|
||||
{ value: 'blocked', label: 'Blocked', activeBg: 'var(--color-danger-bg)', activeFg: 'var(--color-danger)' },
|
||||
]
|
||||
return (
|
||||
<div className="flex flex-col" style={{ gap: 'var(--space-2)' }}>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
lineHeight: 'var(--line-height-tight)',
|
||||
}}>
|
||||
Status
|
||||
</span>
|
||||
{/* Match the width of one column in the 2-col grid above */}
|
||||
<div style={{ width: 'calc(50% - var(--space-2))' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid var(--color-border-strong)',
|
||||
}}>
|
||||
{opts.map((opt, i) => {
|
||||
const active = value === opt.value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
fontSize: 'var(--font-size-base)',
|
||||
fontWeight: active ? 'var(--font-weight-semibold)' : 'var(--font-weight-normal)',
|
||||
cursor: 'pointer',
|
||||
background: active ? opt.activeBg : 'transparent',
|
||||
color: active ? opt.activeFg : 'var(--color-text-muted)',
|
||||
borderRight: i === 0 ? '1px solid var(--color-border-strong)' : 'none',
|
||||
transition: 'background var(--transition-fast), color var(--transition-fast)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Photo upload field ──────────────────────────────────────────────────────
|
||||
|
||||
function PhotoField({ value, onChange, userId }) {
|
||||
const fileInputRef = useRef(null)
|
||||
const toast = useToast()
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.danger('Invalid file', 'Please select an image file.')
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
const result = await api.upload(`/users/${userId}/photo`, file)
|
||||
onChange(result.url || result.photo_url || '')
|
||||
toast.success('Photo updated', 'Profile photo has been uploaded.')
|
||||
} catch (err) {
|
||||
toast.danger('Upload failed', err.message || 'Could not upload photo.')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 'var(--space-3)', alignItems: 'stretch' }}>
|
||||
{/* Clickable thumbnail — same height as the URL input + hint beside it */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Upload profile photo"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
width: 72,
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
border: `1px solid ${hovered && !uploading ? 'var(--color-primary)' : 'var(--color-border-strong)'}`,
|
||||
overflow: 'hidden',
|
||||
cursor: uploading ? 'wait' : 'pointer',
|
||||
background: 'var(--color-bg-abyss)',
|
||||
transition: 'border-color var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
{value ? (
|
||||
<img
|
||||
src={value}
|
||||
alt="Profile"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={(e) => { e.currentTarget.style.display = 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}>
|
||||
<Icon name="upload" size={20} />
|
||||
</div>
|
||||
)}
|
||||
<div aria-hidden style={{
|
||||
position: 'absolute', inset: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(10,14,20,0.60)',
|
||||
opacity: uploading || hovered ? 1 : 0,
|
||||
transition: 'opacity var(--transition-fast)',
|
||||
}}>
|
||||
{uploading ? <Spinner size="sm" /> : <Icon name="edit" size={14} color="var(--color-text-primary)" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<FormField
|
||||
label=""
|
||||
name="photo_url"
|
||||
type="url"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="https://..."
|
||||
hint="Paste a URL or click the photo to upload a file."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Password section ────────────────────────────────────────────────────────
|
||||
|
||||
function EyeToggle({ show, onToggle }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={show ? 'Hide password' : 'Show password'}
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 'var(--space-3)',
|
||||
/* label ~18px + gap 8px = 26px; input height ~36px → centre of input */
|
||||
top: 'calc(26px + 18px)',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--color-text-muted)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
lineHeight: 1,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Icon name={show ? 'eye_off' : 'eye'} size={16} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PasswordCard({ userId, hasUid }) {
|
||||
const toast = useToast()
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [showPass, setShowPass] = useState(false)
|
||||
const showConf = showPass
|
||||
const [saving, setSaving] = useState('') // 'set' | 'reset' | ''
|
||||
|
||||
const mismatch = confirm && password !== confirm
|
||||
|
||||
const handleSet = async () => {
|
||||
if (!password || mismatch) return
|
||||
setSaving('set')
|
||||
try {
|
||||
await api.post(`/users/${userId}/set-password`, { password })
|
||||
toast.success('Password updated', 'The new password is active immediately.')
|
||||
setPassword('')
|
||||
setConfirm('')
|
||||
} catch (err) {
|
||||
toast.danger('Failed', err.message || 'Could not set password.')
|
||||
} finally {
|
||||
setSaving('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = async () => {
|
||||
setSaving('reset')
|
||||
try {
|
||||
await api.post(`/users/${userId}/reset-password`, {})
|
||||
toast.success('Password reset', 'Password has been reset to the default.')
|
||||
} catch (err) {
|
||||
toast.danger('Failed', err.message || 'Could not reset password.')
|
||||
} finally {
|
||||
setSaving('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Firebase Auth Password" variant="flat">
|
||||
{!hasUid ? (
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
||||
This user has no Firebase Auth UID. They must sign up via the app before their password can be managed here.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
||||
Set a new password — active immediately in the mobile app.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
|
||||
{/* New password */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<FormField
|
||||
label="New Password"
|
||||
name="new_password"
|
||||
type={showPass ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 6 characters"
|
||||
inputProps={{ style: { paddingRight: 'var(--space-8)' } }}
|
||||
/>
|
||||
<EyeToggle show={showPass} onToggle={() => setShowPass(v => !v)} />
|
||||
</div>
|
||||
|
||||
{/* Confirm password */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<FormField
|
||||
label="Confirm Password"
|
||||
name="confirm_password"
|
||||
type={showConf ? 'text' : 'password'}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="Re-enter password"
|
||||
error={mismatch ? 'Passwords do not match.' : undefined}
|
||||
inputProps={{ style: { paddingRight: 'var(--space-8)' } }}
|
||||
/>
|
||||
<EyeToggle show={showConf} onToggle={() => setShowPass(v => !v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 'var(--space-3)' }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
type="button"
|
||||
loading={saving === 'set'}
|
||||
disabled={!password || !!mismatch || !!saving}
|
||||
onClick={handleSet}
|
||||
>
|
||||
Set Password
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
type="button"
|
||||
loading={saving === 'reset'}
|
||||
disabled={!!saving}
|
||||
onClick={handleReset}
|
||||
>
|
||||
Reset to Default
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function UserForm() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
email: '',
|
||||
display_name: '',
|
||||
photo_url: '',
|
||||
phone_number: '',
|
||||
status: 'active',
|
||||
bio: '',
|
||||
userTitle: '',
|
||||
settingsPIN: '',
|
||||
quickSettingsPIN: '',
|
||||
})
|
||||
const [uid, setUid] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit) return
|
||||
setLoading(true)
|
||||
api.get(`/users/${id}`)
|
||||
.then((user) => {
|
||||
setUid(user.uid || '')
|
||||
setForm({
|
||||
email: user.email || '',
|
||||
display_name: user.display_name || '',
|
||||
photo_url: user.photo_url || '',
|
||||
phone_number: user.phone_number || '',
|
||||
status: user.status || 'active',
|
||||
bio: user.bio || '',
|
||||
userTitle: user.userTitle || '',
|
||||
settingsPIN: user.settingsPIN || '',
|
||||
quickSettingsPIN: user.quickSettingsPIN || '',
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message)
|
||||
toast.danger('Failed to load', err.message)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }))
|
||||
const setVal = (key) => (val) => setForm((f) => ({ ...f, [key]: val }))
|
||||
const setPin = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value.replace(/\D/g, '') }))
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSaving(true)
|
||||
try {
|
||||
if (isEdit) {
|
||||
await api.put(`/users/${id}`, form)
|
||||
toast.success('Saved', 'User updated successfully.')
|
||||
navigate(`/users/${id}`)
|
||||
} else {
|
||||
const created = await api.post('/users', form)
|
||||
toast.success('Created', `${form.display_name || 'User'} has been added.`)
|
||||
navigate(`/users/${created.id}`)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Something went wrong.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page-wrapper page-wrapper--capped">
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-16)' }}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-wrapper page-wrapper--capped">
|
||||
<PageHeader
|
||||
title={isEdit ? 'Edit User' : 'Add User'}
|
||||
subtitle={isEdit ? 'Update account details and profile information' : 'Create a new app user account'}
|
||||
>
|
||||
<Button variant="ghost" onClick={() => navigate(isEdit ? `/users/${id}` : '/users')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" loading={saving} onClick={handleSubmit}>
|
||||
{isEdit ? 'Save Changes' : 'Create User'}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: 'var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--color-danger-bg)',
|
||||
border: '1px solid var(--color-danger)',
|
||||
color: 'var(--color-danger)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'contents' }}>
|
||||
<div className="masonry-grid masonry-grid--2">
|
||||
|
||||
{/* Account Information */}
|
||||
<Card title="Account Information" variant="flat">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
|
||||
<FormField
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={set('email')}
|
||||
required
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
<FormField
|
||||
label="Phone Number"
|
||||
name="phone_number"
|
||||
type="tel"
|
||||
value={form.phone_number}
|
||||
onChange={set('phone_number')}
|
||||
placeholder="+1 234 567 8900"
|
||||
/>
|
||||
</div>
|
||||
<StatusToggle value={form.status} onChange={setVal('status')} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Profile */}
|
||||
<Card title="Profile" variant="flat">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
<PhotoField
|
||||
value={form.photo_url}
|
||||
onChange={setVal('photo_url')}
|
||||
userId={isEdit ? id : null}
|
||||
/>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
|
||||
<FormField
|
||||
label="Title"
|
||||
name="userTitle"
|
||||
type="text"
|
||||
value={form.userTitle}
|
||||
onChange={set('userTitle')}
|
||||
placeholder="e.g. Fr, Mr, Miss"
|
||||
/>
|
||||
<FormField
|
||||
label="Display Name"
|
||||
name="display_name"
|
||||
type="text"
|
||||
value={form.display_name}
|
||||
onChange={set('display_name')}
|
||||
required
|
||||
placeholder="e.g. Jane Smith"
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
label="Bio"
|
||||
name="bio"
|
||||
type="textarea"
|
||||
value={form.bio}
|
||||
onChange={set('bio')}
|
||||
placeholder="A short description about this user…"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Firebase Auth Password — edit mode only */}
|
||||
{isEdit && (
|
||||
<PasswordCard userId={id} hasUid={!!uid} />
|
||||
)}
|
||||
|
||||
{/* Security — PINs */}
|
||||
<Card title="Security" variant="flat">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
|
||||
<FormField
|
||||
label="Settings PIN"
|
||||
name="settingsPIN"
|
||||
type="text"
|
||||
value={form.settingsPIN}
|
||||
onChange={setPin('settingsPIN')}
|
||||
placeholder="e.g. 1234"
|
||||
hint="Numeric only."
|
||||
/>
|
||||
<FormField
|
||||
label="Quick Settings PIN"
|
||||
name="quickSettingsPIN"
|
||||
type="text"
|
||||
value={form.quickSettingsPIN}
|
||||
onChange={setPin('quickSettingsPIN')}
|
||||
placeholder="e.g. 0000"
|
||||
hint="Numeric only."
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// frontend/src/pages/bellcloud/users/UserList.jsx
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '@/lib/api'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import usePreferences from '@/hooks/usePreferences'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
import Button from '@/components/ui/Button'
|
||||
import DataTable from '@/components/ui/DataTable'
|
||||
import StatusBadge from '@/components/ui/StatusBadge'
|
||||
import Pagination from '@/components/ui/Pagination'
|
||||
import SearchBar from '@/components/ui/SearchBar'
|
||||
import Select from '@/components/ui/Select'
|
||||
import RowActions from '@/components/ui/RowActions'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
import DeleteUserModal from '@/modals/bellcloud/users/DeleteUserModal'
|
||||
import UserListCardView from '@/pages/bellcloud/users/UserListCardView'
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const PREFS_KEY = 'bellcloud_users'
|
||||
|
||||
// ─── Column definitions ───────────────────────────────────────────────────────
|
||||
|
||||
const ALL_COLUMNS = [
|
||||
{ key: 'status', label: '', pickerLabel: 'Status', defaultOn: true, width: '80px' },
|
||||
{ key: 'name', label: 'Name', defaultOn: true, alwaysOn: true },
|
||||
{ key: 'email', label: 'Email', defaultOn: true },
|
||||
{ key: 'phone', label: 'Phone', defaultOn: true },
|
||||
{ key: 'title', label: 'Title', defaultOn: true },
|
||||
{ key: 'lastActive', label: 'Last Active', defaultOn: true },
|
||||
]
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function statusVariant(status) {
|
||||
if (status === 'active') return 'success'
|
||||
if (status === 'blocked') return 'danger'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
function Muted({ children }) {
|
||||
return <span style={{ color: 'var(--color-text-muted)' }}>{children}</span>
|
||||
}
|
||||
|
||||
// ─── UserList ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function UserList() {
|
||||
const navigate = useNavigate()
|
||||
const { hasPermission } = useAuth()
|
||||
const canEdit = hasPermission('app_users', 'edit')
|
||||
|
||||
const { prefs, setPref, ready } = usePreferences(PREFS_KEY)
|
||||
|
||||
// Data
|
||||
const [users, setUsers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Filters
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
|
||||
// Pagination
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
// View mode: 'table' | 'card'
|
||||
const [viewMode, setViewMode] = useState('table')
|
||||
|
||||
// Delete modal
|
||||
const [deleteTarget, setDeleteTarget] = useState(null)
|
||||
const [deleteError, setDeleteError] = useState('')
|
||||
|
||||
// ── Load prefs once ready ──────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!ready) return
|
||||
if (prefs.status_filter !== undefined) setStatusFilter(prefs.status_filter)
|
||||
if (prefs.page_size !== undefined) setPageSize(prefs.page_size)
|
||||
if (prefs.view_mode !== undefined) setViewMode(prefs.view_mode)
|
||||
}, [ready]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Fetch ──────────────────────────────────────────────────────────────────
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set('search', search)
|
||||
if (statusFilter) params.set('status', statusFilter)
|
||||
const qs = params.toString()
|
||||
const data = await api.get(`/users${qs ? `?${qs}` : ''}`)
|
||||
setUsers(data.users ?? [])
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load users.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [search, statusFilter])
|
||||
|
||||
// Silent refresh — re-fetches data without triggering the loading skeleton.
|
||||
// Used after in-place actions like status changes so cards update without flashing.
|
||||
const silentRefreshUsers = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set('search', search)
|
||||
if (statusFilter) params.set('status', statusFilter)
|
||||
const qs = params.toString()
|
||||
const data = await api.get(`/users${qs ? `?${qs}` : ''}`)
|
||||
setUsers(data.users ?? [])
|
||||
} catch {
|
||||
// swallow — a silent refresh failure isn't worth an error banner
|
||||
}
|
||||
}, [search, statusFilter])
|
||||
|
||||
useEffect(() => { fetchUsers() }, [fetchUsers])
|
||||
useEffect(() => { setPage(1) }, [search, statusFilter])
|
||||
|
||||
// ── Handlers with pref persistence ────────────────────────────────────────
|
||||
function handleStatusFilter(value) {
|
||||
setStatusFilter(value)
|
||||
setPref('status_filter', value)
|
||||
}
|
||||
|
||||
function handleViewMode(mode) {
|
||||
setViewMode(mode)
|
||||
setPref('view_mode', mode)
|
||||
}
|
||||
|
||||
function handlePageSize(size) {
|
||||
setPageSize(size)
|
||||
setPage(1)
|
||||
setPref('page_size', size)
|
||||
}
|
||||
|
||||
// ── Delete ─────────────────────────────────────────────────────────────────
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
setDeleteError('')
|
||||
try {
|
||||
await api.delete(`/users/${deleteTarget.id}`)
|
||||
setDeleteTarget(null)
|
||||
fetchUsers()
|
||||
} catch (err) {
|
||||
setDeleteError(err.message || 'Failed to delete user.')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pagination ─────────────────────────────────────────────────────────────
|
||||
const total = users.length
|
||||
const pagedUsers = users.slice((page - 1) * pageSize, page * pageSize)
|
||||
|
||||
// ── Table columns ──────────────────────────────────────────────────────────
|
||||
const tableColumns = ALL_COLUMNS.map((c) => ({
|
||||
key: c.key,
|
||||
label: c.label,
|
||||
width: c.width,
|
||||
render: (user) => {
|
||||
switch (c.key) {
|
||||
case 'status':
|
||||
return (
|
||||
<StatusBadge variant={statusVariant(user.status)} dot={false} size="sm">
|
||||
{user.status ? user.status.charAt(0).toUpperCase() + user.status.slice(1) : 'Unknown'}
|
||||
</StatusBadge>
|
||||
)
|
||||
case 'name':
|
||||
return (
|
||||
<span style={{ fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
|
||||
{user.display_name || 'Unnamed User'}
|
||||
</span>
|
||||
)
|
||||
case 'email':
|
||||
return user.email || <Muted>—</Muted>
|
||||
case 'phone':
|
||||
return user.phone_number || <Muted>—</Muted>
|
||||
case 'title':
|
||||
return user.userTitle || <Muted>—</Muted>
|
||||
case 'lastActive':
|
||||
return user.lastActive
|
||||
? <span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{user.lastActive}</span>
|
||||
: <Muted>—</Muted>
|
||||
default:
|
||||
return <Muted>—</Muted>
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// Actions column — appended, never draggable
|
||||
tableColumns.push({
|
||||
key: '__actions',
|
||||
label: '',
|
||||
width: '56px',
|
||||
align: 'right',
|
||||
render: (user) => (
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'View Details',
|
||||
icon: <Icon name="eye" size={14} />,
|
||||
onClick: () => navigate(`/users/${user.id}`),
|
||||
},
|
||||
...(canEdit ? [
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Icon name="edit" size={14} />,
|
||||
color: 'var(--color-info)',
|
||||
onClick: () => navigate(`/users/${user.id}/edit`),
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: <Icon name="delete" size={14} />,
|
||||
color: 'var(--color-danger)',
|
||||
divider: true,
|
||||
onClick: () => { setDeleteError(''); setDeleteTarget(user) },
|
||||
},
|
||||
] : []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
|
||||
{/* Page header */}
|
||||
<PageHeader
|
||||
title="App Users"
|
||||
subtitle={!loading ? `${total} ${total === 1 ? 'user' : 'users'}` : undefined}
|
||||
>
|
||||
{canEdit && (
|
||||
<Button variant="primary" onClick={() => navigate('/users/new')}>
|
||||
Add User
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
{/* Filter toolbar */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-3)', alignItems: 'center', minWidth: 0 }}>
|
||||
<div style={{ flex: '1 1 0', minWidth: '180px' }}>
|
||||
<SearchBar
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by name, email, phone, or UID…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => handleStatusFilter(e.target.value)}
|
||||
placeholder="All Status"
|
||||
style={{ width: '140px', flexShrink: 0 }}
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
</Select>
|
||||
|
||||
{/* View toggle — height matches input via .icon-btn-group__btn padding override */}
|
||||
<div className="icon-btn-group" role="group" aria-label="View mode">
|
||||
<Button
|
||||
variant={viewMode === 'table' ? 'secondary' : 'ghost'}
|
||||
size="md"
|
||||
className="icon-btn-group__btn"
|
||||
aria-pressed={viewMode === 'table'}
|
||||
aria-label="Table view"
|
||||
onClick={() => handleViewMode('table')}
|
||||
icon={
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.75"
|
||||
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M3 9h18M3 15h18M9 3v18" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant={viewMode === 'card' ? 'secondary' : 'ghost'}
|
||||
size="md"
|
||||
className="icon-btn-group__btn"
|
||||
aria-pressed={viewMode === 'card'}
|
||||
aria-label="Card view"
|
||||
onClick={() => handleViewMode('card')}
|
||||
icon={
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.75"
|
||||
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-danger-bg)',
|
||||
border: '1px solid var(--color-danger-container)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
color: 'var(--color-danger)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data table / Card view */}
|
||||
{viewMode === 'card' ? (
|
||||
<>
|
||||
<UserListCardView
|
||||
users={pagedUsers}
|
||||
loading={loading}
|
||||
canEdit={canEdit}
|
||||
onView={(user) => navigate(`/users/${user.id}`)}
|
||||
onEdit={(user) => navigate(`/users/${user.id}/edit`)}
|
||||
onDelete={(user) => { setDeleteError(''); setDeleteTarget(user) }}
|
||||
onRefresh={silentRefreshUsers}
|
||||
onMiddleClick={(user) => window.open(`/users/${user.id}`, '_blank', 'noopener,noreferrer')}
|
||||
/>
|
||||
{total > 0 && (
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
onSizeChange={handlePageSize}
|
||||
pageSizes={[10, 20, 50]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
data={pagedUsers}
|
||||
loading={loading}
|
||||
keyField="id"
|
||||
emptyMessage="No users found"
|
||||
emptyDescription="Try adjusting your search or filters."
|
||||
onRowClick={(user) => navigate(`/users/${user.id}`)}
|
||||
onRowMiddleClick={(user) => window.open(`/users/${user.id}`, '_blank', 'noopener,noreferrer')}
|
||||
skeletonRows={8}
|
||||
footer={
|
||||
total > 0 && (
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
onSizeChange={handlePageSize}
|
||||
pageSizes={[10, 20, 50]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<DeleteUserModal
|
||||
user={deleteTarget}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => { setDeleteTarget(null); setDeleteError('') }}
|
||||
error={deleteError}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user