chore: release v0.22.9

This commit is contained in:
2026-08-02 06:40:10 +02:00
parent 296a4a55c0
commit f6cd6e999a
38 changed files with 1308 additions and 297 deletions
+56 -27
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -26,6 +26,13 @@ type Device = { id: string; type: string; name: string; host: string; user?: str
type Relay = { number: number; label: string; on: boolean };
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
// Dashboard geometry: a grid of EQUAL columns whose cards also share a common
// HEIGHT per row — every card stretches to the tallest one beside it. Masonry
// packed tighter but left the row edges ragged, which read as untidy on a wide
// screen; aligned rows are what an operator expects from a dashboard.
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
const GRID_GAP = 16; // px between cards, both axes
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 };
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
@@ -269,6 +276,23 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
// Max columns per row (persisted). "Auto" fills the window; a number caps the
// row so cards wrap onto further lines even when there's horizontal room.
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
// How many columns the dashboard shows. "Auto" fits as many CARD_MIN-wide
// cards as the window allows; an explicit count is honoured as asked, even
// past that width — the operator can see his own screen.
const gridRef = useRef<HTMLDivElement | null>(null);
const [colCount, setColCount] = useState(1);
useLayoutEffect(() => {
if (cols !== 'auto') { setColCount(Number(cols) || 1); return; }
const el = gridRef.current;
if (!el) return;
const measure = () => setColCount(Math.max(1, Math.floor((el.clientWidth + GRID_GAP) / (CARD_MIN + GRID_GAP))));
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [cols]);
// Amplifiers (Settings → Amplifier): shown here so operators without a
// FlexRadio panel still get the controls. EVERY configured amp gets its own
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
@@ -415,10 +439,10 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
);
};
// `wide` cards span the whole dashboard instead of sitting in one masonry
// column. The amplifier and tuner carry meter rows and a channel selector that
// are unreadable squeezed into a ~430px column — they are the same cards the
// FlexRadio panel shows full-width, and they need that room here too.
// `wide` cards take two grid columns instead of one. The amplifier and tuner
// carry meter rows and a channel selector that are unreadable squeezed into a
// single ~430px column — they are the same cards the FlexRadio panel shows
// full-width, and they need that room here too.
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
if (rot.enabled) {
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
@@ -446,7 +470,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
card can sit on a further line even with horizontal room to spare. */}
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
{(['auto', '1', '2', '3', '4'] as const).map((c) => (
{(['auto', '1', '2', '3', '4', '5', '6'] as const).map((c) => (
<button key={c} type="button"
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
@@ -466,31 +490,36 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div>
)}
{/* Masonry dashboard: fixed-width cards flow into balanced CSS columns so
they pack tightly by height (no ragged gaps under short cards). "Auto"
fits as many ~430px columns as the window allows; a fixed count caps the
container width to that many columns. Each card has a grip handle (left
rail) as the drag initiator (the body is full of buttons). */}
<div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance',
...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}>
{/* Dashboard grid: equal columns, and every card stretches to the height of
the tallest one on its row, so the rows line up instead of ending
ragged. One column each, two for the amplifier/tuner whose meter rows
need the room. Each card has a grip handle (left rail) as the drag
initiator, since the body is full of buttons. */}
<div ref={gridRef} style={{
display: 'grid',
gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
gap: `${GRID_GAP}px`,
}}>
{ordered.map((w) => (
// column-span:all lifts a wide card out of the columns and across the
// full container, while keeping it in document order — so drag-reorder
// still works between wide and normal cards. Capped so it stays a card
// and not a banner on an ultra-wide window.
<div key={w.id} className="flex items-stretch break-inside-avoid mb-4"
style={w.wide ? { columnSpan: 'all', maxWidth: '900px' } : undefined}
<div key={w.id}
// A wide card takes two columns — but never more than there are, or
// the grid would grow a phantom column on a narrow window.
style={{ gridColumn: w.wide ? `span ${Math.min(2, colCount)}` : undefined }}
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
<div draggable
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
onDragEnd={() => { dragId.current = null; }}
title={t('station.dragMove')}
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
dragId.current === w.id && 'opacity-60')}>
<GripVertical className="size-4" />
{/* h-full down the chain is what makes the card fill the row height
rather than sit at its natural size in a taller cell. */}
<div className="flex items-stretch h-full">
<div draggable
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
onDragEnd={() => { dragId.current = null; }}
title={t('station.dragMove')}
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
dragId.current === w.id && 'opacity-60')}>
<GripVertical className="size-4" />
</div>
<div className="flex-1 min-w-0 [&>*]:h-full">{w.node}</div>
</div>
<div className="flex-1 min-w-0">{w.node}</div>
</div>
))}
</div>