initial commit
This commit is contained in:
14
app/app.config.js
Normal file
14
app/app.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
export default defineAppConfig({
|
||||
title: 'KestrelOS',
|
||||
theme: {
|
||||
dark: true,
|
||||
colors: {
|
||||
background: '#060b14',
|
||||
surface: '#0d1424',
|
||||
border: '#1a2744',
|
||||
text: '#b8c9e0',
|
||||
muted: '#5c6f8a',
|
||||
accent: '#22c9c9',
|
||||
},
|
||||
},
|
||||
})
|
||||
5
app/app.vue
Normal file
5
app/app.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
119
app/components/CameraViewer.vue
Normal file
119
app/components/CameraViewer.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<LiveSessionPanel
|
||||
v-if="isLiveSession"
|
||||
:session="camera"
|
||||
:inline="inline"
|
||||
@close="$emit('close')"
|
||||
/>
|
||||
<aside
|
||||
v-else
|
||||
class="flex flex-col border border-kestrel-border bg-kestrel-surface"
|
||||
:class="asideClass"
|
||||
role="dialog"
|
||||
aria-label="Camera feed"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-kestrel-border px-4 py-3 [box-shadow:0_1px_0_0_rgba(34,201,201,0.08)]">
|
||||
<h2 class="font-medium tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
{{ camera?.name ?? 'Camera' }}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
|
||||
aria-label="Close panel"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<span class="text-xl leading-none">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<div class="relative aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black [box-shadow:inset_0_0_20px_-8px_rgba(34,201,201,0.1)]">
|
||||
<template v-if="sourceType === 'hls'">
|
||||
<video
|
||||
ref="videoRef"
|
||||
class="h-full w-full object-contain"
|
||||
muted
|
||||
autoplay
|
||||
playsinline
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<img
|
||||
v-if="safeStreamUrl && !streamError"
|
||||
:src="safeStreamUrl"
|
||||
alt="Live feed"
|
||||
class="h-full w-full object-contain"
|
||||
@error="streamError = true"
|
||||
>
|
||||
<div
|
||||
v-else-if="streamError || (!safeStreamUrl && streamUrl)"
|
||||
class="flex h-full w-full items-center justify-center text-xs uppercase tracking-wider text-kestrel-muted"
|
||||
>
|
||||
[ Stream unavailable ]
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
/** Device (streamUrl, sourceType, name) or live session (id, label, hasStream) */
|
||||
camera: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
/** When true, render inline (e.g. on Cameras page) instead of overlay */
|
||||
inline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['close'])
|
||||
|
||||
const videoRef = ref(null)
|
||||
const streamError = ref(false)
|
||||
|
||||
const isLiveSession = computed(() =>
|
||||
props.camera && typeof props.camera.hasStream !== 'undefined')
|
||||
|
||||
const asideClass = computed(() =>
|
||||
props.inline ? 'rounded-lg shadow-glow' : 'absolute right-0 top-0 z-[1000] h-full w-full border-l shadow-glow md:w-[420px] [box-shadow:-8px_0_24px_-4px_rgba(34,201,201,0.12)]')
|
||||
|
||||
const streamUrl = computed(() => props.camera?.streamUrl ?? '')
|
||||
const sourceType = computed(() => (props.camera?.sourceType === 'hls' ? 'hls' : 'mjpeg'))
|
||||
|
||||
const safeStreamUrl = computed(() => {
|
||||
const u = streamUrl.value
|
||||
return typeof u === 'string' && u.trim() && (u.startsWith('http://') || u.startsWith('https://')) ? u.trim() : ''
|
||||
})
|
||||
|
||||
function initHls() {
|
||||
const url = streamUrl.value
|
||||
if (!url || sourceType.value !== 'hls' || typeof window === 'undefined') return
|
||||
const Hls = window.Hls
|
||||
if (Hls?.isSupported() && videoRef.value) {
|
||||
const hls = new Hls()
|
||||
hls.loadSource(url)
|
||||
hls.attachMedia(videoRef.value)
|
||||
}
|
||||
else if (videoRef.value?.canPlayType?.('application/vnd.apple.mpegurl')) {
|
||||
videoRef.value.src = url
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (sourceType.value === 'hls') {
|
||||
import('hls.js').then((mod) => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.Hls = mod.default
|
||||
nextTick(initHls)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (videoRef.value) videoRef.value.src = ''
|
||||
})
|
||||
</script>
|
||||
783
app/components/KestrelMap.vue
Normal file
783
app/components/KestrelMap.vue
Normal file
@@ -0,0 +1,783 @@
|
||||
<template>
|
||||
<div
|
||||
ref="mapRef"
|
||||
data-testid="kestrel-map"
|
||||
class="kestrel-map-container relative h-full w-full min-h-[300px]"
|
||||
>
|
||||
<div
|
||||
v-if="contextMenu.type"
|
||||
ref="contextMenuRef"
|
||||
class="pointer-events-auto absolute z-[1000] min-w-[120px] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.2)]"
|
||||
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
|
||||
>
|
||||
<template v-if="contextMenu.type === 'map'">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-3 py-1.5 text-left text-sm text-kestrel-text hover:bg-kestrel-border"
|
||||
@click="openAddPoiModal(contextMenu.latlng)"
|
||||
>
|
||||
Add POI here
|
||||
</button>
|
||||
</template>
|
||||
<template v-else-if="contextMenu.type === 'poi'">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-3 py-1.5 text-left text-sm text-kestrel-text hover:bg-kestrel-border"
|
||||
@click="openEditPoiModal(contextMenu.poi)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-kestrel-border"
|
||||
@click="openDeletePoiModal(contextMenu.poi)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- POI modal (Add / Edit) -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showPoiModal"
|
||||
class="fixed inset-0 z-[2000] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="poiModalMode === 'delete' ? 'delete-poi-title' : 'poi-modal-title'"
|
||||
@keydown.escape="closePoiModal"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 bg-black/60 transition-opacity"
|
||||
aria-label="Close"
|
||||
@click="closePoiModal"
|
||||
/>
|
||||
<!-- Add / Edit form -->
|
||||
<div
|
||||
v-if="poiModalMode === 'add' || poiModalMode === 'edit'"
|
||||
ref="poiModalRef"
|
||||
class="relative w-full max-w-md rounded-lg border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_32px_-8px_rgba(34,201,201,0.25)]"
|
||||
@click.stop
|
||||
>
|
||||
<h2
|
||||
id="poi-modal-title"
|
||||
class="mb-4 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"
|
||||
>
|
||||
{{ poiModalMode === 'edit' ? 'Edit POI' : 'Add POI' }}
|
||||
</h2>
|
||||
<form
|
||||
class="space-y-4"
|
||||
@submit.prevent="submitPoiModal"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="add-poi-label"
|
||||
class="mb-1.5 block text-xs font-medium uppercase tracking-wider text-kestrel-muted"
|
||||
>
|
||||
Label (optional)
|
||||
</label>
|
||||
<input
|
||||
id="add-poi-label"
|
||||
v-model="poiForm.label"
|
||||
type="text"
|
||||
placeholder="e.g. Rally point"
|
||||
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text placeholder:text-kestrel-muted outline-none transition-colors focus:border-kestrel-accent"
|
||||
autocomplete="off"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs font-medium uppercase tracking-wider text-kestrel-muted"
|
||||
>
|
||||
Icon type
|
||||
</label>
|
||||
<div
|
||||
:ref="el => iconDropdownOpen && (iconDropdownRef.value = el)"
|
||||
class="relative inline-block w-full"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full min-w-0 items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-left text-sm text-kestrel-text transition-colors hover:border-kestrel-accent/50"
|
||||
:aria-expanded="iconDropdownOpen"
|
||||
aria-haspopup="listbox"
|
||||
:aria-label="`Icon type: ${poiForm.iconType}`"
|
||||
@click="iconDropdownOpen = !iconDropdownOpen"
|
||||
>
|
||||
<span class="flex items-center gap-2 capitalize">
|
||||
<Icon
|
||||
:name="POI_ICONIFY_IDS[poiForm.iconType]"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
{{ poiForm.iconType }}
|
||||
</span>
|
||||
<span
|
||||
class="text-kestrel-muted transition-transform"
|
||||
:class="iconDropdownOpen && 'rotate-180'"
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-show="iconDropdownOpen"
|
||||
class="absolute left-0 right-0 top-full z-10 mt-1 rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow [box-shadow:0_4px_12px_-2px_rgba(34,201,201,0.15)]"
|
||||
role="listbox"
|
||||
>
|
||||
<button
|
||||
v-for="opt in POI_ICON_TYPES"
|
||||
:key="opt"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="poiForm.iconType === opt"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm capitalize transition-colors"
|
||||
:class="poiForm.iconType === opt
|
||||
? 'bg-kestrel-accent-dim text-kestrel-accent'
|
||||
: 'text-kestrel-text hover:bg-kestrel-border'"
|
||||
@click="poiForm.iconType = opt; iconDropdownOpen = false"
|
||||
>
|
||||
<Icon
|
||||
:name="POI_ICONIFY_IDS[opt]"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border"
|
||||
@click="closePoiModal"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-kestrel-accent px-4 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ poiModalMode === 'edit' ? 'Save changes' : 'Add POI' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Delete confirmation -->
|
||||
<div
|
||||
v-if="poiModalMode === 'delete'"
|
||||
ref="poiModalRef"
|
||||
class="relative w-full max-w-sm rounded-lg border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_32px_-8px_rgba(34,201,201,0.25)]"
|
||||
@click.stop
|
||||
>
|
||||
<h2
|
||||
id="delete-poi-title"
|
||||
class="mb-2 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"
|
||||
>
|
||||
Delete POI?
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-kestrel-muted">
|
||||
{{ deletePoi?.label ? `“${deletePoi.label}” will be removed.` : 'This POI will be removed.' }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border"
|
||||
@click="closePoiModal"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90"
|
||||
@click="confirmDeletePoi"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
const props = defineProps({
|
||||
feeds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
devices: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
pois: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
liveSessions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
canEditPois: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'selectLive', 'refreshPois'])
|
||||
const CONTEXT_MENU_EMPTY = Object.freeze({ type: null, poi: null, latlng: null, x: 0, y: 0 })
|
||||
const mapRef = ref(null)
|
||||
const contextMenuRef = ref(null)
|
||||
const leafletRef = shallowRef(null)
|
||||
const mapContext = ref(null)
|
||||
const markersRef = ref([])
|
||||
const poiMarkersRef = ref({})
|
||||
const liveMarkersRef = ref({})
|
||||
const contextMenu = ref({ ...CONTEXT_MENU_EMPTY })
|
||||
|
||||
const showPoiModal = ref(false)
|
||||
const poiModalRef = ref(null)
|
||||
const poiModalMode = ref('add') // 'add' | 'edit' | 'delete'
|
||||
const addPoiLatlng = ref(null)
|
||||
const editPoi = ref(null)
|
||||
const deletePoi = ref(null)
|
||||
const poiForm = ref({ label: '', iconType: 'pin' })
|
||||
const iconDropdownOpen = ref(false)
|
||||
const iconDropdownRef = ref(null)
|
||||
|
||||
const TILE_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'
|
||||
const TILE_SUBDOMAINS = 'abcd'
|
||||
const ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
|
||||
const DEFAULT_VIEW = [37.7749, -122.4194]
|
||||
const DEFAULT_ZOOM = 17
|
||||
const MARKER_ICON_PATH = '/'
|
||||
const POI_ICON_TYPES = ['pin', 'flag', 'waypoint']
|
||||
const POI_TOOLTIP_CLASS = 'kestrel-poi-tooltip'
|
||||
|
||||
/** Tabler icon names (Nuxt Icon / Iconify) – modern technical aesthetic. */
|
||||
const POI_ICONIFY_IDS = { pin: 'tabler:map-pin', flag: 'tabler:flag', waypoint: 'tabler:current-location' }
|
||||
const POI_ICON_COLORS = { pin: '#22c9c9', flag: '#e53e3e', waypoint: '#a78bfa' }
|
||||
|
||||
const ICON_SIZE = 28
|
||||
|
||||
/** Embedded SVGs so each POI type has a distinct marker (no network, always correct). */
|
||||
function getPoiIconSvg(type) {
|
||||
const c = POI_ICON_COLORS[type] || POI_ICON_COLORS.pin
|
||||
const shapes = {
|
||||
pin: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8c0 4.5-6 9-6 9s-6-4.5-6-9a6 6 0 0 1 12 0z"/><circle cx="12" cy="8" r="2" fill="${c}"/></svg>`,
|
||||
flag: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4v16"/><path d="M6 6h10l4 4-4 4H6"/></svg>`,
|
||||
waypoint: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${c}" 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="${c}"/></svg>`,
|
||||
}
|
||||
return shapes[type] || shapes.pin
|
||||
}
|
||||
|
||||
function getPoiIcon(L, poi) {
|
||||
const type = poi.icon_type === 'pin' || poi.icon_type === 'flag' || poi.icon_type === 'waypoint' ? poi.icon_type : 'pin'
|
||||
const html = getPoiIconSvg(type)
|
||||
return L.divIcon({
|
||||
className: 'poi-div-icon',
|
||||
html: `<span class="poi-icon-svg poi-icon-${type}">${html}</span>`,
|
||||
iconSize: [ICON_SIZE, ICON_SIZE],
|
||||
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
|
||||
})
|
||||
}
|
||||
|
||||
const LIVE_ICON_COLOR = '#22c9c9'
|
||||
function getLiveSessionIcon(L) {
|
||||
const html = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${LIVE_ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="2" fill="${LIVE_ICON_COLOR}"/></svg>`
|
||||
return L.divIcon({
|
||||
className: 'poi-div-icon live-session-icon',
|
||||
html: `<span class="poi-icon-svg">${html}</span>`,
|
||||
iconSize: [ICON_SIZE, ICON_SIZE],
|
||||
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
|
||||
})
|
||||
}
|
||||
|
||||
function createMap(initialCenter) {
|
||||
const { L, offlineApi } = leafletRef.value || {}
|
||||
if (typeof document === 'undefined' || !mapRef.value || !L?.map) return
|
||||
|
||||
const center = Array.isArray(initialCenter) && initialCenter.length === 2
|
||||
? initialCenter
|
||||
: DEFAULT_VIEW
|
||||
|
||||
const map = L.map(mapRef.value, { zoomControl: false, attributionControl: false }).setView(center, DEFAULT_ZOOM)
|
||||
L.control.zoom({ position: 'topleft' }).addTo(map)
|
||||
|
||||
const locateControl = L.control({ position: 'topleft' })
|
||||
locateControl.onAdd = function () {
|
||||
const el = document.createElement('button')
|
||||
el.type = 'button'
|
||||
el.className = 'leaflet-bar leaflet-control-locate'
|
||||
el.title = 'Center on my location'
|
||||
el.setAttribute('aria-label', 'Center on my location')
|
||||
el.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" width="20" height="20" aria-hidden="true"><circle cx="12" cy="12" r="8"/><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><circle cx="12" cy="12" r="2" fill="currentColor"/></svg>`
|
||||
el.addEventListener('click', () => {
|
||||
if (!navigator?.geolocation) return
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const { latitude, longitude } = pos.coords
|
||||
map.setView([latitude, longitude], DEFAULT_ZOOM, { animate: true })
|
||||
},
|
||||
() => {},
|
||||
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 },
|
||||
)
|
||||
})
|
||||
return el
|
||||
}
|
||||
locateControl.addTo(map)
|
||||
|
||||
const baseLayer = L.tileLayer(TILE_URL, {
|
||||
attribution: ATTRIBUTION,
|
||||
subdomains: TILE_SUBDOMAINS,
|
||||
minZoom: 1,
|
||||
maxZoom: 19,
|
||||
})
|
||||
baseLayer.addTo(map)
|
||||
|
||||
const control = (() => {
|
||||
if (!offlineApi?.tileLayerOffline || !offlineApi.savetiles) return null
|
||||
const layer = offlineApi.tileLayerOffline(TILE_URL, { attribution: ATTRIBUTION, subdomains: TILE_SUBDOMAINS, minZoom: 1, maxZoom: 19 })
|
||||
const c = offlineApi.savetiles(layer, {
|
||||
zoomlevels: [10, 11, 12, 13, 14, 15],
|
||||
position: 'topleft',
|
||||
saveText: 'Save tiles',
|
||||
rmText: 'Clear tiles',
|
||||
})
|
||||
c.addTo(map)
|
||||
return c
|
||||
})()
|
||||
|
||||
map.on('contextmenu', (e) => {
|
||||
if (!props.canEditPois) return
|
||||
e.originalEvent?.preventDefault()
|
||||
const pt = map.latLngToContainerPoint(e.latlng)
|
||||
contextMenu.value = { type: 'map', latlng: e.latlng, x: pt.x, y: pt.y }
|
||||
})
|
||||
|
||||
mapContext.value = { map, layer: baseLayer, control, locateControl }
|
||||
updateMarkers()
|
||||
updatePoiMarkers()
|
||||
updateLiveMarkers()
|
||||
}
|
||||
|
||||
function updateMarkers() {
|
||||
const ctx = mapContext.value
|
||||
const { L } = leafletRef.value || {}
|
||||
if (!ctx?.map || !L) return
|
||||
|
||||
markersRef.value.forEach((m) => {
|
||||
if (m) m.remove()
|
||||
})
|
||||
|
||||
const feedSources = [...(props.feeds || []), ...(props.devices || [])]
|
||||
const validSources = feedSources.filter(f => typeof f?.lat === 'number' && typeof f?.lng === 'number')
|
||||
markersRef.value = validSources.map(item =>
|
||||
L.marker([item.lat, item.lng]).addTo(ctx.map).on('click', () => emit('select', item)),
|
||||
)
|
||||
}
|
||||
|
||||
function updatePoiMarkers() {
|
||||
const ctx = mapContext.value
|
||||
const { L } = leafletRef.value || {}
|
||||
if (!ctx?.map || !L) return
|
||||
|
||||
const pois = (props.pois || []).filter(p => typeof p?.lat === 'number' && typeof p?.lng === 'number' && p?.id)
|
||||
const byId = Object.fromEntries(pois.map(p => [p.id, p]))
|
||||
const prev = poiMarkersRef.value
|
||||
|
||||
Object.keys(prev).forEach((id) => {
|
||||
if (!byId[id]) prev[id]?.remove()
|
||||
})
|
||||
|
||||
const next = pois.reduce((acc, poi) => {
|
||||
const existing = prev[poi.id]
|
||||
const icon = getPoiIcon(L, poi)
|
||||
if (existing) {
|
||||
existing.setLatLng([poi.lat, poi.lng])
|
||||
if (icon) existing.setIcon(icon)
|
||||
existing.getTooltip()?.setContent(poi.label || '')
|
||||
if (!existing.getTooltip()?.isOpen() && (poi.label || props.canEditPois)) existing.bindTooltip(poi.label || poi.id, { permanent: false, className: POI_TOOLTIP_CLASS })
|
||||
return { ...acc, [poi.id]: existing }
|
||||
}
|
||||
const marker = L.marker([poi.lat, poi.lng], icon ? { icon } : undefined).addTo(ctx.map)
|
||||
if (poi.label || props.canEditPois) marker.bindTooltip(poi.label || poi.id, { permanent: false, className: POI_TOOLTIP_CLASS })
|
||||
if (props.canEditPois) {
|
||||
marker.on('contextmenu', (e) => {
|
||||
e.originalEvent?.preventDefault()
|
||||
e.originalEvent?.stopPropagation()
|
||||
const pt = ctx.map.latLngToContainerPoint(e.latlng)
|
||||
contextMenu.value = { type: 'poi', poi, x: pt.x, y: pt.y }
|
||||
})
|
||||
}
|
||||
return { ...acc, [poi.id]: marker }
|
||||
}, {})
|
||||
poiMarkersRef.value = next
|
||||
}
|
||||
|
||||
function updateLiveMarkers() {
|
||||
const ctx = mapContext.value
|
||||
const { L } = leafletRef.value || {}
|
||||
if (!ctx?.map || !L) return
|
||||
|
||||
const sessions = (props.liveSessions || []).filter(
|
||||
s => typeof s?.lat === 'number' && typeof s?.lng === 'number' && s?.id,
|
||||
)
|
||||
const byId = Object.fromEntries(sessions.map(s => [s.id, s]))
|
||||
const prev = liveMarkersRef.value
|
||||
const icon = getLiveSessionIcon(L)
|
||||
|
||||
Object.keys(prev).forEach((id) => {
|
||||
if (!byId[id]) prev[id]?.remove()
|
||||
})
|
||||
|
||||
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 existing = prev[session.id]
|
||||
if (existing) {
|
||||
existing.setLatLng([session.lat, session.lng])
|
||||
existing.setIcon(icon)
|
||||
existing.getPopup()?.setContent(content)
|
||||
return { ...acc, [session.id]: existing }
|
||||
}
|
||||
const marker = L.marker([session.lat, session.lng], { icon })
|
||||
.addTo(ctx.map)
|
||||
.bindPopup(content, { className: 'kestrel-live-popup-wrap', maxWidth: 360 })
|
||||
.on('click', () => emit('selectLive', session))
|
||||
return { ...acc, [session.id]: marker }
|
||||
}, {})
|
||||
liveMarkersRef.value = next
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu.value = { ...CONTEXT_MENU_EMPTY }
|
||||
}
|
||||
|
||||
function openAddPoiModal(latlng) {
|
||||
closeContextMenu()
|
||||
poiModalMode.value = 'add'
|
||||
addPoiLatlng.value = { lat: latlng.lat, lng: latlng.lng }
|
||||
editPoi.value = null
|
||||
deletePoi.value = null
|
||||
poiForm.value = { label: '', iconType: 'pin' }
|
||||
iconDropdownOpen.value = false
|
||||
showPoiModal.value = true
|
||||
}
|
||||
|
||||
function openEditPoiModal(poi) {
|
||||
closeContextMenu()
|
||||
poiModalMode.value = 'edit'
|
||||
editPoi.value = poi
|
||||
addPoiLatlng.value = null
|
||||
deletePoi.value = null
|
||||
poiForm.value = { label: (poi.label ?? '').trim(), iconType: poi.icon_type || 'pin' }
|
||||
iconDropdownOpen.value = false
|
||||
showPoiModal.value = true
|
||||
}
|
||||
|
||||
function openDeletePoiModal(poi) {
|
||||
closeContextMenu()
|
||||
poiModalMode.value = 'delete'
|
||||
deletePoi.value = poi
|
||||
addPoiLatlng.value = null
|
||||
editPoi.value = null
|
||||
showPoiModal.value = true
|
||||
}
|
||||
|
||||
function closePoiModal() {
|
||||
showPoiModal.value = false
|
||||
poiModalMode.value = 'add'
|
||||
iconDropdownOpen.value = false
|
||||
addPoiLatlng.value = null
|
||||
editPoi.value = null
|
||||
deletePoi.value = null
|
||||
}
|
||||
|
||||
function onPoiModalDocumentClick(e) {
|
||||
if (!showPoiModal.value) return
|
||||
if (iconDropdownOpen.value && iconDropdownRef.value && !iconDropdownRef.value.contains(e.target)) {
|
||||
iconDropdownOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPoiModal() {
|
||||
if (poiModalMode.value === 'add') {
|
||||
const latlng = addPoiLatlng.value
|
||||
if (!latlng) return
|
||||
const { label, iconType } = poiForm.value
|
||||
try {
|
||||
await $fetch('/api/pois', { method: 'POST', body: { lat: latlng.lat, lng: latlng.lng, label: (label ?? '').trim(), iconType: iconType || 'pin' } })
|
||||
emit('refreshPois')
|
||||
closePoiModal()
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
return
|
||||
}
|
||||
if (poiModalMode.value === 'edit' && editPoi.value) {
|
||||
const { label, iconType } = poiForm.value
|
||||
try {
|
||||
await $fetch(`/api/pois/${editPoi.value.id}`, { method: 'PATCH', body: { label: (label ?? '').trim(), iconType: iconType || 'pin' } })
|
||||
emit('refreshPois')
|
||||
closePoiModal()
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeletePoi() {
|
||||
const poi = deletePoi.value
|
||||
if (!poi?.id) return
|
||||
try {
|
||||
await $fetch(`/api/pois/${poi.id}`, { method: 'DELETE' })
|
||||
emit('refreshPois')
|
||||
closePoiModal()
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function destroyMap() {
|
||||
markersRef.value.forEach((m) => {
|
||||
if (m) m.remove()
|
||||
})
|
||||
markersRef.value = []
|
||||
Object.values(poiMarkersRef.value).forEach(m => m?.remove())
|
||||
poiMarkersRef.value = {}
|
||||
Object.values(liveMarkersRef.value).forEach(m => m?.remove())
|
||||
liveMarkersRef.value = {}
|
||||
|
||||
const ctx = mapContext.value
|
||||
if (ctx) {
|
||||
if (ctx.control && ctx.map) ctx.map.removeControl(ctx.control)
|
||||
if (ctx.locateControl && ctx.map) ctx.map.removeControl(ctx.locateControl)
|
||||
if (ctx.layer && ctx.map) ctx.map.removeLayer(ctx.layer)
|
||||
if (ctx.map) ctx.map.remove()
|
||||
mapContext.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function initMapWithLocation() {
|
||||
if (!navigator?.geolocation) {
|
||||
createMap(DEFAULT_VIEW)
|
||||
return
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const { latitude, longitude } = pos.coords
|
||||
createMap([latitude, longitude])
|
||||
},
|
||||
() => {
|
||||
createMap(DEFAULT_VIEW)
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 },
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!import.meta.client || typeof document === 'undefined') return
|
||||
const [leaflet, offline] = await Promise.all([
|
||||
import('leaflet'),
|
||||
import('leaflet.offline'),
|
||||
])
|
||||
const L = leaflet.default
|
||||
|
||||
if (L.Icon?.Default?.mergeOptions) {
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconUrl: `${MARKER_ICON_PATH}marker-icon.png`,
|
||||
iconRetinaUrl: `${MARKER_ICON_PATH}marker-icon-2x.png`,
|
||||
shadowUrl: `${MARKER_ICON_PATH}marker-shadow.png`,
|
||||
})
|
||||
}
|
||||
|
||||
leafletRef.value = { L, offlineApi: offline }
|
||||
initMapWithLocation()
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
document.addEventListener('click', onPoiModalDocumentClick)
|
||||
})
|
||||
|
||||
function onDocumentClick(e) {
|
||||
if (contextMenu.value.type && contextMenuRef.value && !contextMenuRef.value.contains(e.target)) closeContextMenu()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
document.removeEventListener('click', onPoiModalDocumentClick)
|
||||
destroyMap()
|
||||
})
|
||||
|
||||
watch(() => [props.feeds, props.devices], () => updateMarkers(), { deep: true })
|
||||
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
|
||||
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.modal-enter-active .relative,
|
||||
.modal-leave-active .relative {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.modal-enter-from .relative,
|
||||
.modal-leave-to .relative {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
/* Unrendered/loading tiles show black instead of white when panning */
|
||||
.kestrel-map-container {
|
||||
background: #000 !important;
|
||||
}
|
||||
:deep(.leaflet-tile-pane),
|
||||
:deep(.leaflet-map-pane),
|
||||
:deep(.leaflet-tile-container) {
|
||||
background: #000 !important;
|
||||
}
|
||||
:deep(img.leaflet-tile) {
|
||||
background: #000 !important;
|
||||
/* Override Leaflet’s plus-lighter so unloaded/empty tiles don’t flash white */
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
/* Leaflet injects divIcon HTML into the map; :deep() so these styles apply to that content */
|
||||
:deep(.poi-div-icon) {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
:deep(.poi-icon-svg) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Dark-themed tooltip for POI labels (Leaflet creates these in the map container) */
|
||||
:deep(.kestrel-poi-tooltip) {
|
||||
background: #1e293b;
|
||||
border: 1px solid rgba(34, 201, 201, 0.35);
|
||||
border-radius: 6px;
|
||||
color: #e2e8f0;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
padding: 6px 10px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
:deep(.kestrel-poi-tooltip::before),
|
||||
:deep(.kestrel-poi-tooltip::after) {
|
||||
border-top-color: #1e293b;
|
||||
border-bottom-color: #1e293b;
|
||||
border-left-color: #1e293b;
|
||||
border-right-color: #1e293b;
|
||||
}
|
||||
|
||||
/* Live session popup (content injected by Leaflet) */
|
||||
:deep(.kestrel-live-popup-wrap .leaflet-popup-content) {
|
||||
margin: 8px 12px;
|
||||
min-width: 200px;
|
||||
}
|
||||
:deep(.kestrel-live-popup) {
|
||||
color: #e2e8f0;
|
||||
font-size: 12px;
|
||||
}
|
||||
:deep(.kestrel-live-popup img) {
|
||||
display: block;
|
||||
max-height: 160px;
|
||||
width: auto;
|
||||
border-radius: 4px;
|
||||
background: #0f172a;
|
||||
}
|
||||
:deep(.live-session-icon) {
|
||||
animation: live-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes live-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Map controls – dark theme with cyan glow (zoom, locate, save/clear tiles) */
|
||||
:deep(.leaflet-control-zoom),
|
||||
:deep(.leaflet-control-locate),
|
||||
:deep(.savetiles.leaflet-bar) {
|
||||
border: 1px solid rgba(34, 201, 201, 0.35) !important;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 12px -2px rgba(34, 201, 201, 0.15);
|
||||
font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
|
||||
}
|
||||
:deep(.leaflet-control-zoom a),
|
||||
:deep(.leaflet-control-locate),
|
||||
:deep(.savetiles.leaflet-bar a) {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
line-height: 32px !important;
|
||||
background: #0d1424 !important;
|
||||
color: #b8c9e0 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
font-size: 18px !important;
|
||||
font-weight: 600;
|
||||
text-decoration: none !important;
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s, text-shadow 0.15s;
|
||||
}
|
||||
:deep(.leaflet-control-zoom a + a) {
|
||||
border-top: 1px solid rgba(34, 201, 201, 0.2) !important;
|
||||
}
|
||||
:deep(.leaflet-control-zoom a:hover),
|
||||
:deep(.leaflet-control-locate:hover),
|
||||
:deep(.savetiles.leaflet-bar a:hover) {
|
||||
background: #111a2e !important;
|
||||
color: #22c9c9 !important;
|
||||
box-shadow: 0 0 16px -2px rgba(34, 201, 201, 0.25);
|
||||
text-shadow: 0 0 8px rgba(34, 201, 201, 0.35);
|
||||
}
|
||||
:deep(.leaflet-control-locate) {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
:deep(.leaflet-control-locate svg) {
|
||||
color: currentColor;
|
||||
}
|
||||
/* Save/Clear tiles – text buttons */
|
||||
:deep(.savetiles.leaflet-bar) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
:deep(.savetiles.leaflet-bar a) {
|
||||
width: auto !important;
|
||||
min-width: 5.5em;
|
||||
height: auto !important;
|
||||
line-height: 1.25 !important;
|
||||
padding: 6px 10px !important;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
font-size: 11px !important;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
:deep(.savetiles.leaflet-bar a + a) {
|
||||
border-top: 1px solid rgba(34, 201, 201, 0.2) !important;
|
||||
}
|
||||
</style>
|
||||
324
app/components/LiveSessionPanel.vue
Normal file
324
app/components/LiveSessionPanel.vue
Normal file
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<aside
|
||||
class="flex flex-col border border-kestrel-border bg-kestrel-surface"
|
||||
:class="inline ? 'rounded-lg shadow-glow' : 'absolute right-0 top-0 z-[1000] h-full w-full border-l shadow-glow md:w-[420px] [box-shadow:-8px_0_24px_-4px_rgba(34,201,201,0.12)]'"
|
||||
role="dialog"
|
||||
aria-label="Live feed"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-kestrel-border px-4 py-3 [box-shadow:0_1px_0_0_rgba(34,201,201,0.08)]">
|
||||
<h2 class="font-medium tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
{{ session?.label ?? 'Live' }}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
|
||||
aria-label="Close panel"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<span class="text-xl leading-none">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col overflow-auto p-4">
|
||||
<p class="mb-3 text-xs text-kestrel-muted">
|
||||
Live camera feed (WebRTC)
|
||||
</p>
|
||||
<div class="relative aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black [box-shadow:inset_0_0_20px_-8px_rgba(34,201,201,0.1)]">
|
||||
<video
|
||||
ref="videoRef"
|
||||
autoplay
|
||||
playsinline
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
<div
|
||||
v-if="!hasStream && !error"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 p-4 text-center text-xs uppercase tracking-wider text-kestrel-muted"
|
||||
>
|
||||
<span>Waiting for stream…</span>
|
||||
<span
|
||||
v-if="connectionState === 'connecting'"
|
||||
class="normal-case"
|
||||
>WebRTC: connecting…</span>
|
||||
<template v-if="connectionState === 'failed'">
|
||||
<span class="normal-case font-medium text-red-400">WebRTC: failed</span>
|
||||
<p
|
||||
v-if="failureReason?.wrongHost"
|
||||
class="normal-case text-left text-amber-400"
|
||||
>
|
||||
Wrong host: server sees <strong>{{ failureReason.wrongHost.serverHostname }}</strong> but you opened this page at <strong>{{ failureReason.wrongHost.clientHostname }}</strong>. Use the same URL or set MEDIASOUP_ANNOUNCED_IP.
|
||||
</p>
|
||||
<ul class="normal-case list-inside list-disc text-left text-kestrel-muted">
|
||||
<li><strong>Firewall:</strong> Open UDP/TCP 40000–49999 on the server.</li>
|
||||
<li><strong>Wrong host:</strong> Server must see the same address you use.</li>
|
||||
<li><strong>Restrictive NAT / cellular:</strong> TURN may be required.</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="error"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 overflow-auto p-4 text-center text-xs uppercase tracking-wider text-red-400"
|
||||
>
|
||||
<span>Feed unavailable: {{ error }}</span>
|
||||
<template v-if="connectionState === 'failed' && failureReason">
|
||||
<p
|
||||
v-if="failureReason.wrongHost"
|
||||
class="normal-case text-left text-amber-400"
|
||||
>
|
||||
Wrong host: server sees <strong>{{ failureReason.wrongHost.serverHostname }}</strong> but you opened at <strong>{{ failureReason.wrongHost.clientHostname }}</strong>.
|
||||
</p>
|
||||
<ul class="normal-case list-inside list-disc text-left text-kestrel-muted">
|
||||
<li>Firewall: open ports 40000–49999.</li>
|
||||
<li>Wrong host: use same URL or set MEDIASOUP_ANNOUNCED_IP.</li>
|
||||
<li>Restrictive NAT: TURN may be required.</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { createMediasoupDevice, createRecvTransport, consumeProducer, waitForConnectionState } from '~/composables/useWebRTC.js'
|
||||
import { getWebRTCFailureReason } from '~/composables/useWebRTCFailureReason.js'
|
||||
import { initLogger, logError, logWarn } from '~/utils/logger.js'
|
||||
import { useUser } from '~/composables/useUser.js'
|
||||
|
||||
const { user } = useUser()
|
||||
|
||||
const props = defineProps({
|
||||
session: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
/** When true, render inline (e.g. on Cameras page) instead of overlay panel */
|
||||
inline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['close'])
|
||||
|
||||
const videoRef = ref(null)
|
||||
const hasStream = ref(false)
|
||||
const error = ref('')
|
||||
const connectionState = ref('') // '', 'connecting', 'connected', 'failed'
|
||||
const failureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
|
||||
let device = null
|
||||
let recvTransport = null
|
||||
let consumer = null
|
||||
|
||||
async function runFailureReasonCheck() {
|
||||
failureReason.value = await getWebRTCFailureReason()
|
||||
}
|
||||
|
||||
async function setupWebRTC() {
|
||||
if (!props.session?.id || !props.session?.hasStream) {
|
||||
logWarn('LiveSessionPanel: Cannot setup WebRTC', {
|
||||
hasSession: !!props.session,
|
||||
sessionId: props.session?.id,
|
||||
hasStream: props.session?.hasStream,
|
||||
})
|
||||
error.value = 'No stream available'
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize logger with session and user context
|
||||
initLogger(props.session.id, user.value?.id)
|
||||
|
||||
try {
|
||||
error.value = ''
|
||||
hasStream.value = false
|
||||
connectionState.value = 'connecting'
|
||||
failureReason.value = null
|
||||
|
||||
const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${props.session.id}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
device = await createMediasoupDevice(rtpCapabilities)
|
||||
recvTransport = await createRecvTransport(device, props.session.id)
|
||||
|
||||
recvTransport.on('connectionstatechange', () => {
|
||||
const state = recvTransport.connectionState
|
||||
if (state === 'connected') connectionState.value = 'connected'
|
||||
else if (state === 'failed' || state === 'disconnected' || state === 'closed') {
|
||||
logWarn('LiveSessionPanel: Receive transport connection state changed', {
|
||||
state,
|
||||
transportId: recvTransport.id,
|
||||
sessionId: props.session.id,
|
||||
})
|
||||
if (state === 'failed') {
|
||||
connectionState.value = 'failed'
|
||||
runFailureReasonCheck()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const connectionPromise = waitForConnectionState(recvTransport, 10000)
|
||||
consumer = await consumeProducer(recvTransport, device, props.session.id)
|
||||
const finalConnectionState = await connectionPromise
|
||||
|
||||
if (finalConnectionState !== 'connected') {
|
||||
connectionState.value = 'failed'
|
||||
runFailureReasonCheck()
|
||||
logWarn('LiveSessionPanel: Transport not fully connected', {
|
||||
state: finalConnectionState,
|
||||
transportId: recvTransport.id,
|
||||
consumerId: consumer.id,
|
||||
})
|
||||
}
|
||||
else {
|
||||
connectionState.value = 'connected'
|
||||
}
|
||||
|
||||
// 4. Attach video track to video element
|
||||
// Wait for video ref to be available (nextTick ensures DOM is updated)
|
||||
await nextTick()
|
||||
|
||||
// Retry logic: wait for videoRef to become available
|
||||
let attempts = 0
|
||||
while (!videoRef.value && attempts < 10) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
attempts++
|
||||
}
|
||||
|
||||
if (!consumer.track) {
|
||||
logError('LiveSessionPanel: No video track available', {
|
||||
consumerId: consumer.id,
|
||||
consumerKind: consumer.kind,
|
||||
consumerPaused: consumer.paused,
|
||||
consumerClosed: consumer.closed,
|
||||
consumerProducerId: consumer.producerId,
|
||||
transportConnectionState: recvTransport?.connectionState,
|
||||
})
|
||||
error.value = 'No video track available - consumer may not be receiving data from producer'
|
||||
return
|
||||
}
|
||||
|
||||
if (!videoRef.value) {
|
||||
logError('LiveSessionPanel: Video ref not available', {
|
||||
consumerId: consumer.id,
|
||||
hasTrack: !!consumer.track,
|
||||
})
|
||||
error.value = 'Video element not available'
|
||||
return
|
||||
}
|
||||
|
||||
const stream = new MediaStream([consumer.track])
|
||||
videoRef.value.srcObject = stream
|
||||
hasStream.value = true
|
||||
|
||||
// Wait for video metadata to load (indicates video is actually receiving data)
|
||||
const metadataPromise = new Promise((resolve) => {
|
||||
if (videoRef.value.readyState >= 2) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
let resolved = false
|
||||
const handler = () => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
videoRef.value.removeEventListener('loadedmetadata', handler)
|
||||
if (metadataTimeoutId) clearTimeout(metadataTimeoutId)
|
||||
resolve()
|
||||
}
|
||||
videoRef.value.addEventListener('loadedmetadata', handler)
|
||||
const metadataTimeoutId = setTimeout(() => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
videoRef.value.removeEventListener('loadedmetadata', handler)
|
||||
logWarn('LiveSessionPanel: Video metadata timeout', { consumerId: consumer.id })
|
||||
resolve()
|
||||
}, 5000)
|
||||
})
|
||||
await metadataPromise
|
||||
|
||||
try {
|
||||
const playPromise = videoRef.value.play()
|
||||
if (playPromise !== undefined) await playPromise
|
||||
}
|
||||
catch (playErr) {
|
||||
logWarn('LiveSessionPanel: Video play() failed (may need user interaction)', {
|
||||
consumerId: consumer.id,
|
||||
error: playErr.message || String(playErr),
|
||||
errorName: playErr.name,
|
||||
videoPaused: videoRef.value.paused,
|
||||
videoReadyState: videoRef.value.readyState,
|
||||
})
|
||||
// Don't set error - video might still work, just needs user interaction
|
||||
}
|
||||
|
||||
consumer.track.addEventListener('ended', () => {
|
||||
error.value = 'Video track ended'
|
||||
hasStream.value = false
|
||||
})
|
||||
videoRef.value.addEventListener('error', () => {
|
||||
logError('LiveSessionPanel: Video element error', { consumerId: consumer.id })
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
connectionState.value = 'failed'
|
||||
runFailureReasonCheck()
|
||||
logError('LiveSessionPanel: WebRTC setup error', {
|
||||
err: err.message || String(err),
|
||||
data: err.data,
|
||||
stack: err.stack,
|
||||
status: err.status,
|
||||
statusCode: err.statusCode,
|
||||
sessionId: props.session?.id,
|
||||
})
|
||||
const errorMsg = err.data?.message || err.message || err.toString() || 'Failed to connect to stream'
|
||||
error.value = errorMsg
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (consumer) {
|
||||
consumer.close()
|
||||
consumer = null
|
||||
}
|
||||
if (recvTransport) {
|
||||
recvTransport.close()
|
||||
recvTransport = null
|
||||
}
|
||||
device = null
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = null
|
||||
}
|
||||
hasStream.value = false
|
||||
connectionState.value = ''
|
||||
failureReason.value = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.session?.id,
|
||||
(id) => {
|
||||
cleanup()
|
||||
if (id && props.session?.hasStream) {
|
||||
setupWebRTC()
|
||||
}
|
||||
else {
|
||||
error.value = id ? 'No stream available' : ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.session?.hasStream,
|
||||
(hasStream) => {
|
||||
if (hasStream && props.session?.id && !device) {
|
||||
setupWebRTC()
|
||||
}
|
||||
else if (!hasStream) {
|
||||
cleanup()
|
||||
error.value = 'Stream ended'
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
125
app/components/NavDrawer.vue
Normal file
125
app/components/NavDrawer.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="drawer-backdrop">
|
||||
<button
|
||||
v-if="modelValue"
|
||||
type="button"
|
||||
class="fixed inset-0 z-20 block h-full w-full border-0 bg-black/50 p-0 md:hidden"
|
||||
aria-label="Close navigation"
|
||||
@click="close"
|
||||
/>
|
||||
</Transition>
|
||||
<aside
|
||||
class="nav-drawer fixed left-0 top-0 z-30 flex h-full w-[260px] flex-col border-r border-kestrel-border bg-kestrel-surface transition-transform duration-200 ease-out"
|
||||
:class="{ '-translate-x-full': !modelValue }"
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
:aria-expanded="modelValue"
|
||||
>
|
||||
<div
|
||||
class="flex h-14 shrink-0 items-center justify-between border-b border-kestrel-border bg-kestrel-surface px-4 shadow-glow-sm [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]"
|
||||
>
|
||||
<h2 class="text-sm font-medium uppercase tracking-wider text-kestrel-muted">
|
||||
Navigation
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
|
||||
aria-label="Close navigation"
|
||||
@click="close"
|
||||
>
|
||||
<span class="text-xl leading-none">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex-1 overflow-auto py-2">
|
||||
<ul class="space-y-0.5 px-2">
|
||||
<li
|
||||
v-for="item in navItems"
|
||||
:key="item.to"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="item.to"
|
||||
class="block rounded px-3 py-2 text-sm transition-colors"
|
||||
:class="isActive(item.to)
|
||||
? 'border-l-2 border-kestrel-accent bg-kestrel-surface-hover font-medium text-kestrel-accent [text-shadow:0_0_8px_rgba(34,201,201,0.25)]'
|
||||
: 'border-l-2 border-transparent text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-text'"
|
||||
@click="close"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const route = useRoute()
|
||||
const { canEditPois } = useUser()
|
||||
|
||||
const navItems = computed(() => {
|
||||
const items = [
|
||||
{ to: '/', label: 'Map' },
|
||||
{ to: '/account', label: 'Account' },
|
||||
{ to: '/cameras', label: 'Cameras' },
|
||||
{ to: '/poi', label: 'POI' },
|
||||
{ to: '/members', label: 'Members' },
|
||||
{ to: '/settings', label: 'Settings' },
|
||||
]
|
||||
if (canEditPois.value) {
|
||||
items.splice(1, 0, { to: '/share-live', label: 'Share live' })
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const isActive = to => to === '/' ? route.path === '/' : route.path.startsWith(to)
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function onEscape(e) {
|
||||
if (e.key === 'Escape') close()
|
||||
}
|
||||
|
||||
defineExpose({ close })
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onEscape)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', onEscape)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-backdrop-enter-active,
|
||||
.drawer-backdrop-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.drawer-backdrop-enter-from,
|
||||
.drawer-backdrop-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Same elevation as content: no right-edge shadow on desktop so drawer and navbar read as one layer */
|
||||
.nav-drawer {
|
||||
box-shadow: 8px 0 24px -4px rgba(34, 201, 201, 0.12);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.nav-drawer {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
48
app/composables/useCameras.js
Normal file
48
app/composables/useCameras.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Fetches devices + live sessions (unified cameras). Optionally polls when tab is visible.
|
||||
*/
|
||||
const POLL_MS = 1500
|
||||
|
||||
export function useCameras(options = {}) {
|
||||
const { poll: enablePoll = true } = options
|
||||
const { data, refresh } = useAsyncData(
|
||||
'cameras',
|
||||
() => $fetch('/api/cameras').catch(() => ({ devices: [], liveSessions: [] })),
|
||||
{ default: () => ({ devices: [], liveSessions: [] }) },
|
||||
)
|
||||
|
||||
const pollInterval = ref(null)
|
||||
function startPolling() {
|
||||
if (!enablePoll || pollInterval.value) return
|
||||
refresh()
|
||||
pollInterval.value = setInterval(refresh, POLL_MS)
|
||||
}
|
||||
function stopPolling() {
|
||||
if (pollInterval.value) {
|
||||
clearInterval(pollInterval.value)
|
||||
pollInterval.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
startPolling()
|
||||
refresh()
|
||||
}
|
||||
else {
|
||||
stopPolling()
|
||||
}
|
||||
})
|
||||
if (document.visibilityState === 'visible') startPolling()
|
||||
})
|
||||
onBeforeUnmount(stopPolling)
|
||||
|
||||
const devices = computed(() => data.value?.devices ?? [])
|
||||
const liveSessions = computed(() => data.value?.liveSessions ?? [])
|
||||
/** All cameras: devices first, then live sessions */
|
||||
const cameras = computed(() => [...devices.value, ...liveSessions.value])
|
||||
|
||||
return { data, devices, liveSessions, cameras, refresh, startPolling, stopPolling }
|
||||
}
|
||||
69
app/composables/useLiveSessions.js
Normal file
69
app/composables/useLiveSessions.js
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 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
|
||||
|
||||
export function useLiveSessions() {
|
||||
const { data: sessions, refresh } = useAsyncData(
|
||||
'live-sessions',
|
||||
async () => {
|
||||
try {
|
||||
const result = await $fetch('/api/live')
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[useLiveSessions] Fetched sessions:', result.map(s => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
hasStream: s.hasStream,
|
||||
})))
|
||||
}
|
||||
return result
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err?.message ?? String(err)
|
||||
const status = err?.statusCode ?? err?.status
|
||||
console.error('[useLiveSessions] Failed to fetch sessions:', status ? `${status}: ${msg}` : msg)
|
||||
return []
|
||||
}
|
||||
},
|
||||
{ default: () => [] },
|
||||
)
|
||||
|
||||
const pollInterval = ref(null)
|
||||
|
||||
function startPolling() {
|
||||
if (pollInterval.value) return
|
||||
refresh() // Fetch immediately so new sessions show without waiting for first interval
|
||||
pollInterval.value = setInterval(() => {
|
||||
refresh()
|
||||
}, POLL_MS)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval.value) {
|
||||
clearInterval(pollInterval.value)
|
||||
pollInterval.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
const onFocus = () => startPolling()
|
||||
const onBlur = () => stopPolling()
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
onFocus()
|
||||
refresh() // Fresh data when returning to tab
|
||||
}
|
||||
else onBlur()
|
||||
})
|
||||
if (document.visibilityState === 'visible') startPolling()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
return { sessions, refresh, startPolling, stopPolling }
|
||||
}
|
||||
6
app/composables/usePois.js
Normal file
6
app/composables/usePois.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export function usePois() {
|
||||
return useFetch('/api/pois', {
|
||||
key: 'pois',
|
||||
default: () => [],
|
||||
})
|
||||
}
|
||||
12
app/composables/useUser.js
Normal file
12
app/composables/useUser.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export function useUser() {
|
||||
// eslint-disable-next-line no-undef
|
||||
const requestFetch = useRequestFetch()
|
||||
const { data: user, refresh } = useAsyncData(
|
||||
'user',
|
||||
() => (requestFetch ?? $fetch)('/api/me').catch(() => null),
|
||||
{ default: () => null },
|
||||
)
|
||||
const canEditPois = computed(() => user.value?.role === 'admin' || user.value?.role === 'leader')
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
return { user, canEditPois, isAdmin, refresh }
|
||||
}
|
||||
313
app/composables/useWebRTC.js
Normal file
313
app/composables/useWebRTC.js
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* WebRTC composable for Mediasoup client operations.
|
||||
* Handles device initialization, transport creation, and WebSocket signaling.
|
||||
*/
|
||||
|
||||
import { logError, logWarn } from '../utils/logger.js'
|
||||
|
||||
/**
|
||||
* Initialize Mediasoup device from router RTP capabilities.
|
||||
* @param {object} rtpCapabilities
|
||||
* @returns {Promise<object>} Mediasoup device
|
||||
*/
|
||||
export async function createMediasoupDevice(rtpCapabilities) {
|
||||
// Dynamically import mediasoup-client only in browser
|
||||
if (typeof window === 'undefined') {
|
||||
throw new TypeError('Mediasoup device can only be created in browser')
|
||||
}
|
||||
|
||||
// Use dynamic import for mediasoup-client
|
||||
const { Device } = await import('mediasoup-client')
|
||||
const device = new Device()
|
||||
await device.load({ routerRtpCapabilities: rtpCapabilities })
|
||||
return device
|
||||
}
|
||||
|
||||
/**
|
||||
* Create WebSocket connection for signaling.
|
||||
* @param {string} url - WebSocket URL (e.g., 'ws://localhost:3000/ws')
|
||||
* @returns {Promise<WebSocket>} WebSocket connection
|
||||
*/
|
||||
export function createWebSocketConnection(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = url.startsWith('ws') ? url : `${protocol}//${window.location.host}/ws`
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
resolve(ws)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
reject(new Error('WebSocket connection failed'))
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
// Connection closed
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send WebSocket message and wait for response.
|
||||
* @param {WebSocket} ws
|
||||
* @param {string} sessionId
|
||||
* @param {string} type
|
||||
* @param {object} data
|
||||
* @returns {Promise<object>} Response message
|
||||
*/
|
||||
export function sendWebSocketMessage(ws, sessionId, type, data = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error('WebSocket not open'))
|
||||
return
|
||||
}
|
||||
|
||||
const messageId = `${Date.now()}-${Math.random()}`
|
||||
const message = { sessionId, type, data, messageId }
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ws.removeEventListener('message', handler)
|
||||
reject(new Error('WebSocket message timeout'))
|
||||
}, 10000)
|
||||
|
||||
const handler = (event) => {
|
||||
try {
|
||||
const response = JSON.parse(event.data)
|
||||
if (response.messageId === messageId || response.type) {
|
||||
clearTimeout(timeout)
|
||||
ws.removeEventListener('message', handler)
|
||||
if (response.error) {
|
||||
reject(new Error(response.error))
|
||||
}
|
||||
else {
|
||||
resolve(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Not our message, continue waiting
|
||||
}
|
||||
}
|
||||
|
||||
ws.addEventListener('message', handler)
|
||||
ws.send(JSON.stringify(message))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
try {
|
||||
await $fetch('/api/live/webrtc/connect-transport', {
|
||||
method: 'POST',
|
||||
body: { sessionId, transportId: transportParams.id, dtlsParameters },
|
||||
credentials: 'include',
|
||||
})
|
||||
onConnectSuccess?.()
|
||||
callback()
|
||||
}
|
||||
catch (err) {
|
||||
logError('useWebRTC: Send transport connect failed', {
|
||||
err: err.message || String(err),
|
||||
transportId: transportParams.id,
|
||||
connectionState: transport.connectionState,
|
||||
sessionId,
|
||||
})
|
||||
onConnectFailure?.(err)
|
||||
errback(err)
|
||||
}
|
||||
})
|
||||
|
||||
transport.on('connectionstatechange', () => {
|
||||
const state = transport.connectionState
|
||||
if (state === 'failed' || state === 'disconnected' || state === 'closed') {
|
||||
logWarn('useWebRTC: Send transport connection state changed', {
|
||||
state,
|
||||
transportId: transportParams.id,
|
||||
sessionId,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
transport.on('produce', async ({ kind, rtpParameters }, callback, errback) => {
|
||||
try {
|
||||
const { id } = await $fetch('/api/live/webrtc/create-producer', {
|
||||
method: 'POST',
|
||||
body: { sessionId, transportId: transportParams.id, kind, rtpParameters },
|
||||
credentials: 'include',
|
||||
})
|
||||
callback({ id })
|
||||
}
|
||||
catch (err) {
|
||||
logError('useWebRTC: Producer creation failed', { err: err.message || String(err) })
|
||||
errback(err)
|
||||
}
|
||||
})
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
/**
|
||||
* Create receive transport (for viewer).
|
||||
* @param {object} device
|
||||
* @param {string} sessionId
|
||||
* @returns {Promise<object>} Transport with consume method
|
||||
*/
|
||||
export async function createRecvTransport(device, sessionId) {
|
||||
// Create transport via HTTP API
|
||||
const transportParams = await $fetch('/api/live/webrtc/create-transport', {
|
||||
method: 'POST',
|
||||
body: { sessionId, isProducer: false },
|
||||
credentials: 'include',
|
||||
})
|
||||
const transport = device.createRecvTransport({
|
||||
id: transportParams.id,
|
||||
iceParameters: transportParams.iceParameters,
|
||||
iceCandidates: transportParams.iceCandidates,
|
||||
dtlsParameters: transportParams.dtlsParameters,
|
||||
})
|
||||
|
||||
// Set up connect handler (will be called by mediasoup-client when needed)
|
||||
transport.on('connect', async ({ dtlsParameters }, callback, errback) => {
|
||||
try {
|
||||
await $fetch('/api/live/webrtc/connect-transport', {
|
||||
method: 'POST',
|
||||
body: { sessionId, transportId: transportParams.id, dtlsParameters },
|
||||
credentials: 'include',
|
||||
})
|
||||
callback()
|
||||
}
|
||||
catch (err) {
|
||||
logError('useWebRTC: Recv transport connect failed', {
|
||||
err: err.message || String(err),
|
||||
transportId: transportParams.id,
|
||||
connectionState: transport.connectionState,
|
||||
sessionId,
|
||||
})
|
||||
errback(err)
|
||||
}
|
||||
})
|
||||
|
||||
transport.on('connectionstatechange', () => {
|
||||
const state = transport.connectionState
|
||||
if (state === 'failed' || state === 'disconnected' || state === 'closed') {
|
||||
logWarn('useWebRTC: Recv transport connection state changed', {
|
||||
state,
|
||||
transportId: transportParams.id,
|
||||
sessionId,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume producer's stream (for viewer).
|
||||
* @param {object} transport
|
||||
* @param {object} device
|
||||
* @param {string} sessionId
|
||||
* @returns {Promise<object>} Consumer with track
|
||||
*/
|
||||
export async function consumeProducer(transport, device, sessionId) {
|
||||
const rtpCapabilities = device.rtpCapabilities
|
||||
const consumerParams = await $fetch('/api/live/webrtc/create-consumer', {
|
||||
method: 'POST',
|
||||
body: { sessionId, transportId: transport.id, rtpCapabilities },
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
const consumer = await transport.consume({
|
||||
id: consumerParams.id,
|
||||
producerId: consumerParams.producerId,
|
||||
kind: consumerParams.kind,
|
||||
rtpParameters: consumerParams.rtpParameters,
|
||||
})
|
||||
|
||||
if (!consumer.track) {
|
||||
logWarn('useWebRTC: Consumer created but no track immediately', { consumerId: consumer.id })
|
||||
await waitForCondition(() => consumer.track, 3000, 100)
|
||||
if (!consumer.track) {
|
||||
logError('useWebRTC: Track did not become available after 3s', { consumerId: consumer.id })
|
||||
}
|
||||
}
|
||||
|
||||
return consumer
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve when condition() returns truthy, or after timeoutMs (then resolve anyway).
|
||||
* No mutable shared state; cleanup on first completion.
|
||||
* @param {() => unknown} condition
|
||||
* @param {number} timeoutMs
|
||||
* @param {number} intervalMs
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function waitForCondition(condition, timeoutMs = 3000, intervalMs = 100) {
|
||||
return new Promise((resolve) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
clearInterval(intervalId)
|
||||
resolve()
|
||||
}, timeoutMs)
|
||||
const intervalId = setInterval(() => {
|
||||
if (condition()) {
|
||||
clearTimeout(timeoutId)
|
||||
clearInterval(intervalId)
|
||||
resolve()
|
||||
}
|
||||
}, intervalMs)
|
||||
if (condition()) {
|
||||
clearTimeout(timeoutId)
|
||||
clearInterval(intervalId)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for transport connection state to reach a terminal state or timeout.
|
||||
* @param {object} transport - Mediasoup transport with connectionState and on/off
|
||||
* @param {number} timeoutMs
|
||||
* @returns {Promise<string>} Final connection state
|
||||
*/
|
||||
export function waitForConnectionState(transport, timeoutMs = 10000) {
|
||||
const terminal = ['connected', 'failed', 'disconnected', 'closed']
|
||||
return new Promise((resolve) => {
|
||||
let tid
|
||||
const handler = () => {
|
||||
const state = transport.connectionState
|
||||
if (terminal.includes(state)) {
|
||||
transport.off('connectionstatechange', handler)
|
||||
if (tid) clearTimeout(tid)
|
||||
resolve(state)
|
||||
}
|
||||
}
|
||||
transport.on('connectionstatechange', handler)
|
||||
handler()
|
||||
tid = setTimeout(() => {
|
||||
transport.off('connectionstatechange', handler)
|
||||
resolve(transport.connectionState)
|
||||
}, timeoutMs)
|
||||
})
|
||||
}
|
||||
18
app/composables/useWebRTCFailureReason.js
Normal file
18
app/composables/useWebRTCFailureReason.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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() {
|
||||
try {
|
||||
const res = await $fetch('/api/live/debug-request-host', { credentials: 'include' })
|
||||
const clientHostname = typeof window !== 'undefined' ? window.location.hostname : ''
|
||||
const serverHostname = res?.hostname ?? ''
|
||||
if (serverHostname && clientHostname && serverHostname !== clientHostname) {
|
||||
return { wrongHost: { serverHostname, clientHostname } }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// ignore
|
||||
}
|
||||
return { wrongHost: null }
|
||||
}
|
||||
27
app/error.vue
Normal file
27
app/error.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-kestrel-bg font-mono text-kestrel-text">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-semibold tracking-wide [text-shadow:0_0_12px_rgba(34,201,201,0.3)]">
|
||||
[ Error ]
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-kestrel-muted">
|
||||
{{ error?.message ?? 'Something went wrong' }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 rounded border border-kestrel-accent/50 bg-kestrel-accent-dim px-4 py-2 text-kestrel-accent shadow-glow-sm transition-colors hover:bg-kestrel-accent/20 hover:opacity-90"
|
||||
@click="handleClear"
|
||||
>
|
||||
> Go back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const error = useError()
|
||||
function handleClear() {
|
||||
clearError()
|
||||
navigateTo('/')
|
||||
}
|
||||
</script>
|
||||
71
app/layouts/default.vue
Normal file
71
app/layouts/default.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-kestrel-bg text-kestrel-text font-mono flex flex-col">
|
||||
<div class="relative flex flex-1 min-h-0">
|
||||
<NavDrawer v-model="drawerOpen" />
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col transition-[margin] duration-200 ease-out"
|
||||
:class="{ 'md:ml-[260px]': drawerOpen }"
|
||||
>
|
||||
<header class="flex h-14 shrink-0 items-center gap-3 border-b border-kestrel-border bg-kestrel-surface px-4 shadow-glow-sm [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-2 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
|
||||
aria-label="Toggle navigation"
|
||||
:aria-expanded="drawerOpen"
|
||||
@click="drawerOpen = !drawerOpen"
|
||||
>
|
||||
<span
|
||||
class="text-lg leading-none"
|
||||
aria-hidden="true"
|
||||
>☰</span>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_12px_rgba(34,201,201,0.35)]">
|
||||
KestrelOS
|
||||
</h1>
|
||||
<p class="text-xs uppercase tracking-widest text-kestrel-muted">
|
||||
> Tactical Operations Center — OSINT Feeds
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="user">
|
||||
<span class="text-xs text-kestrel-muted">{{ user.identifier }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-2 py-1 text-xs text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-accent"
|
||||
@click="onLogout"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</template>
|
||||
<NuxtLink
|
||||
v-else
|
||||
to="/login"
|
||||
class="rounded px-2 py-1 text-xs text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-accent"
|
||||
>
|
||||
Sign in
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</header>
|
||||
<main class="min-h-0 flex-1">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const drawerOpen = ref(true)
|
||||
const { user, refresh } = useUser()
|
||||
const route = useRoute()
|
||||
|
||||
async function onLogout() {
|
||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||
await refresh()
|
||||
await navigateTo('/')
|
||||
}
|
||||
watch(() => route.path, () => {
|
||||
drawerOpen.value = false
|
||||
})
|
||||
</script>
|
||||
10
app/middleware/auth.global.js
Normal file
10
app/middleware/auth.global.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const LOGIN_PATH = '/login'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
if (to.path === LOGIN_PATH) return
|
||||
const { user, refresh } = useUser()
|
||||
await refresh()
|
||||
if (user.value) return
|
||||
const redirect = to.fullPath.startsWith('/') ? to.fullPath : `/${to.fullPath}`
|
||||
return navigateTo({ path: LOGIN_PATH, query: { redirect } }, { replace: true })
|
||||
})
|
||||
179
app/pages/account.vue
Normal file
179
app/pages/account.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
Account
|
||||
</h2>
|
||||
|
||||
<!-- Profile -->
|
||||
<section class="mb-8">
|
||||
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
|
||||
Profile
|
||||
</h3>
|
||||
<div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
|
||||
<template v-if="user">
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div>
|
||||
<dt class="text-kestrel-muted">
|
||||
Identifier
|
||||
</dt>
|
||||
<dd class="mt-0.5 font-medium text-kestrel-text">
|
||||
{{ user.identifier }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-kestrel-muted">
|
||||
Role
|
||||
</dt>
|
||||
<dd class="mt-0.5 font-medium text-kestrel-text">
|
||||
{{ user.role }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-kestrel-muted">
|
||||
Sign-in method
|
||||
</dt>
|
||||
<dd class="mt-0.5 font-medium text-kestrel-text">
|
||||
{{ user.auth_provider === 'oidc' ? 'OIDC' : 'Local' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="mt-3 text-xs text-kestrel-muted">
|
||||
Admins can manage all users on the Members page.
|
||||
</p>
|
||||
</template>
|
||||
<p
|
||||
v-else
|
||||
class="text-sm text-kestrel-muted"
|
||||
>
|
||||
Sign in to see your profile.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Change password (local only) -->
|
||||
<section
|
||||
v-if="user?.auth_provider === 'local'"
|
||||
class="mb-8"
|
||||
>
|
||||
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
|
||||
Change password
|
||||
</h3>
|
||||
<div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
|
||||
<p
|
||||
v-if="passwordSuccess"
|
||||
class="mb-3 text-sm text-green-400"
|
||||
>
|
||||
Password updated.
|
||||
</p>
|
||||
<p
|
||||
v-if="passwordError"
|
||||
class="mb-3 text-sm text-red-400"
|
||||
>
|
||||
{{ passwordError }}
|
||||
</p>
|
||||
<form
|
||||
class="space-y-3"
|
||||
@submit.prevent="onChangePassword"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="account-current-password"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
>
|
||||
Current password
|
||||
</label>
|
||||
<input
|
||||
id="account-current-password"
|
||||
v-model="currentPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="account-new-password"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
>
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="account-new-password"
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="account-confirm-password"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
>
|
||||
Confirm new password
|
||||
</label>
|
||||
<input
|
||||
id="account-confirm-password"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-kestrel-accent px-4 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
:disabled="passwordLoading"
|
||||
>
|
||||
{{ passwordLoading ? 'Updating…' : 'Update password' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { user } = useUser()
|
||||
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const passwordLoading = ref(false)
|
||||
const passwordSuccess = ref(false)
|
||||
const passwordError = ref('')
|
||||
|
||||
async function onChangePassword() {
|
||||
passwordError.value = ''
|
||||
passwordSuccess.value = false
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
passwordError.value = 'New password and confirmation do not match.'
|
||||
return
|
||||
}
|
||||
if (newPassword.value.length < 1) {
|
||||
passwordError.value = 'New password cannot be empty.'
|
||||
return
|
||||
}
|
||||
passwordLoading.value = true
|
||||
try {
|
||||
await $fetch('/api/me/password', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
currentPassword: currentPassword.value,
|
||||
newPassword: newPassword.value,
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
passwordSuccess.value = true
|
||||
}
|
||||
catch (e) {
|
||||
passwordError.value = e.data?.message ?? e.message ?? 'Failed to update password.'
|
||||
}
|
||||
finally {
|
||||
passwordLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
85
app/pages/cameras.vue
Normal file
85
app/pages/cameras.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
Cameras
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-kestrel-muted">
|
||||
Devices and live sessions. Select one to view the stream.
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="!cameras?.length"
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-4 py-8 text-center text-sm text-kestrel-muted"
|
||||
>
|
||||
No cameras. Add devices or use
|
||||
<NuxtLink
|
||||
to="/share-live"
|
||||
class="text-kestrel-accent underline"
|
||||
>
|
||||
Share live
|
||||
</NuxtLink>
|
||||
on your phone to stream; it will appear here and on the map.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col gap-4 md:flex-row md:flex-wrap"
|
||||
>
|
||||
<div class="flex-1 md:min-w-[320px]">
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="cam in cameras"
|
||||
:key="cam.id"
|
||||
class="rounded border transition-colors"
|
||||
:class="
|
||||
selectedCamera?.id === cam.id
|
||||
? 'border-kestrel-accent bg-kestrel-accent-dim'
|
||||
: 'border-kestrel-border bg-kestrel-surface hover:border-kestrel-accent/50'
|
||||
"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between px-4 py-3 text-left"
|
||||
@click="selectedCamera = cam"
|
||||
>
|
||||
<span class="font-medium text-kestrel-text">{{ cam.name ?? cam.label }}</span>
|
||||
<span
|
||||
v-if="cam.hasStream"
|
||||
class="rounded bg-green-500/20 px-2 py-0.5 text-xs text-green-400"
|
||||
>
|
||||
Live
|
||||
</span>
|
||||
<span
|
||||
v-else-if="cam.device_type"
|
||||
class="rounded bg-kestrel-surface px-2 py-0.5 text-xs text-kestrel-muted capitalize"
|
||||
>
|
||||
{{ cam.device_type }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex-1 md:min-w-[360px]">
|
||||
<CameraViewer
|
||||
v-if="selectedCamera"
|
||||
:camera="selectedCamera"
|
||||
inline
|
||||
@close="selectedCamera = null"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex aspect-video items-center justify-center rounded border border-kestrel-border bg-kestrel-bg text-sm text-kestrel-muted"
|
||||
>
|
||||
Select a camera to view
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({ layout: 'default' })
|
||||
|
||||
const { cameras } = useCameras()
|
||||
const selectedCamera = ref(null)
|
||||
</script>
|
||||
35
app/pages/index.vue
Normal file
35
app/pages/index.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="flex h-[calc(100vh-5rem)] w-full flex-col md:flex-row">
|
||||
<div class="relative h-2/3 w-full md:h-full md:flex-1">
|
||||
<ClientOnly>
|
||||
<KestrelMap
|
||||
:feeds="[]"
|
||||
:devices="devices ?? []"
|
||||
:pois="pois ?? []"
|
||||
:live-sessions="liveSessions ?? []"
|
||||
:can-edit-pois="canEditPois"
|
||||
@select="selectedCamera = $event"
|
||||
@select-live="onSelectLive($event)"
|
||||
@refresh-pois="refreshPois"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<CameraViewer
|
||||
v-if="selectedCamera"
|
||||
:camera="selectedCamera"
|
||||
@close="selectedCamera = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { devices, liveSessions } = useCameras()
|
||||
const { data: pois, refresh: refreshPois } = usePois()
|
||||
const { canEditPois } = useUser()
|
||||
const selectedCamera = ref(null)
|
||||
|
||||
function onSelectLive(session) {
|
||||
const latest = (liveSessions.value || []).find(s => s.id === session?.id)
|
||||
selectedCamera.value = latest ?? session
|
||||
}
|
||||
</script>
|
||||
114
app/pages/login.vue
Normal file
114
app/pages/login.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="flex min-h-[60vh] items-center justify-center p-6">
|
||||
<div class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
Sign in
|
||||
</h2>
|
||||
<p
|
||||
v-if="error"
|
||||
class="mb-3 text-xs text-red-400"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<a
|
||||
v-if="authConfig?.oidc?.enabled"
|
||||
:href="oidcAuthorizeUrl"
|
||||
class="mb-4 flex w-full items-center justify-center rounded bg-kestrel-accent px-3 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ authConfig.oidc.label }}
|
||||
</a>
|
||||
<p
|
||||
v-if="showDivider"
|
||||
class="mb-3 text-center text-xs text-kestrel-muted"
|
||||
>
|
||||
or
|
||||
</p>
|
||||
<form
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<label
|
||||
for="login-identifier"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
>Email or username</label>
|
||||
<input
|
||||
id="login-identifier"
|
||||
v-model="identifier"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="login-password"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
>Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded bg-kestrel-accent px-3 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ loading ? 'Signing in…' : 'Sign in' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const route = useRoute()
|
||||
const redirect = computed(() => route.query.redirect || '/')
|
||||
const { data: authConfig } = useAsyncData(
|
||||
'auth-config',
|
||||
() => $fetch('/api/auth/config').catch(() => ({ oidc: { enabled: false, label: '' } })),
|
||||
{ default: () => null },
|
||||
)
|
||||
const showDivider = computed(() => !!authConfig.value?.oidc?.enabled)
|
||||
const oidcAuthorizeUrl = computed(() => {
|
||||
const base = '/api/auth/oidc/authorize'
|
||||
const q = redirect.value && redirect.value !== '/' ? `?redirect=${encodeURIComponent(redirect.value)}` : ''
|
||||
return base + q
|
||||
})
|
||||
|
||||
const identifier = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const { refresh } = useUser()
|
||||
|
||||
async function onSubmit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await $fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: { identifier: identifier.value, password: password.value },
|
||||
})
|
||||
await refresh()
|
||||
const target = redirect.value || '/'
|
||||
// Full page redirect so the session cookie is sent on the next request (fixes mobile Safari staying on login)
|
||||
if (import.meta.client && typeof window !== 'undefined') {
|
||||
window.location.href = target
|
||||
return
|
||||
}
|
||||
await navigateTo(target)
|
||||
}
|
||||
catch (e) {
|
||||
error.value = e?.data?.message ?? e?.message ?? 'Sign in failed'
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
565
app/pages/members.vue
Normal file
565
app/pages/members.vue
Normal file
@@ -0,0 +1,565 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h2 class="mb-2 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
Members
|
||||
</h2>
|
||||
<p
|
||||
v-if="!user"
|
||||
class="text-sm text-kestrel-muted"
|
||||
>
|
||||
Sign in to view members.
|
||||
</p>
|
||||
<p
|
||||
v-else-if="!canViewMembers"
|
||||
class="text-sm text-kestrel-muted"
|
||||
>
|
||||
You don't have access to the members list.
|
||||
</p>
|
||||
<template v-else>
|
||||
<p
|
||||
v-if="isAdmin"
|
||||
class="mb-3 text-xs text-kestrel-muted"
|
||||
>
|
||||
Only admins can change roles and manage local users. OIDC users are managed via your identity provider.
|
||||
</p>
|
||||
<div
|
||||
v-if="isAdmin"
|
||||
class="mb-3 flex justify-start"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-accent bg-kestrel-accent/10 px-3 py-1.5 text-sm font-medium text-kestrel-accent hover:bg-kestrel-accent-dim"
|
||||
@click="openAddUserModal"
|
||||
>
|
||||
Add user
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded border border-kestrel-border">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-kestrel-border bg-kestrel-surface-hover">
|
||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
||||
Identifier
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
||||
Auth
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
||||
Role
|
||||
</th>
|
||||
<th
|
||||
v-if="isAdmin"
|
||||
class="px-4 py-2 font-medium text-kestrel-text"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="u in users"
|
||||
:key="u.id"
|
||||
class="border-b border-kestrel-border"
|
||||
>
|
||||
<td class="px-4 py-2 text-kestrel-text">
|
||||
{{ u.identifier }}
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<span
|
||||
class="rounded px-1.5 py-0.5 text-xs text-kestrel-muted"
|
||||
:class="u.auth_provider === 'oidc' ? 'bg-kestrel-surface' : ''"
|
||||
>
|
||||
{{ u.auth_provider === 'oidc' ? 'OIDC' : 'Local' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div
|
||||
v-if="isAdmin"
|
||||
:ref="el => setDropdownWrapRef(u.id, el)"
|
||||
class="relative inline-block"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-[6rem] items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-left text-sm text-kestrel-text shadow-sm transition-colors hover:border-kestrel-accent/50 hover:bg-kestrel-surface"
|
||||
:aria-expanded="openRoleDropdownId === u.id"
|
||||
:aria-haspopup="true"
|
||||
aria-label="Change role"
|
||||
@click.stop="toggleRoleDropdown(u.id)"
|
||||
>
|
||||
<span>{{ roleByUserId[u.id] ?? u.role }}</span>
|
||||
<span
|
||||
class="text-kestrel-muted transition-transform"
|
||||
:class="openRoleDropdownId === u.id && 'rotate-180'"
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="text-kestrel-muted"
|
||||
>{{ u.role }}</span>
|
||||
</td>
|
||||
<td
|
||||
v-if="isAdmin"
|
||||
class="px-4 py-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
v-if="roleByUserId[u.id] !== u.role"
|
||||
type="button"
|
||||
class="rounded border border-kestrel-accent px-2 py-1 text-xs text-kestrel-accent hover:bg-kestrel-accent-dim"
|
||||
@click="saveRole(u.id)"
|
||||
>
|
||||
Save role
|
||||
</button>
|
||||
<template v-if="u.auth_provider !== 'oidc'">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-2 py-1 text-xs text-kestrel-text hover:bg-kestrel-surface"
|
||||
@click="openEditUser(u)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
v-if="u.id !== user?.id"
|
||||
type="button"
|
||||
class="rounded border border-red-500/60 px-2 py-1 text-xs text-red-400 hover:bg-red-500/10"
|
||||
@click="openDeleteConfirm(u)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<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-if="openRoleDropdownId && dropdownPlacement"
|
||||
ref="dropdownMenuRef"
|
||||
role="menu"
|
||||
class="fixed z-[100] min-w-[6rem] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow [box-shadow:0_4px_12px_-2px_rgba(34,201,201,0.15)]"
|
||||
:style="{
|
||||
top: `${dropdownPlacement.top}px`,
|
||||
left: `${dropdownPlacement.left}px`,
|
||||
minWidth: `${dropdownPlacement.minWidth}px`,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
v-for="role in roleOptions"
|
||||
:key="role"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="block w-full px-3 py-1.5 text-left text-sm transition-colors"
|
||||
:class="roleByUserId[openRoleDropdownId] === role
|
||||
? 'bg-kestrel-accent-dim text-kestrel-accent'
|
||||
: 'text-kestrel-text hover:bg-kestrel-border hover:text-kestrel-text'"
|
||||
@click.stop="selectRole(openRoleDropdownId, role)"
|
||||
>
|
||||
{{ role }}
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<!-- Add user modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="addUserModalOpen"
|
||||
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="add-user-title"
|
||||
@click.self="closeAddUserModal"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
|
||||
@click.stop
|
||||
>
|
||||
<h3
|
||||
id="add-user-title"
|
||||
class="mb-3 text-sm font-medium text-kestrel-text"
|
||||
>
|
||||
Add user
|
||||
</h3>
|
||||
<form @submit.prevent="submitAddUser">
|
||||
<div class="mb-3 flex flex-col gap-1">
|
||||
<label
|
||||
for="add-identifier"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>Username</label>
|
||||
<input
|
||||
id="add-identifier"
|
||||
v-model="newUser.identifier"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
|
||||
placeholder="username"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-3 flex flex-col gap-1">
|
||||
<label
|
||||
for="add-password"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>Password</label>
|
||||
<input
|
||||
id="add-password"
|
||||
v-model="newUser.password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-4 flex flex-col gap-1">
|
||||
<label
|
||||
for="add-role"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>Role</label>
|
||||
<select
|
||||
id="add-role"
|
||||
v-model="newUser.role"
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
|
||||
>
|
||||
<option value="member">
|
||||
member
|
||||
</option>
|
||||
<option value="leader">
|
||||
leader
|
||||
</option>
|
||||
<option value="admin">
|
||||
admin
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p
|
||||
v-if="createError"
|
||||
class="mb-2 text-xs text-red-400"
|
||||
>
|
||||
{{ createError }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
|
||||
@click="closeAddUserModal"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
|
||||
>
|
||||
Add user
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<!-- Delete user confirmation modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="deleteConfirmUser"
|
||||
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-user-title"
|
||||
@click.self="deleteConfirmUser = null"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
|
||||
@click.stop
|
||||
>
|
||||
<h3
|
||||
id="delete-user-title"
|
||||
class="mb-2 text-sm font-medium text-kestrel-text"
|
||||
>
|
||||
Delete user?
|
||||
</h3>
|
||||
<p class="mb-4 text-sm text-kestrel-muted">
|
||||
Are you sure you want to delete <strong class="text-kestrel-text">{{ deleteConfirmUser?.identifier }}</strong>? They will not be able to sign in again.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
|
||||
@click="deleteConfirmUser = null"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-red-500/60 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 hover:bg-red-500/20"
|
||||
@click="confirmDeleteUser"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="editUserModal"
|
||||
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
|
||||
@click.self="editUserModal = null"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="edit-user-title"
|
||||
>
|
||||
<h3
|
||||
id="edit-user-title"
|
||||
class="mb-3 text-sm font-medium text-kestrel-text"
|
||||
>
|
||||
Edit local user
|
||||
</h3>
|
||||
<form @submit.prevent="submitEditUser">
|
||||
<div class="mb-3 flex flex-col gap-1">
|
||||
<label
|
||||
for="edit-identifier"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>Identifier</label>
|
||||
<input
|
||||
id="edit-identifier"
|
||||
v-model="editForm.identifier"
|
||||
type="text"
|
||||
required
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-4 flex flex-col gap-1">
|
||||
<label
|
||||
for="edit-password"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>New password (leave blank to keep)</label>
|
||||
<input
|
||||
id="edit-password"
|
||||
v-model="editForm.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
<p class="mt-0.5 text-xs text-kestrel-muted">
|
||||
If you change your password, use the new one next time you sign in.
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="editError"
|
||||
class="mb-2 text-xs text-red-400"
|
||||
>
|
||||
{{ editError }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
|
||||
@click="editUserModal = null"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { user, isAdmin, refresh: refreshUser } = useUser()
|
||||
const canViewMembers = computed(() => user.value?.role === 'admin' || user.value?.role === 'leader')
|
||||
|
||||
const { data: usersData, refresh: refreshUsers } = useAsyncData(
|
||||
'users',
|
||||
() => $fetch('/api/users').catch(() => []),
|
||||
{ default: () => [] },
|
||||
)
|
||||
const users = computed(() => (Array.isArray(usersData.value) ? usersData.value : []))
|
||||
|
||||
const roleOptions = ['admin', 'leader', 'member']
|
||||
const pendingRoleUpdates = ref({})
|
||||
const roleByUserId = computed(() => {
|
||||
const base = Object.fromEntries(users.value.map(u => [u.id, u.role]))
|
||||
return { ...base, ...pendingRoleUpdates.value }
|
||||
})
|
||||
const openRoleDropdownId = ref(null)
|
||||
const dropdownWrapRefs = ref({})
|
||||
const dropdownPlacement = ref(null)
|
||||
const dropdownMenuRef = ref(null)
|
||||
|
||||
const addUserModalOpen = ref(false)
|
||||
const newUser = ref({ identifier: '', password: '', role: 'member' })
|
||||
const createError = ref('')
|
||||
const editUserModal = ref(null)
|
||||
const editForm = ref({ identifier: '', password: '' })
|
||||
const editError = ref('')
|
||||
const deleteConfirmUser = ref(null)
|
||||
|
||||
function setDropdownWrapRef(userId, el) {
|
||||
if (el) dropdownWrapRefs.value[userId] = el
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
else delete dropdownWrapRefs.value[userId]
|
||||
}
|
||||
|
||||
watch(user, (u) => {
|
||||
if (u?.role === 'admin' || u?.role === 'leader') refreshUsers()
|
||||
}, { immediate: true })
|
||||
|
||||
function toggleRoleDropdown(userId) {
|
||||
if (openRoleDropdownId.value === userId) {
|
||||
openRoleDropdownId.value = null
|
||||
dropdownPlacement.value = null
|
||||
return
|
||||
}
|
||||
openRoleDropdownId.value = userId
|
||||
nextTick(() => {
|
||||
const wrap = dropdownWrapRefs.value[userId]
|
||||
if (wrap) {
|
||||
const rect = wrap.getBoundingClientRect()
|
||||
dropdownPlacement.value = {
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
minWidth: Math.max(rect.width, 96),
|
||||
}
|
||||
}
|
||||
else {
|
||||
dropdownPlacement.value = { top: 0, left: 0, minWidth: 96 }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function selectRole(userId, role) {
|
||||
pendingRoleUpdates.value = { ...pendingRoleUpdates.value, [userId]: role }
|
||||
openRoleDropdownId.value = null
|
||||
dropdownPlacement.value = null
|
||||
}
|
||||
|
||||
function onDocumentClick(e) {
|
||||
const openId = openRoleDropdownId.value
|
||||
if (openId == null) return
|
||||
const wrap = dropdownWrapRefs.value[openId]
|
||||
const menu = dropdownMenuRef.value
|
||||
const inTrigger = wrap && wrap.contains(e.target)
|
||||
const inMenu = menu && menu.contains(e.target)
|
||||
if (!inTrigger && !inMenu) {
|
||||
openRoleDropdownId.value = null
|
||||
dropdownPlacement.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
})
|
||||
|
||||
async function saveRole(id) {
|
||||
const role = roleByUserId.value[id]
|
||||
if (!role) return
|
||||
try {
|
||||
await $fetch(`/api/users/${id}`, { method: 'PATCH', body: { role } })
|
||||
await refreshUsers()
|
||||
const { [id]: _, ...rest } = pendingRoleUpdates.value
|
||||
pendingRoleUpdates.value = rest
|
||||
}
|
||||
catch {
|
||||
// could set error state
|
||||
}
|
||||
}
|
||||
|
||||
function openAddUserModal() {
|
||||
addUserModalOpen.value = true
|
||||
newUser.value = { identifier: '', password: '', role: 'member' }
|
||||
createError.value = ''
|
||||
}
|
||||
|
||||
function closeAddUserModal() {
|
||||
addUserModalOpen.value = false
|
||||
createError.value = ''
|
||||
}
|
||||
|
||||
async function submitAddUser() {
|
||||
createError.value = ''
|
||||
try {
|
||||
await $fetch('/api/users', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
identifier: newUser.value.identifier.trim(),
|
||||
password: newUser.value.password,
|
||||
role: newUser.value.role,
|
||||
},
|
||||
})
|
||||
closeAddUserModal()
|
||||
await refreshUsers()
|
||||
}
|
||||
catch (e) {
|
||||
createError.value = e.data?.message || e.message || 'Failed to create user'
|
||||
}
|
||||
}
|
||||
|
||||
function openEditUser(u) {
|
||||
editUserModal.value = u
|
||||
editForm.value = { identifier: u.identifier, password: '' }
|
||||
editError.value = ''
|
||||
}
|
||||
|
||||
async function submitEditUser() {
|
||||
if (!editUserModal.value) return
|
||||
editError.value = ''
|
||||
const id = editUserModal.value.id
|
||||
const body = { identifier: editForm.value.identifier.trim() }
|
||||
if (editForm.value.password) body.password = editForm.value.password
|
||||
try {
|
||||
await $fetch(`/api/users/${id}`, { method: 'PATCH', body })
|
||||
editUserModal.value = null
|
||||
await refreshUsers()
|
||||
// If you edited yourself, refresh current user so the header/nav shows the new identifier
|
||||
await refreshUser()
|
||||
}
|
||||
catch (e) {
|
||||
editError.value = e.data?.message || e.message || 'Failed to update user'
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteConfirm(u) {
|
||||
deleteConfirmUser.value = u
|
||||
}
|
||||
|
||||
async function confirmDeleteUser() {
|
||||
const u = deleteConfirmUser.value
|
||||
if (!u) return
|
||||
try {
|
||||
await $fetch(`/api/users/${u.id}`, { method: 'DELETE' })
|
||||
deleteConfirmUser.value = null
|
||||
await refreshUsers()
|
||||
}
|
||||
catch (e) {
|
||||
alert(e.data?.message || e.message || 'Failed to remove user')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
170
app/pages/poi.vue
Normal file
170
app/pages/poi.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h2 class="mb-2 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
POI placement
|
||||
</h2>
|
||||
<p
|
||||
v-if="!canEditPois"
|
||||
class="mb-4 text-sm text-kestrel-muted"
|
||||
>
|
||||
View-only. Sign in as admin or leader to add or edit POIs.
|
||||
</p>
|
||||
<template v-else>
|
||||
<form
|
||||
class="mb-6 flex flex-wrap items-end gap-3 rounded border border-kestrel-border bg-kestrel-surface p-4"
|
||||
@submit.prevent="onAdd"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="poi-lat"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
>Lat</label>
|
||||
<input
|
||||
id="poi-lat"
|
||||
v-model.number="form.lat"
|
||||
type="number"
|
||||
step="any"
|
||||
required
|
||||
class="w-28 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="poi-lng"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
>Lng</label>
|
||||
<input
|
||||
id="poi-lng"
|
||||
v-model.number="form.lng"
|
||||
type="number"
|
||||
step="any"
|
||||
required
|
||||
class="w-28 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="poi-label"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
>Label</label>
|
||||
<input
|
||||
id="poi-label"
|
||||
v-model="form.label"
|
||||
type="text"
|
||||
class="w-40 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="poi-icon"
|
||||
class="mb-1 block text-xs text-kestrel-muted"
|
||||
>Icon</label>
|
||||
<select
|
||||
id="poi-icon"
|
||||
v-model="form.iconType"
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
|
||||
>
|
||||
<option value="pin">
|
||||
pin
|
||||
</option>
|
||||
<option value="flag">
|
||||
flag
|
||||
</option>
|
||||
<option value="waypoint">
|
||||
waypoint
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-kestrel-accent px-3 py-1.5 text-sm font-medium text-kestrel-bg hover:opacity-90"
|
||||
>
|
||||
Add POI
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
<div class="overflow-x-auto rounded border border-kestrel-border">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-kestrel-border bg-kestrel-surface-hover">
|
||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
||||
Label
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
||||
Lat
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
||||
Lng
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
||||
Icon
|
||||
</th>
|
||||
<th
|
||||
v-if="canEditPois"
|
||||
class="px-4 py-2 font-medium text-kestrel-text"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="p in poisList"
|
||||
:key="p.id"
|
||||
class="border-b border-kestrel-border"
|
||||
>
|
||||
<td class="px-4 py-2 text-kestrel-text">
|
||||
{{ p.label || '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-kestrel-muted">
|
||||
{{ p.lat }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-kestrel-muted">
|
||||
{{ p.lng }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-kestrel-muted">
|
||||
{{ p.icon_type }}
|
||||
</td>
|
||||
<td
|
||||
v-if="canEditPois"
|
||||
class="px-4 py-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-red-400 hover:underline"
|
||||
@click="remove(p.id)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { data: poisData, refresh } = usePois()
|
||||
const { canEditPois } = useUser()
|
||||
const poisList = computed(() => poisData.value ?? [])
|
||||
|
||||
const form = ref({ lat: 37.77, lng: -122.42, label: '', iconType: 'pin' })
|
||||
|
||||
async function onAdd() {
|
||||
const { lat, lng, label, iconType } = form.value
|
||||
try {
|
||||
await $fetch('/api/pois', { method: 'POST', body: { lat, lng, label, iconType } })
|
||||
await refresh()
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
try {
|
||||
await $fetch(`/api/pois/${id}`, { method: 'DELETE' })
|
||||
await refresh()
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
</script>
|
||||
114
app/pages/settings.vue
Normal file
114
app/pages/settings.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
Settings
|
||||
</h2>
|
||||
|
||||
<!-- Map & offline -->
|
||||
<section class="mb-8">
|
||||
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
|
||||
Map & offline
|
||||
</h3>
|
||||
<div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
|
||||
<p class="mb-3 text-sm text-kestrel-text">
|
||||
Clear saved map tiles to free storage. The map will load tiles from the network again when you use it.
|
||||
</p>
|
||||
<p
|
||||
v-if="tilesStored !== null"
|
||||
class="mb-2 text-xs text-kestrel-muted"
|
||||
>
|
||||
{{ tilesStored > 0 ? `${tilesStored} tiles stored.` : 'No tiles stored.' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="tilesMessage"
|
||||
class="mb-2 text-sm"
|
||||
:class="tilesMessageSuccess ? 'text-green-400' : 'text-red-400'"
|
||||
>
|
||||
{{ tilesMessage }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border disabled:opacity-50"
|
||||
:disabled="tilesLoading"
|
||||
@click="onClearTiles"
|
||||
>
|
||||
{{ tilesLoading ? 'Clearing…' : 'Clear saved map tiles' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- About -->
|
||||
<section>
|
||||
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
|
||||
About
|
||||
</h3>
|
||||
<div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
|
||||
<p class="font-medium text-kestrel-text">
|
||||
KestrelOS
|
||||
</p>
|
||||
<p
|
||||
v-if="version"
|
||||
class="mt-1 text-sm text-kestrel-muted"
|
||||
>
|
||||
Version {{ version }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-kestrel-muted">
|
||||
Tactical Operations Center for OSINT feeds.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const config = useRuntimeConfig()
|
||||
const version = config.public?.version ?? null
|
||||
|
||||
const tilesStored = ref(null)
|
||||
const tilesMessage = ref('')
|
||||
const tilesMessageSuccess = ref(false)
|
||||
const tilesLoading = ref(false)
|
||||
|
||||
async function loadTilesStored() {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const offline = await import('leaflet.offline')
|
||||
if (offline.getStorageLength) {
|
||||
const n = await offline.getStorageLength()
|
||||
tilesStored.value = n
|
||||
}
|
||||
}
|
||||
catch {
|
||||
tilesStored.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onClearTiles() {
|
||||
tilesMessage.value = ''
|
||||
tilesLoading.value = true
|
||||
try {
|
||||
const offline = await import('leaflet.offline')
|
||||
if (offline.truncate) {
|
||||
await offline.truncate()
|
||||
tilesStored.value = 0
|
||||
tilesMessage.value = 'Saved map tiles cleared.'
|
||||
tilesMessageSuccess.value = true
|
||||
}
|
||||
else {
|
||||
tilesMessage.value = 'Could not clear tiles.'
|
||||
tilesMessageSuccess.value = false
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
tilesMessage.value = e?.message ?? 'Failed to clear tiles.'
|
||||
tilesMessageSuccess.value = false
|
||||
}
|
||||
finally {
|
||||
tilesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTilesStored()
|
||||
})
|
||||
</script>
|
||||
406
app/pages/share-live.vue
Normal file
406
app/pages/share-live.vue
Normal file
@@ -0,0 +1,406 @@
|
||||
<template>
|
||||
<div class="flex min-h-[80vh] flex-col items-center justify-center p-6">
|
||||
<div class="w-full max-w-md rounded-lg border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_24px_-6px_rgba(34,201,201,0.2)]">
|
||||
<h2 class="mb-2 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
Share live (camera + location)
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-kestrel-muted">
|
||||
Use this page in Safari on your iPhone to stream your camera and location to the map. You'll appear as a live POI.
|
||||
</p>
|
||||
<div
|
||||
v-if="!isSecureContext"
|
||||
class="mb-4 rounded border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-sm text-amber-200"
|
||||
>
|
||||
<strong>HTTPS required.</strong> From your phone, camera and location only work over a secure connection. Open this app using an HTTPS URL (e.g. a tunnel like ngrok, or a server with an SSL certificate).
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="mb-4 rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text">
|
||||
<p
|
||||
v-if="status"
|
||||
class="font-medium"
|
||||
>
|
||||
{{ status }}
|
||||
</p>
|
||||
<p
|
||||
v-if="webrtcState === 'connecting'"
|
||||
class="mt-1 text-kestrel-muted"
|
||||
>
|
||||
WebRTC: connecting…
|
||||
</p>
|
||||
<template v-if="webrtcState === 'failed'">
|
||||
<p class="mt-1 font-medium text-red-400">
|
||||
WebRTC: failed
|
||||
</p>
|
||||
<p
|
||||
v-if="webrtcFailureReason?.wrongHost"
|
||||
class="mt-1 text-amber-400"
|
||||
>
|
||||
Wrong host: server sees <strong>{{ webrtcFailureReason.wrongHost.serverHostname }}</strong> but you opened this page at <strong>{{ webrtcFailureReason.wrongHost.clientHostname }}</strong>. Use the same URL on phone and server, or set MEDIASOUP_ANNOUNCED_IP.
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-0.5 text-kestrel-muted">
|
||||
<li><strong>Firewall:</strong> Open UDP/TCP ports 40000–49999 on the server.</li>
|
||||
<li><strong>Wrong host:</strong> Server must see the same address you use (see above or open /api/live/debug-request-host).</li>
|
||||
<li><strong>Restrictive NAT / cellular:</strong> A TURN server may be required (future enhancement).</li>
|
||||
</ul>
|
||||
</template>
|
||||
<p
|
||||
v-if="error"
|
||||
class="mt-1 text-red-400"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Local preview -->
|
||||
<div
|
||||
v-if="stream && videoRef"
|
||||
class="relative mb-4 aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black"
|
||||
>
|
||||
<video
|
||||
ref="videoRef"
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-if="sharing"
|
||||
class="absolute bottom-2 left-2 rounded bg-black/70 px-2 py-1 text-xs text-green-400"
|
||||
>
|
||||
● Live — you appear on the map
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
v-if="!sharing"
|
||||
type="button"
|
||||
class="w-full rounded bg-kestrel-accent px-4 py-3 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
:disabled="starting"
|
||||
@click="startSharing"
|
||||
>
|
||||
{{ starting ? 'Starting…' : 'Start sharing' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="w-full rounded border border-red-400/60 bg-red-400/10 px-4 py-3 text-sm font-medium text-red-400 transition-opacity hover:bg-red-400/20"
|
||||
@click="stopSharing"
|
||||
>
|
||||
Stop sharing
|
||||
</button>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="block text-center text-sm text-kestrel-muted underline hover:text-kestrel-accent"
|
||||
>
|
||||
Back to map
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { createMediasoupDevice, createSendTransport } from '~/composables/useWebRTC.js'
|
||||
import { getWebRTCFailureReason } from '~/composables/useWebRTCFailureReason.js'
|
||||
import { initLogger, logError, logWarn } from '~/utils/logger.js'
|
||||
import { useUser } from '~/composables/useUser.js'
|
||||
|
||||
definePageMeta({ layout: 'default' })
|
||||
|
||||
const { user } = useUser()
|
||||
|
||||
const videoRef = ref(null)
|
||||
const stream = ref(null)
|
||||
const sessionId = ref(null)
|
||||
const status = ref('')
|
||||
const error = ref('')
|
||||
const sharing = ref(false)
|
||||
const starting = ref(false)
|
||||
const isSecureContext = typeof window !== 'undefined' && window.isSecureContext
|
||||
const webrtcState = ref('') // '', 'connecting', 'connected', 'failed'
|
||||
const webrtcFailureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
|
||||
let locationWatchId = null
|
||||
let locationIntervalId = null
|
||||
let device = null
|
||||
let sendTransport = null
|
||||
let producer = null
|
||||
|
||||
async function runFailureReasonCheck() {
|
||||
webrtcFailureReason.value = await getWebRTCFailureReason()
|
||||
}
|
||||
|
||||
function setStatus(msg) {
|
||||
status.value = msg
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function setError(msg) {
|
||||
error.value = msg
|
||||
}
|
||||
|
||||
async function startSharing() {
|
||||
starting.value = true
|
||||
setStatus('Requesting camera and location…')
|
||||
setError('')
|
||||
|
||||
try {
|
||||
// 1. Start live session on server
|
||||
const session = await $fetch('/api/live/start', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
sessionId.value = session.id
|
||||
// Initialize logger with session and user context
|
||||
initLogger(session.id, user.value?.id)
|
||||
setStatus('Session started. Requesting camera…')
|
||||
|
||||
// 2. Get camera if available (requires HTTPS on mobile Safari)
|
||||
const hasMediaDevices = typeof navigator !== 'undefined' && navigator.mediaDevices != null
|
||||
if (!hasMediaDevices) {
|
||||
setError('Media devices not available. HTTPS required.')
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
let mediaStream = null
|
||||
try {
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: 'environment',
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
},
|
||||
audio: false,
|
||||
})
|
||||
stream.value = mediaStream
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = mediaStream
|
||||
}
|
||||
setStatus('Camera on. Setting up WebRTC…')
|
||||
}
|
||||
catch {
|
||||
setError('Camera denied or unavailable. Allow camera access in browser settings.')
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Initialize Mediasoup device and create WebRTC transport
|
||||
try {
|
||||
webrtcState.value = 'connecting'
|
||||
webrtcFailureReason.value = null
|
||||
const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${sessionId.value}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
device = await createMediasoupDevice(rtpCapabilities)
|
||||
sendTransport = await createSendTransport(device, sessionId.value, {
|
||||
onConnectSuccess: () => { webrtcState.value = 'connected' },
|
||||
onConnectFailure: () => {
|
||||
webrtcState.value = 'failed'
|
||||
runFailureReasonCheck()
|
||||
},
|
||||
})
|
||||
|
||||
// 4. Produce video track
|
||||
const videoTrack = mediaStream.getVideoTracks()[0]
|
||||
if (!videoTrack) {
|
||||
throw new Error('No video track available')
|
||||
}
|
||||
producer = await sendTransport.produce({ track: videoTrack })
|
||||
// Monitor producer events
|
||||
producer.on('transportclose', () => {
|
||||
logWarn('share-live: Producer transport closed', {
|
||||
producerId: producer.id,
|
||||
producerPaused: producer.paused,
|
||||
producerClosed: producer.closed,
|
||||
})
|
||||
})
|
||||
producer.on('trackended', () => {
|
||||
logWarn('share-live: Producer track ended', {
|
||||
producerId: producer.id,
|
||||
producerPaused: producer.paused,
|
||||
producerClosed: producer.closed,
|
||||
})
|
||||
})
|
||||
// Monitor transport state (mediasoup-client does not pass a parameter; read from transport.connectionState)
|
||||
sendTransport.on('connectionstatechange', () => {
|
||||
const state = sendTransport.connectionState
|
||||
if (state === 'connected') webrtcState.value = 'connected'
|
||||
else if (state === 'failed' || state === 'disconnected' || state === 'closed') {
|
||||
logWarn('share-live: Send transport connection state changed', {
|
||||
state,
|
||||
transportId: sendTransport.id,
|
||||
producerId: producer.id,
|
||||
})
|
||||
if (state === 'failed') {
|
||||
webrtcState.value = 'failed'
|
||||
runFailureReasonCheck()
|
||||
}
|
||||
}
|
||||
})
|
||||
// Monitor track state
|
||||
if (producer.track) {
|
||||
producer.track.addEventListener('ended', () => {
|
||||
logWarn('share-live: Producer track ended', {
|
||||
producerId: producer.id,
|
||||
trackId: producer.track.id,
|
||||
trackReadyState: producer.track.readyState,
|
||||
trackEnabled: producer.track.enabled,
|
||||
trackMuted: producer.track.muted,
|
||||
})
|
||||
})
|
||||
producer.track.addEventListener('mute', () => {
|
||||
logWarn('share-live: Producer track muted', {
|
||||
producerId: producer.id,
|
||||
trackId: producer.track.id,
|
||||
trackEnabled: producer.track.enabled,
|
||||
trackMuted: producer.track.muted,
|
||||
})
|
||||
})
|
||||
producer.track.addEventListener('unmute', () => {})
|
||||
}
|
||||
webrtcState.value = 'connected'
|
||||
setStatus('WebRTC connected. Requesting location…')
|
||||
}
|
||||
catch (webrtcErr) {
|
||||
logError('share-live: WebRTC setup error', { err: webrtcErr.message || String(webrtcErr), stack: webrtcErr.stack })
|
||||
webrtcState.value = 'failed'
|
||||
runFailureReasonCheck()
|
||||
setError('Failed to set up video stream: ' + (webrtcErr.message || String(webrtcErr)))
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Get location (continuous) — also requires HTTPS on mobile Safari
|
||||
if (!navigator.geolocation) {
|
||||
setError('Geolocation not supported in this browser.')
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
locationWatchId = navigator.geolocation.watchPosition(
|
||||
(pos) => {
|
||||
resolve(pos)
|
||||
},
|
||||
(err) => {
|
||||
reject(err)
|
||||
},
|
||||
{ enableHighAccuracy: true, maximumAge: 0, timeout: 10000 },
|
||||
)
|
||||
})
|
||||
}
|
||||
catch (locErr) {
|
||||
const msg = locErr?.code === 1 || (locErr?.message && locErr.message.toLowerCase().includes('permission'))
|
||||
? 'Camera and location require a secure connection (HTTPS) when using this page from your phone. Open this app via an HTTPS URL (e.g. use a tunnel or a server with SSL).'
|
||||
: (locErr?.message || 'Location was denied or unavailable.')
|
||||
setError(msg)
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
setStatus('Location enabled. Streaming live…')
|
||||
sharing.value = true
|
||||
starting.value = false
|
||||
|
||||
// 6. Send location updates periodically (video is handled by WebRTC)
|
||||
let locationUpdate404Logged = false
|
||||
const sendLocationUpdate = async () => {
|
||||
if (!sessionId.value || !sharing.value) return
|
||||
const id = sessionId.value
|
||||
const pos = await new Promise((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
enableHighAccuracy: true,
|
||||
maximumAge: 2000,
|
||||
timeout: 5000,
|
||||
})
|
||||
}).catch(() => null)
|
||||
const lat = pos?.coords?.latitude
|
||||
const lng = pos?.coords?.longitude
|
||||
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng)) {
|
||||
try {
|
||||
await $fetch(`/api/live/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
lat,
|
||||
lng,
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
if (e?.statusCode === 404) {
|
||||
if (locationIntervalId != null) {
|
||||
clearInterval(locationIntervalId)
|
||||
locationIntervalId = null
|
||||
}
|
||||
sharing.value = false
|
||||
if (!locationUpdate404Logged) {
|
||||
locationUpdate404Logged = true
|
||||
logWarn('share-live: Session ended (404), stopping location updates', { sessionId: id })
|
||||
}
|
||||
}
|
||||
else {
|
||||
logWarn('share-live: Live location update failed', { err: e.message || String(e) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await sendLocationUpdate()
|
||||
locationIntervalId = setInterval(sendLocationUpdate, 2000)
|
||||
}
|
||||
catch (e) {
|
||||
starting.value = false
|
||||
if (e?.message) setError(e.message)
|
||||
else if (e?.name === 'NotAllowedError') setError('Camera or location was denied. Allow in Safari settings.')
|
||||
else if (e?.name === 'NotFoundError') setError('No camera found.')
|
||||
else setError('Failed to start: ' + String(e))
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (locationWatchId != null && navigator.geolocation?.clearWatch) {
|
||||
navigator.geolocation.clearWatch(locationWatchId)
|
||||
}
|
||||
locationWatchId = null
|
||||
if (locationIntervalId != null) {
|
||||
clearInterval(locationIntervalId)
|
||||
}
|
||||
locationIntervalId = null
|
||||
if (producer) {
|
||||
producer.close()
|
||||
producer = null
|
||||
}
|
||||
if (sendTransport) {
|
||||
sendTransport.close()
|
||||
sendTransport = null
|
||||
}
|
||||
device = null
|
||||
if (stream.value) {
|
||||
stream.value.getTracks().forEach(t => t.stop())
|
||||
stream.value = null
|
||||
}
|
||||
if (sessionId.value) {
|
||||
$fetch(`/api/live/${sessionId.value}`, { method: 'DELETE' }).catch(() => {})
|
||||
sessionId.value = null
|
||||
}
|
||||
sharing.value = false
|
||||
webrtcState.value = ''
|
||||
webrtcFailureReason.value = null
|
||||
}
|
||||
|
||||
async function stopSharing() {
|
||||
setStatus('Stopping…')
|
||||
cleanup()
|
||||
setStatus('')
|
||||
setError('')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
13
app/plugins/auth.client.js
Normal file
13
app/plugins/auth.client.js
Normal file
@@ -0,0 +1,13 @@
|
||||
export default defineNuxtPlugin(() => {
|
||||
const route = useRoute()
|
||||
const baseFetch = globalThis.$fetch ?? $fetch
|
||||
globalThis.$fetch = baseFetch.create({
|
||||
onResponseError({ response, request }) {
|
||||
if (response?.status !== 401) return
|
||||
const url = typeof request === 'string' ? request : request?.url ?? ''
|
||||
if (!url.startsWith('/')) return
|
||||
const redirect = (route.fullPath && route.fullPath !== '/' ? route.fullPath : '/')
|
||||
navigateTo({ path: '/login', query: { redirect } }, { replace: true })
|
||||
},
|
||||
})
|
||||
})
|
||||
88
app/utils/logger.js
Normal file
88
app/utils/logger.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Client-side logger that sends logs to server for debugging.
|
||||
* Falls back to console if server logging fails.
|
||||
*/
|
||||
|
||||
let sessionId = null
|
||||
let userId = null
|
||||
|
||||
/**
|
||||
* Initialize logger with session/user context.
|
||||
* @param {string} sessId
|
||||
* @param {string} uid
|
||||
*/
|
||||
export function initLogger(sessId, uid) {
|
||||
sessionId = sessId
|
||||
userId = uid
|
||||
}
|
||||
|
||||
/**
|
||||
* Send log to server (non-blocking).
|
||||
* @param {string} level
|
||||
* @param {string} message
|
||||
* @param {object} data
|
||||
*/
|
||||
async function sendToServer(level, message, data) {
|
||||
// Use setTimeout to avoid blocking - fire and forget
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await $fetch('/api/log', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
level,
|
||||
message,
|
||||
data,
|
||||
sessionId,
|
||||
userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
credentials: 'include',
|
||||
}).catch(() => {
|
||||
// Silently fail - don't spam console if server is down
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// Ignore errors - logging shouldn't break the app
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log at error level.
|
||||
* @param {string} message
|
||||
* @param {object} data
|
||||
*/
|
||||
export function logError(message, data) {
|
||||
console.error(`[${message}]`, data)
|
||||
sendToServer('error', message, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log at warn level.
|
||||
* @param {string} message
|
||||
* @param {object} data
|
||||
*/
|
||||
export function logWarn(message, data) {
|
||||
console.warn(`[${message}]`, data)
|
||||
sendToServer('warn', message, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log at info level.
|
||||
* @param {string} message
|
||||
* @param {object} data
|
||||
*/
|
||||
export function logInfo(message, data) {
|
||||
console.log(`[${message}]`, data)
|
||||
sendToServer('info', message, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log at debug level.
|
||||
* @param {string} message
|
||||
* @param {object} data
|
||||
*/
|
||||
export function logDebug(message, data) {
|
||||
console.log(`[${message}]`, data)
|
||||
sendToServer('debug', message, data)
|
||||
}
|
||||
Reference in New Issue
Block a user