Files
kestrelos/app/components/AppShell.vue
T
keligrubb bb01e9a06c
Push / release (push) Successful in 13s
Push / publish (push) Successful in 1m4s
Add ADS-B, AIS, and ALPR map layers with live CoT streaming (#36)
## Summary

- **ADS-B & AIS:** OpenSky and AISStream OSINT feeds upsert into the CoT store; tactical tracks still arrive via adsbcot/aiscot on `:8089`. Map clients subscribe via `GET /api/cot/stream` (SSE) with viewport bbox filtering and Air / Surface / Team layer toggles.
- **ALPR (Flock/OSM):** Toggleable license-plate reader layer sourced from OpenStreetMap, with SQLite cache, Overpass fallback, tiled viewport fetching, and clustered markers with direction cones.
- **Map performance:** Ring-based tile selection (fixes zoom-out crash), immutable tile cache, incremental marker sync, split cluster load/query, and padded SSE bbox to reduce reconnect churn.

## Docs

- `docs/tracking.md` — ADS-B/AIS accuracy tiers, freshness, self-hosted receivers, optional OSINT API keys
- `docs/map-and-cameras.md` — ALPR layer and map behavior updates

---------

Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #36
2026-06-24 20:54:50 +00:00

95 lines
2.7 KiB
Vue

<template>
<div class="flex min-h-0 flex-1 flex-col">
<header class="relative z-40 flex h-14 shrink-0 items-center gap-3 bg-kestrel-surface px-4">
<NuxtLink
to="/"
class="text-lg font-semibold tracking-wide text-kestrel-text no-underline text-shadow-glow-md transition-colors hover:text-kestrel-accent focus-visible:ring-2 focus-visible:ring-kestrel-accent focus-visible:rounded"
>
KestrelOS
</NuxtLink>
<button
type="button"
class="rounded p-2 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent md:hidden"
aria-label="Toggle navigation"
:aria-expanded="drawerOpen"
@click="drawerOpen = !drawerOpen"
>
<span
class="text-lg leading-none"
aria-hidden="true"
>&#9776;</span>
</button>
<div class="min-w-0 flex-1" />
<div class="flex items-center gap-2">
<UserMenu
v-if="user"
:user="user"
@signout="onLogout"
/>
<span
v-else-if="authPending"
class="inline-block h-8 w-8"
aria-hidden="true"
/>
<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>
<div class="flex min-h-0 flex-1">
<NavDrawer
v-model="drawerOpen"
v-model:collapsed="sidebarCollapsed"
:is-mobile="isMobile"
/>
<!-- Content area: rounded top-left so it nestles into the shell (GitLab gl-rounded-t-lg style). -->
<div class="relative min-h-0 flex-1 min-w-0 overflow-clip rounded-tl-lg">
<main class="relative h-full w-full min-h-0 overflow-auto">
<slot />
</main>
</div>
</div>
</div>
</template>
<script setup>
const isMobile = useMediaQuery('(max-width: 767px)')
const drawerOpen = ref(true)
const SIDEBAR_COLLAPSED_KEY = 'kestrelos-sidebar-collapsed'
const sidebarCollapsed = ref(false)
onMounted(() => {
try {
const stored = localStorage.getItem(SIDEBAR_COLLAPSED_KEY)
if (stored !== null) sidebarCollapsed.value = stored === 'true'
}
catch {
// localStorage unavailable (e.g. private mode)
}
})
watch(sidebarCollapsed, (v) => {
try {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(v))
}
catch {
// localStorage unavailable
}
})
const { user, authPending, refresh } = useUser()
watch(isMobile, (mobile) => {
if (mobile) drawerOpen.value = false
}, { immediate: true })
async function onLogout() {
await $fetch('/api/auth/logout', { method: 'POST' })
await refresh()
await navigateTo('/')
}
</script>