minor: heavily simplify server and app content. unify styling (#4)
All checks were successful
ci/woodpecker/push/push Pipeline was successful

Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-02-14 04:52:18 +00:00
parent 1a143d2f8e
commit 17f28401ba
40 changed files with 595 additions and 933 deletions

128
app/assets/css/main.css Normal file
View 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; }
}

View File

@@ -7,18 +7,18 @@
/> />
<aside <aside
v-else v-else
class="flex flex-col border border-kestrel-border bg-kestrel-surface" class="kestrel-panel-base"
:class="asideClass" :class="inline ? 'kestrel-panel-inline' : 'kestrel-panel-overlay'"
role="dialog" role="dialog"
aria-label="Camera feed" 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)]"> <div class="kestrel-panel-header">
<h2 class="font-medium tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"> <h2 class="font-medium tracking-wide text-kestrel-text text-shadow-glow-sm">
{{ camera?.name ?? 'Camera' }} {{ camera?.name ?? 'Camera' }}
</h2> </h2>
<button <button
type="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" aria-label="Close panel"
@click="$emit('close')" @click="$emit('close')"
> >
@@ -26,7 +26,7 @@
</button> </button>
</div> </div>
<div class="flex-1 overflow-auto p-4"> <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'"> <template v-if="sourceType === 'hls'">
<video <video
ref="videoRef" ref="videoRef"
@@ -75,18 +75,14 @@ defineEmits(['close'])
const videoRef = ref(null) const videoRef = ref(null)
const streamError = ref(false) const streamError = ref(false)
const isLiveSession = computed(() => const isLiveSession = computed(() => props.camera?.hasStream !== undefined)
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 streamUrl = computed(() => props.camera?.streamUrl ?? '') const streamUrl = computed(() => props.camera?.streamUrl ?? '')
const sourceType = computed(() => (props.camera?.sourceType === 'hls' ? 'hls' : 'mjpeg')) const sourceType = computed(() => (props.camera?.sourceType === 'hls' ? 'hls' : 'mjpeg'))
const safeStreamUrl = computed(() => { const safeStreamUrl = computed(() => {
const u = streamUrl.value const u = streamUrl.value?.trim()
return typeof u === 'string' && u.trim() && (u.startsWith('http://') || u.startsWith('https://')) ? u.trim() : '' return (u?.startsWith('http://') || u?.startsWith('https://')) ? u : ''
}) })
function initHls() { function initHls() {

View File

@@ -7,13 +7,13 @@
<div <div
v-if="contextMenu.type" v-if="contextMenu.type"
ref="contextMenuRef" 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' }" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
> >
<template v-if="contextMenu.type === 'map'"> <template v-if="contextMenu.type === 'map'">
<button <button
type="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)" @click="openAddPoiModal(contextMenu.latlng)"
> >
Add POI here Add POI here
@@ -22,14 +22,14 @@
<template v-else-if="contextMenu.type === 'poi'"> <template v-else-if="contextMenu.type === 'poi'">
<button <button
type="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)" @click="openEditPoiModal(contextMenu.poi)"
> >
Edit Edit
</button> </button>
<button <button
type="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)" @click="openDeletePoiModal(contextMenu.poi)"
> >
Delete Delete
@@ -37,176 +37,16 @@
</template> </template>
</div> </div>
<!-- POI modal (Add / Edit) --> <PoiModal
<Teleport to="body"> :show="showPoiModal"
<Transition name="modal"> :mode="poiModalMode"
<div :form="poiForm"
v-if="showPoiModal" :edit-poi="editPoi"
class="fixed inset-0 z-[2000] flex items-center justify-center p-4" :delete-poi="deletePoi"
role="dialog" @close="closePoiModal"
aria-modal="true" @submit="onPoiSubmit"
:aria-labelledby="poiModalMode === 'delete' ? 'delete-poi-title' : 'poi-modal-title'" @confirm-delete="confirmDeletePoi"
@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>
</div> </div>
</template> </template>
@@ -244,14 +84,11 @@ const liveMarkersRef = ref({})
const contextMenu = ref({ ...CONTEXT_MENU_EMPTY }) const contextMenu = ref({ ...CONTEXT_MENU_EMPTY })
const showPoiModal = ref(false) const showPoiModal = ref(false)
const poiModalRef = ref(null)
const poiModalMode = ref('add') // 'add' | 'edit' | 'delete' const poiModalMode = ref('add') // 'add' | 'edit' | 'delete'
const addPoiLatlng = ref(null) const addPoiLatlng = ref(null)
const editPoi = ref(null) const editPoi = ref(null)
const deletePoi = ref(null) const deletePoi = ref(null)
const poiForm = ref({ label: '', iconType: 'pin' }) 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_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'
const TILE_SUBDOMAINS = 'abcd' const TILE_SUBDOMAINS = 'abcd'
@@ -259,11 +96,7 @@ const ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">Op
const DEFAULT_VIEW = [37.7749, -122.4194] const DEFAULT_VIEW = [37.7749, -122.4194]
const DEFAULT_ZOOM = 17 const DEFAULT_ZOOM = 17
const MARKER_ICON_PATH = '/' const MARKER_ICON_PATH = '/'
const POI_ICON_TYPES = ['pin', 'flag', 'waypoint']
const POI_TOOLTIP_CLASS = 'kestrel-poi-tooltip' 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 POI_ICON_COLORS = { pin: '#22c9c9', flag: '#e53e3e', waypoint: '#a78bfa' }
const ICON_SIZE = 28 const ICON_SIZE = 28
@@ -279,8 +112,9 @@ function getPoiIconSvg(type) {
return shapes[type] || shapes.pin return shapes[type] || shapes.pin
} }
const VALID_POI_TYPES = ['pin', 'flag', 'waypoint']
function getPoiIcon(L, poi) { 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) const html = getPoiIconSvg(type)
return L.divIcon({ return L.divIcon({
className: 'poi-div-icon', 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) { 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>` 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({ return L.divIcon({
@@ -439,7 +273,7 @@ function updateLiveMarkers() {
}) })
const next = sessions.reduce((acc, session) => { 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] const existing = prev[session.id]
if (existing) { if (existing) {
existing.setLatLng([session.lat, session.lng]) existing.setLatLng([session.lat, session.lng])
@@ -473,7 +307,6 @@ function openAddPoiModal(latlng) {
editPoi.value = null editPoi.value = null
deletePoi.value = null deletePoi.value = null
poiForm.value = { label: '', iconType: 'pin' } poiForm.value = { label: '', iconType: 'pin' }
iconDropdownOpen.value = false
showPoiModal.value = true showPoiModal.value = true
} }
@@ -484,7 +317,6 @@ function openEditPoiModal(poi) {
addPoiLatlng.value = null addPoiLatlng.value = null
deletePoi.value = null deletePoi.value = null
poiForm.value = { label: (poi.label ?? '').trim(), iconType: poi.icon_type || 'pin' } poiForm.value = { label: (poi.label ?? '').trim(), iconType: poi.icon_type || 'pin' }
iconDropdownOpen.value = false
showPoiModal.value = true showPoiModal.value = true
} }
@@ -500,52 +332,38 @@ function openDeletePoiModal(poi) {
function closePoiModal() { function closePoiModal() {
showPoiModal.value = false showPoiModal.value = false
poiModalMode.value = 'add' poiModalMode.value = 'add'
iconDropdownOpen.value = false
addPoiLatlng.value = null addPoiLatlng.value = null
editPoi.value = null editPoi.value = null
deletePoi.value = null deletePoi.value = null
} }
function onPoiModalDocumentClick(e) { async function doPoiFetch(fn) {
if (!showPoiModal.value) return try {
if (iconDropdownOpen.value && iconDropdownRef.value && !iconDropdownRef.value.contains(e.target)) { await fn()
iconDropdownOpen.value = false 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') { if (poiModalMode.value === 'add') {
const latlng = addPoiLatlng.value const latlng = addPoiLatlng.value
if (!latlng) return if (!latlng) return
const { label, iconType } = poiForm.value await doPoiFetch(() => $fetch('/api/pois', { method: 'POST', body: { ...body, lat: latlng.lat, lng: latlng.lng } }))
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 */ }
return return
} }
if (poiModalMode.value === 'edit' && editPoi.value) { if (poiModalMode.value === 'edit' && editPoi.value) {
const { label, iconType } = poiForm.value await doPoiFetch(() => $fetch(`/api/pois/${editPoi.value.id}`, { method: 'PATCH', body }))
try {
await $fetch(`/api/pois/${editPoi.value.id}`, { method: 'PATCH', body: { label: (label ?? '').trim(), iconType: iconType || 'pin' } })
emit('refreshPois')
closePoiModal()
}
catch { /* ignore */ }
} }
} }
async function confirmDeletePoi() { async function confirmDeletePoi() {
const poi = deletePoi.value const poi = deletePoi.value
if (!poi?.id) return if (!poi?.id) return
try { await doPoiFetch(() => $fetch(`/api/pois/${poi.id}`, { method: 'DELETE' }))
await $fetch(`/api/pois/${poi.id}`, { method: 'DELETE' })
emit('refreshPois')
closePoiModal()
}
catch { /* ignore */ }
} }
function destroyMap() { function destroyMap() {
@@ -604,7 +422,6 @@ onMounted(async () => {
leafletRef.value = { L, offlineApi: offline } leafletRef.value = { L, offlineApi: offline }
initMapWithLocation() initMapWithLocation()
document.addEventListener('click', onDocumentClick) document.addEventListener('click', onDocumentClick)
document.addEventListener('click', onPoiModalDocumentClick)
}) })
function onDocumentClick(e) { function onDocumentClick(e) {
@@ -613,7 +430,6 @@ function onDocumentClick(e) {
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick) document.removeEventListener('click', onDocumentClick)
document.removeEventListener('click', onPoiModalDocumentClick)
destroyMap() destroyMap()
}) })
@@ -621,158 +437,3 @@ watch(() => props.devices, () => updateMarkers(), { deep: true })
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true }) watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true }) watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
</script> </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 Leaflets plus-lighter so unloaded/empty tiles dont 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>

View File

@@ -1,17 +1,17 @@
<template> <template>
<aside <aside
class="flex flex-col border border-kestrel-border bg-kestrel-surface" class="kestrel-panel-base"
: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="inline ? 'kestrel-panel-inline' : 'kestrel-panel-overlay'"
role="dialog" role="dialog"
aria-label="Live feed" 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)]"> <div class="kestrel-panel-header">
<h2 class="font-medium tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"> <h2 class="font-medium tracking-wide text-kestrel-text text-shadow-glow-sm">
{{ session?.label ?? 'Live' }} {{ session?.label ?? 'Live' }}
</h2> </h2>
<button <button
type="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" aria-label="Close panel"
@click="$emit('close')" @click="$emit('close')"
> >
@@ -22,7 +22,7 @@
<p class="mb-3 text-xs text-kestrel-muted"> <p class="mb-3 text-xs text-kestrel-muted">
Live camera feed (WebRTC) Live camera feed (WebRTC)
</p> </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 <video
ref="videoRef" ref="videoRef"
autoplay autoplay

View File

@@ -17,14 +17,14 @@
:aria-expanded="modelValue" :aria-expanded="modelValue"
> >
<div <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"> <h2 class="text-sm font-medium uppercase tracking-wider text-kestrel-muted">
Navigation Navigation
</h2> </h2>
<button <button
type="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" aria-label="Close navigation"
@click="close" @click="close"
> >
@@ -41,7 +41,7 @@
:to="item.to" :to="item.to"
class="block rounded px-3 py-2 text-sm transition-colors" class="block rounded px-3 py-2 text-sm transition-colors"
:class="isActive(item.to) :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'" : 'border-l-2 border-transparent text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-text'"
@click="close" @click="close"
> >
@@ -102,24 +102,3 @@ onBeforeUnmount(() => {
document.removeEventListener('keydown', onEscape) document.removeEventListener('keydown', onEscape)
}) })
</script> </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
View 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>

View 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 },
)
}

View File

@@ -1,16 +1,19 @@
/** /** Fetches devices + live sessions; polls when tab visible. */
* Fetches devices + live sessions (unified cameras). Optionally polls when tab is visible.
*/
const POLL_MS = 1500 const POLL_MS = 1500
const EMPTY_RESPONSE = Object.freeze({ devices: [], liveSessions: [] })
export function useCameras(options = {}) { export function useCameras(options = {}) {
const { poll: enablePoll = true } = options const { poll: enablePoll = true } = options
const { data, refresh } = useAsyncData( const { data, refresh } = useAsyncData(
'cameras', 'cameras',
() => $fetch('/api/cameras').catch(() => ({ devices: [], liveSessions: [] })), () => $fetch('/api/cameras').catch(() => EMPTY_RESPONSE),
{ default: () => ({ devices: [], liveSessions: [] }) }, { 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) const pollInterval = ref(null)
function startPolling() { function startPolling() {
if (!enablePoll || pollInterval.value) return if (!enablePoll || pollInterval.value) return
@@ -27,22 +30,11 @@ export function useCameras(options = {}) {
onMounted(() => { onMounted(() => {
if (typeof document === 'undefined') return if (typeof document === 'undefined') return
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') { document.visibilityState === 'visible' ? (startPolling(), refresh()) : stopPolling()
startPolling()
refresh()
}
else {
stopPolling()
}
}) })
if (document.visibilityState === 'visible') startPolling() if (document.visibilityState === 'visible') startPolling()
}) })
onBeforeUnmount(stopPolling) onBeforeUnmount(stopPolling)
const devices = computed(() => data.value?.devices ?? []) return Object.freeze({ data, devices, liveSessions, cameras, refresh, startPolling, stopPolling })
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 }
} }

View File

@@ -1,24 +1,12 @@
/** /** Fetches live sessions; polls when tab visible. */
* 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.
*/
const POLL_MS = 1500 const POLL_MS = 1500
export function useLiveSessions() { export function useLiveSessions() {
const { data: sessions, refresh } = useAsyncData( const { data: _sessions, refresh } = useAsyncData(
'live-sessions', 'live-sessions',
async () => { async () => {
try { try {
const result = await $fetch('/api/live') return 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
} }
catch (err) { catch (err) {
const msg = err?.message ?? String(err) const msg = err?.message ?? String(err)
@@ -30,14 +18,13 @@ export function useLiveSessions() {
{ default: () => [] }, { default: () => [] },
) )
const sessions = computed(() => Object.freeze([...(_sessions.value ?? [])]))
const pollInterval = ref(null) const pollInterval = ref(null)
function startPolling() { function startPolling() {
if (pollInterval.value) return if (pollInterval.value) return
refresh() // Fetch immediately so new sessions show without waiting for first interval
pollInterval.value = setInterval(() => {
refresh() refresh()
}, POLL_MS) pollInterval.value = setInterval(refresh, POLL_MS)
} }
function stopPolling() { function stopPolling() {
@@ -49,21 +36,12 @@ export function useLiveSessions() {
onMounted(() => { onMounted(() => {
if (typeof document === 'undefined') return if (typeof document === 'undefined') return
const onFocus = () => startPolling()
const onBlur = () => stopPolling()
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') { document.visibilityState === 'visible' ? (startPolling(), refresh()) : stopPolling()
onFocus()
refresh() // Fresh data when returning to tab
}
else onBlur()
}) })
if (document.visibilityState === 'visible') startPolling() if (document.visibilityState === 'visible') startPolling()
}) })
onBeforeUnmount(stopPolling)
onBeforeUnmount(() => { return Object.freeze({ sessions, refresh, startPolling, stopPolling })
stopPolling()
})
return { sessions, refresh, startPolling, stopPolling }
} }

View File

@@ -1,3 +1,5 @@
const EDIT_ROLES = Object.freeze(['admin', 'leader'])
export function useUser() { export function useUser() {
const requestFetch = useRequestFetch() const requestFetch = useRequestFetch()
const { data: user, refresh } = useAsyncData( const { data: user, refresh } = useAsyncData(
@@ -5,7 +7,7 @@ export function useUser() {
() => (requestFetch ?? $fetch)('/api/me').catch(() => null), () => (requestFetch ?? $fetch)('/api/me').catch(() => null),
{ default: () => 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') const isAdmin = computed(() => user.value?.role === 'admin')
return { user, canEditPois, isAdmin, refresh } return Object.freeze({ user, canEditPois, isAdmin, refresh })
} }

View File

@@ -1,61 +1,26 @@
/** /** WebRTC/Mediasoup client utilities. */
* WebRTC composable for Mediasoup client operations.
* Handles device initialization, transport creation, and WebSocket signaling.
*/
import { logError, logWarn } from '../utils/logger.js' import { logError, logWarn } from '../utils/logger.js'
/** const FETCH_OPTS = { credentials: 'include' }
* 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')
}
// 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 } = await import('mediasoup-client')
const device = new Device() const device = new Device()
await device.load({ routerRtpCapabilities: rtpCapabilities }) await device.load({ routerRtpCapabilities: rtpCapabilities })
return device 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) { export function createWebSocketConnection(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = url.startsWith('ws') ? url : `${protocol}//${window.location.host}/ws` const wsUrl = url.startsWith('ws') ? url : `${protocol}//${window.location.host}/ws`
const ws = new WebSocket(wsUrl) const ws = new WebSocket(wsUrl)
ws.onopen = () => resolve(ws)
ws.onopen = () => { ws.onerror = () => reject(new Error('WebSocket connection failed'))
resolve(ws)
}
ws.onerror = () => {
reject(new Error('WebSocket connection failed'))
}
ws.onclose = () => {
// Connection closed
}
}) })
} }
/**
* 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 = {}) { export function sendWebSocketMessage(ws, sessionId, type, data = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (ws.readyState !== WebSocket.OPEN) { if (ws.readyState !== WebSocket.OPEN) {
@@ -95,41 +60,20 @@ export function sendWebSocketMessage(ws, sessionId, type, data = {}) {
}) })
} }
/** function attachTransportHandlers(transport, transportParams, sessionId, label, { onConnectSuccess, onConnectFailure } = {}) {
* 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,
})
transport.on('connect', async ({ dtlsParameters }, callback, errback) => { transport.on('connect', async ({ dtlsParameters }, callback, errback) => {
try { try {
await $fetch('/api/live/webrtc/connect-transport', { await $fetch('/api/live/webrtc/connect-transport', {
method: 'POST', method: 'POST',
body: { sessionId, transportId: transportParams.id, dtlsParameters }, body: { sessionId, transportId: transportParams.id, dtlsParameters },
credentials: 'include', ...FETCH_OPTS,
}) })
onConnectSuccess?.() onConnectSuccess?.()
callback() callback()
} }
catch (err) { catch (err) {
logError('useWebRTC: Send transport connect failed', { logError(`useWebRTC: ${label} transport connect failed`, {
err: err.message || String(err), err: err?.message ?? String(err),
transportId: transportParams.id, transportId: transportParams.id,
connectionState: transport.connectionState, connectionState: transport.connectionState,
sessionId, sessionId,
@@ -138,48 +82,50 @@ export async function createSendTransport(device, sessionId, options = {}) {
errback(err) errback(err)
} }
}) })
transport.on('connectionstatechange', () => { transport.on('connectionstatechange', () => {
const state = transport.connectionState const state = transport.connectionState
if (state === 'failed' || state === 'disconnected' || state === 'closed') { if (['failed', 'disconnected', 'closed'].includes(state)) {
logWarn('useWebRTC: Send transport connection state changed', { logWarn(`useWebRTC: ${label} transport connection state changed`, { state, transportId: transportParams.id, sessionId })
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) => { transport.on('produce', async ({ kind, rtpParameters }, callback, errback) => {
try { try {
const { id } = await $fetch('/api/live/webrtc/create-producer', { const { id } = await $fetch('/api/live/webrtc/create-producer', {
method: 'POST', method: 'POST',
body: { sessionId, transportId: transportParams.id, kind, rtpParameters }, body: { sessionId, transportId: transportParams.id, kind, rtpParameters },
credentials: 'include', ...FETCH_OPTS,
}) })
callback({ id }) callback({ id })
} }
catch (err) { catch (err) {
logError('useWebRTC: Producer creation failed', { err: err.message || String(err) }) logError('useWebRTC: Producer creation failed', { err: err?.message ?? String(err) })
errback(err) errback(err)
} }
}) })
return transport 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) { export async function createRecvTransport(device, sessionId) {
// Create transport via HTTP API
const transportParams = await $fetch('/api/live/webrtc/create-transport', { const transportParams = await $fetch('/api/live/webrtc/create-transport', {
method: 'POST', method: 'POST',
body: { sessionId, isProducer: false }, body: { sessionId, isProducer: false },
credentials: 'include', ...FETCH_OPTS,
}) })
const transport = device.createRecvTransport({ const transport = device.createRecvTransport({
id: transportParams.id, id: transportParams.id,
@@ -187,55 +133,15 @@ export async function createRecvTransport(device, sessionId) {
iceCandidates: transportParams.iceCandidates, iceCandidates: transportParams.iceCandidates,
dtlsParameters: transportParams.dtlsParameters, dtlsParameters: transportParams.dtlsParameters,
}) })
attachTransportHandlers(transport, transportParams, sessionId, 'Recv')
// 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,
})
}
})
return transport 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) { export async function consumeProducer(transport, device, sessionId) {
const rtpCapabilities = device.rtpCapabilities
const consumerParams = await $fetch('/api/live/webrtc/create-consumer', { const consumerParams = await $fetch('/api/live/webrtc/create-consumer', {
method: 'POST', method: 'POST',
body: { sessionId, transportId: transport.id, rtpCapabilities }, body: { sessionId, transportId: transport.id, rtpCapabilities: device.rtpCapabilities },
credentials: 'include', ...FETCH_OPTS,
}) })
const consumer = await transport.consume({ const consumer = await transport.consume({
@@ -256,14 +162,6 @@ export async function consumeProducer(transport, device, sessionId) {
return consumer 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) { function waitForCondition(condition, timeoutMs = 3000, intervalMs = 100) {
return new Promise((resolve) => { return new Promise((resolve) => {
const timeoutId = setTimeout(() => { 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) { export function waitForConnectionState(transport, timeoutMs = 10000) {
const terminal = ['connected', 'failed', 'disconnected', 'closed'] const terminal = ['connected', 'failed', 'disconnected', 'closed']
return new Promise((resolve) => { return new Promise((resolve) => {

View File

@@ -1,18 +1,13 @@
/** /** Pure: fetches WebRTC failure reason (e.g. wrong host). Returns frozen object. */
* 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.
*/
export async function getWebRTCFailureReason() { export async function getWebRTCFailureReason() {
try { try {
const res = await $fetch('/api/live/debug-request-host', { credentials: 'include' }) const res = await $fetch('/api/live/debug-request-host', { credentials: 'include' })
const clientHostname = typeof window !== 'undefined' ? window.location.hostname : '' const clientHostname = typeof window !== 'undefined' ? window.location.hostname : ''
const serverHostname = res?.hostname ?? '' const serverHostname = res?.hostname ?? ''
if (serverHostname && clientHostname && serverHostname !== clientHostname) { if (serverHostname && clientHostname && serverHostname !== clientHostname) {
return { wrongHost: { serverHostname, clientHostname } } return Object.freeze({ wrongHost: Object.freeze({ serverHostname, clientHostname }) })
} }
} }
catch { catch { /* ignore */ }
// ignore return Object.freeze({ wrongHost: null })
}
return { wrongHost: null }
} }

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex min-h-screen items-center justify-center bg-kestrel-bg font-mono text-kestrel-text"> <div class="flex min-h-screen items-center justify-center bg-kestrel-bg font-mono text-kestrel-text">
<div class="text-center"> <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 ] [ Error ]
</h1> </h1>
<p class="mt-2 text-sm text-kestrel-muted"> <p class="mt-2 text-sm text-kestrel-muted">

