een-api-toolkit 0.3.15 → 0.3.16

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.
@@ -1,9 +1,15 @@
1
1
  <script setup lang="ts">
2
- import { ref, watch } from 'vue'
3
- import { listNotifications, type Camera, type Notification, type EenError } from 'een-api-toolkit'
2
+ import { ref, watch, computed, onUnmounted } from 'vue'
3
+ import { listNotifications, getNotification, getRecordedImage, type Camera, type Notification, 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
 
@@ -13,6 +19,67 @@ const loadingMore = ref(false)
13
19
  const error = ref<EenError | null>(null)
14
20
  const nextPageToken = ref<string | undefined>(undefined)
15
21
 
22
+ // Modal state
23
+ const showModal = ref(false)
24
+ const selectedNotification = ref<Notification | null>(null)
25
+ const loadingDetails = ref(false)
26
+ const detailsError = ref<EenError | null>(null)
27
+
28
+ // Lightbox state
29
+ const showLightbox = ref(false)
30
+ const lightboxImageUrl = ref<string | null>(null)
31
+ const loadingImage = ref(false)
32
+ const imageError = ref<string | null>(null)
33
+
34
+ // Video state (showVideo controls lightbox mode, rest from composable)
35
+ const showVideo = ref(false)
36
+
37
+ // Check if notification has an httpsUrl in its data
38
+ // The httpsUrl is in the list_data array, in an object with type "een.fullFrameImageUrl.v1"
39
+ const notificationImageUrl = computed(() => {
40
+ const rawData = selectedNotification.value?.data
41
+ if (!rawData) return null
42
+
43
+ // Validate data is an object before casting
44
+ if (typeof rawData !== 'object' || rawData === null) return null
45
+ const data = rawData as Record<string, unknown>
46
+
47
+ // Look for list_data array
48
+ const listData = data.list_data
49
+ if (!Array.isArray(listData)) return null
50
+
51
+ // Find the object with type "een.fullFrameImageUrl.v1"
52
+ const imageItem = listData.find(
53
+ (item: unknown) =>
54
+ item &&
55
+ typeof item === 'object' &&
56
+ (item as Record<string, unknown>).type === 'een.fullFrameImageUrl.v1'
57
+ ) as Record<string, unknown> | undefined
58
+
59
+ if (imageItem && typeof imageItem.httpsUrl === 'string') {
60
+ return imageItem.httpsUrl
61
+ }
62
+
63
+ return null
64
+ })
65
+
66
+ // Parse the image URL to extract parameters for getRecordedImage
67
+ function parseImageUrlParams(url: string): { deviceId: string; type: 'preview' | 'main'; timestamp: string } | null {
68
+ try {
69
+ const urlObj = new URL(url)
70
+ const deviceId = urlObj.searchParams.get('deviceId')
71
+ const type = urlObj.searchParams.get('type') as 'preview' | 'main'
72
+ const timestamp = urlObj.searchParams.get('timestamp__gte')
73
+
74
+ if (deviceId && type && timestamp) {
75
+ return { deviceId, type, timestamp }
76
+ }
77
+ return null
78
+ } catch {
79
+ return null
80
+ }
81
+ }
82
+
16
83
  function getTimeRangeMs(range: string): number {
17
84
  switch (range) {
18
85
  case '1h': return 60 * 60 * 1000
@@ -24,8 +91,6 @@ function getTimeRangeMs(range: string): number {
24
91
  }
25
92
 
26
93
  async function fetchNotifications(append = false) {
27
- if (!props.camera?.id) return
28
-
29
94
  if (append) {
30
95
  loadingMore.value = true
31
96
  } else {
@@ -35,18 +100,27 @@ async function fetchNotifications(append = false) {
35
100
  }
36
101
  error.value = null
37
102
 
38
- const now = new Date()
39
- const rangeMs = getTimeRangeMs(props.timeRange)
40
- const startTime = new Date(now.getTime() - rangeMs)
41
-
42
- const result = await listNotifications({
43
- actorId: props.camera.id,
44
- timestamp__gte: startTime.toISOString(),
45
- timestamp__lte: now.toISOString(),
103
+ const params: Parameters<typeof listNotifications>[0] = {
46
104
  pageSize: 20,
47
105
  pageToken: append ? nextPageToken.value : undefined,
48
106
  sort: ['-timestamp']
49
- })
107
+ }
108
+
109
+ // Only apply time filter if a specific time range is selected (not 'none')
110
+ if (props.timeRange !== 'none') {
111
+ const now = new Date()
112
+ const rangeMs = getTimeRangeMs(props.timeRange)
113
+ const startTime = new Date(now.getTime() - rangeMs)
114
+ params.timestamp__gte = startTime.toISOString()
115
+ params.timestamp__lte = now.toISOString()
116
+ }
117
+
118
+ // Only filter by camera if a specific camera is selected
119
+ if (props.camera?.id) {
120
+ params.actorId = props.camera.id
121
+ }
122
+
123
+ const result = await listNotifications(params)
50
124
 
51
125
  if (result.error) {
52
126
  error.value = result.error
@@ -84,13 +158,126 @@ function getCategoryClass(category: string): string {
84
158
  }
85
159
  }
86
160
 
161
+ async function handleNotificationClick(notification: Notification) {
162
+ showModal.value = true
163
+ loadingDetails.value = true
164
+ detailsError.value = null
165
+ selectedNotification.value = null
166
+
167
+ const result = await getNotification(notification.id)
168
+
169
+ if (result.error) {
170
+ detailsError.value = result.error
171
+ } else {
172
+ selectedNotification.value = result.data
173
+ }
174
+
175
+ loadingDetails.value = false
176
+ }
177
+
178
+ function closeModal() {
179
+ showModal.value = false
180
+ selectedNotification.value = null
181
+ detailsError.value = null
182
+ }
183
+
184
+ async function handleImageClick(quality: 'preview' | 'main' = 'preview') {
185
+ if (!notificationImageUrl.value) return
186
+
187
+ loadingImage.value = true
188
+ imageError.value = null
189
+ lightboxImageUrl.value = null
190
+ showLightbox.value = true
191
+
192
+ // Parse the URL to extract parameters
193
+ const params = parseImageUrlParams(notificationImageUrl.value)
194
+ if (!params) {
195
+ imageError.value = 'Invalid image URL format'
196
+ loadingImage.value = false
197
+ return
198
+ }
199
+
200
+ // Use the toolkit's getRecordedImage function
201
+ const result = await getRecordedImage({
202
+ deviceId: params.deviceId,
203
+ type: quality,
204
+ timestamp__gte: params.timestamp
205
+ })
206
+
207
+ if (result.error) {
208
+ imageError.value = result.error.message
209
+ } else if (result.data?.imageData) {
210
+ lightboxImageUrl.value = result.data.imageData
211
+ } else {
212
+ imageError.value = 'No image data returned'
213
+ }
214
+
215
+ loadingImage.value = false
216
+ }
217
+
218
+ function closeLightbox() {
219
+ showLightbox.value = false
220
+ lightboxImageUrl.value = null
221
+ imageError.value = null
222
+ // Also cleanup video if it was playing
223
+ resetVideo()
224
+ showVideo.value = false
225
+ }
226
+
227
+ async function handleVideoClick() {
228
+ if (!notificationImageUrl.value) return
229
+
230
+ // Parse the URL to extract parameters
231
+ const params = parseImageUrlParams(notificationImageUrl.value)
232
+ if (!params) {
233
+ return
234
+ }
235
+
236
+ showVideo.value = true
237
+ showLightbox.value = true
238
+
239
+ // Use the composable to load and play video
240
+ await loadVideo(params.deviceId, params.timestamp)
241
+ }
242
+
243
+ // Debounce delay for filter changes (ms)
244
+ const DEBOUNCE_DELAY = 300
245
+
246
+ // Debounce timer reference - component-scoped to avoid race conditions
247
+ const debounceTimer = ref<ReturnType<typeof setTimeout> | null>(null)
248
+
249
+ // Debounced fetch to avoid rapid API calls on quick filter changes
250
+ function debouncedFetchNotifications() {
251
+ if (debounceTimer.value) {
252
+ clearTimeout(debounceTimer.value)
253
+ }
254
+ debounceTimer.value = setTimeout(() => {
255
+ fetchNotifications()
256
+ debounceTimer.value = null
257
+ }, DEBOUNCE_DELAY)
258
+ }
259
+
260
+ // Fetch notifications when camera or time range changes (debounced)
87
261
  watch(
88
262
  () => [props.camera?.id, props.timeRange],
89
- () => {
90
- fetchNotifications()
263
+ (_newVal, oldVal) => {
264
+ // Immediate fetch on first load (when oldVal is undefined)
265
+ if (oldVal === undefined) {
266
+ fetchNotifications()
267
+ } else {
268
+ debouncedFetchNotifications()
269
+ }
91
270
  },
92
271
  { immediate: true }
93
272
  )
273
+
274
+ // Cleanup debounce timer on unmount to prevent memory leaks
275
+ onUnmounted(() => {
276
+ if (debounceTimer.value) {
277
+ clearTimeout(debounceTimer.value)
278
+ debounceTimer.value = null
279
+ }
280
+ })
94
281
  </script>
95
282
 
96
283
  <template>
@@ -108,9 +295,10 @@ watch(
108
295
  <div
109
296
  v-for="notification in notifications"
110
297
  :key="notification.id"
111
- class="notification-item"
298
+ class="notification-item clickable"
112
299
  :class="{ unread: !notification.read }"
113
300
  data-testid="notification-item"
301
+ @click="handleNotificationClick(notification)"
114
302
  >
115
303
  <div class="notification-header">
116
304
  <span
@@ -140,6 +328,154 @@ watch(
140
328
  {{ loadingMore ? 'Loading...' : 'Load More' }}
141
329
  </button>
142
330
  </div>
331
+
332
+ <!-- Notification Details Modal -->
333
+ <div v-if="showModal" class="modal-overlay" @click.self="closeModal" data-testid="notification-modal-overlay">
334
+ <div class="modal-content" data-testid="notification-modal">
335
+ <div class="modal-header">
336
+ <h3>Notification Details</h3>
337
+ <div class="modal-header-buttons">
338
+ <button
339
+ v-if="notificationImageUrl"
340
+ class="image-button"
341
+ @click="handleImageClick('preview')"
342
+ data-testid="notification-image-button"
343
+ >
344
+ Preview
345
+ </button>
346
+ <button
347
+ v-if="notificationImageUrl"
348
+ class="image-button image-button-hd"
349
+ @click="handleImageClick('main')"
350
+ data-testid="notification-image-hd-button"
351
+ >
352
+ HD Image
353
+ </button>
354
+ <button
355
+ v-if="notificationImageUrl"
356
+ class="image-button image-button-video"
357
+ @click="handleVideoClick"
358
+ data-testid="notification-video-button"
359
+ >
360
+ Video
361
+ </button>
362
+ <button class="close-button" @click="closeModal" data-testid="notification-modal-close">&times;</button>
363
+ </div>
364
+ </div>
365
+ <div class="modal-body">
366
+ <div v-if="loadingDetails" class="loading">Loading notification details...</div>
367
+ <div v-else-if="detailsError" class="error">{{ detailsError.message }}</div>
368
+ <div v-else-if="selectedNotification" class="notification-details">
369
+ <div class="detail-row">
370
+ <span class="detail-label">ID:</span>
371
+ <span class="detail-value monospace">{{ selectedNotification.id }}</span>
372
+ </div>
373
+ <div class="detail-row">
374
+ <span class="detail-label">Category:</span>
375
+ <span class="detail-value">
376
+ <span class="category-badge" :class="getCategoryClass(selectedNotification.category)">
377
+ {{ selectedNotification.category }}
378
+ </span>
379
+ </span>
380
+ </div>
381
+ <div class="detail-row">
382
+ <span class="detail-label">Status:</span>
383
+ <span class="detail-value">{{ selectedNotification.status }}</span>
384
+ </div>
385
+ <div class="detail-row">
386
+ <span class="detail-label">Read:</span>
387
+ <span class="detail-value">{{ selectedNotification.read ? 'Yes' : 'No' }}</span>
388
+ </div>
389
+ <div class="detail-row">
390
+ <span class="detail-label">Timestamp:</span>
391
+ <span class="detail-value">{{ formatTime(selectedNotification.timestamp) }}</span>
392
+ </div>
393
+ <div class="detail-row">
394
+ <span class="detail-label">Created:</span>
395
+ <span class="detail-value">{{ formatTime(selectedNotification.createTimestamp) }}</span>
396
+ </div>
397
+ <div v-if="selectedNotification.sentTimestamp" class="detail-row">
398
+ <span class="detail-label">Sent:</span>
399
+ <span class="detail-value">{{ formatTime(selectedNotification.sentTimestamp) }}</span>
400
+ </div>
401
+ <div v-if="selectedNotification.description" class="detail-row">
402
+ <span class="detail-label">Description:</span>
403
+ <span class="detail-value">{{ selectedNotification.description }}</span>
404
+ </div>
405
+ <div class="detail-row">
406
+ <span class="detail-label">Actor ID:</span>
407
+ <span class="detail-value monospace">{{ selectedNotification.actorId }}</span>
408
+ </div>
409
+ <div v-if="selectedNotification.actorName" class="detail-row">
410
+ <span class="detail-label">Actor Name:</span>
411
+ <span class="detail-value">{{ selectedNotification.actorName }}</span>
412
+ </div>
413
+ <div class="detail-row">
414
+ <span class="detail-label">Actor Type:</span>
415
+ <span class="detail-value">{{ selectedNotification.actorType }}</span>
416
+ </div>
417
+ <div v-if="selectedNotification.alertId" class="detail-row">
418
+ <span class="detail-label">Alert ID:</span>
419
+ <span class="detail-value monospace">{{ selectedNotification.alertId }}</span>
420
+ </div>
421
+ <div v-if="selectedNotification.alertType" class="detail-row">
422
+ <span class="detail-label">Alert Type:</span>
423
+ <span class="detail-value">{{ selectedNotification.alertType }}</span>
424
+ </div>
425
+ <div class="detail-row">
426
+ <span class="detail-label">User ID:</span>
427
+ <span class="detail-value monospace">{{ selectedNotification.userId }}</span>
428
+ </div>
429
+ <div v-if="selectedNotification.notificationActions && selectedNotification.notificationActions.length > 0" class="detail-row">
430
+ <span class="detail-label">Actions:</span>
431
+ <span class="detail-value">{{ selectedNotification.notificationActions.join(', ') }}</span>
432
+ </div>
433
+ <div v-if="selectedNotification.dataSchemas && selectedNotification.dataSchemas.length > 0" class="detail-row">
434
+ <span class="detail-label">Data Schemas:</span>
435
+ <span class="detail-value">{{ selectedNotification.dataSchemas.join(', ') }}</span>
436
+ </div>
437
+ <div v-if="selectedNotification.data && Object.keys(selectedNotification.data).length > 0" class="detail-section">
438
+ <span class="detail-label">Data:</span>
439
+ <pre class="detail-json">{{ JSON.stringify(selectedNotification.data, null, 2) }}</pre>
440
+ </div>
441
+ </div>
442
+ </div>
443
+ </div>
444
+ </div>
445
+
446
+ <!-- Image/Video Lightbox -->
447
+ <div v-if="showLightbox" class="lightbox-overlay" @click.self="closeLightbox" data-testid="notification-lightbox-overlay">
448
+ <div class="lightbox-content" data-testid="notification-lightbox">
449
+ <button class="lightbox-close" @click="closeLightbox" data-testid="notification-lightbox-close">&times;</button>
450
+ <!-- Video mode -->
451
+ <template v-if="showVideo">
452
+ <div v-if="loadingVideo" class="lightbox-loading">Loading video...</div>
453
+ <div v-else-if="videoError" class="lightbox-error">{{ videoError }}</div>
454
+ <video
455
+ v-else-if="videoUrl"
456
+ :ref="(el) => hlsPlayer.videoRef.value = el as HTMLVideoElement | null"
457
+ class="lightbox-video"
458
+ controls
459
+ autoplay
460
+ muted
461
+ playsinline
462
+ data-testid="notification-lightbox-video"
463
+ />
464
+ </template>
465
+ <!-- Image mode -->
466
+ <template v-else>
467
+ <div v-if="loadingImage" class="lightbox-loading">Loading image...</div>
468
+ <div v-else-if="imageError" class="lightbox-error">{{ imageError }}</div>
469
+ <img
470
+ v-else-if="lightboxImageUrl"
471
+ :src="lightboxImageUrl"
472
+ alt="Notification image"
473
+ class="lightbox-image"
474
+ data-testid="notification-lightbox-image"
475
+ />
476
+ </template>
477
+ </div>
478
+ </div>
143
479
  </div>
144
480
  </template>
145
481
 
@@ -260,4 +596,230 @@ watch(
260
596
  background: #f5f5f5;
261
597
  color: #999;
262
598
  }
599
+
600
+ .notification-item.clickable {
601
+ cursor: pointer;
602
+ transition: background-color 0.15s ease, border-color 0.15s ease;
603
+ }
604
+
605
+ .notification-item.clickable:hover {
606
+ background: #f0f0f0;
607
+ border-color: #ccc;
608
+ }
609
+
610
+ .notification-item.clickable.unread:hover {
611
+ background: #e0f2fe;
612
+ border-color: #7dd3fc;
613
+ }
614
+
615
+ /* Modal styles */
616
+ .modal-overlay {
617
+ position: fixed;
618
+ top: 0;
619
+ left: 0;
620
+ right: 0;
621
+ bottom: 0;
622
+ background: rgba(0, 0, 0, 0.5);
623
+ display: flex;
624
+ align-items: center;
625
+ justify-content: center;
626
+ z-index: 1000;
627
+ }
628
+
629
+ .modal-content {
630
+ background: white;
631
+ border-radius: 8px;
632
+ width: 80%;
633
+ max-height: 80vh;
634
+ overflow: hidden;
635
+ display: flex;
636
+ flex-direction: column;
637
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
638
+ }
639
+
640
+ .modal-header {
641
+ display: flex;
642
+ justify-content: space-between;
643
+ align-items: center;
644
+ padding: 16px 20px;
645
+ border-bottom: 1px solid #eee;
646
+ }
647
+
648
+ .modal-header h3 {
649
+ margin: 0;
650
+ font-size: 1.1rem;
651
+ color: #333;
652
+ }
653
+
654
+ .modal-header-buttons {
655
+ display: flex;
656
+ align-items: center;
657
+ gap: 10px;
658
+ }
659
+
660
+ .image-button {
661
+ padding: 6px 14px;
662
+ background: #42b883;
663
+ color: white;
664
+ border: none;
665
+ border-radius: 4px;
666
+ cursor: pointer;
667
+ font-size: 0.85rem;
668
+ font-weight: 500;
669
+ }
670
+
671
+ .image-button:hover {
672
+ background: #3aa876;
673
+ }
674
+
675
+ .image-button-hd {
676
+ background: #3b82f6;
677
+ }
678
+
679
+ .image-button-hd:hover {
680
+ background: #2563eb;
681
+ }
682
+
683
+ .image-button-video {
684
+ background: #9b59b6;
685
+ }
686
+
687
+ .image-button-video:hover {
688
+ background: #8e44ad;
689
+ }
690
+
691
+ .close-button {
692
+ background: none;
693
+ border: none;
694
+ font-size: 1.5rem;
695
+ cursor: pointer;
696
+ color: #666;
697
+ padding: 0;
698
+ line-height: 1;
699
+ }
700
+
701
+ .close-button:hover {
702
+ color: #333;
703
+ }
704
+
705
+ .modal-body {
706
+ padding: 20px;
707
+ overflow-y: auto;
708
+ }
709
+
710
+ .notification-details {
711
+ display: flex;
712
+ flex-direction: column;
713
+ gap: 12px;
714
+ }
715
+
716
+ .detail-row {
717
+ display: flex;
718
+ gap: 10px;
719
+ }
720
+
721
+ .detail-label {
722
+ font-weight: 600;
723
+ color: #555;
724
+ min-width: 110px;
725
+ flex-shrink: 0;
726
+ }
727
+
728
+ .detail-value {
729
+ color: #333;
730
+ word-break: break-word;
731
+ }
732
+
733
+ .detail-value.monospace {
734
+ font-family: monospace;
735
+ font-size: 0.85rem;
736
+ background: #f5f5f5;
737
+ padding: 2px 6px;
738
+ border-radius: 3px;
739
+ }
740
+
741
+ .detail-section {
742
+ display: flex;
743
+ flex-direction: column;
744
+ gap: 8px;
745
+ margin-top: 8px;
746
+ }
747
+
748
+ .detail-json {
749
+ background: #f8f8f8;
750
+ border: 1px solid #eee;
751
+ border-radius: 4px;
752
+ padding: 12px;
753
+ font-size: 0.8rem;
754
+ overflow-x: auto;
755
+ margin: 0;
756
+ white-space: pre-wrap;
757
+ word-break: break-word;
758
+ }
759
+
760
+ /* Lightbox styles */
761
+ .lightbox-overlay {
762
+ position: fixed;
763
+ top: 0;
764
+ left: 0;
765
+ right: 0;
766
+ bottom: 0;
767
+ background: rgba(0, 0, 0, 0.9);
768
+ display: flex;
769
+ align-items: center;
770
+ justify-content: center;
771
+ z-index: 1100;
772
+ }
773
+
774
+ .lightbox-content {
775
+ position: relative;
776
+ max-width: 90vw;
777
+ max-height: 90vh;
778
+ display: flex;
779
+ align-items: center;
780
+ justify-content: center;
781
+ }
782
+
783
+ .lightbox-close {
784
+ position: absolute;
785
+ top: -40px;
786
+ right: 0;
787
+ background: none;
788
+ border: none;
789
+ color: white;
790
+ font-size: 2rem;
791
+ cursor: pointer;
792
+ padding: 5px 10px;
793
+ line-height: 1;
794
+ }
795
+
796
+ .lightbox-close:hover {
797
+ color: #ccc;
798
+ }
799
+
800
+ .lightbox-image {
801
+ max-width: 90vw;
802
+ max-height: 90vh;
803
+ object-fit: contain;
804
+ border-radius: 4px;
805
+ }
806
+
807
+ .lightbox-video {
808
+ max-width: 90vw;
809
+ max-height: 90vh;
810
+ width: 100%;
811
+ background: #000;
812
+ border-radius: 4px;
813
+ }
814
+
815
+ .lightbox-loading,
816
+ .lightbox-error {
817
+ color: white;
818
+ font-size: 1.1rem;
819
+ padding: 40px;
820
+ }
821
+
822
+ .lightbox-error {
823
+ color: #ff6b6b;
824
+ }
263
825
  </style>