stratoflights-web/src/routes/flights/+page.svelte
2026-08-14 00:01:49 +09:00

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>