radio: public read-only tracker for Opa (NL/EN toggle, bigger type and tap targets)
Deploy production / deploy (push) Successful in 1m22s
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:
1 parent
e1e2bee590
commit
96d7333fe9
16 files changed
+1061
-420
No files matched your search
@@ -31,6 +31,7 @@ const staticPaths = [
|
||||
'/stats/kor/overview',
|
||||
'/trackers/books',
|
||||
'/trackers/ai',
|
||||
'/trackers/radio',
|
||||
'/trackers/spotting',
|
||||
'/trackers/cars',
|
||||
'/trackers/cars/brands',
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||
import PrivateLock from '#lib/components/PrivateLock.svelte';
|
||||
import Tile from '#lib/radio/Tile.svelte';
|
||||
import BandIcon from '#lib/radio/BandIcon.svelte';
|
||||
import UpNext from '#lib/radio/UpNext.svelte';
|
||||
import QuickLog from '#lib/radio/QuickLog.svelte';
|
||||
import FreqList from '#lib/radio/FreqList.svelte';
|
||||
@@ -11,64 +11,53 @@
|
||||
import Nets from '#lib/radio/Nets.svelte';
|
||||
import IssPasses from '#lib/radio/IssPasses.svelte';
|
||||
import LogList from '#lib/radio/LogList.svelte';
|
||||
import { BANDS } from '#lib/radio/index.js';
|
||||
import { DICTS, LANGS, setRadio } from '#lib/radio/i18n.js';
|
||||
import LayoutDashboard from 'lucide-svelte/icons/layout-dashboard';
|
||||
import RadioReceiver from 'lucide-svelte/icons/radio-receiver';
|
||||
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
|
||||
import CalendarClock from 'lucide-svelte/icons/calendar-clock';
|
||||
import Satellite from 'lucide-svelte/icons/satellite';
|
||||
import NotebookPen from 'lucide-svelte/icons/notebook-pen';
|
||||
import Languages from 'lucide-svelte/icons/languages';
|
||||
import { untrack, type Component } from 'svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
// Owner flag and language for every radio component (getters, so they follow navigation).
|
||||
setRadio({
|
||||
get owner() {
|
||||
return data.owner;
|
||||
},
|
||||
get lang() {
|
||||
return data.lang;
|
||||
}
|
||||
});
|
||||
const t = $derived(DICTS[data.lang]);
|
||||
|
||||
type Tab = 'overview' | 'frequencies' | 'banks' | 'nets' | 'iss' | 'log';
|
||||
type Icon = Component<{ size?: number; strokeWidth?: number }>;
|
||||
const TABS: { id: Tab; label: string; icon: Icon; c: [string, string] }[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'overview',
|
||||
icon: LayoutDashboard as unknown as Icon,
|
||||
c: ['#f43f5e', '#881337']
|
||||
},
|
||||
{
|
||||
id: 'frequencies',
|
||||
label: 'frequencies',
|
||||
icon: RadioReceiver as unknown as Icon,
|
||||
c: ['#a78bfa', '#5b21b6']
|
||||
},
|
||||
{
|
||||
id: 'banks',
|
||||
label: 'scanner banks',
|
||||
icon: LayoutGrid as unknown as Icon,
|
||||
c: ['#38bdf8', '#0369a1']
|
||||
},
|
||||
{
|
||||
id: 'nets',
|
||||
label: 'nets',
|
||||
icon: CalendarClock as unknown as Icon,
|
||||
c: ['#fbbf24', '#b45309']
|
||||
},
|
||||
{
|
||||
id: 'iss',
|
||||
label: 'ISS passes',
|
||||
icon: Satellite as unknown as Icon,
|
||||
c: ['#2dd4bf', '#0f766e']
|
||||
},
|
||||
{ id: 'log', label: 'log', icon: NotebookPen as unknown as Icon, c: ['#34d399', '#047857'] }
|
||||
const TABS: { id: Tab; icon: Icon; c: [string, string] }[] = [
|
||||
{ id: 'overview', icon: LayoutDashboard as unknown as Icon, c: ['#f43f5e', '#881337'] },
|
||||
{ id: 'frequencies', icon: RadioReceiver as unknown as Icon, c: ['#a78bfa', '#5b21b6'] },
|
||||
{ id: 'banks', icon: LayoutGrid as unknown as Icon, c: ['#38bdf8', '#0369a1'] },
|
||||
{ id: 'nets', icon: CalendarClock as unknown as Icon, c: ['#fbbf24', '#b45309'] },
|
||||
{ id: 'iss', icon: Satellite as unknown as Icon, c: ['#2dd4bf', '#0f766e'] },
|
||||
{ id: 'log', icon: NotebookPen as unknown as Icon, c: ['#34d399', '#047857'] }
|
||||
];
|
||||
const tab = $derived<Tab>(
|
||||
(TABS.find((t) => t.id === page.url.searchParams.get('tab'))?.id ?? 'overview') as Tab
|
||||
(TABS.find((x) => x.id === page.url.searchParams.get('tab'))?.id ?? 'overview') as Tab
|
||||
);
|
||||
const count = (t: Tab) =>
|
||||
t === 'frequencies'
|
||||
const count = (id: Tab) =>
|
||||
id === 'frequencies'
|
||||
? data.frequencies.length
|
||||
: t === 'banks'
|
||||
: id === 'banks'
|
||||
? data.channels.length
|
||||
: t === 'nets'
|
||||
: id === 'nets'
|
||||
? data.nets.length
|
||||
: t === 'iss'
|
||||
: id === 'iss'
|
||||
? data.passes.filter((p) => p.maxEl >= 10).length
|
||||
: t === 'log'
|
||||
: id === 'log'
|
||||
? data.logTotal
|
||||
: null;
|
||||
|
||||
@@ -76,38 +65,66 @@
|
||||
let now = $state(untrack(() => data.now));
|
||||
$effect(() => {
|
||||
now = Date.now();
|
||||
const t = setInterval(() => (now = Date.now()), 30_000);
|
||||
return () => clearInterval(t);
|
||||
const i = setInterval(() => (now = Date.now()), 30_000);
|
||||
return () => clearInterval(i);
|
||||
});
|
||||
|
||||
const logMessage = $derived(form?.action === 'log' ? (form.message ?? null) : null);
|
||||
const logError = $derived(form?.action === 'log' && !form.ok);
|
||||
const logFreq = $derived(Number(page.url.searchParams.get('f')) || null);
|
||||
const tabHref = (t: Tab) =>
|
||||
resolve((t === 'overview' ? '/trackers/radio' : `/trackers/radio?tab=${t}`) as '/');
|
||||
const tabHref = (id: Tab) =>
|
||||
resolve((id === 'overview' ? '/trackers/radio' : `/trackers/radio?tab=${id}`) as '/');
|
||||
/** The current URL with another language (the server remembers it in a cookie). */
|
||||
const langHref = (l: string) => {
|
||||
const keep = [...page.url.searchParams].filter(([k]) => k !== 'lang');
|
||||
const q = [...keep, ['lang', l]]
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||
.join('&');
|
||||
return resolve(`/trackers/radio?${q}` as '/');
|
||||
};
|
||||
const bandCounts = $derived(
|
||||
BANDS.map((b) => ({ band: b, n: data.frequencies.filter((f) => f.band === b).length })).filter(
|
||||
(x) => x.n
|
||||
)
|
||||
);
|
||||
const freqsHref = (band: string) =>
|
||||
resolve(`/trackers/radio?tab=frequencies&band=${band}` as '/');
|
||||
</script>
|
||||
|
||||
<div class="radio">
|
||||
<div class="radio" lang={data.lang}>
|
||||
<header class="head">
|
||||
<a href={resolve('/trackers/radio')} class="title-link"
|
||||
><TrackerIcon name="radio" class="size-10" /></a
|
||||
<a href={resolve('/trackers/radio')} class="title-link" aria-label={t.title}
|
||||
><TrackerIcon name="radio" class="size-12" /></a
|
||||
>
|
||||
<div class="title">
|
||||
<h1>radio<PrivateLock size={14} /></h1>
|
||||
<p>Frequencies, scanner banks, nets, ISS passes over Gouda and what I heard.</p>
|
||||
<h1>{t.title}</h1>
|
||||
<p>{data.owner ? t.intro : t.introPublic}</p>
|
||||
</div>
|
||||
<nav class="lang" aria-label={t.language}>
|
||||
<span class="lang-icon" aria-hidden="true"><Languages size={18} /></span>
|
||||
{#each LANGS as l (l)}
|
||||
<a
|
||||
href={langHref(l)}
|
||||
hreflang={l}
|
||||
class={{ active: data.lang === l }}
|
||||
aria-current={data.lang === l ? 'true' : undefined}
|
||||
data-sveltekit-noscroll
|
||||
data-sveltekit-replacestate>{l === 'nl' ? 'NL' : 'EN'}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" aria-label="Radio tracker sections">
|
||||
{#each TABS as t (t.id)}
|
||||
{@const n = count(t.id)}
|
||||
<nav class="tabs" aria-label={t.tabsLabel}>
|
||||
{#each TABS as x (x.id)}
|
||||
{@const n = count(x.id)}
|
||||
<a
|
||||
href={tabHref(t.id)}
|
||||
class={{ active: tab === t.id }}
|
||||
aria-current={tab === t.id ? 'page' : undefined}
|
||||
><Tile icon={t.icon} from={t.c[0]} to={t.c[1]} />{t.label}{#if n !== null}<span class="n"
|
||||
>{n}</span
|
||||
>{/if}</a
|
||||
href={tabHref(x.id)}
|
||||
class={{ active: tab === x.id }}
|
||||
aria-current={tab === x.id ? 'page' : undefined}
|
||||
><Tile icon={x.icon} from={x.c[0]} to={x.c[1]} size={28} /><span class="lbl"
|
||||
>{t.tabs[x.id]}</span
|
||||
>{#if n !== null}<span class="n">{n}</span>{/if}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -116,40 +133,60 @@
|
||||
<div class="overview">
|
||||
<section class="card">
|
||||
<h2 class="card-title">
|
||||
<Tile icon={TABS[3].icon} from="#fbbf24" to="#b45309" size={18} />up next
|
||||
<Tile icon={TABS[3].icon} from="#fbbf24" to="#b45309" size={24} />{t.upNext}
|
||||
</h2>
|
||||
<UpNext nets={data.nets} passes={data.passes} frequencies={data.frequencies} {now} />
|
||||
</section>
|
||||
{#if data.owner}
|
||||
<section class="card">
|
||||
<h2 class="card-title">
|
||||
<Tile icon={TABS[5].icon} from="#34d399" to="#047857" size={24} />{t.quickLog}
|
||||
</h2>
|
||||
<QuickLog frequencies={data.frequencies} message={logMessage} error={logError} />
|
||||
<div class="recent">
|
||||
<LogList log={data.log} frequencies={data.frequencies} {now} limit={5} />
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="card">
|
||||
<h2 class="card-title">
|
||||
<Tile icon={TABS[1].icon} from="#a78bfa" to="#5b21b6" size={24} />{t.byBand}
|
||||
</h2>
|
||||
<ul class="bands">
|
||||
{#each bandCounts as b (b.band)}
|
||||
<li>
|
||||
<a href={freqsHref(b.band)}
|
||||
><BandIcon band={b.band} size={32} /><span class="bn">{t.bands[b.band]}</span
|
||||
><span class="bc">{b.n}</span></a
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
<section class="card">
|
||||
<h2 class="card-title">
|
||||
<Tile icon={TABS[5].icon} from="#34d399" to="#047857" size={18} />quick log
|
||||
</h2>
|
||||
<QuickLog frequencies={data.frequencies} message={logMessage} error={logError} />
|
||||
<div class="recent">
|
||||
<LogList log={data.log} frequencies={data.frequencies} {now} limit={5} />
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2 class="card-title">
|
||||
<Tile icon={TABS[4].icon} from="#2dd4bf" to="#0f766e" size={18} />next ISS passes
|
||||
<a class="more" href={tabHref('iss')}>all</a>
|
||||
<Tile icon={TABS[4].icon} from="#2dd4bf" to="#0f766e" size={24} />{t.nextIss}
|
||||
<a class="more" href={tabHref('iss')}>{t.all}</a>
|
||||
</h2>
|
||||
<IssPasses passes={data.passes} {now} limit={4} compact />
|
||||
</section>
|
||||
<section class="card stats">
|
||||
<h2 class="card-title">
|
||||
<Tile icon={TABS[2].icon} from="#38bdf8" to="#0369a1" size={18} />scanner
|
||||
<Tile icon={TABS[2].icon} from="#38bdf8" to="#0369a1" size={24} />{t.scanner}
|
||||
</h2>
|
||||
<p>
|
||||
<b>{data.channels.length}</b> of 500 channels programmed, <b>{data.frequencies.length}</b> frequencies
|
||||
known.
|
||||
</p>
|
||||
<p class="meta">
|
||||
Airband: banks 4–6 (scan 4 5 6). Marine: banks 7–10 (press SCAN, then 7 8 9 0). Uniden
|
||||
UBC125XLT layout; check the label on the scanner to confirm the model.
|
||||
</p>
|
||||
<a class="ghost" href={tabHref('banks')}>open banks</a>
|
||||
<p>{t.channelsProgrammed(data.channels.length, data.frequencies.length)}</p>
|
||||
<p class="meta">{t.scanHelp}</p>
|
||||
<a class="ghost" href={tabHref('banks')}>{t.openBanks}</a>
|
||||
</section>
|
||||
{#if !data.owner && data.log.length}
|
||||
<section class="card">
|
||||
<h2 class="card-title">
|
||||
<Tile icon={TABS[5].icon} from="#34d399" to="#047857" size={24} />{t.recentLog}
|
||||
</h2>
|
||||
<LogList log={data.log} frequencies={data.frequencies} {now} limit={5} />
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab === 'frequencies'}
|
||||
<FreqList frequencies={data.frequencies} />
|
||||
@@ -160,12 +197,14 @@
|
||||
{:else if tab === 'iss'}
|
||||
<section class="card"><IssPasses passes={data.passes} tle={data.tle} {now} /></section>
|
||||
{:else}
|
||||
<section class="card">
|
||||
<QuickLog frequencies={data.frequencies} message={logMessage} error={logError} />
|
||||
</section>
|
||||
{#if data.owner}
|
||||
<section class="card">
|
||||
<QuickLog frequencies={data.frequencies} message={logMessage} error={logError} />
|
||||
</section>
|
||||
{/if}
|
||||
<section class="card">
|
||||
{#if logFreq}<p class="meta">
|
||||
Showing one frequency. <a href={tabHref('log')} class="u">Show all</a>
|
||||
{t.oneFreq} <a href={tabHref('log')} class="u">{t.showAllLog}</a>
|
||||
</p>{/if}
|
||||
<LogList log={data.log} frequencies={data.frequencies} {now} frequencyId={logFreq} />
|
||||
</section>
|
||||
@@ -173,52 +212,98 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Built to be read by an older visitor on a phone or tablet: big type, strong contrast, large
|
||||
tap targets. Black-and-white base, colour only in the icon tiles. */
|
||||
.radio {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 1.1rem;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
gap: 0.9rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.title-link {
|
||||
display: inline-flex;
|
||||
}
|
||||
.title {
|
||||
flex: 1 1 16rem;
|
||||
min-width: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
font-size: 2.25rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.title p {
|
||||
color: var(--site-text);
|
||||
opacity: 0.8;
|
||||
font-size: 1.05rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.lang {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.lang-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #60a5fa, #1d4ed8);
|
||||
}
|
||||
.lang a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 3rem;
|
||||
min-height: 2.75rem;
|
||||
padding: 0 0.8rem;
|
||||
border-radius: 999px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--site-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.lang a:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.lang a.active {
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
padding-bottom: 0.6rem;
|
||||
padding-bottom: 0.8rem;
|
||||
}
|
||||
.tabs a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0.6rem 0.25rem 0.25rem;
|
||||
gap: 0.5rem;
|
||||
min-height: 3rem;
|
||||
padding: 0.3rem 0.9rem 0.3rem 0.35rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.83rem;
|
||||
color: var(--site-muted);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--site-text);
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color 0.15s,
|
||||
@@ -226,7 +311,6 @@
|
||||
background 0.15s;
|
||||
}
|
||||
.tabs a:hover {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.tabs a.active {
|
||||
@@ -235,7 +319,7 @@
|
||||
color: var(--site-bg);
|
||||
}
|
||||
.n {
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -246,54 +330,86 @@
|
||||
align-items: start;
|
||||
}
|
||||
.recent {
|
||||
margin-top: 0.4rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
.more {
|
||||
margin-left: auto;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--site-text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
padding: 0.4rem 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.more:hover,
|
||||
.u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.stats p + p {
|
||||
margin-top: 0.4rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.stats .ghost {
|
||||
margin-top: 0.7rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
.bands {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(12.5rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.bands a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
min-height: 3.5rem;
|
||||
padding: 0.5rem 0.75rem 0.5rem 0.5rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-bg);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.bands a:hover {
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.bn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.bc {
|
||||
font-weight: 650;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Shared building blocks for the section components. */
|
||||
.radio :global(.card) {
|
||||
padding: 0.9rem;
|
||||
padding: 1.1rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
border-radius: 0.9rem;
|
||||
background: var(--site-surface);
|
||||
min-width: 0;
|
||||
}
|
||||
.radio :global(.card-title) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--site-muted);
|
||||
margin-bottom: 0.6rem;
|
||||
gap: 0.6rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 650;
|
||||
color: var(--site-text);
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
.radio :global(.bar) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
gap: 0.7rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.radio :global(.meta),
|
||||
.radio :global(.empty) {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.82rem;
|
||||
color: var(--site-text);
|
||||
opacity: 0.75;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.radio :global(.empty) {
|
||||
padding: 1rem 0;
|
||||
@@ -305,12 +421,13 @@
|
||||
.radio :global(.primary) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
border-radius: 0.5rem;
|
||||
gap: 0.35rem;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.35rem 1rem;
|
||||
border-radius: 0.6rem;
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
font-size: 0.83rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
@@ -323,23 +440,26 @@
|
||||
.radio :global(.ghost) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 0.5rem;
|
||||
gap: 0.4rem;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.35rem 1rem;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid var(--site-border);
|
||||
color: var(--site-muted);
|
||||
font-size: 0.83rem;
|
||||
color: var(--site-text);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.radio :global(.ghost:hover) {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.radio :global(.icon) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.5rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.3rem;
|
||||
border-radius: 0.4rem;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--site-muted);
|
||||
transition:
|
||||
color 0.15s,
|
||||
@@ -354,25 +474,27 @@
|
||||
background: rgb(248 113 113 / 0.1);
|
||||
}
|
||||
.radio :global(.tag) {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.05rem 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.05rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--site-border);
|
||||
color: var(--site-muted);
|
||||
color: var(--site-text);
|
||||
opacity: 0.85;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.radio :global(.chip) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.6rem 0.2rem 0.25rem;
|
||||
gap: 0.45rem;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.25rem 0.85rem 0.25rem 0.35rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
font-size: 1rem;
|
||||
color: var(--site-text);
|
||||
}
|
||||
.radio :global(.chip:first-child) {
|
||||
padding-left: 0.6rem;
|
||||
padding-left: 0.85rem;
|
||||
}
|
||||
.radio :global(.chip.active) {
|
||||
background: var(--site-text);
|
||||
@@ -381,25 +503,25 @@
|
||||
}
|
||||
.radio :global(.err) {
|
||||
color: #f87171;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.95rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
/* Edit forms: a compact grid of labelled fields. */
|
||||
/* Owner edit forms: a compact grid of labelled fields. */
|
||||
.radio :global(.edit) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
padding: 0.75rem;
|
||||
gap: 0.6rem;
|
||||
padding: 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.65rem;
|
||||
border-radius: 0.7rem;
|
||||
background: var(--site-bg);
|
||||
}
|
||||
.radio :global(.edit label) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--site-muted);
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -412,20 +534,20 @@
|
||||
.radio :global(.edit input:not([type='checkbox'])),
|
||||
.radio :global(.edit select),
|
||||
.radio :global(.edit textarea) {
|
||||
height: 2.2rem;
|
||||
padding: 0 0.55rem;
|
||||
height: 2.6rem;
|
||||
padding: 0 0.6rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.45rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--site-surface);
|
||||
color: var(--site-text);
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
font-size: 1rem;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.radio :global(.edit textarea) {
|
||||
height: auto;
|
||||
padding: 0.4rem 0.55rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
resize: vertical;
|
||||
}
|
||||
.radio :global(.edit .actions) {
|
||||
@@ -434,6 +556,11 @@
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.radio :global(a:focus-visible),
|
||||
.radio :global(button:focus-visible) {
|
||||
outline: 3px solid #60a5fa;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.overview {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -442,9 +569,34 @@
|
||||
@media (max-width: 640px) {
|
||||
.radio {
|
||||
padding-top: 1.25rem;
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
.lang {
|
||||
margin-left: auto;
|
||||
}
|
||||
/* Big two-column buttons instead of a sideways-scrolling strip. */
|
||||
.tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.tabs a {
|
||||
min-height: 3.25rem;
|
||||
border-radius: 0.8rem;
|
||||
white-space: normal;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.lbl {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
hyphens: auto;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* No room for the counts next to the bigger labels. */
|
||||
.n {
|
||||
display: none;
|
||||
}
|
||||
.radio :global(.edit) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { channelRows } from '#lib/server/radio.js';
|
||||
import { scannerCsv } from '#lib/radio/index.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// All 500 scanner memories as CSV (Uniden BC125AT/UBC125XLT programming software column order).
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
// All 500 scanner memories as CSV, public like the page (station frequencies only) (Uniden BC125AT/UBC125XLT programming software column order).
|
||||
export const GET: RequestHandler = async () => {
|
||||
const csv = scannerCsv(await channelRows());
|
||||
return new Response(csv, {
|
||||
headers: {
|
||||
'content-type': 'text/csv; charset=utf-8',
|
||||
'content-disposition': 'attachment; filename="ubc125xlt-banks.csv"',
|
||||
'cache-control': 'private, no-store'
|
||||
'cache-control': 'public, max-age=300'
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user