View File

@@ -6,7 +6,7 @@
class="flex min-h-0 flex-1 flex-col transition-[margin] duration-200 ease-out" class="flex min-h-0 flex-1 flex-col transition-[margin] duration-200 ease-out"
:class="{ 'md:ml-[260px]': drawerOpen }" :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 <button
type="button" type="button"
class="rounded p-2 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent" class="rounded p-2 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
@@ -20,7 +20,7 @@
>&#9776;</span> >&#9776;</span>
</button> </button>
<div class="min-w-0 flex-1"> <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 KestrelOS
</h1> </h1>
<p class="text-xs uppercase tracking-widest text-kestrel-muted"> <p class="text-xs uppercase tracking-widest text-kestrel-muted">

View File

@@ -1,15 +1,14 @@
<template> <template>
<div class="p-6"> <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 Account
</h2> </h2>
<!-- Profile -->
<section class="mb-8"> <section class="mb-8">
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted"> <h3 class="kestrel-section-label">
Profile Profile
</h3> </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"> <template v-if="user">
<dl class="space-y-2 text-sm"> <dl class="space-y-2 text-sm">
<div> <div>
@@ -50,15 +49,14 @@
</div> </div>
</section> </section>
<!-- Change password (local only) -->
<section <section
v-if="user?.auth_provider === 'local'" v-if="user?.auth_provider === 'local'"
class="mb-8" class="mb-8"
> >
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted"> <h3 class="kestrel-section-label">
Change password Change password
</h3> </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 <p
v-if="passwordSuccess" v-if="passwordSuccess"
class="mb-3 text-sm text-green-400" class="mb-3 text-sm text-green-400"
@@ -78,46 +76,40 @@
<div> <div>
<label <label
for="account-current-password" for="account-current-password"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
> >Current password</label>
Current password
</label>
<input <input
id="account-current-password" id="account-current-password"
v-model="currentPassword" v-model="currentPassword"
type="password" type="password"
autocomplete="current-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>
<div> <div>
<label <label
for="account-new-password" for="account-new-password"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
> >New password</label>
New password
</label>
<input <input
id="account-new-password" id="account-new-password"
v-model="newPassword" v-model="newPassword"
type="password" type="password"
autocomplete="new-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>
<div> <div>
<label <label
for="account-confirm-password" for="account-confirm-password"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
> >Confirm new password</label>
Confirm new password
</label>
<input <input
id="account-confirm-password" id="account-confirm-password"
v-model="confirmPassword" v-model="confirmPassword"
type="password" type="password"
autocomplete="new-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>
<button <button

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="p-6"> <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 Cameras
</h2> </h2>
<p class="mb-4 text-sm text-kestrel-muted"> <p class="mb-4 text-sm text-kestrel-muted">
@@ -80,6 +80,8 @@
<script setup> <script setup>
definePageMeta({ layout: 'default' }) definePageMeta({ layout: 'default' })
const { cameras } = useCameras() const { cameras, liveSessions } = useCameras()
const selectedCamera = ref(null) const selectedCamera = ref(null)
useAutoCloseLiveSession(selectedCamera, liveSessions)
</script> </script>

