@camstack/addon-admin-ui 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-BoVZEQ1j.js +598 -0
- package/dist/assets/index-DwSc8ann.css +1 -0
- package/{index.html → dist/index.html} +3 -1
- package/dist/server/addon.d.ts +11 -0
- package/dist/server/addon.js +50 -0
- package/dist/server/addon.js.map +1 -0
- package/package.json +4 -1
- package/src/App.tsx +0 -71
- package/src/components/addons/AddonCard.tsx +0 -355
- package/src/components/addons/AddonUploadZone.tsx +0 -69
- package/src/components/addons/CapabilityBadge.tsx +0 -55
- package/src/components/addons/CapabilityMap.tsx +0 -133
- package/src/components/addons/UpdatesList.tsx +0 -108
- package/src/components/agents/AgentCard.tsx +0 -281
- package/src/components/agents/AgentLogs.tsx +0 -231
- package/src/components/agents/ProcessList.tsx +0 -127
- package/src/components/agents/ProcessTree.tsx +0 -369
- package/src/components/agents/TaskList.tsx +0 -68
- package/src/components/cameras/CameraCard.tsx +0 -60
- package/src/components/cameras/LiveEventsPanel.tsx +0 -91
- package/src/components/cameras/ProviderSection.tsx +0 -50
- package/src/components/cameras/StreamArea.tsx +0 -107
- package/src/components/cameras/tabs/AddonsTab.tsx +0 -113
- package/src/components/cameras/tabs/CameraEventsTab.tsx +0 -129
- package/src/components/cameras/tabs/PipelineTab.tsx +0 -118
- package/src/components/cameras/tabs/StreamsTab.tsx +0 -114
- package/src/components/dashboard/BlockPicker.tsx +0 -54
- package/src/components/dashboard/BlockWrapper.tsx +0 -97
- package/src/components/dashboard/DashboardGrid.tsx +0 -160
- package/src/components/dashboard/block-registry.ts +0 -15
- package/src/components/dashboard/blocks/PipelineStagesBlock.tsx +0 -39
- package/src/components/dashboard/blocks/StorageBlock.tsx +0 -66
- package/src/components/dashboard/blocks/SystemStatusBlock.tsx +0 -67
- package/src/components/dashboard/blocks/index.ts +0 -32
- package/src/components/device/DeviceHeader.tsx +0 -116
- package/src/components/device/FloatingPanel.tsx +0 -132
- package/src/components/device/FloatingPanelManager.tsx +0 -167
- package/src/components/device/PanelContent.tsx +0 -196
- package/src/components/device/QuickConfigWizard.tsx +0 -507
- package/src/components/device/tabs/DetectionConfigTab.tsx +0 -96
- package/src/components/device/tabs/EventsTab.tsx +0 -19
- package/src/components/device/tabs/LogsTab.tsx +0 -22
- package/src/components/device/tabs/OverviewTab.tsx +0 -104
- package/src/components/device/tabs/ProviderSettingsTab.tsx +0 -34
- package/src/components/device/tabs/RecordingTab.tsx +0 -47
- package/src/components/device/tabs/ReplTab.tsx +0 -153
- package/src/components/device/tabs/TrackTrailTab.tsx +0 -49
- package/src/components/device/tabs/ZonesTab.tsx +0 -98
- package/src/components/device/zone-editor/ZoneCanvas.tsx +0 -354
- package/src/components/device/zone-editor/ZoneForm.tsx +0 -128
- package/src/components/device/zone-editor/ZoneList.tsx +0 -150
- package/src/components/form-builder/FormBuilder.tsx +0 -135
- package/src/components/form-builder/FormField.tsx +0 -732
- package/src/components/form-builder/ModelSelector.tsx +0 -239
- package/src/components/integrations/AddDeviceDialog.tsx +0 -205
- package/src/components/integrations/CompactDeviceCard.tsx +0 -35
- package/src/components/integrations/DeviceCard.tsx +0 -29
- package/src/components/integrations/DeviceDiscoveryStep.tsx +0 -105
- package/src/components/integrations/DeviceGrid.tsx +0 -79
- package/src/components/integrations/DeviceGroupHeader.tsx +0 -17
- package/src/components/integrations/DiscoveredDeviceCard.tsx +0 -26
- package/src/components/integrations/IntegrationCard.tsx +0 -40
- package/src/components/integrations/IntegrationWizard.tsx +0 -172
- package/src/components/integrations/ProviderConfigForm.tsx +0 -89
- package/src/components/integrations/ProviderPicker.tsx +0 -91
- package/src/components/integrations/SnapshotPopover.tsx +0 -68
- package/src/components/metrics/AgentLoad.tsx +0 -105
- package/src/components/metrics/IntegrationUsage.tsx +0 -73
- package/src/components/metrics/PipelineStatus.tsx +0 -74
- package/src/components/metrics/ProcessResources.tsx +0 -123
- package/src/components/pipeline/PhaseSettings.tsx +0 -131
- package/src/components/shared/CapabilityBadges.tsx +0 -30
- package/src/components/shared/ProviderIcon.tsx +0 -42
- package/src/components/shared/StatusBadge.tsx +0 -23
- package/src/components/shared/WebRtcPlayer.tsx +0 -211
- package/src/components/timeline/EventMarker.tsx +0 -32
- package/src/components/timeline/TimelineBar.tsx +0 -131
- package/src/components/ui/ConfirmDialog.tsx +0 -115
- package/src/components/ui/ToastContainer.tsx +0 -92
- package/src/contexts/auth-context.tsx +0 -91
- package/src/hooks/useBackendClient.ts +0 -6
- package/src/hooks/useTheme.ts +0 -1
- package/src/i18n/en.json +0 -164
- package/src/i18n/index.ts +0 -29
- package/src/i18n/it.json +0 -164
- package/src/index.css +0 -63
- package/src/layouts/AddonPageLoader.tsx +0 -120
- package/src/layouts/AppLayout.tsx +0 -254
- package/src/layouts/ProtectedRoute.tsx +0 -25
- package/src/lib/addon-page-context.ts +0 -29
- package/src/lib/backend.ts +0 -16
- package/src/main.tsx +0 -21
- package/src/pages/AccessDenied.tsx +0 -22
- package/src/pages/Cameras.tsx +0 -127
- package/src/pages/Dashboard.tsx +0 -6
- package/src/pages/DeviceDetail.tsx +0 -175
- package/src/pages/IntegrationDetail.tsx +0 -222
- package/src/pages/Integrations.tsx +0 -333
- package/src/pages/Login.tsx +0 -106
- package/src/pages/Metrics.tsx +0 -18
- package/src/pages/PipelineConfig.tsx +0 -282
- package/src/pages/Showroom.tsx +0 -351
- package/src/pages/Timeline.tsx +0 -269
- package/src/pages/system/Addons.tsx +0 -396
- package/src/pages/system/Agents.tsx +0 -362
- package/src/pages/system/Logs.tsx +0 -131
- package/src/pages/system/Models.tsx +0 -102
- package/src/pages/system/Processes.tsx +0 -129
- package/src/pages/system/Repl.tsx +0 -148
- package/src/pages/system/Settings.tsx +0 -168
- package/src/pages/system/Users.tsx +0 -174
- package/src/server/addon.ts +0 -54
- package/src/types/config-ui.ts +0 -28
- package/src/types/dashboard.ts +0 -39
- package/tsconfig.json +0 -29
- package/tsconfig.server.json +0 -16
- package/tsup.config.ts +0 -20
- package/vite.config.ts +0 -68
- /package/{public → dist}/brand/logo-dark.svg +0 -0
- /package/{public → dist}/brand/logo-horizontal-dark.svg +0 -0
- /package/{public → dist}/brand/logo-horizontal-light.svg +0 -0
- /package/{public → dist}/brand/logo-light.svg +0 -0
- /package/{public → dist}/brand/logo-wide-dark.svg +0 -0
- /package/{public → dist}/brand/logo-wide-light.svg +0 -0
- /package/{public → dist}/favicon.svg +0 -0
- /package/{public → dist}/vendor/react-jsx-runtime.mjs +0 -0
- /package/{public → dist}/vendor/react.mjs +0 -0
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState, useCallback } from 'react'
|
|
2
|
-
import { Volume2, VolumeX, RefreshCw, WifiOff } from 'lucide-react'
|
|
3
|
-
|
|
4
|
-
export interface WebRtcPlayerProps {
|
|
5
|
-
serverUrl: string
|
|
6
|
-
streamId: string
|
|
7
|
-
autoPlay?: boolean
|
|
8
|
-
muted?: boolean
|
|
9
|
-
className?: string
|
|
10
|
-
onError?: (error: string) => void
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
type PlayerState = 'connecting' | 'playing' | 'error' | 'disconnected'
|
|
14
|
-
|
|
15
|
-
const RECONNECT_DELAY_MS = 3000
|
|
16
|
-
const MAX_RECONNECT_ATTEMPTS = 5
|
|
17
|
-
|
|
18
|
-
export function WebRtcPlayer({
|
|
19
|
-
serverUrl,
|
|
20
|
-
streamId,
|
|
21
|
-
autoPlay = true,
|
|
22
|
-
muted: initialMuted = true,
|
|
23
|
-
className = '',
|
|
24
|
-
onError,
|
|
25
|
-
}: WebRtcPlayerProps) {
|
|
26
|
-
const videoRef = useRef<HTMLVideoElement>(null)
|
|
27
|
-
const pcRef = useRef<RTCPeerConnection | null>(null)
|
|
28
|
-
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
29
|
-
const reconnectAttemptsRef = useRef(0)
|
|
30
|
-
const mountedRef = useRef(true)
|
|
31
|
-
|
|
32
|
-
const [state, setState] = useState<PlayerState>('connecting')
|
|
33
|
-
const [errorMessage, setErrorMessage] = useState<string>('')
|
|
34
|
-
const [isMuted, setIsMuted] = useState(initialMuted)
|
|
35
|
-
|
|
36
|
-
const cleanup = useCallback(() => {
|
|
37
|
-
if (reconnectTimerRef.current) {
|
|
38
|
-
clearTimeout(reconnectTimerRef.current)
|
|
39
|
-
reconnectTimerRef.current = null
|
|
40
|
-
}
|
|
41
|
-
if (pcRef.current) {
|
|
42
|
-
pcRef.current.close()
|
|
43
|
-
pcRef.current = null
|
|
44
|
-
}
|
|
45
|
-
if (videoRef.current) {
|
|
46
|
-
videoRef.current.srcObject = null
|
|
47
|
-
}
|
|
48
|
-
}, [])
|
|
49
|
-
|
|
50
|
-
const connect = useCallback(async () => {
|
|
51
|
-
if (!mountedRef.current) return
|
|
52
|
-
|
|
53
|
-
cleanup()
|
|
54
|
-
setState('connecting')
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
const pc = new RTCPeerConnection({
|
|
58
|
-
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
|
59
|
-
})
|
|
60
|
-
pcRef.current = pc
|
|
61
|
-
|
|
62
|
-
// Add a transceiver for video (receive-only)
|
|
63
|
-
pc.addTransceiver('video', { direction: 'recvonly' })
|
|
64
|
-
// Optionally receive audio
|
|
65
|
-
pc.addTransceiver('audio', { direction: 'recvonly' })
|
|
66
|
-
|
|
67
|
-
pc.ontrack = (event) => {
|
|
68
|
-
if (videoRef.current && event.streams[0]) {
|
|
69
|
-
videoRef.current.srcObject = event.streams[0]
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
pc.oniceconnectionstatechange = () => {
|
|
74
|
-
if (!mountedRef.current) return
|
|
75
|
-
const iceState = pc.iceConnectionState
|
|
76
|
-
if (iceState === 'connected' || iceState === 'completed') {
|
|
77
|
-
reconnectAttemptsRef.current = 0
|
|
78
|
-
setState('playing')
|
|
79
|
-
} else if (iceState === 'failed' || iceState === 'disconnected' || iceState === 'closed') {
|
|
80
|
-
setState('disconnected')
|
|
81
|
-
scheduleReconnect()
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Create SDP offer (WHEP)
|
|
86
|
-
const offer = await pc.createOffer()
|
|
87
|
-
await pc.setLocalDescription(offer)
|
|
88
|
-
|
|
89
|
-
// Wait for ICE gathering to complete
|
|
90
|
-
await new Promise<void>((resolve) => {
|
|
91
|
-
if (pc.iceGatheringState === 'complete') {
|
|
92
|
-
resolve()
|
|
93
|
-
return
|
|
94
|
-
}
|
|
95
|
-
const onGatheringChange = () => {
|
|
96
|
-
if (pc.iceGatheringState === 'complete') {
|
|
97
|
-
pc.removeEventListener('icegatheringstatechange', onGatheringChange)
|
|
98
|
-
resolve()
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
pc.addEventListener('icegatheringstatechange', onGatheringChange)
|
|
102
|
-
// Timeout after 5s
|
|
103
|
-
setTimeout(resolve, 5000)
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
if (!mountedRef.current) return
|
|
107
|
-
|
|
108
|
-
const whepUrl = `${serverUrl}/api/webrtc?src=${encodeURIComponent(streamId)}`
|
|
109
|
-
const response = await fetch(whepUrl, {
|
|
110
|
-
method: 'POST',
|
|
111
|
-
headers: { 'Content-Type': 'application/sdp' },
|
|
112
|
-
body: pc.localDescription?.sdp ?? '',
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
if (!response.ok) {
|
|
116
|
-
throw new Error(`WHEP server returned ${response.status}: ${response.statusText}`)
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const answerSdp = await response.text()
|
|
120
|
-
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp })
|
|
121
|
-
} catch (err) {
|
|
122
|
-
if (!mountedRef.current) return
|
|
123
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
124
|
-
setErrorMessage(msg)
|
|
125
|
-
setState('error')
|
|
126
|
-
onError?.(msg)
|
|
127
|
-
scheduleReconnect()
|
|
128
|
-
}
|
|
129
|
-
}, [serverUrl, streamId, cleanup, onError])
|
|
130
|
-
|
|
131
|
-
const scheduleReconnect = useCallback(() => {
|
|
132
|
-
if (!mountedRef.current) return
|
|
133
|
-
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) return
|
|
134
|
-
reconnectAttemptsRef.current += 1
|
|
135
|
-
reconnectTimerRef.current = setTimeout(() => {
|
|
136
|
-
if (mountedRef.current) connect()
|
|
137
|
-
}, RECONNECT_DELAY_MS)
|
|
138
|
-
}, [connect])
|
|
139
|
-
|
|
140
|
-
useEffect(() => {
|
|
141
|
-
mountedRef.current = true
|
|
142
|
-
reconnectAttemptsRef.current = 0
|
|
143
|
-
connect()
|
|
144
|
-
return () => {
|
|
145
|
-
mountedRef.current = false
|
|
146
|
-
cleanup()
|
|
147
|
-
}
|
|
148
|
-
}, [serverUrl, streamId]) // Re-connect if serverUrl or streamId changes
|
|
149
|
-
|
|
150
|
-
const handleManualReconnect = () => {
|
|
151
|
-
reconnectAttemptsRef.current = 0
|
|
152
|
-
connect()
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const toggleMute = () => {
|
|
156
|
-
if (videoRef.current) {
|
|
157
|
-
videoRef.current.muted = !videoRef.current.muted
|
|
158
|
-
setIsMuted(videoRef.current.muted)
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
return (
|
|
163
|
-
<div className={`relative bg-black overflow-hidden rounded ${className}`}>
|
|
164
|
-
<video
|
|
165
|
-
ref={videoRef}
|
|
166
|
-
autoPlay={autoPlay}
|
|
167
|
-
muted={isMuted}
|
|
168
|
-
playsInline
|
|
169
|
-
className="w-full h-full object-contain"
|
|
170
|
-
/>
|
|
171
|
-
|
|
172
|
-
{/* Overlay: connecting */}
|
|
173
|
-
{state === 'connecting' && (
|
|
174
|
-
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/70 gap-2">
|
|
175
|
-
<RefreshCw className="h-6 w-6 text-foreground-subtle animate-spin" />
|
|
176
|
-
<span className="text-xs text-foreground-subtle">Connecting…</span>
|
|
177
|
-
</div>
|
|
178
|
-
)}
|
|
179
|
-
|
|
180
|
-
{/* Overlay: error / disconnected */}
|
|
181
|
-
{(state === 'error' || state === 'disconnected') && (
|
|
182
|
-
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/70 gap-2">
|
|
183
|
-
<WifiOff className="h-6 w-6 text-danger" />
|
|
184
|
-
<span className="text-xs text-danger text-center px-4">
|
|
185
|
-
{state === 'error' ? errorMessage || 'Connection failed' : 'Stream disconnected'}
|
|
186
|
-
</span>
|
|
187
|
-
<button
|
|
188
|
-
onClick={handleManualReconnect}
|
|
189
|
-
className="mt-1 inline-flex items-center gap-1.5 rounded px-3 py-1 text-xs bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
190
|
-
>
|
|
191
|
-
<RefreshCw className="h-3 w-3" />
|
|
192
|
-
Reconnect
|
|
193
|
-
</button>
|
|
194
|
-
</div>
|
|
195
|
-
)}
|
|
196
|
-
|
|
197
|
-
{/* Controls: mute button (only visible while playing) */}
|
|
198
|
-
{state === 'playing' && (
|
|
199
|
-
<div className="absolute bottom-2 right-2">
|
|
200
|
-
<button
|
|
201
|
-
onClick={toggleMute}
|
|
202
|
-
title={isMuted ? 'Unmute' : 'Mute'}
|
|
203
|
-
className="rounded-full p-1.5 bg-black/50 text-white hover:bg-black/70 transition-colors"
|
|
204
|
-
>
|
|
205
|
-
{isMuted ? <VolumeX className="h-3.5 w-3.5" /> : <Volume2 className="h-3.5 w-3.5" />}
|
|
206
|
-
</button>
|
|
207
|
-
</div>
|
|
208
|
-
)}
|
|
209
|
-
</div>
|
|
210
|
-
)
|
|
211
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
interface EventMarkerProps {
|
|
2
|
-
readonly label: string
|
|
3
|
-
readonly color: string
|
|
4
|
-
readonly title?: string
|
|
5
|
-
readonly style?: React.CSSProperties
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export function EventMarker({ label, color, title, style }: EventMarkerProps) {
|
|
9
|
-
return (
|
|
10
|
-
<div
|
|
11
|
-
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 z-10 group cursor-pointer"
|
|
12
|
-
style={style}
|
|
13
|
-
title={title}
|
|
14
|
-
>
|
|
15
|
-
<div
|
|
16
|
-
className="h-2.5 w-2.5 rounded-full border-2 border-background shadow-sm transition-transform group-hover:scale-150"
|
|
17
|
-
style={{ backgroundColor: color }}
|
|
18
|
-
/>
|
|
19
|
-
{title && (
|
|
20
|
-
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 hidden group-hover:block z-20 pointer-events-none">
|
|
21
|
-
<div className="bg-surface border border-border rounded px-2 py-1 text-[10px] text-foreground whitespace-nowrap shadow-lg">
|
|
22
|
-
<span
|
|
23
|
-
className="inline-block h-1.5 w-1.5 rounded-full mr-1 align-middle"
|
|
24
|
-
style={{ backgroundColor: color }}
|
|
25
|
-
/>
|
|
26
|
-
{label}
|
|
27
|
-
</div>
|
|
28
|
-
</div>
|
|
29
|
-
)}
|
|
30
|
-
</div>
|
|
31
|
-
)
|
|
32
|
-
}
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import { useRef, useCallback } from 'react'
|
|
2
|
-
import { EventMarker } from './EventMarker'
|
|
3
|
-
|
|
4
|
-
export interface RecordingSegment {
|
|
5
|
-
readonly startMs: number
|
|
6
|
-
readonly endMs: number
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export interface TimelineEvent {
|
|
10
|
-
readonly id: string
|
|
11
|
-
readonly timestampMs: number
|
|
12
|
-
readonly label: string
|
|
13
|
-
readonly color: string
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
interface TimelineBarProps {
|
|
17
|
-
readonly startMs: number
|
|
18
|
-
readonly endMs: number
|
|
19
|
-
readonly segments: readonly RecordingSegment[]
|
|
20
|
-
readonly events: readonly TimelineEvent[]
|
|
21
|
-
readonly playheadMs: number
|
|
22
|
-
readonly onSeek: (ms: number) => void
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const HOUR_MS = 3_600_000
|
|
26
|
-
const LABEL_HOURS = [0, 3, 6, 9, 12, 15, 18, 21, 24]
|
|
27
|
-
|
|
28
|
-
function padTwo(n: number): string {
|
|
29
|
-
return String(n).padStart(2, '0')
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function formatHour(ms: number): string {
|
|
33
|
-
const d = new Date(ms)
|
|
34
|
-
return `${padTwo(d.getHours())}:00`
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function TimelineBar({
|
|
38
|
-
startMs,
|
|
39
|
-
endMs,
|
|
40
|
-
segments,
|
|
41
|
-
events,
|
|
42
|
-
playheadMs,
|
|
43
|
-
onSeek,
|
|
44
|
-
}: TimelineBarProps) {
|
|
45
|
-
const trackRef = useRef<HTMLDivElement>(null)
|
|
46
|
-
const durationMs = endMs - startMs
|
|
47
|
-
|
|
48
|
-
const msToPercent = useCallback(
|
|
49
|
-
(ms: number) => Math.max(0, Math.min(100, ((ms - startMs) / durationMs) * 100)),
|
|
50
|
-
[startMs, durationMs],
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
function handlePointerEvent(e: React.PointerEvent<HTMLDivElement>) {
|
|
54
|
-
if (!trackRef.current) return
|
|
55
|
-
const rect = trackRef.current.getBoundingClientRect()
|
|
56
|
-
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
|
57
|
-
onSeek(Math.round(startMs + ratio * durationMs))
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Build hour tick labels within the window
|
|
61
|
-
const tickLabels: Array<{ percent: number; label: string }> = []
|
|
62
|
-
const windowStartHour = Math.floor(startMs / HOUR_MS)
|
|
63
|
-
const windowEndHour = Math.ceil(endMs / HOUR_MS)
|
|
64
|
-
for (let h = windowStartHour; h <= windowEndHour; h++) {
|
|
65
|
-
const tickMs = h * HOUR_MS
|
|
66
|
-
if (tickMs < startMs || tickMs > endMs) continue
|
|
67
|
-
tickLabels.push({
|
|
68
|
-
percent: msToPercent(tickMs),
|
|
69
|
-
label: formatHour(tickMs),
|
|
70
|
-
})
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return (
|
|
74
|
-
<div className="select-none">
|
|
75
|
-
{/* Hour labels */}
|
|
76
|
-
<div className="relative h-5 mb-1">
|
|
77
|
-
{tickLabels.map(({ percent, label }) => (
|
|
78
|
-
<span
|
|
79
|
-
key={label}
|
|
80
|
-
className="absolute -translate-x-1/2 text-[10px] text-foreground-subtle"
|
|
81
|
-
style={{ left: `${percent}%` }}
|
|
82
|
-
>
|
|
83
|
-
{label}
|
|
84
|
-
</span>
|
|
85
|
-
))}
|
|
86
|
-
</div>
|
|
87
|
-
|
|
88
|
-
{/* Track */}
|
|
89
|
-
<div
|
|
90
|
-
ref={trackRef}
|
|
91
|
-
className="relative h-8 rounded bg-surface-hover cursor-pointer overflow-hidden border border-border"
|
|
92
|
-
onClick={handlePointerEvent}
|
|
93
|
-
onPointerMove={(e) => {
|
|
94
|
-
if (e.buttons === 1) handlePointerEvent(e)
|
|
95
|
-
}}
|
|
96
|
-
>
|
|
97
|
-
{/* Recording segments */}
|
|
98
|
-
{segments.map((seg, idx) => {
|
|
99
|
-
const left = msToPercent(seg.startMs)
|
|
100
|
-
const width = msToPercent(seg.endMs) - left
|
|
101
|
-
return (
|
|
102
|
-
<div
|
|
103
|
-
key={idx}
|
|
104
|
-
className="absolute top-1 bottom-1 rounded-sm bg-success/60"
|
|
105
|
-
style={{ left: `${left}%`, width: `${Math.max(width, 0.1)}%` }}
|
|
106
|
-
/>
|
|
107
|
-
)
|
|
108
|
-
})}
|
|
109
|
-
|
|
110
|
-
{/* Event markers */}
|
|
111
|
-
{events.map((ev) => (
|
|
112
|
-
<EventMarker
|
|
113
|
-
key={ev.id}
|
|
114
|
-
label={ev.label}
|
|
115
|
-
color={ev.color}
|
|
116
|
-
title={`${ev.label} — ${new Date(ev.timestampMs).toLocaleTimeString()}`}
|
|
117
|
-
style={{ left: `${msToPercent(ev.timestampMs)}%` }}
|
|
118
|
-
/>
|
|
119
|
-
))}
|
|
120
|
-
|
|
121
|
-
{/* Playhead */}
|
|
122
|
-
<div
|
|
123
|
-
className="absolute top-0 bottom-0 w-0.5 bg-danger z-20 pointer-events-none"
|
|
124
|
-
style={{ left: `${msToPercent(playheadMs)}%` }}
|
|
125
|
-
>
|
|
126
|
-
<div className="absolute -top-0.5 left-1/2 -translate-x-1/2 w-2.5 h-2.5 bg-danger rotate-45" />
|
|
127
|
-
</div>
|
|
128
|
-
</div>
|
|
129
|
-
</div>
|
|
130
|
-
)
|
|
131
|
-
}
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import { useState, useCallback, createContext, useContext, type ReactNode } from 'react'
|
|
2
|
-
|
|
3
|
-
// ---------------------------------------------------------------------------
|
|
4
|
-
// Dialog state
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
|
|
7
|
-
interface ConfirmOptions {
|
|
8
|
-
title: string
|
|
9
|
-
message: string
|
|
10
|
-
confirmLabel?: string
|
|
11
|
-
cancelLabel?: string
|
|
12
|
-
variant?: 'danger' | 'warning' | 'default'
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface ConfirmContextValue {
|
|
16
|
-
confirm: (options: ConfirmOptions) => Promise<boolean>
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const ConfirmContext = createContext<ConfirmContextValue | null>(null)
|
|
20
|
-
|
|
21
|
-
export function useConfirm(): (options: ConfirmOptions) => Promise<boolean> {
|
|
22
|
-
const ctx = useContext(ConfirmContext)
|
|
23
|
-
if (!ctx) throw new Error('useConfirm must be used within ConfirmDialogProvider')
|
|
24
|
-
return ctx.confirm
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// Provider
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
|
|
31
|
-
interface DialogState extends ConfirmOptions {
|
|
32
|
-
resolve: (value: boolean) => void
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
|
|
36
|
-
const [dialog, setDialog] = useState<DialogState | null>(null)
|
|
37
|
-
|
|
38
|
-
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
|
|
39
|
-
return new Promise<boolean>((resolve) => {
|
|
40
|
-
setDialog({ ...options, resolve })
|
|
41
|
-
})
|
|
42
|
-
}, [])
|
|
43
|
-
|
|
44
|
-
const handleConfirm = () => {
|
|
45
|
-
dialog?.resolve(true)
|
|
46
|
-
setDialog(null)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const handleCancel = () => {
|
|
50
|
-
dialog?.resolve(false)
|
|
51
|
-
setDialog(null)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const variantStyles = {
|
|
55
|
-
danger: {
|
|
56
|
-
icon: 'text-red-400',
|
|
57
|
-
button: 'bg-red-500 hover:bg-red-600 text-white',
|
|
58
|
-
},
|
|
59
|
-
warning: {
|
|
60
|
-
icon: 'text-orange-400',
|
|
61
|
-
button: 'bg-orange-500 hover:bg-orange-600 text-white',
|
|
62
|
-
},
|
|
63
|
-
default: {
|
|
64
|
-
icon: 'text-primary',
|
|
65
|
-
button: 'bg-primary hover:bg-primary/90 text-white',
|
|
66
|
-
},
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const style = dialog ? variantStyles[dialog.variant ?? 'default'] : variantStyles.default
|
|
70
|
-
|
|
71
|
-
return (
|
|
72
|
-
<ConfirmContext.Provider value={{ confirm }}>
|
|
73
|
-
{children}
|
|
74
|
-
|
|
75
|
-
{/* Backdrop + Dialog */}
|
|
76
|
-
{dialog && (
|
|
77
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
78
|
-
{/* Backdrop */}
|
|
79
|
-
<div
|
|
80
|
-
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
|
81
|
-
onClick={handleCancel}
|
|
82
|
-
/>
|
|
83
|
-
|
|
84
|
-
{/* Dialog */}
|
|
85
|
-
<div
|
|
86
|
-
className="relative z-10 w-full max-w-sm mx-4 rounded-xl border border-border bg-surface shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150"
|
|
87
|
-
onClick={(e) => e.stopPropagation()}
|
|
88
|
-
>
|
|
89
|
-
<div className="px-5 pt-5 pb-4">
|
|
90
|
-
<h3 className="text-sm font-semibold text-foreground">{dialog.title}</h3>
|
|
91
|
-
<p className="mt-2 text-xs text-foreground-subtle leading-relaxed">{dialog.message}</p>
|
|
92
|
-
</div>
|
|
93
|
-
|
|
94
|
-
<div className="flex justify-end gap-2 px-5 py-3 border-t border-border bg-surface-hover/30">
|
|
95
|
-
<button
|
|
96
|
-
type="button"
|
|
97
|
-
onClick={handleCancel}
|
|
98
|
-
className="px-3 py-1.5 text-xs font-medium rounded-md border border-border text-foreground-subtle hover:text-foreground hover:bg-surface-hover transition-colors"
|
|
99
|
-
>
|
|
100
|
-
{dialog.cancelLabel ?? 'Cancel'}
|
|
101
|
-
</button>
|
|
102
|
-
<button
|
|
103
|
-
type="button"
|
|
104
|
-
onClick={handleConfirm}
|
|
105
|
-
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${style.button}`}
|
|
106
|
-
>
|
|
107
|
-
{dialog.confirmLabel ?? 'Confirm'}
|
|
108
|
-
</button>
|
|
109
|
-
</div>
|
|
110
|
-
</div>
|
|
111
|
-
</div>
|
|
112
|
-
)}
|
|
113
|
-
</ConfirmContext.Provider>
|
|
114
|
-
)
|
|
115
|
-
}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
-
import { X, Info, AlertTriangle, AlertCircle } from 'lucide-react'
|
|
3
|
-
import { useBackendClient } from '../../hooks/useBackendClient'
|
|
4
|
-
|
|
5
|
-
interface ToastItem {
|
|
6
|
-
id: string
|
|
7
|
-
title: string
|
|
8
|
-
message: string
|
|
9
|
-
severity: 'info' | 'warning' | 'critical'
|
|
10
|
-
duration?: number
|
|
11
|
-
timestamp: number
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const SEVERITY_STYLES: Record<string, { bg: string; border: string; icon: typeof Info }> = {
|
|
15
|
-
info: { bg: 'bg-blue-500/10', border: 'border-blue-500/30', icon: Info },
|
|
16
|
-
warning: { bg: 'bg-orange-500/10', border: 'border-orange-500/30', icon: AlertTriangle },
|
|
17
|
-
critical: { bg: 'bg-red-500/10', border: 'border-red-500/30', icon: AlertCircle },
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function Toast({ toast, onDismiss }: { toast: ToastItem; onDismiss: (id: string) => void }) {
|
|
21
|
-
const style = SEVERITY_STYLES[toast.severity] ?? SEVERITY_STYLES.info
|
|
22
|
-
const Icon = style.icon
|
|
23
|
-
|
|
24
|
-
useEffect(() => {
|
|
25
|
-
const duration = toast.duration ?? 5000
|
|
26
|
-
if (duration <= 0) return
|
|
27
|
-
const timer = setTimeout(() => onDismiss(toast.id), duration)
|
|
28
|
-
return () => clearTimeout(timer)
|
|
29
|
-
}, [toast.id, toast.duration, onDismiss])
|
|
30
|
-
|
|
31
|
-
return (
|
|
32
|
-
<div
|
|
33
|
-
className={`flex items-start gap-3 px-4 py-3 rounded-lg border ${style.border} ${style.bg} shadow-lg backdrop-blur-sm animate-in slide-in-from-right-5 fade-in duration-200`}
|
|
34
|
-
>
|
|
35
|
-
<Icon className="h-4 w-4 shrink-0 mt-0.5 text-foreground-subtle" />
|
|
36
|
-
<div className="flex-1 min-w-0">
|
|
37
|
-
<div className="text-xs font-semibold text-foreground">{toast.title}</div>
|
|
38
|
-
<div className="text-[11px] text-foreground-subtle mt-0.5">{toast.message}</div>
|
|
39
|
-
</div>
|
|
40
|
-
<button
|
|
41
|
-
type="button"
|
|
42
|
-
onClick={() => onDismiss(toast.id)}
|
|
43
|
-
className="shrink-0 p-0.5 rounded hover:bg-surface-hover transition-colors"
|
|
44
|
-
>
|
|
45
|
-
<X className="h-3.5 w-3.5 text-foreground-subtle" />
|
|
46
|
-
</button>
|
|
47
|
-
</div>
|
|
48
|
-
)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function ToastContainer() {
|
|
52
|
-
const [toasts, setToasts] = useState<ToastItem[]>([])
|
|
53
|
-
const client = useBackendClient()
|
|
54
|
-
|
|
55
|
-
const dismiss = useCallback((id: string) => {
|
|
56
|
-
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
57
|
-
}, [])
|
|
58
|
-
|
|
59
|
-
useEffect(() => {
|
|
60
|
-
let unsubscribe: (() => void) | null = null
|
|
61
|
-
|
|
62
|
-
try {
|
|
63
|
-
// Subscribe to toast events via tRPC WebSocket subscription
|
|
64
|
-
const sub = client.trpc.toast?.onToast.subscribe(undefined, {
|
|
65
|
-
onData: (toast: any) => {
|
|
66
|
-
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
67
|
-
setToasts((prev) => [...prev, { ...toast, id, timestamp: Date.now() }])
|
|
68
|
-
},
|
|
69
|
-
onError: (err: unknown) => {
|
|
70
|
-
console.warn('[ToastContainer] Subscription error:', err)
|
|
71
|
-
},
|
|
72
|
-
})
|
|
73
|
-
unsubscribe = () => sub?.unsubscribe()
|
|
74
|
-
} catch {
|
|
75
|
-
// WS not available (e.g., server not ready)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return () => unsubscribe?.()
|
|
79
|
-
}, [client])
|
|
80
|
-
|
|
81
|
-
if (toasts.length === 0) return null
|
|
82
|
-
|
|
83
|
-
return (
|
|
84
|
-
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
|
85
|
-
{toasts.map((toast) => (
|
|
86
|
-
<div key={toast.id} className="pointer-events-auto">
|
|
87
|
-
<Toast toast={toast} onDismiss={dismiss} />
|
|
88
|
-
</div>
|
|
89
|
-
))}
|
|
90
|
-
</div>
|
|
91
|
-
)
|
|
92
|
-
}
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
|
2
|
-
import type { ReactNode } from 'react'
|
|
3
|
-
import { getBackendClient, resetBackendClient } from '../lib/backend'
|
|
4
|
-
|
|
5
|
-
interface AuthUser {
|
|
6
|
-
username: string
|
|
7
|
-
role: string
|
|
8
|
-
exp: number
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface AuthContextValue {
|
|
12
|
-
user: AuthUser | null
|
|
13
|
-
loading: boolean
|
|
14
|
-
login: (username: string, password: string) => Promise<void>
|
|
15
|
-
logout: () => void
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const AuthContext = createContext<AuthContextValue | null>(null)
|
|
19
|
-
|
|
20
|
-
function decodeToken(token: string): AuthUser | null {
|
|
21
|
-
try {
|
|
22
|
-
const payload = JSON.parse(atob(token.split('.')[1]!))
|
|
23
|
-
return { username: payload.username ?? 'unknown', role: payload.role ?? 'viewer', exp: payload.exp ?? 0 }
|
|
24
|
-
} catch {
|
|
25
|
-
return null
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
30
|
-
const [user, setUser] = useState<AuthUser | null>(null)
|
|
31
|
-
const [loading, setLoading] = useState(true)
|
|
32
|
-
|
|
33
|
-
useEffect(() => {
|
|
34
|
-
const token = localStorage.getItem('camstack_admin_token')
|
|
35
|
-
if (!token) {
|
|
36
|
-
setLoading(false)
|
|
37
|
-
return
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const decoded = decodeToken(token)
|
|
41
|
-
if (!decoded || decoded.exp * 1000 < Date.now()) {
|
|
42
|
-
localStorage.removeItem('camstack_admin_token')
|
|
43
|
-
setLoading(false)
|
|
44
|
-
return
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const client = getBackendClient()
|
|
48
|
-
client.getSystemInfo()
|
|
49
|
-
.then(() => setUser(decoded))
|
|
50
|
-
.catch((err: unknown) => {
|
|
51
|
-
// Only invalidate token for explicit auth rejection from the server.
|
|
52
|
-
// tRPC returns { data: { code: 'UNAUTHORIZED' } } when the token is invalid.
|
|
53
|
-
const code = (err as { data?: { code?: string } })?.data?.code
|
|
54
|
-
if (code === 'UNAUTHORIZED' || code === 'FORBIDDEN') {
|
|
55
|
-
localStorage.removeItem('camstack_admin_token')
|
|
56
|
-
resetBackendClient()
|
|
57
|
-
} else {
|
|
58
|
-
// Network error, server not ready, timeout, etc. — keep the token
|
|
59
|
-
setUser(decoded)
|
|
60
|
-
}
|
|
61
|
-
})
|
|
62
|
-
.finally(() => setLoading(false))
|
|
63
|
-
}, [])
|
|
64
|
-
|
|
65
|
-
const login = useCallback(async (username: string, password: string) => {
|
|
66
|
-
const client = getBackendClient()
|
|
67
|
-
const result = await client.login(username, password) as { token: string }
|
|
68
|
-
const token = result.token
|
|
69
|
-
localStorage.setItem('camstack_admin_token', token)
|
|
70
|
-
client.setToken(token)
|
|
71
|
-
setUser(decodeToken(token))
|
|
72
|
-
}, [])
|
|
73
|
-
|
|
74
|
-
const logout = useCallback(() => {
|
|
75
|
-
localStorage.removeItem('camstack_admin_token')
|
|
76
|
-
resetBackendClient()
|
|
77
|
-
setUser(null)
|
|
78
|
-
}, [])
|
|
79
|
-
|
|
80
|
-
return (
|
|
81
|
-
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
|
82
|
-
{children}
|
|
83
|
-
</AuthContext.Provider>
|
|
84
|
-
)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function useAuth(): AuthContextValue {
|
|
88
|
-
const ctx = useContext(AuthContext)
|
|
89
|
-
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
|
90
|
-
return ctx
|
|
91
|
-
}
|
package/src/hooks/useTheme.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { useThemeMode } from '@camstack/ui'
|