generated from CubeCraft-Creations/Tracehound
CUB-176: central hub frontend — camera grid, start/stop controls, history viewer
- CameraCard: color-coded status (green/yellow/red), per-camera start/stop, battery bar, recording indicator - HistoryViewer: modal dialog with 24h status log browsing per camera - App: responsive grid (1-4 cols), Start/Stop All global buttons, SSE connection badge, live stats strip - API service: aligned with backend endpoints (list, detail, start, stop) - Types: added StatusLog, CameraDetail, CameraInfo, StartStopResponse - All 23 tests pass, lint clean, TypeScript + Vite build clean
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Video, Wifi, WifiOff, Signal, Battery, Radio } from 'lucide-react'
|
||||
import { Video, Wifi, WifiOff, Signal, Battery, Radio, Play, Square } from 'lucide-react'
|
||||
import type { CameraStatus } from '../types'
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -23,11 +23,11 @@ function formatRelativeTime(iso: string): string {
|
||||
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 batteryColor(pct: number | null): { status: 'good' | 'low' | 'critical'; bar: string; text: string } {
|
||||
if (pct === null) return { status: 'critical', bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' }
|
||||
if (pct >= 50) return { status: 'good', bar: 'bg-rig-success', text: 'text-rig-success' }
|
||||
if (pct >= 15) return { status: 'low', bar: 'bg-rig-warning', text: 'text-rig-warning' }
|
||||
return { status: 'critical', bar: 'bg-rig-danger', text: 'text-rig-danger' }
|
||||
}
|
||||
|
||||
function formatTimeLeft(sec: number): string {
|
||||
@@ -37,14 +37,33 @@ function formatTimeLeft(sec: number): string {
|
||||
return `${m}m ${s}s left`
|
||||
}
|
||||
|
||||
function cameraStatus(online: boolean, batteryPct: number | null): 'good' | 'warning' | 'critical' {
|
||||
if (!online) return 'critical'
|
||||
if (batteryPct === null) return 'good'
|
||||
if (batteryPct >= 50) return 'good'
|
||||
if (batteryPct >= 15) return 'warning'
|
||||
return 'critical'
|
||||
}
|
||||
|
||||
const STATUS_BORDER: Record<string, string> = {
|
||||
good: 'border-l-rig-success',
|
||||
warning: 'border-l-rig-warning',
|
||||
critical: 'border-l-rig-danger',
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface CameraCardProps {
|
||||
camera: CameraStatus
|
||||
onStart: (cameraId: string) => void
|
||||
onStop: (cameraId: string) => void
|
||||
onViewHistory: (cameraId: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function CameraCard({ camera }: CameraCardProps) {
|
||||
export default function CameraCard({ camera, onStart, onStop, onViewHistory, disabled }: CameraCardProps) {
|
||||
const {
|
||||
camera_id,
|
||||
friendly_name,
|
||||
online,
|
||||
resolution,
|
||||
@@ -57,21 +76,23 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
||||
} = camera
|
||||
|
||||
const batt = batteryColor(battery_pct)
|
||||
const status = cameraStatus(online, battery_pct)
|
||||
const borderColor = STATUS_BORDER[status]
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`rounded-xl border bg-rig-dark-800/60 transition-colors ${
|
||||
className={`rounded-xl border border-rig-dark-600 bg-rig-dark-800/60 transition-colors border-l-4 ${borderColor} ${
|
||||
online
|
||||
? 'border-rig-dark-600 hover:border-rig-accent/40'
|
||||
: 'border-rig-dark-700 opacity-75'
|
||||
? 'hover:border-rig-accent/40'
|
||||
: 'opacity-75'
|
||||
}`}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Video className="h-4 w-4 text-rig-accent" aria-hidden="true" />
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Video className="h-4 w-4 shrink-0 text-rig-accent" aria-hidden="true" />
|
||||
<h3
|
||||
className="text-sm font-semibold text-rig-dark-100 truncate max-w-[180px]"
|
||||
className="text-sm font-semibold text-rig-dark-100 truncate"
|
||||
title={friendly_name}
|
||||
>
|
||||
{friendly_name}
|
||||
@@ -82,7 +103,7 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
||||
<span
|
||||
role="status"
|
||||
aria-label={online ? 'Camera online' : 'Camera offline'}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
className={`ml-2 shrink-0 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'
|
||||
@@ -99,6 +120,9 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="space-y-2.5 px-4 pb-3">
|
||||
{/* Camera ID */}
|
||||
<p className="text-[11px] font-mono text-rig-dark-500">{camera_id}</p>
|
||||
|
||||
{/* Resolution + FPS */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-rig-dark-300">
|
||||
<Signal className="h-3.5 w-3.5" />
|
||||
@@ -159,26 +183,60 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
||||
</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>
|
||||
</>
|
||||
<div className="rounded-b-xl border-t border-rig-dark-700/50 bg-rig-dark-900/30">
|
||||
{/* Controls row */}
|
||||
<div className="flex items-center gap-1 px-3 py-2">
|
||||
{recording ? (
|
||||
<button
|
||||
onClick={() => onStop(camera_id)}
|
||||
disabled={disabled}
|
||||
className="flex items-center gap-1 rounded-md bg-rig-danger/20 px-2.5 py-1 text-xs font-medium text-rig-danger hover:bg-rig-danger/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label={`Stop recording ${friendly_name}`}
|
||||
>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-rig-dark-400">Offline</span>
|
||||
<button
|
||||
onClick={() => onStart(camera_id)}
|
||||
disabled={disabled || !online}
|
||||
className="flex items-center gap-1 rounded-md bg-rig-success/20 px-2.5 py-1 text-xs font-medium text-rig-success hover:bg-rig-success/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label={`Start recording ${friendly_name}`}
|
||||
>
|
||||
<Play className="h-3 w-3 fill-current" />
|
||||
Record
|
||||
</button>
|
||||
)}
|
||||
<span className="text-rig-dark-500">·</span>
|
||||
<span className="text-rig-dark-400">{formatRelativeTime(last_seen)}</span>
|
||||
<button
|
||||
onClick={() => onViewHistory(camera_id)}
|
||||
className="ml-auto rounded-md bg-rig-dark-700/50 px-2 py-1 text-[11px] text-rig-dark-300 hover:bg-rig-dark-600 hover:text-rig-dark-100 transition-colors"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
</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>
|
||||
{/* Status strip */}
|
||||
<div className="flex items-center justify-between px-4 pb-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>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-rig-dark-400">Offline</span>
|
||||
)}
|
||||
<span className="text-rig-dark-500">·</span>
|
||||
<span className="text-rig-dark-400">{formatRelativeTime(last_seen)}</span>
|
||||
</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>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user