radio: public read-only tracker for Opa (NL/EN toggle, bigger type and tap targets)
Deploy production / deploy (push) Successful in 1m22s

Visitors see frequencies, banks, nets, ISS passes and the log without logging in; every
write stays owner-only (actions check the owner, hooks refuse non-GET). The public payload
drops reminder switches, log notes and inactive nets, and the page no longer shows the
observer coordinates (only "Gouda"). Dutch/English toggle (?lang, cookie, Accept-Language,
NL fallback). Larger fonts, high-contrast labels and 44px+ controls for phones and tablets;
radio listed as public (no lock) and in the sitemap; banks CSV public.
This commit is contained in:
stan committed 2026-10-27 09:23:28 +00:00
1 parent e1e2bee590
commit 96d7333fe9
16 files changed
+1061 -420

No files matched your search

+2 -2
View File
@@ -428,10 +428,10 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
},
{
name: 'radio',
subtitle: 'scanner frequencies, banks, nets, ISS passes and my listening log',
subtitle: "Opa's scanner frequencies, banks, nets, ISS passes and my listening log",
href: '/trackers/radio',
external: false,
private: true
private: false
},
{
name: 'math',
+115 -33
View File
@@ -1,5 +1,6 @@
<script lang="ts">
// Uniden UBC125XLT memory: 10 banks x 50 channels. Pick a bank, tap a channel to edit it.
// Uniden UBC125XLT memory: 10 banks x 50 channels. Pick a bank, tap a channel to see it (and,
// for the owner, edit it).
import { enhance } from '$app/forms';
import { page } from '$app/state';
import { resolve } from '$app/paths';
@@ -16,10 +17,17 @@
bankRange,
formatFreq
} from './index.js';
import { DICTS, getRadio } from './i18n.js';
import type { Channel, Frequency } from '#lib/server/radio.js';
let { channels, frequencies }: { channels: Channel[]; frequencies: Frequency[] } = $props();
const r = getRadio();
/** Bring the channel details into view (they sit under the 50 channels). */
const reveal = (node: HTMLElement) => {
node.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
};
const t = $derived(DICTS[r.lang]);
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);
@@ -46,7 +54,7 @@
<section class="card">
<div class="bar">
<div class="banks" role="tablist" aria-label="Scanner banks">
<div class="banks" role="tablist" aria-label={t.banksLabel}>
{#each perBank as n, i (i)}
<button
type="button"
@@ -56,17 +64,18 @@
onclick={() => {
bank = i + 1;
selected = null;
}}><b>{i + 1}</b><span>{n}/{PER_BANK}</span></button
}}
aria-label="{t.bank} {i + 1}: {n}/{PER_BANK}"
><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
><Download size={18} />{t.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)
{t.bankHint(bank, bankRange(bank)[0], bankRange(bank)[1], channels.length)}
</p>
<div class="grid">
@@ -81,17 +90,50 @@
>
<span class="no">{ch}</span>
{#if f && c}
<BandIcon band={f.band} size={16} />
<BandIcon band={f.band} size={26} />
<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>
<span class="tg dim">{t.empty}</span>
{/if}
</button>
{/each}
</div>
{#if selected}
{#if selected && !r.owner}
{#key selected}
{@const f = sel ? byId.get(sel.frequencyId) : undefined}
<div class="detail" role="region" use:reveal aria-label={t.channelN(selected)}>
<div class="bar">
<strong>{t.channelN(selected)}</strong>
<button type="button" class="ghost" onclick={() => (selected = null)}
><X size={18} />{t.close}</button
>
</div>
{#if f && sel}
<div class="drow">
<BandIcon band={f.band} size={40} />
<div>
<p class="dname">{f.name}</p>
<p class="dfreq">
{formatFreq(f.freqHz)} · {f.mode} · {t.bands[f.band as keyof typeof t.bands] ??
f.band}
</p>
</div>
</div>
<p class="dflags">
<span class="tag">{sel.tag}</span>
{#if sel.priority}<span class="tag">{t.priority}</span>{/if}
{#if sel.lockout}<span class="tag">{t.lockout}</span>{/if}
{#if f.location}<span>{f.location}</span>{/if}
</p>
{#if f.notes}<p class="meta">{f.notes}</p>{/if}
{:else}
<p class="meta">{t.empty}</p>
{/if}
</div>
{/key}
{:else if selected}
{@const f = sel ? byId.get(sel.frequencyId) : undefined}
<form
class="edit chedit"
@@ -162,27 +204,28 @@
<style>
.banks {
display: flex;
gap: 0.3rem;
overflow-x: auto;
scrollbar-width: none;
gap: 0.4rem;
flex-wrap: wrap;
}
.bank {
display: inline-flex;
flex-direction: column;
align-items: center;
min-width: 2.8rem;
padding: 0.25rem 0.4rem;
justify-content: center;
min-width: 3.4rem;
min-height: 3.4rem;
padding: 0.3rem 0.5rem;
border: 1px solid var(--site-border);
border-radius: 0.55rem;
border-radius: 0.7rem;
color: var(--site-text);
line-height: 1.1;
line-height: 1.15;
}
.bank b {
font-size: 0.95rem;
font-size: 1.2rem;
}
.bank span {
font-size: 0.62rem;
color: var(--site-muted);
font-size: 0.78rem;
opacity: 0.75;
font-variant-numeric: tabular-nums;
}
.bank.empty {
@@ -203,18 +246,20 @@
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
gap: 0.3rem;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 0.4rem;
}
.slot {
display: grid;
grid-template-columns: 1.9rem 16px 1fr;
grid-template-columns: 2.4rem 26px 1fr;
grid-template-rows: auto auto;
column-gap: 0.4rem;
column-gap: 0.5rem;
align-items: center;
padding: 0.35rem 0.45rem;
min-height: 3.5rem;
padding: 0.45rem 0.6rem;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
border-radius: 0.65rem;
color: var(--site-text);
text-align: left;
background: var(--site-bg);
min-width: 0;
@@ -232,15 +277,17 @@
}
.no {
grid-row: 1 / 3;
font-size: 0.7rem;
color: var(--site-muted);
font-size: 0.9rem;
font-weight: 600;
opacity: 0.75;
font-variant-numeric: tabular-nums;
}
.slot :global(.band-icon) {
grid-row: 1 / 3;
}
.tg {
font-size: 0.74rem;
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -248,14 +295,14 @@
}
.tg.dim {
grid-column: 2 / 4;
color: var(--site-muted);
opacity: 0.6;
font-weight: 400;
opacity: 0.55;
text-transform: none;
}
.fq {
grid-column: 3;
font-size: 0.68rem;
color: var(--site-muted);
font-size: 0.85rem;
opacity: 0.8;
font-variant-numeric: tabular-nums;
}
.fq i {
@@ -265,6 +312,38 @@
.chedit {
margin-top: 0.8rem;
}
.detail {
margin-top: 0.9rem;
padding: 1rem;
border: 1px solid var(--site-text);
border-radius: 0.8rem;
background: var(--site-bg);
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.detail strong {
font-size: 1.15rem;
}
.drow {
display: flex;
align-items: center;
gap: 0.8rem;
}
.dname {
font-size: 1.15rem;
font-weight: 600;
}
.dfreq {
font-size: 1.05rem;
font-variant-numeric: tabular-nums;
}
.dflags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
}
.checks {
display: flex;
gap: 0.8rem;
@@ -279,7 +358,10 @@
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: minmax(0, 1fr);
}
.bank {
min-width: 3.1rem;
}
}
</style>
+68 -50
View File
@@ -1,5 +1,5 @@
<script lang="ts">
// Frequencies: search, band filter, inline edit.
// Frequencies: search, band filter, inline edit (owner only).
import { page } from '$app/state';
import { resolve } from '$app/paths';
import BandIcon from './BandIcon.svelte';
@@ -7,11 +7,14 @@
import Search from 'lucide-svelte/icons/search';
import Plus from 'lucide-svelte/icons/plus';
import Pencil from 'lucide-svelte/icons/pencil';
import { BANDS, BAND_LABEL, formatFreq, mhz } from './index.js';
import { BANDS, formatFreq, mhz } from './index.js';
import { DICTS, getRadio } from './i18n.js';
import type { Frequency } from '#lib/server/radio.js';
let { frequencies }: { frequencies: Frequency[] } = $props();
const r = getRadio();
const t = $derived(DICTS[r.lang]);
let q = $state(page.url.searchParams.get('q') ?? '');
let band = $state<string | null>(page.url.searchParams.get('band'));
let editing = $state<number | null>(null);
@@ -30,7 +33,7 @@
if (!needle) return true;
if (/^\d+(\.\d*)?$/.test(num))
return mhz(f.freqHz).startsWith(num) || String(f.freqHz / 1000).startsWith(num);
if (/^ch\s*\d+$/.test(needle)) {
if (/^(ch|kan\.?)\s*\d+$/.test(needle)) {
const n = Number(needle.replace(/\D/g, ''));
return f.channels.includes(n) || f.marineCh === String(n).padStart(2, '0');
}
@@ -49,38 +52,38 @@
<Search size={15} />
<input
type="search"
placeholder="search name, 145.45, ch 16, tag"
aria-label="Search frequencies"
placeholder={t.searchPlaceholder}
aria-label={t.searchLabel}
bind:value={q}
/>
</label>
<button type="button" class="primary" onclick={() => (adding = !adding)}
><Plus size={15} />add</button
>
{#if r.owner}<button type="button" class="primary" onclick={() => (adding = !adding)}
><Plus size={15} />add</button
>{/if}
</div>
<div class="chips" role="group" aria-label="Filter by band">
<div class="chips" role="group" aria-label={t.filterBand}>
<button type="button" class={['chip', { active: !band }]} onclick={() => (band = null)}
>all <span class="n">{frequencies.length}</span></button
>{t.allBands} <span class="n">{frequencies.length}</span></button
>
{#each BANDS.filter((b) => counts[b]) as b (b)}
<button
type="button"
class={['chip', { active: band === b }]}
onclick={() => (band = band === b ? null : b)}
><BandIcon band={b} size={18} />{BAND_LABEL[b]} <span class="n">{counts[b]}</span></button
><BandIcon band={b} size={28} />{t.bands[b]} <span class="n">{counts[b]}</span></button
>
{/each}
</div>
{#if adding}<FreqForm ondone={() => (adding = false)} />{/if}
{#if r.owner && adding}<FreqForm ondone={() => (adding = false)} />{/if}
<ul class="freqs">
{#each shown as f (f.id)}
<li>
{#if editing === f.id}
{#if r.owner && editing === f.id}
<FreqForm freq={f} ondone={() => (editing = null)} />
{:else}
<div class="frow">
<BandIcon band={f.band} size={26} />
<BandIcon band={f.band} size={36} />
<span class="fq"
>{formatFreq(f.freqHz, false)}<small>{f.freqHz < 30e6 ? 'kHz' : 'MHz'}</small></span
>
@@ -88,7 +91,7 @@
<div class="nm">
<span class="name">{f.name}</span>
<span class="sub">
{#if f.marineCh}<span class="tag">ch {f.marineCh}</span>{/if}
{#if f.marineCh}<span class="tag">{t.channel} {f.marineCh}</span>{/if}
{#if f.location}<span>{f.location}</span>{/if}
{#each f.tags as t (t)}<span class="tag">{t}</span>{/each}
{#if f.notes}<span class="note">{f.notes}</span>{/if}
@@ -98,25 +101,26 @@
{#each f.channels as c (c)}<a
class="chan"
href={resolve(`/trackers/radio?tab=banks&ch=${c}` as '/')}
title="scanner channel {c}">{c}</a
title={t.scannerChannel(c)}
aria-label={t.scannerChannel(c)}>{c}</a
>{/each}
{#if f.logCount}<a
class="logged"
href={resolve(`/trackers/radio?tab=log&f=${f.id}` as '/')}
title="log entries">{f.logCount}×</a
title={t.logEntries}>{f.logCount}×</a
>{/if}
</div>
<button
type="button"
class="icon"
aria-label="edit {f.name}"
onclick={() => (editing = f.id)}><Pencil size={14} /></button
>
{#if r.owner}<button
type="button"
class="icon"
aria-label="edit {f.name}"
onclick={() => (editing = f.id)}><Pencil size={16} /></button
>{/if}
</div>
{/if}
</li>
{:else}
<li class="empty">No frequencies match.</li>
<li class="empty">{t.noMatch}</li>
{/each}
</ul>
</section>
@@ -127,9 +131,9 @@
align-items: center;
gap: 0.4rem;
flex: 1 1 14rem;
max-width: 24rem;
padding: 0 0.6rem;
height: 2.2rem;
max-width: 28rem;
padding: 0 0.85rem;
height: 3rem;
border: 1px solid var(--site-border);
border-radius: 999px;
color: var(--site-muted);
@@ -141,14 +145,14 @@
border: 0;
outline: none;
font: inherit;
font-size: 0.85rem;
font-size: 16px;
color: var(--site-text);
}
.chips {
display: flex;
gap: 0.35rem;
gap: 0.45rem;
flex-wrap: wrap;
margin: 0.7rem 0;
margin: 0.9rem 0;
}
.freqs {
display: flex;
@@ -160,26 +164,28 @@
.frow {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0;
gap: 0.7rem;
padding: 0.65rem 0;
}
.fq {
width: 6.6rem;
width: 8.6rem;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
font-family: ui-monospace, monospace;
font-size: 0.86rem;
font-size: 1.05rem;
font-weight: 600;
}
.fq small {
margin-left: 0.2rem;
font-size: 0.65rem;
color: var(--site-muted);
margin-left: 0.25rem;
font-size: 0.8rem;
font-weight: 400;
opacity: 0.7;
}
.md {
width: 2.6rem;
width: 3rem;
flex-shrink: 0;
font-size: 0.7rem;
color: var(--site-muted);
font-size: 0.85rem;
opacity: 0.75;
}
.nm {
flex: 1;
@@ -188,31 +194,40 @@
flex-direction: column;
}
.name {
font-size: 0.88rem;
font-size: 1.05rem;
font-weight: 500;
}
.sub {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
gap: 0.35rem;
align-items: center;
font-size: 0.74rem;
color: var(--site-muted);
font-size: 0.92rem;
color: var(--site-text);
}
.sub > span:not(.tag) {
opacity: 0.75;
}
.note {
flex-basis: 100%;
}
.chs {
display: flex;
gap: 0.25rem;
gap: 0.3rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.chan,
.logged {
font-size: 0.7rem;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
min-height: 2.25rem;
font-size: 0.9rem;
font-variant-numeric: tabular-nums;
padding: 0.05rem 0.4rem;
border-radius: 0.35rem;
padding: 0.1rem 0.5rem;
border-radius: 0.45rem;
background: color-mix(in srgb, var(--site-text) 8%, transparent);
}
.logged {
@@ -229,10 +244,13 @@
.md {
width: auto;
}
.fq {
width: auto;
}
.nm {
flex-basis: calc(100% - 2.5rem);
flex-basis: 100%;
order: 5;
padding-left: 2.1rem;
padding-left: calc(36px + 0.7rem);
}
.chs {
margin-left: auto;
+71 -46
View File
@@ -1,19 +1,18 @@
<script lang="ts">
// ISS passes over Gouda: start / max / end (Amsterdam time), max elevation, direction.
// ISS passes over Gouda: start / max / end (Amsterdam time), max elevation, direction. The
// observer position stays in scripts/radio.ts (server side); the page only says "Gouda".
import Eye from 'lucide-svelte/icons/eye';
import {
ISS_DOWNLINK_HZ,
ISS_MIN_EL,
QUIET_HOURS,
REMIND_MINUTES,
compass,
dayKey,
fmtTime,
formatFreq,
parseQuiet,
relDay,
until
parseQuiet
} from './index.js';
import { DICTS, getRadio, helpers } from './i18n.js';
import type { IssPass } from '#lib/server/radio.js';
let {
@@ -30,6 +29,9 @@
compact?: boolean;
} = $props();
const r = getRadio();
const t = $derived(DICTS[r.lang]);
const h = $derived(helpers(t));
let showLow = $state(false);
const list = $derived.by(() => {
const l = passes.filter(
@@ -41,30 +43,25 @@
const out: { key: string; label: string; passes: IssPass[] }[] = [];
for (const p of list) {
const k = dayKey(p.startAt);
if (out.at(-1)?.key !== k) out.push({ key: k, label: relDay(p.startAt, now), passes: [] });
if (out.at(-1)?.key !== k) out.push({ key: k, label: h.relDay(p.startAt, now), passes: [] });
out.at(-1)!.passes.push(p);
}
return out;
});
const mins = (p: IssPass) =>
Math.round((new Date(p.endAt).getTime() - new Date(p.startAt).getTime()) / 60_000);
const fmtAgo = (iso: string) =>
new Date(iso).toLocaleString('en-GB', {
timeZone: 'Europe/Amsterdam',
dateStyle: 'medium',
timeStyle: 'short'
});
</script>
{#if !compact}
<div class="bar">
<p class="meta">
Gouda (52.011 N, 4.711 E) · listen on <strong>{formatFreq(ISS_DOWNLINK_HZ)} NFM</strong>
(ARISS voice / SSTV) ·
<Eye size={12} /> = sunlit in a dark sky, visible to the eye
{t.issMeta} <strong>{formatFreq(ISS_DOWNLINK_HZ)} NFM</strong>
{t.issMeta2} ·
<span class="vis inline"><Eye size={16} /></span>
{t.visibleLegend}
</p>
<label class="low"
><input type="checkbox" bind:checked={showLow} />passes below {ISS_MIN_EL}°</label
><input type="checkbox" bind:checked={showLow} />{t.showLow(ISS_MIN_EL)}</label
>
</div>
{/if}
@@ -76,35 +73,45 @@
{#each d.passes as p (p.startAt)}
{@const live = new Date(p.startAt).getTime() <= now}
<li class={{ low: p.maxEl < ISS_MIN_EL, live }}>
<span class="times">
<span
class="times"
aria-label="{t.start} {fmtTime(p.startAt)}, {t.highest} {fmtTime(
p.maxAt
)}, {t.end} {fmtTime(p.endAt)}"
>
<b>{fmtTime(p.startAt)}</b><span class="arrow">→</span>{fmtTime(p.maxAt)}<span
class="arrow">→</span
>{fmtTime(p.endAt)}
</span>
<span class="el" title="max elevation">
<span class="el" title={t.maxEl}>
<span class="elbar"><i style:width="{Math.min(100, (p.maxEl / 90) * 100)}%"></i></span
>{Math.round(p.maxEl)}°
</span>
<span class="dir">{compass(p.startAz)} → {compass(p.maxAz)} → {compass(p.endAz)}</span>
<span class="dur">{mins(p)} min</span>
<span class="vis"
>{#if p.sunlit}<Eye size={14} aria-label="visible to the eye" />{/if}</span
<span class="dir"
>{h.compass(p.startAz)} → {h.compass(p.maxAz)} → {h.compass(p.endAz)}</span
>
<span class="rel">{live ? 'overhead now' : until(p.startAt, now)}</span>
<span class="dur">{t.minutes(mins(p))}</span>
<span class="vis"
>{#if p.sunlit}<Eye size={18} aria-label={t.visibleEye} />{/if}</span
>
<span class="rel">{live ? t.overheadNow : h.until(p.startAt, now)}</span>
</li>
{/each}
</ul>
{/each}
{:else}
<p class="empty">No passes computed yet (the daily refresh fills this).</p>
<p class="empty">{t.noPasses}</p>
{/if}
</div>
{#if !compact && tle}
<p class="meta foot">
{tle.name} TLE from Celestrak, epoch {tle.epoch ? fmtAgo(tle.epoch) : '?'}, fetched {tle.fetchedAt
? fmtAgo(tle.fetchedAt)
: '?'} (refreshed daily). Passes of {ISS_MIN_EL}° and up get a reminder {REMIND_MINUTES} min before,
except during quiet hours ({parseQuiet(QUIET_HOURS)?.label}).
{t.tleFoot(
tle.name,
tle.epoch ? h.fmtDateTime(tle.epoch) : '?',
tle.fetchedAt ? h.fmtDateTime(tle.fetchedAt) : '?'
)}
{#if r.owner}Passes of {ISS_MIN_EL}° and up get a phone reminder {REMIND_MINUTES} min before, except
during quiet hours ({parseQuiet(QUIET_HOURS)?.label}).{/if}
</p>
{/if}
@@ -112,16 +119,22 @@
.low {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--site-muted);
gap: 0.45rem;
min-height: 2.75rem;
font-size: 0.98rem;
cursor: pointer;
}
.low input {
width: 1.25rem;
height: 1.25rem;
}
.day {
font-size: 0.72rem;
font-size: 0.98rem;
font-weight: 650;
margin: 0.9rem 0 0.25rem;
}
.day::first-letter {
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
margin: 0.8rem 0 0.25rem;
}
.passes {
display: flex;
@@ -129,12 +142,12 @@
}
li {
display: grid;
grid-template-columns: 11.5rem 5.5rem 1fr 3.5rem 1.2rem 6.5rem;
grid-template-columns: 13.5rem 6.5rem 1fr 4.5rem 1.5rem 8rem;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.3rem;
border-radius: 0.45rem;
font-size: 0.84rem;
gap: 0.6rem;
padding: 0.6rem 0.4rem;
border-radius: 0.55rem;
font-size: 1.02rem;
font-variant-numeric: tabular-nums;
}
li + li {
@@ -156,8 +169,8 @@
gap: 0.35rem;
}
.elbar {
width: 2.4rem;
height: 5px;
width: 2.8rem;
height: 7px;
border-radius: 3px;
background: var(--site-border);
overflow: hidden;
@@ -170,16 +183,19 @@
.dir,
.dur,
.rel {
color: var(--site-muted);
font-size: 0.78rem;
opacity: 0.8;
font-size: 0.95rem;
}
.rel {
text-align: right;
}
.vis {
color: #fbbf24;
color: #f59e0b;
display: inline-flex;
}
.vis.inline {
vertical-align: -0.2em;
}
.foot {
margin-top: 0.8rem;
}
@@ -193,6 +209,13 @@
}
.times {
grid-area: 1 / 1;
white-space: nowrap;
}
.arrow {
margin: 0 0.15rem;
}
.elbar {
display: none;
}
.el {
grid-area: 1 / 2;
@@ -206,8 +229,10 @@
.dur {
display: none;
}
/* Shares row 2 with the direction, pushed right, so it never widens the columns. */
.rel {
grid-area: 2 / 2 / 3 / 4;
grid-area: 2 / 1 / 3 / 4;
justify-self: end;
}
}
</style>
+35 -27
View File
@@ -5,7 +5,8 @@
import BandIcon from './BandIcon.svelte';
import Signal from './Signal.svelte';
import Trash2 from 'lucide-svelte/icons/trash-2';
import { dayKey, fmtTime, formatFreq, mhz, relDay } from './index.js';
import { dayKey, fmtTime, formatFreq, mhz } from './index.js';
import { DICTS, getRadio, helpers } from './i18n.js';
import type { Frequency, LogEntry } from '#lib/server/radio.js';
let {
@@ -22,6 +23,9 @@
frequencyId?: number | null;
} = $props();
const r = getRadio();
const t = $derived(DICTS[r.lang]);
const h = $derived(helpers(t));
const byId = $derived(new Map(frequencies.map((f) => [f.id, f])));
const days = $derived.by(() => {
let l = frequencyId ? log.filter((e) => e.frequencyId === frequencyId) : log;
@@ -29,7 +33,7 @@
const out: { key: string; label: string; entries: LogEntry[] }[] = [];
for (const e of l) {
const k = dayKey(e.heardAt);
if (out.at(-1)?.key !== k) out.push({ key: k, label: relDay(e.heardAt, now), entries: [] });
if (out.at(-1)?.key !== k) out.push({ key: k, label: h.relDay(e.heardAt, now), entries: [] });
out.at(-1)!.entries.push(e);
}
return out;
@@ -43,7 +47,7 @@
{@const f = e.frequencyId ? byId.get(e.frequencyId) : undefined}
<li>
<span class="time">{fmtTime(e.heardAt)}</span>
<BandIcon band={f?.band ?? 'other'} size={22} />
<BandIcon band={f?.band ?? 'other'} size={30} />
<div class="body">
<span class="what">{e.what}</span>
<span class="sub">
@@ -56,30 +60,34 @@
</span>
</div>
<Signal value={e.signal} />
<form method="POST" action="?/deleteLog" use:enhance>
<input type="hidden" name="id" value={e.id} />
<button
class="icon danger"
aria-label="delete log entry"
onclick={(ev) => {
if (!confirm('Delete this log entry?')) ev.preventDefault();
}}><Trash2 size={14} /></button
>
</form>
{#if r.owner}
<form method="POST" action="?/deleteLog" use:enhance>
<input type="hidden" name="id" value={e.id} />
<button
class="icon danger"
aria-label="delete log entry"
onclick={(ev) => {
if (!confirm('Delete this log entry?')) ev.preventDefault();
}}><Trash2 size={16} /></button
>
</form>
{/if}
</li>
{/each}
</ul>
{:else}
<p class="empty">Nothing logged yet. Heard something? Log it above.</p>
<p class="empty">{r.owner ? t.nothingLoggedOwner : t.nothingLogged}</p>
{/each}
<style>
.day {
font-size: 0.72rem;
font-size: 0.95rem;
font-weight: 650;
color: var(--site-text);
margin: 0.9rem 0 0.25rem;
}
.day::first-letter {
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
margin: 0.8rem 0 0.25rem;
}
.log {
display: flex;
@@ -88,18 +96,18 @@
li {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0;
gap: 0.7rem;
padding: 0.55rem 0;
}
li + li {
border-top: 1px solid var(--site-border);
}
.time {
width: 2.8rem;
width: 3.4rem;
flex-shrink: 0;
font-size: 0.8rem;
font-size: 1rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--site-muted);
}
.body {
flex: 1;
@@ -108,14 +116,14 @@
flex-direction: column;
}
.what {
font-size: 0.88rem;
font-size: 1.05rem;
}
.sub {
display: flex;
flex-wrap: wrap;
gap: 0.1rem 0.6rem;
font-size: 0.74rem;
color: var(--site-muted);
gap: 0.1rem 0.7rem;
font-size: 0.92rem;
opacity: 0.75;
}
.fq {
font-variant-numeric: tabular-nums;
+70 -50
View File
@@ -1,5 +1,6 @@
<script lang="ts">
// Nets and broadcast schedules (Amsterdam time), with the ntfy reminder toggle per net.
// Nets and broadcast schedules (Amsterdam time). The owner also gets the phone reminder toggle
// and the edit form; visitors see the schedule only.
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import BandIcon from './BandIcon.svelte';
@@ -15,18 +16,19 @@
QUIET_HOURS,
REMIND_MINUTES,
parseQuiet,
daysLabel,
fmtTime,
formatFreq,
mhz,
nextOccurrence,
relDay,
until
nextOccurrence
} from './index.js';
import { DICTS, getRadio, helpers } from './i18n.js';
import type { Frequency, Net } from '#lib/server/radio.js';
let { nets, frequencies, now }: { nets: Net[]; frequencies: Frequency[]; now: number } = $props();
const r = getRadio();
const t = $derived(DICTS[r.lang]);
const h = $derived(helpers(t));
let editing = $state<number | 'new' | null>(null);
let error = $state<string | null>(null);
const byId = $derived(new Map(frequencies.map((f) => [f.id, f])));
@@ -119,77 +121,83 @@
<section class="card">
<div class="bar">
<p class="meta">
Times are Amsterdam time. A bell sends an ntfy reminder {REMIND_MINUTES} minutes before each start,
except during quiet hours ({parseQuiet(QUIET_HOURS)?.label}).
</p>
<button
type="button"
class="primary"
onclick={() => (editing = editing === 'new' ? null : 'new')}><Plus size={15} />add</button
>
{#if r.owner}
<p class="meta">
Times are Amsterdam time. A bell sends a phone reminder {REMIND_MINUTES} minutes before each start,
except during quiet hours ({parseQuiet(QUIET_HOURS)?.label}).
</p>
<button
type="button"
class="primary"
onclick={() => (editing = editing === 'new' ? null : 'new')}><Plus size={15} />add</button
>
{:else}
<p class="meta">{t.netsNote}</p>
{/if}
</div>
{#if editing === 'new'}{@render form(null)}{/if}
{#if r.owner && editing === 'new'}{@render form(null)}{/if}
<ul class="nets">
{#each nets as n (n.id)}
{@const f = n.frequencyId ? byId.get(n.frequencyId) : undefined}
{@const next = nextOccurrence(n, new Date(now))}
<li>
{#if editing === n.id}
{#if r.owner && editing === n.id}
{@render form(n)}
{:else}
<div class="net">
<BandIcon band={f?.band ?? 'other'} size={30} />
<BandIcon band={f?.band ?? 'other'} size={40} />
<div class="body">
<div class="top">
<strong>{n.name}</strong>
<span class="tag">{n.kind}</span>
<span class="tag">{t.kinds[n.kind] ?? n.kind}</span>
</div>
<div class="sub">
{#if f}<a
href={resolve(`/trackers/radio?tab=frequencies&q=${mhz(f.freqHz)}` as '/')}
class="fq"
>{formatFreq(f.freqHz)}
{f.mode}{f.marineCh ? ` · ch ${f.marineCh}` : ''}{f.channels.length
? ` · scanner ${f.channels.join(', ')}`
{f.mode}{f.marineCh ? ` · ${t.channel} ${f.marineCh}` : ''}{f.channels.length
? ` · ${t.scannerCh} ${f.channels.join(', ')}`
: ''}</a
>{/if}
<span
>{daysLabel(n.days)}
{n.times.length ? n.times.join(' & ') : '· time unknown'}</span
<span class="when"
>{h.daysLabel(n.days)}
{n.times.length ? n.times.join(' & ') : `· ${t.timeUnknown}`}</span
>
</div>
{#if n.notes}<p class="meta">{n.notes}</p>{/if}
</div>
<div class="right">
{#if next}<span class="next">{relDay(next, now)} {fmtTime(next)}</span><span
class="meta">{until(next, now)}</span
{#if next}<span class="next">{h.relDay(next, now)} {fmtTime(next)}</span><span
class="meta">{h.until(next, now)}</span
>{/if}
</div>
<form method="POST" action="?/remind" use:enhance>
<input type="hidden" name="id" value={n.id} />
{#if r.owner}
<form method="POST" action="?/remind" use:enhance>
<input type="hidden" name="id" value={n.id} />
<button
class={['icon', 'bell', { on: n.remind && n.times.length }]}
aria-label={n.remind ? 'reminder on, turn off' : 'reminder off, turn on'}
title={n.times.length
? n.remind
? 'reminder on'
: 'reminder off'
: 'no time set, so no reminder'}
>{#if n.remind}<Bell size={15} />{:else}<BellOff size={15} />{/if}</button
>
</form>
<button
class={['icon', 'bell', { on: n.remind && n.times.length }]}
aria-label={n.remind ? 'reminder on, turn off' : 'reminder off, turn on'}
title={n.times.length
? n.remind
? 'reminder on'
: 'reminder off'
: 'no time set, so no reminder'}
>{#if n.remind}<Bell size={15} />{:else}<BellOff size={15} />{/if}</button
type="button"
class="icon"
aria-label="edit {n.name}"
onclick={() => (editing = n.id)}><Pencil size={16} /></button
>
</form>
<button
type="button"
class="icon"
aria-label="edit {n.name}"
onclick={() => (editing = n.id)}><Pencil size={14} /></button
>
{/if}
</div>
{/if}
</li>
{:else}
<li class="empty">No nets yet.</li>
<li class="empty">{t.noNets}</li>
{/each}
</ul>
</section>
@@ -206,8 +214,11 @@
.net {
display: flex;
align-items: flex-start;
gap: 0.65rem;
padding: 0.65rem 0;
gap: 0.8rem;
padding: 0.85rem 0;
}
strong {
font-size: 1.1rem;
}
.body {
flex: 1;
@@ -225,9 +236,11 @@
.sub {
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.7rem;
font-size: 0.8rem;
color: var(--site-muted);
gap: 0.2rem 0.8rem;
font-size: 0.98rem;
}
.when {
font-weight: 500;
}
.fq {
font-variant-numeric: tabular-nums;
@@ -243,7 +256,8 @@
flex-shrink: 0;
}
.next {
font-size: 0.85rem;
font-size: 1.05rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.bell.on {
@@ -275,12 +289,18 @@
.net {
flex-wrap: wrap;
}
.body {
flex-basis: calc(100% - 40px - 0.8rem);
}
.body .meta {
overflow-wrap: anywhere;
}
.right {
order: 5;
flex-direction: row;
gap: 0.5rem;
align-items: baseline;
padding-left: 2.6rem;
padding-left: calc(40px + 0.8rem);
}
}
</style>
+22 -6
View File
@@ -1,25 +1,41 @@
<script lang="ts">
// Signal quality 1..5 as five little bars.
import { DICTS, getRadio } from './i18n.js';
let { value }: { value: number | null } = $props();
const LABEL = ['', 'barely', 'weak', 'fair', 'good', 'loud and clear'];
const r = getRadio();
const t = $derived(DICTS[r.lang]);
</script>
{#if value}
<span class="sig" title="signal {value}/5: {LABEL[value]}" aria-label="signal {value} of 5">
{#each [1, 2, 3, 4, 5] as i (i)}<i class={{ on: i <= value }} style:height="{3 + i * 2}px"
></i>{/each}
<span class="sig" title="{t.signal(value)}: {t.signalWords[value]}">
<span class="bars" aria-hidden="true">
{#each [1, 2, 3, 4, 5] as i (i)}<i class={{ on: i <= value }} style:height="{4 + i * 3}px"
></i>{/each}
</span>
<span class="word">{t.signalWords[value]}</span>
</span>
{/if}
<style>
.sig {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.bars {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 13px;
height: 19px;
}
.word {
font-size: 0.85rem;
opacity: 0.75;
white-space: nowrap;
}
i {
width: 3px;
width: 4px;
border-radius: 1px;
background: var(--site-border);
}
+34 -38
View File
@@ -4,16 +4,8 @@
import BandIcon from './BandIcon.svelte';
import Satellite from 'lucide-svelte/icons/satellite';
import Bell from 'lucide-svelte/icons/bell';
import {
ISS_DOWNLINK_HZ,
ISS_MIN_EL,
compass,
fmtTime,
formatFreq,
occurrences,
relDay,
until
} from './index.js';
import { ISS_DOWNLINK_HZ, ISS_MIN_EL, fmtTime, formatFreq, occurrences } from './index.js';
import { DICTS, getRadio, helpers } from './i18n.js';
import type { Frequency, IssPass, Net } from '#lib/server/radio.js';
let {
@@ -30,6 +22,9 @@
limit?: number;
} = $props();
const r = getRadio();
const t = $derived(DICTS[r.lang]);
const h = $derived(helpers(t));
const byId = $derived(new Map(frequencies.map((f) => [f.id, f])));
type Item = {
key: string;
@@ -58,11 +53,11 @@
end,
title: n.name,
sub: f
? `${formatFreq(f.freqHz)} ${f.mode}${f.marineCh ? ` · ch ${f.marineCh}` : ''}`
? `${formatFreq(f.freqHz)} ${f.mode}${f.marineCh ? ` · ${t.channel} ${f.marineCh}` : ''}`
: '',
band: f?.band ?? 'other',
iss: false,
remind: n.remind
remind: r.owner && n.remind
});
}
}
@@ -74,11 +69,11 @@
key: `iss-${p.startAt}`,
at: new Date(p.startAt).getTime(),
end,
title: `ISS pass, max ${Math.round(p.maxEl)}°`,
sub: `${compass(p.startAz)} → ${compass(p.endAz)} · ${formatFreq(ISS_DOWNLINK_HZ)} NFM${p.sunlit ? ' · visible' : ''}`,
title: t.issPass(Math.round(p.maxEl)),
sub: `${h.compass(p.startAz)} → ${h.compass(p.endAz)} · ${formatFreq(ISS_DOWNLINK_HZ)} NFM${p.sunlit ? ` · ${t.visible}` : ''}`,
band: null,
iss: true,
remind: true
remind: r.owner
});
}
return out.sort((a, b) => a.at - b.at).slice(0, limit);
@@ -91,8 +86,8 @@
{@const live = it.at <= now && (it.end ?? it.at) >= now}
<li class={{ live }}>
{#if it.iss}<span class="iss-icon" aria-hidden="true"
><Satellite size={13} strokeWidth={2.25} /></span
>{:else}<BandIcon band={it.band ?? 'other'} size={26} />{/if}
><Satellite size={20} strokeWidth={2.25} /></span
>{:else}<BandIcon band={it.band ?? 'other'} size={36} />{/if}
<div class="what">
<a href={resolve(`/trackers/radio?tab=${it.iss ? 'iss' : 'nets'}` as '/')} class="t"
>{it.title}</a
@@ -100,10 +95,10 @@
<span class="s">{it.sub}</span>
</div>
<div class="when">
<span class="clock">{relDay(it.at, now)} {fmtTime(it.at)}</span>
<span class="clock">{h.relDay(it.at, now)} {fmtTime(it.at)}</span>
<span class="rel"
>{live ? 'on now' : until(it.at, now)}{#if it.remind}<Bell
size={11}
>{live ? t.onNow : h.until(it.at, now)}{#if it.remind}<Bell
size={14}
aria-label="reminder on"
/>{/if}</span
>
@@ -112,21 +107,21 @@
{/each}
</ol>
{:else}
<p class="empty">Nothing scheduled in the next 3 days.</p>
<p class="empty">{t.nothingScheduled}</p>
{/if}
<style>
.upnext {
display: flex;
flex-direction: column;
gap: 0.15rem;
gap: 0.2rem;
}
li {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.35rem;
border-radius: 0.5rem;
gap: 0.75rem;
padding: 0.55rem 0.4rem;
border-radius: 0.6rem;
}
li.live {
background: color-mix(in srgb, #34d399 12%, transparent);
@@ -135,10 +130,10 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: 8px;
border-radius: 11px;
color: #fff;
background: linear-gradient(135deg, #2dd4bf, #0f766e);
}
@@ -149,19 +144,18 @@
flex-direction: column;
}
.t {
font-size: 0.88rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1.05rem;
font-weight: 600;
overflow-wrap: anywhere;
}
.t:hover {
text-decoration: underline;
}
.s,
.rel {
font-size: 0.74rem;
color: var(--site-muted);
font-size: 0.92rem;
color: var(--site-text);
opacity: 0.75;
}
.when {
display: flex;
@@ -171,7 +165,8 @@
flex-shrink: 0;
}
.clock {
font-size: 0.82rem;
font-size: 1rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.rel {
@@ -180,7 +175,8 @@
gap: 0.25rem;
}
li.live .rel {
color: #34d399;
font-weight: 600;
color: #10b981;
opacity: 1;
font-weight: 650;
}
</style>
+286
View File
@@ -0,0 +1,286 @@
// Radio tracker UI text in Dutch and English (the page is public and shown to Opa, who reads
// Dutch). The language comes from ?lang=, then the radio_lang cookie, then Accept-Language, with
// Dutch as the fallback. Owner-only edit forms stay English. Pure module: no server imports.
import { getContext, setContext } from 'svelte';
import { type Band, dayKey } from './index.js';
export const LANGS = ['nl', 'en'] as const;
export type Lang = (typeof LANGS)[number];
export const LANG_COOKIE = 'radio_lang';
export const isLang = (v: unknown): v is Lang => v === 'nl' || v === 'en';
/** First supported language in an Accept-Language header, Dutch when none matches. */
export function fromAcceptLanguage(header: string | null | undefined): Lang {
const prefs = (header ?? '')
.split(',')
.map((part) => {
const [tag, ...params] = part.trim().toLowerCase().split(';');
const q = params.find((p) => p.trim().startsWith('q='));
return { tag: tag.trim(), q: q ? Number(q.trim().slice(2)) || 0 : 1 };
})
.filter((p) => p.tag)
.sort((a, b) => b.q - a.q);
for (const p of prefs) {
const base = p.tag.split('-')[0];
if (isLang(base)) return base;
}
return 'nl';
}
const en = {
title: 'radio',
intro: 'Scanner frequencies, memory banks, nets, ISS passes over Gouda and what I heard.',
introPublic:
"Opa Chiel's scanner frequencies and memory banks, the nets to listen to, and ISS passes over Gouda.",
tabsLabel: 'Radio tracker sections',
tabs: {
overview: 'overview',
frequencies: 'frequencies',
banks: 'scanner banks',
nets: 'nets',
iss: 'ISS passes',
log: 'log'
},
language: 'Language',
upNext: 'up next',
quickLog: 'quick log',
nextIss: 'next ISS passes',
all: 'show all',
scanner: 'scanner',
byBand: 'frequencies by band',
recentLog: 'recently heard',
channelsProgrammed: (n: number, f: number) =>
`${n} of 500 channels programmed, ${f} frequencies known.`,
scanHelp:
'Airband: banks 4–6 (scan 4 5 6). Marine: banks 7–10 (press SCAN, then 7 8 9 0). Uniden UBC125XLT layout.',
openBanks: 'open the banks',
bands: {
marine: 'marine',
air: 'airband',
ham: 'ham radio',
pmr: 'PMR',
other: 'other'
} satisfies Record<Band, string>,
nothingScheduled: 'Nothing scheduled in the next 3 days.',
onNow: 'on now',
visible: 'visible',
issPass: (el: number) => `ISS pass, up to ${el}°`,
channel: 'ch',
scannerCh: 'scanner',
searchPlaceholder: 'name, 145.45 or ch 16',
searchLabel: 'Search frequencies',
filterBand: 'Filter by band',
allBands: 'all',
noMatch: 'No frequencies match.',
scannerChannel: (c: number) => `scanner channel ${c}`,
logEntries: 'log entries',
banksLabel: 'Scanner banks',
bank: 'bank',
bankHint: (b: number, a: number, z: number, n: number) =>
`Bank ${b}: channels ${a}–${z} · ${n} of 500 programmed · Uniden UBC125XLT layout`,
empty: 'empty',
csv: 'download CSV',
channelN: (c: number) => `Channel ${c}`,
close: 'close',
frequency: 'frequency',
mode: 'mode',
delay: 'delay',
lockout: 'locked out',
priority: 'priority',
netsNote: 'Times are Amsterdam time.',
timeUnknown: 'time unknown',
kinds: { net: 'net', broadcast: 'broadcast', other: 'other' } as Record<string, string>,
noNets: 'No nets yet.',
issMeta: 'Over Gouda · listen on',
issMeta2: '(ARISS voice / SSTV)',
visibleLegend: '= sunlit in a dark sky, visible to the eye',
showLow: (min: number) => `also passes below ${min}°`,
start: 'start',
highest: 'highest',
end: 'end',
maxEl: 'highest point above the horizon',
minutes: (n: number) => `${n} min`,
overheadNow: 'overhead now',
visibleEye: 'visible to the eye',
noPasses: 'No passes computed yet (the daily refresh fills this).',
tleFoot: (name: string, epoch: string, fetched: string) =>
`${name} orbit data from Celestrak, epoch ${epoch}, fetched ${fetched} (refreshed daily).`,
nothingLogged: 'Nothing logged yet.',
nothingLoggedOwner: 'Nothing logged yet. Heard something? Log it above.',
oneFreq: 'Showing one frequency.',
showAllLog: 'Show all',
signal: (v: number) => `signal ${v} of 5`,
signalWords: ['', 'barely', 'weak', 'fair', 'good', 'loud and clear'],
today: 'today',
tomorrow: 'tomorrow',
now: 'now',
inMin: (m: number) => `in ${m} min`,
inH: (h: number, m: number) => `in ${h} h${m ? ` ${m} min` : ''}`,
inDays: (d: number) => `in ${d} day${d === 1 ? '' : 's'}`,
daily: 'daily',
weekdays: 'weekdays',
weekends: 'weekends',
days: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'],
compass: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'],
locale: 'en-GB'
};
export type Dict = typeof en;
const nl: Dict = {
title: 'radio',
intro:
'Scannerfrequenties, geheugenbanken, rondes, ISS-overkomsten boven Gouda en wat ik hoorde.',
introPublic:
'De scannerfrequenties en geheugenbanken van Opa Chiel, de rondes om naar te luisteren en de ISS-overkomsten boven Gouda.',
tabsLabel: 'Onderdelen van de radiotracker',
tabs: {
overview: 'overzicht',
frequencies: 'frequenties',
banks: 'scannerbanken',
nets: 'rondes',
iss: 'ISS-overkomsten',
log: 'logboek'
},
language: 'Taal',
upNext: 'binnenkort',
quickLog: 'snel loggen',
nextIss: 'volgende ISS-overkomsten',
all: 'alles tonen',
scanner: 'scanner',
byBand: 'frequenties per band',
recentLog: 'laatst gehoord',
channelsProgrammed: (n: number, f: number) =>
`${n} van de 500 kanalen geprogrammeerd, ${f} frequenties bekend.`,
scanHelp:
'Luchtvaart: banken 4–6 (scan 4 5 6). Scheepvaart: banken 7–10 (druk SCAN, dan 7 8 9 0). Indeling van de Uniden UBC125XLT.',
openBanks: 'banken bekijken',
bands: {
marine: 'scheepvaart',
air: 'luchtvaart',
ham: 'zendamateurs',
pmr: 'PMR',
other: 'overig'
},
nothingScheduled: 'De komende 3 dagen staat er niets gepland.',
onNow: 'nu bezig',
visible: 'zichtbaar',
issPass: (el: number) => `ISS-overkomst, tot ${el}°`,
channel: 'kan.',
scannerCh: 'scanner',
searchPlaceholder: 'naam, 145.45 of kan. 16',
searchLabel: 'Frequenties zoeken',
filterBand: 'Filter op band',
allBands: 'alle',
noMatch: 'Geen frequenties gevonden.',
scannerChannel: (c: number) => `scannerkanaal ${c}`,
logEntries: 'logregels',
banksLabel: 'Scannerbanken',
bank: 'bank',
bankHint: (b: number, a: number, z: number, n: number) =>
`Bank ${b}: kanalen ${a}–${z} · ${n} van 500 geprogrammeerd · indeling Uniden UBC125XLT`,
empty: 'leeg',
csv: 'CSV downloaden',
channelN: (c: number) => `Kanaal ${c}`,
close: 'sluiten',
frequency: 'frequentie',
mode: 'modulatie',
delay: 'vertraging',
lockout: 'overgeslagen',
priority: 'voorrang',
netsNote: 'Tijden zijn Nederlandse tijd.',
timeUnknown: 'tijd onbekend',
kinds: { net: 'ronde', broadcast: 'uitzending', other: 'overig' },
noNets: 'Nog geen rondes.',
issMeta: 'Boven Gouda · luister op',
issMeta2: '(ARISS-spraak / SSTV)',
visibleLegend: '= in de zon tegen een donkere lucht, met het blote oog te zien',
showLow: (min: number) => `ook overkomsten lager dan ${min}°`,
start: 'begin',
highest: 'hoogste punt',
end: 'einde',
maxEl: 'hoogste punt boven de horizon',
minutes: (n: number) => `${n} min`,
overheadNow: 'nu boven ons',
visibleEye: 'met het blote oog te zien',
noPasses: 'Nog geen overkomsten berekend (dit wordt elke dag bijgewerkt).',
tleFoot: (name: string, epoch: string, fetched: string) =>
`Baangegevens van ${name} van Celestrak, epoch ${epoch}, opgehaald ${fetched} (elke dag bijgewerkt).`,
nothingLogged: 'Nog niets gelogd.',
nothingLoggedOwner: 'Nog niets gelogd. Iets gehoord? Log het hierboven.',
oneFreq: 'Eén frequentie getoond.',
showAllLog: 'Alles tonen',
signal: (v: number) => `signaal ${v} van 5`,
signalWords: ['', 'nauwelijks', 'zwak', 'redelijk', 'goed', 'luid en duidelijk'],
today: 'vandaag',
tomorrow: 'morgen',
now: 'nu',
inMin: (m: number) => `over ${m} min`,
inH: (h: number, m: number) => `over ${h} u${m ? ` ${m} min` : ''}`,
inDays: (d: number) => `over ${d} ${d === 1 ? 'dag' : 'dagen'}`,
daily: 'dagelijks',
weekdays: 'werkdagen',
weekends: 'weekenden',
days: ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo'],
compass: ['N', 'NO', 'O', 'ZO', 'Z', 'ZW', 'W', 'NW'],
locale: 'nl-NL'
};
export const DICTS: Record<Lang, Dict> = { en, nl };
// ---- localised time and direction helpers ------------------------------------------------------
const dayFmts = new Map<string, Intl.DateTimeFormat>();
function fmtDayL(d: Dict, at: Date | string | number) {
let f = dayFmts.get(d.locale);
if (!f) {
f = new Intl.DateTimeFormat(d.locale, {
timeZone: 'Europe/Amsterdam',
weekday: 'short',
day: 'numeric',
month: 'short'
});
dayFmts.set(d.locale, f);
}
return f.format(new Date(at));
}
export function helpers(d: Dict) {
return {
relDay(at: Date | string | number, now: Date | number = Date.now()) {
const k = dayKey(at);
if (k === dayKey(now)) return d.today;
if (k === dayKey(new Date(now).getTime() + 86_400_000)) return d.tomorrow;
return fmtDayL(d, at);
},
until(at: Date | string | number, now: Date | number = Date.now()) {
const mins = Math.round((new Date(at).getTime() - new Date(now).getTime()) / 60_000);
if (mins <= 0) return d.now;
if (mins < 60) return d.inMin(mins);
const h = Math.floor(mins / 60);
if (h < 24) return d.inH(h, mins % 60);
return d.inDays(Math.round(h / 24));
},
daysLabel(days: number[]) {
const s = [...new Set(days)].sort();
if (s.length === 7) return d.daily;
if (s.join() === '1,2,3,4,5') return d.weekdays;
if (s.join() === '6,7') return d.weekends;
return s.map((x) => d.days[x - 1]).join(', ');
},
compass: (az: number) => d.compass[Math.round((((az % 360) + 360) % 360) / 45) % 8],
fmtDateTime(iso: string) {
return new Date(iso).toLocaleString(d.locale, {
timeZone: 'Europe/Amsterdam',
dateStyle: 'medium',
timeStyle: 'short'
});
}
};
}
// ---- context: owner flag and language for every radio component ------------------------------
export type RadioCtx = { readonly owner: boolean; readonly lang: Lang };
const KEY = Symbol('radio');
export const setRadio = (ctx: RadioCtx) => setContext(KEY, ctx);
export const getRadio = (): RadioCtx => getContext<RadioCtx>(KEY) ?? { owner: false, lang: 'nl' };
+6 -5
View File
@@ -51,12 +51,13 @@ describe('owner gate', () => {
expect(isOwnerWritePath('/trackers/cargill')).toBe(true);
expect(isOwnerWritePath('/trackers/cargill/glossary/ester')).toBe(true);
});
it('gates the whole radio tracker, reads, writes and the CSV export', () => {
expect(isOwnerOnlyPath('/trackers/radio')).toBe(true);
expect(isOwnerOnlyPath('/trackers/radio/__data.json')).toBe(true);
expect(isOwnerOnlyPath('/trackers/radio/banks.csv')).toBe(true);
expect(isOwnerOnlyPath('/trackers/radiox')).toBe(false);
it('shows the radio tracker to everyone but keeps every write mine', () => {
expect(isOwnerOnlyPath('/trackers/radio')).toBe(false);
expect(isOwnerOnlyPath('/trackers/radio/__data.json')).toBe(false);
expect(isOwnerOnlyPath('/trackers/radio/banks.csv')).toBe(false);
expect(isOwnerWritePath('/trackers/radio')).toBe(true);
expect(isOwnerWritePath('/trackers/radio/__data.json')).toBe(true);
expect(isOwnerWritePath('/trackers/radiox')).toBe(false);
});
it('only lets the owner write in owner areas', () => {
expect(isOwnerWritePath('/trackers/books/12')).toBe(true);
-2
View File
@@ -42,8 +42,6 @@ export const ownerOnlyPrefixes = [
'/projects/worldwide-todo/api/queue',
// Cargill plant tracker: what the factory I work at does. Mine only.
'/trackers/cargill',
// Radio tracker: Opa's scanner frequencies, banks, nets, ISS passes and my log. Mine only.
'/trackers/radio',
// Math tracker (the Napkin): owner-only while MATH_PRIVATE (#lib/math) is on.
...(MATH_PRIVATE ? (['/trackers/math'] as const) : [])
] as const;
+17 -2
View File
@@ -1,5 +1,5 @@
// Radio tracker (/trackers/radio, owner-only): loads everything for the page and handles the
// owner's edits. Hand-written SQL on the shared postgres.js client; bigint Hz come back as
// Radio tracker (/trackers/radio, public read-only, owner edits): loads everything for the page
// (publicRadio() strips what only the owner may see) and handles the owner's edits. Hand-written SQL on the shared postgres.js client; bigint Hz come back as
// float8 so they are plain numbers.
import { client } from '#lib/server/db/index.js';
import {
@@ -155,6 +155,21 @@ export async function loadRadio() {
};
}
type RadioData = Awaited<ReturnType<typeof loadRadio>>;
/**
* The page data for visitors: no reminder switches (they are mine), no personal notes on log
* entries, and nets that are switched off are left out. Frequency and net notes are station
* facts (repeater inputs, schedules), so they stay.
*/
export function publicRadio(d: RadioData) {
return {
...d,
nets: d.nets.filter((n) => n.active).map((n) => ({ ...n, remind: false })),
log: d.log.map((l) => ({ ...l, notes: null }))
};
}
/** All channels with their frequency, for the CSV export. */
export async function channelRows(): Promise<ChannelRow[]> {
const rows = await client`SELECT c.channel, c.alpha_tag, c.delay, c.lockout, c.priority,
+1
View File
@@ -31,6 +31,7 @@ const staticPaths = [
'/stats/kor/overview',
'/trackers/books',
'/trackers/ai',
'/trackers/radio',
'/trackers/spotting',
'/trackers/cars',
'/trackers/cars/brands',
+33 -8
View File
@@ -1,29 +1,54 @@
import { requireOwner } from '#lib/server/owner.js';
import { isOwner, requireOwner } from '#lib/server/owner.js';
import {
addLog,
deleteFrequency,
deleteLog,
deleteNet,
loadRadio,
publicRadio,
saveFrequency,
saveNet,
setChannel,
toggleRemind
} from '#lib/server/radio.js';
import { LANG_COOKIE, fromAcceptLanguage, isLang, type Lang } from '#lib/radio/i18n.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.
// /trackers/radio: public and read-only (shown to Opa Chiel). Visitors get publicRadio(): no
// reminder settings, no log notes, no edit controls. Every write is owner-only: the actions
// below check the owner, and hooks.server.ts refuses non-GET requests here (ownerWritePrefixes).
// Language: ?lang=nl|en (remembered in a cookie), else the cookie, else Accept-Language, else NL.
export const load: PageServerLoad = async (event) => {
requireOwner(event);
const owner = isOwner(event);
// Varies by login, cookie and Accept-Language, so never cache it in between.
event.setHeaders({ 'cache-control': 'private, no-store', vary: 'cookie, accept-language' });
const asked = event.url.searchParams.get('lang');
let lang: Lang;
if (isLang(asked)) {
lang = asked;
event.cookies.set(LANG_COOKIE, lang, {
path: '/trackers/radio',
maxAge: 60 * 60 * 24 * 365,
httpOnly: false,
sameSite: 'lax'
});
} else {
const saved = event.cookies.get(LANG_COOKIE);
lang = isLang(saved) ? saved : fromAcceptLanguage(event.request.headers.get('accept-language'));
}
const radio = await loadRadio();
return {
...(await loadRadio()),
...(owner ? radio : publicRadio(radio)),
owner,
lang,
now: Date.now(),
seo: {
title: 'radio',
description: 'Frequencies, scanner banks, nets and ISS passes.',
index: false
description:
lang === 'nl'
? 'Scannerfrequenties, geheugenbanken, rondes en ISS-overkomsten boven Gouda.'
: 'Scanner frequencies, memory banks, nets and ISS passes over Gouda.'
}
};
};
@@ -31,7 +56,7 @@ export const load: PageServerLoad = async (event) => {
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.
// Actions don't run the load, so each one checks the owner again (404 for everyone else).
const action = (name: string, fn: Handler) => async (event: RequestEvent) => {
requireOwner(event);
const r = await fn(await event.request.formData());
+298 -146
View File
@@ -2,8 +2,8 @@
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 BandIcon from '#lib/radio/BandIcon.svelte';
import UpNext from '#lib/radio/UpNext.svelte';
import QuickLog from '#lib/radio/QuickLog.svelte';
import FreqList from '#lib/radio/FreqList.svelte';
@@ -11,64 +11,53 @@
import Nets from '#lib/radio/Nets.svelte';
import IssPasses from '#lib/radio/IssPasses.svelte';
import LogList from '#lib/radio/LogList.svelte';
import { BANDS } from '#lib/radio/index.js';
import { DICTS, LANGS, setRadio } from '#lib/radio/i18n.js';
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 Languages from 'lucide-svelte/icons/languages';
import { untrack, type Component } from 'svelte';
let { data, form } = $props();
// Owner flag and language for every radio component (getters, so they follow navigation).
setRadio({
get owner() {
return data.owner;
},
get lang() {
return data.lang;
}
});
const t = $derived(DICTS[data.lang]);
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 TABS: { id: Tab; icon: Icon; c: [string, string] }[] = [
{ id: 'overview', icon: LayoutDashboard as unknown as Icon, c: ['#f43f5e', '#881337'] },
{ id: 'frequencies', icon: RadioReceiver as unknown as Icon, c: ['#a78bfa', '#5b21b6'] },
{ id: 'banks', icon: LayoutGrid as unknown as Icon, c: ['#38bdf8', '#0369a1'] },
{ id: 'nets', icon: CalendarClock as unknown as Icon, c: ['#fbbf24', '#b45309'] },
{ id: 'iss', icon: Satellite as unknown as Icon, c: ['#2dd4bf', '#0f766e'] },
{ id: '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
(TABS.find((x) => x.id === page.url.searchParams.get('tab'))?.id ?? 'overview') as Tab
);
const count = (t: Tab) =>
t === 'frequencies'
const count = (id: Tab) =>
id === 'frequencies'
? data.frequencies.length
: t === 'banks'
: id === 'banks'
? data.channels.length
: t === 'nets'
: id === 'nets'
? data.nets.length
: t === 'iss'
: id === 'iss'
? data.passes.filter((p) => p.maxEl >= 10).length
: t === 'log'
: id === 'log'
? data.logTotal
: null;
@@ -76,38 +65,66 @@
let now = $state(untrack(() => data.now));
$effect(() => {
now = Date.now();
const t = setInterval(() => (now = Date.now()), 30_000);
return () => clearInterval(t);
const i = setInterval(() => (now = Date.now()), 30_000);
return () => clearInterval(i);
});
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 '/');
const tabHref = (id: Tab) =>
resolve((id === 'overview' ? '/trackers/radio' : `/trackers/radio?tab=${id}`) as '/');
/** The current URL with another language (the server remembers it in a cookie). */
const langHref = (l: string) => {
const keep = [...page.url.searchParams].filter(([k]) => k !== 'lang');
const q = [...keep, ['lang', l]]
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&');
return resolve(`/trackers/radio?${q}` as '/');
};
const bandCounts = $derived(
BANDS.map((b) => ({ band: b, n: data.frequencies.filter((f) => f.band === b).length })).filter(
(x) => x.n
)
);
const freqsHref = (band: string) =>
resolve(`/trackers/radio?tab=frequencies&band=${band}` as '/');
</script>
<div class="radio">
<div class="radio" lang={data.lang}>
<header class="head">
<a href={resolve('/trackers/radio')} class="title-link"
><TrackerIcon name="radio" class="size-10" /></a
<a href={resolve('/trackers/radio')} class="title-link" aria-label={t.title}
><TrackerIcon name="radio" class="size-12" /></a
>
<div class="title">
<h1>radio<PrivateLock size={14} /></h1>
<p>Frequencies, scanner banks, nets, ISS passes over Gouda and what I heard.</p>
<h1>{t.title}</h1>
<p>{data.owner ? t.intro : t.introPublic}</p>
</div>
<nav class="lang" aria-label={t.language}>
<span class="lang-icon" aria-hidden="true"><Languages size={18} /></span>
{#each LANGS as l (l)}
<a
href={langHref(l)}
hreflang={l}
class={{ active: data.lang === l }}
aria-current={data.lang === l ? 'true' : undefined}
data-sveltekit-noscroll
data-sveltekit-replacestate>{l === 'nl' ? 'NL' : 'EN'}</a
>
{/each}
</nav>
</header>
<nav class="tabs" aria-label="Radio tracker sections">
{#each TABS as t (t.id)}
{@const n = count(t.id)}
<nav class="tabs" aria-label={t.tabsLabel}>
{#each TABS as x (x.id)}
{@const n = count(x.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
href={tabHref(x.id)}
class={{ active: tab === x.id }}
aria-current={tab === x.id ? 'page' : undefined}
><Tile icon={x.icon} from={x.c[0]} to={x.c[1]} size={28} /><span class="lbl"
>{t.tabs[x.id]}</span
>{#if n !== null}<span class="n">{n}</span>{/if}</a
>
{/each}
</nav>
@@ -116,40 +133,60 @@
<div class="overview">
<section class="card">
<h2 class="card-title">
<Tile icon={TABS[3].icon} from="#fbbf24" to="#b45309" size={18} />up next
<Tile icon={TABS[3].icon} from="#fbbf24" to="#b45309" size={24} />{t.upNext}
</h2>
<UpNext nets={data.nets} passes={data.passes} frequencies={data.frequencies} {now} />
</section>
{#if data.owner}
<section class="card">
<h2 class="card-title">
<Tile icon={TABS[5].icon} from="#34d399" to="#047857" size={24} />{t.quickLog}
</h2>
<QuickLog frequencies={data.frequencies} message={logMessage} error={logError} />
<div class="recent">
<LogList log={data.log} frequencies={data.frequencies} {now} limit={5} />
</div>
</section>
{:else}
<section class="card">
<h2 class="card-title">
<Tile icon={TABS[1].icon} from="#a78bfa" to="#5b21b6" size={24} />{t.byBand}
</h2>
<ul class="bands">
{#each bandCounts as b (b.band)}
<li>
<a href={freqsHref(b.band)}
><BandIcon band={b.band} size={32} /><span class="bn">{t.bands[b.band]}</span
><span class="bc">{b.n}</span></a
>
</li>
{/each}
</ul>
</section>
{/if}
<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>
<Tile icon={TABS[4].icon} from="#2dd4bf" to="#0f766e" size={24} />{t.nextIss}
<a class="more" href={tabHref('iss')}>{t.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
<Tile icon={TABS[2].icon} from="#38bdf8" to="#0369a1" size={24} />{t.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>
<p>{t.channelsProgrammed(data.channels.length, data.frequencies.length)}</p>
<p class="meta">{t.scanHelp}</p>
<a class="ghost" href={tabHref('banks')}>{t.openBanks}</a>
</section>
{#if !data.owner && data.log.length}
<section class="card">
<h2 class="card-title">
<Tile icon={TABS[5].icon} from="#34d399" to="#047857" size={24} />{t.recentLog}
</h2>
<LogList log={data.log} frequencies={data.frequencies} {now} limit={5} />
</section>
{/if}
</div>
{:else if tab === 'frequencies'}
<FreqList frequencies={data.frequencies} />
@@ -160,12 +197,14 @@
{: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>
{#if data.owner}
<section class="card">
<QuickLog frequencies={data.frequencies} message={logMessage} error={logError} />
</section>
{/if}
<section class="card">
{#if logFreq}<p class="meta">
Showing one frequency. <a href={tabHref('log')} class="u">Show all</a>
{t.oneFreq} <a href={tabHref('log')} class="u">{t.showAllLog}</a>
</p>{/if}
<LogList log={data.log} frequencies={data.frequencies} {now} frequencyId={logFreq} />
</section>
@@ -173,52 +212,98 @@
</div>
<style>
/* Built to be read by an older visitor on a phone or tablet: big type, strong contrast, large
tap targets. Black-and-white base, colour only in the icon tiles. */
.radio {
max-width: 72rem;
margin: 0 auto;
padding: 2rem 1rem 3rem;
display: flex;
flex-direction: column;
gap: 1rem;
gap: 1.1rem;
font-size: 1.0625rem;
line-height: 1.5;
}
.head {
display: flex;
align-items: center;
gap: 0.85rem;
gap: 0.9rem;
flex-wrap: wrap;
}
.title-link {
display: inline-flex;
}
.title {
flex: 1 1 16rem;
min-width: 0;
}
h1 {
font-size: 2rem;
font-weight: 600;
font-size: 2.25rem;
font-weight: 650;
letter-spacing: -0.02em;
line-height: 1.1;
}
.title p {
color: var(--site-text);
opacity: 0.8;
font-size: 1.05rem;
margin-top: 0.2rem;
}
.lang {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem;
border: 1px solid var(--site-border);
border-radius: 999px;
}
.lang-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
color: #fff;
background: linear-gradient(135deg, #60a5fa, #1d4ed8);
}
.lang a {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 3rem;
min-height: 2.75rem;
padding: 0 0.8rem;
border-radius: 999px;
font-size: 1rem;
font-weight: 600;
color: var(--site-muted);
font-size: 0.9rem;
}
.lang a:hover {
color: var(--site-text);
}
.lang a.active {
background: var(--site-text);
color: var(--site-bg);
}
.tabs {
display: flex;
gap: 0.35rem;
overflow-x: auto;
scrollbar-width: none;
gap: 0.45rem;
flex-wrap: wrap;
border-bottom: 1px solid var(--site-border);
padding-bottom: 0.6rem;
padding-bottom: 0.8rem;
}
.tabs a {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.6rem 0.25rem 0.25rem;
gap: 0.5rem;
min-height: 3rem;
padding: 0.3rem 0.9rem 0.3rem 0.35rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.83rem;
color: var(--site-muted);
font-size: 1rem;
font-weight: 500;
color: var(--site-text);
white-space: nowrap;
transition:
color 0.15s,
@@ -226,7 +311,6 @@
background 0.15s;
}
.tabs a:hover {
color: var(--site-text);
border-color: var(--site-muted);
}
.tabs a.active {
@@ -235,7 +319,7 @@
color: var(--site-bg);
}
.n {
font-size: 0.7rem;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
@@ -246,54 +330,86 @@
align-items: start;
}
.recent {
margin-top: 0.4rem;
margin-top: 0.6rem;
}
.more {
margin-left: auto;
text-transform: none;
letter-spacing: 0;
font-size: 0.78rem;
font-size: 0.95rem;
font-weight: 500;
color: var(--site-text);
text-decoration: underline;
text-underline-offset: 3px;
padding: 0.4rem 0;
white-space: nowrap;
}
.more:hover,
.u {
text-decoration: underline;
}
.stats p + p {
margin-top: 0.4rem;
margin-top: 0.5rem;
}
.stats .ghost {
margin-top: 0.7rem;
margin-top: 0.9rem;
}
.bands {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(12.5rem, 1fr));
gap: 0.5rem;
}
.bands a {
display: flex;
align-items: center;
gap: 0.65rem;
min-height: 3.5rem;
padding: 0.5rem 0.75rem 0.5rem 0.5rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-bg);
font-size: 1.05rem;
}
.bands a:hover {
border-color: var(--site-muted);
}
.bn {
flex: 1;
min-width: 0;
}
.bc {
font-weight: 650;
font-variant-numeric: tabular-nums;
}
/* Shared building blocks for the section components. */
.radio :global(.card) {
padding: 0.9rem;
padding: 1.1rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
border-radius: 0.9rem;
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;
gap: 0.6rem;
font-size: 1.15rem;
font-weight: 650;
color: var(--site-text);
margin-bottom: 0.8rem;
}
.radio :global(.bar) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
gap: 0.7rem;
flex-wrap: wrap;
}
.radio :global(.meta),
.radio :global(.empty) {
color: var(--site-muted);
font-size: 0.82rem;
color: var(--site-text);
opacity: 0.75;
font-size: 0.95rem;
}
.radio :global(.empty) {
padding: 1rem 0;
@@ -305,12 +421,13 @@
.radio :global(.primary) {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.8rem;
border-radius: 0.5rem;
gap: 0.35rem;
min-height: 2.75rem;
padding: 0.35rem 1rem;
border-radius: 0.6rem;
background: var(--site-text);
color: var(--site-bg);
font-size: 0.83rem;
font-size: 1rem;
font-weight: 600;
transition: opacity 0.15s;
}
@@ -323,23 +440,26 @@
.radio :global(.ghost) {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.7rem;
border-radius: 0.5rem;
gap: 0.4rem;
min-height: 2.75rem;
padding: 0.35rem 1rem;
border-radius: 0.6rem;
border: 1px solid var(--site-border);
color: var(--site-muted);
font-size: 0.83rem;
color: var(--site-text);
font-size: 1rem;
font-weight: 500;
}
.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;
min-width: 2.5rem;
min-height: 2.5rem;
padding: 0.3rem;
border-radius: 0.4rem;
border-radius: 0.5rem;
color: var(--site-muted);
transition:
color 0.15s,
@@ -354,25 +474,27 @@
background: rgb(248 113 113 / 0.1);
}
.radio :global(.tag) {
font-size: 0.68rem;
padding: 0.05rem 0.4rem;
font-size: 0.85rem;
padding: 0.05rem 0.5rem;
border-radius: 999px;
border: 1px solid var(--site-border);
color: var(--site-muted);
color: var(--site-text);
opacity: 0.85;
white-space: nowrap;
}
.radio :global(.chip) {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.6rem 0.2rem 0.25rem;
gap: 0.45rem;
min-height: 2.75rem;
padding: 0.25rem 0.85rem 0.25rem 0.35rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.8rem;
color: var(--site-muted);
font-size: 1rem;
color: var(--site-text);
}
.radio :global(.chip:first-child) {
padding-left: 0.6rem;
padding-left: 0.85rem;
}
.radio :global(.chip.active) {
background: var(--site-text);
@@ -381,25 +503,25 @@
}
.radio :global(.err) {
color: #f87171;
font-size: 0.8rem;
font-size: 0.95rem;
margin-right: auto;
}
/* Edit forms: a compact grid of labelled fields. */
/* Owner 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;
gap: 0.6rem;
padding: 0.85rem;
margin: 0.5rem 0;
border: 1px solid var(--site-border);
border-radius: 0.65rem;
border-radius: 0.7rem;
background: var(--site-bg);
}
.radio :global(.edit label) {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.72rem;
font-size: 0.85rem;
color: var(--site-muted);
min-width: 0;
}
@@ -412,20 +534,20 @@
.radio :global(.edit input:not([type='checkbox'])),
.radio :global(.edit select),
.radio :global(.edit textarea) {
height: 2.2rem;
padding: 0 0.55rem;
height: 2.6rem;
padding: 0 0.6rem;
border: 1px solid var(--site-border);
border-radius: 0.45rem;
border-radius: 0.5rem;
background: var(--site-surface);
color: var(--site-text);
font: inherit;
font-size: 0.86rem;
font-size: 1rem;
min-width: 0;
width: 100%;
}
.radio :global(.edit textarea) {
height: auto;
padding: 0.4rem 0.55rem;
padding: 0.4rem 0.6rem;
resize: vertical;
}
.radio :global(.edit .actions) {
@@ -434,6 +556,11 @@
justify-content: flex-end;
gap: 0.5rem;
}
.radio :global(a:focus-visible),
.radio :global(button:focus-visible) {
outline: 3px solid #60a5fa;
outline-offset: 2px;
}
@media (max-width: 860px) {
.overview {
grid-template-columns: minmax(0, 1fr);
@@ -442,9 +569,34 @@
@media (max-width: 640px) {
.radio {
padding-top: 1.25rem;
font-size: 1.0625rem;
}
h1 {
font-size: 1.6rem;
font-size: 2rem;
}
.lang {
margin-left: auto;
}
/* Big two-column buttons instead of a sideways-scrolling strip. */
.tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tabs a {
min-height: 3.25rem;
border-radius: 0.8rem;
white-space: normal;
line-height: 1.2;
}
.lbl {
flex: 1;
min-width: 0;
hyphens: auto;
overflow-wrap: anywhere;
}
/* No room for the counts next to the bigger labels. */
.n {
display: none;
}
.radio :global(.edit) {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1,17 +1,15 @@
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);
// All 500 scanner memories as CSV, public like the page (station frequencies only) (Uniden BC125AT/UBC125XLT programming software column order).
export const GET: RequestHandler = async () => {
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'
'cache-control': 'public, max-age=300'
}
});
};