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

This commit is contained in:
stan committed 2026-10-26 16:58:29 +00:00
1 parent e24ee7f95d
commit 03574a95a7
37 files changed
+48839 -3

No files matched your search

+285
View File
@@ -0,0 +1,285 @@
<script lang="ts">
// Uniden UBC125XLT memory: 10 banks x 50 channels. Pick a bank, tap a channel to edit it.
import { enhance } from '$app/forms';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import BandIcon from './BandIcon.svelte';
import Download from 'lucide-svelte/icons/download';
import X from 'lucide-svelte/icons/x';
import {
ALPHA_TAG_MAX,
BANDS,
BAND_LABEL,
BANKS,
PER_BANK,
bankOf,
bankRange,
formatFreq
} from './index.js';
import type { Channel, Frequency } from '#lib/server/radio.js';
let { channels, frequencies }: { channels: Channel[]; frequencies: Frequency[] } = $props();
const initialCh = Number(page.url.searchParams.get('ch')) || null;
let bank = $state(initialCh ? bankOf(initialCh) : Number(page.url.searchParams.get('bank')) || 4);
let selected = $state<number | null>(initialCh);
let error = $state<string | null>(null);
const byCh = $derived(new Map(channels.map((c) => [c.channel, c])));
const byId = $derived(new Map(frequencies.map((f) => [f.id, f])));
const perBank = $derived.by(() => {
const n = Array.from({ length: BANKS }, () => 0);
for (const c of channels) n[bankOf(c.channel) - 1]++;
return n;
});
const slots = $derived.by(() => {
const [a, b] = bankRange(bank);
return Array.from({ length: b - a + 1 }, (_, i) => a + i);
});
const sel = $derived(selected ? byCh.get(selected) : undefined);
const grouped = $derived(
BANDS.map((b) => ({ band: b, list: frequencies.filter((f) => f.band === b) })).filter(
(g) => g.list.length
)
);
</script>
<section class="card">
<div class="bar">
<div class="banks" role="tablist" aria-label="Scanner banks">
{#each perBank as n, i (i)}
<button
type="button"
role="tab"
aria-selected={bank === i + 1}
class={['bank', { active: bank === i + 1, empty: !n }]}
onclick={() => {
bank = i + 1;
selected = null;
}}><b>{i + 1}</b><span>{n}/{PER_BANK}</span></button
>
{/each}
</div>
<a class="ghost" href={resolve('/trackers/radio/banks.csv')} download data-sveltekit-reload
><Download size={14} />CSV</a
>
</div>
<p class="meta hint">
Bank {bank}: channels {bankRange(bank)[0]}–{bankRange(bank)[1]} · {channels.length} of 500 programmed
· Uniden UBC125XLT layout (model unconfirmed)
</p>
<div class="grid">
{#each slots as ch (ch)}
{@const c = byCh.get(ch)}
{@const f = c ? byId.get(c.frequencyId) : undefined}
<button
type="button"
class={['slot', { filled: !!f, sel: selected === ch, locked: c?.lockout }]}
onclick={() => (selected = selected === ch ? null : ch)}
aria-pressed={selected === ch}
>
<span class="no">{ch}</span>
{#if f && c}
<BandIcon band={f.band} size={16} />
<span class="tg">{c.tag}</span>
<span class="fq">{formatFreq(f.freqHz, false)} <i>{f.mode}</i></span>
{:else}
<span class="tg dim">empty</span>
{/if}
</button>
{/each}
</div>
{#if selected}
{@const f = sel ? byId.get(sel.frequencyId) : undefined}
<form
class="edit chedit"
method="POST"
action="?/channel"
use:enhance={() =>
async ({ result, update }) => {
if (result.type === 'failure') error = String(result.data?.message ?? 'Could not save.');
else {
error = null;
await update({ reset: false });
}
}}
>
<input type="hidden" name="channel" value={selected} />
<div class="full bar">
<strong>Channel {selected}</strong>
<button type="button" class="icon" aria-label="close" onclick={() => (selected = null)}
><X size={15} /></button
>
</div>
{#key selected}
<label class="wide"
>frequency<select name="frequency_id" value={sel?.frequencyId ?? ''} required>
<option value="" disabled>pick a frequency…</option>
{#each grouped as g (g.band)}
<optgroup label={BAND_LABEL[g.band]}>
{#each g.list as o (o.id)}<option value={o.id}
>{formatFreq(o.freqHz)} · {o.name}</option
>{/each}
</optgroup>
{/each}
</select></label
>
<label
>alpha tag<input
name="alpha_tag"
maxlength={ALPHA_TAG_MAX}
value={sel?.alphaTag ?? ''}
placeholder={sel?.tag ?? 'auto from name'}
/></label
>
<div class="checks">
<label><input type="checkbox" name="delay" checked={sel?.delay ?? true} />delay</label>
<label
><input type="checkbox" name="lockout" checked={sel?.lockout ?? false} />lockout</label
>
<label
><input
type="checkbox"
name="priority"
checked={sel?.priority ?? false}
/>priority</label
>
</div>
{/key}
{#if f?.notes}<p class="meta full">{f.notes}</p>{/if}
<div class="actions full">
{#if error}<span class="err">{error}</span>{/if}
{#if sel}<button class="ghost" name="clear" value="1" formnovalidate>clear channel</button
>{/if}
<button class="primary">save</button>
</div>
</form>
{/if}
</section>
<style>
.banks {
display: flex;
gap: 0.3rem;
overflow-x: auto;
scrollbar-width: none;
}
.bank {
display: inline-flex;
flex-direction: column;
align-items: center;
min-width: 2.8rem;
padding: 0.25rem 0.4rem;
border: 1px solid var(--site-border);
border-radius: 0.55rem;
color: var(--site-text);
line-height: 1.1;
}
.bank b {
font-size: 0.95rem;
}
.bank span {
font-size: 0.62rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.bank.empty {
opacity: 0.55;
}
.bank.active {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
opacity: 1;
}
.bank.active span {
color: inherit;
opacity: 0.7;
}
.hint {
margin: 0.5rem 0 0.7rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
gap: 0.3rem;
}
.slot {
display: grid;
grid-template-columns: 1.9rem 16px 1fr;
grid-template-rows: auto auto;
column-gap: 0.4rem;
align-items: center;
padding: 0.35rem 0.45rem;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
text-align: left;
background: var(--site-bg);
min-width: 0;
font-family: ui-monospace, monospace;
}
.slot:hover {
border-color: var(--site-muted);
}
.slot.sel {
border-color: var(--site-text);
box-shadow: 0 0 0 1px var(--site-text);
}
.slot.locked {
opacity: 0.5;
}
.no {
grid-row: 1 / 3;
font-size: 0.7rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.slot :global(.band-icon) {
grid-row: 1 / 3;
}
.tg {
font-size: 0.74rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
}
.tg.dim {
grid-column: 2 / 4;
color: var(--site-muted);
opacity: 0.6;
text-transform: none;
}
.fq {
grid-column: 3;
font-size: 0.68rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.fq i {
font-style: normal;
opacity: 0.7;
}
.chedit {
margin-top: 0.8rem;
}
.checks {
display: flex;
gap: 0.8rem;
align-items: flex-end;
font-size: 0.8rem;
padding-bottom: 0.4rem;
}
.checks label {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>