minor: heavily simplify server and app content. unify styling #4
128
app/assets/css/main.css
Normal file
128
app/assets/css/main.css
Normal file
@@ -0,0 +1,128 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
.kestrel-page-heading { @apply text-xl font-semibold tracking-wide text-kestrel-text text-shadow-glow-sm; }
|
||||
.kestrel-section-heading { @apply text-lg font-semibold tracking-wide text-kestrel-text text-shadow-glow-sm; }
|
||||
.kestrel-panel-header { @apply flex items-center justify-between border-b border-kestrel-border px-4 py-3 shadow-border-header; }
|
||||
.kestrel-video-frame { @apply relative aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black shadow-glow-inset-video; }
|
||||
.kestrel-close-btn { @apply rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent; }
|
||||
.kestrel-card { @apply rounded border border-kestrel-border bg-kestrel-surface shadow-glow-card; }
|
||||
.kestrel-card-modal { @apply rounded-lg border border-kestrel-border bg-kestrel-surface shadow-glow-modal; }
|
||||
.kestrel-label { @apply mb-1.5 block text-xs font-medium uppercase tracking-wider text-kestrel-muted; }
|
||||
.kestrel-section-label { @apply mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted; }
|
||||
.kestrel-input { @apply w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text placeholder:text-kestrel-muted outline-none transition-colors focus:border-kestrel-accent; }
|
||||
.kestrel-btn-secondary { @apply rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border; }
|
||||
.kestrel-context-menu-item { @apply block w-full px-3 py-1.5 text-left text-sm text-kestrel-text transition-colors hover:bg-kestrel-border; }
|
||||
.kestrel-context-menu-item-danger { @apply block w-full px-3 py-1.5 text-left text-sm text-red-400 transition-colors hover:bg-kestrel-border; }
|
||||
.kestrel-panel-base { @apply flex flex-col border border-kestrel-border bg-kestrel-surface; }
|
||||
.kestrel-panel-inline { @apply rounded-lg shadow-glow; }
|
||||
.kestrel-panel-overlay { @apply absolute right-0 top-0 z-[1000] h-full w-full border-l shadow-glow md:w-[420px] shadow-glow-panel; }
|
||||
}
|
||||
|
||||
/* Transitions: modal + drawer-backdrop (same fade) */
|
||||
.modal-enter-active, .modal-leave-active,
|
||||
.drawer-backdrop-enter-active, .drawer-backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||
.modal-enter-from, .modal-leave-to,
|
||||
.drawer-backdrop-enter-from, .drawer-backdrop-leave-to { opacity: 0; }
|
||||
.modal-enter-active .relative, .modal-leave-active .relative { transition: transform 0.2s ease; }
|
||||
.modal-enter-from .relative, .modal-leave-to .relative { transform: scale(0.96); }
|
||||
|
||||
.nav-drawer { box-shadow: 8px 0 24px -4px rgba(34, 201, 201, 0.12); }
|
||||
@media (min-width: 768px) { .nav-drawer { box-shadow: none; } }
|
||||
|
||||
/* Leaflet map */
|
||||
.kestrel-map-container {
|
||||
background: #000 !important;
|
||||
}
|
||||
.kestrel-map-container .leaflet-tile-pane,
|
||||
.kestrel-map-container .leaflet-map-pane,
|
||||
.kestrel-map-container .leaflet-tile-container {
|
||||
background: #000 !important;
|
||||
}
|
||||
.kestrel-map-container img.leaflet-tile {
|
||||
background: #000 !important;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
.kestrel-map-container .poi-div-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
.kestrel-map-container .poi-icon-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.kestrel-map-container .kestrel-poi-tooltip,
|
||||
.kestrel-map-container .kestrel-live-popup-wrap .leaflet-popup-content-wrapper,
|
||||
.kestrel-map-container .kestrel-live-popup-wrap .leaflet-popup-tip {
|
||||
@apply bg-kestrel-surface-elevated border border-kestrel-glow rounded-md shadow-elevated;
|
||||
}
|
||||
.kestrel-map-container .kestrel-poi-tooltip {
|
||||
@apply text-kestrel-text-bright text-xs font-[inherit] py-1.5 px-2.5;
|
||||
}
|
||||
.kestrel-map-container .kestrel-poi-tooltip::before,
|
||||
.kestrel-map-container .kestrel-poi-tooltip::after {
|
||||
border-color: #1e293b;
|
||||
}
|
||||
.kestrel-map-container .kestrel-live-popup-wrap .leaflet-popup-content {
|
||||
@apply text-kestrel-text-bright my-2 mx-3 min-w-[200px];
|
||||
}
|
||||
.kestrel-map-container .kestrel-live-popup {
|
||||
@apply text-kestrel-text-bright text-xs;
|
||||
}
|
||||
.kestrel-map-container .kestrel-live-popup img {
|
||||
@apply block max-h-40 w-auto rounded bg-kestrel-bg;
|
||||
}
|
||||
.kestrel-map-container .leaflet-control-zoom,
|
||||
.kestrel-map-container .leaflet-control-locate,
|
||||
.kestrel-map-container .savetiles.leaflet-bar {
|
||||
@apply rounded-md overflow-hidden font-mono border border-kestrel-glow shadow-glow-sm;
|
||||
border-color: rgba(34, 201, 201, 0.35) !important;
|
||||
}
|
||||
.kestrel-map-container .leaflet-control-zoom a,
|
||||
.kestrel-map-container .leaflet-control-locate,
|
||||
.kestrel-map-container .savetiles.leaflet-bar a {
|
||||
@apply w-8 h-8 leading-8 bg-kestrel-surface text-kestrel-text border-none rounded-none text-lg font-semibold no-underline transition-all duration-150;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
line-height: 32px !important;
|
||||
background: #0d1424 !important;
|
||||
color: #b8c9e0 !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
.kestrel-map-container .leaflet-control-zoom a + a,
|
||||
.kestrel-map-container .savetiles.leaflet-bar a + a {
|
||||
border-top: 1px solid rgba(34, 201, 201, 0.2);
|
||||
}
|
||||
.kestrel-map-container .leaflet-control-zoom a:hover,
|
||||
.kestrel-map-container .leaflet-control-locate:hover,
|
||||
.kestrel-map-container .savetiles.leaflet-bar a:hover {
|
||||
@apply bg-kestrel-surface-hover text-kestrel-accent shadow-glow-md text-shadow-glow-md;
|
||||
}
|
||||
.kestrel-map-container .savetiles.leaflet-bar {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
.kestrel-map-container .savetiles.leaflet-bar a {
|
||||
@apply min-w-[5.5em] leading-tight py-1.5 px-2.5 whitespace-nowrap text-center text-[11px] font-medium tracking-wide;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
line-height: 1.25 !important;
|
||||
padding: 6px 10px !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
.kestrel-map-container .leaflet-control-locate {
|
||||
@apply flex items-center justify-center p-0 cursor-pointer;
|
||||
}
|
||||
.kestrel-map-container .leaflet-control-locate svg {
|
||||
color: currentColor;
|
||||
}
|
||||
.kestrel-map-container .live-session-icon {
|
||||
animation: live-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes live-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
@@ -7,18 +7,18 @@
|
||||
/>
|
||||
<aside
|
||||
v-else
|
||||
class="flex flex-col border border-kestrel-border bg-kestrel-surface"
|
||||
:class="asideClass"
|
||||
class="kestrel-panel-base"
|
||||
:class="inline ? 'kestrel-panel-inline' : 'kestrel-panel-overlay'"
|
||||
role="dialog"
|
||||
aria-label="Camera feed"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-kestrel-border px-4 py-3 [box-shadow:0_1px_0_0_rgba(34,201,201,0.08)]">
|
||||
<h2 class="font-medium tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
<div class="kestrel-panel-header">
|
||||
<h2 class="font-medium tracking-wide text-kestrel-text text-shadow-glow-sm">
|
||||
{{ camera?.name ?? 'Camera' }}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
|
||||
class="kestrel-close-btn"
|
||||
aria-label="Close panel"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
@@ -26,7 +26,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<div class="relative aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black [box-shadow:inset_0_0_20px_-8px_rgba(34,201,201,0.1)]">
|
||||
<div class="kestrel-video-frame">
|
||||
<template v-if="sourceType === 'hls'">
|
||||
<video
|
||||
ref="videoRef"
|
||||
@@ -75,18 +75,14 @@ defineEmits(['close'])
|
||||
const videoRef = ref(null)
|
||||
const streamError = ref(false)
|
||||
|
||||
const isLiveSession = computed(() =>
|
||||
props.camera && typeof props.camera.hasStream !== 'undefined')
|
||||
|
||||
const asideClass = computed(() =>
|
||||
props.inline ? 'rounded-lg shadow-glow' : 'absolute right-0 top-0 z-[1000] h-full w-full border-l shadow-glow md:w-[420px] [box-shadow:-8px_0_24px_-4px_rgba(34,201,201,0.12)]')
|
||||
const isLiveSession = computed(() => props.camera?.hasStream !== undefined)
|
||||
|
||||
const streamUrl = computed(() => props.camera?.streamUrl ?? '')
|
||||
const sourceType = computed(() => (props.camera?.sourceType === 'hls' ? 'hls' : 'mjpeg'))
|
||||
|
||||
const safeStreamUrl = computed(() => {
|
||||
const u = streamUrl.value
|
||||
return typeof u === 'string' && u.trim() && (u.startsWith('http://') || u.startsWith('https://')) ? u.trim() : ''
|
||||
const u = streamUrl.value?.trim()
|
||||
return (u?.startsWith('http://') || u?.startsWith('https://')) ? u : ''
|
||||
})
|
||||
|
||||
function initHls() {
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
<div
|
||||
v-if="contextMenu.type"
|
||||
ref="contextMenuRef"
|
||||
class="pointer-events-auto absolute z-[1000] min-w-[120px] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.2)]"
|
||||
class="pointer-events-auto absolute z-[1000] min-w-[120px] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow shadow-glow-context"
|
||||
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
|
||||
>
|
||||
<template v-if="contextMenu.type === 'map'">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-3 py-1.5 text-left text-sm text-kestrel-text hover:bg-kestrel-border"
|
||||
class="kestrel-context-menu-item"
|
||||
@click="openAddPoiModal(contextMenu.latlng)"
|
||||
>
|
||||
Add POI here
|
||||
@@ -22,14 +22,14 @@
|
||||
<template v-else-if="contextMenu.type === 'poi'">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-3 py-1.5 text-left text-sm text-kestrel-text hover:bg-kestrel-border"
|
||||
class="kestrel-context-menu-item"
|
||||
@click="openEditPoiModal(contextMenu.poi)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-kestrel-border"
|
||||
class="kestrel-context-menu-item-danger"
|
||||
@click="openDeletePoiModal(contextMenu.poi)"
|
||||
>
|
||||
Delete
|
||||
@@ -37,176 +37,16 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- POI modal (Add / Edit) -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showPoiModal"
|
||||
class="fixed inset-0 z-[2000] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="poiModalMode === 'delete' ? 'delete-poi-title' : 'poi-modal-title'"
|
||||
@keydown.escape="closePoiModal"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 bg-black/60 transition-opacity"
|
||||
aria-label="Close"
|
||||
@click="closePoiModal"
|
||||
/>
|
||||
<!-- Add / Edit form -->
|
||||
<div
|
||||
v-if="poiModalMode === 'add' || poiModalMode === 'edit'"
|
||||
ref="poiModalRef"
|
||||
class="relative w-full max-w-md rounded-lg border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_32px_-8px_rgba(34,201,201,0.25)]"
|
||||
@click.stop
|
||||
>
|
||||
<h2
|
||||
id="poi-modal-title"
|
||||
class="mb-4 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"
|
||||
>
|
||||
{{ poiModalMode === 'edit' ? 'Edit POI' : 'Add POI' }}
|
||||
</h2>
|
||||
<form
|
||||
class="space-y-4"
|
||||
@submit.prevent="submitPoiModal"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="add-poi-label"
|
||||
class="mb-1.5 block text-xs font-medium uppercase tracking-wider text-kestrel-muted"
|
||||
>
|
||||
Label (optional)
|
||||
</label>
|
||||
<input
|
||||
id="add-poi-label"
|
||||
v-model="poiForm.label"
|
||||
type="text"
|
||||
placeholder="e.g. Rally point"
|
||||
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text placeholder:text-kestrel-muted outline-none transition-colors focus:border-kestrel-accent"
|
||||
autocomplete="off"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs font-medium uppercase tracking-wider text-kestrel-muted"
|
||||
>
|
||||
Icon type
|
||||
</label>
|
||||
<div
|
||||
:ref="el => iconDropdownOpen && (iconDropdownRef.value = el)"
|
||||
class="relative inline-block w-full"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full min-w-0 items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-left text-sm text-kestrel-text transition-colors hover:border-kestrel-accent/50"
|
||||
:aria-expanded="iconDropdownOpen"
|
||||
aria-haspopup="listbox"
|
||||
:aria-label="`Icon type: ${poiForm.iconType}`"
|
||||
@click="iconDropdownOpen = !iconDropdownOpen"
|
||||
>
|
||||
<span class="flex items-center gap-2 capitalize">
|
||||
<Icon
|
||||
:name="POI_ICONIFY_IDS[poiForm.iconType]"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
{{ poiForm.iconType }}
|
||||
</span>
|
||||
<span
|
||||
class="text-kestrel-muted transition-transform"
|
||||
:class="iconDropdownOpen && 'rotate-180'"
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-show="iconDropdownOpen"
|
||||
class="absolute left-0 right-0 top-full z-10 mt-1 rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow [box-shadow:0_4px_12px_-2px_rgba(34,201,201,0.15)]"
|
||||
role="listbox"
|
||||
>
|
||||
<button
|
||||
v-for="opt in POI_ICON_TYPES"
|
||||
:key="opt"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="poiForm.iconType === opt"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm capitalize transition-colors"
|
||||
:class="poiForm.iconType === opt
|
||||
? 'bg-kestrel-accent-dim text-kestrel-accent'
|
||||
: 'text-kestrel-text hover:bg-kestrel-border'"
|
||||
@click="poiForm.iconType = opt; iconDropdownOpen = false"
|
||||
>
|
||||
<Icon
|
||||
:name="POI_ICONIFY_IDS[opt]"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border"
|
||||
@click="closePoiModal"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-kestrel-accent px-4 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ poiModalMode === 'edit' ? 'Save changes' : 'Add POI' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Delete confirmation -->
|
||||
<div
|
||||
v-if="poiModalMode === 'delete'"
|
||||
ref="poiModalRef"
|
||||
class="relative w-full max-w-sm rounded-lg border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_32px_-8px_rgba(34,201,201,0.25)]"
|
||||
@click.stop
|
||||
>
|
||||
<h2
|
||||
id="delete-poi-title"
|
||||
class="mb-2 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"
|
||||
>
|
||||
Delete POI?
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-kestrel-muted">
|
||||
{{ deletePoi?.label ? `“${deletePoi.label}” will be removed.` : 'This POI will be removed.' }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border"
|
||||
@click="closePoiModal"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90"
|
||||
@click="confirmDeletePoi"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<PoiModal
|
||||
:show="showPoiModal"
|
||||
:mode="poiModalMode"
|
||||
:form="poiForm"
|
||||
:edit-poi="editPoi"
|
||||
:delete-poi="deletePoi"
|
||||
@close="closePoiModal"
|
||||
@submit="onPoiSubmit"
|
||||
@confirm-delete="confirmDeletePoi"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -244,14 +84,11 @@ const liveMarkersRef = ref({})
|
||||
const contextMenu = ref({ ...CONTEXT_MENU_EMPTY })
|
||||
|
||||
const showPoiModal = ref(false)
|
||||
const poiModalRef = ref(null)
|
||||
const poiModalMode = ref('add') // 'add' | 'edit' | 'delete'
|
||||
const addPoiLatlng = ref(null)
|
||||
const editPoi = ref(null)
|
||||
const deletePoi = ref(null)
|
||||
const poiForm = ref({ label: '', iconType: 'pin' })
|
||||
const iconDropdownOpen = ref(false)
|
||||
const iconDropdownRef = ref(null)
|
||||
|
||||
const TILE_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'
|
||||
const TILE_SUBDOMAINS = 'abcd'
|
||||
@@ -259,11 +96,7 @@ const ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">Op
|
||||
const DEFAULT_VIEW = [37.7749, -122.4194]
|
||||
const DEFAULT_ZOOM = 17
|
||||
const MARKER_ICON_PATH = '/'
|
||||
const POI_ICON_TYPES = ['pin', 'flag', 'waypoint']
|
||||
const POI_TOOLTIP_CLASS = 'kestrel-poi-tooltip'
|
||||
|
||||
/** Tabler icon names (Nuxt Icon / Iconify) – modern technical aesthetic. */
|
||||
const POI_ICONIFY_IDS = { pin: 'tabler:map-pin', flag: 'tabler:flag', waypoint: 'tabler:current-location' }
|
||||
const POI_ICON_COLORS = { pin: '#22c9c9', flag: '#e53e3e', waypoint: '#a78bfa' }
|
||||
|
||||
const ICON_SIZE = 28
|
||||
@@ -279,8 +112,9 @@ function getPoiIconSvg(type) {
|
||||
return shapes[type] || shapes.pin
|
||||
}
|
||||
|
||||
const VALID_POI_TYPES = ['pin', 'flag', 'waypoint']
|
||||
function getPoiIcon(L, poi) {
|
||||
const type = poi.icon_type === 'pin' || poi.icon_type === 'flag' || poi.icon_type === 'waypoint' ? poi.icon_type : 'pin'
|
||||
const type = VALID_POI_TYPES.includes(poi.icon_type) ? poi.icon_type : 'pin'
|
||||
const html = getPoiIconSvg(type)
|
||||
return L.divIcon({
|
||||
className: 'poi-div-icon',
|
||||
@@ -290,7 +124,7 @@ function getPoiIcon(L, poi) {
|
||||
})
|
||||
}
|
||||
|
||||
const LIVE_ICON_COLOR = '#22c9c9'
|
||||
const LIVE_ICON_COLOR = '#22c9c9' /* kestrel-accent – JS string for Leaflet SVG */
|
||||
function getLiveSessionIcon(L) {
|
||||
const html = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${LIVE_ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="2" fill="${LIVE_ICON_COLOR}"/></svg>`
|
||||
return L.divIcon({
|
||||
@@ -439,7 +273,7 @@ function updateLiveMarkers() {
|
||||
})
|
||||
|
||||
const next = sessions.reduce((acc, session) => {
|
||||
const content = `<div class="kestrel-live-popup"><strong>${escapeHtml(session.label)}</strong>${session.hasStream ? ' <span style="color:#22c9c9">● Live</span>' : ''}</div>`
|
||||
const content = `<div class="kestrel-live-popup"><strong>${escapeHtml(session.label)}</strong>${session.hasStream ? ' <span class="text-kestrel-accent">● Live</span>' : ''}</div>`
|
||||
const existing = prev[session.id]
|
||||
if (existing) {
|
||||
existing.setLatLng([session.lat, session.lng])
|
||||
@@ -473,7 +307,6 @@ function openAddPoiModal(latlng) {
|
||||
editPoi.value = null
|
||||
deletePoi.value = null
|
||||
poiForm.value = { label: '', iconType: 'pin' }
|
||||
iconDropdownOpen.value = false
|
||||
showPoiModal.value = true
|
||||
}
|
||||
|
||||
@@ -484,7 +317,6 @@ function openEditPoiModal(poi) {
|
||||
addPoiLatlng.value = null
|
||||
deletePoi.value = null
|
||||
poiForm.value = { label: (poi.label ?? '').trim(), iconType: poi.icon_type || 'pin' }
|
||||
iconDropdownOpen.value = false
|
||||
showPoiModal.value = true
|
||||
}
|
||||
|
||||
@@ -500,52 +332,38 @@ function openDeletePoiModal(poi) {
|
||||
function closePoiModal() {
|
||||
showPoiModal.value = false
|
||||
poiModalMode.value = 'add'
|
||||
iconDropdownOpen.value = false
|
||||
addPoiLatlng.value = null
|
||||
editPoi.value = null
|
||||
deletePoi.value = null
|
||||
}
|
||||
|
||||
function onPoiModalDocumentClick(e) {
|
||||
if (!showPoiModal.value) return
|
||||
if (iconDropdownOpen.value && iconDropdownRef.value && !iconDropdownRef.value.contains(e.target)) {
|
||||
iconDropdownOpen.value = false
|
||||
async function doPoiFetch(fn) {
|
||||
try {
|
||||
await fn()
|
||||
emit('refreshPois')
|
||||
closePoiModal()
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function submitPoiModal() {
|
||||
async function onPoiSubmit(payload) {
|
||||
const { label, iconType } = payload
|
||||
const body = { label: (label ?? '').trim(), iconType: iconType || 'pin' }
|
||||
if (poiModalMode.value === 'add') {
|
||||
const latlng = addPoiLatlng.value
|
||||
if (!latlng) return
|
||||
const { label, iconType } = poiForm.value
|
||||
try {
|
||||
await $fetch('/api/pois', { method: 'POST', body: { lat: latlng.lat, lng: latlng.lng, label: (label ?? '').trim(), iconType: iconType || 'pin' } })
|
||||
emit('refreshPois')
|
||||
closePoiModal()
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
await doPoiFetch(() => $fetch('/api/pois', { method: 'POST', body: { ...body, lat: latlng.lat, lng: latlng.lng } }))
|
||||
return
|
||||
}
|
||||
if (poiModalMode.value === 'edit' && editPoi.value) {
|
||||
const { label, iconType } = poiForm.value
|
||||
try {
|
||||
await $fetch(`/api/pois/${editPoi.value.id}`, { method: 'PATCH', body: { label: (label ?? '').trim(), iconType: iconType || 'pin' } })
|
||||
emit('refreshPois')
|
||||
closePoiModal()
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
await doPoiFetch(() => $fetch(`/api/pois/${editPoi.value.id}`, { method: 'PATCH', body }))
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeletePoi() {
|
||||
const poi = deletePoi.value
|
||||
if (!poi?.id) return
|
||||
try {
|
||||
await $fetch(`/api/pois/${poi.id}`, { method: 'DELETE' })
|
||||
emit('refreshPois')
|
||||
closePoiModal()
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
await doPoiFetch(() => $fetch(`/api/pois/${poi.id}`, { method: 'DELETE' }))
|
||||
}
|
||||
|
||||
function destroyMap() {
|
||||
@@ -604,7 +422,6 @@ onMounted(async () => {
|
||||
leafletRef.value = { L, offlineApi: offline }
|
||||
initMapWithLocation()
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
document.addEventListener('click', onPoiModalDocumentClick)
|
||||
})
|
||||
|
||||
function onDocumentClick(e) {
|
||||
@@ -613,7 +430,6 @@ function onDocumentClick(e) {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
document.removeEventListener('click', onPoiModalDocumentClick)
|
||||
destroyMap()
|
||||
})
|
||||
|
||||
@@ -621,158 +437,3 @@ watch(() => props.devices, () => updateMarkers(), { deep: true })
|
||||
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
|
||||
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.modal-enter-active .relative,
|
||||
.modal-leave-active .relative {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.modal-enter-from .relative,
|
||||
.modal-leave-to .relative {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
/* Unrendered/loading tiles show black instead of white when panning */
|
||||
.kestrel-map-container {
|
||||
background: #000 !important;
|
||||
}
|
||||
:deep(.leaflet-tile-pane),
|
||||
:deep(.leaflet-map-pane),
|
||||
:deep(.leaflet-tile-container) {
|
||||
background: #000 !important;
|
||||
}
|
||||
:deep(img.leaflet-tile) {
|
||||
background: #000 !important;
|
||||
/* Override Leaflet’s plus-lighter so unloaded/empty tiles don’t flash white */
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
/* Leaflet injects divIcon HTML into the map; :deep() so these styles apply to that content */
|
||||
:deep(.poi-div-icon) {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
:deep(.poi-icon-svg) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Dark-themed tooltip for POI labels (Leaflet creates these in the map container) */
|
||||
:deep(.kestrel-poi-tooltip) {
|
||||
background: #1e293b;
|
||||
border: 1px solid rgba(34, 201, 201, 0.35);
|
||||
border-radius: 6px;
|
||||
color: #e2e8f0;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
padding: 6px 10px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
:deep(.kestrel-poi-tooltip::before),
|
||||
:deep(.kestrel-poi-tooltip::after) {
|
||||
border-top-color: #1e293b;
|
||||
border-bottom-color: #1e293b;
|
||||
border-left-color: #1e293b;
|
||||
border-right-color: #1e293b;
|
||||
}
|
||||
|
||||
/* Live session popup (content injected by Leaflet) */
|
||||
:deep(.kestrel-live-popup-wrap .leaflet-popup-content) {
|
||||
margin: 8px 12px;
|
||||
min-width: 200px;
|
||||
}
|
||||
:deep(.kestrel-live-popup) {
|
||||
color: #e2e8f0;
|
||||
font-size: 12px;
|
||||
}
|
||||
:deep(.kestrel-live-popup img) {
|
||||
display: block;
|
||||
max-height: 160px;
|
||||
width: auto;
|
||||
border-radius: 4px;
|
||||
background: #0f172a;
|
||||
}
|
||||
:deep(.live-session-icon) {
|
||||
animation: live-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes live-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Map controls – dark theme with cyan glow (zoom, locate, save/clear tiles) */
|
||||
:deep(.leaflet-control-zoom),
|
||||
:deep(.leaflet-control-locate),
|
||||
:deep(.savetiles.leaflet-bar) {
|
||||
border: 1px solid rgba(34, 201, 201, 0.35) !important;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 12px -2px rgba(34, 201, 201, 0.15);
|
||||
font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
|
||||
}
|
||||
:deep(.leaflet-control-zoom a),
|
||||
:deep(.leaflet-control-locate),
|
||||
:deep(.savetiles.leaflet-bar a) {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
line-height: 32px !important;
|
||||
background: #0d1424 !important;
|
||||
color: #b8c9e0 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
font-size: 18px !important;
|
||||
font-weight: 600;
|
||||
text-decoration: none !important;
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s, text-shadow 0.15s;
|
||||
}
|
||||
:deep(.leaflet-control-zoom a + a) {
|
||||
border-top: 1px solid rgba(34, 201, 201, 0.2) !important;
|
||||
}
|
||||
:deep(.leaflet-control-zoom a:hover),
|
||||
:deep(.leaflet-control-locate:hover),
|
||||
:deep(.savetiles.leaflet-bar a:hover) {
|
||||
background: #111a2e !important;
|
||||
color: #22c9c9 !important;
|
||||
box-shadow: 0 0 16px -2px rgba(34, 201, 201, 0.25);
|
||||
text-shadow: 0 0 8px rgba(34, 201, 201, 0.35);
|
||||
}
|
||||
:deep(.leaflet-control-locate) {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
:deep(.leaflet-control-locate svg) {
|
||||
color: currentColor;
|
||||
}
|
||||
/* Save/Clear tiles – text buttons */
|
||||
:deep(.savetiles.leaflet-bar) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
:deep(.savetiles.leaflet-bar a) {
|
||||
width: auto !important;
|
||||
min-width: 5.5em;
|
||||
height: auto !important;
|
||||
line-height: 1.25 !important;
|
||||
padding: 6px 10px !important;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
font-size: 11px !important;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
:deep(.savetiles.leaflet-bar a + a) {
|
||||
border-top: 1px solid rgba(34, 201, 201, 0.2) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<aside
|
||||
class="flex flex-col border border-kestrel-border bg-kestrel-surface"
|
||||
:class="inline ? 'rounded-lg shadow-glow' : 'absolute right-0 top-0 z-[1000] h-full w-full border-l shadow-glow md:w-[420px] [box-shadow:-8px_0_24px_-4px_rgba(34,201,201,0.12)]'"
|
||||
class="kestrel-panel-base"
|
||||
:class="inline ? 'kestrel-panel-inline' : 'kestrel-panel-overlay'"
|
||||
role="dialog"
|
||||
aria-label="Live feed"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-kestrel-border px-4 py-3 [box-shadow:0_1px_0_0_rgba(34,201,201,0.08)]">
|
||||
<h2 class="font-medium tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
<div class="kestrel-panel-header">
|
||||
<h2 class="font-medium tracking-wide text-kestrel-text text-shadow-glow-sm">
|
||||
{{ session?.label ?? 'Live' }}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
|
||||
class="kestrel-close-btn"
|
||||
aria-label="Close panel"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
@@ -22,7 +22,7 @@
|
||||
<p class="mb-3 text-xs text-kestrel-muted">
|
||||
Live camera feed (WebRTC)
|
||||
</p>
|
||||
<div class="relative aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black [box-shadow:inset_0_0_20px_-8px_rgba(34,201,201,0.1)]">
|
||||
<div class="kestrel-video-frame">
|
||||
<video
|
||||
ref="videoRef"
|
||||
autoplay
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
:aria-expanded="modelValue"
|
||||
>
|
||||
<div
|
||||
class="flex h-14 shrink-0 items-center justify-between border-b border-kestrel-border bg-kestrel-surface px-4 shadow-glow-sm [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]"
|
||||
class="flex h-14 shrink-0 items-center justify-between border-b border-kestrel-border bg-kestrel-surface px-4 shadow-glow-sm shadow-glow-header"
|
||||
>
|
||||
<h2 class="text-sm font-medium uppercase tracking-wider text-kestrel-muted">
|
||||
Navigation
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
|
||||
class="kestrel-close-btn"
|
||||
aria-label="Close navigation"
|
||||
@click="close"
|
||||
>
|
||||
@@ -41,7 +41,7 @@
|
||||
:to="item.to"
|
||||
class="block rounded px-3 py-2 text-sm transition-colors"
|
||||
:class="isActive(item.to)
|
||||
? 'border-l-2 border-kestrel-accent bg-kestrel-surface-hover font-medium text-kestrel-accent [text-shadow:0_0_8px_rgba(34,201,201,0.25)]'
|
||||
? 'border-l-2 border-kestrel-accent bg-kestrel-surface-hover font-medium text-kestrel-accent text-shadow-glow-sm'
|
||||
: 'border-l-2 border-transparent text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-text'"
|
||||
@click="close"
|
||||
>
|
||||
@@ -102,24 +102,3 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', onEscape)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-backdrop-enter-active,
|
||||
.drawer-backdrop-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.drawer-backdrop-enter-from,
|
||||
.drawer-backdrop-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Same elevation as content: no right-edge shadow on desktop so drawer and navbar read as one layer */
|
||||
.nav-drawer {
|
||||
box-shadow: 8px 0 24px -4px rgba(34, 201, 201, 0.12);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.nav-drawer {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
190
app/components/PoiModal.vue
Normal file
190
app/components/PoiModal.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[2000] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="mode === 'delete' ? 'delete-poi-title' : 'poi-modal-title'"
|
||||
@keydown.escape="$emit('close')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 bg-black/60 transition-opacity"
|
||||
aria-label="Close"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
<div
|
||||
v-if="mode === 'add' || mode === 'edit'"
|
||||
ref="modalRef"
|
||||
class="kestrel-card-modal relative w-full max-w-md p-6"
|
||||
@click.stop
|
||||
>
|
||||
<h2
|
||||
id="poi-modal-title"
|
||||
class="kestrel-section-heading mb-4"
|
||||
>
|
||||
{{ mode === 'edit' ? 'Edit POI' : 'Add POI' }}
|
||||
</h2>
|
||||
<form
|
||||
class="space-y-4"
|
||||
@submit.prevent="$emit('submit', { label: localForm.label, iconType: localForm.iconType })"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="add-poi-label"
|
||||
class="kestrel-label"
|
||||
>Label (optional)</label>
|
||||
<input
|
||||
id="add-poi-label"
|
||||
v-model="localForm.label"
|
||||
type="text"
|
||||
placeholder="e.g. Rally point"
|
||||
class="kestrel-input"
|
||||
autocomplete="off"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
ref="iconRef"
|
||||
class="relative inline-block w-full"
|
||||
>
|
||||
<label class="kestrel-label">Icon type</label>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full min-w-0 items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-left text-sm text-kestrel-text transition-colors hover:border-kestrel-accent/50"
|
||||
:aria-expanded="iconOpen"
|
||||
aria-haspopup="listbox"
|
||||
:aria-label="`Icon type: ${localForm.iconType}`"
|
||||
@click="iconOpen = !iconOpen"
|
||||
>
|
||||
<span class="flex items-center gap-2 capitalize">
|
||||
<Icon
|
||||
:name="POI_ICONIFY_IDS[localForm.iconType]"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
{{ localForm.iconType }}
|
||||
</span>
|
||||
<span
|
||||
class="text-kestrel-muted transition-transform"
|
||||
:class="iconOpen && 'rotate-180'"
|
||||
>▾</span>
|
||||
</button>
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-show="iconOpen"
|
||||
class="absolute left-0 right-0 top-full z-10 mt-1 rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow shadow-glow-dropdown"
|
||||
role="listbox"
|
||||
>
|
||||
<button
|
||||
v-for="opt in POI_ICON_TYPES"
|
||||
:key="opt"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="localForm.iconType === opt"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm capitalize transition-colors"
|
||||
:class="localForm.iconType === opt ? 'bg-kestrel-accent-dim text-kestrel-accent' : 'text-kestrel-text hover:bg-kestrel-border'"
|
||||
@click="localForm.iconType = opt; iconOpen = false"
|
||||
>
|
||||
<Icon
|
||||
:name="POI_ICONIFY_IDS[opt]"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="kestrel-btn-secondary"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-kestrel-accent px-4 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ mode === 'edit' ? 'Save changes' : 'Add POI' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
v-if="mode === 'delete'"
|
||||
ref="modalRef"
|
||||
class="kestrel-card-modal relative w-full max-w-sm p-6"
|
||||
@click.stop
|
||||
>
|
||||
<h2
|
||||
id="delete-poi-title"
|
||||
class="kestrel-section-heading mb-2"
|
||||
>
|
||||
Delete POI?
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-kestrel-muted">
|
||||
{{ deletePoi?.label ? `"${deletePoi.label}" will be removed.` : 'This POI will be removed.' }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="kestrel-btn-secondary"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90"
|
||||
@click="$emit('confirmDelete')"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const POI_ICONIFY_IDS = { pin: 'tabler:map-pin', flag: 'tabler:flag', waypoint: 'tabler:current-location' }
|
||||
const POI_ICON_TYPES = Object.keys(POI_ICONIFY_IDS)
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
mode: { type: String, default: 'add' },
|
||||
form: { type: Object, default: () => ({ label: '', iconType: 'pin' }) },
|
||||
editPoi: { type: Object, default: null },
|
||||
deletePoi: { type: Object, default: null },
|
||||
})
|
||||
defineEmits(['close', 'submit', 'confirmDelete'])
|
||||
|
||||
const modalRef = ref(null)
|
||||
const iconRef = ref(null)
|
||||
const iconOpen = ref(false)
|
||||
const localForm = ref({ label: '', iconType: 'pin' })
|
||||
|
||||
watch(() => props.show, (show) => {
|
||||
if (!show) return
|
||||
iconOpen.value = false
|
||||
localForm.value = props.mode === 'edit' && props.editPoi
|
||||
? { label: (props.editPoi.label ?? '').trim(), iconType: props.editPoi.icon_type || 'pin' }
|
||||
: { ...props.form }
|
||||
})
|
||||
|
||||
function onDocClick(e) {
|
||||
if (iconOpen.value && iconRef.value && !iconRef.value.contains(e.target)) iconOpen.value = false
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', onDocClick))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
|
||||
</script>
|
||||
12
app/composables/useAutoCloseLiveSession.js
Normal file
12
app/composables/useAutoCloseLiveSession.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** Auto-closes selectedCamera when the selected live session disappears from liveSessions. */
|
||||
export function useAutoCloseLiveSession(selectedCamera, liveSessions) {
|
||||
watch(
|
||||
[() => selectedCamera.value, () => liveSessions.value],
|
||||
([sel, sessions]) => {
|
||||
if (!sel || typeof sel.hasStream === 'undefined') return
|
||||
const stillActive = (sessions ?? []).some(s => s.id === sel.id)
|
||||
if (!stillActive) selectedCamera.value = null
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
/**
|
||||
* Fetches devices + live sessions (unified cameras). Optionally polls when tab is visible.
|
||||
*/
|
||||
/** Fetches devices + live sessions; polls when tab visible. */
|
||||
const POLL_MS = 1500
|
||||
const EMPTY_RESPONSE = Object.freeze({ devices: [], liveSessions: [] })
|
||||
|
||||
export function useCameras(options = {}) {
|
||||
const { poll: enablePoll = true } = options
|
||||
const { data, refresh } = useAsyncData(
|
||||
'cameras',
|
||||
() => $fetch('/api/cameras').catch(() => ({ devices: [], liveSessions: [] })),
|
||||
{ default: () => ({ devices: [], liveSessions: [] }) },
|
||||
() => $fetch('/api/cameras').catch(() => EMPTY_RESPONSE),
|
||||
{ default: () => EMPTY_RESPONSE },
|
||||
)
|
||||
|
||||
const devices = computed(() => Object.freeze([...(data.value?.devices ?? [])]))
|
||||
const liveSessions = computed(() => Object.freeze([...(data.value?.liveSessions ?? [])]))
|
||||
const cameras = computed(() => Object.freeze([...devices.value, ...liveSessions.value]))
|
||||
|
||||
const pollInterval = ref(null)
|
||||
function startPolling() {
|
||||
if (!enablePoll || pollInterval.value) return
|
||||
@@ -27,22 +30,11 @@ export function useCameras(options = {}) {
|
||||
onMounted(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
startPolling()
|
||||
refresh()
|
||||
}
|
||||
else {
|
||||
stopPolling()
|
||||
}
|
||||
document.visibilityState === 'visible' ? (startPolling(), refresh()) : stopPolling()
|
||||
})
|
||||
if (document.visibilityState === 'visible') startPolling()
|
||||
})
|
||||
onBeforeUnmount(stopPolling)
|
||||
|
||||
const devices = computed(() => data.value?.devices ?? [])
|
||||
const liveSessions = computed(() => data.value?.liveSessions ?? [])
|
||||
/** All cameras: devices first, then live sessions */
|
||||
const cameras = computed(() => [...devices.value, ...liveSessions.value])
|
||||
|
||||
return { data, devices, liveSessions, cameras, refresh, startPolling, stopPolling }
|
||||
return Object.freeze({ data, devices, liveSessions, cameras, refresh, startPolling, stopPolling })
|
||||
}
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
/**
|
||||
* Fetches active live sessions (camera + location sharing) and refreshes on an interval.
|
||||
* Only runs when the app is focused so we don't poll in the background.
|
||||
*/
|
||||
|
||||
/** Fetches live sessions; polls when tab visible. */
|
||||
const POLL_MS = 1500
|
||||
|
||||
export function useLiveSessions() {
|
||||
const { data: sessions, refresh } = useAsyncData(
|
||||
const { data: _sessions, refresh } = useAsyncData(
|
||||
'live-sessions',
|
||||
async () => {
|
||||
try {
|
||||
const result = await $fetch('/api/live')
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[useLiveSessions] Fetched sessions:', result.map(s => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
hasStream: s.hasStream,
|
||||
})))
|
||||
}
|
||||
return result
|
||||
return await $fetch('/api/live')
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err?.message ?? String(err)
|
||||
@@ -30,14 +18,13 @@ export function useLiveSessions() {
|
||||
{ default: () => [] },
|
||||
)
|
||||
|
||||
const sessions = computed(() => Object.freeze([...(_sessions.value ?? [])]))
|
||||
const pollInterval = ref(null)
|
||||
|
||||
function startPolling() {
|
||||
if (pollInterval.value) return
|
||||
refresh() // Fetch immediately so new sessions show without waiting for first interval
|
||||
pollInterval.value = setInterval(() => {
|
||||
refresh()
|
||||
}, POLL_MS)
|
||||
refresh()
|
||||
pollInterval.value = setInterval(refresh, POLL_MS)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
@@ -49,21 +36,12 @@ export function useLiveSessions() {
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
const onFocus = () => startPolling()
|
||||
const onBlur = () => stopPolling()
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
onFocus()
|
||||
refresh() // Fresh data when returning to tab
|
||||
}
|
||||
else onBlur()
|
||||
document.visibilityState === 'visible' ? (startPolling(), refresh()) : stopPolling()
|
||||
})
|
||||
if (document.visibilityState === 'visible') startPolling()
|
||||
})
|
||||
onBeforeUnmount(stopPolling)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
return { sessions, refresh, startPolling, stopPolling }
|
||||
return Object.freeze({ sessions, refresh, startPolling, stopPolling })
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const EDIT_ROLES = Object.freeze(['admin', 'leader'])
|
||||
|
||||
export function useUser() {
|
||||
const requestFetch = useRequestFetch()
|
||||
const { data: user, refresh } = useAsyncData(
|
||||
@@ -5,7 +7,7 @@ export function useUser() {
|
||||
() => (requestFetch ?? $fetch)('/api/me').catch(() => null),
|
||||
{ default: () => null },
|
||||
)
|
||||
const canEditPois = computed(() => user.value?.role === 'admin' || user.value?.role === 'leader')
|
||||
const canEditPois = computed(() => EDIT_ROLES.includes(user.value?.role))
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
return { user, canEditPois, isAdmin, refresh }
|
||||
return Object.freeze({ user, canEditPois, isAdmin, refresh })
|
||||
}
|
||||
|
||||
@@ -1,61 +1,26 @@
|
||||
/**
|
||||
* WebRTC composable for Mediasoup client operations.
|
||||
* Handles device initialization, transport creation, and WebSocket signaling.
|
||||
*/
|
||||
|
||||
/** WebRTC/Mediasoup client utilities. */
|
||||
import { logError, logWarn } from '../utils/logger.js'
|
||||
|
||||
/**
|
||||
* Initialize Mediasoup device from router RTP capabilities.
|
||||
* @param {object} rtpCapabilities
|
||||
* @returns {Promise<object>} Mediasoup device
|
||||
*/
|
||||
export async function createMediasoupDevice(rtpCapabilities) {
|
||||
// Dynamically import mediasoup-client only in browser
|
||||
if (typeof window === 'undefined') {
|
||||
throw new TypeError('Mediasoup device can only be created in browser')
|
||||
}
|
||||
const FETCH_OPTS = { credentials: 'include' }
|
||||
|
||||
// Use dynamic import for mediasoup-client
|
||||
export async function createMediasoupDevice(rtpCapabilities) {
|
||||
if (typeof window === 'undefined') throw new TypeError('Mediasoup device can only be created in browser')
|
||||
const { Device } = await import('mediasoup-client')
|
||||
const device = new Device()
|
||||
await device.load({ routerRtpCapabilities: rtpCapabilities })
|
||||
return device
|
||||
}
|
||||
|
||||
/**
|
||||
* Create WebSocket connection for signaling.
|
||||
* @param {string} url - WebSocket URL (e.g., 'ws://localhost:3000/ws')
|
||||
* @returns {Promise<WebSocket>} WebSocket connection
|
||||
*/
|
||||
export function createWebSocketConnection(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = url.startsWith('ws') ? url : `${protocol}//${window.location.host}/ws`
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
resolve(ws)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
reject(new Error('WebSocket connection failed'))
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
// Connection closed
|
||||
}
|
||||
ws.onopen = () => resolve(ws)
|
||||
ws.onerror = () => reject(new Error('WebSocket connection failed'))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send WebSocket message and wait for response.
|
||||
* @param {WebSocket} ws
|
||||
* @param {string} sessionId
|
||||
* @param {string} type
|
||||
* @param {object} data
|
||||
* @returns {Promise<object>} Response message
|
||||
*/
|
||||
export function sendWebSocketMessage(ws, sessionId, type, data = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
@@ -95,41 +60,20 @@ export function sendWebSocketMessage(ws, sessionId, type, data = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create send transport (for publisher).
|
||||
* @param {object} device
|
||||
* @param {string} sessionId
|
||||
* @param {{ onConnectSuccess?: () => void, onConnectFailure?: (err: Error) => void }} [options] - Optional callbacks when transport connect succeeds or fails.
|
||||
* @returns {Promise<object>} Transport with send method
|
||||
*/
|
||||
export async function createSendTransport(device, sessionId, options = {}) {
|
||||
const { onConnectSuccess, onConnectFailure } = options
|
||||
// Create transport via HTTP API
|
||||
const transportParams = await $fetch('/api/live/webrtc/create-transport', {
|
||||
method: 'POST',
|
||||
body: { sessionId, isProducer: true },
|
||||
credentials: 'include',
|
||||
})
|
||||
const transport = device.createSendTransport({
|
||||
id: transportParams.id,
|
||||
iceParameters: transportParams.iceParameters,
|
||||
iceCandidates: transportParams.iceCandidates,
|
||||
dtlsParameters: transportParams.dtlsParameters,
|
||||
})
|
||||
|
||||
function attachTransportHandlers(transport, transportParams, sessionId, label, { onConnectSuccess, onConnectFailure } = {}) {
|
||||
transport.on('connect', async ({ dtlsParameters }, callback, errback) => {
|
||||
try {
|
||||
await $fetch('/api/live/webrtc/connect-transport', {
|
||||
method: 'POST',
|
||||
body: { sessionId, transportId: transportParams.id, dtlsParameters },
|
||||
credentials: 'include',
|
||||
...FETCH_OPTS,
|
||||
})
|
||||
onConnectSuccess?.()
|
||||
callback()
|
||||
}
|
||||
catch (err) {
|
||||
logError('useWebRTC: Send transport connect failed', {
|
||||
err: err.message || String(err),
|
||||
logError(`useWebRTC: ${label} transport connect failed`, {
|
||||
err: err?.message ?? String(err),
|
||||
transportId: transportParams.id,
|
||||
connectionState: transport.connectionState,
|
||||
sessionId,
|
||||
@@ -138,48 +82,50 @@ export async function createSendTransport(device, sessionId, options = {}) {
|
||||
errback(err)
|
||||
}
|
||||
})
|
||||
|
||||
transport.on('connectionstatechange', () => {
|
||||
const state = transport.connectionState
|
||||
if (state === 'failed' || state === 'disconnected' || state === 'closed') {
|
||||
logWarn('useWebRTC: Send transport connection state changed', {
|
||||
state,
|
||||
transportId: transportParams.id,
|
||||
sessionId,
|
||||
})
|
||||
if (['failed', 'disconnected', 'closed'].includes(state)) {
|
||||
logWarn(`useWebRTC: ${label} transport connection state changed`, { state, transportId: transportParams.id, sessionId })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function createSendTransport(device, sessionId, options = {}) {
|
||||
const transportParams = await $fetch('/api/live/webrtc/create-transport', {
|
||||
method: 'POST',
|
||||
body: { sessionId, isProducer: true },
|
||||
...FETCH_OPTS,
|
||||
})
|
||||
const transport = device.createSendTransport({
|
||||
id: transportParams.id,
|
||||
iceParameters: transportParams.iceParameters,
|
||||
iceCandidates: transportParams.iceCandidates,
|
||||
dtlsParameters: transportParams.dtlsParameters,
|
||||
})
|
||||
attachTransportHandlers(transport, transportParams, sessionId, 'Send', options)
|
||||
|
||||
transport.on('produce', async ({ kind, rtpParameters }, callback, errback) => {
|
||||
try {
|
||||
const { id } = await $fetch('/api/live/webrtc/create-producer', {
|
||||
method: 'POST',
|
||||
body: { sessionId, transportId: transportParams.id, kind, rtpParameters },
|
||||
credentials: 'include',
|
||||
...FETCH_OPTS,
|
||||
})
|
||||
callback({ id })
|
||||
}
|
||||
catch (err) {
|
||||
logError('useWebRTC: Producer creation failed', { err: err.message || String(err) })
|
||||
logError('useWebRTC: Producer creation failed', { err: err?.message ?? String(err) })
|
||||
errback(err)
|
||||
}
|
||||
})
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
/**
|
||||
* Create receive transport (for viewer).
|
||||
* @param {object} device
|
||||
* @param {string} sessionId
|
||||
* @returns {Promise<object>} Transport with consume method
|
||||
*/
|
||||
export async function createRecvTransport(device, sessionId) {
|
||||
// Create transport via HTTP API
|
||||
const transportParams = await $fetch('/api/live/webrtc/create-transport', {
|
||||
method: 'POST',
|
||||
body: { sessionId, isProducer: false },
|
||||
credentials: 'include',
|
||||
...FETCH_OPTS,
|
||||
})
|
||||
const transport = device.createRecvTransport({
|
||||
id: transportParams.id,
|
||||
@@ -187,55 +133,15 @@ export async function createRecvTransport(device, sessionId) {
|
||||
iceCandidates: transportParams.iceCandidates,
|
||||
dtlsParameters: transportParams.dtlsParameters,
|
||||
})
|
||||
|
||||
// Set up connect handler (will be called by mediasoup-client when needed)
|
||||
transport.on('connect', async ({ dtlsParameters }, callback, errback) => {
|
||||
try {
|
||||
await $fetch('/api/live/webrtc/connect-transport', {
|
||||
method: 'POST',
|
||||
body: { sessionId, transportId: transportParams.id, dtlsParameters },
|
||||
credentials: 'include',
|
||||
})
|
||||
callback()
|
||||
}
|
||||
catch (err) {
|
||||
logError('useWebRTC: Recv transport connect failed', {
|
||||
err: err.message || String(err),
|
||||
transportId: transportParams.id,
|
||||
connectionState: transport.connectionState,
|
||||
sessionId,
|
||||
})
|
||||
errback(err)
|
||||
}
|
||||
})
|
||||
|
||||
transport.on('connectionstatechange', () => {
|
||||
const state = transport.connectionState
|
||||
if (state === 'failed' || state === 'disconnected' || state === 'closed') {
|
||||
logWarn('useWebRTC: Recv transport connection state changed', {
|
||||
state,
|
||||
transportId: transportParams.id,
|
||||
sessionId,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
attachTransportHandlers(transport, transportParams, sessionId, 'Recv')
|
||||
return transport
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume producer's stream (for viewer).
|
||||
* @param {object} transport
|
||||
* @param {object} device
|
||||
* @param {string} sessionId
|
||||
* @returns {Promise<object>} Consumer with track
|
||||
*/
|
||||
export async function consumeProducer(transport, device, sessionId) {
|
||||
const rtpCapabilities = device.rtpCapabilities
|
||||
const consumerParams = await $fetch('/api/live/webrtc/create-consumer', {
|
||||
method: 'POST',
|
||||
body: { sessionId, transportId: transport.id, rtpCapabilities },
|
||||
credentials: 'include',
|
||||
body: { sessionId, transportId: transport.id, rtpCapabilities: device.rtpCapabilities },
|
||||
...FETCH_OPTS,
|
||||
})
|
||||
|
||||
const consumer = await transport.consume({
|
||||
@@ -256,14 +162,6 @@ export async function consumeProducer(transport, device, sessionId) {
|
||||
return consumer
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve when condition() returns truthy, or after timeoutMs (then resolve anyway).
|
||||
* No mutable shared state; cleanup on first completion.
|
||||
* @param {() => unknown} condition
|
||||
* @param {number} timeoutMs
|
||||
* @param {number} intervalMs
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function waitForCondition(condition, timeoutMs = 3000, intervalMs = 100) {
|
||||
return new Promise((resolve) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
@@ -285,12 +183,6 @@ function waitForCondition(condition, timeoutMs = 3000, intervalMs = 100) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for transport connection state to reach a terminal state or timeout.
|
||||
* @param {object} transport - Mediasoup transport with connectionState and on/off
|
||||
* @param {number} timeoutMs
|
||||
* @returns {Promise<string>} Final connection state
|
||||
*/
|
||||
export function waitForConnectionState(transport, timeoutMs = 10000) {
|
||||
const terminal = ['connected', 'failed', 'disconnected', 'closed']
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
/**
|
||||
* Fetch WebRTC failure reason (e.g. wrong host). Pure: same inputs → same output.
|
||||
* @returns {Promise<{ wrongHost: { serverHostname: string, clientHostname: string } | null }>} Failure reason or null.
|
||||
*/
|
||||
/** Pure: fetches WebRTC failure reason (e.g. wrong host). Returns frozen object. */
|
||||
export async function getWebRTCFailureReason() {
|
||||
try {
|
||||
const res = await $fetch('/api/live/debug-request-host', { credentials: 'include' })
|
||||
const clientHostname = typeof window !== 'undefined' ? window.location.hostname : ''
|
||||
const serverHostname = res?.hostname ?? ''
|
||||
if (serverHostname && clientHostname && serverHostname !== clientHostname) {
|
||||
return { wrongHost: { serverHostname, clientHostname } }
|
||||
return Object.freeze({ wrongHost: Object.freeze({ serverHostname, clientHostname }) })
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// ignore
|
||||
}
|
||||
return { wrongHost: null }
|
||||
catch { /* ignore */ }
|
||||
return Object.freeze({ wrongHost: null })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-kestrel-bg font-mono text-kestrel-text">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-semibold tracking-wide [text-shadow:0_0_12px_rgba(34,201,201,0.3)]">
|
||||
<h1 class="text-2xl font-semibold tracking-wide text-shadow-glow-md">
|
||||
[ Error ]
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-kestrel-muted">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
class="flex min-h-0 flex-1 flex-col transition-[margin] duration-200 ease-out"
|
||||
:class="{ 'md:ml-[260px]': drawerOpen }"
|
||||
>
|
||||
<header class="flex h-14 shrink-0 items-center gap-3 border-b border-kestrel-border bg-kestrel-surface px-4 shadow-glow-sm [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
|
||||
<header class="flex h-14 shrink-0 items-center gap-3 border-b border-kestrel-border bg-kestrel-surface px-4 shadow-glow-sm shadow-glow-header">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-2 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
|
||||
@@ -20,7 +20,7 @@
|
||||
>☰</span>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_12px_rgba(34,201,201,0.35)]">
|
||||
<h1 class="text-lg font-semibold tracking-wide text-kestrel-text text-shadow-glow-md">
|
||||
KestrelOS
|
||||
</h1>
|
||||
<p class="text-xs uppercase tracking-widest text-kestrel-muted">
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
<h2 class="kestrel-page-heading mb-4">
|
||||
Account
|
||||
</h2>
|
||||
|
||||
<!-- Profile -->
|
||||
<section class="mb-8">
|
||||
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
|
||||
<h3 class="kestrel-section-label">
|
||||
Profile
|
||||
</h3>
|
||||
<div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
|
||||
<div class="kestrel-card p-4">
|
||||
<template v-if="user">
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div>
|
||||
@@ -50,15 +49,14 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Change password (local only) -->
|
||||
<section
|
||||
v-if="user?.auth_provider === 'local'"
|
||||
class="mb-8"
|
||||
>
|
||||
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
|
||||
<h3 class="kestrel-section-label">
|
||||
Change password
|
||||
</h3>
|
||||
<div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
|
||||
<div class="kestrel-card p-4">
|
||||
<p
|
||||
v-if="passwordSuccess"
|
||||
class="mb-3 text-sm text-green-400"
|
||||
@@ -78,46 +76,40 @@
|
||||
<div>
|
||||
<label
|
||||
for="account-current-password"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
>
|
||||
Current password
|
||||
</label>
|
||||
class="kestrel-label"
|
||||
>Current password</label>
|
||||
<input
|
||||
id="account-current-password"
|
||||
v-model="currentPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
|
||||
class="kestrel-input"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="account-new-password"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
>
|
||||
New password
|
||||
</label>
|
||||
class="kestrel-label"
|
||||
>New password</label>
|
||||
<input
|
||||
id="account-new-password"
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
|
||||
class="kestrel-input"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="account-confirm-password"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
>
|
||||
Confirm new password
|
||||
</label>
|
||||
class="kestrel-label"
|
||||
>Confirm new password</label>
|
||||
<input
|
||||
id="account-confirm-password"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
|
||||
class="kestrel-input"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
<h2 class="kestrel-page-heading mb-4">
|
||||
Cameras
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-kestrel-muted">
|
||||
@@ -80,6 +80,8 @@
|
||||
<script setup>
|
||||
definePageMeta({ layout: 'default' })
|
||||
|
||||
const { cameras } = useCameras()
|
||||
const { cameras, liveSessions } = useCameras()
|
||||
const selectedCamera = ref(null)
|
||||
|
||||
useAutoCloseLiveSession(selectedCamera, liveSessions)
|
||||
</script>
|
||||
|
||||
@@ -28,7 +28,8 @@ const { canEditPois } = useUser()
|
||||
const selectedCamera = ref(null)
|
||||
|
||||
function onSelectLive(session) {
|
||||
const latest = (liveSessions.value || []).find(s => s.id === session?.id)
|
||||
selectedCamera.value = latest ?? session
|
||||
selectedCamera.value = (liveSessions.value ?? []).find(s => s.id === session?.id) ?? session
|
||||
}
|
||||
|
||||
useAutoCloseLiveSession(selectedCamera, liveSessions)
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex min-h-[60vh] items-center justify-center p-6">
|
||||
<div class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
<div class="kestrel-card w-full max-w-sm p-6">
|
||||
<h2 class="kestrel-section-heading mb-4">
|
||||
Sign in
|
||||
</h2>
|
||||
<p
|
||||
@@ -29,28 +29,28 @@
|
||||
<div class="mb-3">
|
||||
<label
|
||||
for="login-identifier"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
class="kestrel-label"
|
||||
>Email or username</label>
|
||||
<input
|
||||
id="login-identifier"
|
||||
v-model="identifier"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
|
||||
class="kestrel-input"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="login-password"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
class="kestrel-label"
|
||||
>Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
|
||||
class="kestrel-input"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
@@ -69,16 +69,16 @@
|
||||
<script setup>
|
||||
const route = useRoute()
|
||||
const redirect = computed(() => route.query.redirect || '/')
|
||||
const AUTH_CONFIG_DEFAULT = Object.freeze({ oidc: { enabled: false, label: '' } })
|
||||
const { data: authConfig } = useAsyncData(
|
||||
'auth-config',
|
||||
() => $fetch('/api/auth/config').catch(() => ({ oidc: { enabled: false, label: '' } })),
|
||||
() => $fetch('/api/auth/config').catch(() => AUTH_CONFIG_DEFAULT),
|
||||
{ default: () => null },
|
||||
)
|
||||
const showDivider = computed(() => !!authConfig.value?.oidc?.enabled)
|
||||
const oidcAuthorizeUrl = computed(() => {
|
||||
const base = '/api/auth/oidc/authorize'
|
||||
const q = redirect.value && redirect.value !== '/' ? `?redirect=${encodeURIComponent(redirect.value)}` : ''
|
||||
return base + q
|
||||
const r = redirect.value
|
||||
return `/api/auth/oidc/authorize${r && r !== '/' ? `?redirect=${encodeURIComponent(r)}` : ''}`
|
||||
})
|
||||
|
||||
const identifier = ref('')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h2 class="mb-2 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
<h2 class="kestrel-page-heading mb-2">
|
||||
Members
|
||||
</h2>
|
||||
<p
|
||||
@@ -10,7 +10,7 @@
|
||||
Sign in to view members.
|
||||
</p>
|
||||
<p
|
||||
v-else-if="!canViewMembers"
|
||||
v-else-if="!canEditPois"
|
||||
class="text-sm text-kestrel-muted"
|
||||
>
|
||||
You don't have access to the members list.
|
||||
@@ -149,7 +149,7 @@
|
||||
v-if="openRoleDropdownId && dropdownPlacement"
|
||||
ref="dropdownMenuRef"
|
||||
role="menu"
|
||||
class="fixed z-[100] min-w-[6rem] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow [box-shadow:0_4px_12px_-2px_rgba(34,201,201,0.15)]"
|
||||
class="fixed z-[100] min-w-[6rem] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow shadow-glow-dropdown"
|
||||
:style="{
|
||||
top: `${dropdownPlacement.top}px`,
|
||||
left: `${dropdownPlacement.left}px`,
|
||||
@@ -183,7 +183,7 @@
|
||||
@click.self="closeAddUserModal"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
|
||||
class="kestrel-card-modal w-full max-w-sm p-4"
|
||||
@click.stop
|
||||
>
|
||||
<h3
|
||||
@@ -204,7 +204,7 @@
|
||||
type="text"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
|
||||
class="kestrel-input"
|
||||
placeholder="username"
|
||||
>
|
||||
</div>
|
||||
@@ -219,7 +219,7 @@
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
|
||||
class="kestrel-input"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
</div>
|
||||
@@ -231,7 +231,7 @@
|
||||
<select
|
||||
id="add-role"
|
||||
v-model="newUser.role"
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
|
||||
class="kestrel-input"
|
||||
>
|
||||
<option value="member">
|
||||
member
|
||||
@@ -253,7 +253,7 @@
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
|
||||
class="kestrel-btn-secondary"
|
||||
@click="closeAddUserModal"
|
||||
>
|
||||
Cancel
|
||||
@@ -280,7 +280,7 @@
|
||||
@click.self="deleteConfirmUser = null"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
|
||||
class="kestrel-card-modal w-full max-w-sm p-4"
|
||||
@click.stop
|
||||
>
|
||||
<h3
|
||||
@@ -295,7 +295,7 @@
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
|
||||
class="kestrel-btn-secondary"
|
||||
@click="deleteConfirmUser = null"
|
||||
>
|
||||
Cancel
|
||||
@@ -318,7 +318,7 @@
|
||||
@click.self="editUserModal = null"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
|
||||
class="kestrel-card-modal w-full max-w-sm p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="edit-user-title"
|
||||
@@ -340,7 +340,7 @@
|
||||
v-model="editForm.identifier"
|
||||
type="text"
|
||||
required
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
|
||||
class="kestrel-input"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-4 flex flex-col gap-1">
|
||||
@@ -353,7 +353,7 @@
|
||||
v-model="editForm.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
|
||||
class="kestrel-input"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
<p class="mt-0.5 text-xs text-kestrel-muted">
|
||||
@@ -369,7 +369,7 @@
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
|
||||
class="kestrel-btn-secondary"
|
||||
@click="editUserModal = null"
|
||||
>
|
||||
Cancel
|
||||
@@ -390,15 +390,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { user, isAdmin, refresh: refreshUser } = useUser()
|
||||
const canViewMembers = computed(() => user.value?.role === 'admin' || user.value?.role === 'leader')
|
||||
const { user, isAdmin, canEditPois, refresh: refreshUser } = useUser()
|
||||
|
||||
const { data: usersData, refresh: refreshUsers } = useAsyncData(
|
||||
'users',
|
||||
() => $fetch('/api/users').catch(() => []),
|
||||
{ default: () => [] },
|
||||
)
|
||||
const users = computed(() => (Array.isArray(usersData.value) ? usersData.value : []))
|
||||
const users = computed(() => Object.freeze([...(usersData.value ?? [])]))
|
||||
|
||||
const roleOptions = ['admin', 'leader', 'member']
|
||||
const pendingRoleUpdates = ref({})
|
||||
@@ -428,8 +427,8 @@ function setDropdownWrapRef(userId, el) {
|
||||
}
|
||||
}
|
||||
|
||||
watch(user, (u) => {
|
||||
if (u?.role === 'admin' || u?.role === 'leader') refreshUsers()
|
||||
watch(user, () => {
|
||||
if (canEditPois.value) refreshUsers()
|
||||
}, { immediate: true })
|
||||
|
||||
function toggleRoleDropdown(userId) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h2 class="mb-2 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
<h2 class="kestrel-page-heading mb-2">
|
||||
POI placement
|
||||
</h2>
|
||||
<p
|
||||
@@ -17,7 +17,7 @@
|
||||
<div>
|
||||
<label
|
||||
for="poi-lat"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
class="kestrel-label"
|
||||
>Lat</label>
|
||||
<input
|
||||
id="poi-lat"
|
||||
@@ -25,13 +25,13 @@
|
||||
type="number"
|
||||
step="any"
|
||||
required
|
||||
class="w-28 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
|
||||
class="kestrel-input w-28"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="poi-lng"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
class="kestrel-label"
|
||||
>Lng</label>
|
||||
<input
|
||||
id="poi-lng"
|
||||
@@ -39,39 +39,37 @@
|
||||
type="number"
|
||||
step="any"
|
||||
required
|
||||
class="w-28 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
|
||||
class="kestrel-input w-28"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="poi-label"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
class="kestrel-label"
|
||||
>Label</label>
|
||||
<input
|
||||
id="poi-label"
|
||||
v-model="form.label"
|
||||
type="text"
|
||||
class="w-40 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
|
||||
class="kestrel-input w-40"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="poi-icon"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
class="kestrel-label"
|
||||
>Icon</label>
|
||||
<select
|
||||
id="poi-icon"
|
||||
v-model="form.iconType"
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
|
||||
class="kestrel-input w-28"
|
||||
>
|
||||
<option value="pin">
|
||||
pin
|
||||
</option>
|
||||
<option value="flag">
|
||||
flag
|
||||
</option>
|
||||
<option value="waypoint">
|
||||
waypoint
|
||||
<option
|
||||
v-for="opt in POI_ICON_TYPES"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ opt }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -145,6 +143,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])
|
||||
|
||||
const { data: poisData, refresh } = usePois()
|
||||
const { canEditPois } = useUser()
|
||||
const poisList = computed(() => poisData.value ?? [])
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
<h2 class="kestrel-page-heading mb-4">
|
||||
Settings
|
||||
</h2>
|
||||
|
||||
<!-- Map & offline -->
|
||||
<section class="mb-8">
|
||||
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
|
||||
<h3 class="kestrel-section-label">
|
||||
Map & offline
|
||||
</h3>
|
||||
<div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
|
||||
<div class="kestrel-card p-4">
|
||||
<p class="mb-3 text-sm text-kestrel-text">
|
||||
Clear saved map tiles to free storage. The map will load tiles from the network again when you use it.
|
||||
</p>
|
||||
@@ -28,7 +27,7 @@
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border disabled:opacity-50"
|
||||
class="kestrel-btn-secondary disabled:opacity-50"
|
||||
:disabled="tilesLoading"
|
||||
@click="onClearTiles"
|
||||
>
|
||||
@@ -37,12 +36,11 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- About -->
|
||||
<section>
|
||||
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
|
||||
<h3 class="kestrel-section-label">
|
||||
About
|
||||
</h3>
|
||||
<div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
|
||||
<div class="kestrel-card p-4">
|
||||
<p class="font-medium text-kestrel-text">
|
||||
KestrelOS
|
||||
</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex min-h-[80vh] flex-col items-center justify-center p-6">
|
||||
<div class="w-full max-w-md rounded-lg border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_24px_-6px_rgba(34,201,201,0.2)]">
|
||||
<h2 class="mb-2 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
<div class="kestrel-card-modal w-full max-w-md p-6">
|
||||
<h2 class="kestrel-section-heading mb-2">
|
||||
Share live (camera + location)
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-kestrel-muted">
|
||||
@@ -55,7 +55,7 @@
|
||||
<!-- Local preview -->
|
||||
<div
|
||||
v-if="stream && videoRef"
|
||||
class="relative mb-4 aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black"
|
||||
class="kestrel-video-frame mb-4"
|
||||
>
|
||||
<video
|
||||
ref="videoRef"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** Wraps $fetch to redirect to /login on 401 for same-origin requests. */
|
||||
export default defineNuxtPlugin(() => {
|
||||
const route = useRoute()
|
||||
const baseFetch = globalThis.$fetch ?? $fetch
|
||||
@@ -6,8 +7,7 @@ export default defineNuxtPlugin(() => {
|
||||
if (response?.status !== 401) return
|
||||
const url = typeof request === 'string' ? request : request?.url ?? ''
|
||||
if (!url.startsWith('/')) return
|
||||
const redirect = (route.fullPath && route.fullPath !== '/' ? route.fullPath : '/')
|
||||
navigateTo({ path: '/login', query: { redirect } }, { replace: true })
|
||||
navigateTo({ path: '/login', query: { redirect: route.fullPath || '/' } }, { replace: true })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,88 +1,30 @@
|
||||
/**
|
||||
* Client-side logger that sends logs to server for debugging.
|
||||
* Falls back to console if server logging fails.
|
||||
*/
|
||||
|
||||
/** Client-side logger: sends to server, falls back to console. */
|
||||
let sessionId = null
|
||||
let userId = null
|
||||
|
||||
/**
|
||||
* Initialize logger with session/user context.
|
||||
* @param {string} sessId
|
||||
* @param {string} uid
|
||||
*/
|
||||
const CONSOLE_METHOD = Object.freeze({ error: 'error', warn: 'warn', info: 'log', debug: 'log' })
|
||||
|
||||
export function initLogger(sessId, uid) {
|
||||
sessionId = sessId
|
||||
userId = uid
|
||||
}
|
||||
|
||||
/**
|
||||
* Send log to server (non-blocking).
|
||||
* @param {string} level
|
||||
* @param {string} message
|
||||
* @param {object} data
|
||||
*/
|
||||
async function sendToServer(level, message, data) {
|
||||
// Use setTimeout to avoid blocking - fire and forget
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await $fetch('/api/log', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
level,
|
||||
message,
|
||||
data,
|
||||
sessionId,
|
||||
userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
credentials: 'include',
|
||||
}).catch(() => {
|
||||
// Silently fail - don't spam console if server is down
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// Ignore errors - logging shouldn't break the app
|
||||
}
|
||||
function sendToServer(level, message, data) {
|
||||
setTimeout(() => {
|
||||
$fetch('/api/log', {
|
||||
method: 'POST',
|
||||
body: { level, message, data, sessionId, userId, timestamp: new Date().toISOString() },
|
||||
credentials: 'include',
|
||||
}).catch(() => { /* server down - don't spam console */ })
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log at error level.
|
||||
* @param {string} message
|
||||
* @param {object} data
|
||||
*/
|
||||
export function logError(message, data) {
|
||||
console.error(`[${message}]`, data)
|
||||
sendToServer('error', message, data)
|
||||
function log(level, message, data) {
|
||||
console[CONSOLE_METHOD[level]](`[${message}]`, data)
|
||||
sendToServer(level, message, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log at warn level.
|
||||
* @param {string} message
|
||||
* @param {object} data
|
||||
*/
|
||||
export function logWarn(message, data) {
|
||||
console.warn(`[${message}]`, data)
|
||||
sendToServer('warn', message, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log at info level.
|
||||
* @param {string} message
|
||||
* @param {object} data
|
||||
*/
|
||||
export function logInfo(message, data) {
|
||||
console.log(`[${message}]`, data)
|
||||
sendToServer('info', message, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log at debug level.
|
||||
* @param {string} message
|
||||
* @param {object} data
|
||||
*/
|
||||
export function logDebug(message, data) {
|
||||
console.log(`[${message}]`, data)
|
||||
sendToServer('debug', message, data)
|
||||
}
|
||||
export const logError = (message, data) => log('error', message, data)
|
||||
export const logWarn = (message, data) => log('warn', message, data)
|
||||
export const logInfo = (message, data) => log('info', message, data)
|
||||
export const logDebug = (message, data) => log('debug', message, data)
|
||||
|
||||
@@ -27,6 +27,7 @@ export default defineNuxtConfig({
|
||||
],
|
||||
},
|
||||
},
|
||||
css: ['~/assets/css/main.css'],
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
version: pkg.version ?? '',
|
||||
|
||||
@@ -47,7 +47,7 @@ body="## Changelog
|
||||
|
||||
## Installation
|
||||
- [Docker image](${CI_FORGE_URL}/${CI_REPO_OWNER}/-/packages/container/${CI_REPO_NAME})
|
||||
- [Helm chart](${CI_FORGE_URL}/${CI_REPO_OWNER}/-/packages/helm)"
|
||||
- [Helm chart](${CI_FORGE_URL}/${CI_REPO_OWNER}/-/packages/helm/${CI_REPO_NAME})"
|
||||
release_url="${CI_FORGE_URL}/api/v1/repos/${CI_REPO_OWNER}/${CI_REPO_NAME}/releases"
|
||||
echo "$body" | awk -v tag="v$newVersion" 'BEGIN{printf "{\"tag_name\":\"" tag "\",\"name\":\"" tag "\",\"body\":\""} { gsub(/\\/,"\\\\"); gsub(/"/,"\\\""); if (NR>1) printf "\\n"; printf "%s", $0 } END{printf "\"}\n"}' > /tmp/release.json
|
||||
wget -q -O /dev/null --post-file=/tmp/release.json \
|
||||
|
||||
@@ -7,6 +7,6 @@ export default defineEventHandler(async (event) => {
|
||||
requireAuth(event)
|
||||
const [db, sessions] = await Promise.all([getDb(), getActiveSessions()])
|
||||
const rows = await db.all('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices ORDER BY id')
|
||||
const devices = rows.map(r => rowToDevice(r)).filter(Boolean).map(sanitizeDeviceForResponse)
|
||||
const devices = rows.map(rowToDevice).filter(Boolean).map(sanitizeDeviceForResponse)
|
||||
return { devices, liveSessions: sessions }
|
||||
})
|
||||
|
||||
@@ -1,32 +1,11 @@
|
||||
/**
|
||||
* Client-side logging endpoint.
|
||||
* Accepts log messages from the browser and outputs them server-side.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Note: Auth is optional - we rely on session cookie validation if needed
|
||||
const CONSOLE_METHOD = Object.freeze({ error: 'error', warn: 'warn', info: 'log', debug: 'log' })
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event).catch(() => ({}))
|
||||
const { level, message, data, sessionId, userId } = body
|
||||
|
||||
const logPrefix = `[CLIENT${sessionId ? `:${sessionId}` : ''}${userId ? `:${userId.slice(0, 8)}` : ''}]`
|
||||
const logMessage = data ? `${message} ${JSON.stringify(data)}` : message
|
||||
|
||||
switch (level) {
|
||||
case 'error':
|
||||
console.error(logPrefix, logMessage)
|
||||
break
|
||||
case 'warn':
|
||||
console.warn(logPrefix, logMessage)
|
||||
break
|
||||
case 'info':
|
||||
console.log(logPrefix, logMessage)
|
||||
break
|
||||
case 'debug':
|
||||
console.log(logPrefix, logMessage)
|
||||
break
|
||||
default:
|
||||
console.log(logPrefix, logMessage)
|
||||
}
|
||||
|
||||
const prefix = `[CLIENT${sessionId ? `:${sessionId}` : ''}${userId ? `:${userId.slice(0, 8)}` : ''}]`
|
||||
const msg = data ? `${message} ${JSON.stringify(data)}` : message
|
||||
const method = CONSOLE_METHOD[level] || 'log'
|
||||
console[method](prefix, msg)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { requireAuth } from '../utils/authHelpers.js'
|
||||
|
||||
const ICON_TYPES = ['pin', 'flag', 'waypoint']
|
||||
import { POI_ICON_TYPES } from '../utils/poiConstants.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event, { role: 'adminOrLeader' })
|
||||
const body = await readBody(event)
|
||||
const lat = Number(body?.lat)
|
||||
const lng = Number(body?.lng)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
|
||||
throw createError({ statusCode: 400, message: 'lat and lng required as numbers' })
|
||||
}
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) throw createError({ statusCode: 400, message: 'lat and lng required as numbers' })
|
||||
const label = typeof body?.label === 'string' ? body.label.trim() : ''
|
||||
const iconType = ICON_TYPES.includes(body?.iconType) ? body.iconType : 'pin'
|
||||
const iconType = POI_ICON_TYPES.includes(body?.iconType) ? body.iconType : 'pin'
|
||||
const id = crypto.randomUUID()
|
||||
const { run } = await getDb()
|
||||
await run(
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { getDb } from '../../utils/db.js'
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
|
||||
const ICON_TYPES = ['pin', 'flag', 'waypoint']
|
||||
import { POI_ICON_TYPES } from '../../utils/poiConstants.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event, { role: 'adminOrLeader' })
|
||||
const id = event.context.params?.id
|
||||
if (!id) throw createError({ statusCode: 400, message: 'id required' })
|
||||
const body = await readBody(event) || {}
|
||||
const body = (await readBody(event)) || {}
|
||||
const updates = []
|
||||
const params = []
|
||||
if (typeof body.label === 'string') {
|
||||
updates.push('label = ?')
|
||||
params.push(body.label.trim())
|
||||
}
|
||||
if (ICON_TYPES.includes(body.iconType)) {
|
||||
if (POI_ICON_TYPES.includes(body.iconType)) {
|
||||
updates.push('icon_type = ?')
|
||||
params.push(body.iconType)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { getDb, closeDb } from '../utils/db.js'
|
||||
|
||||
/**
|
||||
* Initialize DB at server startup.
|
||||
* Close DB on server shutdown to avoid native sqlite3 crashes in worker teardown.
|
||||
*/
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
void getDb()
|
||||
nitroApp.hooks.hook('close', () => {
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
/**
|
||||
* WebSocket server for WebRTC signaling.
|
||||
* Attaches to Nitro's HTTP server and handles WebSocket connections.
|
||||
*/
|
||||
|
||||
import { WebSocketServer } from 'ws'
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { handleWebSocketMessage } from '../utils/webrtcSignaling.js'
|
||||
|
||||
/**
|
||||
* Parse cookie header string into object.
|
||||
* @param {string} cookieHeader
|
||||
* @returns {Record<string, string>} Parsed cookie name-value pairs.
|
||||
*/
|
||||
function parseCookie(cookieHeader) {
|
||||
const cookies = {}
|
||||
if (!cookieHeader) return cookies
|
||||
@@ -25,30 +15,16 @@ function parseCookie(cookieHeader) {
|
||||
}
|
||||
|
||||
let wss = null
|
||||
const connections = new Map() // sessionId -> Set<WebSocket>
|
||||
const connections = new Map()
|
||||
|
||||
/**
|
||||
* Get WebSocket server instance.
|
||||
* @returns {WebSocketServer | null} WebSocket server instance or null.
|
||||
*/
|
||||
export function getWebSocketServer() {
|
||||
return wss
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connections for a session.
|
||||
* @param {string} sessionId
|
||||
* @returns {Set<WebSocket>} Set of WebSockets for the session.
|
||||
*/
|
||||
export function getSessionConnections(sessionId) {
|
||||
return connections.get(sessionId) || new Set()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add connection to session.
|
||||
* @param {string} sessionId
|
||||
* @param {WebSocket} ws
|
||||
*/
|
||||
export function addSessionConnection(sessionId, ws) {
|
||||
if (!connections.has(sessionId)) {
|
||||
connections.set(sessionId, new Set())
|
||||
@@ -56,11 +32,6 @@ export function addSessionConnection(sessionId, ws) {
|
||||
connections.get(sessionId).add(ws)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove connection from session.
|
||||
* @param {string} sessionId
|
||||
* @param {WebSocket} ws
|
||||
*/
|
||||
export function removeSessionConnection(sessionId, ws) {
|
||||
const conns = connections.get(sessionId)
|
||||
if (conns) {
|
||||
@@ -71,11 +42,6 @@ export function removeSessionConnection(sessionId, ws) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to all connections for a session.
|
||||
* @param {string} sessionId
|
||||
* @param {object} message
|
||||
*/
|
||||
export function broadcastToSession(sessionId, message) {
|
||||
const conns = getSessionConnections(sessionId)
|
||||
const data = JSON.stringify(message)
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
/**
|
||||
* Read auth config from env. Returns only non-secret data for client.
|
||||
* Auth always allows local (password) sign-in and OIDC when configured.
|
||||
* @returns {{ oidc: { enabled: boolean, label: string } }} Public auth config (oidc.enabled, oidc.label).
|
||||
*/
|
||||
export function getAuthConfig() {
|
||||
const hasOidcEnv
|
||||
= process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET
|
||||
const envLabel = process.env.OIDC_LABEL ?? ''
|
||||
const label = envLabel || (hasOidcEnv ? 'Sign in with OIDC' : '')
|
||||
return {
|
||||
oidc: {
|
||||
enabled: !!hasOidcEnv,
|
||||
label,
|
||||
},
|
||||
}
|
||||
const hasOidc = !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET)
|
||||
const label = process.env.OIDC_LABEL?.trim() || (hasOidc ? 'Sign in with OIDC' : '')
|
||||
return Object.freeze({ oidc: { enabled: hasOidc, label } })
|
||||
}
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
/**
|
||||
* Require authenticated user. Optionally require role. Throws 401 if none, 403 if role insufficient.
|
||||
* @param {import('h3').H3Event} event
|
||||
* @param {{ role?: 'admin' | 'adminOrLeader' }} [opts] - role: 'admin' = admin only; 'adminOrLeader' = admin or leader
|
||||
* @returns {{ id: string, identifier: string, role: string }} The current user.
|
||||
*/
|
||||
const ROLES_ADMIN_OR_LEADER = Object.freeze(['admin', 'leader'])
|
||||
|
||||
export function requireAuth(event, opts = {}) {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
if (!user) throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
const { role } = opts
|
||||
if (role === 'admin' && user.role !== 'admin') {
|
||||
throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
}
|
||||
if (role === 'adminOrLeader' && user.role !== 'admin' && user.role !== 'leader') {
|
||||
throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
}
|
||||
if (role === 'admin' && user.role !== 'admin') throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
if (role === 'adminOrLeader' && !ROLES_ADMIN_OR_LEADER.includes(user.role)) throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
return user
|
||||
}
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
/**
|
||||
* Paths that skip auth middleware (no session required).
|
||||
* Do not add a path here if any handler under it uses requireAuth (with or without role).
|
||||
* When adding a new API route that requires auth, add its path prefix to PROTECTED_PATH_PREFIXES below
|
||||
* so tests can assert it is never skipped.
|
||||
*/
|
||||
export const SKIP_PATHS = [
|
||||
/** Paths that skip auth (no session required). Do not add if any handler uses requireAuth. */
|
||||
export const SKIP_PATHS = Object.freeze([
|
||||
'/api/auth/login',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/config',
|
||||
'/api/auth/oidc/authorize',
|
||||
'/api/auth/oidc/callback',
|
||||
]
|
||||
])
|
||||
|
||||
/**
|
||||
* Path prefixes for API routes that require an authenticated user (or role).
|
||||
* Every path in this list must NOT be skipped (skipAuth must return false).
|
||||
* Used by tests to prevent protected routes from being added to SKIP_PATHS.
|
||||
*/
|
||||
export const PROTECTED_PATH_PREFIXES = [
|
||||
/** Path prefixes for protected routes. Used by tests to ensure they're never in SKIP_PATHS. */
|
||||
export const PROTECTED_PATH_PREFIXES = Object.freeze([
|
||||
'/api/cameras',
|
||||
'/api/devices',
|
||||
'/api/live',
|
||||
'/api/me',
|
||||
'/api/pois',
|
||||
'/api/users',
|
||||
]
|
||||
])
|
||||
|
||||
export function skipAuth(path) {
|
||||
if (path.startsWith('/api/health') || path === '/health') return true
|
||||
|
||||
11
server/utils/bootstrap.js
vendored
11
server/utils/bootstrap.js
vendored
@@ -1,13 +1,10 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { hashPassword } from './password.js'
|
||||
|
||||
const DEFAULT_ADMIN_IDENTIFIER = 'admin'
|
||||
const PASSWORD_CHARS = 'abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789'
|
||||
const PASSWORD_CHARS = Object.freeze('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789')
|
||||
|
||||
const generateRandomPassword = () => {
|
||||
const bytes = randomBytes(14)
|
||||
return Array.from(bytes, b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
|
||||
}
|
||||
const generateRandomPassword = () =>
|
||||
Array.from(randomBytes(14), b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
|
||||
|
||||
export async function bootstrapAdmin(run, get) {
|
||||
const row = await get('SELECT COUNT(*) as n FROM users')
|
||||
@@ -15,7 +12,7 @@ export async function bootstrapAdmin(run, get) {
|
||||
|
||||
const email = process.env.BOOTSTRAP_EMAIL?.trim()
|
||||
const password = process.env.BOOTSTRAP_PASSWORD
|
||||
const identifier = (email && password) ? email : DEFAULT_ADMIN_IDENTIFIER
|
||||
const identifier = (email && password) ? email : 'admin'
|
||||
const plainPassword = (email && password) ? password : generateRandomPassword()
|
||||
|
||||
await run(
|
||||
|
||||
1
server/utils/poiConstants.js
Normal file
1
server/utils/poiConstants.js
Normal file
@@ -0,0 +1 @@
|
||||
export const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])
|
||||
@@ -1,15 +1,6 @@
|
||||
const DEFAULT_DAYS = 7
|
||||
const MIN_DAYS = 1
|
||||
const MAX_DAYS = 365
|
||||
const [MIN_DAYS, MAX_DAYS, DEFAULT_DAYS] = [1, 365, 7]
|
||||
|
||||
/**
|
||||
* Session lifetime in days (for cookie and DB expires_at). Uses SESSION_MAX_AGE_DAYS.
|
||||
* Clamped to 1–365 days.
|
||||
*/
|
||||
export function getSessionMaxAgeDays() {
|
||||
const raw = process.env.SESSION_MAX_AGE_DAYS != null
|
||||
? Number.parseInt(process.env.SESSION_MAX_AGE_DAYS, 10)
|
||||
: Number.NaN
|
||||
if (Number.isFinite(raw)) return Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw))
|
||||
return DEFAULT_DAYS
|
||||
const raw = Number.parseInt(process.env.SESSION_MAX_AGE_DAYS ?? '', 10)
|
||||
return Number.isFinite(raw) ? Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw)) : DEFAULT_DAYS
|
||||
}
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
/**
|
||||
* WebRTC signaling message handlers.
|
||||
* Processes WebSocket messages for WebRTC operations.
|
||||
*/
|
||||
|
||||
import { getLiveSession, updateLiveSession } from './liveSessions.js'
|
||||
import { getRouter, createTransport, getTransport } from './mediasoup.js'
|
||||
|
||||
/**
|
||||
* Handle WebSocket message for WebRTC signaling.
|
||||
* @param {string} userId
|
||||
* @param {string} sessionId
|
||||
* @param {string} type
|
||||
* @param {object} data
|
||||
* @returns {Promise<object | null>} Response message or null
|
||||
*/
|
||||
export async function handleWebSocketMessage(userId, sessionId, type, data) {
|
||||
const session = getLiveSession(sessionId)
|
||||
if (!session) {
|
||||
|
||||
@@ -16,9 +16,11 @@ export default {
|
||||
kestrel: {
|
||||
'bg': '#060b14',
|
||||
'surface': '#0d1424',
|
||||
'surface-elevated': '#1e293b',
|
||||
'surface-hover': '#111a2e',
|
||||
'border': '#1a2744',
|
||||
'text': '#b8c9e0',
|
||||
'text-bright': '#e2e8f0',
|
||||
'muted': '#5c6f8a',
|
||||
'accent': '#22c9c9',
|
||||
'accent-dim': '#0f3d3d',
|
||||
@@ -30,12 +32,30 @@ export default {
|
||||
'glow': '0 0 20px -4px rgba(34, 201, 201, 0.3)',
|
||||
'glow-md': '0 0 24px -2px rgba(34, 201, 201, 0.25)',
|
||||
'glow-border': 'inset 0 0 20px -8px rgba(34, 201, 201, 0.15)',
|
||||
'glow-header': '0 0 20px -4px rgba(34, 201, 201, 0.15)',
|
||||
'glow-dropdown': '0 4px 12px -2px rgba(34, 201, 201, 0.15)',
|
||||
'glow-panel': '-8px 0 24px -4px rgba(34, 201, 201, 0.12)',
|
||||
'glow-modal': '0 0 32px -8px rgba(34, 201, 201, 0.25)',
|
||||
'glow-card': '0 0 20px -4px rgba(34, 201, 201, 0.15)',
|
||||
'glow-context': '0 0 20px -4px rgba(34, 201, 201, 0.2)',
|
||||
'glow-inset-video': 'inset 0 0 20px -8px rgba(34, 201, 201, 0.1)',
|
||||
'border-header': '0 1px 0 0 rgba(34, 201, 201, 0.08)',
|
||||
'elevated': '0 4px 12px rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
textShadow: {
|
||||
'glow': '0 0 12px rgba(34, 201, 201, 0.4)',
|
||||
'glow-sm': '0 0 8px rgba(34, 201, 201, 0.3)',
|
||||
'glow-sm': '0 0 8px rgba(34, 201, 201, 0.25)',
|
||||
'glow-md': '0 0 12px rgba(34, 201, 201, 0.35)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [
|
||||
function ({ addUtilities, theme }) {
|
||||
addUtilities({
|
||||
'.text-shadow-glow-sm': { textShadow: theme('textShadow.glow-sm') },
|
||||
'.text-shadow-glow': { textShadow: theme('textShadow.glow') },
|
||||
'.text-shadow-glow-md': { textShadow: theme('textShadow.glow-md') },
|
||||
})
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user