View File

@@ -28,7 +28,8 @@ const { canEditPois } = useUser()
const selectedCamera = ref(null) const selectedCamera = ref(null)
function onSelectLive(session) { function onSelectLive(session) {
const latest = (liveSessions.value || []).find(s => s.id === session?.id) selectedCamera.value = (liveSessions.value ?? []).find(s => s.id === session?.id) ?? session
selectedCamera.value = latest ?? session
} }
useAutoCloseLiveSession(selectedCamera, liveSessions)
</script> </script>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex min-h-[60vh] items-center justify-center p-6"> <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)]"> <div class="kestrel-card w-full max-w-sm p-6">
<h2 class="mb-4 text-lg font-semibold text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"> <h2 class="kestrel-section-heading mb-4">
Sign in Sign in
</h2> </h2>
<p <p
@@ -29,28 +29,28 @@
<div class="mb-3"> <div class="mb-3">
<label <label
for="login-identifier" for="login-identifier"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
>Email or username</label> >Email or username</label>
<input <input
id="login-identifier" id="login-identifier"
v-model="identifier" v-model="identifier"
type="text" type="text"
autocomplete="username" 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 required
> >
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label <label
for="login-password" for="login-password"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
>Password</label> >Password</label>
<input <input
id="login-password" id="login-password"
v-model="password" v-model="password"
type="password" type="password"
autocomplete="current-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 required
> >
</div> </div>
@@ -69,16 +69,16 @@
<script setup> <script setup>
const route = useRoute() const route = useRoute()
const redirect = computed(() => route.query.redirect || '/') const redirect = computed(() => route.query.redirect || '/')
const AUTH_CONFIG_DEFAULT = Object.freeze({ oidc: { enabled: false, label: '' } })
const { data: authConfig } = useAsyncData( const { data: authConfig } = useAsyncData(
'auth-config', 'auth-config',
() => $fetch('/api/auth/config').catch(() => ({ oidc: { enabled: false, label: '' } })), () => $fetch('/api/auth/config').catch(() => AUTH_CONFIG_DEFAULT),
{ default: () => null }, { default: () => null },
) )
const showDivider = computed(() => !!authConfig.value?.oidc?.enabled) const showDivider = computed(() => !!authConfig.value?.oidc?.enabled)
const oidcAuthorizeUrl = computed(() => { const oidcAuthorizeUrl = computed(() => {
const base = '/api/auth/oidc/authorize' const r = redirect.value
const q = redirect.value && redirect.value !== '/' ? `?redirect=${encodeURIComponent(redirect.value)}` : '' return `/api/auth/oidc/authorize${r && r !== '/' ? `?redirect=${encodeURIComponent(r)}` : ''}`
return base + q
}) })
const identifier = ref('') const identifier = ref('')

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="p-6"> <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 Members
</h2> </h2>
<p <p
@@ -10,7 +10,7 @@
Sign in to view members. Sign in to view members.
</p> </p>
<p <p
v-else-if="!canViewMembers" v-else-if="!canEditPois"
class="text-sm text-kestrel-muted" class="text-sm text-kestrel-muted"
> >
You don't have access to the members list. You don't have access to the members list.
@@ -149,7 +149,7 @@
v-if="openRoleDropdownId && dropdownPlacement" v-if="openRoleDropdownId && dropdownPlacement"
ref="dropdownMenuRef" ref="dropdownMenuRef"
role="menu" 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="{ :style="{
top: `${dropdownPlacement.top}px`, top: `${dropdownPlacement.top}px`,
left: `${dropdownPlacement.left}px`, left: `${dropdownPlacement.left}px`,
@@ -183,7 +183,7 @@
@click.self="closeAddUserModal" @click.self="closeAddUserModal"
> >
<div <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 @click.stop
> >
<h3 <h3
@@ -204,7 +204,7 @@
type="text" type="text"
required required
autocomplete="username" 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" placeholder="username"
> >
</div> </div>
@@ -219,7 +219,7 @@
type="password" type="password"
required required
autocomplete="new-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="••••••••" placeholder="••••••••"
> >
</div> </div>
@@ -231,7 +231,7 @@
<select <select
id="add-role" id="add-role"
v-model="newUser.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"> <option value="member">
member member
@@ -253,7 +253,7 @@
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button <button
type="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" @click="closeAddUserModal"
> >
Cancel Cancel
@@ -280,7 +280,7 @@
@click.self="deleteConfirmUser = null" @click.self="deleteConfirmUser = null"
> >
<div <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 @click.stop
> >
<h3 <h3
@@ -295,7 +295,7 @@
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button <button
type="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" @click="deleteConfirmUser = null"
> >
Cancel Cancel
@@ -318,7 +318,7 @@
@click.self="editUserModal = null" @click.self="editUserModal = null"
> >
<div <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" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="edit-user-title" aria-labelledby="edit-user-title"
@@ -340,7 +340,7 @@
v-model="editForm.identifier" v-model="editForm.identifier"
type="text" type="text"
required 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>
<div class="mb-4 flex flex-col gap-1"> <div class="mb-4 flex flex-col gap-1">
@@ -353,7 +353,7 @@
v-model="editForm.password" v-model="editForm.password"
type="password" type="password"
autocomplete="new-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="••••••••" placeholder="••••••••"
> >
<p class="mt-0.5 text-xs text-kestrel-muted"> <p class="mt-0.5 text-xs text-kestrel-muted">
@@ -369,7 +369,7 @@
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button <button
type="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" @click="editUserModal = null"
> >
Cancel Cancel
@@ -390,15 +390,14 @@
</template> </template>
<script setup> <script setup>
const { user, isAdmin, refresh: refreshUser } = useUser() const { user, isAdmin, canEditPois, refresh: refreshUser } = useUser()
const canViewMembers = computed(() => user.value?.role === 'admin' || user.value?.role === 'leader')
const { data: usersData, refresh: refreshUsers } = useAsyncData( const { data: usersData, refresh: refreshUsers } = useAsyncData(
'users', 'users',
() => $fetch('/api/users').catch(() => []), () => $fetch('/api/users').catch(() => []),
{ default: () => [] }, { default: () => [] },
) )
const users = computed(() => (Array.isArray(usersData.value) ? usersData.value : [])) const users = computed(() => Object.freeze([...(usersData.value ?? [])]))
const roleOptions = ['admin', 'leader', 'member'] const roleOptions = ['admin', 'leader', 'member']
const pendingRoleUpdates = ref({}) const pendingRoleUpdates = ref({})
@@ -428,8 +427,8 @@ function setDropdownWrapRef(userId, el) {
} }
} }
watch(user, (u) => { watch(user, () => {
if (u?.role === 'admin' || u?.role === 'leader') refreshUsers() if (canEditPois.value) refreshUsers()
}, { immediate: true }) }, { immediate: true })
function toggleRoleDropdown(userId) { function toggleRoleDropdown(userId) {

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="p-6"> <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 POI placement
</h2> </h2>
<p <p
@@ -17,7 +17,7 @@
<div> <div>
<label <label
for="poi-lat" for="poi-lat"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
>Lat</label> >Lat</label>
<input <input
id="poi-lat" id="poi-lat"
@@ -25,13 +25,13 @@
type="number" type="number"
step="any" step="any"
required 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>
<div> <div>
<label <label
for="poi-lng" for="poi-lng"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
>Lng</label> >Lng</label>
<input <input
id="poi-lng" id="poi-lng"
@@ -39,39 +39,37 @@
type="number" type="number"
step="any" step="any"
required 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>
<div> <div>
<label <label
for="poi-label" for="poi-label"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
>Label</label> >Label</label>
<input <input
id="poi-label" id="poi-label"
v-model="form.label" v-model="form.label"
type="text" 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>
<div> <div>
<label <label
for="poi-icon" for="poi-icon"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
>Icon</label> >Icon</label>
<select <select
id="poi-icon" id="poi-icon"
v-model="form.iconType" 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"> <option
pin v-for="opt in POI_ICON_TYPES"
</option> :key="opt"
<option value="flag"> :value="opt"
flag >
</option> {{ opt }}
<option value="waypoint">
waypoint
</option> </option>
</select> </select>
</div> </div>
@@ -145,6 +143,8 @@
</template> </template>
<script setup> <script setup>
const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])
const { data: poisData, refresh } = usePois() const { data: poisData, refresh } = usePois()
const { canEditPois } = useUser() const { canEditPois } = useUser()
const poisList = computed(() => poisData.value ?? []) const poisList = computed(() => poisData.value ?? [])

