radio: private radio tracker (/trackers/radio): frequencies, UBC125XLT banks + CSV, nets, ISS passes over Gouda, listening log, ntfy reminders
All checks were successful
Deploy production / deploy (push) Successful in 1m26s

This commit is contained in:
2026-10-26 16:58:29 +00:00
parent e24ee7f95d
commit 03574a95a7
37 changed files with 48839 additions and 3 deletions

View File

@@ -0,0 +1,51 @@
import { requireOwner } from '#lib/server/owner.js';
import {
addLog,
deleteFrequency,
deleteLog,
deleteNet,
loadRadio,
saveFrequency,
saveNet,
setChannel,
toggleRemind
} from '#lib/server/radio.js';
import { fail, type RequestEvent } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
// /trackers/radio: owner-only. Everyone else gets a 404 (here and in hooks.server.ts via
// ownerOnlyPrefixes); hooks.server.ts also sends cache-control: private, no-store.
export const load: PageServerLoad = async (event) => {
requireOwner(event);
return {
...(await loadRadio()),
now: Date.now(),
seo: {
title: 'radio',
description: 'Frequencies, scanner banks, nets and ISS passes.',
index: false
}
};
};
type Handler = (
form: FormData
) => Promise<{ ok: true; message?: string } | { ok: false; message: string }>;
// Actions don't run the load, so each one checks the owner again.
const action = (name: string, fn: Handler) => async (event: RequestEvent) => {
requireOwner(event);
const r = await fn(await event.request.formData());
if (!r.ok) return fail(400, { action: name, message: r.message });
return { action: name, ok: true as const, message: r.message ?? null };
};
export const actions: Actions = {
saveFreq: action('saveFreq', saveFrequency),
deleteFreq: action('deleteFreq', deleteFrequency),
channel: action('channel', setChannel),
saveNet: action('saveNet', saveNet),
deleteNet: action('deleteNet', deleteNet),
remind: action('remind', toggleRemind),
log: action('log', addLog),
deleteLog: action('deleteLog', deleteLog)
};

View File

