een-api-toolkit 0.3.15 → 0.3.20

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 (41) hide show
  1. package/CHANGELOG.md +45 -6
  2. package/README.md +1 -0
  3. package/dist/index.cjs +3 -1
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.ts +585 -0
  6. package/dist/index.js +485 -261
  7. package/dist/index.js.map +1 -1
  8. package/docs/AI-CONTEXT.md +144 -1
  9. package/examples/vue-alerts-metrics/e2e/auth.spec.ts +8 -1
  10. package/examples/vue-alerts-metrics/package-lock.json +8 -1
  11. package/examples/vue-alerts-metrics/package.json +4 -3
  12. package/examples/vue-alerts-metrics/src/components/AlertsList.vue +567 -16
  13. package/examples/vue-alerts-metrics/src/components/CameraSelector.vue +16 -6
  14. package/examples/vue-alerts-metrics/src/components/MetricsChart.vue +23 -9
  15. package/examples/vue-alerts-metrics/src/components/NotificationsList.vue +579 -17
  16. package/examples/vue-alerts-metrics/src/components/TimeRangeSelector.vue +197 -12
  17. package/examples/vue-alerts-metrics/src/composables/useHlsPlayer.ts +285 -0
  18. package/examples/vue-alerts-metrics/src/views/Dashboard.vue +31 -9
  19. package/examples/vue-alerts-metrics/src/views/Home.vue +56 -7
  20. package/examples/vue-event-subscriptions/.env.example +15 -0
  21. package/examples/vue-event-subscriptions/README.md +103 -0
  22. package/examples/vue-event-subscriptions/e2e/app.spec.ts +71 -0
  23. package/examples/vue-event-subscriptions/e2e/auth.spec.ts +290 -0
  24. package/examples/vue-event-subscriptions/index.html +13 -0
  25. package/examples/vue-event-subscriptions/package-lock.json +1719 -0
  26. package/examples/vue-event-subscriptions/package.json +28 -0
  27. package/examples/vue-event-subscriptions/playwright.config.ts +47 -0
  28. package/examples/vue-event-subscriptions/src/App.vue +233 -0
  29. package/examples/vue-event-subscriptions/src/main.ts +25 -0
  30. package/examples/vue-event-subscriptions/src/router/index.ts +68 -0
  31. package/examples/vue-event-subscriptions/src/views/Callback.vue +76 -0
  32. package/examples/vue-event-subscriptions/src/views/Home.vue +192 -0
  33. package/examples/vue-event-subscriptions/src/views/LiveEvents.vue +640 -0
  34. package/examples/vue-event-subscriptions/src/views/Login.vue +33 -0
  35. package/examples/vue-event-subscriptions/src/views/Logout.vue +59 -0
  36. package/examples/vue-event-subscriptions/src/views/Subscriptions.vue +402 -0
  37. package/examples/vue-event-subscriptions/src/vite-env.d.ts +12 -0
  38. package/examples/vue-event-subscriptions/tsconfig.json +21 -0
  39. package/examples/vue-event-subscriptions/tsconfig.node.json +10 -0
  40. package/examples/vue-event-subscriptions/vite.config.ts +12 -0
  41. package/package.json +1 -1
@@ -1,9 +1,15 @@
1
1
  <script setup lang="ts">
2
- import { ref, watch } from 'vue'
3
- import { listAlerts, listAlertTypes, type Camera, type Alert, type AlertType, type EenError } from 'een-api-toolkit'
2
+ import { ref, watch, computed, onUnmounted } from 'vue'
3
+ import { listAlerts, listAlertTypes, getAlert, getRecordedImage, type Camera, type Alert, type AlertType, type EenError } from 'een-api-toolkit'
4
+ import { useHlsPlayer } from '../composables/useHlsPlayer'
5
+
6
+ // Initialize HLS player composable
7
+ // Note: videoRef is not destructured - accessed as hlsPlayer.videoRef in template
8
+ const hlsPlayer = useHlsPlayer()
9
+ const { videoUrl, videoError, loadingVideo, loadVideo, resetVideo } = hlsPlayer
4
10
 
5
11
  const props = defineProps<{
6
- camera: Camera
12
+ camera: Camera | null
7
13
  timeRange: string
8
14
  }>()
