@camstack/addon-admin-ui 0.1.2 → 0.1.3

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.
Files changed (127) hide show
  1. package/dist/assets/index-DjELGD4R.css +1 -0
  2. package/dist/assets/index-w55PwKyu.js +598 -0
  3. package/{index.html → dist/index.html} +3 -1
  4. package/dist/server/addon.d.ts +11 -0
  5. package/dist/server/addon.js +50 -0
  6. package/dist/server/addon.js.map +1 -0
  7. package/package.json +4 -1
  8. package/src/App.tsx +0 -71
  9. package/src/components/addons/AddonCard.tsx +0 -355
  10. package/src/components/addons/AddonUploadZone.tsx +0 -69
  11. package/src/components/addons/CapabilityBadge.tsx +0 -55
  12. package/src/components/addons/CapabilityMap.tsx +0 -133
  13. package/src/components/addons/UpdatesList.tsx +0 -108
  14. package/src/components/agents/AgentCard.tsx +0 -281
  15. package/src/components/agents/AgentLogs.tsx +0 -231
  16. package/src/components/agents/ProcessList.tsx +0 -127
  17. package/src/components/agents/ProcessTree.tsx +0 -369
  18. package/src/components/agents/TaskList.tsx +0 -68
  19. package/src/components/cameras/CameraCard.tsx +0 -60
  20. package/src/components/cameras/LiveEventsPanel.tsx +0 -91
  21. package/src/components/cameras/ProviderSection.tsx +0 -50
  22. package/src/components/cameras/StreamArea.tsx +0 -107
  23. package/src/components/cameras/tabs/AddonsTab.tsx +0 -113
  24. package/src/components/cameras/tabs/CameraEventsTab.tsx +0 -129
  25. package/src/components/cameras/tabs/PipelineTab.tsx +0 -118
  26. package/src/components/cameras/tabs/StreamsTab.tsx +0 -114
  27. package/src/components/dashboard/BlockPicker.tsx +0 -54
  28. package/src/components/dashboard/BlockWrapper.tsx +0 -97
  29. package/src/components/dashboard/DashboardGrid.tsx +0 -160
  30. package/src/components/dashboard/block-registry.ts +0 -15
  31. package/src/components/dashboard/blocks/PipelineStagesBlock.tsx +0 -39
  32. package/src/components/dashboard/blocks/StorageBlock.tsx +0 -66
  33. package/src/components/dashboard/blocks/SystemStatusBlock.tsx +0 -67
  34. package/src/components/dashboard/blocks/index.ts +0 -32
  35. package/src/components/device/DeviceHeader.tsx +0 -116
  36. package/src/components/device/FloatingPanel.tsx +0 -132
  37. package/src/components/device/FloatingPanelManager.tsx +0 -167
  38. package/src/components/device/PanelContent.tsx +0 -196
  39. package/src/components/device/QuickConfigWizard.tsx +0 -507
  40. package/src/components/device/tabs/DetectionConfigTab.tsx +0 -96
  41. package/src/components/device/tabs/EventsTab.tsx +0 -19
  42. package/src/components/device/tabs/LogsTab.tsx +0 -22
  43. package/src/components/device/tabs/OverviewTab.tsx +0 -104
  44. package/src/components/device/tabs/ProviderSettingsTab.tsx +0 -34
  45. package/src/components/device/tabs/RecordingTab.tsx +0 -47
  46. package/src/components/device/tabs/ReplTab.tsx +0 -153
  47. package/src/components/device/tabs/TrackTrailTab.tsx +0 -49
  48. package/src/components/device/tabs/ZonesTab.tsx +0 -98
  49. package/src/components/device/zone-editor/ZoneCanvas.tsx +0 -354
  50. package/src/components/device/zone-editor/ZoneForm.tsx +0 -128
  51. package/src/components/device/zone-editor/ZoneList.tsx +0 -150
  52. package/src/components/form-builder/FormBuilder.tsx +0 -135
  53. package/src/components/form-builder/FormField.tsx +0 -732
  54. package/src/components/form-builder/ModelSelector.tsx +0 -239
  55. package/src/components/integrations/AddDeviceDialog.tsx +0 -205
  56. package/src/components/integrations/CompactDeviceCard.tsx +0 -35
  57. package/src/components/integrations/DeviceCard.tsx +0 -29
  58. package/src/components/integrations/DeviceDiscoveryStep.tsx +0 -105
  59. package/src/components/integrations/DeviceGrid.tsx +0 -79
  60. package/src/components/integrations/DeviceGroupHeader.tsx +0 -17
  61. package/src/components/integrations/DiscoveredDeviceCard.tsx +0 -26
  62. package/src/components/integrations/IntegrationCard.tsx +0 -40
  63. package/src/components/integrations/IntegrationWizard.tsx +0 -172
  64. package/src/components/integrations/ProviderConfigForm.tsx +0 -89
  65. package/src/components/integrations/ProviderPicker.tsx +0 -91
  66. package/src/components/integrations/SnapshotPopover.tsx +0 -68
  67. package/src/components/metrics/AgentLoad.tsx +0 -105
  68. package/src/components/metrics/IntegrationUsage.tsx +0 -73
  69. package/src/components/metrics/PipelineStatus.tsx +0 -74
  70. package/src/components/metrics/ProcessResources.tsx +0 -123
  71. package/src/components/pipeline/PhaseSettings.tsx +0 -131
  72. package/src/components/shared/CapabilityBadges.tsx +0 -30
  73. package/src/components/shared/ProviderIcon.tsx +0 -42
  74. package/src/components/shared/StatusBadge.tsx +0 -23
  75. package/src/components/shared/WebRtcPlayer.tsx +0 -211
  76. package/src/components/timeline/EventMarker.tsx +0 -32
  77. package/src/components/timeline/TimelineBar.tsx +0 -131
  78. package/src/components/ui/ConfirmDialog.tsx +0 -115
  79. package/src/components/ui/ToastContainer.tsx +0 -92
  80. package/src/contexts/auth-context.tsx +0 -91
  81. package/src/hooks/useBackendClient.ts +0 -6
  82. package/src/hooks/useTheme.ts +0 -1
  83. package/src/i18n/en.json +0 -164
  84. package/src/i18n/index.ts +0 -29
  85. package/src/i18n/it.json +0 -164
  86. package/src/index.css +0 -63
  87. package/src/layouts/AddonPageLoader.tsx +0 -120
  88. package/src/layouts/AppLayout.tsx +0 -254
  89. package/src/layouts/ProtectedRoute.tsx +0 -25
  90. package/src/lib/addon-page-context.ts +0 -29
  91. package/src/lib/backend.ts +0 -16
  92. package/src/main.tsx +0 -21
  93. package/src/pages/AccessDenied.tsx +0 -22
  94. package/src/pages/Cameras.tsx +0 -127
  95. package/src/pages/Dashboard.tsx +0 -6
  96. package/src/pages/DeviceDetail.tsx +0 -175
  97. package/src/pages/IntegrationDetail.tsx +0 -222
  98. package/src/pages/Integrations.tsx +0 -333
  99. package/src/pages/Login.tsx +0 -106
  100. package/src/pages/Metrics.tsx +0 -18
  101. package/src/pages/PipelineConfig.tsx +0 -282
  102. package/src/pages/Showroom.tsx +0 -351
  103. package/src/pages/Timeline.tsx +0 -269
  104. package/src/pages/system/Addons.tsx +0 -396
  105. package/src/pages/system/Agents.tsx +0 -362
  106. package/src/pages/system/Logs.tsx +0 -131
  107. package/src/pages/system/Models.tsx +0 -102
  108. package/src/pages/system/Processes.tsx +0 -129
  109. package/src/pages/system/Repl.tsx +0 -148
  110. package/src/pages/system/Settings.tsx +0 -168
  111. package/src/pages/system/Users.tsx +0 -174
  112. package/src/server/addon.ts +0 -54
  113. package/src/types/config-ui.ts +0 -28
  114. package/src/types/dashboard.ts +0 -39
  115. package/tsconfig.json +0 -29
  116. package/tsconfig.server.json +0 -16
  117. package/tsup.config.ts +0 -20
  118. package/vite.config.ts +0 -68
  119. /package/{public → dist}/brand/logo-dark.svg +0 -0
  120. /package/{public → dist}/brand/logo-horizontal-dark.svg +0 -0
  121. /package/{public → dist}/brand/logo-horizontal-light.svg +0 -0
  122. /package/{public → dist}/brand/logo-light.svg +0 -0
  123. /package/{public → dist}/brand/logo-wide-dark.svg +0 -0
  124. /package/{public → dist}/brand/logo-wide-light.svg +0 -0
  125. /package/{public → dist}/favicon.svg +0 -0
  126. /package/{public → dist}/vendor/react-jsx-runtime.mjs +0 -0
  127. /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
- }
@@ -1,6 +0,0 @@
1
- import { useMemo } from 'react'
2
- import { getBackendClient } from '../lib/backend'
3
-
4
- export function useBackendClient() {
5
- return useMemo(() => getBackendClient(), [])
6
- }
@@ -1 +0,0 @@
1
- export { useThemeMode } from '@camstack/ui'