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.
368 lines
9.1 KiB
Svelte
368 lines
9.1 KiB
Svelte
<script lang="ts">
|
|
// 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 { page } from '$app/state';
|
|
import { resolve } from '$app/paths';
|
|
import BandIcon from './BandIcon.svelte';
|
|
import Download from 'lucide-svelte/icons/download';
|
|
import X from 'lucide-svelte/icons/x';
|
|
import {
|
|
ALPHA_TAG_MAX,
|
|
BANDS,
|
|
BAND_LABEL,
|
|
BANKS,
|
|
PER_BANK,
|
|
bankOf,
|
|
bankRange,
|
|
formatFreq
|
|
} from './index.js';
|
|
import { DICTS, getRadio } from './i18n.js';
|
|
import type { Channel, Frequency } from '#lib/server/radio.js';
|
|
|
|
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;
|
|
let bank = $state(initialCh ? bankOf(initialCh) : Number(page.url.searchParams.get('bank')) || 4);
|
|
let selected = $state<number | null>(initialCh);
|
|
let error = $state<string | null>(null);
|
|
|
|
const byCh = $derived(new Map(channels.map((c) => [c.channel, c])));
|
|
const byId = $derived(new Map(frequencies.map((f) => [f.id, f])));
|
|
const perBank = $derived.by(() => {
|
|
const n = Array.from({ length: BANKS }, () => 0);
|
|
for (const c of channels) n[bankOf(c.channel) - 1]++;
|
|
return n;
|
|
});
|
|
const slots = $derived.by(() => {
|
|
const [a, b] = bankRange(bank);
|
|
return Array.from({ length: b - a + 1 }, (_, i) => a + i);
|
|
});
|
|
const sel = $derived(selected ? byCh.get(selected) : undefined);
|
|
const grouped = $derived(
|
|
BANDS.map((b) => ({ band: b, list: frequencies.filter((f) => f.band === b) })).filter(
|
|
(g) => g.list.length
|
|
)
|
|
);
|
|
</script>
|
|
|
|
<section class="card">
|
|
<div class="bar">
|
|
<div class="banks" role="tablist" aria-label={t.banksLabel}>
|
|
{#each perBank as n, i (i)}
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={bank === i + 1}
|
|
class={['bank', { active: bank === i + 1, empty: !n }]}
|
|
onclick={() => {
|
|
bank = i + 1;
|
|
selected = null;
|
|
}}
|
|
aria-label="{t.bank} {i + 1}: {n}/{PER_BANK}"
|
|
><b>{i + 1}</b><span>{n}/{PER_BANK}</span></button
|
|
>
|
|
{/each}
|
|
</div>
|
|
<a class="ghost" href={resolve('/trackers/radio/banks.csv')} download data-sveltekit-reload
|
|
><Download size={18} />{t.csv}</a
|
|
>
|
|
</div>
|
|
<p class="meta hint">
|
|
{t.bankHint(bank, bankRange(bank)[0], bankRange(bank)[1], channels.length)}
|
|
</p>
|
|
|
|
<div class="grid">
|
|
{#each slots as ch (ch)}
|
|
{@const c = byCh.get(ch)}
|
|
{@const f = c ? byId.get(c.frequencyId) : undefined}
|
|
<button
|
|
type="button"
|
|
class={['slot', { filled: !!f, sel: selected === ch, locked: c?.lockout }]}
|
|
onclick={() => (selected = selected === ch ? null : ch)}
|
|
aria-pressed={selected === ch}
|
|
>
|
|
<span class="no">{ch}</span>
|
|
{#if f && c}
|
|
<BandIcon band={f.band} size={26} />
|
|
<span class="tg">{c.tag}</span>
|
|
<span class="fq">{formatFreq(f.freqHz, false)} <i>{f.mode}</i></span>
|
|
{:else}
|
|
<span class="tg dim">{t.empty}</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
{#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}
|
|
<form
|
|
class="edit chedit"
|
|
method="POST"
|
|
action="?/channel"
|
|
use:enhance={() =>
|
|
async ({ result, update }) => {
|
|
if (result.type === 'failure') error = String(result.data?.message ?? 'Could not save.');
|
|
else {
|
|
error = null;
|
|
await update({ reset: false });
|
|
}
|
|
}}
|
|
>
|
|
<input type="hidden" name="channel" value={selected} />
|
|
<div class="full bar">
|
|
<strong>Channel {selected}</strong>
|
|
<button type="button" class="icon" aria-label="close" onclick={() => (selected = null)}
|
|
><X size={15} /></button
|
|
>
|
|
</div>
|
|
{#key selected}
|
|
<label class="wide"
|
|
>frequency<select name="frequency_id" value={sel?.frequencyId ?? ''} required>
|
|
<option value="" disabled>pick a frequency…</option>
|
|
{#each grouped as g (g.band)}
|
|
<optgroup label={BAND_LABEL[g.band]}>
|
|
{#each g.list as o (o.id)}<option value={o.id}
|
|
>{formatFreq(o.freqHz)} · {o.name}</option
|
|
>{/each}
|
|
</optgroup>
|
|
{/each}
|
|
</select></label
|
|
>
|
|
<label
|
|
>alpha tag<input
|
|
name="alpha_tag"
|
|
maxlength={ALPHA_TAG_MAX}
|
|
value={sel?.alphaTag ?? ''}
|
|
placeholder={sel?.tag ?? 'auto from name'}
|
|
/></label
|
|
>
|
|
<div class="checks">
|
|
<label><input type="checkbox" name="delay" checked={sel?.delay ?? true} />delay</label>
|
|
<label
|
|
><input type="checkbox" name="lockout" checked={sel?.lockout ?? false} />lockout</label
|
|
>
|
|
<label
|
|
><input
|
|
type="checkbox"
|
|
name="priority"
|
|
checked={sel?.priority ?? false}
|
|
/>priority</label
|
|
>
|
|
</div>
|
|
{/key}
|
|
{#if f?.notes}<p class="meta full">{f.notes}</p>{/if}
|
|
<div class="actions full">
|
|
{#if error}<span class="err">{error}</span>{/if}
|
|
{#if sel}<button class="ghost" name="clear" value="1" formnovalidate>clear channel</button
|
|
>{/if}
|
|
<button class="primary">save</button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
</section>
|
|
|
|
<style>
|
|
.banks {
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.bank {
|
|
display: inline-flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 3.4rem;
|
|
min-height: 3.4rem;
|
|
padding: 0.3rem 0.5rem;
|
|
border: 1px solid var(--site-border);
|
|
border-radius: 0.7rem;
|
|
color: var(--site-text);
|
|
line-height: 1.15;
|
|
}
|
|
.bank b {
|
|
font-size: 1.2rem;
|
|
}
|
|
.bank span {
|
|
font-size: 0.78rem;
|
|
opacity: 0.75;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.bank.empty {
|
|
opacity: 0.55;
|
|
}
|
|
.bank.active {
|
|
background: var(--site-text);
|
|
color: var(--site-bg);
|
|
border-color: var(--site-text);
|
|
opacity: 1;
|
|
}
|
|
.bank.active span {
|
|
color: inherit;
|
|
opacity: 0.7;
|
|
}
|
|
.hint {
|
|
margin: 0.5rem 0 0.7rem;
|
|
}
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
|
gap: 0.4rem;
|
|
}
|
|
.slot {
|
|
display: grid;
|
|
grid-template-columns: 2.4rem 26px 1fr;
|
|
grid-template-rows: auto auto;
|
|
column-gap: 0.5rem;
|
|
align-items: center;
|
|
min-height: 3.5rem;
|
|
padding: 0.45rem 0.6rem;
|
|
border: 1px solid var(--site-border);
|
|
border-radius: 0.65rem;
|
|
color: var(--site-text);
|
|
text-align: left;
|
|
background: var(--site-bg);
|
|
min-width: 0;
|
|
font-family: ui-monospace, monospace;
|
|
}
|
|
.slot:hover {
|
|
border-color: var(--site-muted);
|
|
}
|
|
.slot.sel {
|
|
border-color: var(--site-text);
|
|
box-shadow: 0 0 0 1px var(--site-text);
|
|
}
|
|
.slot.locked {
|
|
opacity: 0.5;
|
|
}
|
|
.no {
|
|
grid-row: 1 / 3;
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
opacity: 0.75;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.slot :global(.band-icon) {
|
|
grid-row: 1 / 3;
|
|
}
|
|
.tg {
|
|
font-size: 0.95rem;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
text-transform: uppercase;
|
|
}
|
|
.tg.dim {
|
|
grid-column: 2 / 4;
|
|
font-weight: 400;
|
|
opacity: 0.55;
|
|
text-transform: none;
|
|
}
|
|
.fq {
|
|
grid-column: 3;
|
|
font-size: 0.85rem;
|
|
opacity: 0.8;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.fq i {
|
|
font-style: normal;
|
|
opacity: 0.7;
|
|
}
|
|
.chedit {
|
|
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 {
|
|
display: flex;
|
|
gap: 0.8rem;
|
|
align-items: flex-end;
|
|
font-size: 0.8rem;
|
|
padding-bottom: 0.4rem;
|
|
}
|
|
.checks label {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
}
|
|
@media (max-width: 640px) {
|
|
.grid {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
.bank {
|
|
min-width: 3.1rem;
|
|
}
|
|
}
|
|
</style>
|