View File

@@ -1,15 +1,14 @@
<template> <template>
<div class="p-6"> <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 Settings
</h2> </h2>
<!-- Map & offline -->
<section class="mb-8"> <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 Map & offline
</h3> </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"> <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. Clear saved map tiles to free storage. The map will load tiles from the network again when you use it.
</p> </p>
@@ -28,7 +27,7 @@
</p> </p>
<button <button
type="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" :disabled="tilesLoading"
@click="onClearTiles" @click="onClearTiles"
> >
@@ -37,12 +36,11 @@
</div> </div>
</section> </section>
<!-- About -->
<section> <section>
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted"> <h3 class="kestrel-section-label">
About About
</h3> </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"> <p class="font-medium text-kestrel-text">
KestrelOS KestrelOS
</p> </p>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex min-h-[80vh] flex-col items-center justify-center p-6"> <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)]"> <div class="kestrel-card-modal w-full max-w-md p-6">
<h2 class="mb-2 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"> <h2 class="kestrel-section-heading mb-2">
Share live (camera + location) Share live (camera + location)
</h2> </h2>
<p class="mb-4 text-sm text-kestrel-muted"> <p class="mb-4 text-sm text-kestrel-muted">
@@ -55,7 +55,7 @@
<!-- Local preview --> <!-- Local preview -->
<div <div
v-if="stream && videoRef" 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 <video
ref="videoRef" ref="videoRef"

