Frontend: finish the token migration — raw greys to AA theme tokens #282

Merged
logan merged 1 commits from fix/readability-tokens into main 2026-10-25 21:34:39 -04:00
39 changed files with 655 additions and 655 deletions
+109 -109
View File
@@ -85,7 +85,7 @@ function fmtDatetime(iso: string | null | undefined) {
const ROLE_COLORS: Record<UserRole, string> = { const ROLE_COLORS: Record<UserRole, string> = {
admin: "bg-indigo-900 text-indigo-300", admin: "bg-indigo-900 text-indigo-300",
operator: "bg-green-900 text-green-300", operator: "bg-green-900 text-green-300",
viewer: "bg-gray-800 text-gray-400", viewer: "bg-raised text-ink-2",
}; };
function RoleBadge({ role }: { role: UserRole }) { function RoleBadge({ role }: { role: UserRole }) {
@@ -136,14 +136,14 @@ function FeaturesTab() {
</div> </div>
)} )}
{loading ? ( {loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : ( ) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800"> <div className="bg-surface border border-line rounded-xl divide-y divide-line">
{FLAG_META.map(({ key, label, description }) => ( {FLAG_META.map(({ key, label, description }) => (
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4"> <div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-white text-sm font-semibold">{label}</p> <p className="text-white text-sm font-semibold">{label}</p>
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p> <p className="text-ink-muted text-xs mt-0.5 leading-snug">{description}</p>
</div> </div>
<Toggle <Toggle
enabled={flags?.[key] ?? true} enabled={flags?.[key] ?? true}
@@ -238,30 +238,30 @@ function CorrelationDebugTab() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-gray-500 font-mono"> <p className="text-xs text-ink-muted font-mono">
Fetches recent incidents with per-call correlation debug fields, plus orphaned calls. Fetches recent incidents with per-call correlation debug fields, plus orphaned calls.
Copy the JSON and paste it to Claude to diagnose correlation problems. Copy the JSON and paste it to Claude to diagnose correlation problems.
</p> </p>
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Incidents (limit)</label> <label className="text-xs text-ink-2 block mb-1">Incidents (limit)</label>
<input <input
type="number" type="number"
min={1} max={100} min={1} max={100}
value={limit} value={limit}
onChange={(e) => setLimit(Math.min(100, Math.max(1, Number(e.target.value))))} onChange={(e) => setLimit(Math.min(100, Math.max(1, Number(e.target.value))))}
className="w-24 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-24 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Orphan scan window (hours)</label> <label className="text-xs text-ink-2 block mb-1">Orphan scan window (hours)</label>
<input <input
type="number" type="number"
min={1} max={168} min={1} max={168}
value={orphanHours} value={orphanHours}
onChange={(e) => setOrphanHours(Math.min(168, Math.max(1, Number(e.target.value))))} onChange={(e) => setOrphanHours(Math.min(168, Math.max(1, Number(e.target.value))))}
className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-28 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<button <button
@@ -281,12 +281,12 @@ function CorrelationDebugTab() {
{meta && ( {meta && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-gray-500 font-mono"> <p className="text-xs text-ink-muted font-mono">
{meta.incident_count} incidents · {meta.orphaned_call_count} orphaned calls · {meta.generated_at} {meta.incident_count} incidents · {meta.orphaned_call_count} orphaned calls · {meta.generated_at}
</p> </p>
<button <button
onClick={handleCopy} onClick={handleCopy}
className="text-xs font-mono px-3 py-1.5 rounded-lg border border-gray-700 bg-gray-900 text-gray-300 hover:text-white transition-colors" className="text-xs font-mono px-3 py-1.5 rounded-lg border border-line-strong bg-surface text-ink-2 hover:text-white transition-colors"
> >
{copied ? "Copied!" : "Copy JSON"} {copied ? "Copied!" : "Copy JSON"}
</button> </button>
@@ -295,22 +295,22 @@ function CorrelationDebugTab() {
{meta && Object.keys(pathCounts).length > 0 && ( {meta && Object.keys(pathCounts).length > 0 && (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1"> <div className="bg-surface border border-line rounded-xl p-4 space-y-1">
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">corr_path distribution</p> <p className="text-xs text-ink-2 font-mono font-semibold mb-2">corr_path distribution</p>
{Object.entries(pathCounts).sort((a, b) => b[1] - a[1]).map(([path, n]) => ( {Object.entries(pathCounts).sort((a, b) => b[1] - a[1]).map(([path, n]) => (
<div key={path} className="flex justify-between text-xs font-mono"> <div key={path} className="flex justify-between text-xs font-mono">
<span className="text-gray-300">{path}</span> <span className="text-ink-2">{path}</span>
<span className="text-indigo-400">{n}</span> <span className="text-indigo-400">{n}</span>
</div> </div>
))} ))}
</div> </div>
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1"> <div className="bg-surface border border-line rounded-xl p-4 space-y-1">
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">fit_signal distribution</p> <p className="text-xs text-ink-2 font-mono font-semibold mb-2">fit_signal distribution</p>
{Object.keys(signalCounts).length === 0 {Object.keys(signalCounts).length === 0
? <p className="text-xs text-gray-600 font-mono">No signal data yet — deploy correlator update first</p> ? <p className="text-xs text-ink-muted font-mono">No signal data yet — deploy correlator update first</p>
: Object.entries(signalCounts).sort((a, b) => b[1] - a[1]).map(([sig, n]) => ( : Object.entries(signalCounts).sort((a, b) => b[1] - a[1]).map(([sig, n]) => (
<div key={sig} className="flex justify-between text-xs font-mono"> <div key={sig} className="flex justify-between text-xs font-mono">
<span className={sig === "time_fallback" ? "text-yellow-400" : "text-gray-300"}>{sig}</span> <span className={sig === "time_fallback" ? "text-yellow-400" : "text-ink-2"}>{sig}</span>
<span className="text-indigo-400">{n}</span> <span className="text-indigo-400">{n}</span>
</div> </div>
)) ))
@@ -320,13 +320,13 @@ function CorrelationDebugTab() {
)} )}
{meta?.orphans_by_talkgroup && meta.orphans_by_talkgroup.length > 0 && ( {meta?.orphans_by_talkgroup && meta.orphans_by_talkgroup.length > 0 && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1"> <div className="bg-surface border border-line rounded-xl p-4 space-y-1">
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">orphans by talkgroup</p> <p className="text-xs text-ink-2 font-mono font-semibold mb-2">orphans by talkgroup</p>
{meta.orphans_by_talkgroup.map((tg) => ( {meta.orphans_by_talkgroup.map((tg) => (
<div key={String(tg.talkgroup_id)} className="flex justify-between text-xs font-mono"> <div key={String(tg.talkgroup_id)} className="flex justify-between text-xs font-mono">
<span className="text-gray-300">{tg.talkgroup_name} <span className="text-gray-600">({tg.talkgroup_id})</span></span> <span className="text-ink-2">{tg.talkgroup_name} <span className="text-ink-muted">({tg.talkgroup_id})</span></span>
<span className="text-gray-400"> <span className="text-ink-2">
{tg.count} total · <span className="text-gray-500">{tg.no_type_count} no-type</span> · <span className="text-red-400">{tg.sweep_exhausted_count} exhausted</span> {tg.count} total · <span className="text-ink-muted">{tg.no_type_count} no-type</span> · <span className="text-red-400">{tg.sweep_exhausted_count} exhausted</span>
</span> </span>
</div> </div>
))} ))}
@@ -336,7 +336,7 @@ function CorrelationDebugTab() {
{json && ( {json && (
<pre <pre
ref={preRef} ref={preRef}
className="bg-gray-950 border border-gray-800 rounded-xl p-4 text-xs text-gray-300 font-mono overflow-auto max-h-[60vh] whitespace-pre" className="bg-page border border-line rounded-xl p-4 text-xs text-ink-2 font-mono overflow-auto max-h-[60vh] whitespace-pre"
> >
{json} {json}
</pre> </pre>
@@ -455,13 +455,13 @@ function UserDetailPanel({
const isSelf = user.uid === currentUid; const isSelf = user.uid === currentUid;
return ( return (
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-5 font-mono"> <div className="bg-surface border border-line-strong rounded-xl p-5 space-y-5 font-mono">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<p className="text-white font-semibold">{detail.email}</p> <p className="text-white font-semibold">{detail.email}</p>
<p className="text-xs text-gray-500 mt-0.5">{detail.uid}</p> <p className="text-xs text-ink-muted mt-0.5">{detail.uid}</p>
</div> </div>
<button onClick={onClose} className="text-gray-600 hover:text-gray-300 transition-colors text-xl leading-none">×</button> <button onClick={onClose} className="text-ink-muted hover:text-ink transition-colors text-xl leading-none">×</button>
</div> </div>
{error && ( {error && (
@@ -472,21 +472,21 @@ function UserDetailPanel({
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Display Name</label> <label className="text-xs text-ink-2 block mb-1">Display Name</label>
<input <input
value={editName} value={editName}
onChange={(e) => setEditName(e.target.value)} onChange={(e) => setEditName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="Full name" placeholder="Full name"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Role</label> <label className="text-xs text-ink-2 block mb-1">Role</label>
<select <select
value={editRole} value={editRole}
onChange={(e) => setEditRole(e.target.value as UserRole)} onChange={(e) => setEditRole(e.target.value as UserRole)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
> >
<option value="admin">Admin — full access</option> <option value="admin">Admin — full access</option>
<option value="operator">Operator — owns nodes</option> <option value="operator">Operator — owns nodes</option>
@@ -496,13 +496,13 @@ function UserDetailPanel({
{editRole === "operator" && ( {editRole === "operator" && (
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span> Owned Node IDs <span className="text-ink-muted">(comma-separated, required)</span>
</label> </label>
<input <input
value={editNodes} value={editNodes}
onChange={(e) => setEditNodes(e.target.value)} onChange={(e) => setEditNodes(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="node-abc123, node-def456" placeholder="node-abc123, node-def456"
/> />
</div> </div>
@@ -517,10 +517,10 @@ function UserDetailPanel({
</button> </button>
</div> </div>
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs"> <div className="border-t border-line pt-4 space-y-2 text-xs">
<div className="flex justify-between items-center gap-3"> <div className="flex justify-between items-center gap-3">
<span className="text-gray-500">Organization</span> <span className="text-ink-muted">Organization</span>
<span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-gray-300" : "text-yellow-400"}`}> <span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-ink-2" : "text-yellow-400"}`}>
{!detail.org_id {!detail.org_id
? "None: sees no data" ? "None: sees no data"
: detail.org_id === myOrgId : detail.org_id === myOrgId
@@ -538,34 +538,34 @@ function UserDetailPanel({
</button> </button>
)} )}
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500">Status</span> <span className="text-ink-muted">Status</span>
<span className={detail.disabled ? "text-red-400" : "text-green-400"}> <span className={detail.disabled ? "text-red-400" : "text-green-400"}>
{detail.disabled ? "Disabled" : "Active"} {detail.disabled ? "Disabled" : "Active"}
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500">Discord</span> <span className="text-ink-muted">Discord</span>
<span className="text-gray-300"> <span className="text-ink-2">
{detail.discord_linked {detail.discord_linked
? `@${detail.discord_username ?? detail.discord_user_id}` ? `@${detail.discord_username ?? detail.discord_user_id}`
: "Not linked"} : "Not linked"}
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500">Created</span> <span className="text-ink-muted">Created</span>
<span className="text-gray-300">{fmtDate(detail.creation_time)}</span> <span className="text-ink-2">{fmtDate(detail.creation_time)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500">Last sign-in</span> <span className="text-ink-muted">Last sign-in</span>
<span className="text-gray-300">{fmtDate(detail.last_sign_in)}</span> <span className="text-ink-2">{fmtDate(detail.last_sign_in)}</span>
</div> </div>
</div> </div>
{(detail.sessions?.length ?? 0) > 0 && ( {(detail.sessions?.length ?? 0) > 0 && (
<div className="border-t border-gray-800 pt-4"> <div className="border-t border-line pt-4">
<button <button
onClick={() => setShowSessions((v) => !v)} onClick={() => setShowSessions((v) => !v)}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink transition-colors flex items-center gap-1"
> >
<span>{showSessions ? "▲" : "▼"}</span> <span>{showSessions ? "▲" : "▼"}</span>
<span>Login history ({detail.sessions?.length} recent)</span> <span>Login history ({detail.sessions?.length} recent)</span>
@@ -573,9 +573,9 @@ function UserDetailPanel({
{showSessions && ( {showSessions && (
<div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto"> <div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto">
{detail.sessions?.map((s) => ( {detail.sessions?.map((s) => (
<div key={s.session_id} className="text-xs text-gray-400 flex justify-between gap-4"> <div key={s.session_id} className="text-xs text-ink-2 flex justify-between gap-4">
<span>{fmtDatetime(s.timestamp)}</span> <span>{fmtDatetime(s.timestamp)}</span>
<span className="text-gray-600 truncate">{s.ip ?? "—"}</span> <span className="text-ink-muted truncate">{s.ip ?? "—"}</span>
</div> </div>
))} ))}
</div> </div>
@@ -583,7 +583,7 @@ function UserDetailPanel({
</div> </div>
)} )}
<div className="border-t border-gray-800 pt-4 flex gap-4 flex-wrap"> <div className="border-t border-line pt-4 flex gap-4 flex-wrap">
{!isSelf ? ( {!isSelf ? (
<> <>
<button <button
@@ -602,7 +602,7 @@ function UserDetailPanel({
</button> </button>
</> </>
) : ( ) : (
<p className="text-xs text-gray-600">Cannot disable or delete your own account.</p> <p className="text-xs text-ink-muted">Cannot disable or delete your own account.</p>
)} )}
</div> </div>
</div> </div>
@@ -667,19 +667,19 @@ function CreateUserModal({
if (inviteLink) { if (inviteLink) {
return ( return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 space-y-4 font-mono"> <div className="bg-surface border border-line-strong rounded-xl w-full max-w-md p-6 space-y-4 font-mono">
<h2 className="text-white font-semibold">User Created</h2> <h2 className="text-white font-semibold">User Created</h2>
<p className="text-xs text-gray-400"> <p className="text-xs text-ink-2">
Share this one-time invite link with the new user so they can set their password. Share this one-time invite link with the new user so they can set their password.
It expires after use. It expires after use.
</p> </p>
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3"> <div className="bg-raised border border-line-strong rounded-lg p-3">
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p> <p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={copyLink} onClick={copyLink}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors" className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
> >
{copied ? "Copied!" : "Copy link"} {copied ? "Copied!" : "Copy link"}
</button> </button>
@@ -697,37 +697,37 @@ function CreateUserModal({
return ( return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 font-mono"> <div className="bg-surface border border-line-strong rounded-xl w-full max-w-md p-6 font-mono">
<h2 className="text-white font-semibold mb-4">Create User</h2> <h2 className="text-white font-semibold mb-4">Create User</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Email</label> <label className="text-xs text-ink-2 block mb-1">Email</label>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="user@example.com" placeholder="user@example.com"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Display Name <span className="text-gray-600">(optional)</span> Display Name <span className="text-ink-muted">(optional)</span>
</label> </label>
<input <input
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="Jane Smith" placeholder="Jane Smith"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Role</label> <label className="text-xs text-ink-2 block mb-1">Role</label>
<select <select
value={role} value={role}
onChange={(e) => setRole(e.target.value as UserRole)} onChange={(e) => setRole(e.target.value as UserRole)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
> >
<option value="admin">Admin — full access</option> <option value="admin">Admin — full access</option>
<option value="operator">Operator — owns nodes</option> <option value="operator">Operator — owns nodes</option>
@@ -736,14 +736,14 @@ function CreateUserModal({
</div> </div>
{role === "operator" && ( {role === "operator" && (
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span> Owned Node IDs <span className="text-ink-muted">(comma-separated, required)</span>
</label> </label>
<input <input
value={nodeIds} value={nodeIds}
onChange={(e) => setNodeIds(e.target.value)} onChange={(e) => setNodeIds(e.target.value)}
required required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="node-abc123, node-def456" placeholder="node-abc123, node-def456"
/> />
</div> </div>
@@ -760,7 +760,7 @@ function CreateUserModal({
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors" className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
> >
Cancel Cancel
</button> </button>
@@ -820,7 +820,7 @@ function UsersTab({ currentUid }: { currentUid: string }) {
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-gray-500 font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p> <p className="text-xs text-ink-muted font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p>
<button <button
onClick={() => setShowCreate(true)} onClick={() => setShowCreate(true)}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors" className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
@@ -836,14 +836,14 @@ function UsersTab({ currentUid }: { currentUid: string }) {
)} )}
{loading ? ( {loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : users.length === 0 ? ( ) : users.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No users found.</p> <p className="text-ink-muted text-sm font-mono">No users found.</p>
) : ( ) : (
<div className="border border-gray-800 rounded-xl overflow-hidden"> <div className="border border-line rounded-xl overflow-hidden">
<table className="w-full text-xs font-mono"> <table className="w-full text-xs font-mono">
<thead> <thead>
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider"> <tr className="bg-surface text-ink-muted uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">Email</th> <th className="px-4 py-2.5 text-left">Email</th>
<th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th> <th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th>
<th className="px-4 py-2.5 text-left">Role</th> <th className="px-4 py-2.5 text-left">Role</th>
@@ -857,17 +857,17 @@ function UsersTab({ currentUid }: { currentUid: string }) {
{users.map((u) => ( {users.map((u) => (
<tr <tr
key={u.uid} key={u.uid}
className={`border-t border-gray-800 transition-colors ${ className={`border-t border-line transition-colors ${
selectedUid === u.uid ? "bg-gray-800/60" : "hover:bg-gray-900/60" selectedUid === u.uid ? "bg-raised/60" : "hover:bg-raised/60"
}`} }`}
> >
<td className="px-4 py-2.5 text-gray-200">{u.email ?? "—"}</td> <td className="px-4 py-2.5 text-ink">{u.email ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-400 hidden lg:table-cell">{u.display_name ?? "—"}</td> <td className="px-4 py-2.5 text-ink-2 hidden lg:table-cell">{u.display_name ?? "—"}</td>
<td className="px-4 py-2.5"><RoleBadge role={u.role} /></td> <td className="px-4 py-2.5"><RoleBadge role={u.role} /></td>
<td className="px-4 py-2.5 text-gray-500 hidden sm:table-cell"> <td className="px-4 py-2.5 text-ink-muted hidden sm:table-cell">
{u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"} {u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"}
</td> </td>
<td className="px-4 py-2.5 text-gray-500 hidden md:table-cell">{fmtDate(u.last_sign_in)}</td> <td className="px-4 py-2.5 text-ink-muted hidden md:table-cell">{fmtDate(u.last_sign_in)}</td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
{u.disabled {u.disabled
? <span className="text-red-500">Disabled</span> ? <span className="text-red-500">Disabled</span>
@@ -952,15 +952,15 @@ function AuditLogTab() {
)} )}
{loading ? ( {loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : entries.length === 0 ? ( ) : entries.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No audit entries yet.</p> <p className="text-ink-muted text-sm font-mono">No audit entries yet.</p>
) : ( ) : (
<> <>
<div className="border border-gray-800 rounded-xl overflow-hidden"> <div className="border border-line rounded-xl overflow-hidden">
<table className="w-full text-xs font-mono"> <table className="w-full text-xs font-mono">
<thead> <thead>
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider"> <tr className="bg-surface text-ink-muted uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">Time</th> <th className="px-4 py-2.5 text-left">Time</th>
<th className="px-4 py-2.5 text-left">Action</th> <th className="px-4 py-2.5 text-left">Action</th>
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th> <th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th>
@@ -970,12 +970,12 @@ function AuditLogTab() {
</thead> </thead>
<tbody> <tbody>
{entries.map((e) => ( {entries.map((e) => (
<tr key={e.log_id} className="border-t border-gray-800 hover:bg-gray-900/40"> <tr key={e.log_id} className="border-t border-line hover:bg-raised/40">
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{fmtDatetime(e.timestamp)}</td> <td className="px-4 py-2.5 text-ink-muted whitespace-nowrap">{fmtDatetime(e.timestamp)}</td>
<td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td> <td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td>
<td className="px-4 py-2.5 text-gray-400 hidden sm:table-cell">{e.actor_email}</td> <td className="px-4 py-2.5 text-ink-2 hidden sm:table-cell">{e.actor_email}</td>
<td className="px-4 py-2.5 text-gray-400 hidden md:table-cell">{e.target_email ?? "—"}</td> <td className="px-4 py-2.5 text-ink-2 hidden md:table-cell">{e.target_email ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-600 max-w-xs truncate"> <td className="px-4 py-2.5 text-ink-muted max-w-xs truncate">
{Object.keys(e.details).length > 0 {Object.keys(e.details).length > 0
? Object.entries(e.details) ? Object.entries(e.details)
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`) .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
@@ -1029,26 +1029,26 @@ function StaleCallsTab() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<p className="text-xs text-gray-500 font-mono"> <p className="text-xs text-ink-muted font-mono">
Finds calls stuck in <span className="text-gray-300">active</span> status because a node rebooted before sending an end-call event. Finds calls stuck in <span className="text-ink-2">active</span> status because a node rebooted before sending an end-call event.
Preview first, then close. Preview first, then close.
</p> </p>
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Older than (minutes)</label> <label className="text-xs text-ink-2 block mb-1">Older than (minutes)</label>
<input <input
type="number" type="number"
min={1} max={1440} min={1} max={1440}
value={minutes} value={minutes}
onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))} onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))}
className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-28 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<button <button
onClick={() => run(true)} onClick={() => run(true)}
disabled={loading} disabled={loading}
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors" className="bg-raised hover:bg-gray-700 disabled:opacity-50 border border-line-strong text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
> >
{loading ? "Working…" : "Preview"} {loading ? "Working…" : "Preview"}
</button> </button>
@@ -1068,18 +1068,18 @@ function StaleCallsTab() {
)} )}
{result && ( {result && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-2"> <div className="bg-surface border border-line rounded-xl p-4 space-y-2">
<p className="text-sm font-mono text-white"> <p className="text-sm font-mono text-white">
{result.dry_run ? "Preview: " : "Closed: "} {result.dry_run ? "Preview: " : "Closed: "}
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}> <span className={result.count > 0 ? "text-amber-400" : "text-green-400"}>
{result.count} stale call{result.count !== 1 ? "s" : ""} {result.count} stale call{result.count !== 1 ? "s" : ""}
</span> </span>
{result.count === 0 && <span className="text-gray-500"> — nothing to clear</span>} {result.count === 0 && <span className="text-ink-muted"> — nothing to clear</span>}
</p> </p>
{result.call_ids.length > 0 && ( {result.call_ids.length > 0 && (
<div className="max-h-40 overflow-y-auto space-y-0.5"> <div className="max-h-40 overflow-y-auto space-y-0.5">
{result.call_ids.map((id) => ( {result.call_ids.map((id) => (
<p key={id} className="text-xs font-mono text-gray-400">{id}</p> <p key={id} className="text-xs font-mono text-ink-2">{id}</p>
))} ))}
</div> </div>
)} )}
@@ -1104,17 +1104,17 @@ function fmtPct(x: number | null | undefined): string {
function EvalStatsBar({ stats }: { stats: { eval_count: number; raw_wer: number | null; corrected_wer: number | null } | null }) { function EvalStatsBar({ stats }: { stats: { eval_count: number; raw_wer: number | null; corrected_wer: number | null } | null }) {
return ( return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 flex flex-wrap gap-x-8 gap-y-2"> <div className="bg-surface border border-line rounded-xl p-4 flex flex-wrap gap-x-8 gap-y-2">
<div> <div>
<p className="text-xs text-gray-500 font-mono">Calls verified</p> <p className="text-xs text-ink-muted font-mono">Calls verified</p>
<p className="text-white text-lg font-mono">{stats?.eval_count ?? "—"}</p> <p className="text-white text-lg font-mono">{stats?.eval_count ?? "—"}</p>
</div> </div>
<div> <div>
<p className="text-xs text-gray-500 font-mono">Raw WER (whisper-1)</p> <p className="text-xs text-ink-muted font-mono">Raw WER (whisper-1)</p>
<p className="text-white text-lg font-mono">{fmtPct(stats?.raw_wer)}</p> <p className="text-white text-lg font-mono">{fmtPct(stats?.raw_wer)}</p>
</div> </div>
<div> <div>
<p className="text-xs text-gray-500 font-mono">Corrected WER (shipped)</p> <p className="text-xs text-ink-muted font-mono">Corrected WER (shipped)</p>
<p className="text-white text-lg font-mono">{fmtPct(stats?.corrected_wer)}</p> <p className="text-white text-lg font-mono">{fmtPct(stats?.corrected_wer)}</p>
</div> </div>
{stats && stats.eval_count > 0 && stats.eval_count < 20 && ( {stats && stats.eval_count > 0 && stats.eval_count < 20 && (
@@ -1197,7 +1197,7 @@ function SttEvalTab() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-gray-500 font-mono"> <p className="text-xs text-ink-muted font-mono">
Listen to the audio, correct the transcript below until it matches what was actually said, then save. Listen to the audio, correct the transcript below until it matches what was actually said, then save.
This never touches the call&apos;s real transcript or re-runs anything — it only records ground truth This never touches the call&apos;s real transcript or re-runs anything — it only records ground truth
for measuring the pipeline. Do as many or as few as you have time for; it picks up where you left off. for measuring the pipeline. Do as many or as few as you have time for; it picks up where you left off.
@@ -1212,8 +1212,8 @@ function SttEvalTab() {
)} )}
{current ? ( {current ? (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3"> <div className="bg-surface border border-line rounded-xl p-4 space-y-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-gray-400"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-ink-2">
<span>{new Date(current.started_at).toLocaleString()}</span> <span>{new Date(current.started_at).toLocaleString()}</span>
<span>{current.talkgroup_name || (current.talkgroup_id ? `TGID ${current.talkgroup_id}` : "unknown talkgroup")}</span> <span>{current.talkgroup_name || (current.talkgroup_id ? `TGID ${current.talkgroup_id}` : "unknown talkgroup")}</span>
</div> </div>
@@ -1222,18 +1222,18 @@ function SttEvalTab() {
// eslint-disable-next-line jsx-a11y/media-has-caption // eslint-disable-next-line jsx-a11y/media-has-caption
<audio controls src={current.audio_url} className="w-full h-9" /> <audio controls src={current.audio_url} className="w-full h-9" />
) : ( ) : (
<p className="text-xs text-gray-500 italic">No audio on this call — skip it.</p> <p className="text-xs text-ink-muted italic">No audio on this call — skip it.</p>
)} )}
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Machine transcript (pre-filled) — correct it into what was actually said Machine transcript (pre-filled) — correct it into what was actually said
</label> </label>
<textarea <textarea
value={draft} value={draft}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
rows={4} rows={4}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
@@ -1248,15 +1248,15 @@ function SttEvalTab() {
<button <button
onClick={skip} onClick={skip}
disabled={saving} disabled={saving}
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors" className="bg-raised hover:bg-gray-700 disabled:opacity-50 border border-line-strong text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
> >
Skip Skip
</button> </button>
</div> </div>
</div> </div>
) : ( ) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4"> <div className="bg-surface border border-line rounded-xl p-4">
<p className="text-sm font-mono text-gray-400"> <p className="text-sm font-mono text-ink-2">
{loadingBatch ? "Loading calls…" : exhausted ? "Nothing left to verify right now — check back after more calls come in." : "Loading…"} {loadingBatch ? "Loading calls…" : exhausted ? "Nothing left to verify right now — check back after more calls come in." : "Loading…"}
</p> </p>
</div> </div>
@@ -1312,13 +1312,13 @@ export default function AdminPage() {
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}> <div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
<h1 className="text-white text-xl font-bold font-mono">Admin</h1> <h1 className="text-white text-xl font-bold font-mono">Admin</h1>
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit"> <div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
{TAB_LABELS.map(({ key, label }) => ( {TAB_LABELS.map(({ key, label }) => (
<button <button
key={key} key={key}
onClick={() => setTab(key)} onClick={() => setTab(key)}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${ className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
tab === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300" tab === key ? "bg-raised text-white" : "text-ink-muted hover:text-ink"
}`} }`}
> >
{label} {label}
+30 -30
View File
@@ -74,39 +74,39 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{isAdmin && ( {isAdmin && (
<form onSubmit={handleCreate} className="bg-gray-900 border border-gray-800 rounded-xl p-5 space-y-4"> <form onSubmit={handleCreate} className="bg-surface border border-line rounded-xl p-5 space-y-4">
<h3 className="text-white font-mono text-sm font-bold">New Alert Rule</h3> <h3 className="text-white font-mono text-sm font-bold">New Alert Rule</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Name</label> <label className="text-xs text-ink-2 block mb-1">Name</label>
<input <input
required value={name} onChange={(e) => setName(e.target.value)} required value={name} onChange={(e) => setName(e.target.value)}
placeholder="e.g. Structure Fire" placeholder="e.g. Structure Fire"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Keywords (comma-separated)</label> <label className="text-xs text-ink-2 block mb-1">Keywords (comma-separated)</label>
<input <input
value={keywords} onChange={(e) => setKeywords(e.target.value)} value={keywords} onChange={(e) => setKeywords(e.target.value)}
placeholder="fire, smoke, structure" placeholder="fire, smoke, structure"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Talkgroup IDs (comma-separated)</label> <label className="text-xs text-ink-2 block mb-1">Talkgroup IDs (comma-separated)</label>
<input <input
value={tgIds} onChange={(e) => setTgIds(e.target.value)} value={tgIds} onChange={(e) => setTgIds(e.target.value)}
placeholder="9048, 9600" placeholder="9048, 9600"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Discord Webhook URL (optional)</label> <label className="text-xs text-ink-2 block mb-1">Discord Webhook URL (optional)</label>
<input <input
value={webhook} onChange={(e) => setWebhook(e.target.value)} value={webhook} onChange={(e) => setWebhook(e.target.value)}
placeholder="https://discord.com/api/webhooks/…" placeholder="https://discord.com/api/webhooks/…"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
</div> </div>
@@ -120,13 +120,13 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
</form> </form>
)} )}
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> <div className="bg-surface border border-line rounded-xl overflow-hidden">
{rules.length === 0 ? ( {rules.length === 0 ? (
<p className="text-gray-600 text-sm font-mono p-4">No alert rules configured.</p> <p className="text-ink-muted text-sm font-mono p-4">No alert rules configured.</p>
) : ( ) : (
<table className="w-full text-left"> <table className="w-full text-left">
<thead> <thead>
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase"> <tr className="border-b border-line text-xs text-ink-muted uppercase">
<th className="px-4 py-3">Name</th> <th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Keywords</th> <th className="px-4 py-3">Keywords</th>
<th className="px-4 py-3">Talkgroups</th> <th className="px-4 py-3">Talkgroups</th>
@@ -137,11 +137,11 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
</thead> </thead>
<tbody> <tbody>
{rules.map((rule) => ( {rules.map((rule) => (
<tr key={rule.rule_id} className="border-b border-gray-800"> <tr key={rule.rule_id} className="border-b border-line">
<td className="px-4 py-3 text-white text-sm">{rule.name}</td> <td className="px-4 py-3 text-white text-sm">{rule.name}</td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{rule.keywords.join(", ") || "—"}</td> <td className="px-4 py-3 text-ink-2 text-xs font-mono">{rule.keywords.join(", ") || "—"}</td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{rule.talkgroup_ids.join(", ") || "—"}</td> <td className="px-4 py-3 text-ink-2 text-xs font-mono">{rule.talkgroup_ids.join(", ") || "—"}</td>
<td className="px-4 py-3 text-gray-400 text-xs"> <td className="px-4 py-3 text-ink-2 text-xs">
{rule.discord_webhook ? ( {rule.discord_webhook ? (
<span className="text-green-400">configured</span> <span className="text-green-400">configured</span>
) : "—"} ) : "—"}
@@ -153,13 +153,13 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${ className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
rule.enabled rule.enabled
? "bg-green-900 text-green-300 hover:bg-green-800" ? "bg-green-900 text-green-300 hover:bg-green-800"
: "bg-gray-800 text-gray-400 hover:bg-gray-700" : "bg-raised text-ink-2 hover:bg-gray-700"
}`} }`}
> >
{rule.enabled ? "enabled" : "disabled"} {rule.enabled ? "enabled" : "disabled"}
</button> </button>
) : ( ) : (
<span className={`text-xs ${rule.enabled ? "text-green-400" : "text-gray-500"}`}> <span className={`text-xs ${rule.enabled ? "text-green-400" : "text-ink-muted"}`}>
{rule.enabled ? "enabled" : "disabled"} {rule.enabled ? "enabled" : "disabled"}
</span> </span>
)} )}
@@ -209,13 +209,13 @@ export default function AlertsPage() {
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit"> <div className="flex gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
{(["events", ...(isAdmin ? ["rules"] : [])] as const).map((t) => ( {(["events", ...(isAdmin ? ["rules"] : [])] as const).map((t) => (
<button <button
key={t} key={t}
onClick={() => setTab(t as "events" | "rules")} onClick={() => setTab(t as "events" | "rules")}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${ className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${
tab === t ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300" tab === t ? "bg-raised text-white" : "text-ink-muted hover:text-ink"
}`} }`}
> >
{t === "events" ? "Triggered Alerts" : "Rules"} {t === "events" ? "Triggered Alerts" : "Rules"}
@@ -225,7 +225,7 @@ export default function AlertsPage() {
{tab === "events" && ( {tab === "events" && (
loading ? ( loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : error ? ( ) : error ? (
<p className="text-red-400 text-sm font-mono"> <p className="text-red-400 text-sm font-mono">
{/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error) {/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error)
@@ -233,7 +233,7 @@ export default function AlertsPage() {
: `Couldn't load alerts: ${error}`} : `Couldn't load alerts: ${error}`}
</p> </p>
) : alerts.length === 0 ? ( ) : alerts.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p> <p className="text-ink-muted text-sm font-mono">No alerts triggered yet.</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{/* Gate A / A2 (server-26#46) — the Snippet column is transcript text, {/* Gate A / A2 (server-26#46) — the Snippet column is transcript text,
@@ -242,10 +242,10 @@ export default function AlertsPage() {
variant="inline" variant="inline"
detail="alerts match against automated transcripts and may fire on, or miss, the wrong words." detail="alerts match against automated transcripts and may fire on, or miss, the wrong words."
/> />
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> <div className="bg-surface border border-line rounded-xl overflow-hidden">
<table className="w-full text-left"> <table className="w-full text-left">
<thead> <thead>
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase"> <tr className="border-b border-line text-xs text-ink-muted uppercase">
<th className="px-4 py-3">Rule</th> <th className="px-4 py-3">Rule</th>
<th className="px-4 py-3">Talkgroup</th> <th className="px-4 py-3">Talkgroup</th>
<th className="px-4 py-3">Matched</th> <th className="px-4 py-3">Matched</th>
@@ -258,10 +258,10 @@ export default function AlertsPage() {
{alerts.map((alert) => ( {alerts.map((alert) => (
<tr <tr
key={alert.alert_id} key={alert.alert_id}
className={`border-b border-gray-800 ${alert.acknowledged ? "opacity-50" : ""}`} className={`border-b border-line ${alert.acknowledged ? "opacity-50" : ""}`}
> >
<td className="px-4 py-3 text-white text-sm">{alert.rule_name}</td> <td className="px-4 py-3 text-white text-sm">{alert.rule_name}</td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono"> <td className="px-4 py-3 text-ink-2 text-xs font-mono">
{alert.talkgroup_name || alert.talkgroup_id || "—"} {alert.talkgroup_name || alert.talkgroup_id || "—"}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -273,17 +273,17 @@ export default function AlertsPage() {
))} ))}
</div> </div>
</td> </td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono max-w-xs truncate"> <td className="px-4 py-3 text-ink-2 text-xs font-mono max-w-xs truncate">
{alert.transcript_snippet || "—"} {alert.transcript_snippet || "—"}
</td> </td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(alert.triggered_at)}</td> <td className="px-4 py-3 text-ink-2 text-xs font-mono">{fmtTime(alert.triggered_at)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{alert.acknowledged ? ( {alert.acknowledged ? (
<span className="text-xs text-gray-500">acked</span> <span className="text-xs text-ink-muted">acked</span>
) : ( ) : (
<button <button
onClick={() => handleAcknowledge(alert.alert_id)} onClick={() => handleAcknowledge(alert.alert_id)}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors" className="text-xs bg-raised hover:bg-gray-700 text-ink-2 px-2 py-1 rounded transition-colors"
> >
Acknowledge Acknowledge
</button> </button>
+4 -4
View File
@@ -68,7 +68,7 @@ function ChevronIcon({ open }: { open: boolean }) {
<svg <svg
width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round" strokeLinecap="round" strokeLinejoin="round"
className={`text-gray-500 shrink-0 transition-transform ${open ? "rotate-180" : ""}`} className={`text-ink-muted shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
> >
<polyline points="6 9 12 15 18 9" /> <polyline points="6 9 12 15 18 9" />
</svg> </svg>
@@ -83,10 +83,10 @@ export default function FaqPage() {
<div className="text-center"> <div className="text-center">
<Badge tone="brand">FAQ</Badge> <Badge tone="brand">FAQ</Badge>
<h1 className="text-display-sm md:text-display text-white mt-5">Frequently asked questions</h1> <h1 className="text-display-sm md:text-display text-white mt-5">Frequently asked questions</h1>
<p className="text-gray-400 mt-4">Can&apos;t find what you&apos;re looking for? Sign in and reach out from your account.</p> <p className="text-ink-2 mt-4">Can&apos;t find what you&apos;re looking for? Sign in and reach out from your account.</p>
</div> </div>
<div className="mt-12 divide-y divide-gray-800 border-t border-b border-gray-800"> <div className="mt-12 divide-y divide-line border-t border-b border-line">
{FAQS.map((item, i) => { {FAQS.map((item, i) => {
const open = openIndex === i; const open = openIndex === i;
return ( return (
@@ -100,7 +100,7 @@ export default function FaqPage() {
<ChevronIcon open={open} /> <ChevronIcon open={open} />
</button> </button>
{open && ( {open && (
<div className="text-gray-400 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</div> <div className="text-ink-2 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</div>
)} )}
</div> </div>
); );
+4 -4
View File
@@ -76,7 +76,7 @@ export default function FeaturesPage() {
<div className="max-w-2xl"> <div className="max-w-2xl">
<Badge tone="brand">Features</Badge> <Badge tone="brand">Features</Badge>
<h1 className="text-display-sm md:text-display text-white mt-5">Everything between the radio and the map</h1> <h1 className="text-display-sm md:text-display text-white mt-5">Everything between the radio and the map</h1>
<p className="text-gray-400 mt-4 leading-relaxed"> <p className="text-ink-2 mt-4 leading-relaxed">
DRB is the pipeline from decoded radio traffic to a picture your team can act on: transcription, DRB is the pipeline from decoded radio traffic to a picture your team can act on: transcription,
correlation, mapping, and a live relay — end to end. correlation, mapping, and a live relay — end to end.
</p> </p>
@@ -88,12 +88,12 @@ export default function FeaturesPage() {
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<p className="text-indigo-400 text-xs font-mono uppercase tracking-wider font-semibold">{s.eyebrow}</p> <p className="text-indigo-400 text-xs font-mono uppercase tracking-wider font-semibold">{s.eyebrow}</p>
<h2 className="text-white text-2xl font-bold mt-2">{s.title}</h2> <h2 className="text-white text-2xl font-bold mt-2">{s.title}</h2>
<p className="text-gray-400 mt-3 leading-relaxed">{s.body}</p> <p className="text-ink-2 mt-3 leading-relaxed">{s.body}</p>
</div> </div>
<Card padding="lg" className="lg:col-span-3"> <Card padding="lg" className="lg:col-span-3">
<ul className="space-y-3"> <ul className="space-y-3">
{s.points.map((p) => ( {s.points.map((p) => (
<li key={p} className="flex items-start gap-3 text-sm text-gray-300"> <li key={p} className="flex items-start gap-3 text-sm text-ink-2">
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0" /> <span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0" />
{p} {p}
</li> </li>
@@ -107,7 +107,7 @@ export default function FeaturesPage() {
))} ))}
</div> </div>
<div className="text-center mt-20 pt-16 border-t border-gray-800"> <div className="text-center mt-20 pt-16 border-t border-line">
<h2 className="text-display-sm text-white">See it running on your own traffic</h2> <h2 className="text-display-sm text-white">See it running on your own traffic</h2>
<div className="mt-6"> <div className="mt-6">
<LinkButton href="/login" size="lg">Get started</LinkButton> <LinkButton href="/login" size="lg">Get started</LinkButton>
+7 -7
View File
@@ -87,30 +87,30 @@ export default function LoginPage() {
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span> <span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
DRB DRB
</Link> </Link>
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono"> <div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
<h1 className="text-white text-lg font-bold">Sign in</h1> <h1 className="text-white text-lg font-bold">Sign in</h1>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Email</label> <label className="text-xs text-ink-2 block mb-1">Email</label>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
autoComplete="email" autoComplete="email"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Password</label> <label className="text-xs text-ink-2 block mb-1">Password</label>
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
autoComplete="current-password" autoComplete="current-password"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
{error && ( {error && (
@@ -127,7 +127,7 @@ export default function LoginPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex-1 h-px bg-gray-700" /> <div className="flex-1 h-px bg-gray-700" />
<span className="text-xs text-gray-500">or</span> <span className="text-xs text-ink-muted">or</span>
<div className="flex-1 h-px bg-gray-700" /> <div className="flex-1 h-px bg-gray-700" />
</div> </div>
@@ -146,7 +146,7 @@ export default function LoginPage() {
Continue with Google Continue with Google
</button> </button>
<p className="text-center text-xs text-gray-500"> <p className="text-center text-xs text-ink-muted">
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<Link href="/signup" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign up</Link> <Link href="/signup" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign up</Link>
</p> </p>
+13 -13
View File
@@ -125,7 +125,7 @@ export default function NodeDetailPage() {
} }
if (!node) { if (!node) {
return <p className="text-gray-500 font-mono text-sm">Loading node…</p>; return <p className="text-ink-muted font-mono text-sm">Loading node…</p>;
} }
const system = systemMap[node.assigned_system_id ?? ""]; const system = systemMap[node.assigned_system_id ?? ""];
@@ -135,7 +135,7 @@ export default function NodeDetailPage() {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h1 className="text-xl font-bold text-white font-mono">{node.name}</h1> <h1 className="text-xl font-bold text-white font-mono">{node.name}</h1>
<p className="text-gray-500 text-sm font-mono">{node.node_id}</p> <p className="text-ink-muted text-sm font-mono">{node.node_id}</p>
</div> </div>
<StatusBadge status={node.status} /> <StatusBadge status={node.status} />
</div> </div>
@@ -147,7 +147,7 @@ export default function NodeDetailPage() {
<span className="text-base">⚠</span> <span className="text-base">⚠</span>
<span>Local System Override Active</span> <span>Local System Override Active</span>
</div> </div>
<p className="text-gray-400 text-xs leading-relaxed"> <p className="text-ink-2 text-xs leading-relaxed">
This node is operating on a local system override. This node is operating on a local system override.
{node.override_timeout_at ? ( {node.override_timeout_at ? (
<> Resets automatically on: <span className="text-white font-bold">{new Date(node.override_timeout_at).toLocaleString()}</span>.</> <> Resets automatically on: <span className="text-white font-bold">{new Date(node.override_timeout_at).toLocaleString()}</span>.</>
@@ -189,7 +189,7 @@ export default function NodeDetailPage() {
)} )}
{/* Info */} {/* Info */}
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm"> <div className="bg-surface border border-line rounded-lg divide-y divide-line font-mono text-sm">
{[ {[
["System", system?.name ?? "Unassigned"], ["System", system?.name ?? "Unassigned"],
["Location", `${node.lat}, ${node.lon}`], ["Location", `${node.lat}, ${node.lon}`],
@@ -208,12 +208,12 @@ export default function NodeDetailPage() {
...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []), ...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []),
].map(([label, value]) => ( ].map(([label, value]) => (
<div key={label} className="flex justify-between px-4 py-2.5"> <div key={label} className="flex justify-between px-4 py-2.5">
<span className="text-gray-500">{label}</span> <span className="text-ink-muted">{label}</span>
<span className="text-gray-200">{value}</span> <span className="text-ink">{value}</span>
</div> </div>
))} ))}
<div className="flex justify-between px-4 py-2.5"> <div className="flex justify-between px-4 py-2.5">
<span className="text-gray-500">Approval</span> <span className="text-ink-muted">Approval</span>
<ApprovalBadge status={node.approval_status ?? null} /> <ApprovalBadge status={node.approval_status ?? null} />
</div> </div>
</div> </div>
@@ -232,7 +232,7 @@ export default function NodeDetailPage() {
<button <button
onClick={handleReject} onClick={handleReject}
disabled={approving} disabled={approving}
className="px-4 py-2 bg-red-900 hover:bg-red-800 disabled:opacity-50 text-gray-300 rounded-lg text-sm font-mono transition-colors" className="px-4 py-2 bg-red-900 hover:bg-red-800 disabled:opacity-50 text-ink-2 rounded-lg text-sm font-mono transition-colors"
> >
Reject Reject
</button> </button>
@@ -250,7 +250,7 @@ export default function NodeDetailPage() {
<button <button
disabled={sending} disabled={sending}
onClick={() => sendCommand("op25_restart")} onClick={() => sendCommand("op25_restart")}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50" className="px-4 py-2 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
> >
Restart OP25 Restart OP25
</button> </button>
@@ -258,7 +258,7 @@ export default function NodeDetailPage() {
<button <button
disabled={sending} disabled={sending}
onClick={handleRequestUpdate} onClick={handleRequestUpdate}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50" className="px-4 py-2 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
> >
Update Node Software Update Node Software
</button> </button>
@@ -283,16 +283,16 @@ export default function NodeDetailPage() {
{/* Recent calls */} {/* Recent calls */}
<section> <section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2> <h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-3">Recent Calls</h2>
{/* Gate A / A2 (server-26#46) — each row expands to a transcript. */} {/* Gate A / A2 (server-26#46) — each row expands to a transcript. */}
{nodeCalls.length > 0 && <MachineOutputNotice variant="inline" className="mb-3" />} {nodeCalls.length > 0 && <MachineOutputNotice variant="inline" className="mb-3" />}
{nodeCalls.length === 0 ? ( {nodeCalls.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No calls recorded from this node.</p> <p className="text-ink-muted text-sm font-mono">No calls recorded from this node.</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800"> <tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line">
<th className="px-4 py-2 text-left">Time</th> <th className="px-4 py-2 text-left">Time</th>
<th className="px-4 py-2 text-left">Talkgroup</th> <th className="px-4 py-2 text-left">Talkgroup</th>
<th className="px-4 py-2 text-left">System</th> <th className="px-4 py-2 text-left">System</th>
+4 -4
View File
@@ -65,7 +65,7 @@ export default function NodesPage() {
<button <button
onClick={handleUpdateAll} onClick={handleUpdateAll}
disabled={updatingAll} disabled={updatingAll}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50" className="px-4 py-2 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
> >
{updatingAll ? "Requesting…" : "Update All Nodes"} {updatingAll ? "Requesting…" : "Update All Nodes"}
</button> </button>
@@ -88,13 +88,13 @@ export default function NodesPage() {
)} )}
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider"> <h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider">
All Nodes ({nodes.length}) All Nodes ({nodes.length})
</h2> </h2>
{loading ? ( {loading ? (
<p className="text-gray-600 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : nodes.length === 0 ? ( ) : nodes.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No nodes registered yet. Boot a Pi to get started.</p> <p className="text-ink-muted text-sm font-mono">No nodes registered yet. Boot a Pi to get started.</p>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{nodes.map((n) => ( {nodes.map((n) => (
+4 -4
View File
@@ -55,17 +55,17 @@ export default function OnboardingPage() {
return ( return (
<div className="max-w-sm mx-auto pt-16"> <div className="max-w-sm mx-auto pt-16">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono"> <div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
<div> <div>
<h1 className="text-white text-lg font-bold">Set up your organization</h1> <h1 className="text-white text-lg font-bold">Set up your organization</h1>
<p className="text-gray-400 text-xs mt-2 leading-relaxed"> <p className="text-ink-2 text-xs mt-2 leading-relaxed">
One more step — name the organization your nodes, calls, and incidents will belong to. You can change this later. One more step — name the organization your nodes, calls, and incidents will belong to. You can change this later.
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Organization name</label> <label className="text-xs text-ink-2 block mb-1">Organization name</label>
<input <input
type="text" type="text"
value={orgName} value={orgName}
@@ -73,7 +73,7 @@ export default function OnboardingPage() {
required required
autoFocus autoFocus
placeholder="e.g. Riverside County Scanner" placeholder="e.g. Riverside County Scanner"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
{error && <p className="text-red-400 text-xs">{error}</p>} {error && <p className="text-red-400 text-xs">{error}</p>}
+9 -9
View File
@@ -51,7 +51,7 @@ function MarketingHomePage() {
<h1 className="text-display-sm md:text-display mt-5 text-white"> <h1 className="text-display-sm md:text-display mt-5 text-white">
See what&apos;s happening on the radio, as an incident — not a wall of calls. See what&apos;s happening on the radio, as an incident — not a wall of calls.
</h1> </h1>
<p className="text-gray-400 text-base md:text-lg mt-5 max-w-2xl leading-relaxed"> <p className="text-ink-2 text-base md:text-lg mt-5 max-w-2xl leading-relaxed">
DRB turns field SDR nodes into a live public-safety picture: police/fire radio is decoded, transcribed, DRB turns field SDR nodes into a live public-safety picture: police/fire radio is decoded, transcribed,
and correlated into incidents you can watch on a map or scrub back through in history — with a Discord and correlated into incidents you can watch on a map or scrub back through in history — with a Discord
bot to relay the audio live to your team. bot to relay the audio live to your team.
@@ -68,7 +68,7 @@ function MarketingHomePage() {
<section className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20"> <section className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="max-w-2xl mb-10"> <div className="max-w-2xl mb-10">
<h2 className="text-display-sm text-white">The unit of value is the incident</h2> <h2 className="text-display-sm text-white">The unit of value is the incident</h2>
<p className="text-gray-400 mt-3"> <p className="text-ink-2 mt-3">
A scanner feed is noise. DRB's job is to turn that noise into a small number of things you actually care A scanner feed is noise. DRB's job is to turn that noise into a small number of things you actually care
about — and let you click into any one of them for the full picture. about — and let you click into any one of them for the full picture.
</p> </p>
@@ -77,14 +77,14 @@ function MarketingHomePage() {
{CAPABILITIES.map((c) => ( {CAPABILITIES.map((c) => (
<Card key={c.title} padding="lg" hover> <Card key={c.title} padding="lg" hover>
<h3 className="text-white font-semibold">{c.title}</h3> <h3 className="text-white font-semibold">{c.title}</h3>
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{c.body}</p> <p className="text-ink-2 text-sm mt-2 leading-relaxed">{c.body}</p>
</Card> </Card>
))} ))}
</div> </div>
</section> </section>
{/* How it works */} {/* How it works */}
<section className="border-t border-gray-800"> <section className="border-t border-line">
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20"> <div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<h2 className="text-display-sm text-white mb-10">How it works</h2> <h2 className="text-display-sm text-white mb-10">How it works</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
@@ -92,7 +92,7 @@ function MarketingHomePage() {
<div key={s.n}> <div key={s.n}>
<p className="text-indigo-400 font-mono text-sm font-bold">{s.n}</p> <p className="text-indigo-400 font-mono text-sm font-bold">{s.n}</p>
<h3 className="text-white font-semibold mt-2">{s.title}</h3> <h3 className="text-white font-semibold mt-2">{s.title}</h3>
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{s.body}</p> <p className="text-ink-2 text-sm mt-2 leading-relaxed">{s.body}</p>
</div> </div>
))} ))}
</div> </div>
@@ -100,12 +100,12 @@ function MarketingHomePage() {
</section> </section>
{/* Pricing — Gate A (minutes #42/#62): no price on a public surface. */} {/* Pricing — Gate A (minutes #42/#62): no price on a public surface. */}
<section className="border-t border-gray-800"> <section className="border-t border-line">
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20"> <div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div> <div>
<h2 className="text-display-sm text-white">Pricing is in development</h2> <h2 className="text-display-sm text-white">Pricing is in development</h2>
<p className="text-gray-400 mt-2 max-w-xl"> <p className="text-ink-2 mt-2 max-w-xl">
We would rather talk to you about what you need than post a number we would have to We would rather talk to you about what you need than post a number we would have to
walk back. Tell us about your coverage area and we will figure it out together. walk back. Tell us about your coverage area and we will figure it out together.
</p> </p>
@@ -118,10 +118,10 @@ function MarketingHomePage() {
</section> </section>
{/* Final CTA */} {/* Final CTA */}
<section className="border-t border-gray-800"> <section className="border-t border-line">
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20 text-center"> <div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20 text-center">
<h2 className="text-display-sm text-white">Bring your first node online</h2> <h2 className="text-display-sm text-white">Bring your first node online</h2>
<p className="text-gray-400 mt-3 max-w-xl mx-auto"> <p className="text-ink-2 mt-3 max-w-xl mx-auto">
Tell us about your coverage area. We will get you a node online and you will see incidents within minutes of your first call. Tell us about your coverage area. We will get you a node online and you will see incidents within minutes of your first call.
</p> </p>
<div className="mt-8"> <div className="mt-8">
+3 -3
View File
@@ -15,7 +15,7 @@ export default function PricingPage() {
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20"> <div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="text-center max-w-2xl mx-auto"> <div className="text-center max-w-2xl mx-auto">
<h1 className="text-display-sm md:text-display text-white">Pricing is in development</h1> <h1 className="text-display-sm md:text-display text-white">Pricing is in development</h1>
<p className="text-gray-400 mt-4"> <p className="text-ink-2 mt-4">
We are still working out what a fair price looks like for the people who actually use this. We are still working out what a fair price looks like for the people who actually use this.
Rather than post a number we would have to walk back, we would rather talk to you about what Rather than post a number we would have to walk back, we would rather talk to you about what
you need and what it is worth. you need and what it is worth.
@@ -24,7 +24,7 @@ export default function PricingPage() {
<Card padding="lg" className="mt-12 max-w-2xl mx-auto"> <Card padding="lg" className="mt-12 max-w-2xl mx-auto">
<h2 className="text-white text-lg font-bold">What you get today</h2> <h2 className="text-white text-lg font-bold">What you get today</h2>
<p className="text-gray-400 text-sm mt-2 leading-relaxed"> <p className="text-ink-2 text-sm mt-2 leading-relaxed">
The full incident pipeline — transcription, correlation into incidents, mapping, and the The full incident pipeline — transcription, correlation into incidents, mapping, and the
Discord relay. Node counts, seats, and history length are set per account while we work out Discord relay. Node counts, seats, and history length are set per account while we work out
the plan structure. the plan structure.
@@ -35,7 +35,7 @@ export default function PricingPage() {
</Card> </Card>
<div className="text-center mt-16"> <div className="text-center mt-16">
<p className="text-gray-400"> <p className="text-ink-2">
Questions?{" "} Questions?{" "}
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link>
{" "}or{" "} {" "}or{" "}
+3 -3
View File
@@ -63,18 +63,18 @@ export default function PrivacyPage() {
</div> </div>
<h1 className="text-display-sm text-white">Privacy Policy</h1> <h1 className="text-display-sm text-white">Privacy Policy</h1>
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p> <p className="text-ink-muted text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
<div className="mt-10 space-y-8"> <div className="mt-10 space-y-8">
{SECTIONS.map((s) => ( {SECTIONS.map((s) => (
<section key={s.heading}> <section key={s.heading}>
<h2 className="text-white font-semibold">{s.heading}</h2> <h2 className="text-white font-semibold">{s.heading}</h2>
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p> <p className="text-ink-muted text-sm mt-2 leading-relaxed italic">{s.note}</p>
</section> </section>
))} ))}
</div> </div>
<p className="text-gray-600 text-xs font-mono mt-16"> <p className="text-ink-muted text-xs font-mono mt-16">
Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "} Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "}
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>. <Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
</p> </p>
+16 -16
View File
@@ -92,13 +92,13 @@ export default function ProfilePage() {
<div> <div>
<h1 className="text-white text-xl font-bold">{displayName}</h1> <h1 className="text-white text-xl font-bold">{displayName}</h1>
{user.displayName && user.email && ( {user.displayName && user.email && (
<p className="text-gray-400 text-sm mt-0.5">{user.email}</p> <p className="text-ink-2 text-sm mt-0.5">{user.email}</p>
)} )}
{role && ( {role && (
<span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${ <span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${
role === "admin" ? "bg-indigo-900 text-indigo-300" : role === "admin" ? "bg-indigo-900 text-indigo-300" :
role === "operator" ? "bg-green-900 text-green-300" : role === "operator" ? "bg-green-900 text-green-300" :
"bg-gray-800 text-gray-400" "bg-raised text-ink-2"
}`}> }`}>
{role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"} {role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"}
</span> </span>
@@ -107,9 +107,9 @@ export default function ProfilePage() {
</div> </div>
{/* Firebase account */} {/* Firebase account */}
<section className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800"> <section className="bg-surface border border-line rounded-xl divide-y divide-line">
<div className="px-4 py-3"> <div className="px-4 py-3">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Account</p> <p className="text-xs text-ink-muted uppercase tracking-wider font-mono mb-2">Account</p>
<div className="space-y-2"> <div className="space-y-2">
<Row label="Email" value={user.email ?? "—"} /> <Row label="Email" value={user.email ?? "—"} />
<Row label="UID" value={user.uid} mono truncate /> <Row label="UID" value={user.uid} mono truncate />
@@ -125,12 +125,12 @@ export default function ProfilePage() {
</section> </section>
{/* Discord linking */} {/* Discord linking */}
<section className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> <section className="bg-surface border border-line rounded-xl overflow-hidden">
<div className="px-4 py-3"> <div className="px-4 py-3">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-3">Discord</p> <p className="text-xs text-ink-muted uppercase tracking-wider font-mono mb-3">Discord</p>
{linkLoading ? ( {linkLoading ? (
<p className="text-gray-500 text-sm">Loading…</p> <p className="text-ink-muted text-sm">Loading…</p>
) : linkStatus?.linked ? ( ) : linkStatus?.linked ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
@@ -156,21 +156,21 @@ export default function ProfilePage() {
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-gray-400"> <p className="text-sm text-ink-2">
Link your Discord account to access private trips from both the web and Discord. Link your Discord account to access private trips from both the web and Discord.
</p> </p>
{code ? ( {code ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="bg-gray-800 rounded-lg px-4 py-3 flex items-center gap-3"> <div className="bg-raised rounded-lg px-4 py-3 flex items-center gap-3">
<span className="font-mono text-2xl tracking-[0.4em] text-white select-all">{code}</span> <span className="font-mono text-2xl tracking-[0.4em] text-white select-all">{code}</span>
</div> </div>
<p className="text-xs text-gray-400"> <p className="text-xs text-ink-2">
Run <span className="font-mono text-gray-200">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes. Run <span className="font-mono text-ink">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes.
</p> </p>
<button <button
onClick={generateCode} onClick={generateCode}
disabled={generating} disabled={generating}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors disabled:opacity-50" className="text-xs text-ink-muted hover:text-ink transition-colors disabled:opacity-50"
> >
Generate new code Generate new code
</button> </button>
@@ -190,9 +190,9 @@ export default function ProfilePage() {
</section> </section>
{/* Sign out */} {/* Sign out */}
<section className="bg-gray-900 border border-gray-800 rounded-xl"> <section className="bg-surface border border-line rounded-xl">
<div className="px-4 py-3 flex items-center justify-between"> <div className="px-4 py-3 flex items-center justify-between">
<p className="text-sm text-gray-400">Sign out of this device</p> <p className="text-sm text-ink-2">Sign out of this device</p>
<button <button
onClick={handleSignOut} onClick={handleSignOut}
className="text-sm text-red-500 hover:text-red-400 transition-colors" className="text-sm text-red-500 hover:text-red-400 transition-colors"
@@ -213,8 +213,8 @@ function Row({ label, value, mono = false, truncate = false }: {
}) { }) {
return ( return (
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<span className="text-xs text-gray-500 shrink-0">{label}</span> <span className="text-xs text-ink-muted shrink-0">{label}</span>
<span className={`text-sm text-gray-200 text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}> <span className={`text-sm text-ink text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}>
{value} {value}
</span> </span>
</div> </div>
+13 -13
View File
@@ -39,11 +39,11 @@ function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<Card padding="lg" className="w-full max-w-lg space-y-4"> <Card padding="lg" className="w-full max-w-lg space-y-4">
<h2 className="text-white font-semibold">Key created</h2> <h2 className="text-white font-semibold">Key created</h2>
<p className="text-xs text-gray-400"> <p className="text-xs text-ink-2">
Copy this key now — it won&apos;t be shown again. This is a sample key from the demo module in{" "} Copy this key now — it won&apos;t be shown again. This is a sample key from the demo module in{" "}
<code className="text-gray-300">lib/apiKeys.ts</code>; it doesn&apos;t authenticate against anything. <code className="text-ink-2">lib/apiKeys.ts</code>; it doesn&apos;t authenticate against anything.
</p> </p>
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3"> <div className="bg-raised border border-line-strong rounded-lg p-3">
<p className="text-xs text-indigo-300 break-all font-mono">{rawKey}</p> <p className="text-xs text-indigo-300 break-all font-mono">{rawKey}</p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
@@ -61,11 +61,11 @@ function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated
<h2 className="text-white font-semibold mb-4">New API key</h2> <h2 className="text-white font-semibold mb-4">New API key</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Label</label> <label className="text-xs text-ink-2 block mb-1">Label</label>
<input <input
required value={name} onChange={(e) => setName(e.target.value)} required value={name} onChange={(e) => setName(e.target.value)}
placeholder="e.g. Ops dashboard integration" placeholder="e.g. Ops dashboard integration"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
@@ -96,9 +96,9 @@ export default function ApiKeysSettingsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4"> <div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4">
<p className="text-indigo-300 text-sm font-semibold">Preview feature</p> <p className="text-indigo-300 text-sm font-semibold">Preview feature</p>
<p className="text-gray-400 text-xs mt-1 leading-relaxed"> <p className="text-ink-2 text-xs mt-1 leading-relaxed">
Organization API keys aren&apos;t backed by a real endpoint yet — this screen runs against an in-memory Organization API keys aren&apos;t backed by a real endpoint yet — this screen runs against an in-memory
demo module (<code className="text-gray-300">lib/apiKeys.ts</code>) so the flow can be reviewed end to demo module (<code className="text-ink-2">lib/apiKeys.ts</code>) so the flow can be reviewed end to
end. See that file for the exact backend routes a real integration needs. end. See that file for the exact backend routes a real integration needs.
</p> </p>
</div> </div>
@@ -108,7 +108,7 @@ export default function ApiKeysSettingsPage() {
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-gray-500">{loading ? "Loading…" : `${active.length} active key${active.length !== 1 ? "s" : ""}`}</p> <p className="text-sm text-ink-muted">{loading ? "Loading…" : `${active.length} active key${active.length !== 1 ? "s" : ""}`}</p>
<Button size="sm" onClick={() => setShowCreate(true)}>+ Create key</Button> <Button size="sm" onClick={() => setShowCreate(true)}>+ Create key</Button>
</div> </div>
@@ -118,7 +118,7 @@ export default function ApiKeysSettingsPage() {
<Card padding="none" className="overflow-hidden"> <Card padding="none" className="overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900"> <tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line bg-surface">
<th className="px-4 py-3 text-left">Label</th> <th className="px-4 py-3 text-left">Label</th>
<th className="px-4 py-3 text-left">Key</th> <th className="px-4 py-3 text-left">Key</th>
<th className="px-4 py-3 text-left hidden sm:table-cell">Created</th> <th className="px-4 py-3 text-left hidden sm:table-cell">Created</th>
@@ -129,11 +129,11 @@ export default function ApiKeysSettingsPage() {
</thead> </thead>
<tbody> <tbody>
{keys.map((k) => ( {keys.map((k) => (
<tr key={k.key_id} className="border-b border-gray-800 last:border-0"> <tr key={k.key_id} className="border-b border-line last:border-0">
<td className="px-4 py-3 text-white">{k.name}</td> <td className="px-4 py-3 text-white">{k.name}</td>
<td className="px-4 py-3 text-gray-500 font-mono text-xs">{k.key_prefix}…</td> <td className="px-4 py-3 text-ink-muted font-mono text-xs">{k.key_prefix}…</td>
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{fmtDate(k.created_at)}</td> <td className="px-4 py-3 text-ink-2 text-xs hidden sm:table-cell">{fmtDate(k.created_at)}</td>
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{k.last_used_at ? fmtDate(k.last_used_at) : "Never"}</td> <td className="px-4 py-3 text-ink-2 text-xs hidden sm:table-cell">{k.last_used_at ? fmtDate(k.last_used_at) : "Never"}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{k.revoked ? <Badge tone="danger">Revoked</Badge> : <Badge tone="success">Active</Badge>} {k.revoked ? <Badge tone="danger">Revoked</Badge> : <Badge tone="success">Active</Badge>}
</td> </td>
+12 -12
View File
@@ -54,12 +54,12 @@ function UsageBar({ label, used, limit }: { label: string; used: number; limit:
return ( return (
<div> <div>
<div className="flex items-baseline justify-between mb-1.5"> <div className="flex items-baseline justify-between mb-1.5">
<span className="text-xs text-gray-400 font-mono">{label}</span> <span className="text-xs text-ink-2 font-mono">{label}</span>
<span className="text-xs font-mono text-gray-300"> <span className="text-xs font-mono text-ink-2">
{used} / {limit === "unlimited" ? "∞" : limit} {used} / {limit === "unlimited" ? "∞" : limit}
</span> </span>
</div> </div>
<div className="h-2 rounded-full bg-gray-800 overflow-hidden"> <div className="h-2 rounded-full bg-raised overflow-hidden">
<div <div
className={`h-full rounded-full transition-all ${nearLimit ? "bg-orange-500" : "bg-indigo-500"}`} className={`h-full rounded-full transition-all ${nearLimit ? "bg-orange-500" : "bg-indigo-500"}`}
style={{ width: limit === "unlimited" ? "8%" : `${pct}%` }} style={{ width: limit === "unlimited" ? "8%" : `${pct}%` }}
@@ -142,7 +142,7 @@ export default function BillingSettingsPage() {
return ( return (
<div className="space-y-6 max-w-4xl"> <div className="space-y-6 max-w-4xl">
<p className="text-xs text-gray-600 font-mono"> <p className="text-xs text-ink-muted font-mono">
Demo data — this page isn&apos;t connected to a live payment processor. See lib/billing.ts for the integration plan. Demo data — this page isn&apos;t connected to a live payment processor. See lib/billing.ts for the integration plan.
</p> </p>
@@ -164,7 +164,7 @@ export default function BillingSettingsPage() {
<UsageBar label="Seats" used={usage.seatsUsed} limit={usage.seatsLimit} /> <UsageBar label="Seats" used={usage.seatsUsed} limit={usage.seatsLimit} />
<UsageBar label="Nodes" used={usage.nodesUsed} limit={usage.nodesLimit} /> <UsageBar label="Nodes" used={usage.nodesUsed} limit={usage.nodesLimit} />
</div> </div>
<div className="mt-5 pt-5 border-t border-gray-800"> <div className="mt-5 pt-5 border-t border-line">
<Button variant="secondary" size="sm" onClick={handleManageBilling} disabled={portalBusy}> <Button variant="secondary" size="sm" onClick={handleManageBilling} disabled={portalBusy}>
{portalBusy ? "Opening…" : "Manage payment method & invoices"} {portalBusy ? "Opening…" : "Manage payment method & invoices"}
</Button> </Button>
@@ -179,10 +179,10 @@ export default function BillingSettingsPage() {
return ( return (
<div <div
key={p.id} key={p.id}
className={`rounded-xl border p-4 flex flex-col ${p.highlighted ? "border-indigo-600/40" : "border-gray-800"}`} className={`rounded-xl border p-4 flex flex-col ${p.highlighted ? "border-indigo-600/40" : "border-line"}`}
> >
<p className="text-white font-semibold text-sm">{p.name}</p> <p className="text-white font-semibold text-sm">{p.name}</p>
<p className="text-gray-500 text-xs mt-1 flex-1">{p.tagline}</p> <p className="text-ink-muted text-xs mt-1 flex-1">{p.tagline}</p>
{(UNBUILT_CLAIMS[p.id] ?? []).length > 0 && ( {(UNBUILT_CLAIMS[p.id] ?? []).length > 0 && (
<div className="flex flex-wrap gap-1 mt-2"> <div className="flex flex-wrap gap-1 mt-2">
{(UNBUILT_CLAIMS[p.id] ?? []).map((claim) => ( {(UNBUILT_CLAIMS[p.id] ?? []).map((claim) => (
@@ -212,17 +212,17 @@ export default function BillingSettingsPage() {
<Card> <Card>
<CardHeader title="Invoice history" /> <CardHeader title="Invoice history" />
{invoices.length === 0 ? ( {invoices.length === 0 ? (
<p className="text-gray-600 text-sm">No invoices yet.</p> <p className="text-ink-muted text-sm">No invoices yet.</p>
) : ( ) : (
<div className="divide-y divide-gray-800"> <div className="divide-y divide-line">
{invoices.map((inv) => ( {invoices.map((inv) => (
<div key={inv.id} className="flex items-center justify-between gap-4 py-3"> <div key={inv.id} className="flex items-center justify-between gap-4 py-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-gray-200 text-sm">{inv.description}</p> <p className="text-ink text-sm">{inv.description}</p>
<p className="text-gray-600 text-xs font-mono">{fmtDate(inv.date)}</p> <p className="text-ink-muted text-xs font-mono">{fmtDate(inv.date)}</p>
</div> </div>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<span className="text-gray-300 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span> <span className="text-ink-2 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span>
<Badge tone={INVOICE_TONE[inv.status]}>{inv.status}</Badge> <Badge tone={INVOICE_TONE[inv.status]}>{inv.status}</Badge>
</div> </div>
</div> </div>
+3 -3
View File
@@ -43,15 +43,15 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
description="Organization profile, team access, node ownership, API keys, and billing." description="Organization profile, team access, node ownership, API keys, and billing."
/> />
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit max-w-full overflow-x-auto"> <div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit max-w-full overflow-x-auto">
{[...TABS, ...(isAdmin ? ADMIN_TABS : [])].map((t) => ( {[...TABS, ...(isAdmin ? ADMIN_TABS : [])].map((t) => (
<Link <Link
key={t.href} key={t.href}
href={t.href} href={t.href}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors whitespace-nowrap ${ className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors whitespace-nowrap ${
pathname === t.href || pathname.startsWith(t.href + "/") pathname === t.href || pathname.startsWith(t.href + "/")
? "bg-gray-800 text-white" ? "bg-raised text-white"
: "text-gray-500 hover:text-gray-300" : "text-ink-muted hover:text-ink"
}`} }`}
> >
{t.label} {t.label}
+13 -13
View File
@@ -46,8 +46,8 @@ function InviteModal({ onClose, onCreated }: { onClose: () => void; onCreated: (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<Card padding="lg" className="w-full max-w-md space-y-4"> <Card padding="lg" className="w-full max-w-md space-y-4">
<h2 className="text-white font-semibold">Member invited</h2> <h2 className="text-white font-semibold">Member invited</h2>
<p className="text-xs text-gray-400">Share this one-time invite link so they can set their password. It expires after use.</p> <p className="text-xs text-ink-2">Share this one-time invite link so they can set their password. It expires after use.</p>
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3"> <div className="bg-raised border border-line-strong rounded-lg p-3">
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p> <p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
</div> </div>
<Button onClick={onClose} fullWidth>Done</Button> <Button onClick={onClose} fullWidth>Done</Button>
@@ -62,18 +62,18 @@ function InviteModal({ onClose, onCreated }: { onClose: () => void; onCreated: (
<h2 className="text-white font-semibold mb-4">Invite a member</h2> <h2 className="text-white font-semibold mb-4">Invite a member</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Email</label> <label className="text-xs text-ink-2 block mb-1">Email</label>
<input <input
type="email" required value={email} onChange={(e) => setEmail(e.target.value)} type="email" required value={email} onChange={(e) => setEmail(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="teammate@example.com" placeholder="teammate@example.com"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Role</label> <label className="text-xs text-ink-2 block mb-1">Role</label>
<select <select
value={role} onChange={(e) => setRole(e.target.value as UserRole)} value={role} onChange={(e) => setRole(e.target.value as UserRole)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
> >
<option value="admin">Admin — full access</option> <option value="admin">Admin — full access</option>
<option value="operator">Operator — owns nodes</option> <option value="operator">Operator — owns nodes</option>
@@ -130,7 +130,7 @@ export default function MembersSettingsPage() {
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-gray-500"> <p className="text-sm text-ink-muted">
{loading ? "Loading members…" : `${users.length} member${users.length !== 1 ? "s" : ""}`} {loading ? "Loading members…" : `${users.length} member${users.length !== 1 ? "s" : ""}`}
</p> </p>
<Button size="sm" onClick={() => setShowInvite(true)}>+ Invite member</Button> <Button size="sm" onClick={() => setShowInvite(true)}>+ Invite member</Button>
@@ -144,7 +144,7 @@ export default function MembersSettingsPage() {
<Card padding="none" className="overflow-hidden"> <Card padding="none" className="overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900"> <tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line bg-surface">
<th className="px-4 py-3 text-left">Member</th> <th className="px-4 py-3 text-left">Member</th>
<th className="px-4 py-3 text-left">Role</th> <th className="px-4 py-3 text-left">Role</th>
<th className="px-4 py-3 text-left hidden sm:table-cell">Owned nodes</th> <th className="px-4 py-3 text-left hidden sm:table-cell">Owned nodes</th>
@@ -155,10 +155,10 @@ export default function MembersSettingsPage() {
{loading {loading
? Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={4} />) ? Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={4} />)
: users.map((u) => ( : users.map((u) => (
<tr key={u.uid} className="border-b border-gray-800 last:border-0"> <tr key={u.uid} className="border-b border-line last:border-0">
<td className="px-4 py-3"> <td className="px-4 py-3">
<p className="text-white">{u.display_name || u.email}</p> <p className="text-white">{u.display_name || u.email}</p>
{u.display_name && <p className="text-gray-600 text-xs">{u.email}</p>} {u.display_name && <p className="text-ink-muted text-xs">{u.email}</p>}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{u.uid === user?.uid ? ( {u.uid === user?.uid ? (
@@ -168,7 +168,7 @@ export default function MembersSettingsPage() {
value={u.role} value={u.role}
disabled={savingUid === u.uid} disabled={savingUid === u.uid}
onChange={(e) => handleRoleChange(u, e.target.value as UserRole)} onChange={(e) => handleRoleChange(u, e.target.value as UserRole)}
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50" className="bg-raised border border-line-strong rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50"
> >
<option value="admin">Admin</option> <option value="admin">Admin</option>
<option value="operator">Operator</option> <option value="operator">Operator</option>
@@ -176,7 +176,7 @@ export default function MembersSettingsPage() {
</select> </select>
)} )}
</td> </td>
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell"> <td className="px-4 py-3 text-ink-2 text-xs hidden sm:table-cell">
{u.role === "operator" ? u.owned_node_ids.length : "—"} {u.role === "operator" ? u.owned_node_ids.length : "—"}
</td> </td>
<td className="px-4 py-3 hidden md:table-cell"> <td className="px-4 py-3 hidden md:table-cell">
@@ -189,7 +189,7 @@ export default function MembersSettingsPage() {
</Card> </Card>
)} )}
<p className="text-xs text-gray-600 font-mono"> <p className="text-xs text-ink-muted font-mono">
Need to disable or delete a member? Use the full user admin panel under Admin → Users. Need to disable or delete a member? Use the full user admin panel under Admin → Users.
</p> </p>
</div> </div>
+14 -14
View File
@@ -114,7 +114,7 @@ function EnrollmentTokensPanel() {
</p> </p>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<p className="flex-1 text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p> <p className="flex-1 text-xs text-indigo-100 font-mono break-all bg-surface rounded px-2 py-1.5">{justMinted}</p>
<button <button
type="button" type="button"
onClick={() => navigator.clipboard?.writeText(justMinted).then(() => { onClick={() => navigator.clipboard?.writeText(justMinted).then(() => {
@@ -133,7 +133,7 @@ function EnrollmentTokensPanel() {
: <>(check the broker host)</>}: : <>(check the broker host)</>}:
</p> </p>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-gray-900 rounded px-2 py-1.5">{installCmd}</pre> <pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-surface rounded px-2 py-1.5">{installCmd}</pre>
<button <button
type="button" type="button"
onClick={() => navigator.clipboard?.writeText(installCmd).then(() => { onClick={() => navigator.clipboard?.writeText(installCmd).then(() => {
@@ -163,7 +163,7 @@ function EnrollmentTokensPanel() {
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
placeholder="Node ID, e.g. node-003" placeholder="Node ID, e.g. node-003"
className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500" className="flex-1 min-w-[12rem] bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
<Button type="submit" size="sm" disabled={minting || !label.trim()}> <Button type="submit" size="sm" disabled={minting || !label.trim()}>
{minting ? "Minting…" : "New token"} {minting ? "Minting…" : "New token"}
@@ -176,11 +176,11 @@ function EnrollmentTokensPanel() {
<SkeletonRow cols={1} /> <SkeletonRow cols={1} />
</div> </div>
) : tokens.length === 0 ? ( ) : tokens.length === 0 ? (
<p className="text-gray-600 text-xs">No enrollment tokens yet.</p> <p className="text-ink-muted text-xs">No enrollment tokens yet.</p>
) : ( ) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800"> <tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line">
<th className="py-2 text-left">Label</th> <th className="py-2 text-left">Label</th>
<th className="py-2 text-left hidden sm:table-cell">Created</th> <th className="py-2 text-left hidden sm:table-cell">Created</th>
<th className="py-2 text-left">Status</th> <th className="py-2 text-left">Status</th>
@@ -189,14 +189,14 @@ function EnrollmentTokensPanel() {
</thead> </thead>
<tbody> <tbody>
{tokens.map((t) => ( {tokens.map((t) => (
<tr key={t.token_id} className="border-b border-gray-800 last:border-0"> <tr key={t.token_id} className="border-b border-line last:border-0">
<td className="py-2 text-white">{t.label}</td> <td className="py-2 text-white">{t.label}</td>
<td className="py-2 text-gray-500 text-xs hidden sm:table-cell"> <td className="py-2 text-ink-muted text-xs hidden sm:table-cell">
{new Date(t.created_at).toLocaleDateString()} {new Date(t.created_at).toLocaleDateString()}
</td> </td>
<td className="py-2 text-xs"> <td className="py-2 text-xs">
{t.revoked ? ( {t.revoked ? (
<span className="text-gray-600">Revoked</span> <span className="text-ink-muted">Revoked</span>
) : ( ) : (
<span className="text-green-400">Active · {t.uses} use{t.uses !== 1 ? "s" : ""}</span> <span className="text-green-400">Active · {t.uses} use{t.uses !== 1 ? "s" : ""}</span>
)} )}
@@ -283,7 +283,7 @@ export default function NodeOwnershipSettingsPage() {
<EnrollmentTokensPanel /> <EnrollmentTokensPanel />
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-gray-500"> <p className="text-sm text-ink-muted">
Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here. Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here.
</p> </p>
@@ -292,7 +292,7 @@ export default function NodeOwnershipSettingsPage() {
<Card padding="none" className="overflow-hidden"> <Card padding="none" className="overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900"> <tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line bg-surface">
<th className="px-4 py-3 text-left">Node</th> <th className="px-4 py-3 text-left">Node</th>
<th className="px-4 py-3 text-left hidden sm:table-cell">Status</th> <th className="px-4 py-3 text-left hidden sm:table-cell">Status</th>
<th className="px-4 py-3 text-left">Owner</th> <th className="px-4 py-3 text-left">Owner</th>
@@ -302,17 +302,17 @@ export default function NodeOwnershipSettingsPage() {
{loading ? ( {loading ? (
Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={3} />) Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={3} />)
) : nodes.length === 0 ? ( ) : nodes.length === 0 ? (
<tr><td colSpan={3} className="px-4 py-8 text-center text-gray-600 text-sm">No nodes registered yet.</td></tr> <tr><td colSpan={3} className="px-4 py-8 text-center text-ink-muted text-sm">No nodes registered yet.</td></tr>
) : ( ) : (
nodes.map((n) => { nodes.map((n) => {
const owner = ownerByNode.get(n.node_id); const owner = ownerByNode.get(n.node_id);
return ( return (
<tr key={n.node_id} className="border-b border-gray-800 last:border-0"> <tr key={n.node_id} className="border-b border-line last:border-0">
<td className="px-4 py-3"> <td className="px-4 py-3">
<Link href={`/nodes/${n.node_id}`} className="text-white hover:text-indigo-300 transition-colors"> <Link href={`/nodes/${n.node_id}`} className="text-white hover:text-indigo-300 transition-colors">
{n.name} {n.name}
</Link> </Link>
<p className="text-gray-600 text-xs font-mono">{n.node_id}</p> <p className="text-ink-muted text-xs font-mono">{n.node_id}</p>
</td> </td>
<td className="px-4 py-3 hidden sm:table-cell"><StatusBadge status={n.status} /></td> <td className="px-4 py-3 hidden sm:table-cell"><StatusBadge status={n.status} /></td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -320,7 +320,7 @@ export default function NodeOwnershipSettingsPage() {
value={owner?.uid ?? UNASSIGNED} value={owner?.uid ?? UNASSIGNED}
disabled={savingNodeId === n.node_id} disabled={savingNodeId === n.node_id}
onChange={(e) => reassign(n.node_id, e.target.value)} onChange={(e) => reassign(n.node_id, e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50 max-w-[14rem]" className="bg-raised border border-line-strong rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50 max-w-[14rem]"
> >
<option value={UNASSIGNED}>Unassigned</option> <option value={UNASSIGNED}>Unassigned</option>
{assignable.map((u) => ( {assignable.map((u) => (
@@ -16,7 +16,7 @@ import { PreferredBotsCard } from "@/components/discord/PreferredBotsCard";
function StatTile({ label, value }: { label: string; value: string | number }) { function StatTile({ label, value }: { label: string; value: string | number }) {
return ( return (
<div> <div>
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">{label}</p> <p className="text-xs text-ink-muted uppercase tracking-wider font-mono">{label}</p>
<p className="text-2xl font-bold text-white font-mono mt-1">{value}</p> <p className="text-2xl font-bold text-white font-mono mt-1">{value}</p>
</div> </div>
); );
@@ -69,12 +69,12 @@ export default function OrganizationSettingsPage() {
/> />
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Organization name</label> <label className="text-xs text-ink-2 block mb-1">Organization name</label>
<input <input
value={orgName} value={orgName}
disabled={orgLoading || !canEdit} disabled={orgLoading || !canEdit}
onChange={(e) => setOrgName(e.target.value)} onChange={(e) => setOrgName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50"
/> />
</div> </div>
{saveError && <p className="text-red-400 text-xs">{saveError}</p>} {saveError && <p className="text-red-400 text-xs">{saveError}</p>}
@@ -110,7 +110,7 @@ export default function OrganizationSettingsPage() {
value={sub ? sub.status.replace("_", " ") : "—"} value={sub ? sub.status.replace("_", " ") : "—"}
/> />
</div> </div>
<div className="mt-5 pt-5 border-t border-gray-800 flex items-center gap-4 text-sm"> <div className="mt-5 pt-5 border-t border-line flex items-center gap-4 text-sm">
<Link href="/settings/billing" className="text-indigo-400 hover:text-indigo-300 transition-colors"> <Link href="/settings/billing" className="text-indigo-400 hover:text-indigo-300 transition-colors">
Manage plan & billing → Manage plan & billing →
</Link> </Link>
@@ -124,7 +124,7 @@ export default function OrganizationSettingsPage() {
<PreferredBotsCard canEdit={canEdit} /> <PreferredBotsCard canEdit={canEdit} />
<div className="bg-gray-900 border border-red-800/60 rounded-xl p-5"> <div className="bg-surface border border-red-800/60 rounded-xl p-5">
<CardHeader title="Danger zone" subtitle="Destructive organization-level actions." /> <CardHeader title="Danger zone" subtitle="Destructive organization-level actions." />
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Button variant="danger" size="sm" disabled title="Not available in this build — contact support"> <Button variant="danger" size="sm" disabled title="Not available in this build — contact support">
+7 -7
View File
@@ -61,23 +61,23 @@ export default function SignupPage() {
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span> <span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
DRB DRB
</Link> </Link>
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono"> <div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
<h1 className="text-white text-lg font-bold">Create your account</h1> <h1 className="text-white text-lg font-bold">Create your account</h1>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Email</label> <label className="text-xs text-ink-2 block mb-1">Email</label>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
autoComplete="email" autoComplete="email"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Password</label> <label className="text-xs text-ink-2 block mb-1">Password</label>
<input <input
type="password" type="password"
value={password} value={password}
@@ -85,7 +85,7 @@ export default function SignupPage() {
required required
minLength={6} minLength={6}
autoComplete="new-password" autoComplete="new-password"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
{error && ( {error && (
@@ -102,7 +102,7 @@ export default function SignupPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex-1 h-px bg-gray-700" /> <div className="flex-1 h-px bg-gray-700" />
<span className="text-xs text-gray-500">or</span> <span className="text-xs text-ink-muted">or</span>
<div className="flex-1 h-px bg-gray-700" /> <div className="flex-1 h-px bg-gray-700" />
</div> </div>
@@ -121,7 +121,7 @@ export default function SignupPage() {
Continue with Google Continue with Google
</button> </button>
<p className="text-center text-xs text-gray-500"> <p className="text-center text-xs text-ink-muted">
Already have an account?{" "} Already have an account?{" "}
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign in</Link> <Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign in</Link>
</p> </p>
+113 -113
View File
@@ -438,17 +438,17 @@ function RRImportModal({
return ( return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono"> <div className="bg-surface border border-line-strong rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono">
{/* Header */} {/* Header */}
<div className="px-5 pt-5 pb-4 border-b border-gray-800"> <div className="px-5 pt-5 pb-4 border-b border-line">
<h2 className="text-white font-semibold">{system.name}</h2> <h2 className="text-white font-semibold">{system.name}</h2>
<p className="text-xs text-gray-500 mt-0.5"> <p className="text-xs text-ink-muted mt-0.5">
{system.systemType}{system.location ? ` · ${system.location}` : ""} {system.systemType}{system.location ? ` · ${system.location}` : ""}
</p> </p>
{system.sysIds && ( {system.sysIds && (
<p className="text-xs text-gray-600 mt-0.5">System IDs: {system.sysIds}</p> <p className="text-xs text-ink-muted mt-0.5">System IDs: {system.sysIds}</p>
)} )}
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-ink-muted mt-1">
{system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups {system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups
</p> </p>
</div> </div>
@@ -456,7 +456,7 @@ function RRImportModal({
{/* Category list */} {/* Category list */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1.5"> <div className="flex-1 overflow-y-auto px-5 py-4 space-y-1.5">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<p className="text-xs text-gray-400 uppercase tracking-wider">Talkgroup Categories</p> <p className="text-xs text-ink-2 uppercase tracking-wider">Talkgroup Categories</p>
<div className="flex gap-3 text-xs text-indigo-400"> <div className="flex gap-3 text-xs text-indigo-400">
<button <button
type="button" type="button"
@@ -480,18 +480,18 @@ function RRImportModal({
type="checkbox" type="checkbox"
checked={selected.has(cat.name)} checked={selected.has(cat.name)}
onChange={() => toggle(cat.name)} onChange={() => toggle(cat.name)}
className="rounded border-gray-600 bg-gray-800 accent-indigo-500" className="rounded border-line-strong bg-raised accent-indigo-500"
/> />
<span className="text-sm text-gray-200 flex-1 group-hover:text-white transition-colors truncate"> <span className="text-sm text-ink flex-1 group-hover:text-white transition-colors truncate">
{cat.name} {cat.name}
</span> </span>
<span className="text-xs text-gray-600 shrink-0">{cat.talkgroups.length} TGs</span> <span className="text-xs text-ink-muted shrink-0">{cat.talkgroups.length} TGs</span>
</label> </label>
))} ))}
</div> </div>
{/* Footer */} {/* Footer */}
<div className="px-5 pb-5 pt-4 border-t border-gray-800 flex gap-3"> <div className="px-5 pb-5 pt-4 border-t border-line flex gap-3">
<button <button
type="button" type="button"
onClick={handleImport} onClick={handleImport}
@@ -503,7 +503,7 @@ function RRImportModal({
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors" className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
> >
Cancel Cancel
</button> </button>
@@ -533,10 +533,10 @@ function LocalKnowledgeField({
multiline?: boolean; multiline?: boolean;
}) { }) {
const cls = const cls =
"w-full mt-0.5 bg-gray-900 border border-gray-700 rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500"; "w-full mt-0.5 bg-surface border border-line-strong rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500";
return ( return (
<label className="block"> <label className="block">
<span className="text-xs text-gray-500 font-sans">{label}</span> <span className="text-xs text-ink-muted font-sans">{label}</span>
{multiline ? ( {multiline ? (
<textarea <textarea
value={value} value={value}
@@ -656,8 +656,8 @@ function TalkgroupEditor({
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-xs text-gray-400"> <label className="text-xs text-ink-2">
Talkgroups{talkgroups.length > 0 && <span className="text-gray-600 ml-1">({talkgroups.length})</span>} Talkgroups{talkgroups.length > 0 && <span className="text-ink-muted ml-1">({talkgroups.length})</span>}
</label> </label>
<div className="flex gap-3"> <div className="flex gap-3">
{/* RadioReference file import */} {/* RadioReference file import */}
@@ -704,10 +704,10 @@ function TalkgroupEditor({
)} )}
{showPaste && ( {showPaste && (
<div className="space-y-2 p-3 bg-gray-800 rounded-lg border border-gray-700"> <div className="space-y-2 p-3 bg-raised rounded-lg border border-line-strong">
<p className="text-xs text-gray-500"> <p className="text-xs text-ink-muted">
Paste rows from RadioReference — tab- or comma-separated:{" "} Paste rows from RadioReference — tab- or comma-separated:{" "}
<span className="text-gray-400">ID, Name, Tag</span> <span className="text-ink-2">ID, Name, Tag</span>
<br />Tags: fire · police · ems · transit · public works · other <br />Tags: fire · police · ems · transit · public works · other
</p> </p>
<textarea <textarea
@@ -715,7 +715,7 @@ function TalkgroupEditor({
onChange={(e) => setPasteText(e.target.value)} onChange={(e) => setPasteText(e.target.value)}
rows={6} rows={6}
placeholder={"1234\tFire Dispatch\tfire\n5678\tPolice Zone 1\tpolice\n9012\tEMS\tems"} placeholder={"1234\tFire Dispatch\tfire\n5678\tPolice Zone 1\tpolice\n9012\tEMS\tems"}
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-surface border border-line-strong rounded px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/> />
<button <button
type="button" type="button"
@@ -729,10 +729,10 @@ function TalkgroupEditor({
)} )}
{talkgroups.length > 0 ? ( {talkgroups.length > 0 ? (
<div className="border border-gray-800 rounded-lg overflow-hidden"> <div className="border border-line rounded-lg overflow-hidden">
<table className="w-full text-xs font-mono"> <table className="w-full text-xs font-mono">
<thead> <thead>
<tr className="bg-gray-800 text-gray-400"> <tr className="bg-raised text-ink-2">
<th className="px-3 py-1.5 text-left w-20">Dec ID</th> <th className="px-3 py-1.5 text-left w-20">Dec ID</th>
<th className="px-3 py-1.5 text-left">Name</th> <th className="px-3 py-1.5 text-left">Name</th>
<th className="px-3 py-1.5 text-left w-28">Tag</th> <th className="px-3 py-1.5 text-left w-28">Tag</th>
@@ -742,7 +742,7 @@ function TalkgroupEditor({
<tbody> <tbody>
{talkgroups.map((tg, i) => ( {talkgroups.map((tg, i) => (
<Fragment key={i}> <Fragment key={i}>
<tr className="border-t border-gray-800 hover:bg-gray-800/30"> <tr className="border-t border-line hover:bg-raised/30">
<td className="px-2 py-1"> <td className="px-2 py-1">
<input <input
value={tg.id} value={tg.id}
@@ -763,7 +763,7 @@ function TalkgroupEditor({
<select <select
value={tg.tag} value={tg.tag}
onChange={(e) => updateRow(i, "tag", e.target.value)} onChange={(e) => updateRow(i, "tag", e.target.value)}
className="w-full bg-gray-900 text-gray-300 focus:outline-none rounded px-1" className="w-full bg-surface text-ink-2 focus:outline-none rounded px-1"
> >
{TG_TAGS.map((t) => ( {TG_TAGS.map((t) => (
<option key={t} value={t}>{t}</option> <option key={t} value={t}>{t}</option>
@@ -776,7 +776,7 @@ function TalkgroupEditor({
title="Local knowledge for the transcript corrector" title="Local knowledge for the transcript corrector"
onClick={() => setExpanded(expanded === i ? null : i)} onClick={() => setExpanded(expanded === i ? null : i)}
className={`transition-colors font-bold mr-2 ${ className={`transition-colors font-bold mr-2 ${
hasLocalKnowledge(tg) ? "text-indigo-400" : "text-gray-600 hover:text-gray-300" hasLocalKnowledge(tg) ? "text-indigo-400" : "text-ink-muted hover:text-ink"
}`} }`}
> >
{expanded === i ? "▾" : "▸"} {expanded === i ? "▾" : "▸"}
@@ -784,7 +784,7 @@ function TalkgroupEditor({
<button <button
type="button" type="button"
onClick={() => removeRow(i)} onClick={() => removeRow(i)}
className="text-gray-600 hover:text-red-400 transition-colors font-bold" className="text-ink-muted hover:text-red-400 transition-colors font-bold"
> >
× ×
</button> </button>
@@ -794,11 +794,11 @@ function TalkgroupEditor({
talkgroups and most inherit the system's context rather than talkgroups and most inherit the system's context rather than
override it. */} override it. */}
{expanded === i && ( {expanded === i && (
<tr className="border-t border-gray-800 bg-gray-900/60"> <tr className="border-t border-line bg-surface/60">
<td colSpan={4} className="px-3 py-3"> <td colSpan={4} className="px-3 py-3">
<p className="text-xs text-gray-500 mb-2 font-sans"> <p className="text-xs text-ink-muted mb-2 font-sans">
Given to the transcript corrector for this talkgroup only, ranked Given to the transcript corrector for this talkgroup only, ranked
<span className="text-gray-300"> above</span> the system&apos;s own list. <span className="text-ink-2"> above</span> the system&apos;s own list.
Leave blank to inherit the system&apos;s. Leave blank to inherit the system&apos;s.
</p> </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
@@ -849,7 +849,7 @@ function TalkgroupEditor({
</table> </table>
</div> </div>
) : ( ) : (
<p className="text-xs text-gray-600 italic py-1"> <p className="text-xs text-ink-muted italic py-1">
No talkgroups — add rows, paste from RadioReference, or use the RadioReference import button. No talkgroups — add rows, paste from RadioReference, or use the RadioReference import button.
</p> </p>
)} )}
@@ -864,52 +864,52 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">NAC (hex)</label> <label className="text-xs text-ink-2 block mb-1">NAC (hex)</label>
<input <input
value={value.nac} value={value.nac}
onChange={(e) => onChange({ ...value, nac: e.target.value })} onChange={(e) => onChange({ ...value, nac: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="0x293" placeholder="0x293"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">System ID</label> <label className="text-xs text-ink-2 block mb-1">System ID</label>
<input <input
value={value.system_id} value={value.system_id}
onChange={(e) => onChange({ ...value, system_id: e.target.value })} onChange={(e) => onChange({ ...value, system_id: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="50513" placeholder="50513"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">WACN (hex)</label> <label className="text-xs text-ink-2 block mb-1">WACN (hex)</label>
<input <input
value={value.wacn} value={value.wacn}
onChange={(e) => onChange({ ...value, wacn: e.target.value })} onChange={(e) => onChange({ ...value, wacn: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="0xBEE00" placeholder="0xBEE00"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Control Channels <span className="text-gray-600">(MHz, comma-separated)</span> Control Channels <span className="text-ink-muted">(MHz, comma-separated)</span>
</label> </label>
<input <input
value={value.control_channels} value={value.control_channels}
onChange={(e) => onChange({ ...value, control_channels: e.target.value })} onChange={(e) => onChange({ ...value, control_channels: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="851.0125, 851.5125, 852.0125" placeholder="851.0125, 851.5125, 852.0125"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Voice Channels <span className="text-gray-600">(MHz, comma-separated — leave blank for auto-discovery)</span> Voice Channels <span className="text-ink-muted">(MHz, comma-separated — leave blank for auto-discovery)</span>
</label> </label>
<input <input
value={value.voice_channels} value={value.voice_channels}
onChange={(e) => onChange({ ...value, voice_channels: e.target.value })} onChange={(e) => onChange({ ...value, voice_channels: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="Optional" placeholder="Optional"
/> />
</div> </div>
@@ -923,7 +923,7 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
// ── Fixed-channel editors (node-26#24) ──────────────────────────────────────── // ── Fixed-channel editors (node-26#24) ────────────────────────────────────────
const INPUT = "w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"; const INPUT = "w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v: P25Config) => void }) { function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v: P25Config) => void }) {
const chs = value.channels; const chs = value.channels;
@@ -931,13 +931,13 @@ function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v:
onChange({ ...value, channels: chs.map((ch, j) => (j === i ? { ...ch, [field]: v } : ch)) }); onChange({ ...value, channels: chs.map((ch, j) => (j === i ? { ...ch, [field]: v } : ch)) });
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-gray-500"> <p className="text-xs text-ink-muted">
Fixed voice channels with no control channel — RadioReference lists them as conventional Fixed voice channels with no control channel — RadioReference lists them as conventional
frequencies with mode P25 (or &quot;DE&quot;). A node decodes up to {MAX_CONVENTIONAL_CHANNELS} on frequencies with mode P25 (or &quot;DE&quot;). A node decodes up to {MAX_CONVENTIONAL_CHANNELS} on
one SDR, within about 1.5 MHz of each other. Each channel&apos;s calls are labelled with its name. one SDR, within about 1.5 MHz of each other. Each channel&apos;s calls are labelled with its name.
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
<div className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 text-xs text-gray-400"> <div className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 text-xs text-ink-2">
<span>Frequency (MHz)</span> <span>Frequency (MHz)</span>
<span>Channel name</span> <span>Channel name</span>
<span>NAC (hex, optional)</span> <span>NAC (hex, optional)</span>
@@ -992,7 +992,7 @@ function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v:
talkgroups={value.talkgroups} talkgroups={value.talkgroups}
onChange={(tgs) => onChange({ ...value, talkgroups: tgs })} onChange={(tgs) => onChange({ ...value, talkgroups: tgs })}
/> />
<p className="text-xs text-gray-600 -mt-2"> <p className="text-xs text-ink-muted -mt-2">
Talkgroups are optional here: only needed if the radios send distinct talkgroup IDs you want named. Talkgroups are optional here: only needed if the radios send distinct talkgroup IDs you want named.
</p> </p>
</div> </div>
@@ -1004,7 +1004,7 @@ function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: An
return ( return (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label htmlFor="analog-freq" className="text-xs text-gray-400 block mb-1">Frequency (MHz)</label> <label htmlFor="analog-freq" className="text-xs text-ink-2 block mb-1">Frequency (MHz)</label>
<input <input
id="analog-freq" id="analog-freq"
value={ch.freq} value={ch.freq}
@@ -1016,7 +1016,7 @@ function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: An
/> />
</div> </div>
<div> <div>
<label htmlFor="analog-name" className="text-xs text-gray-400 block mb-1">Channel name</label> <label htmlFor="analog-name" className="text-xs text-ink-2 block mb-1">Channel name</label>
<input <input
id="analog-name" id="analog-name"
value={ch.name} value={ch.name}
@@ -1124,27 +1124,27 @@ function SystemForm({
const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System"; const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System";
return ( return (
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4 font-mono"> <form onSubmit={handleSubmit} className="bg-surface border border-line-strong rounded-xl p-5 space-y-4 font-mono">
<h3 className="text-white font-semibold">{title}</h3> <h3 className="text-white font-semibold">{title}</h3>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Name</label> <label className="text-xs text-ink-2 block mb-1">Name</label>
<input <input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
required required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="Westchester County P25" placeholder="Westchester County P25"
/> />
</div> </div>
<div> <div>
<label htmlFor="system-kind" className="text-xs text-gray-400 block mb-1">Type</label> <label htmlFor="system-kind" className="text-xs text-ink-2 block mb-1">Type</label>
<select <select
id="system-kind" id="system-kind"
value={kind} value={kind}
onChange={(e) => handleKindChange(e.target.value as SystemKind)} onChange={(e) => handleKindChange(e.target.value as SystemKind)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
> >
{KIND_OPTIONS.map((o) => ( {KIND_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option> <option key={o.value} value={o.value}>{o.label}</option>
@@ -1165,12 +1165,12 @@ function SystemForm({
<AnalogForm value={analog} onChange={setAnalog} /> <AnalogForm value={analog} onChange={setAnalog} />
) : ( ) : (
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Config (JSON)</label> <label className="text-xs text-ink-2 block mb-1">Config (JSON)</label>
<textarea <textarea
value={rawJson} value={rawJson}
onChange={(e) => setRawJson(e.target.value)} onChange={(e) => setRawJson(e.target.value)}
rows={10} rows={10}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
)} )}
@@ -1179,7 +1179,7 @@ function SystemForm({
<button <button
type="button" type="button"
onClick={toggleRaw} onClick={toggleRaw}
className="text-xs text-gray-600 hover:text-gray-400 transition-colors" className="text-xs text-ink-muted hover:text-ink-2 transition-colors"
> >
{showRaw ? "← Use structured form" : "Edit raw JSON →"} {showRaw ? "← Use structured form" : "Edit raw JSON →"}
</button> </button>
@@ -1198,7 +1198,7 @@ function SystemForm({
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors" className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
> >
Cancel Cancel
</button> </button>
@@ -1267,10 +1267,10 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
const currentToken = tokens?.find((t) => t.token_id === preferredId); const currentToken = tokens?.find((t) => t.token_id === preferredId);
return ( return (
<div className="mt-3 border-t border-gray-800 pt-3"> <div className="mt-3 border-t border-line pt-3">
<button <button
onClick={toggle} onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
> >
<span>{open ? "▲" : "▼"}</span> <span>{open ? "▲" : "▼"}</span>
<span> <span>
@@ -1282,14 +1282,14 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
{open && ( {open && (
<div className="mt-3 space-y-2 font-mono text-xs"> <div className="mt-3 space-y-2 font-mono text-xs">
{tokens === null ? ( {tokens === null ? (
<p className="text-gray-600 italic">Loading tokens…</p> <p className="text-ink-muted italic">Loading tokens…</p>
) : error ? ( ) : error ? (
<p className="text-red-400">{error}</p> <p className="text-red-400">{error}</p>
) : tokens.length === 0 ? ( ) : tokens.length === 0 ? (
<p className="text-gray-600 italic">No tokens in pool.</p> <p className="text-ink-muted italic">No tokens in pool.</p>
) : ( ) : (
<> <>
<p className="text-gray-600"> <p className="text-ink-muted">
When a node on this system joins a voice channel, this token is tried first. When a node on this system joins a voice channel, this token is tried first.
</p> </p>
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -1303,7 +1303,7 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
disabled={saving} disabled={saving}
className="accent-indigo-500" className="accent-indigo-500"
/> />
<span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-gray-600" : "text-gray-300"}`}> <span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-ink-muted" : "text-ink-2"}`}>
{t.name} {t.name}
{t.in_use && <span className="ml-1.5 text-green-600">in use</span>} {t.in_use && <span className="ml-1.5 text-green-600">in use</span>}
</span> </span>
@@ -1314,13 +1314,13 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
<button <button
onClick={handleClear} onClick={handleClear}
disabled={saving} disabled={saving}
className="text-gray-600 hover:text-gray-400 transition-colors disabled:opacity-50" className="text-ink-muted hover:text-ink-2 transition-colors disabled:opacity-50"
> >
Clear preference (use any free token) Clear preference (use any free token)
</button> </button>
)} )}
{!preferredId && ( {!preferredId && (
<p className="text-gray-700">No preference — any free token will be used.</p> <p className="text-ink-muted">No preference — any free token will be used.</p>
)} )}
{currentToken && ( {currentToken && (
<p className="text-indigo-500">Preferred: {currentToken.name}</p> <p className="text-indigo-500">Preferred: {currentToken.name}</p>
@@ -1371,10 +1371,10 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
]; ];
return ( return (
<div className="mt-3 border-t border-gray-800 pt-3"> <div className="mt-3 border-t border-line pt-3">
<button <button
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
> >
<span>{open ? "▲" : "▼"}</span> <span>{open ? "▲" : "▼"}</span>
<span>AI Flags</span> <span>AI Flags</span>
@@ -1400,23 +1400,23 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
> >
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${isOn ? "translate-x-4" : "translate-x-0.5"}`} /> <span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${isOn ? "translate-x-4" : "translate-x-0.5"}`} />
</button> </button>
<span className="text-gray-300 flex-1">{label}</span> <span className="text-ink-2 flex-1">{label}</span>
{isSet ? ( {isSet ? (
<button <button
onClick={() => handleClear(key)} onClick={() => handleClear(key)}
disabled={saving === key} disabled={saving === key}
className="text-gray-600 hover:text-gray-400 transition-colors" className="text-ink-muted hover:text-ink-2 transition-colors"
title="Clear override (inherit global)" title="Clear override (inherit global)"
> >
reset reset
</button> </button>
) : ( ) : (
<span className="text-gray-700">inherits global</span> <span className="text-ink-muted">inherits global</span>
)} )}
</div> </div>
); );
})} })}
<p className="text-gray-700 pt-1">Overrides apply on top of global AI flags. "reset" restores global default.</p> <p className="text-ink-muted pt-1">Overrides apply on top of global AI flags. "reset" restores global default.</p>
</div> </div>
)} )}
</div> </div>
@@ -1456,15 +1456,15 @@ function SourceCallPlayer({ callId }: { callId: string }) {
{loading ? "loading…" : open ? "▲ source" : "▶ source"} {loading ? "loading…" : open ? "▲ source" : "▶ source"}
</button> </button>
{open && call && ( {open && call && (
<div className="mt-1.5 space-y-1 pl-2 border-l border-gray-700"> <div className="mt-1.5 space-y-1 pl-2 border-l border-line-strong">
{call.audio_url ? ( {call.audio_url ? (
<audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} /> <audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} />
) : ( ) : (
<p className="text-gray-600 italic">No audio</p> <p className="text-ink-muted italic">No audio</p>
)} )}
{transcript && ( {transcript && (
<> <>
<p className="text-gray-500 italic line-clamp-2">{transcript}</p> <p className="text-ink-muted italic line-clamp-2">{transcript}</p>
{/* Gate A / A2 (server-26#46) — this is a raw pipeline transcript. */} {/* Gate A / A2 (server-26#46) — this is a raw pipeline transcript. */}
<MachineOutputNotice variant="inline" className="text-[10px]" /> <MachineOutputNotice variant="inline" className="text-[10px]" />
</> </>
@@ -1528,16 +1528,16 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
: 0; : 0;
return ( return (
<div className="mt-3 border-t border-gray-800 pt-3"> <div className="mt-3 border-t border-line pt-3">
<button <button
onClick={toggle} onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
> >
<span>{open ? "▲" : "▼"}</span> <span>{open ? "▲" : "▼"}</span>
<span> <span>
Local Area Local Area
{area !== null && ( {area !== null && (
<span className="text-gray-600 ml-1"> <span className="text-ink-muted ml-1">
({filled > 0 ? `${filled} field${filled === 1 ? "" : "s"} set` : "not set"}) ({filled > 0 ? `${filled} field${filled === 1 ? "" : "s"} set` : "not set"})
</span> </span>
)} )}
@@ -1546,16 +1546,16 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
{open && ( {open && (
<div className="mt-3 space-y-3 text-xs"> <div className="mt-3 space-y-3 text-xs">
{loading && <p className="text-gray-600 italic font-mono">Loading…</p>} {loading && <p className="text-ink-muted italic font-mono">Loading…</p>}
{area && ( {area && (
<> <>
<p className="text-gray-500"> <p className="text-ink-muted">
Given to the transcript corrector for every talkgroup on this system. Given to the transcript corrector for every talkgroup on this system.
Whisper mishears local names constantly — naming them here is what lets Whisper mishears local names constantly — naming them here is what lets
them be put back. them be put back.
</p> </p>
<p className="text-gray-500"> <p className="text-ink-muted">
Fill this in <span className="text-gray-300">only if it is true of every Fill this in <span className="text-ink-2">only if it is true of every
talkgroup</span> on the system. One town, one department — fill it once here. talkgroup</span> on the system. One town, one department — fill it once here.
A system spanning several counties — leave it blank and describe each A system spanning several counties — leave it blank and describe each
talkgroup in the edit form instead. talkgroup in the edit form instead.
@@ -1592,7 +1592,7 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
{/* The anchor is the backend's answer to what was typed above, and {/* The anchor is the backend's answer to what was typed above, and
it decides whether location checking runs at all — so it is it decides whether location checking runs at all — so it is
worth showing rather than leaving as invisible state. */} worth showing rather than leaving as invisible state. */}
<p className="text-gray-600 font-mono">{anchorLabel(area)}</p> <p className="text-ink-muted font-mono">{anchorLabel(area)}</p>
<button <button
type="button" type="button"
onClick={save} onClick={save}
@@ -1659,16 +1659,16 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
const total = (rows ?? []).reduce((n, r) => n + r.pending.length, 0); const total = (rows ?? []).reduce((n, r) => n + r.pending.length, 0);
return ( return (
<div className="mt-3 border-t border-gray-800 pt-3"> <div className="mt-3 border-t border-line pt-3">
<button <button
onClick={toggle} onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
> >
<span>{open ? "▲" : "▼"}</span> <span>{open ? "▲" : "▼"}</span>
<span> <span>
Proposed Terms Proposed Terms
{rows !== null && ( {rows !== null && (
<span className={total > 0 ? "text-indigo-400 ml-1" : "text-gray-600 ml-1"}> <span className={total > 0 ? "text-indigo-400 ml-1" : "text-ink-muted ml-1"}>
({total > 0 ? `${total} awaiting review` : "none"}) ({total > 0 ? `${total} awaiting review` : "none"})
</span> </span>
)} )}
@@ -1677,9 +1677,9 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
{open && ( {open && (
<div className="mt-3 space-y-3 text-xs"> <div className="mt-3 space-y-3 text-xs">
{rows === null && <p className="text-gray-600 italic font-mono">Loading…</p>} {rows === null && <p className="text-ink-muted italic font-mono">Loading…</p>}
{rows !== null && total === 0 && ( {rows !== null && total === 0 && (
<p className="text-gray-600 italic"> <p className="text-ink-muted italic">
Nothing proposed yet. Terms appear here when a corrected place name Nothing proposed yet. Terms appear here when a corrected place name
turns out to be real nearby, or when the induction loop spots one in turns out to be real nearby, or when the induction loop spots one in
recent traffic. recent traffic.
@@ -1687,19 +1687,19 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
)} )}
{(rows ?? []).map((row) => ( {(rows ?? []).map((row) => (
<div key={row.talkgroup_id}> <div key={row.talkgroup_id}>
<p className="text-gray-400 font-mono mb-1"> <p className="text-ink-2 font-mono mb-1">
{row.talkgroup_name || `TGID ${row.talkgroup_id}`} {row.talkgroup_name || `TGID ${row.talkgroup_id}`}
</p> </p>
<ul className="space-y-1"> <ul className="space-y-1">
{row.pending.map((p) => ( {row.pending.map((p) => (
<li <li
key={p.term} key={p.term}
className="flex items-start gap-2 bg-gray-900/60 border border-gray-800 rounded px-2 py-1" className="flex items-start gap-2 bg-surface/60 border border-line rounded px-2 py-1"
> >
<span className="flex-1"> <span className="flex-1">
<span className="text-white font-mono">{p.term}</span> <span className="text-white font-mono">{p.term}</span>
{p.meaning && <span className="text-gray-500"> — {p.meaning}</span>} {p.meaning && <span className="text-ink-muted"> — {p.meaning}</span>}
<span className="text-gray-700 ml-2">{p.source}</span> <span className="text-ink-muted ml-2">{p.source}</span>
</span> </span>
<button <button
type="button" type="button"
@@ -1713,7 +1713,7 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
type="button" type="button"
disabled={busy === `${row.talkgroup_id}:${p.term}`} disabled={busy === `${row.talkgroup_id}:${p.term}`}
onClick={() => act(row.talkgroup_id, p.term, false)} onClick={() => act(row.talkgroup_id, p.term, false)}
className="text-gray-600 hover:text-red-400 disabled:opacity-40 font-semibold" className="text-ink-muted hover:text-red-400 disabled:opacity-40 font-semibold"
> >
Drop Drop
</button> </button>
@@ -1802,16 +1802,16 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
} }
return ( return (
<div className="mt-3 border-t border-gray-800 pt-3"> <div className="mt-3 border-t border-line pt-3">
<button <button
onClick={toggle} onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
> >
<span>{open ? "▲" : "▼"}</span> <span>{open ? "▲" : "▼"}</span>
<span> <span>
Vocabulary Vocabulary
{vocab !== null && ( {vocab !== null && (
<span className="text-gray-600 ml-1"> <span className="text-ink-muted ml-1">
({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""}) ({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""})
</span> </span>
)} )}
@@ -1820,7 +1820,7 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
{open && ( {open && (
<div className="mt-3 space-y-3 font-mono text-xs"> <div className="mt-3 space-y-3 font-mono text-xs">
{loading && <p className="text-gray-600 italic">Loading…</p>} {loading && <p className="text-ink-muted italic">Loading…</p>}
{!loading && vocab !== null && ( {!loading && vocab !== null && (
<> <>
@@ -1832,19 +1832,19 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
> >
{bootstrapping ? "Bootstrapping…" : bootstrapped ? "Re-bootstrap with AI" : "Bootstrap with AI"} {bootstrapping ? "Bootstrapping…" : bootstrapped ? "Re-bootstrap with AI" : "Bootstrap with AI"}
</button> </button>
<span className="text-gray-600">GPT-4o generates local knowledge (agencies, units, streets)</span> <span className="text-ink-muted">GPT-4o generates local knowledge (agencies, units, streets)</span>
</div> </div>
<div> <div>
<p className="text-gray-500 uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p> <p className="text-ink-muted uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
{vocab.length > 0 ? ( {vocab.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{vocab.map((term) => ( {vocab.map((term) => (
<span key={term} className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full"> <span key={term} className="inline-flex items-center gap-1 bg-raised text-ink-2 px-2 py-0.5 rounded-full">
{term} {term}
<button <button
onClick={() => handleRemove(term)} onClick={() => handleRemove(term)}
className="text-gray-600 hover:text-red-400 transition-colors leading-none" className="text-ink-muted hover:text-red-400 transition-colors leading-none"
> >
× ×
</button> </button>
@@ -1852,7 +1852,7 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
))} ))}
</div> </div>
) : ( ) : (
<p className="text-gray-600 italic">No terms yet — bootstrap or add manually.</p> <p className="text-ink-muted italic">No terms yet — bootstrap or add manually.</p>
)} )}
</div> </div>
@@ -1861,12 +1861,12 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
value={newTerm} value={newTerm}
onChange={(e) => setNewTerm(e.target.value)} onChange={(e) => setNewTerm(e.target.value)}
placeholder="Add term (e.g. 5-baker, YVAC)" placeholder="Add term (e.g. 5-baker, YVAC)"
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-xs font-mono focus:outline-none focus:border-indigo-500" className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/> />
<button <button
type="submit" type="submit"
disabled={adding || !newTerm.trim()} disabled={adding || !newTerm.trim()}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-gray-200 px-3 py-1.5 rounded-lg transition-colors" className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-ink px-3 py-1.5 rounded-lg transition-colors"
> >
Add Add
</button> </button>
@@ -1874,17 +1874,17 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
{pending.length > 0 && ( {pending.length > 0 && (
<div> <div>
<p className="text-gray-500 uppercase tracking-wider mb-1.5"> <p className="text-ink-muted uppercase tracking-wider mb-1.5">
Induction suggestions ({pending.length}) Induction suggestions ({pending.length})
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
{pending.map((p) => ( {pending.map((p) => (
<div key={p.term} className="space-y-1"> <div key={p.term} className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-gray-300 flex-1">{p.term}</span> <span className="text-ink-2 flex-1">{p.term}</span>
<span className="text-gray-600">{p.source}</span> <span className="text-ink-muted">{p.source}</span>
<button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button> <button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button>
<button onClick={() => handleDismiss(p.term)} className="text-gray-600 hover:text-red-400 transition-colors px-1">✕</button> <button onClick={() => handleDismiss(p.term)} className="text-ink-muted hover:text-red-400 transition-colors px-1">✕</button>
</div> </div>
{p.source_call_ids && p.source_call_ids.length > 0 && ( {p.source_call_ids && p.source_call_ids.length > 0 && (
<div className="pl-1 space-y-1"> <div className="pl-1 space-y-1">
@@ -1970,9 +1970,9 @@ export default function SystemsPage() {
)} )}
{loading ? ( {loading ? (
<p className="text-gray-600 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : systems.length === 0 ? ( ) : systems.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No systems defined yet.</p> <p className="text-ink-muted text-sm font-mono">No systems defined yet.</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{systems.map((s) => { {systems.map((s) => {
@@ -1987,13 +1987,13 @@ export default function SystemsPage() {
? (s.config.channels as unknown[]).length ? (s.config.channels as unknown[]).length
: null; : null;
return ( return (
<div key={s.system_id} className="bg-gray-900 border border-gray-800 rounded-lg p-4 font-mono"> <div key={s.system_id} className="bg-surface border border-line rounded-lg p-4 font-mono">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<p className="text-white font-semibold">{s.name}</p> <p className="text-white font-semibold">{s.name}</p>
<p className="text-xs text-gray-500">{s.system_id}</p> <p className="text-xs text-ink-muted">{s.system_id}</p>
{(s.config.nac || ccCount !== null || chCount !== null || tgCount !== null) && ( {(s.config.nac || ccCount !== null || chCount !== null || tgCount !== null) && (
<p className="text-xs text-gray-600 mt-0.5 space-x-2"> <p className="text-xs text-ink-muted mt-0.5 space-x-2">
{!!s.config.nac && <span>NAC {String(s.config.nac)}</span>} {!!s.config.nac && <span>NAC {String(s.config.nac)}</span>}
{ccCount !== null && <span>· {ccCount} CC</span>} {ccCount !== null && <span>· {ccCount} CC</span>}
{chCount !== null && <span>· {chCount} ch</span>} {chCount !== null && <span>· {chCount} ch</span>}
@@ -2001,7 +2001,7 @@ export default function SystemsPage() {
</p> </p>
)} )}
</div> </div>
<span className="text-xs bg-gray-800 border border-gray-700 text-gray-400 px-2 py-0.5 rounded"> <span className="text-xs bg-raised border border-line-strong text-ink-2 px-2 py-0.5 rounded">
{kindLabel(s)} {kindLabel(s)}
</span> </span>
</div> </div>
@@ -2014,7 +2014,7 @@ export default function SystemsPage() {
</button> </button>
<button <button
onClick={() => openDuplicate(s)} onClick={() => openDuplicate(s)}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors" className="text-xs text-ink-muted hover:text-ink transition-colors"
> >
Duplicate Duplicate
</button> </button>
+3 -3
View File
@@ -69,18 +69,18 @@ export default function TermsPage() {
</div> </div>
<h1 className="text-display-sm text-white">Terms of Service</h1> <h1 className="text-display-sm text-white">Terms of Service</h1>
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p> <p className="text-ink-muted text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
<div className="mt-10 space-y-8"> <div className="mt-10 space-y-8">
{SECTIONS.map((s) => ( {SECTIONS.map((s) => (
<section key={s.heading}> <section key={s.heading}>
<h2 className="text-white font-semibold">{s.heading}</h2> <h2 className="text-white font-semibold">{s.heading}</h2>
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p> <p className="text-ink-muted text-sm mt-2 leading-relaxed italic">{s.note}</p>
</section> </section>
))} ))}
</div> </div>
<p className="text-gray-600 text-xs font-mono mt-16"> <p className="text-ink-muted text-xs font-mono mt-16">
Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "} Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "}
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>. <Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
</p> </p>
+72 -72
View File
@@ -141,7 +141,7 @@ function PlaceSearch({
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), search())} onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), search())}
placeholder="Search a place…" placeholder="Search a place…"
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
<button <button
type="button" type="button"
@@ -153,7 +153,7 @@ function PlaceSearch({
</button> </button>
</div> </div>
{results.length > 0 && ( {results.length > 0 && (
<div className="bg-gray-800 border border-gray-700 rounded-lg overflow-hidden divide-y divide-gray-700"> <div className="bg-raised border border-line-strong rounded-lg overflow-hidden divide-y divide-line-strong">
{results.map((p) => ( {results.map((p) => (
<button <button
key={p.place_id} key={p.place_id}
@@ -162,7 +162,7 @@ function PlaceSearch({
className="w-full text-left px-3 py-2 hover:bg-gray-700 transition-colors" className="w-full text-left px-3 py-2 hover:bg-gray-700 transition-colors"
> >
<p className="text-white text-sm font-medium">{p.name}</p> <p className="text-white text-sm font-medium">{p.name}</p>
<p className="text-gray-400 text-xs">{p.address}</p> <p className="text-ink-2 text-xs">{p.address}</p>
{p.rating && ( {p.rating && (
<p className="text-yellow-400 text-xs">★ {p.rating}</p> <p className="text-yellow-400 text-xs">★ {p.rating}</p>
)} )}
@@ -240,60 +240,60 @@ function AddEventModal({
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto" className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto"
> >
<h2 className="text-white font-bold text-lg">{isEdit ? "Edit Event" : "Add Event"}</h2> <h2 className="text-white font-bold text-lg">{isEdit ? "Edit Event" : "Add Event"}</h2>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Title</label> <label className="text-xs text-ink-2 block mb-1">Title</label>
<input <input
required value={title} onChange={(e) => setTitle(e.target.value)} required value={title} onChange={(e) => setTitle(e.target.value)}
placeholder="Dinner at the bar" placeholder="Dinner at the bar"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Date</label> <label className="text-xs text-ink-2 block mb-1">Date</label>
<input <input
required type="date" required type="date"
min={trip.start_date} max={trip.end_date} min={trip.start_date} max={trip.end_date}
value={date} onChange={(e) => setDate(e.target.value)} value={date} onChange={(e) => setDate(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Start</label> <label className="text-xs text-ink-2 block mb-1">Start</label>
<input <input
type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">End</label> <label className="text-xs text-ink-2 block mb-1">End</label>
<input <input
type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)}
min={startTime} min={startTime}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Find a place</label> <label className="text-xs text-ink-2 block mb-1">Find a place</label>
<PlaceSearch near={trip.location} onSelect={handlePlaceSelect} /> <PlaceSearch near={trip.location} onSelect={handlePlaceSelect} />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Location{" "} Location{" "}
<span className="text-gray-600">(auto-filled from search, or type manually)</span> <span className="text-ink-muted">(auto-filled from search, or type manually)</span>
</label> </label>
<input <input
value={location} onChange={(e) => setLocation(e.target.value)} value={location} onChange={(e) => setLocation(e.target.value)}
placeholder={`Inherits: ${trip.location}`} placeholder={`Inherits: ${trip.location}`}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
@@ -307,16 +307,16 @@ function AddEventModal({
)} )}
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Notes (optional)</label> <label className="text-xs text-ink-2 block mb-1">Notes (optional)</label>
<textarea <textarea
value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} rows={2}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none resize-none" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none resize-none"
/> />
</div> </div>
{availableTags.length > 0 && ( {availableTags.length > 0 && (
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Tags</label> <label className="text-xs text-ink-2 block mb-1">Tags</label>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{availableTags.map((tag) => { {availableTags.map((tag) => {
const active = tags.includes(tag); const active = tags.includes(tag);
@@ -326,7 +326,7 @@ function AddEventModal({
type="button" type="button"
onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])} onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])}
className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${ className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${
active ? tagColor(tag, availableTags) : "bg-gray-800 text-gray-500 border-gray-700 hover:border-gray-600 hover:text-gray-400" active ? tagColor(tag, availableTags) : "bg-raised text-ink-muted border-line-strong hover:border-line-strong hover:text-ink-2"
}`} }`}
> >
{tag} {tag}
@@ -340,7 +340,7 @@ function AddEventModal({
{error && <p className="text-red-400 text-xs">{error}</p>} {error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 justify-end pt-1"> <div className="flex gap-3 justify-end pt-1">
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2"> <button type="button" onClick={onClose} className="text-sm text-ink-2 hover:text-ink px-4 py-2">
Cancel Cancel
</button> </button>
<button <button
@@ -389,7 +389,7 @@ function DayTimeline({
if (timed.length === 0 && untimed.length === 0) { if (timed.length === 0 && untimed.length === 0) {
return ( return (
<p className="text-gray-600 text-sm font-mono py-8 text-center"> <p className="text-ink-muted text-sm font-mono py-8 text-center">
No events on this day yet. No events on this day yet.
</p> </p>
); );
@@ -424,10 +424,10 @@ function DayTimeline({
style={{ top: (h * 60 - rangeStart) * PX_PER_MIN }} style={{ top: (h * 60 - rangeStart) * PX_PER_MIN }}
className="absolute inset-x-0 flex items-center pointer-events-none" className="absolute inset-x-0 flex items-center pointer-events-none"
> >
<span className="w-14 text-right pr-3 text-xs text-gray-600 font-mono shrink-0 -translate-y-2"> <span className="w-14 text-right pr-3 text-xs text-ink-muted font-mono shrink-0 -translate-y-2">
{h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`} {h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`}
</span> </span>
<div className="flex-1 border-t border-gray-800/70" /> <div className="flex-1 border-t border-line/70" />
</div> </div>
))} ))}
@@ -444,7 +444,7 @@ function DayTimeline({
{e.end_time && ( {e.end_time && (
<div <div
style={{ top, height, left: 60, right: 0 }} style={{ top, height, left: 60, right: 0 }}
className="absolute bg-gray-800/30 border-l-2 border-gray-600/30 z-0 pointer-events-none" className="absolute bg-raised/30 border-l-2 border-line-strong/30 z-0 pointer-events-none"
/> />
)} )}
{/* Dashed marker line + label */} {/* Dashed marker line + label */}
@@ -452,15 +452,15 @@ function DayTimeline({
style={{ top, left: 60, right: 0 }} style={{ top, left: 60, right: 0 }}
className="absolute z-10 flex items-center gap-2" className="absolute z-10 flex items-center gap-2"
> >
<div className="flex-1 border-t border-dashed border-gray-600/40" /> <div className="flex-1 border-t border-dashed border-line-strong/40" />
<span className="text-gray-500 text-[10px] font-mono shrink-0 pr-1"> <span className="text-ink-muted text-[10px] font-mono shrink-0 pr-1">
{fmtTime(e.start_time)} {fmtTime(e.start_time)}
</span> </span>
<span className="text-gray-500 text-[10px] truncate max-w-[120px] shrink-0">{e.title}</span> <span className="text-ink-muted text-[10px] truncate max-w-[120px] shrink-0">{e.title}</span>
{isAdmin && ( {isAdmin && (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"> <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onClick={() => onEdit(e)} className="text-gray-600 hover:text-indigo-400 text-[10px]">Edit</button> <button onClick={() => onEdit(e)} className="text-ink-muted hover:text-indigo-400 text-[10px]">Edit</button>
<button onClick={() => onDelete(e.event_id)} className="text-gray-600 hover:text-red-400 text-xs leading-none">×</button> <button onClick={() => onDelete(e.event_id)} className="text-ink-muted hover:text-red-400 text-xs leading-none">×</button>
</div> </div>
)} )}
</div> </div>
@@ -498,10 +498,10 @@ function DayTimeline({
</p> </p>
)} )}
{height >= 60 && !e.location_inherited && e.location && ( {height >= 60 && !e.location_inherited && e.location && (
<p className="text-gray-500 text-xs mt-0.5 truncate">{e.location}</p> <p className="text-ink-muted text-xs mt-0.5 truncate">{e.location}</p>
)} )}
{height >= 60 && e.notes && ( {height >= 60 && e.notes && (
<p className="text-gray-600 text-xs mt-0.5 italic truncate">{e.notes}</p> <p className="text-ink-muted text-xs mt-0.5 italic truncate">{e.notes}</p>
)} )}
{e.tags?.length > 0 && ( {e.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1 mt-1">
@@ -525,13 +525,13 @@ function DayTimeline({
<> <>
<button <button
onClick={() => onEdit(e)} onClick={() => onEdit(e)}
className="text-gray-600 hover:text-indigo-400 text-xs opacity-0 group-hover:opacity-100 transition-opacity" className="text-ink-muted hover:text-indigo-400 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
> >
Edit Edit
</button> </button>
<button <button
onClick={() => onDelete(e.event_id)} onClick={() => onDelete(e.event_id)}
className="text-gray-600 hover:text-red-400 text-base leading-none opacity-0 group-hover:opacity-100 transition-opacity" className="text-ink-muted hover:text-red-400 text-base leading-none opacity-0 group-hover:opacity-100 transition-opacity"
> >
× ×
</button> </button>
@@ -543,9 +543,9 @@ function DayTimeline({
{drive && ( {drive && (
<div <div
style={{ top: top + height + 2, left: 60 }} style={{ top: top + height + 2, left: 60 }}
className="absolute text-xs text-gray-600 font-mono flex items-center gap-1 z-20" className="absolute text-xs text-ink-muted font-mono flex items-center gap-1 z-20"
> >
<span className="text-gray-700">↓</span> {drive} drive <span className="text-ink-muted">↓</span> {drive} drive
</div> </div>
)} )}
</div> </div>
@@ -557,18 +557,18 @@ function DayTimeline({
{/* Untimed / all-day events */} {/* Untimed / all-day events */}
{untimed.length > 0 && ( {untimed.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-xs font-mono text-gray-600 uppercase tracking-wider">Unscheduled</p> <p className="text-xs font-mono text-ink-muted uppercase tracking-wider">Unscheduled</p>
{untimed.map((e) => ( {untimed.map((e) => (
<div <div
key={e.event_id} key={e.event_id}
className="bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 flex items-center justify-between group" className="bg-surface border border-line rounded-lg px-4 py-2.5 flex items-center justify-between group"
> >
<div className="min-w-0"> <div className="min-w-0">
<p className="text-white text-sm">{e.title}</p> <p className="text-white text-sm">{e.title}</p>
{!e.location_inherited && e.location && ( {!e.location_inherited && e.location && (
<p className="text-gray-500 text-xs mt-0.5">{e.location}</p> <p className="text-ink-muted text-xs mt-0.5">{e.location}</p>
)} )}
{e.notes && <p className="text-gray-600 text-xs italic mt-0.5">{e.notes}</p>} {e.notes && <p className="text-ink-muted text-xs italic mt-0.5">{e.notes}</p>}
{e.tags?.length > 0 && ( {e.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1 mt-1">
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)} {e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
@@ -585,11 +585,11 @@ function DayTimeline({
{isAdmin && ( {isAdmin && (
<> <>
<button onClick={() => onEdit(e)} <button onClick={() => onEdit(e)}
className="text-xs text-gray-600 hover:text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity"> className="text-xs text-ink-muted hover:text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity">
Edit Edit
</button> </button>
<button onClick={() => onDelete(e.event_id)} <button onClick={() => onDelete(e.event_id)}
className="text-gray-600 hover:text-red-400 text-sm opacity-0 group-hover:opacity-100 transition-opacity"> className="text-ink-muted hover:text-red-400 text-sm opacity-0 group-hover:opacity-100 transition-opacity">
× ×
</button> </button>
</> </>
@@ -728,17 +728,17 @@ function AssistantPanel({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */} {/* Header */}
<div className="px-4 py-3 border-b border-gray-800 shrink-0 flex items-start justify-between gap-2"> <div className="px-4 py-3 border-b border-line shrink-0 flex items-start justify-between gap-2">
<div> <div>
<p className="text-white text-sm font-semibold">Trip Assistant</p> <p className="text-white text-sm font-semibold">Trip Assistant</p>
<p className="text-gray-500 text-xs mt-0.5"> <p className="text-ink-muted text-xs mt-0.5">
Tell me what you want to do — I can search places and suggest events. Tell me what you want to do — I can search places and suggest events.
</p> </p>
</div> </div>
{messages.length > 0 && ( {messages.length > 0 && (
<button <button
onClick={clearChat} onClick={clearChat}
className="text-xs text-gray-600 hover:text-gray-400 transition-colors shrink-0 mt-0.5" className="text-xs text-ink-muted hover:text-ink-2 transition-colors shrink-0 mt-0.5"
> >
Clear Clear
</button> </button>
@@ -758,7 +758,7 @@ function AssistantPanel({
<button <button
key={prompt} key={prompt}
onClick={() => { setInput(prompt); }} onClick={() => { setInput(prompt); }}
className="w-full text-left bg-gray-800/60 hover:bg-gray-800 border border-gray-700/50 rounded-lg px-3 py-2 text-gray-400 text-xs transition-colors" className="w-full text-left bg-raised/60 hover:bg-raised border border-line-strong/50 rounded-lg px-3 py-2 text-ink-2 text-xs transition-colors"
> >
{prompt} {prompt}
</button> </button>
@@ -772,7 +772,7 @@ function AssistantPanel({
className={`rounded-xl px-3 py-2 text-sm max-w-[90%] ${ className={`rounded-xl px-3 py-2 text-sm max-w-[90%] ${
msg.role === "user" msg.role === "user"
? "bg-indigo-600 text-white" ? "bg-indigo-600 text-white"
: "bg-gray-800 text-gray-200" : "bg-raised text-ink"
}`} }`}
> >
{msg.role === "user" ? msg.content : ( {msg.role === "user" ? msg.content : (
@@ -804,11 +804,11 @@ function AssistantPanel({
className={`w-full border rounded-xl p-3 space-y-1.5 transition-all ${ className={`w-full border rounded-xl p-3 space-y-1.5 transition-all ${
s.added s.added
? "border-green-800/50 bg-green-950/30" ? "border-green-800/50 bg-green-950/30"
: "border-gray-700 bg-gray-900" : "border-line-strong bg-surface"
}`} }`}
> >
<p className="text-white text-sm font-semibold">{s.title}</p> <p className="text-white text-sm font-semibold">{s.title}</p>
<div className="text-xs text-gray-400 space-y-0.5"> <div className="text-xs text-ink-2 space-y-0.5">
{s.date && ( {s.date && (
<p> <p>
{fmtDate(s.date)} {fmtDate(s.date)}
@@ -817,7 +817,7 @@ function AssistantPanel({
</p> </p>
)} )}
{s.location && <p className="truncate">{s.location}</p>} {s.location && <p className="truncate">{s.location}</p>}
{s.notes && <p className="text-gray-500 italic">{s.notes}</p>} {s.notes && <p className="text-ink-muted italic">{s.notes}</p>}
{s.tags && s.tags.length > 0 && ( {s.tags && s.tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-0.5"> <div className="flex flex-wrap gap-1 pt-0.5">
{s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)} {s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)}
@@ -840,7 +840,7 @@ function AssistantPanel({
</button> </button>
<button <button
onClick={() => dismissSuggestion(msg.id, idx)} onClick={() => dismissSuggestion(msg.id, idx)}
className="px-3 text-gray-500 hover:text-gray-300 text-xs transition-colors" className="px-3 text-ink-muted hover:text-ink text-xs transition-colors"
> >
Skip Skip
</button> </button>
@@ -856,7 +856,7 @@ function AssistantPanel({
{loading && ( {loading && (
<div className="flex items-start"> <div className="flex items-start">
<div className="bg-gray-800 rounded-xl px-3 py-2 text-gray-400 text-sm"> <div className="bg-raised rounded-xl px-3 py-2 text-ink-2 text-sm">
<span className="animate-pulse">Thinking…</span> <span className="animate-pulse">Thinking…</span>
</div> </div>
</div> </div>
@@ -865,7 +865,7 @@ function AssistantPanel({
</div> </div>
{/* Input */} {/* Input */}
<div className="px-3 py-3 border-t border-gray-800 shrink-0"> <div className="px-3 py-3 border-t border-line shrink-0">
<div className="flex gap-2"> <div className="flex gap-2">
<textarea <textarea
ref={inputRef} ref={inputRef}
@@ -875,7 +875,7 @@ function AssistantPanel({
placeholder="What do you want to do?" placeholder="What do you want to do?"
disabled={loading} disabled={loading}
rows={1} rows={1}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50 resize-none overflow-hidden" className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50 resize-none overflow-hidden"
/> />
<button <button
onClick={send} onClick={send}
@@ -1055,8 +1055,8 @@ export default function TripDetailPage() {
setTrip((prev) => prev ? { ...prev, overlap_tags: overlap } : prev); setTrip((prev) => prev ? { ...prev, overlap_tags: overlap } : prev);
} }
if (loading) return <p className="text-gray-500 text-sm font-mono">Loading…</p>; if (loading) return <p className="text-ink-muted text-sm font-mono">Loading…</p>;
if (!trip) return <p className="text-gray-500 text-sm font-mono">Trip not found.</p>; if (!trip) return <p className="text-ink-muted text-sm font-mono">Trip not found.</p>;
const days = dateRange(trip.start_date, trip.end_date); const days = dateRange(trip.start_date, trip.end_date);
const attendees = Object.values(trip.attendees ?? {}); const attendees = Object.values(trip.attendees ?? {});
@@ -1070,7 +1070,7 @@ export default function TripDetailPage() {
<div className="space-y-4"> <div className="space-y-4">
<button <button
onClick={() => router.push("/trips")} onClick={() => router.push("/trips")}
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors" className="text-sm font-mono text-ink-muted hover:text-ink transition-colors"
> >
← Trips ← Trips
</button> </button>
@@ -1078,7 +1078,7 @@ export default function TripDetailPage() {
<div className="flex items-start justify-between gap-4 flex-wrap"> <div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-white text-2xl font-bold">{trip.name}</h1> <h1 className="text-white text-2xl font-bold">{trip.name}</h1>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-400"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-ink-2">
<span>{trip.location}</span> <span>{trip.location}</span>
{trip.maps_link && ( {trip.maps_link && (
<a href={trip.maps_link} target="_blank" rel="noopener noreferrer" <a href={trip.maps_link} target="_blank" rel="noopener noreferrer"
@@ -1086,11 +1086,11 @@ export default function TripDetailPage() {
Maps Maps
</a> </a>
)} )}
<span className="text-gray-700">•</span> <span className="text-ink-muted">•</span>
<span className="font-mono text-xs">{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}</span> <span className="font-mono text-xs">{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}</span>
{attendees.length > 0 && ( {attendees.length > 0 && (
<> <>
<span className="text-gray-700">•</span> <span className="text-ink-muted">•</span>
<span className="text-xs">{attendees.join(", ")}</span> <span className="text-xs">{attendees.join(", ")}</span>
</> </>
)} )}
@@ -1114,7 +1114,7 @@ export default function TripDetailPage() {
</button> </button>
<button <button
onClick={handleToggleVisibility} onClick={handleToggleVisibility}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors border border-gray-700 rounded-lg px-3 py-2" className="text-xs text-ink-muted hover:text-ink transition-colors border border-line-strong rounded-lg px-3 py-2"
> >
{trip.visibility === "private" ? "Make public" : "Make private"} {trip.visibility === "private" ? "Make public" : "Make private"}
</button> </button>
@@ -1159,9 +1159,9 @@ export default function TripDetailPage() {
onChange={(e) => setTagInput(e.target.value)} onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())} onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())}
placeholder="Add tag…" placeholder="Add tag…"
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-24" className="bg-transparent border border-line-strong rounded-full px-2.5 py-0.5 text-xs text-ink-2 placeholder-ink-muted focus:outline-none focus:border-gray-500 w-24"
/> />
<button onClick={handleAddTag} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button> <button onClick={handleAddTag} className="text-xs text-ink-muted hover:text-ink transition-colors">+</button>
</div> </div>
)} )}
</div> </div>
@@ -1170,13 +1170,13 @@ export default function TripDetailPage() {
{/* Invite management — admin only, only when private */} {/* Invite management — admin only, only when private */}
{isAdmin && trip.visibility === "private" && ( {isAdmin && trip.visibility === "private" && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">Invited</p> <p className="text-xs text-ink-muted uppercase tracking-wider font-mono">Invited</p>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{(trip.invited_discord_ids ?? []).length === 0 && ( {(trip.invited_discord_ids ?? []).length === 0 && (
<span className="text-xs text-gray-600">No invites yet</span> <span className="text-xs text-ink-muted">No invites yet</span>
)} )}
{(trip.invited_discord_ids ?? []).map((discordId) => ( {(trip.invited_discord_ids ?? []).map((discordId) => (
<span key={discordId} className="inline-flex items-center gap-1 text-xs bg-gray-800 border border-gray-700 rounded-full px-2.5 py-0.5 text-gray-300"> <span key={discordId} className="inline-flex items-center gap-1 text-xs bg-raised border border-line-strong rounded-full px-2.5 py-0.5 text-ink-2">
<span className="font-mono">{discordId}</span> <span className="font-mono">{discordId}</span>
<button <button
onClick={() => handleRevokeInvite(discordId)} onClick={() => handleRevokeInvite(discordId)}
@@ -1192,9 +1192,9 @@ export default function TripDetailPage() {
onChange={(e) => setInviteInput(e.target.value)} onChange={(e) => setInviteInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())} onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())}
placeholder="Discord user ID…" placeholder="Discord user ID…"
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-36" className="bg-transparent border border-line-strong rounded-full px-2.5 py-0.5 text-xs text-ink-2 placeholder-ink-muted focus:outline-none focus:border-gray-500 w-36"
/> />
<button onClick={handleInvite} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button> <button onClick={handleInvite} className="text-xs text-ink-muted hover:text-ink transition-colors">+</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1218,13 +1218,13 @@ export default function TripDetailPage() {
className={`flex flex-col items-center px-3 py-2 rounded-xl text-xs transition-colors relative ${ className={`flex flex-col items-center px-3 py-2 rounded-xl text-xs transition-colors relative ${
day === selectedDay day === selectedDay
? "bg-indigo-600 text-white" ? "bg-indigo-600 text-white"
: "bg-gray-900 border border-gray-800 text-gray-400 hover:border-gray-700 hover:text-gray-200" : "bg-surface border border-line text-ink-2 hover:border-line-strong hover:text-ink"
}`} }`}
> >
<span className="font-mono font-bold">{short}</span> <span className="font-mono font-bold">{short}</span>
<span className="font-mono">{date}</span> <span className="font-mono">{date}</span>
{count > 0 && ( {count > 0 && (
<span className={`text-xs mt-0.5 ${day === selectedDay ? "text-indigo-200" : "text-gray-600"}`}> <span className={`text-xs mt-0.5 ${day === selectedDay ? "text-indigo-200" : "text-ink-muted"}`}>
{count} {count}
</span> </span>
)} )}
@@ -1238,7 +1238,7 @@ export default function TripDetailPage() {
{/* Selected day */} {/* Selected day */}
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm font-mono text-gray-400 uppercase tracking-wider"> <p className="text-sm font-mono text-ink-2 uppercase tracking-wider">
{fmtDayHeading(selectedDay)} {fmtDayHeading(selectedDay)}
</p> </p>
<DayTimeline <DayTimeline
@@ -1254,7 +1254,7 @@ export default function TripDetailPage() {
</div> </div>
{/* Right: AI Assistant — sticky */} {/* Right: AI Assistant — sticky */}
<div className="w-80 xl:w-96 shrink-0 sticky top-20 h-[calc(100vh-240px)] flex flex-col bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> <div className="w-80 xl:w-96 shrink-0 sticky top-20 h-[calc(100vh-240px)] flex flex-col bg-surface border border-line rounded-xl overflow-hidden">
<AssistantPanel trip={trip} onAddEvent={handleAssistantAddEvent} /> <AssistantPanel trip={trip} onAddEvent={handleAssistantAddEvent} />
</div> </div>
</div> </div>
+21 -21
View File
@@ -29,25 +29,25 @@ function TripCard({ trip, isAdmin, onDelete }: {
return ( return (
<div <div
className="bg-gray-900 border border-gray-800 rounded-xl p-5 cursor-pointer hover:border-gray-700 transition-colors" className="bg-surface border border-line rounded-xl p-5 cursor-pointer hover:border-line-strong transition-colors"
onClick={() => router.push(`/trips/${trip.trip_id}`)} onClick={() => router.push(`/trips/${trip.trip_id}`)}
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${ <span className={`text-xs font-mono px-2 py-0.5 rounded-full ${
upcoming ? "bg-indigo-900 text-indigo-300" : "bg-gray-800 text-gray-500" upcoming ? "bg-indigo-900 text-indigo-300" : "bg-raised text-ink-muted"
}`}> }`}>
{upcoming ? "Upcoming" : "Past"} {upcoming ? "Upcoming" : "Past"}
</span> </span>
</div> </div>
<h3 className="text-white font-semibold text-sm leading-snug">{trip.name}</h3> <h3 className="text-white font-semibold text-sm leading-snug">{trip.name}</h3>
<p className="text-gray-400 text-xs mt-1">{trip.location}</p> <p className="text-ink-2 text-xs mt-1">{trip.location}</p>
<p className="text-gray-500 text-xs font-mono mt-1"> <p className="text-ink-muted text-xs font-mono mt-1">
{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)} {fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}
</p> </p>
{attendeeCount > 0 && ( {attendeeCount > 0 && (
<p className="text-gray-500 text-xs mt-1"> <p className="text-ink-muted text-xs mt-1">
{attendeeCount} going {attendeeCount} going
</p> </p>
)} )}
@@ -102,59 +102,59 @@ function CreateModal({ onClose, onCreate }: {
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto" className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
> >
<h2 className="text-white font-bold">New Trip</h2> <h2 className="text-white font-bold">New Trip</h2>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Name</label> <label className="text-xs text-ink-2 block mb-1">Name</label>
<input <input
required value={name} onChange={(e) => setName(e.target.value)} required value={name} onChange={(e) => setName(e.target.value)}
placeholder="Road trip to Nashville" placeholder="Road trip to Nashville"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Location</label> <label className="text-xs text-ink-2 block mb-1">Location</label>
<input <input
required value={location} onChange={(e) => setLocation(e.target.value)} required value={location} onChange={(e) => setLocation(e.target.value)}
placeholder="Nashville, TN" placeholder="Nashville, TN"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Start date</label> <label className="text-xs text-ink-2 block mb-1">Start date</label>
<input <input
required type="date" value={start} onChange={(e) => setStart(e.target.value)} required type="date" value={start} onChange={(e) => setStart(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">End date</label> <label className="text-xs text-ink-2 block mb-1">End date</label>
<input <input
required type="date" value={end} onChange={(e) => setEnd(e.target.value)} required type="date" value={end} onChange={(e) => setEnd(e.target.value)}
min={start} min={start}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Google Maps link (optional)</label> <label className="text-xs text-ink-2 block mb-1">Google Maps link (optional)</label>
<input <input
type="url" value={mapsLink} onChange={(e) => setMapsLink(e.target.value)} type="url" value={mapsLink} onChange={(e) => setMapsLink(e.target.value)}
placeholder="https://maps.google.com/…" placeholder="https://maps.google.com/…"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
{error && <p className="text-red-400 text-xs">{error}</p>} {error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2"> <button type="button" onClick={onClose} className="text-sm text-ink-2 hover:text-ink px-4 py-2">
Cancel Cancel
</button> </button>
<button <button
@@ -198,12 +198,12 @@ export default function TripsPage() {
</div> </div>
{loading ? ( {loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : ( ) : (
<> <>
{upcoming.length > 0 && ( {upcoming.length > 0 && (
<section> <section>
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Upcoming</h2> <h2 className="text-sm font-mono text-ink-2 uppercase tracking-wider mb-3">Upcoming</h2>
<div className="space-y-3"> <div className="space-y-3">
{upcoming.map((t) => ( {upcoming.map((t) => (
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} /> <TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
@@ -214,7 +214,7 @@ export default function TripsPage() {
{past.length > 0 && ( {past.length > 0 && (
<section> <section>
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Past</h2> <h2 className="text-sm font-mono text-ink-2 uppercase tracking-wider mb-3">Past</h2>
<div className="space-y-3"> <div className="space-y-3">
{past.map((t) => ( {past.map((t) => (
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} /> <TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
@@ -224,7 +224,7 @@ export default function TripsPage() {
)} )}
{trips.length === 0 && ( {trips.length === 0 && (
<p className="text-gray-600 text-sm font-mono">No trips yet.</p> <p className="text-ink-muted text-sm font-mono">No trips yet.</p>
)} )}
</> </>
)} )}
+9 -9
View File
@@ -40,11 +40,11 @@ export default function WaitlistPage() {
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span> <span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
DRB DRB
</Link> </Link>
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono"> <div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
{done ? ( {done ? (
<> <>
<h1 className="text-white text-lg font-bold">You&apos;re on the list</h1> <h1 className="text-white text-lg font-bold">You&apos;re on the list</h1>
<p className="text-gray-400 text-sm leading-relaxed"> <p className="text-ink-2 text-sm leading-relaxed">
Thanks — we&apos;ll reach out at the email you gave us. In the meantime, feel free to{" "} Thanks — we&apos;ll reach out at the email you gave us. In the meantime, feel free to{" "}
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">read the FAQ</Link>. <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">read the FAQ</Link>.
</p> </p>
@@ -53,39 +53,39 @@ export default function WaitlistPage() {
<> <>
<div> <div>
<h1 className="text-white text-lg font-bold">Request access</h1> <h1 className="text-white text-lg font-bold">Request access</h1>
<p className="text-gray-400 text-xs mt-2 leading-relaxed"> <p className="text-ink-2 text-xs mt-2 leading-relaxed">
Self-serve signup isn&apos;t open yet. Leave your details and we&apos;ll follow up to get your organization set up. Self-serve signup isn&apos;t open yet. Leave your details and we&apos;ll follow up to get your organization set up.
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Email</label> <label className="text-xs text-ink-2 block mb-1">Email</label>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
autoComplete="email" autoComplete="email"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Organization (optional)</label> <label className="text-xs text-ink-2 block mb-1">Organization (optional)</label>
<input <input
type="text" type="text"
value={orgName} value={orgName}
onChange={(e) => setOrgName(e.target.value)} onChange={(e) => setOrgName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Anything else? (optional)</label> <label className="text-xs text-ink-2 block mb-1">Anything else? (optional)</label>
<textarea <textarea
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
rows={3} rows={3}
maxLength={2000} maxLength={2000}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 resize-none" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 resize-none"
/> />
</div> </div>
{error && <p className="text-red-400 text-xs">{error}</p>} {error && <p className="text-red-400 text-xs">{error}</p>}
+31 -31
View File
@@ -85,17 +85,17 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
return ( return (
<> <>
<tr <tr
className={`border-b border-gray-800 font-mono text-sm ${hasDetails ? "cursor-pointer hover:bg-gray-900/50" : "hover:bg-gray-900/30"}`} className={`border-b border-line font-mono text-sm ${hasDetails ? "cursor-pointer hover:bg-raised/50" : "hover:bg-raised/30"}`}
onClick={() => hasDetails && setExpanded((v) => !v)} onClick={() => hasDetails && setExpanded((v) => !v)}
> >
<td className="px-4 py-2 text-gray-400 text-xs whitespace-nowrap"> <td className="px-4 py-2 text-ink-2 text-xs whitespace-nowrap">
<span className="text-gray-600">{new Date(call.started_at).toLocaleDateString([], { month: "short", day: "numeric" })} </span> <span className="text-ink-muted">{new Date(call.started_at).toLocaleDateString([], { month: "short", day: "numeric" })} </span>
{new Date(call.started_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} {new Date(call.started_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</td> </td>
<td className="px-4 py-2 text-gray-300"> <td className="px-4 py-2 text-ink-2">
<span>{call.talkgroup_name || call.talkgroup_id || "—"}</span> <span>{call.talkgroup_name || call.talkgroup_id || "—"}</span>
{call.tags && call.tags.length > 0 && ( {call.tags && call.tags.length > 0 && (
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded-full capitalize ${TAG_COLORS[call.tags[0]] ?? "bg-gray-800 text-gray-400"}`}> <span className={`ml-2 text-xs px-1.5 py-0.5 rounded-full capitalize ${TAG_COLORS[call.tags[0]] ?? "bg-raised text-ink-2"}`}>
{call.tags[0]} {call.tags[0]}
</span> </span>
)} )}
@@ -104,29 +104,29 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
<span className="ml-2">{severityBadge(call.severity)}</span> <span className="ml-2">{severityBadge(call.severity)}</span>
)} )}
</td> </td>
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{systemName ?? call.system_id ?? "—"}</td> <td className="px-4 py-2 text-ink-2 hidden sm:table-cell">{systemName ?? call.system_id ?? "—"}</td>
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{call.node_id}</td> <td className="px-4 py-2 text-ink-2 hidden sm:table-cell">{call.node_id}</td>
<td className="px-4 py-2"> <td className="px-4 py-2">
{isActive ? ( {isActive ? (
<span className="text-orange-400 animate-pulse">● live</span> <span className="text-orange-400 animate-pulse">● live</span>
) : ( ) : (
<span className="text-gray-500">{duration(call.started_at, call.ended_at)}</span> <span className="text-ink-muted">{duration(call.started_at, call.ended_at)}</span>
)} )}
</td> </td>
<td className="px-4 py-2 text-xs"> <td className="px-4 py-2 text-xs">
{hasAudio ? ( {hasAudio ? (
<span className="text-blue-400">▶</span> <span className="text-blue-400">▶</span>
) : ( ) : (
<span className="text-gray-700">—</span> <span className="text-ink-muted">—</span>
)} )}
</td> </td>
<td className="px-4 py-2 text-gray-600 text-xs"> <td className="px-4 py-2 text-ink-muted text-xs">
{hasDetails && (expanded ? "▲" : "▼")} {hasDetails && (expanded ? "▲" : "▼")}
</td> </td>
</tr> </tr>
{expanded && hasDetails && ( {expanded && hasDetails && (
<tr className="bg-gray-900/60 border-b border-gray-800"> <tr className="bg-surface/60 border-b border-line">
<td colSpan={7} className="px-6 py-3 space-y-2"> <td colSpan={7} className="px-6 py-3 space-y-2">
{/* Audio player */} {/* Audio player */}
{hasAudio && ( {hasAudio && (
@@ -140,7 +140,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
) : ( ) : (
<p className="text-xs text-gray-600 font-mono">Loading audio…</p> <p className="text-xs text-ink-muted font-mono">Loading audio…</p>
) )
)} )}
@@ -150,7 +150,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
{call.tags.map((tag) => ( {call.tags.map((tag) => (
<span <span
key={tag} key={tag}
className={`text-xs px-2 py-0.5 rounded-full capitalize ${TAG_COLORS[tag] ?? "bg-gray-800 text-gray-400"}`} className={`text-xs px-2 py-0.5 rounded-full capitalize ${TAG_COLORS[tag] ?? "bg-raised text-ink-2"}`}
> >
{tag} {tag}
</span> </span>
@@ -161,7 +161,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
{/* Incident links — one per scene detected in the recording */} {/* Incident links — one per scene detected in the recording */}
{incidentIds.length > 0 && ( {incidentIds.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-indigo-400"> <div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-indigo-400">
<span className="text-gray-600">{incidentIds.length === 1 ? "Incident:" : "Incidents:"}</span> <span className="text-ink-muted">{incidentIds.length === 1 ? "Incident:" : "Incidents:"}</span>
{incidentIds.map((id) => ( {incidentIds.map((id) => (
<a key={id} href={`/incidents/${id}`} className="underline hover:text-indigo-300"> <a key={id} href={`/incidents/${id}`} className="underline hover:text-indigo-300">
{id.slice(0, 8)}… {id.slice(0, 8)}…
@@ -172,9 +172,9 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
{/* Correlation debug — admin only */} {/* Correlation debug — admin only */}
{isAdmin && call.corr_path && ( {isAdmin && call.corr_path && (
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-gray-600"> <div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-ink-muted">
<span>corr:</span> <span>corr:</span>
<span className="text-gray-400">{call.corr_path}</span> <span className="text-ink-2">{call.corr_path}</span>
{call.corr_incident_idle_min != null && ( {call.corr_incident_idle_min != null && (
<span>idle {call.corr_incident_idle_min}min</span> <span>idle {call.corr_incident_idle_min}min</span>
)} )}
@@ -200,7 +200,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
value={editText} value={editText}
onChange={(e) => setEditText(e.target.value)} onChange={(e) => setEditText(e.target.value)}
rows={4} rows={4}
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-xs text-gray-200 font-mono focus:outline-none focus:border-indigo-500 resize-none" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-xs text-ink font-mono focus:outline-none focus:border-indigo-500 resize-none"
/> />
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<button <button
@@ -212,7 +212,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); setEditing(false); }} onClick={(e) => { e.stopPropagation(); setEditing(false); }}
className="text-xs text-gray-500 hover:text-gray-300 px-3 py-1 transition-colors" className="text-xs text-ink-muted hover:text-ink px-3 py-1 transition-colors"
> >
Cancel Cancel
</button> </button>
@@ -224,23 +224,23 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
) : hasSegments ? ( ) : hasSegments ? (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-gray-600 font-mono"> <span className="text-xs text-ink-muted font-mono">
{hasBoth && !showOriginal ? "corrected" : `${call.segments!.length} transmissions`} {hasBoth && !showOriginal ? "corrected" : `${call.segments!.length} transmissions`}
</span> </span>
{isAdmin && ( {isAdmin && (
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors">edit</button> <button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors">edit</button>
)} )}
</div> </div>
{hasBoth && !showOriginal ? ( {hasBoth && !showOriginal ? (
<pre className="text-xs text-gray-300 bg-gray-800 rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-48 overflow-y-auto"> <pre className="text-xs text-ink-2 bg-raised rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-48 overflow-y-auto">
{call.transcript_corrected} {call.transcript_corrected}
</pre> </pre>
) : ( ) : (
<div className="bg-gray-800 rounded-lg px-4 py-3 space-y-2 max-h-48 overflow-y-auto"> <div className="bg-raised rounded-lg px-4 py-3 space-y-2 max-h-48 overflow-y-auto">
{call.segments!.map((seg, i) => ( {call.segments!.map((seg, i) => (
<div key={i} className="flex gap-3 text-xs font-mono"> <div key={i} className="flex gap-3 text-xs font-mono">
<span className="text-gray-600 shrink-0">{i + 1}. [{seg.start}s]</span> <span className="text-ink-muted shrink-0">{i + 1}. [{seg.start}s]</span>
<span className="text-gray-300">{seg.text}</span> <span className="text-ink-2">{seg.text}</span>
</div> </div>
))} ))}
</div> </div>
@@ -248,7 +248,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
{hasBoth && ( {hasBoth && (
<button <button
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }} onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }}
className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors" className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors"
> >
{showOriginal ? "show corrected ↑" : "show original ↓"} {showOriginal ? "show corrected ↑" : "show original ↓"}
</button> </button>
@@ -258,27 +258,27 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{hasBoth && <span className="text-xs text-gray-600 font-mono">{showOriginal ? "original" : "corrected"}</span>} {hasBoth && <span className="text-xs text-ink-muted font-mono">{showOriginal ? "original" : "corrected"}</span>}
{!hasBoth && call.transcript_corrected && <span className="text-xs text-gray-600 font-mono">corrected</span>} {!hasBoth && call.transcript_corrected && <span className="text-xs text-ink-muted font-mono">corrected</span>}
</div> </div>
{isAdmin && ( {isAdmin && (
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors">edit</button> <button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors">edit</button>
)} )}
</div> </div>
<pre className="text-xs text-gray-300 bg-gray-800 rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-40 overflow-y-auto"> <pre className="text-xs text-ink-2 bg-raised rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-40 overflow-y-auto">
{displayTranscript} {displayTranscript}
</pre> </pre>
{hasBoth && ( {hasBoth && (
<button <button
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }} onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }}
className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors" className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors"
> >
{showOriginal ? "show corrected ↑" : "show original ↓"} {showOriginal ? "show corrected ↑" : "show original ↓"}
</button> </button>
)} )}
</div> </div>
) : ( ) : (
<p className="text-xs text-gray-600 font-mono italic">No transcript available.</p> <p className="text-xs text-ink-muted font-mono italic">No transcript available.</p>
)} )}
</td> </td>
</tr> </tr>
+2 -2
View File
@@ -283,11 +283,11 @@ export function CameraFeed({ camera, className, mode = "auto", priority = "norma
{camera.image_url ? "…" : "No still for this camera"} {camera.image_url ? "…" : "No still for this camera"}
</div> </div>
)} )}
<span className={`absolute top-1 left-1 px-1 rounded-sm font-mono text-[9px] leading-[14px] tracking-wide ${toneClass}`} title="Frame time is when DRB first saw this frame"> <span className={`absolute top-1 left-1 px-1 rounded-sm font-mono text-[10px] leading-[14px] tracking-wide ${toneClass}`} title="Frame time is when DRB first saw this frame">
{badge.text} {badge.text}
</span> </span>
{credit?.attribution && ( {credit?.attribution && (
<span className="absolute bottom-0 left-0 right-0 px-1 bg-black/60 text-white text-[9px] leading-[13px] truncate"> <span className="absolute bottom-0 left-0 right-0 px-1 bg-black/60 text-white text-[10px] leading-[13px] truncate">
{credit.attribution_url ? ( {credit.attribution_url ? (
<a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="hover:underline">{credit.attribution}</a> <a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="hover:underline">{credit.attribution}</a>
) : credit.attribution} ) : credit.attribution}
+1 -1
View File
@@ -74,7 +74,7 @@ const RECENT_CALLS = 500;
const KIOSK_TYPE = [ const KIOSK_TYPE = [
"[&[data-kiosk]_.text-xs]:text-[18px]", "[&[data-kiosk]_.text-xs]:leading-snug", "[&[data-kiosk]_.text-xs]:text-[18px]", "[&[data-kiosk]_.text-xs]:leading-snug",
"[&[data-kiosk]_.text-sm]:text-[20px]", "[&[data-kiosk]_.text-sm]:leading-snug", "[&[data-kiosk]_.text-sm]:text-[20px]", "[&[data-kiosk]_.text-sm]:leading-snug",
"[&[data-kiosk]_[class*='text-[9px]']]:text-[18px]", "[&[data-kiosk]_[class*='text-[10px]']]:text-[18px]",
"[&[data-kiosk]_[class*='text-[10px]']]:text-[18px]", "[&[data-kiosk]_[class*='text-[10px]']]:text-[18px]",
"[&[data-kiosk]_[class*='text-[11px]']]:text-[18px]", "[&[data-kiosk]_[class*='text-[11px]']]:text-[18px]",
"[&[data-kiosk]_.w-56]:w-80", "[&[data-kiosk]_.w-56]:w-80",
+12 -12
View File
@@ -440,7 +440,7 @@ function RadarBadge({ meta, loop, loopClock, onLoop }: {
else text = `Radar valid ${hhmmLocal(meta.valid)} · ${ageMin} min old`; else text = `Radar valid ${hhmmLocal(meta.valid)} · ${ageMin} min old`;
return ( return (
<div className={`flex items-center gap-1.5 bg-surface/90 border rounded px-2 py-1 text-xs text-ink-2 pointer-events-auto ${stale ? "border-accent" : "border-line"}`}> <div className={`flex items-center gap-1.5 bg-surface/90 border rounded px-2 py-1 text-xs text-ink-2 pointer-events-auto ${stale ? "border-accent" : "border-line"}`}>
{stale && <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold tracking-wide" {stale && <span className="bg-accent text-white rounded px-1 text-[10px] font-semibold tracking-wide"
title={`The newest radar frame is more than ${RADAR_STALE_MIN} min old, or its time could not be read`}>STALE</span>} title={`The newest radar frame is more than ${RADAR_STALE_MIN} min old, or its time could not be read`}>STALE</span>}
<span className="tabular-nums whitespace-nowrap">{text}</span> <span className="tabular-nums whitespace-nowrap">{text}</span>
{meta.valid && meta.error && <span className="text-ink-muted whitespace-nowrap" title={meta.error}>· time not refreshing</span>} {meta.valid && meta.error && <span className="text-ink-muted whitespace-nowrap" title={meta.error}>· time not refreshing</span>}
@@ -1233,8 +1233,8 @@ function FanNodeLayer({
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""} className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
> >
<p className="font-semibold text-sm text-gray-900">{node.name}</p> <p className="font-semibold text-sm text-gray-900">{node.name}</p>
<p className="text-xs text-gray-500 font-mono">{node.node_id}</p> <p className="text-xs text-ink-muted font-mono">{node.node_id}</p>
<p className="text-xs capitalize text-gray-700">{node.status}</p> <p className="text-xs capitalize text-ink-muted">{node.status}</p>
{activeByNode[node.node_id] && ( {activeByNode[node.node_id] && (
<p className="text-xs text-orange-600 mt-0.5"> <p className="text-xs text-orange-600 mt-0.5">
● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "} ● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
@@ -1346,8 +1346,8 @@ function FanIncidentLayer({
<p className="text-xs capitalize font-medium" style={{ color }}> <p className="text-xs capitalize font-medium" style={{ color }}>
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} · {inc.type ?? "other"} {isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} · {inc.type ?? "other"}
</p> </p>
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>} {inc.location && <p className="text-xs text-ink-muted">{inc.location}</p>}
<p className="text-[11px] text-gray-500">Pin: {PIN_PRECISION_LABEL[pinPrecision(inc)]}</p> <p className="text-[11px] text-ink-muted">Pin: {PIN_PRECISION_LABEL[pinPrecision(inc)]}</p>
<a <a
href={`/incidents/${inc.incident_id}`} href={`/incidents/${inc.incident_id}`}
onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }} onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }}
@@ -1490,19 +1490,19 @@ function IncidentPathLayer({
> >
<Popup minWidth={160}> <Popup minWidth={160}>
<div className="text-gray-900"> <div className="text-gray-900">
<p className="text-xs text-gray-500">{heading}</p> <p className="text-xs text-ink-muted">{heading}</p>
<p className="font-semibold text-sm mt-0.5">{inc.title ?? "Incident"}</p> <p className="font-semibold text-sm mt-0.5">{inc.title ?? "Incident"}</p>
{c.location && <p className="text-xs text-gray-600 mt-0.5">{c.location}</p>} {c.location && <p className="text-xs text-ink-muted mt-0.5">{c.location}</p>}
{c.location_coords?.location_type === "GEOMETRIC_CENTER" && ( {c.location_coords?.location_type === "GEOMETRIC_CENTER" && (
<p className="text-[11px] text-gray-500">Approximate: centre of a road, junction or place</p> <p className="text-[11px] text-ink-muted">Approximate: centre of a road, junction or place</p>
)} )}
{s.dashedIn && !s.ghost && path.movement && ( {s.dashedIn && !s.ghost && path.movement && (
<p className="text-[11px] text-gray-500">Dashed in: long gap or talkgroup change — route unknown</p> <p className="text-[11px] text-ink-muted">Dashed in: long gap or talkgroup change — route unknown</p>
)} )}
{hasAudio(c) && ( {hasAudio(c) && (
<div className="flex items-center gap-1.5 mt-1"> <div className="flex items-center gap-1.5 mt-1">
<PlayButton callId={c.call_id} label={s.n != null ? `Play stop ${s.n}` : "Play this call"} /> <PlayButton callId={c.call_id} label={s.n != null ? `Play stop ${s.n}` : "Play this call"} />
<span className="text-xs text-gray-600">Play this call</span> <span className="text-xs text-ink-muted">Play this call</span>
</div> </div>
)} )}
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1"> <a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
@@ -1997,7 +1997,7 @@ function MapView({
<span className="text-ink-2">Call location (not a pursuit)</span> <span className="text-ink-2">Call location (not a pursuit)</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[9px] px-0.5 rounded border border-dashed border-line text-ink-2 leading-tight">merged?</span> <span className="text-[10px] px-0.5 rounded border border-dashed border-line text-ink-2 leading-tight">merged?</span>
<span className="text-ink-2">Path &gt; {MAY_BE_MERGED_KM} km — may be 2+ events</span> <span className="text-ink-2">Path &gt; {MAY_BE_MERGED_KM} km — may be 2+ events</span>
</div> </div>
</div> </div>
@@ -2114,7 +2114,7 @@ function MapView({
<span className="uppercase tracking-wide font-semibold text-[11px]" style={{ color }}> <span className="uppercase tracking-wide font-semibold text-[11px]" style={{ color }}>
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} {isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
</span> </span>
{live && <span className="ml-auto bg-accent text-white rounded px-1 text-[9px] font-semibold tracking-wide">ON AIR</span>} {live && <span className="ml-auto bg-accent text-white rounded px-1 text-[10px] font-semibold tracking-wide">ON AIR</span>}
</div> </div>
<p className="text-ink font-semibold leading-snug truncate"> <p className="text-ink font-semibold leading-snug truncate">
{inc.title ?? "Incident"} {inc.title ?? "Incident"}
+10 -10
View File
@@ -19,22 +19,22 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) {
: "never"; : "never";
const body = ( const body = (
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer"> <div className="bg-surface border border-line rounded-lg p-4 hover:border-line-strong transition-colors cursor-pointer">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div> <div>
<p className="font-mono font-semibold text-white">{node.name}</p> <p className="font-mono font-semibold text-white">{node.name}</p>
<p className="text-xs text-gray-500 font-mono">{node.node_id}</p> <p className="text-xs text-ink-muted font-mono">{node.node_id}</p>
</div> </div>
<StatusBadge status={node.status} /> <StatusBadge status={node.status} />
</div> </div>
<div className="space-y-1 text-xs font-mono text-gray-400"> <div className="space-y-1 text-xs font-mono text-ink-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span>System</span> <span>System</span>
<span className="text-gray-300">{system?.name ?? "Unassigned"}</span> <span className="text-ink-2">{system?.name ?? "Unassigned"}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span>Location</span> <span>Location</span>
<span className="text-gray-300"> <span className="text-ink-2">
{node.lat != null && node.lon != null {node.lat != null && node.lon != null
? `${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}` ? `${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}`
: "Unknown"} : "Unknown"}
@@ -42,23 +42,23 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) {
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span>Last seen</span> <span>Last seen</span>
<span className="text-gray-300">{lastSeen}</span> <span className="text-ink-2">{lastSeen}</span>
</div> </div>
</div> </div>
{!node.configured && ( {!node.configured && (
<div className="mt-3 text-xs text-indigo-400 font-mono border-t border-gray-800 pt-2"> <div className="mt-3 text-xs text-indigo-400 font-mono border-t border-line pt-2">
⚠ Needs configuration ⚠ Needs configuration
</div> </div>
)} )}
{node.is_overridden && ( {node.is_overridden && (
<div className="mt-3 text-xs text-yellow-500 font-mono border-t border-gray-800 pt-2 flex justify-between"> <div className="mt-3 text-xs text-yellow-500 font-mono border-t border-line pt-2 flex justify-between">
<span>⚠ Local Override</span> <span>⚠ Local Override</span>
{node.override_timeout_at ? ( {node.override_timeout_at ? (
<span className="text-gray-500"> <span className="text-ink-muted">
Resets: {new Date(node.override_timeout_at).toLocaleTimeString()} Resets: {new Date(node.override_timeout_at).toLocaleTimeString()}
</span> </span>
) : ( ) : (
<span className="text-gray-500">Permanent</span> <span className="text-ink-muted">Permanent</span>
)} )}
</div> </div>
)} )}
+15 -15
View File
@@ -51,20 +51,20 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
return ( return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto"> <div className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
<h2 className="text-white font-semibold mb-1">Configure Node</h2> <h2 className="text-white font-semibold mb-1">Configure Node</h2>
<p className="text-gray-400 text-sm mb-5"> <p className="text-ink-2 text-sm mb-5">
<span className="text-indigo-400">{node.node_id}</span> connected for the first time. <span className="text-indigo-400">{node.node_id}</span> connected for the first time.
Assign it a radio system to begin monitoring. Assign it a radio system to begin monitoring.
</p> </p>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-xs text-gray-400 mb-1">Radio System</label> <label className="block text-xs text-ink-2 mb-1">Radio System</label>
<select <select
value={systemId} value={systemId}
onChange={(e) => setSystemId(e.target.value)} onChange={(e) => setSystemId(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
required required
> >
<option value="">Select a system…</option> <option value="">Select a system…</option>
@@ -77,41 +77,41 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
</div> </div>
<div> <div>
<label className="block text-xs text-gray-400 mb-1">SDR Hardware</label> <label className="block text-xs text-ink-2 mb-1">SDR Hardware</label>
<select <select
value={preset} value={preset}
onChange={(e) => setPreset(e.target.value)} onChange={(e) => setPreset(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
> >
{PRESETS.map((p) => ( {PRESETS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option> <option key={p.value} value={p.value}>{p.label}</option>
))} ))}
</select> </select>
{selectedPreset && ( {selectedPreset && (
<p className="text-xs text-gray-600 mt-1">{selectedPreset.hint}</p> <p className="text-xs text-ink-muted mt-1">{selectedPreset.hint}</p>
)} )}
</div> </div>
<div> <div>
<label className="block text-xs text-gray-400 mb-1"> <label className="block text-xs text-ink-2 mb-1">
PPM offset <span className="text-gray-600">(0 = use preset default; calibrate via OP25 web UI)</span> PPM offset <span className="text-ink-muted">(0 = use preset default; calibrate via OP25 web UI)</span>
</label> </label>
<input <input
type="number" type="number"
value={ppm} value={ppm}
onChange={(e) => setPpm(e.target.value)} onChange={(e) => setPpm(e.target.value)}
step="0.1" step="0.1"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="0" placeholder="0"
/> />
</div> </div>
<div> <div>
<label className="block text-xs text-gray-400 mb-1">Node Type *</label> <label className="block text-xs text-ink-2 mb-1">Node Type *</label>
<select <select
value={nodeType} value={nodeType}
onChange={(e) => setNodeType(e.target.value)} onChange={(e) => setNodeType(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
required required
> >
<option value="">Select node type...</option> <option value="">Select node type...</option>
@@ -127,9 +127,9 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
id="enforceTimeout" id="enforceTimeout"
checked={enforceTimeout} checked={enforceTimeout}
onChange={(e) => setEnforceTimeout(e.target.checked)} onChange={(e) => setEnforceTimeout(e.target.checked)}
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900" className="rounded bg-raised border-line-strong text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
/> />
<label htmlFor="enforceTimeout" className="text-xs text-gray-400 cursor-pointer select-none"> <label htmlFor="enforceTimeout" className="text-xs text-ink-2 cursor-pointer select-none">
Enforce Local Override Timeout (24 hours) Enforce Local Override Timeout (24 hours)
</label> </label>
</div> </div>
@@ -148,7 +148,7 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors" className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
> >
Cancel Cancel
</button> </button>
+5 -5
View File
@@ -27,7 +27,7 @@ export function RadioChannels({ channels }: { channels?: RadioChannel[] | null }
: `${encrypted.length} of ${channels.length} channels ${encrypted.length === 1 ? "is" : "are"} now`; : `${encrypted.length} of ${channels.length} channels ${encrypted.length === 1 ? "is" : "are"} now`;
return ( return (
<section> <section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Conventional Channels</h2> <h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-3">Conventional Channels</h2>
{encrypted.length > 0 && ( {encrypted.length > 0 && (
<p role="status" className="text-xs text-red-300 bg-red-950 border border-red-900 rounded-lg px-3 py-2 mb-3 font-mono"> <p role="status" className="text-xs text-red-300 bg-red-950 border border-red-900 rounded-lg px-3 py-2 mb-3 font-mono">
{subject} encrypted — those calls can&apos;t be decoded, so nothing from{" "} {subject} encrypted — those calls can&apos;t be decoded, so nothing from{" "}
@@ -37,17 +37,17 @@ export function RadioChannels({ channels }: { channels?: RadioChannel[] | null }
)} )}
<ul className="space-y-1 font-mono text-xs"> <ul className="space-y-1 font-mono text-xs">
{channels.map((c, i) => ( {channels.map((c, i) => (
<li key={i} className="flex flex-wrap gap-x-3 text-gray-300"> <li key={i} className="flex flex-wrap gap-x-3 text-ink-2">
<span className="text-white">{c.name || mhz(c.freq)}</span> <span className="text-white">{c.name || mhz(c.freq)}</span>
{c.name && <span className="text-gray-500">{mhz(c.freq)}</span>} {c.name && <span className="text-ink-muted">{mhz(c.freq)}</span>}
{c.nac != null && <span className="text-gray-500">NAC {hex(c.nac, 3)}</span>} {c.nac != null && <span className="text-ink-muted">NAC {hex(c.nac, 3)}</span>}
{c.encrypted ? ( {c.encrypted ? (
<span className="text-red-400"> <span className="text-red-400">
✕ Encrypted{c.algid != null ? ` · ${ALGIDS[c.algid] ?? `ALGID ${hex(c.algid, 2)}`}` : ""} ✕ Encrypted{c.algid != null ? ` · ${ALGIDS[c.algid] ?? `ALGID ${hex(c.algid, 2)}`}` : ""}
{c.keyid != null ? ` · key ${hex(c.keyid, 4)}` : ""} {c.keyid != null ? ` · key ${hex(c.keyid, 4)}` : ""}
</span> </span>
) : ( ) : (
<span className="text-gray-400"> <span className="text-ink-2">
{c.clear_calls > 0 ? `Clear · ${c.clear_calls} calls heard` : "No traffic heard yet"} {c.clear_calls > 0 ? `Clear · ${c.clear_calls} calls heard` : "No traffic heard yet"}
</span> </span>
)} )}
+15 -15
View File
@@ -23,7 +23,7 @@ function rowsFrom(priority: string[], pins: Record<string, string>): Row[] {
} }
const selectClass = const selectClass =
"bg-gray-800 border border-gray-700 rounded px-2 py-1 text-gray-200 text-xs focus:outline-none focus:border-indigo-500 disabled:opacity-60 max-w-[13rem]"; "bg-raised border border-line-strong rounded px-2 py-1 text-ink text-xs focus:outline-none focus:border-indigo-500 disabled:opacity-60 max-w-[13rem]";
function DeviceSelect({ function DeviceSelect({
value, devices, devicesKnown, autoLabel, label, disabled, onChange, value, devices, devicesKnown, autoLabel, label, disabled, onChange,
@@ -116,18 +116,18 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
return ( return (
<section> <section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">SDRs</h2> <h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-1">SDRs</h2>
<p className="text-xs text-gray-500 font-mono mb-3"> <p className="text-xs text-ink-muted font-mono mb-3">
{devicesKnown {devicesKnown
? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.` ? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.`
: "This node hasn't reported its SDRs yet."} : "This node hasn't reported its SDRs yet."}
</p> </p>
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm"> <div className="bg-surface border border-line rounded-lg divide-y divide-line font-mono text-sm">
<div className="flex flex-wrap items-center gap-3 px-4 py-2.5"> <div className="flex flex-wrap items-center gap-3 px-4 py-2.5">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-gray-200">OP25 SDR</div> <div className="text-ink">OP25 SDR</div>
<div className="text-xs text-gray-500"> <div className="text-xs text-ink-muted">
{op25Moving {op25Moving
? "Saving restarts OP25 on the selected SDR." ? "Saving restarts OP25 on the selected SDR."
: node.op25_sdr_serial : node.op25_sdr_serial
@@ -162,18 +162,18 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
: "Waiting for SDR"; : "Waiting for SDR";
return ( return (
<div key={row.mode} className="flex flex-wrap items-center gap-3 px-4 py-2.5"> <div key={row.mode} className="flex flex-wrap items-center gap-3 px-4 py-2.5">
<span className="w-4 text-right text-gray-600 text-xs">{row.enabled ? ++rank : ""}</span> <span className="w-4 text-right text-ink-muted text-xs">{row.enabled ? ++rank : ""}</span>
<input <input
type="checkbox" type="checkbox"
checked={row.enabled} checked={row.enabled}
disabled={!canEdit} disabled={!canEdit}
aria-label={`Enable ${meta.name}`} aria-label={`Enable ${meta.name}`}
onChange={(e) => edit(rows.map((r, j) => (j === i ? { ...r, enabled: e.target.checked } : r)))} onChange={(e) => edit(rows.map((r, j) => (j === i ? { ...r, enabled: e.target.checked } : r)))}
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900" className="rounded bg-raised border-line-strong text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
/> />
<div className="flex-1 min-w-[7rem]"> <div className="flex-1 min-w-[7rem]">
<div className="text-gray-200">{meta.name}</div> <div className="text-ink">{meta.name}</div>
<div className="text-xs text-gray-500">{meta.hint}</div> <div className="text-xs text-ink-muted">{meta.hint}</div>
</div> </div>
<DeviceSelect <DeviceSelect
value={row.pin} value={row.pin}
@@ -191,7 +191,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
onClick={() => move(i, -1)} onClick={() => move(i, -1)}
disabled={i === 0} disabled={i === 0}
aria-label={`Move ${meta.name} up`} aria-label={`Move ${meta.name} up`}
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30" className="w-7 h-7 rounded bg-raised hover:bg-gray-700 text-ink-2 disabled:opacity-30"
> >
▲ ▲
</button> </button>
@@ -200,13 +200,13 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
onClick={() => move(i, 1)} onClick={() => move(i, 1)}
disabled={i === rows.length - 1} disabled={i === rows.length - 1}
aria-label={`Move ${meta.name} down`} aria-label={`Move ${meta.name} down`}
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30" className="w-7 h-7 rounded bg-raised hover:bg-gray-700 text-ink-2 disabled:opacity-30"
> >
▼ ▼
</button> </button>
</div> </div>
)} )}
<span className={`w-32 text-right text-xs ${state === "Running" ? "text-green-400" : "text-gray-500"}`}> <span className={`w-32 text-right text-xs ${state === "Running" ? "text-green-400" : "text-ink-muted"}`}>
{state} {state}
</span> </span>
</div> </div>
@@ -214,7 +214,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
})} })}
</div> </div>
<p className="text-xs text-gray-500 font-mono mt-2"> <p className="text-xs text-ink-muted font-mono mt-2">
Every SDR besides OP25&apos;s runs the next enabled service, top first. Pin a service to the SDR with its Every SDR besides OP25&apos;s runs the next enabled service, top first. Pin a service to the SDR with its
antenna, or leave it on &quot;Any spare SDR&quot;. antenna, or leave it on &quot;Any spare SDR&quot;.
</p> </p>
@@ -235,7 +235,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
> >
{saving ? "Saving…" : "Save"} {saving ? "Saving…" : "Save"}
</button> </button>
{message && <span className="text-xs text-gray-500 font-mono">{message}</span>} {message && <span className="text-xs text-ink-muted font-mono">{message}</span>}
</div> </div>
)} )}
</section> </section>
+1 -1
View File
@@ -3,7 +3,7 @@ import type { NodeStatus } from "@/lib/types";
const styles: Record<NodeStatus, string> = { const styles: Record<NodeStatus, string> = {
online: "bg-green-950 text-green-400 border border-green-800", online: "bg-green-950 text-green-400 border border-green-800",
recording: "bg-orange-950 text-orange-400 border border-orange-800 animate-pulse", recording: "bg-orange-950 text-orange-400 border border-orange-800 animate-pulse",
offline: "bg-gray-900 text-gray-500 border border-gray-700", offline: "bg-surface text-ink-muted border border-line-strong",
unconfigured: "bg-indigo-950 text-indigo-400 border border-indigo-800", unconfigured: "bg-indigo-950 text-indigo-400 border border-indigo-800",
}; };
@@ -58,16 +58,16 @@ export function CameraProvidersTab() {
return ( return (
<section className="space-y-3"> <section className="space-y-3">
<p className="text-gray-400 text-xs leading-snug"> <p className="text-ink-2 text-xs leading-snug">
Public camera sources for the Live wall. Every provider is <b>off</b> until enabled here. Licence status comes from the Public camera sources for the Live wall. Every provider is <b>off</b> until enabled here. Licence status comes from the
camera-sources catalog (2026-10-04); a red or amber licence means DRB does not hold a confirmed right to show that imagery. camera-sources catalog (2026-10-04); a red or amber licence means DRB does not hold a confirmed right to show that imagery.
Required attributions render on every camera tile. Required attributions render on every camera tile.
</p> </p>
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>} {error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
{!providers ? ( {!providers ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : ( ) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800"> <div className="bg-surface border border-line rounded-xl divide-y divide-line">
{providers.map((p) => { {providers.map((p) => {
const lic = LICENCE_STYLE[p.licence.status]; const lic = LICENCE_STYLE[p.licence.status];
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null; const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
@@ -77,8 +77,8 @@ export function CameraProvidersTab() {
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span> <span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
<p className="text-white text-sm font-semibold">{p.name}</p> <p className="text-white text-sm font-semibold">{p.name}</p>
<p className="text-gray-400 text-xs">{p.licence.label}</p> <p className="text-ink-2 text-xs">{p.licence.label}</p>
<p className="text-gray-500 text-xs">{p.region} · {p.formats}</p> <p className="text-ink-muted text-xs">{p.region} · {p.formats}</p>
</div> </div>
<div className="shrink-0 text-right space-y-1"> <div className="shrink-0 text-right space-y-1">
<button <button
@@ -93,25 +93,25 @@ export function CameraProvidersTab() {
> >
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} /> <span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
</button> </button>
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-gray-500"}`}> <p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-ink-muted"}`}>
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"} {p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
</p> </p>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-[11px] font-mono text-gray-400"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-[11px] font-mono text-ink-2">
<span>cameras: {p.health ? p.health.count : "—"}</span> <span>cameras: {p.health ? p.health.count : "—"}</span>
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span> <span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span> <span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
<span className={p.key_missing ? "text-amber-400" : ""}>{p.key_setting ? `key: ${p.key_missing ? "missing" : "set"}` : "no key needed"}</span> <span className={p.key_missing ? "text-amber-400" : ""}>{p.key_setting ? `key: ${p.key_missing ? "missing" : "set"}` : "no key needed"}</span>
</div> </div>
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>} {p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
{p.licence.attribution && <p className="text-[11px] text-gray-400">Attribution shown with imagery: “{p.licence.attribution}”</p>} {p.licence.attribution && <p className="text-[11px] text-ink-2">Attribution shown with imagery: “{p.licence.attribution}”</p>}
{p.acknowledgement && ( {p.acknowledgement && (
<p className="text-[11px] text-amber-300"> <p className="text-[11px] text-amber-300">
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()} Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
</p> </p>
)} )}
<details className="text-[11px] text-gray-500"> <details className="text-[11px] text-ink-muted">
<summary className="cursor-pointer select-none">Terms & adapter notes</summary> <summary className="cursor-pointer select-none">Terms & adapter notes</summary>
<p className="mt-1">{p.licence.terms}</p> <p className="mt-1">{p.licence.terms}</p>
<p className="mt-1">{p.notes}</p> <p className="mt-1">{p.notes}</p>
@@ -129,16 +129,16 @@ export function CameraProvidersTab() {
{confirming && ( {confirming && (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="cam-ack-title"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="cam-ack-title">
<div className="bg-gray-900 border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3"> <div className="bg-surface border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
<h2 id="cam-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2> <h2 id="cam-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p> <p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
<p className="text-gray-300 text-sm leading-snug">{confirming.licence.warning}</p> <p className="text-ink-2 text-sm leading-snug">{confirming.licence.warning}</p>
<label className="flex items-start gap-2 text-sm text-gray-200"> <label className="flex items-start gap-2 text-sm text-ink">
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" /> <input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
<span>I confirm DRB holds the right to show this source&apos;s imagery. This confirmation is recorded with my account and the time in the audit log.</span> <span>I confirm DRB holds the right to show this source&apos;s imagery. This confirmation is recorded with my account and the time in the audit log.</span>
</label> </label>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-gray-700 text-gray-300">Cancel</button> <button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-line-strong text-ink-2">Cancel</button>
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)} <button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button> className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
</div> </div>
@@ -60,7 +60,7 @@ export function OutageProvidersTab() {
return ( return (
<section className="space-y-3"> <section className="space-y-3">
<p className="text-gray-400 text-xs leading-snug"> <p className="text-ink-2 text-xs leading-snug">
Power-outage sources for the map. Every provider is <b>off</b> until enabled here. Licence status comes from the Power-outage sources for the map. Every provider is <b>off</b> until enabled here. Licence status comes from the
source-terms research on server-26#184 (2026-10-05); a red or amber licence means DRB does not hold a confirmed right source-terms research on server-26#184 (2026-10-05); a red or amber licence means DRB does not hold a confirmed right
to show that data. Scraped utility outage maps are never shipped — those sources stay locked until a data-sharing to show that data. Scraped utility outage maps are never shipped — those sources stay locked until a data-sharing
@@ -68,9 +68,9 @@ export function OutageProvidersTab() {
</p> </p>
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>} {error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
{!providers ? ( {!providers ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : ( ) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800"> <div className="bg-surface border border-line rounded-xl divide-y divide-line">
{providers.map((p) => { {providers.map((p) => {
const lic = LICENCE_STYLE[p.licence.status]; const lic = LICENCE_STYLE[p.licence.status];
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null; const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
@@ -80,8 +80,8 @@ export function OutageProvidersTab() {
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span> <span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
<p className="text-white text-sm font-semibold">{p.name}</p> <p className="text-white text-sm font-semibold">{p.name}</p>
<p className="text-gray-400 text-xs">{p.licence.label}</p> <p className="text-ink-2 text-xs">{p.licence.label}</p>
<p className="text-gray-500 text-xs">{p.region} · {p.granularity}</p> <p className="text-ink-muted text-xs">{p.region} · {p.granularity}</p>
</div> </div>
<div className="shrink-0 text-right space-y-1"> <div className="shrink-0 text-right space-y-1">
<button <button
@@ -96,27 +96,27 @@ export function OutageProvidersTab() {
> >
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} /> <span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
</button> </button>
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-gray-500"}`}> <p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-ink-muted"}`}>
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"} {p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
</p> </p>
</div> </div>
</div> </div>
{p.implemented && ( {p.implemented && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-[11px] font-mono text-gray-400"> <div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-[11px] font-mono text-ink-2">
<span>counties out: {p.health ? p.health.count : "—"}</span> <span>counties out: {p.health ? p.health.count : "—"}</span>
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span> <span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span> <span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
</div> </div>
)} )}
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>} {p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
{p.coverage_note && <p className="text-[11px] text-gray-400">{p.coverage_note}</p>} {p.coverage_note && <p className="text-[11px] text-ink-2">{p.coverage_note}</p>}
{p.licence.attribution && <p className="text-[11px] text-gray-400">Attribution shown with the data: “{p.licence.attribution}”</p>} {p.licence.attribution && <p className="text-[11px] text-ink-2">Attribution shown with the data: “{p.licence.attribution}”</p>}
{p.acknowledgement && ( {p.acknowledgement && (
<p className="text-[11px] text-amber-300"> <p className="text-[11px] text-amber-300">
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()} Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
</p> </p>
)} )}
<details className="text-[11px] text-gray-500"> <details className="text-[11px] text-ink-muted">
<summary className="cursor-pointer select-none">Terms & adapter notes</summary> <summary className="cursor-pointer select-none">Terms & adapter notes</summary>
<p className="mt-1">{p.licence.terms}</p> <p className="mt-1">{p.licence.terms}</p>
<p className="mt-1">{p.notes}</p> <p className="mt-1">{p.notes}</p>
@@ -134,17 +134,17 @@ export function OutageProvidersTab() {
{confirming && ( {confirming && (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="outage-ack-title"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="outage-ack-title">
<div className="bg-gray-900 border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3"> <div className="bg-surface border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
<h2 id="outage-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2> <h2 id="outage-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p> <p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
<p className="text-gray-300 text-sm leading-snug">{confirming.licence.warning}</p> <p className="text-ink-2 text-sm leading-snug">{confirming.licence.warning}</p>
{error && <p className="text-red-400 text-xs font-mono break-words">{error}</p>} {error && <p className="text-red-400 text-xs font-mono break-words">{error}</p>}
<label className="flex items-start gap-2 text-sm text-gray-200"> <label className="flex items-start gap-2 text-sm text-ink">
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" /> <input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
<span>I confirm DRB holds the right to show this source&apos;s outage data. This confirmation is recorded with my account and the time in the audit log.</span> <span>I confirm DRB holds the right to show this source&apos;s outage data. This confirmation is recorded with my account and the time in the audit log.</span>
</label> </label>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-gray-700 text-gray-300">Cancel</button> <button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-line-strong text-ink-2">Cancel</button>
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)} <button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button> className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
</div> </div>
+39 -39
View File
@@ -24,9 +24,9 @@ const MODES: { key: ReplayMode; label: string; help: string }[] = [
const SIGNAL_RESOLVES = ["units_cleared", "llm_closure", "reassignment", "children_resolved"]; const SIGNAL_RESOLVES = ["units_cleared", "llm_closure", "reassignment", "children_resolved"];
const input = const input =
"bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"; "bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
const btn = const btn =
"bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"; "bg-raised hover:bg-gray-700 disabled:opacity-50 border border-line-strong text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors";
function toLocalInput(d: Date): string { function toLocalInput(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0"); const pad = (n: number) => String(n).padStart(2, "0");
@@ -88,18 +88,18 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
const canStart = est && !est.truncated && est.calls > 0 && !busy && (mode !== "reuse" || sourceRun); const canStart = est && !est.truncated && est.calls > 0 && !busy && (mode !== "reuse" || sourceRun);
return ( return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-4"> <div className="bg-surface border border-line rounded-xl p-4 space-y-4">
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">From</label> <label className="text-xs text-ink-2 block mb-1">From</label>
<input type="datetime-local" value={from} max={to} onChange={(e) => setFrom(e.target.value)} className={input} /> <input type="datetime-local" value={from} max={to} onChange={(e) => setFrom(e.target.value)} className={input} />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">To</label> <label className="text-xs text-ink-2 block mb-1">To</label>
<input type="datetime-local" value={to} min={from} onChange={(e) => setTo(e.target.value)} className={input} /> <input type="datetime-local" value={to} min={from} onChange={(e) => setTo(e.target.value)} className={input} />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Label</label> <label className="text-xs text-ink-2 block mb-1">Label</label>
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="what changed?" className={`${input} w-56`} /> <input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="what changed?" className={`${input} w-56`} />
</div> </div>
</div> </div>
@@ -109,14 +109,14 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
<label key={m.key} className="flex items-start gap-2 text-sm font-mono cursor-pointer"> <label key={m.key} className="flex items-start gap-2 text-sm font-mono cursor-pointer">
<input type="radio" checked={mode === m.key} onChange={() => setMode(m.key)} className="mt-1" /> <input type="radio" checked={mode === m.key} onChange={() => setMode(m.key)} className="mt-1" />
<span className="text-white">{m.label}</span> <span className="text-white">{m.label}</span>
<span className="text-gray-500 text-xs mt-0.5">{m.help}</span> <span className="text-ink-muted text-xs mt-0.5">{m.help}</span>
</label> </label>
))} ))}
</div> </div>
{mode === "reuse" && ( {mode === "reuse" && (
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Reuse extraction from</label> <label className="text-xs text-ink-2 block mb-1">Reuse extraction from</label>
<select value={sourceRun} onChange={(e) => setSourceRun(e.target.value)} className={input}> <select value={sourceRun} onChange={(e) => setSourceRun(e.target.value)} className={input}>
<option value="">— pick a finished run —</option> <option value="">— pick a finished run —</option>
{sources.map((r) => ( {sources.map((r) => (
@@ -125,15 +125,15 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
</option> </option>
))} ))}
</select> </select>
<p className="text-xs text-gray-500 mt-1">Set the range to match that run; calls outside it are skipped.</p> <p className="text-xs text-ink-muted mt-1">Set the range to match that run; calls outside it are skipped.</p>
</div> </div>
)} )}
{systems.length > 1 && ( {systems.length > 1 && (
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<span className="text-xs text-gray-400">Systems (none = all):</span> <span className="text-xs text-ink-2">Systems (none = all):</span>
{systems.map((s) => ( {systems.map((s) => (
<label key={s.system_id} className="flex items-center gap-1 text-xs font-mono text-gray-300 cursor-pointer"> <label key={s.system_id} className="flex items-center gap-1 text-xs font-mono text-ink-2 cursor-pointer">
<input type="checkbox" checked={systemIds.includes(s.system_id)} onChange={() => toggleSystem(s.system_id)} /> <input type="checkbox" checked={systemIds.includes(s.system_id)} onChange={() => toggleSystem(s.system_id)} />
{s.name} {s.name}
</label> </label>
@@ -154,7 +154,7 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
</div> </div>
{est && ( {est && (
<p className="text-sm font-mono text-gray-300"> <p className="text-sm font-mono text-ink-2">
{est.truncated ? ( {est.truncated ? (
<span className="text-red-400">More than {est.max_calls} calls — narrow the range.</span> <span className="text-red-400">More than {est.max_calls} calls — narrow the range.</span>
) : ( ) : (
@@ -193,15 +193,15 @@ function RunsTable({
onChanged(); onChanged();
} }
if (!runs.length) return <p className="text-sm text-gray-500 font-mono">No runs yet.</p>; if (!runs.length) return <p className="text-sm text-ink-muted font-mono">No runs yet.</p>;
const th = "text-left text-xs text-gray-500 font-normal px-2 py-1.5 whitespace-nowrap"; const th = "text-left text-xs text-ink-muted font-normal px-2 py-1.5 whitespace-nowrap";
const td = "px-2 py-1.5 whitespace-nowrap"; const td = "px-2 py-1.5 whitespace-nowrap";
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm font-mono"> <table className="w-full text-sm font-mono">
<thead> <thead>
<tr className="border-b border-gray-800"> <tr className="border-b border-line">
<th className={th}>Run</th> <th className={th}>Run</th>
<th className={th}>Range</th> <th className={th}>Range</th>
<th className={th}>Status</th> <th className={th}>Status</th>
@@ -222,13 +222,13 @@ function RunsTable({
<tr <tr
key={r.run_id} key={r.run_id}
onClick={() => onSelect(r.run_id)} onClick={() => onSelect(r.run_id)}
className={`border-b border-gray-800/60 cursor-pointer ${selected === r.run_id ? "bg-gray-800/60" : "hover:bg-gray-900"}`} className={`border-b border-line/60 cursor-pointer ${selected === r.run_id ? "bg-raised/60" : "hover:bg-raised"}`}
> >
<td className={td}> <td className={td}>
<div className="text-white">{r.label || r.run_id}</div> <div className="text-white">{r.label || r.run_id}</div>
<div className="text-xs text-gray-500">{r.mode} · {r.git_sha?.slice(0, 7)} · {fmtTime(r.created_at)}</div> <div className="text-xs text-ink-muted">{r.mode} · {r.git_sha?.slice(0, 7)} · {fmtTime(r.created_at)}</div>
</td> </td>
<td className={`${td} text-xs text-gray-400`}>{fmtTime(r.date_from)} → {fmtTime(r.date_to)}</td> <td className={`${td} text-xs text-ink-2`}>{fmtTime(r.date_from)} → {fmtTime(r.date_to)}</td>
<td className={td}> <td className={td}>
{running ? ( {running ? (
<span className="text-amber-400">{r.progress.done}/{r.progress.total}</span> <span className="text-amber-400">{r.progress.done}/{r.progress.total}</span>
@@ -245,7 +245,7 @@ function RunsTable({
<> <>
<span className="text-green-400">{sum(m.resolved_via, SIGNAL_RESOLVES)}</span> <span className="text-green-400">{sum(m.resolved_via, SIGNAL_RESOLVES)}</span>
{" / "} {" / "}
<span className="text-gray-400">{m.resolved_via.idle_timeout ?? 0}</span> <span className="text-ink-2">{m.resolved_via.idle_timeout ?? 0}</span>
</> </>
) : "—"} ) : "—"}
</td> </td>
@@ -255,7 +255,7 @@ function RunsTable({
{running ? ( {running ? (
<button onClick={() => cancel(r.run_id)} className="text-xs text-amber-400 hover:text-amber-300">cancel</button> <button onClick={() => cancel(r.run_id)} className="text-xs text-amber-400 hover:text-amber-300">cancel</button>
) : ( ) : (
<button onClick={() => remove(r.run_id)} className="text-xs text-gray-500 hover:text-red-400">delete</button> <button onClick={() => remove(r.run_id)} className="text-xs text-ink-muted hover:text-red-400">delete</button>
)} )}
</td> </td>
</tr> </tr>
@@ -278,21 +278,21 @@ function CallLine({ call }: { call: ReplayCallRow }) {
} catch { /* audio is a convenience */ } } catch { /* audio is a convenience */ }
} }
return ( return (
<div className="py-1.5 border-t border-gray-800/60 text-xs font-mono"> <div className="py-1.5 border-t border-line/60 text-xs font-mono">
<div className="flex flex-wrap gap-2 text-gray-500"> <div className="flex flex-wrap gap-2 text-ink-muted">
<span>{fmtTime(call.started_at)}</span> <span>{fmtTime(call.started_at)}</span>
<span>{call.talkgroup_name}</span> <span>{call.talkgroup_name}</span>
{call.corr_path.map((p, i) => <span key={i} className="text-indigo-400">{p}</span>)} {call.corr_path.map((p, i) => <span key={i} className="text-indigo-400">{p}</span>)}
{call.units?.length ? <span>units {call.units.join(", ")}</span> : null} {call.units?.length ? <span>units {call.units.join(", ")}</span> : null}
{call.cleared_units?.length ? <span className="text-green-400">cleared {call.cleared_units.join(", ")}</span> : null} {call.cleared_units?.length ? <span className="text-green-400">cleared {call.cleared_units.join(", ")}</span> : null}
{call.skip_reason && <span className="text-gray-600">{call.skip_reason}</span>} {call.skip_reason && <span className="text-ink-muted">{call.skip_reason}</span>}
{audio ? ( {audio ? (
<audio src={audio} controls autoPlay className="h-6" /> <audio src={audio} controls autoPlay className="h-6" />
) : ( ) : (
<button onClick={play} className="text-gray-400 hover:text-white">▶ audio</button> <button onClick={play} className="text-ink-2 hover:text-white">▶ audio</button>
)} )}
</div> </div>
<div className="text-gray-200 mt-0.5">{call.transcript || <span className="text-gray-600">(no transcript)</span>}</div> <div className="text-ink mt-0.5">{call.transcript || <span className="text-ink-muted">(no transcript)</span>}</div>
</div> </div>
); );
} }
@@ -301,13 +301,13 @@ function IncidentCard({ inc }: { inc: ReplayIncident }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const signal = inc.resolved_via && SIGNAL_RESOLVES.includes(inc.resolved_via); const signal = inc.resolved_via && SIGNAL_RESOLVES.includes(inc.resolved_via);
return ( return (
<div className="bg-gray-900 border border-gray-800 rounded-lg px-3 py-2"> <div className="bg-surface border border-line rounded-lg px-3 py-2">
<button onClick={() => setOpen(!open)} className="w-full text-left flex flex-wrap items-center gap-x-3 gap-y-1 text-sm font-mono"> <button onClick={() => setOpen(!open)} className="w-full text-left flex flex-wrap items-center gap-x-3 gap-y-1 text-sm font-mono">
<span className="text-gray-500">{open ? "▾" : "▸"}</span> <span className="text-ink-muted">{open ? "▾" : "▸"}</span>
<span className="text-white">{inc.title || inc.incident_id}</span> <span className="text-white">{inc.title || inc.incident_id}</span>
<span className="text-gray-500 text-xs">{inc.calls.length} call{inc.calls.length !== 1 ? "s" : ""}</span> <span className="text-ink-muted text-xs">{inc.calls.length} call{inc.calls.length !== 1 ? "s" : ""}</span>
<span className="text-gray-500 text-xs">{fmtTime(inc.started_at)} → {fmtTime(inc.resolved_at)}</span> <span className="text-ink-muted text-xs">{fmtTime(inc.started_at)} → {fmtTime(inc.resolved_at)}</span>
<span className={`text-xs ${signal ? "text-green-400" : "text-gray-500"}`}>{inc.resolved_via ?? inc.status}</span> <span className={`text-xs ${signal ? "text-green-400" : "text-ink-muted"}`}>{inc.resolved_via ?? inc.status}</span>
{inc.location_coords && <span className="text-xs text-indigo-400">📍 {inc.location}</span>} {inc.location_coords && <span className="text-xs text-indigo-400">📍 {inc.location}</span>}
</button> </button>
{open && <div className="mt-2">{inc.calls.map((c) => <CallLine key={c.call_id} call={c} />)}</div>} {open && <div className="mt-2">{inc.calls.map((c) => <CallLine key={c.call_id} call={c} />)}</div>}
@@ -347,22 +347,22 @@ function RunDetail({ run }: { run: ReplayRun }) {
["With units cleared", m.incidents_with_units_cleared], ["With units cleared", m.incidents_with_units_cleared],
["With map pin", m.incidents_with_coords], ["With map pin", m.incidents_with_coords],
].map(([k, v]) => ( ].map(([k, v]) => (
<div key={k as string} className="bg-gray-900 border border-gray-800 rounded-lg p-2"> <div key={k as string} className="bg-surface border border-line rounded-lg p-2">
<div className="text-gray-500">{k}</div> <div className="text-ink-muted">{k}</div>
<div className="text-white text-base">{v}</div> <div className="text-white text-base">{v}</div>
</div> </div>
))} ))}
</div> </div>
)} )}
{m && ( {m && (
<p className="text-xs font-mono text-gray-500"> <p className="text-xs font-mono text-ink-muted">
resolved: {Object.entries(m.resolved_via).map(([k, v]) => `${k} ${v}`).join(" · ")} resolved: {Object.entries(m.resolved_via).map(([k, v]) => `${k} ${v}`).join(" · ")}
<br /> <br />
paths: {Object.entries(m.corr_path).map(([k, v]) => `${k} ${v}`).join(" · ")} paths: {Object.entries(m.corr_path).map(([k, v]) => `${k} ${v}`).join(" · ")}
</p> </p>
)} )}
{m?.gemini_usage && Object.keys(m.gemini_usage).length > 0 && ( {m?.gemini_usage && Object.keys(m.gemini_usage).length > 0 && (
<p className="text-xs font-mono text-gray-500"> <p className="text-xs font-mono text-ink-muted">
Gemini tokens:{" "} Gemini tokens:{" "}
{Object.entries(m.gemini_usage) {Object.entries(m.gemini_usage)
.map(([k, u]) => `${k} ${u.calls} calls, in ${u.in.toLocaleString()} / out ${u.out.toLocaleString()} / thinking ${u.thinking.toLocaleString()}`) .map(([k, u]) => `${k} ${u.calls} calls, in ${u.in.toLocaleString()} / out ${u.out.toLocaleString()} / thinking ${u.thinking.toLocaleString()}`)
@@ -380,7 +380,7 @@ function RunDetail({ run }: { run: ReplayRun }) {
{run.errors.map((e, i) => <div key={i}>{e}</div>)} {run.errors.map((e, i) => <div key={i}>{e}</div>)}
</details> </details>
)} )}
{run.status === "running" && <p className="text-sm text-gray-500 font-mono">Incidents appear when the run finishes.</p>} {run.status === "running" && <p className="text-sm text-ink-muted font-mono">Incidents appear when the run finishes.</p>}
{error && <p className="text-red-400 text-sm font-mono">{error}</p>} {error && <p className="text-red-400 text-sm font-mono">{error}</p>}
{data && ( {data && (
<> <>
@@ -389,20 +389,20 @@ function RunDetail({ run }: { run: ReplayRun }) {
<button <button
key={f} key={f}
onClick={() => setFilter(f)} onClick={() => setFilter(f)}
className={`px-3 py-1 rounded-md ${filter === f ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"}`} className={`px-3 py-1 rounded-md ${filter === f ? "bg-raised text-white" : "text-ink-muted hover:text-ink"}`}
> >
{f === "all" ? `All (${data.incidents.length})` : f === "multi" ? "Multi-call" : "Single-call"} {f === "all" ? `All (${data.incidents.length})` : f === "multi" ? "Multi-call" : "Single-call"}
</button> </button>
))} ))}
<button <button
onClick={() => setShowOrphans(!showOrphans)} onClick={() => setShowOrphans(!showOrphans)}
className={`px-3 py-1 rounded-md ${showOrphans ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"}`} className={`px-3 py-1 rounded-md ${showOrphans ? "bg-raised text-white" : "text-ink-muted hover:text-ink"}`}
> >
Orphans ({data.orphans.length}) Orphans ({data.orphans.length})
</button> </button>
</div> </div>
{showOrphans ? ( {showOrphans ? (
<div className="bg-gray-900 border border-gray-800 rounded-lg px-3 py-2"> <div className="bg-surface border border-line rounded-lg px-3 py-2">
{data.orphans.map((c) => <CallLine key={c.call_id} call={c} />)} {data.orphans.map((c) => <CallLine key={c.call_id} call={c} />)}
</div> </div>
) : ( ) : (
@@ -443,7 +443,7 @@ export function ReplayTab() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<p className="text-xs text-gray-500 font-mono"> <p className="text-xs text-ink-muted font-mono">
Re-run the pipeline over a past time range, in the calls&apos; original order, into a sandbox — live incidents are never Re-run the pipeline over a past time range, in the calls&apos; original order, into a sandbox — live incidents are never
touched. Replay the same range after each change and compare the rows below. A run starts with no incidents touched. Replay the same range after each change and compare the rows below. A run starts with no incidents
open, so its first ~90 minutes split more than live did — compare runs over the same range, not a run against live. open, so its first ~90 minutes split more than live did — compare runs over the same range, not a run against live.
+4 -4
View File
@@ -51,7 +51,7 @@ export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: Came
return ( return (
<button type="button" onClick={onOpen} title={cam.name} className="relative block w-full text-left rounded-md overflow-hidden border border-line bg-black group"> <button type="button" onClick={onOpen} title={cam.name} className="relative block w-full text-left rounded-md overflow-hidden border border-line bg-black group">
<CameraFeed camera={cam} credit={feed} className="w-full aspect-video" /> <CameraFeed camera={cam} credit={feed} className="w-full aspect-video" />
{km != null && <span className="absolute top-1 right-1 bg-black/65 text-white font-mono text-[9px] px-1 rounded-sm">{km.toFixed(1)} km</span>} {km != null && <span className="absolute top-1 right-1 bg-black/65 text-white font-mono text-[10px] px-1 rounded-sm">{km.toFixed(1)} km</span>}
<span className="block px-1.5 py-0.5 text-[10px] text-ink-2 bg-surface truncate group-hover:text-ink">{cam.name || cam.id}</span> <span className="block px-1.5 py-0.5 text-[10px] text-ink-2 bg-surface truncate group-hover:text-ink">{cam.name || cam.id}</span>
</button> </button>
); );
@@ -277,7 +277,7 @@ function WindContext({ head }: { head: LatLon | null }) {
{calm ? "" : ` ${dir}`} {calm ? "" : ` ${dir}`}
{" · "}{w.station}{w.station_name ? ` (${w.station_name})` : ""}, {w.distance_km.toFixed(1)} km away {" · "}{w.station}{w.station_name ? ` (${w.station_name})` : ""}, {w.distance_km.toFixed(1)} km away
{obsLabel ? ` · observed ${obsLabel}` : ""} {obsLabel ? ` · observed ${obsLabel}` : ""}
{w.stale && <span className="ml-1 bg-accent text-white rounded px-1 text-[9px] font-semibold" title="This station's latest report is over 2 h old">STALE</span>} {w.stale && <span className="ml-1 bg-accent text-white rounded px-1 text-[10px] font-semibold" title="This station's latest report is over 2 h old">STALE</span>}
<SourceCredit credit={METAR_CREDIT} <SourceCredit credit={METAR_CREDIT}
updated={`fetched ${fetchedLabel(d.fetched_at) ?? "—"}${d.error ? " · feed error, showing the last good report" : ""}${state.error ? " · could not refresh" : ""}`} /> updated={`fetched ${fetchedLabel(d.fetched_at) ?? "—"}${d.error ? " · feed error, showing the last good report" : ""}${state.error ? " · could not refresh" : ""}`} />
</li> </li>
@@ -350,7 +350,7 @@ export function RegionPanel({
{wallCameras.map(({ cam, km }) => ( {wallCameras.map(({ cam, km }) => (
<div key={cam.id} className="relative"> <div key={cam.id} className="relative">
<CameraTile cam={cam} feed={feeds.get(cam.provider)} km={km} onOpen={() => onOpenCamera(cam)} /> <CameraTile cam={cam} feed={feeds.get(cam.provider)} km={km} onOpen={() => onOpenCamera(cam)} />
{pinnedIds.has(cam.id) && <span className="absolute bottom-6 right-1 bg-accent text-white text-[9px] px-1 rounded-sm">PINNED</span>} {pinnedIds.has(cam.id) && <span className="absolute bottom-6 right-1 bg-accent text-white text-[10px] px-1 rounded-sm">PINNED</span>}
</div> </div>
))} ))}
</div> </div>
@@ -465,7 +465,7 @@ export function IncidentPanel({
<span className="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span> <span className="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span>
<span className="text-ink-muted">{inc.type ?? "other"}</span> <span className="text-ink-muted">{inc.type ?? "other"}</span>
{resolved ? <span className="text-ink-muted">resolved</span> {resolved ? <span className="text-ink-muted">resolved</span>
: onAir ? <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold">ON AIR</span> : onAir ? <span className="bg-accent text-white rounded px-1 text-[10px] font-semibold">ON AIR</span>
: <span className="text-ink-muted">active</span>} : <span className="text-ink-muted">active</span>}
<span className="ml-auto font-mono text-ink-muted">{inc.incident_id.slice(0, 8)}</span> <span className="ml-auto font-mono text-ink-muted">{inc.incident_id.slice(0, 8)}</span>
</div> </div>
+1 -1
View File
@@ -7,7 +7,7 @@ interface EmptyStateProps {
action?: ReactNode; action?: ReactNode;
} }
/** Consistent "nothing here yet" panel — replaces the ad-hoc `<p className="text-gray-600">` scattered across pages. */ /** Consistent "nothing here yet" panel — replaces the ad-hoc `<p className="text-ink-muted">` scattered across pages. */
export function EmptyState({ icon, title, description, action }: EmptyStateProps) { export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return ( return (
<div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-line-strong rounded-xl"> <div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-line-strong rounded-xl">
@@ -78,7 +78,7 @@ export function MachineOutputNotice({ variant = "block", detail, className }: Pr
return ( return (
<p <p
role="note" role="note"
className={["text-[10px] leading-snug text-gray-500 mt-1.5 pt-1.5 border-t border-gray-200", className ?? ""] className={["text-[10px] leading-snug text-ink-muted mt-1.5 pt-1.5 border-t border-gray-200", className ?? ""]
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
> >