diff --git a/src/lib/projects.ts b/src/lib/projects.ts
index d9ec604..e57d29f 100644
--- a/src/lib/projects.ts
+++ b/src/lib/projects.ts
@@ -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',
diff --git a/src/lib/radio/Banks.svelte b/src/lib/radio/Banks.svelte
index 43e49ff..ddb99be 100644
--- a/src/lib/radio/Banks.svelte
+++ b/src/lib/radio/Banks.svelte
@@ -1,5 +1,6 @@
{#if !compact}
{/if}
@@ -76,35 +73,45 @@
{#each d.passes as p (p.startAt)}
{@const live = new Date(p.startAt).getTime() <= now}
-
+
{fmtTime(p.startAt)} → {fmtTime(p.maxAt)}→ {fmtTime(p.endAt)}
-
+
{Math.round(p.maxEl)}°
- {compass(p.startAz)} → {compass(p.maxAz)} → {compass(p.endAz)}
- {mins(p)} min
- {#if p.sunlit} {/if} {h.compass(p.startAz)} → {h.compass(p.maxAz)} → {h.compass(p.endAz)}
- {live ? 'overhead now' : until(p.startAt, now)}
+ {t.minutes(mins(p))}
+ {#if p.sunlit} {/if}
+ {live ? t.overheadNow : h.until(p.startAt, now)}
{/each}
{/each}
{:else}
- No passes computed yet (the daily refresh fills this).
+ {t.noPasses}
{/if}
{#if !compact && tle}
{/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;
}
}
diff --git a/src/lib/radio/LogList.svelte b/src/lib/radio/LogList.svelte
index d005ba8..6648363 100644
--- a/src/lib/radio/LogList.svelte
+++ b/src/lib/radio/LogList.svelte
@@ -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}
{fmtTime(e.heardAt)}
-
+
{e.what}
@@ -56,30 +60,34 @@
-
+ {#if r.owner}
+
+ {/if}
{/each}
{:else}
- Nothing logged yet. Heard something? Log it above.
+ {r.owner ? t.nothingLoggedOwner : t.nothingLogged}
{/each}
diff --git a/src/lib/radio/Signal.svelte b/src/lib/radio/Signal.svelte
index 43a35a6..b209ec1 100644
--- a/src/lib/radio/Signal.svelte
+++ b/src/lib/radio/Signal.svelte
@@ -1,25 +1,41 @@
{#if value}
-
- {#each [1, 2, 3, 4, 5] as i (i)} {/each}
+
+
+ {#each [1, 2, 3, 4, 5] as i (i)} {/each}
+
+ {t.signalWords[value]}
{/if}
diff --git a/src/lib/radio/i18n.ts b/src/lib/radio/i18n.ts
new file mode 100644
index 0000000..95983f3
--- /dev/null
+++ b/src/lib/radio/i18n.ts
@@ -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,
+ 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,
+ 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 = { en, nl };
+
+// ---- localised time and direction helpers ------------------------------------------------------
+
+const dayFmts = new Map();
+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(KEY) ?? { owner: false, lang: 'nl' };
diff --git a/src/lib/server/owner.test.ts b/src/lib/server/owner.test.ts
index 9017a46..be8d6c5 100644
--- a/src/lib/server/owner.test.ts
+++ b/src/lib/server/owner.test.ts
@@ -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);
diff --git a/src/lib/server/owner.ts b/src/lib/server/owner.ts
index bd56ac5..5c29214 100644
--- a/src/lib/server/owner.ts
+++ b/src/lib/server/owner.ts
@@ -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;
diff --git a/src/lib/server/radio.ts b/src/lib/server/radio.ts
index d1b90c4..2fa3bea 100644
--- a/src/lib/server/radio.ts
+++ b/src/lib/server/radio.ts
@@ -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>;
+
+/**
+ * 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 {
const rows = await client`SELECT c.channel, c.alpha_tag, c.delay, c.lockout, c.priority,
diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts
index fb8e5ef..a052e0f 100644
--- a/src/routes/sitemap.xml/+server.ts
+++ b/src/routes/sitemap.xml/+server.ts
@@ -31,6 +31,7 @@ const staticPaths = [
'/stats/kor/overview',
'/trackers/books',
'/trackers/ai',
+ '/trackers/radio',
'/trackers/spotting',
'/trackers/cars',
'/trackers/cars/brands',
diff --git a/src/routes/trackers/radio/+page.server.ts b/src/routes/trackers/radio/+page.server.ts
index b22c4e7..83c8983 100644
--- a/src/routes/trackers/radio/+page.server.ts
+++ b/src/routes/trackers/radio/+page.server.ts
@@ -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());
diff --git a/src/routes/trackers/radio/+page.svelte b/src/routes/trackers/radio/+page.svelte
index d2e2182..5caf474 100644
--- a/src/routes/trackers/radio/+page.svelte
+++ b/src/routes/trackers/radio/+page.svelte
@@ -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(
- (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 '/');
-
+
-
- {#each TABS as t (t.id)}
- {@const n = count(t.id)}
+
+ {#each TABS as x (x.id)}
+ {@const n = count(x.id)}
{t.label}{#if n !== null}{n} {/if}{t.tabs[x.id]} {#if n !== null}{n} {/if}
{/each}
@@ -116,40 +133,60 @@
+ {#if data.owner}
+
+
+ {t.quickLog}
+
+
+
+
+
+
+ {:else}
+
+ {/if}
-
- scanner
+ {t.scanner}
-
- {data.channels.length} of 500 channels programmed, {data.frequencies.length} frequencies
- known.
-
-
- 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.
-
- open banks
+ {t.channelsProgrammed(data.channels.length, data.frequencies.length)}
+ {t.scanHelp}
+ {t.openBanks}
+ {#if !data.owner && data.log.length}
+
+ {/if}
{:else if tab === 'frequencies'}
@@ -160,12 +197,14 @@
{:else if tab === 'iss'}
{:else}
-
+ {#if data.owner}
+
+ {/if}
@@ -173,52 +212,98 @@