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.
- package/CHANGELOG.md +11 -5
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +24 -0
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/docs/AI-CONTEXT.md +133 -1
- package/examples/vue-alerts-metrics/e2e/auth.spec.ts +8 -1
- package/examples/vue-alerts-metrics/package-lock.json +8 -1
- package/examples/vue-alerts-metrics/package.json +4 -3
- package/examples/vue-alerts-metrics/src/components/AlertsList.vue +567 -16
- package/examples/vue-alerts-metrics/src/components/CameraSelector.vue +16 -6
- package/examples/vue-alerts-metrics/src/components/MetricsChart.vue +23 -9
- package/examples/vue-alerts-metrics/src/components/NotificationsList.vue +579 -17
- package/examples/vue-alerts-metrics/src/components/TimeRangeSelector.vue +197 -12
- package/examples/vue-alerts-metrics/src/composables/useHlsPlayer.ts +285 -0
- package/examples/vue-alerts-metrics/src/views/Dashboard.vue +31 -9
- package/examples/vue-alerts-metrics/src/views/Home.vue +56 -7
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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">×</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">×</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>
|