9
15
 
@@ -16,6 +22,62 @@ const loadingMore = ref(false)
16
22
  const error = ref<EenError | null>(null)
17
23
  const nextPageToken = ref<string | undefined>(undefined)
18
24
 
25
+ // Modal state
26
+ const showModal = ref(false)
27
+ const selectedAlert = ref<Alert | null>(null)
28
+ const loadingDetails = ref(false)
29
+ const detailsError = ref<EenError | null>(null)
30
+
31
+ // Lightbox state
32
+ const showLightbox = ref(false)
33
+ const lightboxImageUrl = ref<string | null>(null)
34
+ const loadingImage = ref(false)
35
+ const imageError = ref<string | null>(null)
36
+
37
+ // Video state (showVideo controls lightbox mode, rest from composable)
38
+ const showVideo = ref(false)
39
+
40
+ // Check if alert has an httpsUrl in its data
41
+ // The data is an array, look for type "een.fullFrameImageUrl.v1"
42
+ const alertImageUrl = computed(() => {
43
+ if (!selectedAlert.value?.data) return null
44
+ const data = selectedAlert.value.data
45
+
46
+ // Alert data is an array
47
+ if (!Array.isArray(data)) return null
48
+
49
+ // Find the object with type "een.fullFrameImageUrl.v1"
50
+ const imageItem = data.find(
51
+ (item: unknown) =>
52
+ item &&
53
+ typeof item === 'object' &&
54
+ (item as Record<string, unknown>).type === 'een.fullFrameImageUrl.v1'
55
+ ) as Record<string, unknown> | undefined
56
+
57
+ if (imageItem && typeof imageItem.httpsUrl === 'string') {
58
+ return imageItem.httpsUrl
59
+ }
60
+
61
+ return null
62
+ })
63
+
64
+ // Parse the image URL to extract parameters for getRecordedImage
65
+ function parseImageUrlParams(url: string): { deviceId: string; type: 'preview' | 'main'; timestamp: string } | null {
66
+ try {
67
+ const urlObj = new URL(url)
68
+ const deviceId = urlObj.searchParams.get('deviceId')
69
+ const type = urlObj.searchParams.get('type') as 'preview' | 'main'
70
+ const timestamp = urlObj.searchParams.get('timestamp__gte')
71
+
72
+ if (deviceId && type && timestamp) {
73
+ return { deviceId, type, timestamp }
74
+ }
75
+ return null
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+
19
81
  function getTimeRangeMs(range: string): number {
20
82
  switch (range) {
21
83
  case '1h': return 60 * 60 * 1000
@@ -57,8 +119,6 @@ async function fetchAlertTypes() {
57
119
  }
58
120
 
59
121
  async function fetchAlerts(append = false) {
60
- if (!props.camera?.id) return
61
-
62
122
  if (append) {
63
123
  loadingMore.value = true
64
124
  } else {
@@ -68,20 +128,27 @@ async function fetchAlerts(append = false) {
68
128
  }
69
129
  error.value = null
70
130
 
71
- const now = new Date()
72
- const rangeMs = getTimeRangeMs(props.timeRange)
73
- const startTime = new Date(now.getTime() - rangeMs)
74
-
75
131
  const params: Parameters<typeof listAlerts>[0] = {
76
- actorId__in: [props.camera.id],
77
- timestamp__gte: startTime.toISOString(),
78
- timestamp__lte: now.toISOString(),
79
132
  pageSize: 20,
80
133
  pageToken: append ? nextPageToken.value : undefined,
81
134
  include: ['description'],
82
135
  sort: ['-timestamp']
83
136
  }
84
137
 
138
+ // Only apply time filter if a specific time range is selected (not 'none')
139
+ if (props.timeRange !== 'none') {
140
+ const now = new Date()
141
+ const rangeMs = getTimeRangeMs(props.timeRange)
142
+ const startTime = new Date(now.getTime() - rangeMs)
143
+ params.timestamp__gte = startTime.toISOString()
144
+ params.timestamp__lte = now.toISOString()
145
+ }
146
+
147
+ // Only filter by camera if a specific camera is selected
148
+ if (props.camera?.id) {
149
+ params.actorId__in = [props.camera.id]
150
+ }
151
+
85
152
  // Add alert type filter if selected
86
153
  if (selectedAlertType.value) {
87
154
  params.alertType__in = [selectedAlertType.value]
@@ -126,17 +193,131 @@ function getPriorityClass(priority?: number): string {
126
193
  return 'priority-low'
127
194
  }
128
195
 
196
+ async function handleAlertClick(alert: Alert) {
197
+ showModal.value = true
198
+ loadingDetails.value = true
199
+ detailsError.value = null
200
+ selectedAlert.value = null
201
+
202
+ const result = await getAlert(alert.id, {
203
+ include: ['data', 'actions', 'description', 'dataSchemas']
204
+ })
205
+
206
+ if (result.error) {
207
+ detailsError.value = result.error
208
+ } else {
209
+ selectedAlert.value = result.data
210
+ }
211
+
212
+ loadingDetails.value = false
213
+ }
214
+
215
+ function closeModal() {
216
+ showModal.value = false
217
+ selectedAlert.value = null
218
+ detailsError.value = null
219
+ }
220
+
221
+ async function handleImageClick(quality: 'preview' | 'main' = 'preview') {
222
+ if (!alertImageUrl.value) return
223
+
224
+ loadingImage.value = true
225
+ imageError.value = null
226
+ lightboxImageUrl.value = null
227
+ showLightbox.value = true
228
+
229
+ // Parse the URL to extract parameters
230
+ const params = parseImageUrlParams(alertImageUrl.value)
231
+ if (!params) {
232
+ imageError.value = 'Invalid image URL format'
233
+ loadingImage.value = false
234
+ return
235
+ }
236
+
237
+ // Use the toolkit's getRecordedImage function
238
+ const result = await getRecordedImage({
239
+ deviceId: params.deviceId,
240
+ type: quality,
241
+ timestamp__gte: params.timestamp
242
+ })
243
+
244
+ if (result.error) {
245
+ imageError.value = result.error.message
246
+ } else if (result.data?.imageData) {
247
+ lightboxImageUrl.value = result.data.imageData
248
+ } else {
249
+ imageError.value = 'No image data returned'
250
+ }
251
+
252
+ loadingImage.value = false
253
+ }
254
+
255
+ function closeLightbox() {
256
+ showLightbox.value = false
257
+ lightboxImageUrl.value = null
258
+ imageError.value = null
259
+ // Also cleanup video if it was playing
260
+ resetVideo()
261
+ showVideo.value = false
262
+ }
263
+
264
+ async function handleVideoClick() {
265
+ if (!alertImageUrl.value) return
266
+
267
+ // Parse the URL to extract parameters
268
+ const params = parseImageUrlParams(alertImageUrl.value)
269
+ if (!params) {
270
+ return
271
+ }
272
+
273
+ showVideo.value = true
274
+ showLightbox.value = true
275
+
276
+ // Use the composable to load and play video
277
+ await loadVideo(params.deviceId, params.timestamp)
278
+ }
279
+
280
+ // Debounce delay for filter changes (ms)
281
+ const DEBOUNCE_DELAY = 300
282
+
283
+ // Debounce timer reference - component-scoped to avoid race conditions
284
+ const debounceTimer = ref<ReturnType<typeof setTimeout> | null>(null)
285
+
286
+ // Debounced fetch to avoid rapid API calls on quick filter changes
287
+ function debouncedFetchAlerts() {
288
+ if (debounceTimer.value) {
289
+ clearTimeout(debounceTimer.value)
290
+ }
291
+ debounceTimer.value = setTimeout(() => {
292
+ fetchAlerts()
293
+ debounceTimer.value = null
294
+ }, DEBOUNCE_DELAY)
295
+ }
296
+
129
297
  // Fetch alert types once on mount
130
298
  fetchAlertTypes()
131
299
 
132
- // Fetch alerts when camera or time range changes
300
+ // Fetch alerts when camera or time range changes (debounced)
133
301
  watch(
134
302
  () => [props.camera?.id, props.timeRange],
135
- () => {
136
- fetchAlerts()
303
+ (_newVal, oldVal) => {
304
+ // Immediate fetch on first load (when oldVal is undefined)
305
+ if (oldVal === undefined) {
306
+ fetchAlerts()
307
+ } else {
308
+ debouncedFetchAlerts()
309
+ }
137
310
  },
138
311
  { immediate: true }
139
312
  )
313
+
314
+ // Cleanup debounce timer on unmount to prevent memory leaks
315
+ onUnmounted(() => {
316
+ if (debounceTimer.value) {
317
+ clearTimeout(debounceTimer.value)
318
+ debounceTimer.value = null
319
+ }
320
+ })
140
321
  </script>
141
322
 
142
323
  <template>
@@ -177,8 +358,9 @@ watch(
177
358
  <div
178
359
  v-for="alert in alerts"
179
360
  :key="alert.id"
180
- class="alert-item"
361
+ class="alert-item clickable"
181
362
  data-testid="alert-item"
363
+ @click="handleAlertClick(alert)"
182
364
  >
183
365
  <div class="alert-header">
184
366
  <span class="alert-type">{{ alert.alertType?.split('.')[1] || alert.alertType }}</span>
@@ -206,6 +388,154 @@ watch(
206
388
  {{ loadingMore ? 'Loading...' : 'Load More' }}
207
389
  </button>
208
390
  </div>
391
+
392
+ <!-- Alert Details Modal -->
393
+ <div v-if="showModal" class="modal-overlay" @click.self="closeModal" data-testid="alert-modal-overlay">
394
+ <div class="modal-content" data-testid="alert-modal">
395
+ <div class="modal-header">
396
+ <h3>Alert Details</h3>
397
+ <div class="modal-header-buttons">
398
+ <button
399
+ v-if="alertImageUrl"
400
+ class="image-button"
401
+ @click="handleImageClick('preview')"
402
+ data-testid="alert-image-button"
403
+ >
404
+ Preview
405
+ </button>
406
+ <button
407
+ v-if="alertImageUrl"
408
+ class="image-button image-button-hd"
409
+ @click="handleImageClick('main')"
410
+ data-testid="alert-image-hd-button"
411
+ >
412
+ HD Image
413
+ </button>
414
+ <button
415
+ v-if="alertImageUrl"
416
+ class="image-button image-button-video"
417
+ @click="handleVideoClick"
418
+ data-testid="alert-video-button"
419
+ >
420
+ Video
421
+ </button>
422
+ <button class="close-button" @click="closeModal" data-testid="alert-modal-close">&times;</button>
423
+ </div>
424
+ </div>
425
+ <div class="modal-body">
426
+ <div v-if="loadingDetails" class="loading">Loading alert details...</div>
427
+ <div v-else-if="detailsError" class="error">{{ detailsError.message }}</div>
428
+ <div v-else-if="selectedAlert" class="alert-details">
429
+ <div class="detail-row">
430
+ <span class="detail-label">ID:</span>
431
+ <span class="detail-value monospace">{{ selectedAlert.id }}</span>
432
+ </div>
433
+ <div class="detail-row">
434
+ <span class="detail-label">Type:</span>
435
+ <span class="detail-value">{{ formatAlertType(selectedAlert.alertType) }}</span>
436
+ </div>
437
+ <div v-if="selectedAlert.alertName" class="detail-row">
438
+ <span class="detail-label">Name:</span>
439
+ <span class="detail-value">{{ selectedAlert.alertName }}</span>
440
+ </div>
441
+ <div class="detail-row">
442
+ <span class="detail-label">Timestamp:</span>
443
+ <span class="detail-value">{{ formatTime(selectedAlert.timestamp) }}</span>
444
+ </div>
445
+ <div class="detail-row">
446
+ <span class="detail-label">Created:</span>
447
+ <span class="detail-value">{{ formatTime(selectedAlert.createTimestamp) }}</span>
448
+ </div>
449
+ <div v-if="selectedAlert.priority !== undefined" class="detail-row">
450
+ <span class="detail-label">Priority:</span>
451
+ <span class="detail-value">
452
+ <span class="priority-badge" :class="getPriorityClass(selectedAlert.priority)">
453
+ P{{ selectedAlert.priority }}
454
+ </span>
455
+ </span>
456
+ </div>
457
+ <div v-if="selectedAlert.category" class="detail-row">
458
+ <span class="detail-label">Category:</span>
459
+ <span class="detail-value">{{ selectedAlert.category }}</span>
460
+ </div>
461
+ <div v-if="selectedAlert.description" class="detail-row">
462
+ <span class="detail-label">Description:</span>
463
+ <span class="detail-value">{{ selectedAlert.description }}</span>
464
+ </div>
465
+ <div class="detail-row">
466
+ <span class="detail-label">Actor ID:</span>
467
+ <span class="detail-value monospace">{{ selectedAlert.actorId }}</span>
468
+ </div>
469
+ <div v-if="selectedAlert.actorName" class="detail-row">
470
+ <span class="detail-label">Actor Name:</span>
471
+ <span class="detail-value">{{ selectedAlert.actorName }}</span>
472
+ </div>
473
+ <div class="detail-row">
474
+ <span class="detail-label">Actor Type:</span>
475
+ <span class="detail-value">{{ selectedAlert.actorType }}</span>
476
+ </div>
477
+ <div v-if="selectedAlert.locationName" class="detail-row">
478
+ <span class="detail-label">Location:</span>
479
+ <span class="detail-value">{{ selectedAlert.locationName }}</span>
480
+ </div>
481
+ <div v-if="selectedAlert.eventId" class="detail-row">
482
+ <span class="detail-label">Event ID:</span>
483
+ <span class="detail-value monospace">{{ selectedAlert.eventId }}</span>
484
+ </div>
485
+ <div v-if="selectedAlert.ruleId" class="detail-row">
486
+ <span class="detail-label">Rule ID:</span>
487
+ <span class="detail-value monospace">{{ selectedAlert.ruleId }}</span>
488
+ </div>
489
+ <div v-if="selectedAlert.dataSchemas && selectedAlert.dataSchemas.length > 0" class="detail-row">
490
+ <span class="detail-label">Data Schemas:</span>
491
+ <span class="detail-value">{{ selectedAlert.dataSchemas.join(', ') }}</span>
492
+ </div>
493
+ <div v-if="selectedAlert.data && Object.keys(selectedAlert.data).length > 0" class="detail-section">
494
+ <span class="detail-label">Data:</span>
495
+ <pre class="detail-json">{{ JSON.stringify(selectedAlert.data, null, 2) }}</pre>
496
+ </div>
497
+ <div v-if="selectedAlert.actions && Object.keys(selectedAlert.actions).length > 0" class="detail-section">
498
+ <span class="detail-label">Actions:</span>
499
+ <pre class="detail-json">{{ JSON.stringify(selectedAlert.actions, null, 2) }}</pre>
500
+ </div>
501
+ </div>
502
+ </div>
503
+ </div>
504
+ </div>
505
+
506
+ <!-- Image/Video Lightbox -->
507
+ <div v-if="showLightbox" class="lightbox-overlay" @click.self="closeLightbox" data-testid="lightbox-overlay">
508
+ <div class="lightbox-content" data-testid="lightbox">
509
+ <button class="lightbox-close" @click="closeLightbox" data-testid="lightbox-close">&times;</button>
510
+ <!-- Video mode -->
511
+ <template v-if="showVideo">
512
+ <div v-if="loadingVideo" class="lightbox-loading">Loading video...</div>
513
+ <div v-else-if="videoError" class="lightbox-error">{{ videoError }}</div>
514
+ <video
515
+ v-else-if="videoUrl"
516
+ :ref="(el) => hlsPlayer.videoRef.value = el as HTMLVideoElement | null"
517
+ class="lightbox-video"
518
+ controls
519
+ autoplay
520
+ muted
521
+ playsinline
522
+ data-testid="lightbox-video"
523
+ />
524
+ </template>
525
+ <!-- Image mode -->
526
+ <template v-else>
527
+ <div v-if="loadingImage" class="lightbox-loading">Loading image...</div>
528
+ <div v-else-if="imageError" class="lightbox-error">{{ imageError }}</div>
529
+ <img
530
+ v-else-if="lightboxImageUrl"
531
+ :src="lightboxImageUrl"
532
+ alt="Alert image"
533
+ class="lightbox-image"
534
+ data-testid="lightbox-image"
535
+ />
536
+ </template>
537
+ </div>
538
+ </div>
209
539
  </div>
210
540
  </template>
211
541
 
@@ -327,4 +657,225 @@ watch(
327
657
  background: #f5f5f5;
328
658
  color: #999;
329
659
  }
660
+
661
+ .alert-item.clickable {
662
+ cursor: pointer;
663
+ transition: background-color 0.15s ease, border-color 0.15s ease;
664
+ }
665
+
666
+ .alert-item.clickable:hover {
667
+ background: #f0f0f0;
668
+ border-color: #ccc;
669
+ }
670
+
671
+ /* Modal styles */
672
+ .modal-overlay {
673
+ position: fixed;
674
+ top: 0;
675
+ left: 0;
676
+ right: 0;
677
+ bottom: 0;
678
+ background: rgba(0, 0, 0, 0.5);
679
+ display: flex;
680
+ align-items: center;
681
+ justify-content: center;
682
+ z-index: 1000;
683
+ }
684
+
685
+ .modal-content {
686
+ background: white;
687
+ border-radius: 8px;
688
+ width: 80%;
689
+ max-height: 80vh;
690
+ overflow: hidden;
691
+ display: flex;
692
+ flex-direction: column;
693
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
694
+ }
695
+
696
+ .modal-header {
697
+ display: flex;
698
+ justify-content: space-between;
699
+ align-items: center;
700
+ padding: 16px 20px;
701
+ border-bottom: 1px solid #eee;
702
+ }
703
+
704
+ .modal-header h3 {
705
+ margin: 0;
706
+ font-size: 1.1rem;
707
+ color: #333;
708
+ }
709
+
710
+ .close-button {
711
+ background: none;
712
+ border: none;
713
+ font-size: 1.5rem;
714
+ cursor: pointer;
715
+ color: #666;
716
+ padding: 0;
717
+ line-height: 1;
718
+ }
719
+
720
+ .close-button:hover {
721
+ color: #333;
722
+ }
723
+
724
+ .modal-body {
725
+ padding: 20px;
726
+ overflow-y: auto;
727
+ }
728
+
729
+ .alert-details {
730
+ display: flex;
731
+ flex-direction: column;
732
+ gap: 12px;
733
+ }
734
+
735
+ .detail-row {
736
+ display: flex;
737
+ gap: 10px;
738
+ }
739
+
740
+ .detail-label {
741
+ font-weight: 600;
742
+ color: #555;
743
+ min-width: 110px;
744
+ flex-shrink: 0;
745
+ }
746
+
747
+ .detail-value {
748
+ color: #333;
749
+ word-break: break-word;
750
+ }
751
+
752
+ .detail-value.monospace {
753
+ font-family: monospace;
754
+ font-size: 0.85rem;
755
+ background: #f5f5f5;
756
+ padding: 2px 6px;
757
+ border-radius: 3px;
758
+ }
759
+
760
+ .detail-section {
761
+ display: flex;
762
+ flex-direction: column;
763
+ gap: 8px;
764
+ margin-top: 8px;
765
+ }
766
+
767
+ .detail-json {
768
+ background: #f8f8f8;
769
+ border: 1px solid #eee;
770
+ border-radius: 4px;
771
+ padding: 12px;
772
+ font-size: 0.8rem;
773
+ overflow-x: auto;
774
+ margin: 0;
775
+ white-space: pre-wrap;
776
+ word-break: break-word;
777
+ }
778
+
779
+ .modal-header-buttons {
780
+ display: flex;
781
+ align-items: center;
782
+ gap: 10px;
783
+ }
784
+
785
+ .image-button {
786
+ padding: 6px 14px;
787
+ background: #42b883;
788
+ color: white;
789
+ border: none;
790
+ border-radius: 4px;
791
+ cursor: pointer;
792
+ font-size: 0.85rem;
793
+ font-weight: 500;
794
+ }
795
+
796
+ .image-button:hover {
797
+ background: #3aa876;
798
+ }
799
+
800
+ .image-button-hd {
801
+ background: #3b82f6;
802
+ }
803
+
804
+ .image-button-hd:hover {
805
+ background: #2563eb;
806
+ }
807
+
808
+ .image-button-video {
809
+ background: #9b59b6;
810
+ }
811
+
812
+ .image-button-video:hover {
813
+ background: #8e44ad;
814
+ }
815
+
816
+ /* Lightbox styles */
817
+ .lightbox-overlay {
818
+ position: fixed;
819
+ top: 0;
820
+ left: 0;
821
+ right: 0;
822
+ bottom: 0;
823
+ background: rgba(0, 0, 0, 0.9);
824
+ display: flex;
825
+ align-items: center;
826
+ justify-content: center;
827
+ z-index: 1100;
828
+ }
829
+
830
+ .lightbox-content {
831
+ position: relative;
832
+ max-width: 90vw;
833
+ max-height: 90vh;
834
+ display: flex;
835
+ align-items: center;
836
+ justify-content: center;
837
+ }
838
+
839
+ .lightbox-close {
840
+ position: absolute;
841
+ top: -40px;
842
+ right: 0;
843
+ background: none;
844
+ border: none;
845
+ color: white;
846
+ font-size: 2rem;
847
+ cursor: pointer;
848
+ padding: 5px 10px;
849
+ line-height: 1;
850
+ }
851
+
852
+ .lightbox-close:hover {
853
+ color: #ccc;
854
+ }
855
+
856
+ .lightbox-image {
857
+ max-width: 90vw;
858
+ max-height: 90vh;
859
+ object-fit: contain;
860
+ border-radius: 4px;
861
+ }
862
+
863
+ .lightbox-video {
864
+ max-width: 90vw;
865
+ max-height: 90vh;
866
+ width: 100%;
867
+ background: #000;
868
+ border-radius: 4px;
869
+ }
870
+
871
+ .lightbox-loading,
872
+ .lightbox-error {
873
+ color: white;
874
+ font-size: 1.1rem;
875
+ padding: 40px;
876
+ }
877
+
878
+ .lightbox-error {
879
+ color: #ff6b6b;
880
+ }
330
881
  </style>
@@ -2,14 +2,16 @@
2
2
  import { ref, onMounted } from 'vue'
3
3
  import { getCameras, type Camera, type EenError } from 'een-api-toolkit'
4
4
 
5
+ const ALL_CAMERAS_VALUE = '__all__'
6
+
5
7
  const emit = defineEmits<{
6
- select: [camera: Camera]
8
+ select: [camera: Camera | null]
7
9
  }>()
8
10
 
9
11
  const cameras = ref<Camera[]>([])
10
12
  const loading = ref(false)
11
13
  const error = ref<EenError | null>(null)
12
- const selectedCameraId = ref<string>('')
14
+ const selectedCameraId = ref<string>(ALL_CAMERAS_VALUE)
13
15
 
14
16
  async function fetchCameras() {
15
17
  loading.value = true
@@ -22,15 +24,22 @@ async function fetchCameras() {
22
24
  cameras.value = []
23
25
  } else {
24
26
  cameras.value = result.data?.results ?? []
27
+ // Default to "All Cameras" and emit null
28
+ selectedCameraId.value = ALL_CAMERAS_VALUE
29
+ emit('select', null)
25
30
  }
26
31
 
27
32
  loading.value = false
28
33
  }
29
34
 
30
35
  function handleChange() {
31
- const camera = cameras.value.find(c => c.id === selectedCameraId.value)
32
- if (camera) {
33
- emit('select', camera)
36
+ if (selectedCameraId.value === ALL_CAMERAS_VALUE) {
37
+ emit('select', null)
38
+ } else {
39
+ const camera = cameras.value.find(c => c.id === selectedCameraId.value)
40
+ if (camera) {
41
+ emit('select', camera)
42
+ }
34
43
  }
35
44
  }
36
45
 
@@ -49,7 +58,8 @@ onMounted(() => {
49
58
  :disabled="loading"
50
59
  data-testid="camera-select"
51
60
  >
52
- <option value="" disabled>{{ loading ? 'Loading cameras...' : 'Select a camera' }}</option>
61
+ <option v-if="loading" value="" disabled>Loading cameras...</option>
62
+ <option :value="ALL_CAMERAS_VALUE" data-testid="camera-option-all">All Cameras</option>
53
63
  <option
54
64
  v-for="camera in cameras"
55
65
  :key="camera.id"