View File

@@ -1,3 +1,4 @@
/** Wraps $fetch to redirect to /login on 401 for same-origin requests. */
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
const route = useRoute() const route = useRoute()
const baseFetch = globalThis.$fetch ?? $fetch const baseFetch = globalThis.$fetch ?? $fetch
@@ -6,8 +7,7 @@ export default defineNuxtPlugin(() => {
if (response?.status !== 401) return if (response?.status !== 401) return
const url = typeof request === 'string' ? request : request?.url ?? '' const url = typeof request === 'string' ? request : request?.url ?? ''
if (!url.startsWith('/')) return if (!url.startsWith('/')) return
const redirect = (route.fullPath && route.fullPath !== '/' ? route.fullPath : '/') navigateTo({ path: '/login', query: { redirect: route.fullPath || '/' } }, { replace: true })
navigateTo({ path: '/login', query: { redirect } }, { replace: true })
}, },
}) })
}) })

View File

@@ -1,88 +1,30 @@
/** /** Client-side logger: sends to server, falls back to console. */
* Client-side logger that sends logs to server for debugging.
* Falls back to console if server logging fails.
*/
let sessionId = null let sessionId = null
let userId = null let userId = null
/** const CONSOLE_METHOD = Object.freeze({ error: 'error', warn: 'warn', info: 'log', debug: 'log' })
* Initialize logger with session/user context.
* @param {string} sessId
* @param {string} uid
*/
export function initLogger(sessId, uid) { export function initLogger(sessId, uid) {
sessionId = sessId sessionId = sessId
userId = uid userId = uid
} }
/** function sendToServer(level, message, data) {
* Send log to server (non-blocking). setTimeout(() => {
* @param {string} level $fetch('/api/log', {
* @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', method: 'POST',
body: { body: { level, message, data, sessionId, userId, timestamp: new Date().toISOString() },
level,
message,
data,
sessionId,
userId,
timestamp: new Date().toISOString(),
},
credentials: 'include', credentials: 'include',
}).catch(() => { }).catch(() => { /* server down - don't spam console */ })
// Silently fail - don't spam console if server is down
})
}
catch {
// Ignore errors - logging shouldn't break the app
}
}, 0) }, 0)
} }
/** function log(level, message, data) {
* Log at error level. console[CONSOLE_METHOD[level]](`[${message}]`, data)
* @param {string} message sendToServer(level, message, data)
* @param {object} data
*/
export function logError(message, data) {
console.error(`[${message}]`, data)
sendToServer('error', message, data)
} }
/** export const logError = (message, data) => log('error', message, data)
* Log at warn level. export const logWarn = (message, data) => log('warn', message, data)
* @param {string} message export const logInfo = (message, data) => log('info', message, data)
* @param {object} data export const logDebug = (message, data) => log('debug', message, 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)
}

View File

@@ -27,6 +27,7 @@ export default defineNuxtConfig({
], ],
}, },
}, },
css: ['~/assets/css/main.css'],
runtimeConfig: { runtimeConfig: {
public: { public: {
version: pkg.version ?? '', version: pkg.version ?? '',

View File

@@ -47,7 +47,7 @@ body="## Changelog
## Installation ## Installation
- [Docker image](${CI_FORGE_URL}/${CI_REPO_OWNER}/-/packages/container/${CI_REPO_NAME}) - [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" 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 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 \ wget -q -O /dev/null --post-file=/tmp/release.json \

View File

@@ -7,6 +7,6 @@ export default defineEventHandler(async (event) => {
requireAuth(event) requireAuth(event)
const [db, sessions] = await Promise.all([getDb(), getActiveSessions()]) 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 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 } return { devices, liveSessions: sessions }
}) })

View File

@@ -1,32 +1,11 @@
/** const CONSOLE_METHOD = Object.freeze({ error: 'error', warn: 'warn', info: 'log', debug: 'log' })
* 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
export default defineEventHandler(async (event) => {
const body = await readBody(event).catch(() => ({})) const body = await readBody(event).catch(() => ({}))
const { level, message, data, sessionId, userId } = body const { level, message, data, sessionId, userId } = body
const prefix = `[CLIENT${sessionId ? `:${sessionId}` : ''}${userId ? `:${userId.slice(0, 8)}` : ''}]`
const logPrefix = `[CLIENT${sessionId ? `:${sessionId}` : ''}${userId ? `:${userId.slice(0, 8)}` : ''}]` const msg = data ? `${message} ${JSON.stringify(data)}` : message
const logMessage = data ? `${message} ${JSON.stringify(data)}` : message const method = CONSOLE_METHOD[level] || 'log'
console[method](prefix, msg)
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)
}
return { ok: true } return { ok: true }
}) })

View File

@@ -1,18 +1,15 @@
import { getDb } from '../utils/db.js' import { getDb } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js' import { requireAuth } from '../utils/authHelpers.js'
import { POI_ICON_TYPES } from '../utils/poiConstants.js'
const ICON_TYPES = ['pin', 'flag', 'waypoint']
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
requireAuth(event, { role: 'adminOrLeader' }) requireAuth(event, { role: 'adminOrLeader' })
const body = await readBody(event) const body = await readBody(event)
const lat = Number(body?.lat) const lat = Number(body?.lat)
const lng = Number(body?.lng) const lng = Number(body?.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) { if (!Number.isFinite(lat) || !Number.isFinite(lng)) throw createError({ statusCode: 400, message: 'lat and lng required as numbers' })
throw createError({ statusCode: 400, message: 'lat and lng required as numbers' })
}
const label = typeof body?.label === 'string' ? body.label.trim() : '' 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 id = crypto.randomUUID()
const { run } = await getDb() const { run } = await getDb()
await run( await run(

View File

@@ -1,20 +1,19 @@
import { getDb } from '../../utils/db.js' import { getDb } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js' import { requireAuth } from '../../utils/authHelpers.js'
import { POI_ICON_TYPES } from '../../utils/poiConstants.js'
const ICON_TYPES = ['pin', 'flag', 'waypoint']
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
requireAuth(event, { role: 'adminOrLeader' }) requireAuth(event, { role: 'adminOrLeader' })
const id = event.context.params?.id const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' }) if (!id) throw createError({ statusCode: 400, message: 'id required' })
const body = await readBody(event) || {} const body = (await readBody(event)) || {}
const updates = [] const updates = []
const params = [] const params = []
if (typeof body.label === 'string') { if (typeof body.label === 'string') {
updates.push('label = ?') updates.push('label = ?')
params.push(body.label.trim()) params.push(body.label.trim())
} }
if (ICON_TYPES.includes(body.iconType)) { if (POI_ICON_TYPES.includes(body.iconType)) {
updates.push('icon_type = ?') updates.push('icon_type = ?')
params.push(body.iconType) params.push(body.iconType)
} }

View File

@@ -1,9 +1,5 @@
import { getDb, closeDb } from '../utils/db.js' 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) => { export default defineNitroPlugin((nitroApp) => {
void getDb() void getDb()
nitroApp.hooks.hook('close', () => { nitroApp.hooks.hook('close', () => {

View File

@@ -1,17 +1,7 @@
/**
* WebSocket server for WebRTC signaling.
* Attaches to Nitro's HTTP server and handles WebSocket connections.
*/
import { WebSocketServer } from 'ws' import { WebSocketServer } from 'ws'
import { getDb } from '../utils/db.js' import { getDb } from '../utils/db.js'
import { handleWebSocketMessage } from '../utils/webrtcSignaling.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) { function parseCookie(cookieHeader) {
const cookies = {} const cookies = {}
if (!cookieHeader) return cookies if (!cookieHeader) return cookies
@@ -25,30 +15,16 @@ function parseCookie(cookieHeader) {
} }
let wss = null 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() { export function getWebSocketServer() {
return wss return wss
} }
/**
* Get connections for a session.
* @param {string} sessionId
* @returns {Set<WebSocket>} Set of WebSockets for the session.
*/
export function getSessionConnections(sessionId) { export function getSessionConnections(sessionId) {
return connections.get(sessionId) || new Set() return connections.get(sessionId) || new Set()
} }
/**
* Add connection to session.
* @param {string} sessionId
* @param {WebSocket} ws
*/
export function addSessionConnection(sessionId, ws) { export function addSessionConnection(sessionId, ws) {
if (!connections.has(sessionId)) { if (!connections.has(sessionId)) {
connections.set(sessionId, new Set()) connections.set(sessionId, new Set())
@@ -56,11 +32,6 @@ export function addSessionConnection(sessionId, ws) {
connections.get(sessionId).add(ws) connections.get(sessionId).add(ws)
} }
/**
* Remove connection from session.
* @param {string} sessionId
* @param {WebSocket} ws
*/
export function removeSessionConnection(sessionId, ws) { export function removeSessionConnection(sessionId, ws) {
const conns = connections.get(sessionId) const conns = connections.get(sessionId)
if (conns) { 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) { export function broadcastToSession(sessionId, message) {
const conns = getSessionConnections(sessionId) const conns = getSessionConnections(sessionId)
const data = JSON.stringify(message) const data = JSON.stringify(message)

View File

@@ -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() { export function getAuthConfig() {
const hasOidcEnv const hasOidc = !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET)
= 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' : '')
const envLabel = process.env.OIDC_LABEL ?? '' return Object.freeze({ oidc: { enabled: hasOidc, label } })
const label = envLabel || (hasOidcEnv ? 'Sign in with OIDC' : '')
return {
oidc: {
enabled: !!hasOidcEnv,
label,
},
}
} }

View File

@@ -1,20 +1,10 @@
/** const ROLES_ADMIN_OR_LEADER = Object.freeze(['admin', 'leader'])
* 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.
*/
export function requireAuth(event, opts = {}) { export function requireAuth(event, opts = {}) {
const user = event.context.user const user = event.context.user
if (!user) { if (!user) throw createError({ statusCode: 401, message: 'Unauthorized' })
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const { role } = opts const { role } = opts
if (role === 'admin' && user.role !== 'admin') { if (role === 'admin' && user.role !== 'admin') throw createError({ statusCode: 403, message: 'Forbidden' })
throw createError({ statusCode: 403, message: 'Forbidden' }) if (role === 'adminOrLeader' && !ROLES_ADMIN_OR_LEADER.includes(user.role)) throw createError({ statusCode: 403, message: 'Forbidden' })
}
if (role === 'adminOrLeader' && user.role !== 'admin' && user.role !== 'leader') {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
return user return user
} }

View File

@@ -1,30 +1,21 @@
/** /** Paths that skip auth (no session required). Do not add if any handler uses requireAuth. */
* Paths that skip auth middleware (no session required). export const SKIP_PATHS = Object.freeze([
* 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 = [
'/api/auth/login', '/api/auth/login',
'/api/auth/logout', '/api/auth/logout',
'/api/auth/config', '/api/auth/config',
'/api/auth/oidc/authorize', '/api/auth/oidc/authorize',
'/api/auth/oidc/callback', '/api/auth/oidc/callback',
] ])
/** /** Path prefixes for protected routes. Used by tests to ensure they're never in SKIP_PATHS. */
* Path prefixes for API routes that require an authenticated user (or role). export const PROTECTED_PATH_PREFIXES = Object.freeze([
* 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 = [
'/api/cameras', '/api/cameras',
'/api/devices', '/api/devices',
'/api/live', '/api/live',
'/api/me', '/api/me',
'/api/pois', '/api/pois',
'/api/users', '/api/users',
] ])
export function skipAuth(path) { export function skipAuth(path) {
if (path.startsWith('/api/health') || path === '/health') return true if (path.startsWith('/api/health') || path === '/health') return true

View File

@@ -1,13 +1,10 @@
import { randomBytes } from 'node:crypto' import { randomBytes } from 'node:crypto'
import { hashPassword } from './password.js' import { hashPassword } from './password.js'
const DEFAULT_ADMIN_IDENTIFIER = 'admin' const PASSWORD_CHARS = Object.freeze('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789')
const PASSWORD_CHARS = 'abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789'
const generateRandomPassword = () => { const generateRandomPassword = () =>
const bytes = randomBytes(14) Array.from(randomBytes(14), b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
return Array.from(bytes, b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
}
export async function bootstrapAdmin(run, get) { export async function bootstrapAdmin(run, get) {
const row = await get('SELECT COUNT(*) as n FROM users') 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 email = process.env.BOOTSTRAP_EMAIL?.trim()
const password = process.env.BOOTSTRAP_PASSWORD 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() const plainPassword = (email && password) ? password : generateRandomPassword()
await run( await run(

View File

@@ -0,0 +1 @@
export const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])

View File

@@ -1,15 +1,6 @@
const DEFAULT_DAYS = 7 const [MIN_DAYS, MAX_DAYS, DEFAULT_DAYS] = [1, 365, 7]
const MIN_DAYS = 1
const MAX_DAYS = 365
/**
* Session lifetime in days (for cookie and DB expires_at). Uses SESSION_MAX_AGE_DAYS.
* Clamped to 1365 days.
*/
export function getSessionMaxAgeDays() { export function getSessionMaxAgeDays() {
const raw = process.env.SESSION_MAX_AGE_DAYS != null const raw = Number.parseInt(process.env.SESSION_MAX_AGE_DAYS ?? '', 10)
? Number.parseInt(process.env.SESSION_MAX_AGE_DAYS, 10) return Number.isFinite(raw) ? Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw)) : DEFAULT_DAYS
: Number.NaN
if (Number.isFinite(raw)) return Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw))
return DEFAULT_DAYS
} }

View File

@@ -1,19 +1,6 @@
/**
* WebRTC signaling message handlers.
* Processes WebSocket messages for WebRTC operations.
*/
import { getLiveSession, updateLiveSession } from './liveSessions.js' import { getLiveSession, updateLiveSession } from './liveSessions.js'
import { getRouter, createTransport, getTransport } from './mediasoup.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) { export async function handleWebSocketMessage(userId, sessionId, type, data) {
const session = getLiveSession(sessionId) const session = getLiveSession(sessionId)
if (!session) { if (!session) {

View File

@@ -16,9 +16,11 @@ export default {
kestrel: { kestrel: {
'bg': '#060b14', 'bg': '#060b14',
'surface': '#0d1424', 'surface': '#0d1424',
'surface-elevated': '#1e293b',
'surface-hover': '#111a2e', 'surface-hover': '#111a2e',
'border': '#1a2744', 'border': '#1a2744',
'text': '#b8c9e0', 'text': '#b8c9e0',
'text-bright': '#e2e8f0',
'muted': '#5c6f8a', 'muted': '#5c6f8a',
'accent': '#22c9c9', 'accent': '#22c9c9',
'accent-dim': '#0f3d3d', 'accent-dim': '#0f3d3d',
@@ -30,12 +32,30 @@ export default {
'glow': '0 0 20px -4px rgba(34, 201, 201, 0.3)', 'glow': '0 0 20px -4px rgba(34, 201, 201, 0.3)',
'glow-md': '0 0 24px -2px rgba(34, 201, 201, 0.25)', '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-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: { textShadow: {
'glow': '0 0 12px rgba(34, 201, 201, 0.4)', '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') },
})
},
],
} }