up
This commit is contained in:
@@ -41,7 +41,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
||||
No messages recorded yet. Open <strong>Settings → Audio devices & voice keyer</strong> to record F1–F6.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{messages.map((m) => (
|
||||
<button
|
||||
key={m.slot}
|
||||
@@ -50,7 +50,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
||||
onClick={() => onPlay(m.slot)}
|
||||
title={m.has_audio ? `Transmit F${m.slot}${m.label ? ' — ' + m.label : ''} (${m.duration_sec.toFixed(1)}s)` : `F${m.slot} — empty`}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md border px-2 py-1.5 text-left transition-colors',
|
||||
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
|
||||
m.has_audio
|
||||
? 'border-border bg-background hover:border-primary/60 hover:bg-accent/30 cursor-pointer'
|
||||
: 'border-dashed border-border/60 text-muted-foreground/50 cursor-not-allowed',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -106,7 +107,8 @@ function loadPresets(): Record<string, QueryFilter> {
|
||||
try { return JSON.parse(localStorage.getItem(PRESETS_KEY) || '{}'); } catch { return {}; }
|
||||
}
|
||||
function savePresets(p: Record<string, QueryFilter>) {
|
||||
localStorage.setItem(PRESETS_KEY, JSON.stringify(p));
|
||||
// Write-through to the portable DB so presets travel with the data/ folder.
|
||||
writeUiPref(PRESETS_KEY, JSON.stringify(p));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween } from '@/lib/maidenhead';
|
||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||
|
||||
// MainMap — Log4OM-style dual map for the Main tab:
|
||||
// • Left: a world map with the great-circle path drawn from the operator to
|
||||
@@ -15,6 +15,26 @@ interface Props {
|
||||
toGrid: string; // contacted-station grid
|
||||
fromLabel?: string; // operator callsign
|
||||
toLabel?: string; // DX callsign
|
||||
beamAzimuths?: number[]; // antenna heading(s) (deg) → draw a beam lobe each
|
||||
beamWidth?: number; // beamwidth (deg), default 30
|
||||
}
|
||||
|
||||
// unwrapLon makes a lat/lon ring continuous in longitude (each point within
|
||||
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap
|
||||
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
|
||||
function unwrapLon(ring: [number, number][]): [number, number][] {
|
||||
const out: [number, number][] = [];
|
||||
let prev = NaN;
|
||||
for (const [la, lo] of ring) {
|
||||
let lon = lo;
|
||||
if (!Number.isNaN(prev)) {
|
||||
while (lon - prev > 180) lon -= 360;
|
||||
while (lon - prev < -180) lon += 360;
|
||||
}
|
||||
prev = lon;
|
||||
out.push([la, lon]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const CARTO_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||
@@ -31,7 +51,7 @@ function dot(color: string): L.DivIcon {
|
||||
});
|
||||
}
|
||||
|
||||
export function MainMap({ fromGrid, toGrid, fromLabel, toLabel }: Props) {
|
||||
export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth }: Props) {
|
||||
const worldRef = useRef<HTMLDivElement>(null);
|
||||
const locatorRef = useRef<HTMLDivElement>(null);
|
||||
const worldMap = useRef<L.Map | null>(null);
|
||||
@@ -75,6 +95,37 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel }: Props) {
|
||||
const from = gridToLatLon(fromGrid);
|
||||
const to = gridToLatLon(toGrid);
|
||||
|
||||
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
||||
if (from && beamAzimuths && beamAzimuths.length) {
|
||||
const half = (beamWidth ?? 30) / 2;
|
||||
const D = 9000; // lobe length (km)
|
||||
const radial = (b: number): [number, number][] =>
|
||||
Array.from({ length: 14 }, (_, i) => {
|
||||
const d = destinationPoint(from.lat, from.lon, b, (D * (i + 1)) / 14);
|
||||
return [d.lat, d.lon] as [number, number];
|
||||
});
|
||||
for (const az of beamAzimuths) {
|
||||
const arc: [number, number][] = [];
|
||||
for (let b = az - half; b <= az + half + 0.001; b += 2) {
|
||||
const d = destinationPoint(from.lat, from.lon, b, D);
|
||||
arc.push([d.lat, d.lon]);
|
||||
}
|
||||
const ring = unwrapLon([
|
||||
[from.lat, from.lon],
|
||||
...radial(az - half),
|
||||
...arc,
|
||||
...radial(az + half).reverse(),
|
||||
]);
|
||||
L.polygon(ring as L.LatLngExpression[], {
|
||||
color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14,
|
||||
}).addTo(wo);
|
||||
// Boresight (dashed centre line).
|
||||
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
||||
L.polyline(cl as L.LatLngExpression[], { color: '#dc2626', weight: 1.5, opacity: 0.7, dashArray: '5 4' })
|
||||
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Left: world + great-circle arc ──
|
||||
if (from) L.marker([from.lat, from.lon], { icon: dot('#059669'), title: fromLabel || 'Home' }).addTo(wo);
|
||||
if (to) {
|
||||
@@ -108,7 +159,8 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel }: Props) {
|
||||
lm.setView([from.lat, from.lon], 5);
|
||||
}
|
||||
setTimeout(() => { wm.invalidateSize(); lm.invalidateSize(); }, 0);
|
||||
}, [fromGrid, toGrid, fromLabel, toLabel]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth]);
|
||||
|
||||
const path = pathBetween(fromGrid, toGrid);
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// RotorCompass — an azimuthal-equidistant rotor display (à la 4O3A RotorGenius).
|
||||
//
|
||||
// A world map centred on the operator's QTH (north up) fills the dial, ringed by
|
||||
// a green azimuth bezel. A green needle shows the antenna heading (two needles
|
||||
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
|
||||
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
|
||||
// to turn the antenna there.
|
||||
import { useMemo } from 'react';
|
||||
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
|
||||
import { feature } from 'topojson-client';
|
||||
import landTopo from 'world-atlas/land-110m.json';
|
||||
import { Compass, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Decode the coastline outline once (≈110 m simplified land polygons).
|
||||
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
|
||||
const GRATICULE = geoGraticule10();
|
||||
|
||||
interface Props {
|
||||
bearing?: number | null; // short-path azimuth to DX (deg)
|
||||
headings: number[]; // antenna heading(s) — rotor + Ultrabeam pattern
|
||||
centerLat?: number | null; // operator latitude (projection centre)
|
||||
centerLon?: number | null; // operator longitude
|
||||
rotorEnabled?: boolean;
|
||||
onGoto?: (az: number) => void; // click-to-turn
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const SIZE = 168;
|
||||
const C = SIZE / 2;
|
||||
const R = C - 6; // outer bezel radius
|
||||
const MAP_R = R - 6; // map/clip radius (inside the bezel)
|
||||
|
||||
function pt(az: number, radius: number): [number, number] {
|
||||
const a = ((az - 90) * Math.PI) / 180;
|
||||
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
||||
}
|
||||
|
||||
export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
|
||||
const cardinals = useMemo(
|
||||
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
||||
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
||||
[],
|
||||
);
|
||||
|
||||
// Project the world centred on the QTH (north up; antipode at the bezel).
|
||||
const { land, grat } = useMemo(() => {
|
||||
if (centerLat == null || centerLon == null) return { land: '', grat: '' };
|
||||
const proj = geoAzimuthalEquidistant()
|
||||
.rotate([-centerLon, -centerLat])
|
||||
.clipAngle(179.9)
|
||||
.scale(MAP_R / Math.PI)
|
||||
.translate([C, C]);
|
||||
const path = geoPath(proj as any);
|
||||
return { land: path(LAND as any) || '', grat: path(GRATICULE as any) || '' };
|
||||
}, [centerLat, centerLon]);
|
||||
|
||||
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
|
||||
if (!onGoto) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * SIZE - C;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * SIZE - C;
|
||||
let az = (Math.atan2(y, x) * 180) / Math.PI + 90;
|
||||
az = ((az % 360) + 360) % 360;
|
||||
onGoto(Math.round(az));
|
||||
}
|
||||
|
||||
const headLabel = headings.length ? headings[0] : null;
|
||||
|
||||
return (
|
||||
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
|
||||
{/* Header — matches the WinKeyer / Voice keyer panels. */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
||||
<Compass className="size-4 text-primary shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
|
||||
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')}
|
||||
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
|
||||
<div className="flex-1" />
|
||||
<span className="font-mono text-sm font-bold text-emerald-700 tabular-nums">
|
||||
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
||||
</span>
|
||||
{onClose && (
|
||||
<button className="text-muted-foreground hover:text-foreground" title="Hide rotor" onClick={onClose}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center p-2 min-h-0">
|
||||
<svg
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
|
||||
style={{ width: SIZE, height: SIZE }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="rotorDial"><circle cx={C} cy={C} r={MAP_R} /></clipPath>
|
||||
</defs>
|
||||
{/* water + world map, clipped to the dial */}
|
||||
<circle cx={C} cy={C} r={MAP_R} fill="#d3e7f1" />
|
||||
<g clipPath="url(#rotorDial)">
|
||||
{grat && <path d={grat} fill="none" stroke="#9cc0d6" strokeWidth={0.4} opacity={0.7} />}
|
||||
{land && <path d={land} fill="#dfe2cf" stroke="#9aa589" strokeWidth={0.4} />}
|
||||
</g>
|
||||
{/* green azimuth bezel */}
|
||||
<circle cx={C} cy={C} r={R} fill="none" stroke="#16a34a" strokeWidth={5} />
|
||||
|
||||
{/* ticks every 10°, longer at 30° */}
|
||||
{Array.from({ length: 36 }, (_, i) => i * 10).map((d) => {
|
||||
const major = d % 30 === 0;
|
||||
const [x1, y1] = pt(d, MAP_R);
|
||||
const [x2, y2] = pt(d, MAP_R - (major ? 7 : 4));
|
||||
return <line key={d} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#475569" strokeWidth={major ? 1 : 0.6} opacity={0.7} />;
|
||||
})}
|
||||
{/* cardinal labels + degree numbers at 45° */}
|
||||
{cardinals.map(({ d, l }) => {
|
||||
const [x, y] = pt(d, MAP_R - 13);
|
||||
return <text key={l} x={x} y={y} textAnchor="middle" dominantBaseline="central" className="fill-slate-700" style={{ fontSize: l.length > 1 ? 7 : 9, fontWeight: 700 }}>{l}</text>;
|
||||
})}
|
||||
|
||||
{/* DX short-path bearing → small red marker on the bezel */}
|
||||
{bearing != null && (() => { const [x, y] = pt(bearing, MAP_R); return (
|
||||
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
|
||||
); })()}
|
||||
|
||||
{/* antenna heading needle(s) — green; two when bidirectional */}
|
||||
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
|
||||
<g key={i}>
|
||||
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
|
||||
<polygon points={`${x},${y} ${pt(h - 5, MAP_R - 12).join(',')} ${pt(h + 5, MAP_R - 12).join(',')}`} fill="#15803d" opacity={i === 0 ? 1 : 0.55} />
|
||||
</g>
|
||||
); })}
|
||||
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { OperatingPanel } from '@/components/OperatingPanel';
|
||||
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
|
||||
|
||||
@@ -421,8 +422,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||||
const [dvkErr, setDvkErr] = useState('');
|
||||
|
||||
// General behaviour prefs (machine-local, applied live via localStorage).
|
||||
// General behaviour prefs (mirrored to the DB so they travel with data/).
|
||||
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
||||
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||
|
||||
// E-mail / SMTP (send QSO recordings).
|
||||
type EmailCfg = {
|
||||
@@ -2897,7 +2900,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={autofocusWB}
|
||||
onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); localStorage.setItem('opslog.autofocusWB', v ? '1' : '0'); }}
|
||||
onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
@@ -2908,6 +2911,34 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={showBeamMap}
|
||||
onCheckedChange={(c) => { const v = !!c; setShowBeamMap(v); writeUiPref('opslog.showBeamOnMap', v ? '1' : '0'); }}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Show the antenna beam heading on the Main map
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
Draws the beam lobe at the rotor heading (and the opposite/both directions when an Ultrabeam is reversed or bidirectional). Turn off to hide it.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={startEqEnd}
|
||||
onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
QSO start time = end time (log at completion)
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
Sets TIME_ON equal to TIME_OFF — the moment you log the QSO — instead of when you first entered the call. Useful when you call a station for a while: the logged time then matches the other operator's, so LoTW/eQSL confirmations match.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">ClubLog exceptions (DXpedition overrides)</h4>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Radio, Square, Send, Plug, Power, RefreshCw, X } from 'lucide-react';
|
||||
import { Radio, Square, Send, Plug, Power, RefreshCw, X, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -51,8 +51,14 @@ export function WinkeyerPanel({
|
||||
}: Props) {
|
||||
const [cwText, setCwText] = useState('');
|
||||
const [speed, setSpeed] = useState(wpm);
|
||||
// Step the speed (compact +/- control replaces the old slider).
|
||||
const changeSpeed = (delta: number) => {
|
||||
const w = Math.max(5, Math.min(50, speed + delta));
|
||||
setSpeed(w);
|
||||
onSetSpeed(w);
|
||||
};
|
||||
|
||||
// Keep the local speed slider in sync when the device/config changes it.
|
||||
// Keep the local speed in sync when the device/config changes it.
|
||||
useEffect(() => { setSpeed(status.connected ? status.wpm || wpm : wpm); }, [status.wpm, status.connected, wpm]);
|
||||
|
||||
const connected = status.connected;
|
||||
@@ -113,24 +119,10 @@ export function WinkeyerPanel({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 px-3 pb-2 min-h-0 overflow-y-auto">
|
||||
{/* Speed */}
|
||||
<div className="flex flex-col gap-1.5 px-3 pb-2 min-h-0 overflow-y-auto">
|
||||
{/* Live transmitted text (echoed by the keyer) + compact speed stepper. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs w-12 shrink-0">Speed</Label>
|
||||
<input
|
||||
type="range" min={5} max={50} value={speed}
|
||||
onChange={(e) => setSpeed(parseInt(e.target.value, 10))}
|
||||
onMouseUp={() => onSetSpeed(speed)}
|
||||
onTouchEnd={() => onSetSpeed(speed)}
|
||||
disabled={!connected}
|
||||
className="flex-1 accent-primary"
|
||||
/>
|
||||
<span className="font-mono text-sm font-bold w-14 text-right">{speed} wpm</span>
|
||||
</div>
|
||||
|
||||
{/* Live transmitted text (echoed by the keyer as it sends). */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs w-12 shrink-0">TX</Label>
|
||||
<Label className="text-xs w-8 shrink-0">TX</Label>
|
||||
<div className={cn(
|
||||
'flex-1 min-w-0 h-8 rounded-md border border-border bg-muted/30 px-2.5 flex items-center font-mono text-sm tracking-wide truncate',
|
||||
status.busy ? 'text-emerald-700' : 'text-muted-foreground',
|
||||
@@ -138,6 +130,17 @@ export function WinkeyerPanel({
|
||||
{sent || <span className="opacity-50">—</span>}
|
||||
{status.busy && <span className="ml-0.5 animate-pulse">▌</span>}
|
||||
</div>
|
||||
{/* Speed: number + up/down arrows (replaces the slider, saves height). */}
|
||||
<div className="flex items-center gap-1 shrink-0 h-8 rounded-md border border-border bg-muted/20 pl-2 pr-1" title="CW speed (WPM)">
|
||||
<span className="font-mono text-sm font-bold tabular-nums">{speed}</span>
|
||||
<span className="text-[9px] text-muted-foreground">wpm</span>
|
||||
<div className="flex flex-col -my-0.5">
|
||||
<button type="button" disabled={!connected} onClick={() => changeSpeed(+1)} title="Faster"
|
||||
className="text-muted-foreground hover:text-foreground leading-none disabled:opacity-40"><ChevronUp className="size-3.5" /></button>
|
||||
<button type="button" disabled={!connected} onClick={() => changeSpeed(-1)} title="Slower"
|
||||
className="text-muted-foreground hover:text-foreground leading-none disabled:opacity-40"><ChevronDown className="size-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CW text */}
|
||||
@@ -169,8 +172,8 @@ export function WinkeyerPanel({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Macro buttons F1… */}
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{macros.map((m, i) => (
|
||||
<button
|
||||
key={i}
|
||||
@@ -179,12 +182,12 @@ export function WinkeyerPanel({
|
||||
disabled={!connected}
|
||||
title={m.text}
|
||||
className={cn(
|
||||
'flex flex-col items-start rounded-md border border-border px-2 py-1 text-left transition-colors',
|
||||
'flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-left transition-colors',
|
||||
connected ? 'hover:border-primary/60 hover:bg-accent/40' : 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] font-mono text-primary font-semibold">F{i + 1}</span>
|
||||
<span className="text-xs font-medium truncate w-full">{m.label || `Macro ${i + 1}`}</span>
|
||||
<span className="text-[10px] font-mono text-primary font-semibold shrink-0">F{i + 1}</span>
|
||||
<span className="text-xs font-medium truncate">{m.label || `Macro ${i + 1}`}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user