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', 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', href: '/trackers/radio',
external: false, external: false,
private: true private: false
}, },
{ {
name: 'math', name: 'math',
+115 -33
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <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 { enhance } from '$app/forms';
import { page } from '$app/state'; import { page } from '$app/state';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
@@ -16,10 +17,17 @@
bankRange, bankRange,
formatFreq formatFreq
} from './index.js'; } from './index.js';
import { DICTS, getRadio } from './i18n.js';
import type { Channel, Frequency } from '#lib/server/radio.js'; import type { Channel, Frequency } from '#lib/server/radio.js';
let { channels, frequencies }: { channels: Channel[]; frequencies: Frequency[] } = $props(); 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; const initialCh = Number(page.url.searchParams.get('ch')) || null;
let bank = $state(initialCh ? bankOf(initialCh) : Number(page.url.searchParams.get('bank')) || 4); let bank = $state(initialCh ? bankOf(initialCh) : Number(page.url.searchParams.get('bank')) || 4);
let selected = $state<number | null>(initialCh); let selected = $state<number | null>(initialCh);
@@ -46,7 +54,7 @@
<section class="card"> <section class="card">
<div class="bar"> <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)} {#each perBank as n, i (i)}
<button <button
type="button" type="button"
@@ -56,17 +64,18 @@
onclick={() => { onclick={() => {
bank = i + 1; bank = i + 1;
selected = null; 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} {/each}
</div> </div>
<a class="ghost" href={resolve('/trackers/radio/banks.csv')} download data-sveltekit-reload <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> </div>
<p class="meta hint"> <p class="meta hint">
Bank {bank}: channels {bankRange(bank)[0]}–{bankRange(bank)[1]} · {channels.length} of 500 programmed {t.bankHint(bank, bankRange(bank)[0], bankRange(bank)[1], channels.length)}
· Uniden UBC125XLT layout (model unconfirmed)
</p> </p>
<div class="grid"> <div class="grid">
@@ -81,17 +90,50 @@
> >
<span class="no">{ch}</span> <span class="no">{ch}</span>
{#if f && c} {#if f && c}
<BandIcon band={f.band} size={16} /> <BandIcon band={f.band} size={26} />
<span class="tg">{c.tag}</span> <span class="tg">{c.tag}</span>
<span class="fq">{formatFreq(f.freqHz, false)} <i>{f.mode}</i></span> <span class="fq">{formatFreq(f.freqHz, false)} <i>{f.mode}</i></span>
{:else} {:else}
<span class="tg dim">empty</span> <span class="tg dim">{t.empty}</span>
{/if} {/if}
</button> </button>
{/each} {/each}
</div> </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} {@const f = sel ? byId.get(sel.frequencyId) : undefined}
<form <form
class="edit chedit" class="edit chedit"
@@ -162,27 +204,28 @@
<style> <style>
.banks { .banks {
display: flex; display: flex;
gap: 0.3rem; gap: 0.4rem;
overflow-x: auto; flex-wrap: wrap;
scrollbar-width: none;
} }
.bank { .bank {
display: inline-flex; display: inline-flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
min-width: 2.8rem; justify-content: center;
padding: 0.25rem 0.4rem; min-width: 3.4rem;
min-height: 3.4rem;
padding: 0.3rem 0.5rem;
border: 1px solid var(--site-border); border: 1px solid var(--site-border);
border-radius: 0.55rem; border-radius: 0.7rem;
color: var(--site-text); color: var(--site-text);
line-height: 1.1; line-height: 1.15;
} }
.bank b { .bank b {
font-size: 0.95rem; font-size: 1.2rem;
} }
.bank span { .bank span {
font-size: 0.62rem; font-size: 0.78rem;
color: var(--site-muted); opacity: 0.75;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.bank.empty { .bank.empty {
@@ -203,18 +246,20 @@
} }
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 0.3rem; gap: 0.4rem;
} }
.slot { .slot {
display: grid; display: grid;
grid-template-columns: 1.9rem 16px 1fr; grid-template-columns: 2.4rem 26px 1fr;
grid-template-rows: auto auto; grid-template-rows: auto auto;
column-gap: 0.4rem; column-gap: 0.5rem;
align-items: center; align-items: center;
padding: 0.35rem 0.45rem; min-height: 3.5rem;
padding: 0.45rem 0.6rem;
border: 1px solid var(--site-border); border: 1px solid var(--site-border);
border-radius: 0.5rem; border-radius: 0.65rem;
color: var(--site-text);
text-align: left; text-align: left;
background: var(--site-bg); background: var(--site-bg);
min-width: 0; min-width: 0;
@@ -232,15 +277,17 @@
} }
.no { .no {
grid-row: 1 / 3; grid-row: 1 / 3;
font-size: 0.7rem; font-size: 0.9rem;
color: var(--site-muted); font-weight: 600;
opacity: 0.75;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.slot :global(.band-icon) { .slot :global(.band-icon) {
grid-row: 1 / 3; grid-row: 1 / 3;
} }
.tg { .tg {
font-size: 0.74rem; font-size: 0.95rem;
font-weight: 600;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -248,14 +295,14 @@
} }
.tg.dim { .tg.dim {
grid-column: 2 / 4; grid-column: 2 / 4;
color: var(--site-muted); font-weight: 400;
opacity: 0.6; opacity: 0.55;
text-transform: none; text-transform: none;
} }
.fq { .fq {
grid-column: 3; grid-column: 3;
font-size: 0.68rem; font-size: 0.85rem;
color: var(--site-muted); opacity: 0.8;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.fq i { .fq i {
@@ -265,6 +312,38 @@
.chedit { .chedit {
margin-top: 0.8rem; 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 { .checks {
display: flex; display: flex;
gap: 0.8rem; gap: 0.8rem;
@@ -279,7 +358,10 @@
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.grid { .grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: minmax(0, 1fr);
}
.bank {
min-width: 3.1rem;
} }
} }
</style> </style>
+68 -50
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
// Frequencies: search, band filter, inline edit. // Frequencies: search, band filter, inline edit (owner only).
import { page } from '$app/state'; import { page } from '$app/state';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import BandIcon from './BandIcon.svelte'; import BandIcon from './BandIcon.svelte';
@@ -7,11 +7,14 @@
import Search from 'lucide-svelte/icons/search'; import Search from 'lucide-svelte/icons/search';
import Plus from 'lucide-svelte/icons/plus'; import Plus from 'lucide-svelte/icons/plus';
import Pencil from 'lucide-svelte/icons/pencil'; 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'; import type { Frequency } from '#lib/server/radio.js';
let { frequencies }: { frequencies: Frequency[] } = $props(); let { frequencies }: { frequencies: Frequency[] } = $props();
const r = getRadio();
const t = $derived(DICTS[r.lang]);
let q = $state(page.url.searchParams.get('q') ?? ''); let q = $state(page.url.searchParams.get('q') ?? '');
let band = $state<string | null>(page.url.searchParams.get('band')); let band = $state<string | null>(page.url.searchParams.get('band'));
let editing = $state<number | null>(null); let editing = $state<number | null>(null);
@@ -30,7 +33,7 @@
if (!needle) return true; if (!needle) return true;
if (/^\d+(\.\d*)?$/.test(num)) if (/^\d+(\.\d*)?$/.test(num))
return mhz(f.freqHz).startsWith(num) || String(f.freqHz / 1000).startsWith(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, '')); const n = Number(needle.replace(/\D/g, ''));
return f.channels.includes(n) || f.marineCh === String(n).padStart(2, '0'); return f.channels.includes(n) || f.marineCh === String(n).padStart(2, '0');
} }
@@ -49,38 +52,38 @@
<Search size={15} /> <Search size={15} />
<input <input
type="search" type="search"
placeholder="search name, 145.45, ch 16, tag" placeholder={t.searchPlaceholder}
aria-label="Search frequencies" aria-label={t.searchLabel}
bind:value={q} bind:value={q}
/> />
</label> </label>
<button type="button" class="primary" onclick={() => (adding = !adding)} {#if r.owner}<button type="button" class="primary" onclick={() => (adding = !adding)}
><Plus size={15} />add</button ><Plus size={15} />add</button
> >{/if}
</div> </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)} <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)} {#each BANDS.filter((b) => counts[b]) as b (b)}
<button <button
type="button" type="button"
class={['chip', { active: band === b }]} class={['chip', { active: band === b }]}
onclick={() => (band = band === b ? null : 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} {/each}
</div> </div>
{#if adding}<FreqForm ondone={() => (adding = false)} />{/if} {#if r.owner && adding}<FreqForm ondone={() => (adding = false)} />{/if}
<ul class="freqs"> <ul class="freqs">
{#each shown as f (f.id)} {#each shown as f (f.id)}
<li> <li>
{#if editing === f.id} {#if r.owner && editing === f.id}
<FreqForm freq={f} ondone={() => (editing = null)} /> <FreqForm freq={f} ondone={() => (editing = null)} />
{:else} {:else}
<div class="frow"> <div class="frow">
<BandIcon band={f.band} size={26} /> <BandIcon band={f.band} size={36} />
<span class="fq" <span class="fq"
>{formatFreq(f.freqHz, false)}<small>{f.freqHz < 30e6 ? 'kHz' : 'MHz'}</small></span >{formatFreq(f.freqHz, false)}<small>{f.freqHz < 30e6 ? 'kHz' : 'MHz'}</small></span
> >
@@ -88,7 +91,7 @@
<div class="nm"> <div class="nm">
<span class="name">{f.name}</span> <span class="name">{f.name}</span>
<span class="sub"> <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} {#if f.location}<span>{f.location}</span>{/if}
{#each f.tags as t (t)}<span class="tag">{t}</span>{/each} {#each f.tags as t (t)}<span class="tag">{t}</span>{/each}
{#if f.notes}<span class="note">{f.notes}</span>{/if} {#if f.notes}<span class="note">{f.notes}</span>{/if}
@@ -98,25 +101,26 @@
{#each f.channels as c (c)}<a {#each f.channels as c (c)}<a
class="chan" class="chan"
href={resolve(`/trackers/radio?tab=banks&ch=${c}` as '/')} 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} >{/each}
{#if f.logCount}<a {#if f.logCount}<a
class="logged" class="logged"
href={resolve(`/trackers/radio?tab=log&f=${f.id}` as '/')} href={resolve(`/trackers/radio?tab=log&f=${f.id}` as '/')}
title="log entries">{f.logCount}×</a title={t.logEntries}>{f.logCount}×</a
>{/if} >{/if}
</div> </div>
<button {#if r.owner}<button
type="button" type="button"
class="icon" class="icon"
aria-label="edit {f.name}" aria-label="edit {f.name}"
onclick={() => (editing = f.id)}><Pencil size={14} /></button onclick={() => (editing = f.id)}><Pencil size={16} /></button
> >{/if}
</div> </div>
{/if} {/if}
</li> </li>
{:else} {:else}
<li class="empty">No frequencies match.</li> <li class="empty">{t.noMatch}</li>
{/each} {/each}
</ul> </ul>
</section> </section>
@@ -127,9 +131,9 @@
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
flex: 1 1 14rem; flex: 1 1 14rem;
max-width: 24rem; max-width: 28rem;
padding: 0 0.6rem; padding: 0 0.85rem;
height: 2.2rem; height: 3rem;
border: 1px solid var(--site-border); border: 1px solid var(--site-border);
border-radius: 999px; border-radius: 999px;
color: var(--site-muted); color: var(--site-muted);
@@ -141,14 +145,14 @@
border: 0; border: 0;
outline: none; outline: none;
font: inherit; font: inherit;
font-size: 0.85rem; font-size: 16px;
color: var(--site-text); color: var(--site-text);
} }
.chips { .chips {
display: flex; display: flex;
gap: 0.35rem; gap: 0.45rem;
flex-wrap: wrap; flex-wrap: wrap;
margin: 0.7rem 0; margin: 0.9rem 0;
} }
.freqs { .freqs {
display: flex; display: flex;
@@ -160,26 +164,28 @@
.frow { .frow {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.7rem;
padding: 0.45rem 0; padding: 0.65rem 0;
} }
.fq { .fq {
width: 6.6rem; width: 8.6rem;
flex-shrink: 0; flex-shrink: 0;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-family: ui-monospace, monospace; font-family: ui-monospace, monospace;
font-size: 0.86rem; font-size: 1.05rem;
font-weight: 600;
} }
.fq small { .fq small {
margin-left: 0.2rem; margin-left: 0.25rem;
font-size: 0.65rem; font-size: 0.8rem;
color: var(--site-muted); font-weight: 400;
opacity: 0.7;
} }
.md { .md {
width: 2.6rem; width: 3rem;
flex-shrink: 0; flex-shrink: 0;
font-size: 0.7rem; font-size: 0.85rem;
color: var(--site-muted); opacity: 0.75;
} }
.nm { .nm {
flex: 1; flex: 1;
@@ -188,31 +194,40 @@
flex-direction: column; flex-direction: column;
} }
.name { .name {
font-size: 0.88rem; font-size: 1.05rem;
font-weight: 500;
} }
.sub { .sub {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.3rem; gap: 0.35rem;
align-items: center; align-items: center;
font-size: 0.74rem; font-size: 0.92rem;
color: var(--site-muted); color: var(--site-text);
}
.sub > span:not(.tag) {
opacity: 0.75;
} }
.note { .note {
flex-basis: 100%; flex-basis: 100%;
} }
.chs { .chs {
display: flex; display: flex;
gap: 0.25rem; gap: 0.3rem;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
} }
.chan, .chan,
.logged { .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; font-variant-numeric: tabular-nums;
padding: 0.05rem 0.4rem; padding: 0.1rem 0.5rem;
border-radius: 0.35rem; border-radius: 0.45rem;
background: color-mix(in srgb, var(--site-text) 8%, transparent); background: color-mix(in srgb, var(--site-text) 8%, transparent);
} }
.logged { .logged {
@@ -229,10 +244,13 @@
.md { .md {
width: auto; width: auto;
} }
.fq {
width: auto;
}
.nm { .nm {
flex-basis: calc(100% - 2.5rem); flex-basis: 100%;
order: 5; order: 5;
padding-left: 2.1rem; padding-left: calc(36px + 0.7rem);
} }
.chs { .chs {
margin-left: auto; margin-left: auto;
+71 -46
View File
@@ -1,19 +1,18 @@
<script lang="ts"> <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 Eye from 'lucide-svelte/icons/eye';
import { import {
ISS_DOWNLINK_HZ, ISS_DOWNLINK_HZ,
ISS_MIN_EL, ISS_MIN_EL,
QUIET_HOURS, QUIET_HOURS,
REMIND_MINUTES, REMIND_MINUTES,
compass,
dayKey, dayKey,
fmtTime, fmtTime,
formatFreq, formatFreq,
parseQuiet, parseQuiet
relDay,
until
} from './index.js'; } from './index.js';
import { DICTS, getRadio, helpers } from './i18n.js';
import type { IssPass } from '#lib/server/radio.js'; import type { IssPass } from '#lib/server/radio.js';
let { let {
@@ -30,6 +29,9 @@
compact?: boolean; compact?: boolean;
} = $props(); } = $props();
const r = getRadio();
const t = $derived(DICTS[r.lang]);
const h = $derived(helpers(t));
let showLow = $state(false); let showLow = $state(false);
const list = $derived.by(() => { const list = $derived.by(() => {
const l = passes.filter( const l = passes.filter(
@@ -41,30 +43,25 @@
const out: { key: string; label: string; passes: IssPass[] }[] = []; const out: { key: string; label: string; passes: IssPass[] }[] = [];
for (const p of list) { for (const p of list) {
const k = dayKey(p.startAt); 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); out.at(-1)!.passes.push(p);
} }
return out; return out;
}); });
const mins = (p: IssPass) => const mins = (p: IssPass) =>
Math.round((new Date(p.endAt).getTime() - new Date(p.startAt).getTime()) / 60_000); 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> </script>
{#if !compact} {#if !compact}
<div class="bar"> <div class="bar">
<p class="meta"> <p class="meta">
Gouda (52.011 N, 4.711 E) · listen on <strong>{formatFreq(ISS_DOWNLINK_HZ)} NFM</strong> {t.issMeta} <strong>{formatFreq(ISS_DOWNLINK_HZ)} NFM</strong>
(ARISS voice / SSTV) · {t.issMeta2} ·
<Eye size={12} /> = sunlit in a dark sky, visible to the eye <span class="vis inline"><Eye size={16} /></span>
{t.visibleLegend}
</p> </p>
<label class="low" <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> </div>
{/if} {/if}
@@ -76,35 +73,45 @@
{#each d.passes as p (p.startAt)} {#each d.passes as p (p.startAt)}
{@const live = new Date(p.startAt).getTime() <= now} {@const live = new Date(p.startAt).getTime() <= now}
<li class={{ low: p.maxEl < ISS_MIN_EL, live }}> <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 <b>{fmtTime(p.startAt)}</b><span class="arrow">→</span>{fmtTime(p.maxAt)}<span
class="arrow">→</span class="arrow">→</span
>{fmtTime(p.endAt)} >{fmtTime(p.endAt)}
</span> </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 <span class="elbar"><i style:width="{Math.min(100, (p.maxEl / 90) * 100)}%"></i></span
>{Math.round(p.maxEl)}° >{Math.round(p.maxEl)}°
</span> </span>
<span class="dir">{compass(p.startAz)} → {compass(p.maxAz)} → {compass(p.endAz)}</span> <span class="dir"
<span class="dur">{mins(p)} min</span> >{h.compass(p.startAz)} → {h.compass(p.maxAz)} → {h.compass(p.endAz)}</span
<span class="vis"
>{#if p.sunlit}<Eye size={14} aria-label="visible to the eye" />{/if}</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> </li>
{/each} {/each}
</ul> </ul>
{/each} {/each}
{:else} {:else}
<p class="empty">No passes computed yet (the daily refresh fills this).</p> <p class="empty">{t.noPasses}</p>
{/if} {/if}
</div> </div>
{#if !compact && tle} {#if !compact && tle}
<p class="meta foot"> <p class="meta foot">
{tle.name} TLE from Celestrak, epoch {tle.epoch ? fmtAgo(tle.epoch) : '?'}, fetched {tle.fetchedAt {t.tleFoot(
? fmtAgo(tle.fetchedAt) tle.name,
: '?'} (refreshed daily). Passes of {ISS_MIN_EL}° and up get a reminder {REMIND_MINUTES} min before, tle.epoch ? h.fmtDateTime(tle.epoch) : '?',
except during quiet hours ({parseQuiet(QUIET_HOURS)?.label}). 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> </p>
{/if} {/if}
@@ -112,16 +119,22 @@
.low { .low {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.3rem; gap: 0.45rem;
font-size: 0.8rem; min-height: 2.75rem;
color: var(--site-muted); font-size: 0.98rem;
cursor: pointer;
}
.low input {
width: 1.25rem;
height: 1.25rem;
} }
.day { .day {
font-size: 0.72rem; font-size: 0.98rem;
font-weight: 650;
margin: 0.9rem 0 0.25rem;
}
.day::first-letter {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
margin: 0.8rem 0 0.25rem;
} }
.passes { .passes {
display: flex; display: flex;
@@ -129,12 +142,12 @@
} }
li { li {
display: grid; 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; align-items: center;
gap: 0.5rem; gap: 0.6rem;
padding: 0.4rem 0.3rem; padding: 0.6rem 0.4rem;
border-radius: 0.45rem; border-radius: 0.55rem;
font-size: 0.84rem; font-size: 1.02rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
li + li { li + li {
@@ -156,8 +169,8 @@
gap: 0.35rem; gap: 0.35rem;
} }
.elbar { .elbar {
width: 2.4rem; width: 2.8rem;
height: 5px; height: 7px;
border-radius: 3px; border-radius: 3px;
background: var(--site-border); background: var(--site-border);
overflow: hidden; overflow: hidden;
@@ -170,16 +183,19 @@
.dir, .dir,
.dur, .dur,
.rel { .rel {
color: var(--site-muted); opacity: 0.8;
font-size: 0.78rem; font-size: 0.95rem;
} }
.rel { .rel {
text-align: right; text-align: right;
} }
.vis { .vis {
color: #fbbf24; color: #f59e0b;
display: inline-flex; display: inline-flex;
} }
.vis.inline {
vertical-align: -0.2em;
}
.foot { .foot {
margin-top: 0.8rem; margin-top: 0.8rem;
} }
@@ -193,6 +209,13 @@
} }
.times { .times {
grid-area: 1 / 1; grid-area: 1 / 1;
white-space: nowrap;
}
.arrow {
margin: 0 0.15rem;
}
.elbar {
display: none;
} }
.el { .el {
grid-area: 1 / 2; grid-area: 1 / 2;
@@ -206,8 +229,10 @@
.dur { .dur {
display: none; display: none;
} }
/* Shares row 2 with the direction, pushed right, so it never widens the columns. */
.rel { .rel {
grid-area: 2 / 2 / 3 / 4; grid-area: 2 / 1 / 3 / 4;
justify-self: end;
} }
} }
</style> </style>
+35 -27
View File
@@ -5,7 +5,8 @@
import BandIcon from './BandIcon.svelte'; import BandIcon from './BandIcon.svelte';
import Signal from './Signal.svelte'; import Signal from './Signal.svelte';
import Trash2 from 'lucide-svelte/icons/trash-2'; 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'; import type { Frequency, LogEntry } from '#lib/server/radio.js';
let { let {
@@ -22,6 +23,9 @@
frequencyId?: number | null; frequencyId?: number | null;
} = $props(); } = $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 byId = $derived(new Map(frequencies.map((f) => [f.id, f])));
const days = $derived.by(() => { const days = $derived.by(() => {
let l = frequencyId ? log.filter((e) => e.frequencyId === frequencyId) : log; let l = frequencyId ? log.filter((e) => e.frequencyId === frequencyId) : log;
@@ -29,7 +33,7 @@
const out: { key: string; label: string; entries: LogEntry[] }[] = []; const out: { key: string; label: string; entries: LogEntry[] }[] = [];
for (const e of l) { for (const e of l) {
const k = dayKey(e.heardAt); 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); out.at(-1)!.entries.push(e);
} }
return out; return out;
@@ -43,7 +47,7 @@
{@const f = e.frequencyId ? byId.get(e.frequencyId) : undefined} {@const f = e.frequencyId ? byId.get(e.frequencyId) : undefined}
<li> <li>
<span class="time">{fmtTime(e.heardAt)}</span> <span class="time">{fmtTime(e.heardAt)}</span>
<BandIcon band={f?.band ?? 'other'} size={22} /> <BandIcon band={f?.band ?? 'other'} size={30} />
<div class="body"> <div class="body">
<span class="what">{e.what}</span> <span class="what">{e.what}</span>
<span class="sub"> <span class="sub">
@@ -56,30 +60,34 @@
</span> </span>
</div> </div>
<Signal value={e.signal} /> <Signal value={e.signal} />
<form method="POST" action="?/deleteLog" use:enhance> {#if r.owner}
<input type="hidden" name="id" value={e.id} /> <form method="POST" action="?/deleteLog" use:enhance>
<button <input type="hidden" name="id" value={e.id} />
class="icon danger" <button
aria-label="delete log entry" class="icon danger"
onclick={(ev) => { aria-label="delete log entry"
if (!confirm('Delete this log entry?')) ev.preventDefault(); onclick={(ev) => {
}}><Trash2 size={14} /></button if (!confirm('Delete this log entry?')) ev.preventDefault();
> }}><Trash2 size={16} /></button
</form> >
</form>
{/if}
</li> </li>
{/each} {/each}
</ul> </ul>
{:else} {:else}
<p class="empty">Nothing logged yet. Heard something? Log it above.</p> <p class="empty">{r.owner ? t.nothingLoggedOwner : t.nothingLogged}</p>
{/each} {/each}
<style> <style>
.day { .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; text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
margin: 0.8rem 0 0.25rem;
} }
.log { .log {
display: flex; display: flex;
@@ -88,18 +96,18 @@
li { li {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.7rem;
padding: 0.4rem 0; padding: 0.55rem 0;
} }
li + li { li + li {
border-top: 1px solid var(--site-border); border-top: 1px solid var(--site-border);
} }
.time { .time {
width: 2.8rem; width: 3.4rem;
flex-shrink: 0; flex-shrink: 0;
font-size: 0.8rem; font-size: 1rem;
font-weight: 600;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--site-muted);
} }
.body { .body {
flex: 1; flex: 1;
@@ -108,14 +116,14 @@
flex-direction: column; flex-direction: column;
} }
.what { .what {
font-size: 0.88rem; font-size: 1.05rem;
} }
.sub { .sub {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.1rem 0.6rem; gap: 0.1rem 0.7rem;
font-size: 0.74rem; font-size: 0.92rem;
color: var(--site-muted); opacity: 0.75;
} }
.fq { .fq {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
+70 -50
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <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 { enhance } from '$app/forms';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import BandIcon from './BandIcon.svelte'; import BandIcon from './BandIcon.svelte';
@@ -15,18 +16,19 @@
QUIET_HOURS, QUIET_HOURS,
REMIND_MINUTES, REMIND_MINUTES,
parseQuiet, parseQuiet,
daysLabel,
fmtTime, fmtTime,
formatFreq, formatFreq,
mhz, mhz,
nextOccurrence, nextOccurrence
relDay,
until
} from './index.js'; } from './index.js';
import { DICTS, getRadio, helpers } from './i18n.js';
import type { Frequency, Net } from '#lib/server/radio.js'; import type { Frequency, Net } from '#lib/server/radio.js';
let { nets, frequencies, now }: { nets: Net[]; frequencies: Frequency[]; now: number } = $props(); 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 editing = $state<number | 'new' | null>(null);
let error = $state<string | null>(null); let error = $state<string | null>(null);
const byId = $derived(new Map(frequencies.map((f) => [f.id, f]))); const byId = $derived(new Map(frequencies.map((f) => [f.id, f])));
@@ -119,77 +121,83 @@
<section class="card"> <section class="card">
<div class="bar"> <div class="bar">
<p class="meta"> {#if r.owner}
Times are Amsterdam time. A bell sends an ntfy reminder {REMIND_MINUTES} minutes before each start, <p class="meta">
except during quiet hours ({parseQuiet(QUIET_HOURS)?.label}). Times are Amsterdam time. A bell sends a phone reminder {REMIND_MINUTES} minutes before each start,
</p> except during quiet hours ({parseQuiet(QUIET_HOURS)?.label}).
<button </p>
type="button" <button
class="primary" type="button"
onclick={() => (editing = editing === 'new' ? null : 'new')}><Plus size={15} />add</button class="primary"
> onclick={() => (editing = editing === 'new' ? null : 'new')}><Plus size={15} />add</button
>
{:else}
<p class="meta">{t.netsNote}</p>
{/if}
</div> </div>
{#if editing === 'new'}{@render form(null)}{/if} {#if r.owner && editing === 'new'}{@render form(null)}{/if}
<ul class="nets"> <ul class="nets">
{#each nets as n (n.id)} {#each nets as n (n.id)}
{@const f = n.frequencyId ? byId.get(n.frequencyId) : undefined} {@const f = n.frequencyId ? byId.get(n.frequencyId) : undefined}
{@const next = nextOccurrence(n, new Date(now))} {@const next = nextOccurrence(n, new Date(now))}
<li> <li>
{#if editing === n.id} {#if r.owner && editing === n.id}
{@render form(n)} {@render form(n)}
{:else} {:else}
<div class="net"> <div class="net">
<BandIcon band={f?.band ?? 'other'} size={30} /> <BandIcon band={f?.band ?? 'other'} size={40} />
<div class="body"> <div class="body">
<div class="top"> <div class="top">
<strong>{n.name}</strong> <strong>{n.name}</strong>
<span class="tag">{n.kind}</span> <span class="tag">{t.kinds[n.kind] ?? n.kind}</span>
</div> </div>
<div class="sub"> <div class="sub">
{#if f}<a {#if f}<a
href={resolve(`/trackers/radio?tab=frequencies&q=${mhz(f.freqHz)}` as '/')} href={resolve(`/trackers/radio?tab=frequencies&q=${mhz(f.freqHz)}` as '/')}
class="fq" class="fq"
>{formatFreq(f.freqHz)} >{formatFreq(f.freqHz)}
{f.mode}{f.marineCh ? ` · ch ${f.marineCh}` : ''}{f.channels.length {f.mode}{f.marineCh ? ` · ${t.channel} ${f.marineCh}` : ''}{f.channels.length
? ` · scanner ${f.channels.join(', ')}` ? ` · ${t.scannerCh} ${f.channels.join(', ')}`
: ''}</a : ''}</a
>{/if} >{/if}
<span <span class="when"
>{daysLabel(n.days)} >{h.daysLabel(n.days)}
{n.times.length ? n.times.join(' & ') : '· time unknown'}</span {n.times.length ? n.times.join(' & ') : `· ${t.timeUnknown}`}</span
> >
</div> </div>
{#if n.notes}<p class="meta">{n.notes}</p>{/if} {#if n.notes}<p class="meta">{n.notes}</p>{/if}
</div> </div>
<div class="right"> <div class="right">
{#if next}<span class="next">{relDay(next, now)} {fmtTime(next)}</span><span {#if next}<span class="next">{h.relDay(next, now)} {fmtTime(next)}</span><span
class="meta">{until(next, now)}</span class="meta">{h.until(next, now)}</span
>{/if} >{/if}
</div> </div>
<form method="POST" action="?/remind" use:enhance> {#if r.owner}
<input type="hidden" name="id" value={n.id} /> <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 <button
class={['icon', 'bell', { on: n.remind && n.times.length }]} type="button"
aria-label={n.remind ? 'reminder on, turn off' : 'reminder off, turn on'} class="icon"
title={n.times.length aria-label="edit {n.name}"
? n.remind onclick={() => (editing = n.id)}><Pencil size={16} /></button
? 'reminder on'
: 'reminder off'
: 'no time set, so no reminder'}
>{#if n.remind}<Bell size={15} />{:else}<BellOff size={15} />{/if}</button
> >
</form> {/if}
<button
type="button"
class="icon"
aria-label="edit {n.name}"
onclick={() => (editing = n.id)}><Pencil size={14} /></button
>
</div> </div>
{/if} {/if}
</li> </li>
{:else} {:else}
<li class="empty">No nets yet.</li> <li class="empty">{t.noNets}</li>
{/each} {/each}
</ul> </ul>
</section> </section>
@@ -206,8 +214,11 @@
.net { .net {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 0.65rem; gap: 0.8rem;
padding: 0.65rem 0; padding: 0.85rem 0;
}
strong {
font-size: 1.1rem;
} }
.body { .body {
flex: 1; flex: 1;
@@ -225,9 +236,11 @@
.sub { .sub {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.2rem 0.7rem; gap: 0.2rem 0.8rem;
font-size: 0.8rem; font-size: 0.98rem;
color: var(--site-muted); }
.when {
font-weight: 500;
} }
.fq { .fq {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -243,7 +256,8 @@
flex-shrink: 0; flex-shrink: 0;
} }
.next { .next {
font-size: 0.85rem; font-size: 1.05rem;
font-weight: 600;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.bell.on { .bell.on {
@@ -275,12 +289,18 @@
.net { .net {
flex-wrap: wrap; flex-wrap: wrap;
} }
.body {
flex-basis: calc(100% - 40px - 0.8rem);
}
.body .meta {
overflow-wrap: anywhere;
}
.right { .right {
order: 5; order: 5;
flex-direction: row; flex-direction: row;
gap: 0.5rem; gap: 0.5rem;
align-items: baseline; align-items: baseline;
padding-left: 2.6rem; padding-left: calc(40px + 0.8rem);
} }
} }
</style> </style>
+22 -6
View File
@@ -1,25 +1,41 @@
<script lang="ts"> <script lang="ts">
// Signal quality 1..5 as five little bars. // Signal quality 1..5 as five little bars.
import { DICTS, getRadio } from './i18n.js';
let { value }: { value: number | null } = $props(); 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> </script>
{#if value} {#if value}
<span class="sig" title="signal {value}/5: {LABEL[value]}" aria-label="signal {value} of 5"> <span class="sig" title="{t.signal(value)}: {t.signalWords[value]}">
{#each [1, 2, 3, 4, 5] as i (i)}<i class={{ on: i <= value }} style:height="{3 + i * 2}px" <span class="bars" aria-hidden="true">
></i>{/each} {#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> </span>
{/if} {/if}
<style> <style>
.sig { .sig {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.bars {
display: inline-flex; display: inline-flex;
align-items: flex-end; align-items: flex-end;
gap: 2px; gap: 2px;
height: 13px; height: 19px;
}
.word {
font-size: 0.85rem;
opacity: 0.75;
white-space: nowrap;
} }
i { i {
width: 3px; width: 4px;
border-radius: 1px; border-radius: 1px;
background: var(--site-border); background: var(--site-border);
} }
+34 -38
View File
@@ -4,16 +4,8 @@
import BandIcon from './BandIcon.svelte'; import BandIcon from './BandIcon.svelte';
import Satellite from 'lucide-svelte/icons/satellite'; import Satellite from 'lucide-svelte/icons/satellite';
import Bell from 'lucide-svelte/icons/bell'; import Bell from 'lucide-svelte/icons/bell';
import { import { ISS_DOWNLINK_HZ, ISS_MIN_EL, fmtTime, formatFreq, occurrences } from './index.js';
ISS_DOWNLINK_HZ, import { DICTS, getRadio, helpers } from './i18n.js';
ISS_MIN_EL,
compass,
fmtTime,
formatFreq,
occurrences,
relDay,
until
} from './index.js';
import type { Frequency, IssPass, Net } from '#lib/server/radio.js'; import type { Frequency, IssPass, Net } from '#lib/server/radio.js';
let { let {
@@ -30,6 +22,9 @@
limit?: number; limit?: number;
} = $props(); } = $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 byId = $derived(new Map(frequencies.map((f) => [f.id, f])));
type Item = { type Item = {
key: string; key: string;
@@ -58,11 +53,11 @@
end, end,
title: n.name, title: n.name,
sub: f 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', band: f?.band ?? 'other',
iss: false, iss: false,
remind: n.remind remind: r.owner && n.remind
}); });
} }
} }
@@ -74,11 +69,11 @@
key: `iss-${p.startAt}`, key: `iss-${p.startAt}`,
at: new Date(p.startAt).getTime(), at: new Date(p.startAt).getTime(),
end, end,
title: `ISS pass, max ${Math.round(p.maxEl)}°`, title: t.issPass(Math.round(p.maxEl)),
sub: `${compass(p.startAz)} → ${compass(p.endAz)} · ${formatFreq(ISS_DOWNLINK_HZ)} NFM${p.sunlit ? ' · visible' : ''}`, sub: `${h.compass(p.startAz)} → ${h.compass(p.endAz)} · ${formatFreq(ISS_DOWNLINK_HZ)} NFM${p.sunlit ? ` · ${t.visible}` : ''}`,
band: null, band: null,
iss: true, iss: true,
remind: true remind: r.owner
}); });
} }
return out.sort((a, b) => a.at - b.at).slice(0, limit); 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} {@const live = it.at <= now && (it.end ?? it.at) >= now}
<li class={{ live }}> <li class={{ live }}>
{#if it.iss}<span class="iss-icon" aria-hidden="true" {#if it.iss}<span class="iss-icon" aria-hidden="true"
><Satellite size={13} strokeWidth={2.25} /></span ><Satellite size={20} strokeWidth={2.25} /></span
>{:else}<BandIcon band={it.band ?? 'other'} size={26} />{/if} >{:else}<BandIcon band={it.band ?? 'other'} size={36} />{/if}
<div class="what"> <div class="what">
<a href={resolve(`/trackers/radio?tab=${it.iss ? 'iss' : 'nets'}` as '/')} class="t" <a href={resolve(`/trackers/radio?tab=${it.iss ? 'iss' : 'nets'}` as '/')} class="t"
>{it.title}</a >{it.title}</a
@@ -100,10 +95,10 @@
<span class="s">{it.sub}</span> <span class="s">{it.sub}</span>
</div> </div>
<div class="when"> <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" <span class="rel"
>{live ? 'on now' : until(it.at, now)}{#if it.remind}<Bell >{live ? t.onNow : h.until(it.at, now)}{#if it.remind}<Bell
size={11} size={14}
aria-label="reminder on" aria-label="reminder on"
/>{/if}</span />{/if}</span
> >
@@ -112,21 +107,21 @@
{/each} {/each}
</ol> </ol>
{:else} {:else}
<p class="empty">Nothing scheduled in the next 3 days.</p> <p class="empty">{t.nothingScheduled}</p>
{/if} {/if}
<style> <style>
.upnext { .upnext {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.15rem; gap: 0.2rem;
} }
li { li {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.75rem;
padding: 0.4rem 0.35rem; padding: 0.55rem 0.4rem;
border-radius: 0.5rem; border-radius: 0.6rem;
} }
li.live { li.live {
background: color-mix(in srgb, #34d399 12%, transparent); background: color-mix(in srgb, #34d399 12%, transparent);
@@ -135,10 +130,10 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 26px; width: 36px;
height: 26px; height: 36px;
flex-shrink: 0; flex-shrink: 0;
border-radius: 8px; border-radius: 11px;
color: #fff; color: #fff;
background: linear-gradient(135deg, #2dd4bf, #0f766e); background: linear-gradient(135deg, #2dd4bf, #0f766e);
} }
@@ -149,19 +144,18 @@
flex-direction: column; flex-direction: column;
} }
.t { .t {
font-size: 0.88rem; font-size: 1.05rem;
font-weight: 500; font-weight: 600;
overflow: hidden; overflow-wrap: anywhere;
text-overflow: ellipsis;
white-space: nowrap;
} }
.t:hover { .t:hover {
text-decoration: underline; text-decoration: underline;
} }
.s, .s,
.rel { .rel {
font-size: 0.74rem; font-size: 0.92rem;
color: var(--site-muted); color: var(--site-text);
opacity: 0.75;
} }
.when { .when {
display: flex; display: flex;
@@ -171,7 +165,8 @@
flex-shrink: 0; flex-shrink: 0;
} }
.clock { .clock {
font-size: 0.82rem; font-size: 1rem;
font-weight: 600;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.rel { .rel {
@@ -180,7 +175,8 @@
gap: 0.25rem; gap: 0.25rem;
} }
li.live .rel { li.live .rel {
color: #34d399; color: #10b981;
font-weight: 600; opacity: 1;
font-weight: 650;
} }
</style> </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')).toBe(true);
expect(isOwnerWritePath('/trackers/cargill/glossary/ester')).toBe(true); expect(isOwnerWritePath('/trackers/cargill/glossary/ester')).toBe(true);
}); });
it('gates the whole radio tracker, reads, writes and the CSV export', () => { it('shows the radio tracker to everyone but keeps every write mine', () => {
expect(isOwnerOnlyPath('/trackers/radio')).toBe(true); expect(isOwnerOnlyPath('/trackers/radio')).toBe(false);
expect(isOwnerOnlyPath('/trackers/radio/__data.json')).toBe(true); expect(isOwnerOnlyPath('/trackers/radio/__data.json')).toBe(false);
expect(isOwnerOnlyPath('/trackers/radio/banks.csv')).toBe(true); expect(isOwnerOnlyPath('/trackers/radio/banks.csv')).toBe(false);
expect(isOwnerOnlyPath('/trackers/radiox')).toBe(false);
expect(isOwnerWritePath('/trackers/radio')).toBe(true); 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', () => { it('only lets the owner write in owner areas', () => {
expect(isOwnerWritePath('/trackers/books/12')).toBe(true); expect(isOwnerWritePath('/trackers/books/12')).toBe(true);
-2
View File
@@ -42,8 +42,6 @@ export const ownerOnlyPrefixes = [
'/projects/worldwide-todo/api/queue', '/projects/worldwide-todo/api/queue',
// Cargill plant tracker: what the factory I work at does. Mine only. // Cargill plant tracker: what the factory I work at does. Mine only.
'/trackers/cargill', '/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 tracker (the Napkin): owner-only while MATH_PRIVATE (#lib/math) is on.
...(MATH_PRIVATE ? (['/trackers/math'] as const) : []) ...(MATH_PRIVATE ? (['/trackers/math'] as const) : [])
] 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 // Radio tracker (/trackers/radio, public read-only, owner edits): loads everything for the page
// owner's edits. Hand-written SQL on the shared postgres.js client; bigint Hz come back as // (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. // float8 so they are plain numbers.
import { client } from '#lib/server/db/index.js'; import { client } from '#lib/server/db/index.js';
import { 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. */ /** All channels with their frequency, for the CSV export. */
export async function channelRows(): Promise<ChannelRow[]> { export async function channelRows(): Promise<ChannelRow[]> {
const rows = await client`SELECT c.channel, c.alpha_tag, c.delay, c.lockout, c.priority, 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', '/stats/kor/overview',
'/trackers/books', '/trackers/books',
'/trackers/ai', '/trackers/ai',
'/trackers/radio',
'/trackers/spotting', '/trackers/spotting',
'/trackers/cars', '/trackers/cars',
'/trackers/cars/brands', '/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 { import {
addLog, addLog,
deleteFrequency, deleteFrequency,
deleteLog, deleteLog,
deleteNet, deleteNet,
loadRadio, loadRadio,
publicRadio,
saveFrequency, saveFrequency,
saveNet, saveNet,
setChannel, setChannel,
toggleRemind toggleRemind
} from '#lib/server/radio.js'; } 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 { fail, type RequestEvent } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
// /trackers/radio: owner-only. Everyone else gets a 404 (here and in hooks.server.ts via // /trackers/radio: public and read-only (shown to Opa Chiel). Visitors get publicRadio(): no
// ownerOnlyPrefixes); hooks.server.ts also sends cache-control: private, no-store. // 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) => { 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 { return {
...(await loadRadio()), ...(owner ? radio : publicRadio(radio)),
owner,
lang,
now: Date.now(), now: Date.now(),
seo: { seo: {
title: 'radio', title: 'radio',
description: 'Frequencies, scanner banks, nets and ISS passes.', description:
index: false 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 = ( type Handler = (
form: FormData form: FormData
) => Promise<{ ok: true; message?: string } | { ok: false; message: string }>; ) => 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) => { const action = (name: string, fn: Handler) => async (event: RequestEvent) => {
requireOwner(event); requireOwner(event);
const r = await fn(await event.request.formData()); const r = await fn(await event.request.formData());
+298 -146
View File
@@ -2,8 +2,8 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import TrackerIcon from '#lib/components/TrackerIcon.svelte'; import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import Tile from '#lib/radio/Tile.svelte'; import Tile from '#lib/radio/Tile.svelte';
import BandIcon from '#lib/radio/BandIcon.svelte';
import UpNext from '#lib/radio/UpNext.svelte'; import UpNext from '#lib/radio/UpNext.svelte';
import QuickLog from '#lib/radio/QuickLog.svelte'; import QuickLog from '#lib/radio/QuickLog.svelte';
import FreqList from '#lib/radio/FreqList.svelte'; import FreqList from '#lib/radio/FreqList.svelte';
@@ -11,64 +11,53 @@
import Nets from '#lib/radio/Nets.svelte'; import Nets from '#lib/radio/Nets.svelte';
import IssPasses from '#lib/radio/IssPasses.svelte'; import IssPasses from '#lib/radio/IssPasses.svelte';
import LogList from '#lib/radio/LogList.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 LayoutDashboard from 'lucide-svelte/icons/layout-dashboard';
import RadioReceiver from 'lucide-svelte/icons/radio-receiver'; import RadioReceiver from 'lucide-svelte/icons/radio-receiver';
import LayoutGrid from 'lucide-svelte/icons/layout-grid'; import LayoutGrid from 'lucide-svelte/icons/layout-grid';
import CalendarClock from 'lucide-svelte/icons/calendar-clock'; import CalendarClock from 'lucide-svelte/icons/calendar-clock';
import Satellite from 'lucide-svelte/icons/satellite'; import Satellite from 'lucide-svelte/icons/satellite';
import NotebookPen from 'lucide-svelte/icons/notebook-pen'; import NotebookPen from 'lucide-svelte/icons/notebook-pen';
import Languages from 'lucide-svelte/icons/languages';
import { untrack, type Component } from 'svelte'; import { untrack, type Component } from 'svelte';
let { data, form } = $props(); 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 Tab = 'overview' | 'frequencies' | 'banks' | 'nets' | 'iss' | 'log';
type Icon = Component<{ size?: number; strokeWidth?: number }>; type Icon = Component<{ size?: number; strokeWidth?: number }>;
const TABS: { id: Tab; label: string; icon: Icon; c: [string, string] }[] = [ const TABS: { id: Tab; icon: Icon; c: [string, string] }[] = [
{ { id: 'overview', icon: LayoutDashboard as unknown as Icon, c: ['#f43f5e', '#881337'] },
id: 'overview', { id: 'frequencies', icon: RadioReceiver as unknown as Icon, c: ['#a78bfa', '#5b21b6'] },
label: 'overview', { id: 'banks', icon: LayoutGrid as unknown as Icon, c: ['#38bdf8', '#0369a1'] },
icon: LayoutDashboard as unknown as Icon, { id: 'nets', icon: CalendarClock as unknown as Icon, c: ['#fbbf24', '#b45309'] },
c: ['#f43f5e', '#881337'] { id: 'iss', icon: Satellite as unknown as Icon, c: ['#2dd4bf', '#0f766e'] },
}, { id: 'log', icon: NotebookPen as unknown as Icon, c: ['#34d399', '#047857'] }
{
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>( 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) => const count = (id: Tab) =>
t === 'frequencies' id === 'frequencies'
? data.frequencies.length ? data.frequencies.length
: t === 'banks' : id === 'banks'
? data.channels.length ? data.channels.length
: t === 'nets' : id === 'nets'
? data.nets.length ? data.nets.length
: t === 'iss' : id === 'iss'
? data.passes.filter((p) => p.maxEl >= 10).length ? data.passes.filter((p) => p.maxEl >= 10).length
: t === 'log' : id === 'log'
? data.logTotal ? data.logTotal
: null; : null;
@@ -76,38 +65,66 @@
let now = $state(untrack(() => data.now)); let now = $state(untrack(() => data.now));
$effect(() => { $effect(() => {
now = Date.now(); now = Date.now();
const t = setInterval(() => (now = Date.now()), 30_000); const i = setInterval(() => (now = Date.now()), 30_000);
return () => clearInterval(t); return () => clearInterval(i);
}); });
const logMessage = $derived(form?.action === 'log' ? (form.message ?? null) : null); const logMessage = $derived(form?.action === 'log' ? (form.message ?? null) : null);
const logError = $derived(form?.action === 'log' && !form.ok); const logError = $derived(form?.action === 'log' && !form.ok);
const logFreq = $derived(Number(page.url.searchParams.get('f')) || null); const logFreq = $derived(Number(page.url.searchParams.get('f')) || null);
const tabHref = (t: Tab) => const tabHref = (id: Tab) =>
resolve((t === 'overview' ? '/trackers/radio' : `/trackers/radio?tab=${t}`) as '/'); 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> </script>
<div class="radio"> <div class="radio" lang={data.lang}>
<header class="head"> <header class="head">
<a href={resolve('/trackers/radio')} class="title-link" <a href={resolve('/trackers/radio')} class="title-link" aria-label={t.title}
><TrackerIcon name="radio" class="size-10" /></a ><TrackerIcon name="radio" class="size-12" /></a
> >
<div class="title"> <div class="title">
<h1>radio<PrivateLock size={14} /></h1> <h1>{t.title}</h1>
<p>Frequencies, scanner banks, nets, ISS passes over Gouda and what I heard.</p> <p>{data.owner ? t.intro : t.introPublic}</p>
</div> </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> </header>
<nav class="tabs" aria-label="Radio tracker sections"> <nav class="tabs" aria-label={t.tabsLabel}>
{#each TABS as t (t.id)} {#each TABS as x (x.id)}
{@const n = count(t.id)} {@const n = count(x.id)}
<a <a
href={tabHref(t.id)} href={tabHref(x.id)}
class={{ active: tab === t.id }} class={{ active: tab === x.id }}
aria-current={tab === t.id ? 'page' : undefined} aria-current={tab === x.id ? 'page' : undefined}
><Tile icon={t.icon} from={t.c[0]} to={t.c[1]} />{t.label}{#if n !== null}<span class="n" ><Tile icon={x.icon} from={x.c[0]} to={x.c[1]} size={28} /><span class="lbl"
>{n}</span >{t.tabs[x.id]}</span
>{/if}</a >{#if n !== null}<span class="n">{n}</span>{/if}</a
> >
{/each} {/each}
</nav> </nav>
@@ -116,40 +133,60 @@
<div class="overview"> <div class="overview">
<section class="card"> <section class="card">
<h2 class="card-title"> <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> </h2>
<UpNext nets={data.nets} passes={data.passes} frequencies={data.frequencies} {now} /> <UpNext nets={data.nets} passes={data.passes} frequencies={data.frequencies} {now} />
</section> </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"> <section class="card">
<h2 class="card-title"> <h2 class="card-title">
<Tile icon={TABS[5].icon} from="#34d399" to="#047857" size={18} />quick log <Tile icon={TABS[4].icon} from="#2dd4bf" to="#0f766e" size={24} />{t.nextIss}
</h2> <a class="more" href={tabHref('iss')}>{t.all}</a>
<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> </h2>
<IssPasses passes={data.passes} {now} limit={4} compact /> <IssPasses passes={data.passes} {now} limit={4} compact />
</section> </section>
<section class="card stats"> <section class="card stats">
<h2 class="card-title"> <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> </h2>
<p> <p>{t.channelsProgrammed(data.channels.length, data.frequencies.length)}</p>
<b>{data.channels.length}</b> of 500 channels programmed, <b>{data.frequencies.length}</b> frequencies <p class="meta">{t.scanHelp}</p>
known. <a class="ghost" href={tabHref('banks')}>{t.openBanks}</a>
</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> </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> </div>
{:else if tab === 'frequencies'} {:else if tab === 'frequencies'}
<FreqList frequencies={data.frequencies} /> <FreqList frequencies={data.frequencies} />
@@ -160,12 +197,14 @@
{:else if tab === 'iss'} {:else if tab === 'iss'}
<section class="card"><IssPasses passes={data.passes} tle={data.tle} {now} /></section> <section class="card"><IssPasses passes={data.passes} tle={data.tle} {now} /></section>
{:else} {:else}
<section class="card"> {#if data.owner}
<QuickLog frequencies={data.frequencies} message={logMessage} error={logError} /> <section class="card">
</section> <QuickLog frequencies={data.frequencies} message={logMessage} error={logError} />
</section>
{/if}
<section class="card"> <section class="card">
{#if logFreq}<p class="meta"> {#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} </p>{/if}
<LogList log={data.log} frequencies={data.frequencies} {now} frequencyId={logFreq} /> <LogList log={data.log} frequencies={data.frequencies} {now} frequencyId={logFreq} />
</section> </section>
@@ -173,52 +212,98 @@
</div> </div>
<style> <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 { .radio {
max-width: 72rem; max-width: 72rem;
margin: 0 auto; margin: 0 auto;
padding: 2rem 1rem 3rem; padding: 2rem 1rem 3rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1.1rem;
font-size: 1.0625rem;
line-height: 1.5;
} }
.head { .head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.85rem; gap: 0.9rem;
flex-wrap: wrap;
} }
.title-link { .title-link {
display: inline-flex; display: inline-flex;
} }
.title { .title {
flex: 1 1 16rem;
min-width: 0; min-width: 0;
} }
h1 { h1 {
font-size: 2rem; font-size: 2.25rem;
font-weight: 600; font-weight: 650;
letter-spacing: -0.02em; letter-spacing: -0.02em;
line-height: 1.1; line-height: 1.1;
} }
.title p { .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); 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 { .tabs {
display: flex; display: flex;
gap: 0.35rem; gap: 0.45rem;
overflow-x: auto; flex-wrap: wrap;
scrollbar-width: none;
border-bottom: 1px solid var(--site-border); border-bottom: 1px solid var(--site-border);
padding-bottom: 0.6rem; padding-bottom: 0.8rem;
} }
.tabs a { .tabs a {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.5rem;
padding: 0.25rem 0.6rem 0.25rem 0.25rem; min-height: 3rem;
padding: 0.3rem 0.9rem 0.3rem 0.35rem;
border: 1px solid var(--site-border); border: 1px solid var(--site-border);
border-radius: 999px; border-radius: 999px;
font-size: 0.83rem; font-size: 1rem;
color: var(--site-muted); font-weight: 500;
color: var(--site-text);
white-space: nowrap; white-space: nowrap;
transition: transition:
color 0.15s, color 0.15s,
@@ -226,7 +311,6 @@
background 0.15s; background 0.15s;
} }
.tabs a:hover { .tabs a:hover {
color: var(--site-text);
border-color: var(--site-muted); border-color: var(--site-muted);
} }
.tabs a.active { .tabs a.active {
@@ -235,7 +319,7 @@
color: var(--site-bg); color: var(--site-bg);
} }
.n { .n {
font-size: 0.7rem; font-size: 0.85rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
opacity: 0.7; opacity: 0.7;
} }
@@ -246,54 +330,86 @@
align-items: start; align-items: start;
} }
.recent { .recent {
margin-top: 0.4rem; margin-top: 0.6rem;
} }
.more { .more {
margin-left: auto; margin-left: auto;
text-transform: none; text-transform: none;
letter-spacing: 0; 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 { .u {
text-decoration: underline; text-decoration: underline;
} }
.stats p + p { .stats p + p {
margin-top: 0.4rem; margin-top: 0.5rem;
} }
.stats .ghost { .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. */ /* Shared building blocks for the section components. */
.radio :global(.card) { .radio :global(.card) {
padding: 0.9rem; padding: 1.1rem;
border: 1px solid var(--site-border); border: 1px solid var(--site-border);
border-radius: 0.75rem; border-radius: 0.9rem;
background: var(--site-surface); background: var(--site-surface);
min-width: 0; min-width: 0;
} }
.radio :global(.card-title) { .radio :global(.card-title) {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.6rem;
font-size: 0.78rem; font-size: 1.15rem;
text-transform: uppercase; font-weight: 650;
letter-spacing: 0.06em; color: var(--site-text);
color: var(--site-muted); margin-bottom: 0.8rem;
margin-bottom: 0.6rem;
} }
.radio :global(.bar) { .radio :global(.bar) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.6rem; gap: 0.7rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.radio :global(.meta), .radio :global(.meta),
.radio :global(.empty) { .radio :global(.empty) {
color: var(--site-muted); color: var(--site-text);
font-size: 0.82rem; opacity: 0.75;
font-size: 0.95rem;
} }
.radio :global(.empty) { .radio :global(.empty) {
padding: 1rem 0; padding: 1rem 0;
@@ -305,12 +421,13 @@
.radio :global(.primary) { .radio :global(.primary) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.3rem; gap: 0.35rem;
padding: 0.35rem 0.8rem; min-height: 2.75rem;
border-radius: 0.5rem; padding: 0.35rem 1rem;
border-radius: 0.6rem;
background: var(--site-text); background: var(--site-text);
color: var(--site-bg); color: var(--site-bg);
font-size: 0.83rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
transition: opacity 0.15s; transition: opacity 0.15s;
} }
@@ -323,23 +440,26 @@
.radio :global(.ghost) { .radio :global(.ghost) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.3rem; gap: 0.4rem;
padding: 0.35rem 0.7rem; min-height: 2.75rem;
border-radius: 0.5rem; padding: 0.35rem 1rem;
border-radius: 0.6rem;
border: 1px solid var(--site-border); border: 1px solid var(--site-border);
color: var(--site-muted); color: var(--site-text);
font-size: 0.83rem; font-size: 1rem;
font-weight: 500;
} }
.radio :global(.ghost:hover) { .radio :global(.ghost:hover) {
color: var(--site-text);
border-color: var(--site-muted); border-color: var(--site-muted);
} }
.radio :global(.icon) { .radio :global(.icon) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 2.5rem;
min-height: 2.5rem;
padding: 0.3rem; padding: 0.3rem;
border-radius: 0.4rem; border-radius: 0.5rem;
color: var(--site-muted); color: var(--site-muted);
transition: transition:
color 0.15s, color 0.15s,
@@ -354,25 +474,27 @@
background: rgb(248 113 113 / 0.1); background: rgb(248 113 113 / 0.1);
} }
.radio :global(.tag) { .radio :global(.tag) {
font-size: 0.68rem; font-size: 0.85rem;
padding: 0.05rem 0.4rem; padding: 0.05rem 0.5rem;
border-radius: 999px; border-radius: 999px;
border: 1px solid var(--site-border); border: 1px solid var(--site-border);
color: var(--site-muted); color: var(--site-text);
opacity: 0.85;
white-space: nowrap; white-space: nowrap;
} }
.radio :global(.chip) { .radio :global(.chip) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.45rem;
padding: 0.2rem 0.6rem 0.2rem 0.25rem; min-height: 2.75rem;
padding: 0.25rem 0.85rem 0.25rem 0.35rem;
border: 1px solid var(--site-border); border: 1px solid var(--site-border);
border-radius: 999px; border-radius: 999px;
font-size: 0.8rem; font-size: 1rem;
color: var(--site-muted); color: var(--site-text);
} }
.radio :global(.chip:first-child) { .radio :global(.chip:first-child) {
padding-left: 0.6rem; padding-left: 0.85rem;
} }
.radio :global(.chip.active) { .radio :global(.chip.active) {
background: var(--site-text); background: var(--site-text);
@@ -381,25 +503,25 @@
} }
.radio :global(.err) { .radio :global(.err) {
color: #f87171; color: #f87171;
font-size: 0.8rem; font-size: 0.95rem;
margin-right: auto; margin-right: auto;
} }
/* Edit forms: a compact grid of labelled fields. */ /* Owner edit forms: a compact grid of labelled fields. */
.radio :global(.edit) { .radio :global(.edit) {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.55rem; gap: 0.6rem;
padding: 0.75rem; padding: 0.85rem;
margin: 0.5rem 0; margin: 0.5rem 0;
border: 1px solid var(--site-border); border: 1px solid var(--site-border);
border-radius: 0.65rem; border-radius: 0.7rem;
background: var(--site-bg); background: var(--site-bg);
} }
.radio :global(.edit label) { .radio :global(.edit label) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.2rem; gap: 0.2rem;
font-size: 0.72rem; font-size: 0.85rem;
color: var(--site-muted); color: var(--site-muted);
min-width: 0; min-width: 0;
} }
@@ -412,20 +534,20 @@
.radio :global(.edit input:not([type='checkbox'])), .radio :global(.edit input:not([type='checkbox'])),
.radio :global(.edit select), .radio :global(.edit select),
.radio :global(.edit textarea) { .radio :global(.edit textarea) {
height: 2.2rem; height: 2.6rem;
padding: 0 0.55rem; padding: 0 0.6rem;
border: 1px solid var(--site-border); border: 1px solid var(--site-border);
border-radius: 0.45rem; border-radius: 0.5rem;
background: var(--site-surface); background: var(--site-surface);
color: var(--site-text); color: var(--site-text);
font: inherit; font: inherit;
font-size: 0.86rem; font-size: 1rem;
min-width: 0; min-width: 0;
width: 100%; width: 100%;
} }
.radio :global(.edit textarea) { .radio :global(.edit textarea) {
height: auto; height: auto;
padding: 0.4rem 0.55rem; padding: 0.4rem 0.6rem;
resize: vertical; resize: vertical;
} }
.radio :global(.edit .actions) { .radio :global(.edit .actions) {
@@ -434,6 +556,11 @@
justify-content: flex-end; justify-content: flex-end;
gap: 0.5rem; gap: 0.5rem;
} }
.radio :global(a:focus-visible),
.radio :global(button:focus-visible) {
outline: 3px solid #60a5fa;
outline-offset: 2px;
}
@media (max-width: 860px) { @media (max-width: 860px) {
.overview { .overview {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -442,9 +569,34 @@
@media (max-width: 640px) { @media (max-width: 640px) {
.radio { .radio {
padding-top: 1.25rem; padding-top: 1.25rem;
font-size: 1.0625rem;
} }
h1 { 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) { .radio :global(.edit) {
grid-template-columns: repeat(2, minmax(0, 1fr)); 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 { channelRows } from '#lib/server/radio.js';
import { scannerCsv } from '#lib/radio/index.js'; import { scannerCsv } from '#lib/radio/index.js';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
// All 500 scanner memories as CSV (Uniden BC125AT/UBC125XLT programming software column order). // 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 (event) => { export const GET: RequestHandler = async () => {
requireOwner(event);
const csv = scannerCsv(await channelRows()); const csv = scannerCsv(await channelRows());
return new Response(csv, { return new Response(csv, {
headers: { headers: {
'content-type': 'text/csv; charset=utf-8', 'content-type': 'text/csv; charset=utf-8',
'content-disposition': 'attachment; filename="ubc125xlt-banks.csv"', 'content-disposition': 'attachment; filename="ubc125xlt-banks.csv"',
'cache-control': 'private, no-store' 'cache-control': 'public, max-age=300'
} }
}); });
}; };