|
@@ -1,20 +1,436 @@
|
|
|
import Head from 'next/head'
|
|
import Head from 'next/head'
|
|
|
import Link from 'next/link'
|
|
import Link from 'next/link'
|
|
|
|
|
+import Image from 'next/image'
|
|
|
import { useRouter } from 'next/router'
|
|
import { useRouter } from 'next/router'
|
|
|
-import { useEffect, useState } from 'react'
|
|
|
|
|
-import { useDevice } from '../../hooks/useDevices'
|
|
|
|
|
|
|
+import { useEffect, useState, useCallback } from 'react'
|
|
|
|
|
+import { useDevice, useDevices } from '../../hooks/useDevices'
|
|
|
import { ProtectedRoute } from '../../components/ProtectedRoute'
|
|
import { ProtectedRoute } from '../../components/ProtectedRoute'
|
|
|
-import { getStoredUser, authApi } from '../../lib/auth'
|
|
|
|
|
|
|
+import { getStoredUser, authApi, devicesApi, capturesApi } from '../../lib/auth'
|
|
|
import type { AuthUser } from '../../lib/auth'
|
|
import type { AuthUser } from '../../lib/auth'
|
|
|
|
|
|
|
|
|
|
+// ── Types ───────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+interface DeviceConfig {
|
|
|
|
|
+ captureIntervalMinutes?: number
|
|
|
|
|
+ resolution?: string
|
|
|
|
|
+ quality?: number
|
|
|
|
|
+ uploadOnWifiOnly?: boolean
|
|
|
|
|
+ nightModeEnabled?: boolean
|
|
|
|
|
+ nightModeStart?: string
|
|
|
|
|
+ nightModeEnd?: string
|
|
|
|
|
+ maxStorageGb?: number
|
|
|
|
|
+ heartbeatIntervalSeconds?: number
|
|
|
|
|
+ timezone?: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface Capture {
|
|
|
|
|
+ id: string
|
|
|
|
|
+ deviceId: string
|
|
|
|
|
+ capturedAt: string
|
|
|
|
|
+ imageUrl: string | null
|
|
|
|
|
+ resolution?: string | null
|
|
|
|
|
+ fileSizeBytes?: number | null
|
|
|
|
|
+ exposureMs?: number | null
|
|
|
|
|
+ iso?: number | null
|
|
|
|
|
+ aperture?: string | null
|
|
|
|
|
+ status: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Config form ─────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+interface ConfigField {
|
|
|
|
|
+ key: keyof DeviceConfig
|
|
|
|
|
+ label: string
|
|
|
|
|
+ type: 'number' | 'text' | 'boolean' | 'select'
|
|
|
|
|
+ unit?: string
|
|
|
|
|
+ min?: number
|
|
|
|
|
+ max?: number
|
|
|
|
|
+ options?: { value: string; label: string }[]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const CONFIG_FIELDS: ConfigField[] = [
|
|
|
|
|
+ {
|
|
|
|
|
+ key: 'captureIntervalMinutes',
|
|
|
|
|
+ label: 'Capture Interval',
|
|
|
|
|
+ type: 'number',
|
|
|
|
|
+ unit: 'min',
|
|
|
|
|
+ min: 1,
|
|
|
|
|
+ max: 1440,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: 'resolution',
|
|
|
|
|
+ label: 'Resolution',
|
|
|
|
|
+ type: 'select',
|
|
|
|
|
+ options: [
|
|
|
|
|
+ { value: '1920x1080', label: '1920×1080 (Full HD)' },
|
|
|
|
|
+ { value: '3840x2160', label: '3840×2160 (4K)' },
|
|
|
|
|
+ { value: '1280x720', label: '1280×720 (HD)' },
|
|
|
|
|
+ { value: '640x480', label: '640×480 (VGA)' },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: 'quality',
|
|
|
|
|
+ label: 'JPEG Quality',
|
|
|
|
|
+ type: 'number',
|
|
|
|
|
+ unit: '%',
|
|
|
|
|
+ min: 1,
|
|
|
|
|
+ max: 100,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: 'maxStorageGb',
|
|
|
|
|
+ label: 'Max Storage',
|
|
|
|
|
+ type: 'number',
|
|
|
|
|
+ unit: 'GB',
|
|
|
|
|
+ min: 1,
|
|
|
|
|
+ max: 1000,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: 'heartbeatIntervalSeconds',
|
|
|
|
|
+ label: 'Heartbeat Interval',
|
|
|
|
|
+ type: 'number',
|
|
|
|
|
+ unit: 'sec',
|
|
|
|
|
+ min: 10,
|
|
|
|
|
+ max: 3600,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: 'nightModeStart',
|
|
|
|
|
+ label: 'Night Mode Start',
|
|
|
|
|
+ type: 'text',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: 'nightModeEnd',
|
|
|
|
|
+ label: 'Night Mode End',
|
|
|
|
|
+ type: 'text',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: 'timezone',
|
|
|
|
|
+ label: 'Timezone',
|
|
|
|
|
+ type: 'text',
|
|
|
|
|
+ },
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+// ── Image gallery ───────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function ImageGallery({ deviceId }: { deviceId: string }) {
|
|
|
|
|
+ const [captures, setCaptures] = useState<Capture[]>([])
|
|
|
|
|
+ const [loading, setLoading] = useState(true)
|
|
|
|
|
+ const [lightbox, setLightbox] = useState<Capture | null>(null)
|
|
|
|
|
+
|
|
|
|
|
+ const fetchCaptures = useCallback(async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const data = await capturesApi.byDevice(deviceId, 60)
|
|
|
|
|
+ setCaptures(data)
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // silient fail
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setLoading(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [deviceId])
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ fetchCaptures()
|
|
|
|
|
+ const interval = setInterval(fetchCaptures, 30_000)
|
|
|
|
|
+ return () => clearInterval(interval)
|
|
|
|
|
+ }, [fetchCaptures])
|
|
|
|
|
+
|
|
|
|
|
+ function formatTime(iso: string) {
|
|
|
|
|
+ return new Date(iso).toLocaleString('vi-VN', {
|
|
|
|
|
+ day: '2-digit',
|
|
|
|
|
+ month: '2-digit',
|
|
|
|
|
+ hour: '2-digit',
|
|
|
|
|
+ minute: '2-digit',
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function formatSize(bytes: number | null | undefined) {
|
|
|
|
|
+ if (!bytes) return '—'
|
|
|
|
|
+ if (bytes < 1024) return `${bytes} B`
|
|
|
|
|
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
|
|
|
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="rounded-xl border border-gray-200 bg-white shadow-sm">
|
|
|
|
|
+ <div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
|
|
|
|
+ <h2 className="font-semibold text-gray-700">Captures Preview</h2>
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <span className="text-xs text-gray-400">{captures.length} images</span>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={fetchCaptures}
|
|
|
|
|
+ className="rounded border border-gray-300 px-2 py-1 text-xs text-gray-500 hover:bg-gray-50"
|
|
|
|
|
+ >
|
|
|
|
|
+ Refresh
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {loading ? (
|
|
|
|
|
+ <div className="grid grid-cols-4 gap-1 p-4 sm:grid-cols-6 md:grid-cols-8">
|
|
|
|
|
+ {Array.from({ length: 8 }).map((_, i) => (
|
|
|
|
|
+ <div key={i} className="aspect-square animate-pulse rounded bg-gray-200" />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : captures.length === 0 ? (
|
|
|
|
|
+ <div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
|
|
|
|
+ <svg className="mb-3 h-12 w-12 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2 1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <p className="text-sm">No captures yet</p>
|
|
|
|
|
+ <p className="mt-1 text-xs">Device will upload images automatically</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="grid grid-cols-4 gap-1 p-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10">
|
|
|
|
|
+ {captures.map((cap) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={cap.id}
|
|
|
|
|
+ className="group relative aspect-square overflow-hidden rounded border border-gray-200 bg-gray-50 transition-transform hover:scale-105 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-blue-400"
|
|
|
|
|
+ onClick={() => setLightbox(cap)}
|
|
|
|
|
+ title={formatTime(cap.capturedAt)}
|
|
|
|
|
+ >
|
|
|
|
|
+ {cap.imageUrl ? (
|
|
|
|
|
+ // eslint-disable-next-line @next/next/no-img-element
|
|
|
|
|
+ <img
|
|
|
|
|
+ src={cap.imageUrl}
|
|
|
|
|
+ alt={`Capture ${cap.id}`}
|
|
|
|
|
+ className="h-full w-full object-cover"
|
|
|
|
|
+ loading="lazy"
|
|
|
|
|
+ />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="flex h-full w-full items-center justify-center text-gray-300">
|
|
|
|
|
+ <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2 1.586-1.586a2 2 0 012.828 0L20 14" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {/* Hover overlay */}
|
|
|
|
|
+ <div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
|
|
|
|
|
+ <div className="w-full p-1 text-left">
|
|
|
|
|
+ <p className="text-[10px] text-white leading-tight">{formatTime(cap.capturedAt)}</p>
|
|
|
|
|
+ {cap.iso && (
|
|
|
|
|
+ <p className="text-[9px] text-white/70">ISO {cap.iso} {cap.aperture && `· ${cap.aperture}`}</p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Lightbox */}
|
|
|
|
|
+ {lightbox && (
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-8"
|
|
|
|
|
+ onClick={() => setLightbox(null)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="relative max-h-full max-w-4xl w-full" onClick={e => e.stopPropagation()}>
|
|
|
|
|
+ <button
|
|
|
|
|
+ className="absolute -top-10 right-0 text-white/70 hover:text-white text-sm"
|
|
|
|
|
+ onClick={() => setLightbox(null)}
|
|
|
|
|
+ >
|
|
|
|
|
+ ✕ Close
|
|
|
|
|
+ </button>
|
|
|
|
|
+ {lightbox.imageUrl ? (
|
|
|
|
|
+ // eslint-disable-next-line @next/next/no-img-element
|
|
|
|
|
+ <img
|
|
|
|
|
+ src={lightbox.imageUrl}
|
|
|
|
|
+ alt="Capture full view"
|
|
|
|
|
+ className="max-h-[80vh] w-full object-contain rounded"
|
|
|
|
|
+ />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="flex h-96 items-center justify-center rounded bg-gray-800 text-gray-400">
|
|
|
|
|
+ No image
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <div className="mt-3 flex flex-wrap gap-4 text-sm text-white/80">
|
|
|
|
|
+ <span>📅 {formatTime(lightbox.capturedAt)}</span>
|
|
|
|
|
+ {lightbox.resolution && <span>📐 {lightbox.resolution}</span>}
|
|
|
|
|
+ {lightbox.fileSizeBytes && <span>💾 {formatSize(lightbox.fileSizeBytes)}</span>}
|
|
|
|
|
+ {lightbox.exposureMs && <span>⏱ {(lightbox.exposureMs / 1000).toFixed(3)}s</span>}
|
|
|
|
|
+ {lightbox.iso && <span>ISO {lightbox.iso}</span>}
|
|
|
|
|
+ {lightbox.aperture && <span>🎞 {lightbox.aperture}</span>}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Config editor ──────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function ConfigSection({ deviceId, config }: { deviceId: string; config: DeviceConfig | null }) {
|
|
|
|
|
+ const [values, setValues] = useState<Partial<DeviceConfig>>({})
|
|
|
|
|
+ const [saving, setSaving] = useState(false)
|
|
|
|
|
+ const [saved, setSaved] = useState(false)
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ setValues(config ?? {})
|
|
|
|
|
+ }, [config])
|
|
|
|
|
+
|
|
|
|
|
+ function handleChange(key: keyof DeviceConfig, value: string | number | boolean) {
|
|
|
|
|
+ setValues(prev => ({ ...prev, [key]: value }))
|
|
|
|
|
+ setSaved(false)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function handleSave() {
|
|
|
|
|
+ setSaving(true)
|
|
|
|
|
+ try {
|
|
|
|
|
+ await devicesApi.updateConfig(deviceId, values)
|
|
|
|
|
+ setSaved(true)
|
|
|
|
|
+ setTimeout(() => setSaved(false), 3000)
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ alert('Failed to save: ' + (e as Error).message)
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setSaving(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const hasChanges = config ? Object.keys(values).some(k => values[k as keyof DeviceConfig] !== config[k as keyof DeviceConfig]) : true
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="rounded-xl border border-gray-200 bg-white shadow-sm">
|
|
|
|
|
+ <div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
|
|
|
|
+ <h2 className="font-semibold text-gray-700">Device Configuration</h2>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={handleSave}
|
|
|
|
|
+ disabled={saving || !hasChanges}
|
|
|
|
|
+ className={`rounded-lg px-4 py-1.5 text-sm font-medium transition-colors ${
|
|
|
|
|
+ saved
|
|
|
|
|
+ ? 'bg-emerald-100 text-emerald-700'
|
|
|
|
|
+ : hasChanges
|
|
|
|
|
+ ? 'bg-blue-600 text-white hover:bg-blue-700'
|
|
|
|
|
+ : 'cursor-not-allowed bg-gray-100 text-gray-400'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {saving ? 'Saving…' : saved ? '✓ Saved' : 'Save Changes'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="divide-y divide-gray-100 px-6 py-4">
|
|
|
|
|
+ {CONFIG_FIELDS.map((field) => {
|
|
|
|
|
+ const val = values[field.key]
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={field.key} className="flex items-center justify-between py-3">
|
|
|
|
|
+ <label className="flex flex-col">
|
|
|
|
|
+ <span className="text-sm font-medium text-gray-700">{field.label}</span>
|
|
|
|
|
+ {field.unit && <span className="text-xs text-gray-400">in {field.unit}</span>}
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <div className="w-48">
|
|
|
|
|
+ {field.type === 'select' ? (
|
|
|
|
|
+ <select
|
|
|
|
|
+ value={(val as string) ?? ''}
|
|
|
|
|
+ onChange={e => handleChange(field.key, e.target.value)}
|
|
|
|
|
+ className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
|
|
|
+ >
|
|
|
|
|
+ {field.options?.map(opt => (
|
|
|
|
|
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ ) : field.type === 'number' ? (
|
|
|
|
|
+ <div className="flex items-center gap-1">
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="number"
|
|
|
|
|
+ value={(val as number) ?? ''}
|
|
|
|
|
+ min={field.min}
|
|
|
|
|
+ max={field.max}
|
|
|
|
|
+ onChange={e => handleChange(field.key, parseInt(e.target.value) || 0)}
|
|
|
|
|
+ className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
|
|
|
+ />
|
|
|
|
|
+ {field.unit && <span className="text-xs text-gray-400 w-8">{field.unit}</span>}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={(val as string) ?? ''}
|
|
|
|
|
+ onChange={e => handleChange(field.key, e.target.value)}
|
|
|
|
|
+ placeholder="—"
|
|
|
|
|
+ className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ })}
|
|
|
|
|
+
|
|
|
|
|
+ {/* WiFi-only toggle */}
|
|
|
|
|
+ <div className="flex items-center justify-between py-3">
|
|
|
|
|
+ <label className="flex flex-col">
|
|
|
|
|
+ <span className="text-sm font-medium text-gray-700">Upload on WiFi Only</span>
|
|
|
|
|
+ <span className="text-xs text-gray-400">only upload when connected to WiFi</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => handleChange('uploadOnWifiOnly', !values.uploadOnWifiOnly)}
|
|
|
|
|
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
|
|
|
+ values.uploadOnWifiOnly ? 'bg-blue-600' : 'bg-gray-300'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <span
|
|
|
|
|
+ className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
|
|
|
|
+ values.uploadOnWifiOnly ? 'translate-x-6' : 'translate-x-1'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Heartbeat timeline ─────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function HeartbeatTimeline({ heartbeats }: { heartbeats: any[] }) {
|
|
|
|
|
+ if (!heartbeats.length) return null
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="rounded-xl border border-gray-200 bg-white shadow-sm">
|
|
|
|
|
+ <div className="border-b border-gray-200 px-6 py-4">
|
|
|
|
|
+ <h2 className="font-semibold text-gray-700">Recent Heartbeats</h2>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="divide-y divide-gray-100">
|
|
|
|
|
+ {heartbeats.slice(0, 10).map((hb: any) => (
|
|
|
|
|
+ <div key={hb.id} className="flex items-center justify-between px-6 py-3 text-sm">
|
|
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
|
|
+ <div className={`h-2 w-2 rounded-full ${
|
|
|
|
|
+ hb.networkStatus === 'online' ? 'bg-emerald-400' :
|
|
|
|
|
+ hb.networkStatus === 'degraded' ? 'bg-amber-400' : 'bg-gray-300'
|
|
|
|
|
+ }`} />
|
|
|
|
|
+ <span className="text-gray-500">
|
|
|
|
|
+ {new Date(hb.heartbeatAt).toLocaleTimeString('vi-VN')}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex items-center gap-4 text-xs text-gray-400">
|
|
|
|
|
+ {hb.tempC != null && <span>🌡 {hb.tempC}°C</span>}
|
|
|
|
|
+ {hb.storageFreeGb != null && <span>💾 {hb.storageFreeGb}GB free</span>}
|
|
|
|
|
+ {hb.capturesToday != null && <span>📷 {hb.capturesToday}</span>}
|
|
|
|
|
+ {hb.networkStatus && <span>📶 {hb.networkStatus}</span>}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Main page ──────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
function DeviceDetailContent() {
|
|
function DeviceDetailContent() {
|
|
|
const router = useRouter()
|
|
const router = useRouter()
|
|
|
const { id } = router.query as { id?: string }
|
|
const { id } = router.query as { id?: string }
|
|
|
const { data: device, isLoading } = useDevice(id ?? '')
|
|
const { data: device, isLoading } = useDevice(id ?? '')
|
|
|
const [user, setUser] = useState<AuthUser | null>(null)
|
|
const [user, setUser] = useState<AuthUser | null>(null)
|
|
|
|
|
+ const [heartbeats, setHeartbeats] = useState<any[]>([])
|
|
|
|
|
|
|
|
useEffect(() => { setUser(getStoredUser()) }, [])
|
|
useEffect(() => { setUser(getStoredUser()) }, [])
|
|
|
|
|
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!id) return
|
|
|
|
|
+ devicesApi.heartbeats(id, 10)
|
|
|
|
|
+ .then(setHeartbeats)
|
|
|
|
|
+ .catch(() => {})
|
|
|
|
|
+ }, [id])
|
|
|
|
|
+
|
|
|
async function handleLogout() {
|
|
async function handleLogout() {
|
|
|
try { await authApi.logout() } catch {}
|
|
try { await authApi.logout() } catch {}
|
|
|
router.push('/login')
|
|
router.push('/login')
|
|
@@ -27,13 +443,24 @@ function DeviceDetailContent() {
|
|
|
</Head>
|
|
</Head>
|
|
|
|
|
|
|
|
<div className="min-h-screen bg-gray-50">
|
|
<div className="min-h-screen bg-gray-50">
|
|
|
- <header className="bg-white border-b border-gray-200 px-6 py-3">
|
|
|
|
|
|
|
+ <header className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-3">
|
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center justify-between">
|
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-4">
|
|
|
<Link href="/devices" className="text-sm text-gray-500 hover:text-gray-900">← Devices</Link>
|
|
<Link href="/devices" className="text-sm text-gray-500 hover:text-gray-900">← Devices</Link>
|
|
|
- <h1 className="text-lg font-bold text-gray-900">
|
|
|
|
|
- {isLoading ? '...' : device?.name ?? 'Device not found'}
|
|
|
|
|
- </h1>
|
|
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <h1 className="text-lg font-bold text-gray-900">
|
|
|
|
|
+ {isLoading ? '...' : device?.name ?? 'Device not found'}
|
|
|
|
|
+ </h1>
|
|
|
|
|
+ {device && (
|
|
|
|
|
+ <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
|
|
|
+ device.status === 'online' ? 'bg-emerald-100 text-emerald-700' :
|
|
|
|
|
+ device.status === 'offline' ? 'bg-gray-100 text-gray-600' :
|
|
|
|
|
+ 'bg-amber-100 text-amber-700'
|
|
|
|
|
+ }`}>
|
|
|
|
|
+ {device.status}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
<div className="flex items-center gap-3 border-l border-gray-200 pl-4">
|
|
<div className="flex items-center gap-3 border-l border-gray-200 pl-4">
|
|
|
<span className="text-sm text-gray-600">{user?.name ?? user?.email ?? '—'}</span>
|
|
<span className="text-sm text-gray-600">{user?.name ?? user?.email ?? '—'}</span>
|
|
@@ -47,56 +474,55 @@ function DeviceDetailContent() {
|
|
|
</div>
|
|
</div>
|
|
|
</header>
|
|
</header>
|
|
|
|
|
|
|
|
- <main className="max-w-4xl mx-auto px-6 py-8">
|
|
|
|
|
|
|
+ <main className="max-w-5xl mx-auto px-6 py-8 space-y-6">
|
|
|
{isLoading ? (
|
|
{isLoading ? (
|
|
|
- <p className="text-gray-400">Loading...</p>
|
|
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
|
|
+ <div className="h-32 animate-pulse rounded-xl bg-gray-200" />
|
|
|
|
|
+ <div className="h-64 animate-pulse rounded-xl bg-gray-200" />
|
|
|
|
|
+ </div>
|
|
|
) : !device ? (
|
|
) : !device ? (
|
|
|
<p className="text-red-400">Device not found.</p>
|
|
<p className="text-red-400">Device not found.</p>
|
|
|
) : (
|
|
) : (
|
|
|
- <div className="space-y-6">
|
|
|
|
|
- <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
|
|
|
|
- <div className="flex items-center justify-between mb-4">
|
|
|
|
|
- <h2 className="font-semibold text-gray-700">Status</h2>
|
|
|
|
|
- <span className={`rounded-full px-3 py-1 text-sm font-medium ${
|
|
|
|
|
- device.status === 'online' ? 'bg-emerald-100 text-emerald-700' :
|
|
|
|
|
- device.status === 'offline' ? 'bg-gray-100 text-gray-600' :
|
|
|
|
|
- 'bg-amber-100 text-amber-700'
|
|
|
|
|
- }`}>
|
|
|
|
|
- {device.status}
|
|
|
|
|
- </span>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <div className="grid grid-cols-2 gap-4 text-sm">
|
|
|
|
|
|
|
+ <>
|
|
|
|
|
+ {/* Device info bar */}
|
|
|
|
|
+ <div className="rounded-xl border border-gray-200 bg-white px-6 py-4 shadow-sm">
|
|
|
|
|
+ <div className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm sm:grid-cols-4">
|
|
|
<div>
|
|
<div>
|
|
|
- <span className="text-gray-500">Serial No</span>
|
|
|
|
|
|
|
+ <span className="text-gray-400">Serial No</span>
|
|
|
<p className="font-mono font-medium">{device.serialNo}</p>
|
|
<p className="font-mono font-medium">{device.serialNo}</p>
|
|
|
</div>
|
|
</div>
|
|
|
<div>
|
|
<div>
|
|
|
- <span className="text-gray-500">Last Seen</span>
|
|
|
|
|
- <p className="font-medium">
|
|
|
|
|
- {device.lastSeenAt ? new Date(device.lastSeenAt).toLocaleString() : 'Never'}
|
|
|
|
|
- </p>
|
|
|
|
|
|
|
+ <span className="text-gray-400">Firmware</span>
|
|
|
|
|
+ <p className="font-mono">{device.firmwareVersion ?? '—'}</p>
|
|
|
</div>
|
|
</div>
|
|
|
<div>
|
|
<div>
|
|
|
- <span className="text-gray-500">Firmware</span>
|
|
|
|
|
- <p className="font-mono">{device.firmwareVersion ?? '—'}</p>
|
|
|
|
|
|
|
+ <span className="text-gray-400">Last Seen</span>
|
|
|
|
|
+ <p className="font-medium">
|
|
|
|
|
+ {device.lastSeenAt
|
|
|
|
|
+ ? new Date(device.lastSeenAt).toLocaleString('vi-VN')
|
|
|
|
|
+ : 'Never'}
|
|
|
|
|
+ </p>
|
|
|
</div>
|
|
</div>
|
|
|
<div>
|
|
<div>
|
|
|
- <span className="text-gray-500">Project ID</span>
|
|
|
|
|
- <p className="font-mono text-xs truncate">{device.projectId}</p>
|
|
|
|
|
|
|
+ <span className="text-gray-400">Project</span>
|
|
|
|
|
+ <p className="font-mono text-xs truncate">{device.projectId ?? '—'}</p>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {device.config && (
|
|
|
|
|
- <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
|
|
|
|
- <h2 className="font-semibold text-gray-700 mb-4">Configuration</h2>
|
|
|
|
|
- <pre className="text-xs text-gray-600 overflow-x-auto">
|
|
|
|
|
- {JSON.stringify(device.config, null, 2)}
|
|
|
|
|
- </pre>
|
|
|
|
|
|
|
+ {/* Image gallery — full width */}
|
|
|
|
|
+ <ImageGallery deviceId={device.id} />
|
|
|
|
|
+
|
|
|
|
|
+ {/* Config + heartbeats side by side on large screens */}
|
|
|
|
|
+ <div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
|
|
|
|
|
+ <div className="lg:col-span-3">
|
|
|
|
|
+ <ConfigSection deviceId={device.id} config={device.config} />
|
|
|
</div>
|
|
</div>
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <div className="lg:col-span-2">
|
|
|
|
|
+ <HeartbeatTimeline heartbeats={heartbeats} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </>
|
|
|
)}
|
|
)}
|
|
|
</main>
|
|
</main>
|
|
|
</div>
|
|
</div>
|