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
+2
-2
@@ -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
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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' };
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user