@@ -0,0 +1,458 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import Tile from '#lib/radio/Tile.svelte';
import UpNext from '#lib/radio/UpNext.svelte';
import QuickLog from '#lib/radio/QuickLog.svelte';
import FreqList from '#lib/radio/FreqList.svelte';
import Banks from '#lib/radio/Banks.svelte';
import Nets from '#lib/radio/Nets.svelte';
import IssPasses from '#lib/radio/IssPasses.svelte';
import LogList from '#lib/radio/LogList.svelte';
import LayoutDashboard from 'lucide-svelte/icons/layout-dashboard';
import RadioReceiver from 'lucide-svelte/icons/radio-receiver';
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
import CalendarClock from 'lucide-svelte/icons/calendar-clock';
import Satellite from 'lucide-svelte/icons/satellite';
import NotebookPen from 'lucide-svelte/icons/notebook-pen';
import { untrack, type Component } from 'svelte';
let { data, form } = $props();
type Tab = 'overview' | 'frequencies' | 'banks' | 'nets' | 'iss' | 'log';
type Icon = Component<{ size?: number; strokeWidth?: number }>;
const TABS: { id: Tab; label: string; icon: Icon; c: [string, string] }[] = [
{
id: 'overview',
label: 'overview',
icon: LayoutDashboard as unknown as Icon,
c: ['#f43f5e', '#881337']
},
{
id: 'frequencies',
label: 'frequencies',
icon: RadioReceiver as unknown as Icon,
c: ['#a78bfa', '#5b21b6']
},
{
id: 'banks',
label: 'scanner banks',
icon: LayoutGrid as unknown as Icon,
c: ['#38bdf8', '#0369a1']
},
{
id: 'nets',
label: 'nets',
icon: CalendarClock as unknown as Icon,
c: ['#fbbf24', '#b45309']
},
{
id: 'iss',
label: 'ISS passes',
icon: Satellite as unknown as Icon,
c: ['#2dd4bf', '#0f766e']
},
{ id: 'log', label: 'log', icon: NotebookPen as unknown as Icon, c: ['#34d399', '#047857'] }
];
const tab = $derived<Tab>(
(TABS.find((t) => t.id === page.url.searchParams.get('tab'))?.id ?? 'overview') as Tab
);
const count = (t: Tab) =>
t === 'frequencies'
? data.frequencies.length
: t === 'banks'
? data.channels.length
: t === 'nets'
? data.nets.length
: t === 'iss'
? data.passes.filter((p) => p.maxEl >= 10).length
: t === 'log'
? data.logTotal
: null;
// A clock for "in 5 min" and the live highlight (the server's time first, so SSR matches).
let now = $state(untrack(() => data.now));
$effect(() => {
now = Date.now();
const t = setInterval(() => (now = Date.now()), 30_000);
return () => clearInterval(t);
});
const logMessage = $derived(form?.action === 'log' ? (form.message ?? null) : null);
const logError = $derived(form?.action === 'log' && !form.ok);
const logFreq = $derived(Number(page.url.searchParams.get('f')) || null);
const tabHref = (t: Tab) =>
resolve((t === 'overview' ? '/trackers/radio' : `/trackers/radio?tab=${t}`) as '/');
</script>
<div class="radio">
<header class="head">
<a href={resolve('/trackers/radio')} class="title-link"
><TrackerIcon name="radio" class="size-10" /></a
>
<div class="title">
<h1>radio<PrivateLock size={14} /></h1>
<p>Frequencies, scanner banks, nets, ISS passes over Gouda and what I heard.</p>
</div>
</header>
<nav class="tabs" aria-label="Radio tracker sections">
{#each TABS as t (t.id)}
{@const n = count(t.id)}
<a
href={tabHref(t.id)}
class={{ active: tab === t.id }}
aria-current={tab === t.id ? 'page' : undefined}
><Tile icon={t.icon} from={t.c[0]} to={t.c[1]} />{t.label}{#if n !== null}<span class="n"
>{n}</span
>{/if}</a
>
{/each}
</nav>
{#if tab === 'overview'}
<div class="overview">
<section class="card">
<h2 class="card-title">
<Tile icon={TABS[3].icon} from="#fbbf24" to="#b45309" size={18} />up next
</h2>
<UpNext nets={data.nets} passes={data.passes} frequencies={data.frequencies} {now} />
</section>
<section class="card">
<h2 class="card-title">
<Tile icon={TABS[5].icon} from="#34d399" to="#047857" size={18} />quick log
</h2>
<QuickLog frequencies={data.frequencies} message={logMessage} error={logError} />
<div class="recent">
<LogList log={data.log} frequencies={data.frequencies} {now} limit={5} />
</div>
</section>
<section class="card">
<h2 class="card-title">
<Tile icon={TABS[4].icon} from="#2dd4bf" to="#0f766e" size={18} />next ISS passes
<a class="more" href={tabHref('iss')}>all</a>
</h2>
<IssPasses passes={data.passes} {now} limit={4} compact />
</section>
<section class="card stats">
<h2 class="card-title">
<Tile icon={TABS[2].icon} from="#38bdf8" to="#0369a1" size={18} />scanner
</h2>
<p>
<b>{data.channels.length}</b> of 500 channels programmed, <b>{data.frequencies.length}</b> frequencies
known.
</p>
<p class="meta">
Airband: banks 4–6 (scan 4 5 6). Marine: banks 7–10 (press SCAN, then 7 8 9 0). Uniden
UBC125XLT layout; check the label on the scanner to confirm the model.
</p>
<a class="ghost" href={tabHref('banks')}>open banks</a>
</section>
</div>
{:else if tab === 'frequencies'}
<FreqList frequencies={data.frequencies} />
{:else if tab === 'banks'}
<Banks channels={data.channels} frequencies={data.frequencies} />
{:else if tab === 'nets'}
<Nets nets={data.nets} frequencies={data.frequencies} {now} />
{:else if tab === 'iss'}
<section class="card"><IssPasses passes={data.passes} tle={data.tle} {now} /></section>
{:else}
<section class="card">
<QuickLog frequencies={data.frequencies} message={logMessage} error={logError} />
</section>
<section class="card">
{#if logFreq}<p class="meta">
Showing one frequency. <a href={tabHref('log')} class="u">Show all</a>
</p>{/if}
<LogList log={data.log} frequencies={data.frequencies} {now} frequencyId={logFreq} />
</section>
{/if}
</div>
<style>
.radio {
max-width: 72rem;
margin: 0 auto;
padding: 2rem 1rem 3rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.head {
display: flex;
align-items: center;
gap: 0.85rem;
}
.title-link {
display: inline-flex;
}
.title {
min-width: 0;
}
h1 {
font-size: 2rem;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.1;
}
.title p {
color: var(--site-muted);
font-size: 0.9rem;
}
.tabs {
display: flex;
gap: 0.35rem;
overflow-x: auto;
scrollbar-width: none;
border-bottom: 1px solid var(--site-border);
padding-bottom: 0.6rem;
}
.tabs a {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.6rem 0.25rem 0.25rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.83rem;
color: var(--site-muted);
white-space: nowrap;
transition:
color 0.15s,
border-color 0.15s,
background 0.15s;
}
.tabs a:hover {
color: var(--site-text);
border-color: var(--site-muted);
}
.tabs a.active {
background: var(--site-text);
border-color: var(--site-text);
color: var(--site-bg);
}
.n {
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.overview {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
align-items: start;
}
.recent {
margin-top: 0.4rem;
}
.more {
margin-left: auto;
text-transform: none;
letter-spacing: 0;
font-size: 0.78rem;
}
.more:hover,
.u {
text-decoration: underline;
}
.stats p + p {
margin-top: 0.4rem;
}
.stats .ghost {
margin-top: 0.7rem;
}
/* Shared building blocks for the section components. */
.radio :global(.card) {
padding: 0.9rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
min-width: 0;
}
.radio :global(.card-title) {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
margin-bottom: 0.6rem;
}
.radio :global(.bar) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
flex-wrap: wrap;
}
.radio :global(.meta),
.radio :global(.empty) {
color: var(--site-muted);
font-size: 0.82rem;
}
.radio :global(.empty) {
padding: 1rem 0;
}
.radio :global(button) {
cursor: pointer;
font: inherit;
}
.radio :global(.primary) {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.8rem;
border-radius: 0.5rem;
background: var(--site-text);
color: var(--site-bg);
font-size: 0.83rem;
font-weight: 600;
transition: opacity 0.15s;
}
.radio :global(.primary:hover) {
opacity: 0.85;
}
.radio :global(.primary:disabled) {
opacity: 0.5;
}
.radio :global(.ghost) {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.7rem;
border-radius: 0.5rem;
border: 1px solid var(--site-border);
color: var(--site-muted);
font-size: 0.83rem;
}
.radio :global(.ghost:hover) {
color: var(--site-text);
border-color: var(--site-muted);
}
.radio :global(.icon) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.3rem;
border-radius: 0.4rem;
color: var(--site-muted);
transition:
color 0.15s,
background 0.15s;
}
.radio :global(.icon:hover) {
color: var(--site-text);
background: color-mix(in srgb, var(--site-text) 8%, transparent);
}
.radio :global(.icon.danger:hover) {
color: #f87171;
background: rgb(248 113 113 / 0.1);
}
.radio :global(.tag) {
font-size: 0.68rem;
padding: 0.05rem 0.4rem;
border-radius: 999px;
border: 1px solid var(--site-border);
color: var(--site-muted);
white-space: nowrap;
}
.radio :global(.chip) {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.6rem 0.2rem 0.25rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.8rem;
color: var(--site-muted);
}
.radio :global(.chip:first-child) {
padding-left: 0.6rem;
}
.radio :global(.chip.active) {
background: var(--site-text);
border-color: var(--site-text);
color: var(--site-bg);
}
.radio :global(.err) {
color: #f87171;
font-size: 0.8rem;
margin-right: auto;
}
/* Edit forms: a compact grid of labelled fields. */
.radio :global(.edit) {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.55rem;
padding: 0.75rem;
margin: 0.5rem 0;
border: 1px solid var(--site-border);
border-radius: 0.65rem;
background: var(--site-bg);
}
.radio :global(.edit label) {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.72rem;
color: var(--site-muted);
min-width: 0;
}
.radio :global(.edit .wide) {
grid-column: span 2;
}
.radio :global(.edit .full) {
grid-column: 1 / -1;
}
.radio :global(.edit input:not([type='checkbox'])),
.radio :global(.edit select),
.radio :global(.edit textarea) {
height: 2.2rem;
padding: 0 0.55rem;
border: 1px solid var(--site-border);
border-radius: 0.45rem;
background: var(--site-surface);
color: var(--site-text);
font: inherit;
font-size: 0.86rem;
min-width: 0;
width: 100%;
}
.radio :global(.edit textarea) {
height: auto;
padding: 0.4rem 0.55rem;
resize: vertical;
}
.radio :global(.edit .actions) {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
@media (max-width: 860px) {
.overview {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 640px) {
.radio {
padding-top: 1.25rem;
}
h1 {
font-size: 1.6rem;
}
.radio :global(.edit) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.radio :global(.edit input:not([type='checkbox'])),
.radio :global(.edit select),
.radio :global(.edit textarea) {
font-size: 16px;
}
}
</style>

View File

@@ -0,0 +1,17 @@
import { requireOwner } from '#lib/server/owner.js';
import { channelRows } from '#lib/server/radio.js';
import { scannerCsv } from '#lib/radio/index.js';
import type { RequestHandler } from './$types';
// All 500 scanner memories as CSV (Uniden BC125AT/UBC125XLT programming software column order).
export const GET: RequestHandler = async (event) => {
requireOwner(event);
const csv = scannerCsv(await channelRows());
return new Response(csv, {
headers: {
'content-type': 'text/csv; charset=utf-8',
'content-disposition': 'attachment; filename="ubc125xlt-banks.csv"',
'cache-control': 'private, no-store'
}
});
};