162 lines
4.4 KiB
Svelte
162 lines
4.4 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { Map, getMap } from '$map';
|
|
import { Navbar } from '$features/auth';
|
|
import { flightsApi, type Flight } from '$api';
|
|
import { authStore } from '$auth';
|
|
import { settingsStore } from '$features/settings';
|
|
import { TelemetryPanel, TrackRenderer } from '$features/tracking';
|
|
import { formatCoords } from '$domain';
|
|
import { t } from '$i18n';
|
|
import FlightMarkers from './FlightMarkers.svelte';
|
|
|
|
/**
|
|
* Tracking mode: the public live map on the left, the telemetry readout for
|
|
* whatever is being followed on the right. Anonymous by design — discovery is
|
|
* the whole point of the public tier.
|
|
*/
|
|
let flights = $state<Flight[]>([]);
|
|
let status = $state<'loading' | 'empty' | 'error' | 'loaded'>('loading');
|
|
let error = $state('');
|
|
let filter = $state('');
|
|
let poll: ReturnType<typeof setInterval> | null = null;
|
|
|
|
const shown = $derived(
|
|
flights.filter((f) => !filter || f.name.toLowerCase().includes(filter.toLowerCase()))
|
|
);
|
|
|
|
async function load() {
|
|
try {
|
|
const res = await flightsApi.public();
|
|
flights = res.flights;
|
|
status = flights.length === 0 ? 'empty' : 'loaded';
|
|
} catch (err) {
|
|
error = (err as Error).message;
|
|
status = 'error';
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
authStore.refresh();
|
|
load();
|
|
// The list is a live view; refresh it periodically rather than per-flight sockets.
|
|
poll = setInterval(load, 15000);
|
|
return () => {
|
|
if (poll) clearInterval(poll);
|
|
};
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (poll) clearInterval(poll);
|
|
});
|
|
</script>
|
|
|
|
<Navbar />
|
|
<div class="flights-page">
|
|
<Map center={[129.7, 62.0]} zoom={4}>
|
|
<FlightMarkers flights={shown} />
|
|
<TrackRenderer />
|
|
</Map>
|
|
|
|
<div class="flights-panels">
|
|
<div class="flights-panel flights-panel-left">
|
|
<div class="card shadow-sm">
|
|
<div class="card-body p-3">
|
|
<h6 class="mb-2">{$t('flights.liveTitle')}</h6>
|
|
<input
|
|
type="search"
|
|
class="form-control form-control-sm mb-2"
|
|
data-testid="flights-filter"
|
|
placeholder={$t('flights.filter')}
|
|
aria-label={$t('flights.filter')}
|
|
bind:value={filter} />
|
|
|
|
{#if status === 'loading'}
|
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
|
{:else if status === 'error'}
|
|
<div class="alert alert-danger py-1 px-2 small mb-0">
|
|
<div>{error}</div>
|
|
<button class="btn btn-sm btn-link p-0" onclick={load}>{$t('wind.retry')}</button>
|
|
</div>
|
|
{:else if status === 'empty'}
|
|
<p class="small text-muted mb-0" data-testid="flights-empty">
|
|
{$t('flights.noneLive')}
|
|
</p>
|
|
{:else}
|
|
<div class="list-group list-group-flush" data-testid="flights-list">
|
|
{#each shown as f (f.id)}
|
|
<a class="list-group-item list-group-item-action px-2" href={`/track/${f.id}/`}>
|
|
<div class="d-flex justify-content-between">
|
|
<span class="small fw-semibold">{f.name || $t('flights.untitled')}</span>
|
|
<span class="badge text-bg-success align-self-start">
|
|
{f.packet_count}
|
|
</span>
|
|
</div>
|
|
{#if f.last_position}
|
|
<div class="small text-muted font-monospace">
|
|
{formatCoords(
|
|
f.last_position.lat,
|
|
f.last_position.lon,
|
|
$settingsStore.format.coords
|
|
)}
|
|
· {Math.round(f.last_position.alt)} m
|
|
</div>
|
|
{/if}
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flights-panel flights-panel-right">
|
|
<TelemetryPanel />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.flights-page {
|
|
position: absolute;
|
|
inset: var(--navbar-height) 0 0 0;
|
|
}
|
|
|
|
/*
|
|
* Narrow: one scrolling column carries the list and the readout, because two
|
|
* 320px columns would leave no map between them.
|
|
*/
|
|
.flights-panels {
|
|
position: absolute;
|
|
top: 0.75rem;
|
|
left: 0.75rem;
|
|
width: min(320px, calc(100vw - 1.5rem));
|
|
max-height: calc(100% - 1.5rem);
|
|
overflow-y: auto;
|
|
z-index: 5;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
/* Wide: the wrapper steps out of the box model and the panels flank the map. */
|
|
@media (min-width: 768px) {
|
|
.flights-panels {
|
|
display: contents;
|
|
}
|
|
.flights-panel {
|
|
position: absolute;
|
|
top: 0.75rem;
|
|
width: min(320px, calc(50vw - 1.5rem));
|
|
max-height: calc(100% - 1.5rem);
|
|
overflow-y: auto;
|
|
z-index: 5;
|
|
}
|
|
.flights-panel-left {
|
|
left: 0.75rem;
|
|
}
|
|
.flights-panel-right {
|
|
right: 0.75rem;
|
|
}
|
|
}
|
|
</style>
|