From 96d7333fe9f00049f816c3f84be134d2ce49a5d3 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Tue, 27 Oct 2026 09:23:22 +0000 Subject: [PATCH] radio: public read-only tracker for Opa (NL/EN toggle, bigger type and tap targets) 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. --- src/lib/projects.ts | 4 +- src/lib/radio/Banks.svelte | 148 ++++-- src/lib/radio/FreqList.svelte | 118 +++-- src/lib/radio/IssPasses.svelte | 117 +++-- src/lib/radio/LogList.svelte | 62 +-- src/lib/radio/Nets.svelte | 120 +++-- src/lib/radio/Signal.svelte | 28 +- src/lib/radio/UpNext.svelte | 72 ++- src/lib/radio/i18n.ts | 286 +++++++++++ src/lib/server/owner.test.ts | 11 +- src/lib/server/owner.ts | 2 - src/lib/server/radio.ts | 19 +- src/routes/sitemap.xml/+server.ts | 1 + src/routes/trackers/radio/+page.server.ts | 41 +- src/routes/trackers/radio/+page.svelte | 444 ++++++++++++------ .../trackers/radio/banks.csv/+server.ts | 8 +- 16 files changed, 1061 insertions(+), 420 deletions(-) create mode 100644 src/lib/radio/i18n.ts 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}

- Gouda (52.011 N, 4.711 E) · listen on {formatFreq(ISS_DOWNLINK_HZ)} NFM - (ARISS voice / SSTV) · - = sunlit in a dark sky, visible to the eye + {t.issMeta} {formatFreq(ISS_DOWNLINK_HZ)} NFM + {t.issMeta2} · + + {t.visibleLegend}

{t.showLow(ISS_MIN_EL)}
{/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}

    - {tle.name} TLE from Celestrak, epoch {tle.epoch ? fmtAgo(tle.epoch) : '?'}, fetched {tle.fetchedAt - ? fmtAgo(tle.fetchedAt) - : '?'} (refreshed daily). Passes of {ISS_MIN_EL}° and up get a reminder {REMIND_MINUTES} min before, - except during quiet hours ({parseQuiet(QUIET_HOURS)?.label}). + {t.tleFoot( + tle.name, + tle.epoch ? h.fmtDateTime(tle.epoch) : '?', + tle.fetchedAt ? h.fmtDateTime(tle.fetchedAt) : '?' + )} + {#if r.owner}Passes of {ISS_MIN_EL}° and up get a phone reminder {REMIND_MINUTES} min before, except + during quiet hours ({parseQuiet(QUIET_HOURS)?.label}).{/if}

    {/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} + + + {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 '/'); -
    +
    -
    -

    radio

    -

    Frequencies, scanner banks, nets, ISS passes over Gouda and what I heard.

    +

    {t.title}

    +

    {data.owner ? t.intro : t.introPublic}

    +
    -