2026-05-21 11:51:40 +00:00
|
|
|
import { Video, Wifi, WifiOff, Signal, Battery, Radio } from 'lucide-react'
|
|
|
|
|
import type { CameraStatus } from '../types'
|
|
|
|
|
|
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function formatRelativeTime(iso: string): string {
|
|
|
|
|
const then = new Date(iso).getTime()
|
2026-05-21 11:59:40 +00:00
|
|
|
if (isNaN(then)) return 'unknown'
|
|
|
|
|
|
|
|
|
|
const diffSec = Math.floor((Date.now() - then) / 1000)
|
2026-05-21 11:51:40 +00:00
|
|
|
|
|
|
|
|
if (diffSec < 10) return 'just now'
|
|
|
|
|
if (diffSec < 60) return `${diffSec}s ago`
|
|
|
|
|
|
|
|
|
|
const diffMin = Math.floor(diffSec / 60)
|
|
|
|
|
if (diffMin < 60) return `${diffMin}m ago`
|
|
|
|
|
|
|
|
|
|
const diffHr = Math.floor(diffMin / 60)
|
|
|
|
|
if (diffHr < 24) return `${diffHr}h ago`
|
|
|
|
|
|
|
|
|
|
const diffDay = Math.floor(diffHr / 24)
|
|
|
|
|
return `${diffDay}d ago`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function batteryColor(pct: number | null): { bar: string; text: string } {
|
|
|
|
|
if (pct === null) return { bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' }
|
|
|
|
|
if (pct >= 50) return { bar: 'bg-rig-success', text: 'text-rig-success' }
|
|
|
|
|
if (pct >= 15) return { bar: 'bg-rig-warning', text: 'text-rig-warning' }
|
|
|
|
|
return { bar: 'bg-rig-danger', text: 'text-rig-danger' }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatTimeLeft(sec: number): string {
|
2026-05-21 11:59:40 +00:00
|
|
|
if (sec <= 0 || !isFinite(sec)) return '--'
|
2026-05-21 11:51:40 +00:00
|
|
|
const m = Math.floor(sec / 60)
|
|
|
|
|
const s = sec % 60
|
|
|
|
|
return `${m}m ${s}s left`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Component ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
interface CameraCardProps {
|
|
|
|
|
camera: CameraStatus
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function CameraCard({ camera }: CameraCardProps) {
|
|
|
|
|
const {
|
|
|
|
|
friendly_name,
|
|
|
|
|
online,
|
|
|
|
|
resolution,
|
|
|
|
|
fps,
|
|
|
|
|
recording,
|
|
|
|
|
mode,
|
|
|
|
|
battery_pct,
|
|
|
|
|
last_seen,
|
|
|
|
|
video_remaining_sec,
|
|
|
|
|
} = camera
|
|
|
|
|
|
|
|
|
|
const batt = batteryColor(battery_pct)
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-21 11:59:40 +00:00
|
|
|
<article
|
2026-05-21 11:51:40 +00:00
|
|
|
className={`rounded-xl border bg-rig-dark-800/60 transition-colors ${
|
|
|
|
|
online
|
|
|
|
|
? 'border-rig-dark-600 hover:border-rig-accent/40'
|
|
|
|
|
: 'border-rig-dark-700 opacity-75'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{/* ── Header ── */}
|
|
|
|
|
<div className="flex items-center justify-between px-4 pt-4 pb-2">
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-05-21 11:59:40 +00:00
|
|
|
<Video className="h-4 w-4 text-rig-accent" aria-hidden="true" />
|
|
|
|
|
<h3
|
|
|
|
|
className="text-sm font-semibold text-rig-dark-100 truncate max-w-[180px]"
|
|
|
|
|
title={friendly_name}
|
|
|
|
|
>
|
2026-05-21 11:51:40 +00:00
|
|
|
{friendly_name}
|
|
|
|
|
</h3>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Online / Offline badge */}
|
|
|
|
|
<span
|
2026-05-21 11:59:40 +00:00
|
|
|
role="status"
|
|
|
|
|
aria-label={online ? 'Camera online' : 'Camera offline'}
|
2026-05-21 11:51:40 +00:00
|
|
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
|
|
|
online
|
|
|
|
|
? 'bg-rig-success/15 text-rig-success'
|
|
|
|
|
: 'bg-rig-danger/15 text-rig-danger'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{online ? (
|
2026-05-21 11:59:40 +00:00
|
|
|
<Wifi className="h-3 w-3" aria-hidden="true" />
|
2026-05-21 11:51:40 +00:00
|
|
|
) : (
|
2026-05-21 11:59:40 +00:00
|
|
|
<WifiOff className="h-3 w-3" aria-hidden="true" />
|
2026-05-21 11:51:40 +00:00
|
|
|
)}
|
|
|
|
|
{online ? 'Online' : 'Offline'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ── Body ── */}
|
|
|
|
|
<div className="space-y-2.5 px-4 pb-3">
|
|
|
|
|
{/* Resolution + FPS */}
|
|
|
|
|
<div className="flex items-center gap-1.5 text-xs text-rig-dark-300">
|
|
|
|
|
<Signal className="h-3.5 w-3.5" />
|
|
|
|
|
<span>
|
|
|
|
|
{resolution} · {fps} FPS
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Recording state */}
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{recording ? (
|
|
|
|
|
<>
|
2026-05-21 11:59:40 +00:00
|
|
|
<span
|
|
|
|
|
className="relative flex h-2.5 w-2.5"
|
|
|
|
|
role="status"
|
|
|
|
|
aria-label="Recording in progress"
|
|
|
|
|
>
|
2026-05-21 11:51:40 +00:00
|
|
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rig-danger opacity-75" />
|
|
|
|
|
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-rig-danger" />
|
|
|
|
|
</span>
|
|
|
|
|
<span className="rounded bg-rig-danger/15 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-rig-danger">
|
|
|
|
|
REC
|
|
|
|
|
</span>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="rounded bg-rig-dark-600/50 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-rig-dark-400">
|
|
|
|
|
IDLE
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-[11px] text-rig-dark-400">{mode}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Battery */}
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<div className="flex items-center justify-between text-xs">
|
|
|
|
|
<span className="flex items-center gap-1 text-rig-dark-400">
|
|
|
|
|
<Battery className="h-3 w-3" />
|
|
|
|
|
Battery
|
|
|
|
|
</span>
|
|
|
|
|
<span className={`font-mono text-xs ${batt.text}`}>
|
|
|
|
|
{battery_pct !== null ? `${battery_pct}%` : 'N/A'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-05-21 11:59:40 +00:00
|
|
|
<div
|
|
|
|
|
className="h-1.5 w-full overflow-hidden rounded-full bg-rig-dark-700"
|
|
|
|
|
role="progressbar"
|
|
|
|
|
aria-valuenow={battery_pct ?? 0}
|
|
|
|
|
aria-valuemin={0}
|
|
|
|
|
aria-valuemax={100}
|
|
|
|
|
aria-label={`Battery ${battery_pct !== null ? battery_pct + '%' : 'unknown'}`}
|
|
|
|
|
>
|
2026-05-21 11:51:40 +00:00
|
|
|
<div
|
|
|
|
|
className={`h-full rounded-full transition-all ${batt.bar}`}
|
|
|
|
|
style={{ width: battery_pct !== null ? `${Math.min(100, Math.max(0, battery_pct))}%` : '0%' }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ── Footer ── */}
|
|
|
|
|
<div className="flex items-center justify-between rounded-b-xl border-t border-rig-dark-700/50 bg-rig-dark-900/30 px-4 py-2">
|
|
|
|
|
<div className="flex items-center gap-1.5 text-xs">
|
|
|
|
|
{online ? (
|
|
|
|
|
<>
|
|
|
|
|
<span className="h-1.5 w-1.5 rounded-full bg-rig-success" />
|
|
|
|
|
<span className="text-rig-success">Live</span>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
2026-05-21 11:59:40 +00:00
|
|
|
<span className="text-rig-dark-400">Offline</span>
|
2026-05-21 11:51:40 +00:00
|
|
|
)}
|
2026-05-21 11:59:40 +00:00
|
|
|
<span className="text-rig-dark-500">·</span>
|
|
|
|
|
<span className="text-rig-dark-400">{formatRelativeTime(last_seen)}</span>
|
2026-05-21 11:51:40 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{video_remaining_sec !== null && (
|
|
|
|
|
<div className="flex items-center gap-1 text-xs text-rig-dark-400">
|
|
|
|
|
<Radio className="h-3 w-3" />
|
|
|
|
|
<span className="font-mono">{formatTimeLeft(video_remaining_sec)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-05-21 11:59:40 +00:00
|
|
|
</article>
|
2026-05-21 11:51:40 +00:00
|
|
|
)
|
|
|
|
|
}
|