It was sticky inside the scrolling list, and the rows went over it: a menu item carries its own background and its own stacking context, so it wins against a sticky sibling however high the z-index is raised. The menu is a flex column now — a header that never scrolls, and the list scrolling beneath it. Nothing to lose the fight with.
276 lines
14 KiB
TypeScript
276 lines
14 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { Upload, FolderOpen, Loader2, ChevronsUpDown } from 'lucide-react';
|
||
import {
|
||
GetWebPublishConfig, SaveWebPublishConfig, WebPublishColumns,
|
||
TestWebPublishFTP, PublishLogNow, GetWebPublishStatus, PickBackupFolder,
|
||
} from '../../wailsjs/go/main/App';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Checkbox } from '@/components/ui/checkbox';
|
||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||
import {
|
||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuCheckboxItem,
|
||
} from '@/components/ui/dropdown-menu';
|
||
import { useI18n } from '@/lib/i18n';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
type Cfg = {
|
||
enabled: boolean; format: string; folder: string; file_name: string; title: string;
|
||
count: number; interval_min: number; columns: string[];
|
||
ftp_enabled: boolean; ftp_host: string; ftp_port: number; ftp_user: string;
|
||
ftp_password: string; ftp_tls: boolean; ftp_folder: string; ftp_file_name: string;
|
||
};
|
||
type Col = { key: string; header: string; group: string };
|
||
|
||
export function WebPublishPanel() {
|
||
const { t } = useI18n();
|
||
const [cfg, setCfg] = useState<Cfg | null>(null);
|
||
const [cols, setCols] = useState<Col[]>([]);
|
||
// The catalogue is 123 fields, so the picker is a dropdown with a search box
|
||
// rather than anything laid out on the page. Chosen columns stay visible above
|
||
// it, in publication order: after picking eight out of a hundred the question
|
||
// stops being 'what exists' and becomes 'what did I pick, and how will it print'.
|
||
const [colSearch, setColSearch] = useState('');
|
||
const [busy, setBusy] = useState<'' | 'test' | 'publish'>('');
|
||
const [msg, setMsg] = useState('');
|
||
const [err, setErr] = useState('');
|
||
// Raw text for the two numeric boxes: bound straight to the number they could
|
||
// not be cleared, the same trap as the Recent-QSOs Max field.
|
||
const [countText, setCountText] = useState('');
|
||
const [everyText, setEveryText] = useState('');
|
||
|
||
useEffect(() => {
|
||
(async () => {
|
||
try {
|
||
const [c, k, st] = await Promise.all([GetWebPublishConfig(), WebPublishColumns(), GetWebPublishStatus()]);
|
||
setCfg(c as any);
|
||
setCols((k ?? []) as Col[]);
|
||
setCountText(String((c as any).count ?? 100));
|
||
setEveryText(String((c as any).interval_min ?? 0));
|
||
const s: any = st;
|
||
if (s?.last_err) setErr(s.last_err);
|
||
else if (s?.last_run) setMsg(t('wpub.lastRun') + ' ' + s.last_run);
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
})();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
if (!cfg) return <p className="text-xs text-muted-foreground">…</p>;
|
||
const set = (p: Partial<Cfg>) => setCfg((c) => (c ? { ...c, ...p } : c));
|
||
|
||
// Every control saves immediately: this panel has no Save button of its own,
|
||
// matching the other "saved instantly" panels.
|
||
const save = async (next: Cfg) => {
|
||
setCfg(next);
|
||
try { await SaveWebPublishConfig(next as any); setErr(''); }
|
||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
};
|
||
const patch = (p: Partial<Cfg>) => save({ ...cfg, ...p });
|
||
|
||
const toggleCol = (key: string) => {
|
||
const has = cfg.columns?.includes(key);
|
||
patch({ columns: has ? cfg.columns.filter((c) => c !== key) : [...(cfg.columns ?? []), key] });
|
||
};
|
||
|
||
const run = async (what: 'test' | 'publish') => {
|
||
setBusy(what); setMsg(''); setErr('');
|
||
try {
|
||
const r = what === 'test' ? await TestWebPublishFTP(cfg as any) : await PublishLogNow();
|
||
setMsg(String(r));
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
finally { setBusy(''); }
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4 max-w-3xl">
|
||
<p className="text-xs text-muted-foreground leading-relaxed">{t('wpub.hint')}</p>
|
||
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={cfg.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} />
|
||
{t('wpub.enable')}
|
||
</label>
|
||
|
||
{cfg.enabled && (<>
|
||
{/* ── The file ── */}
|
||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||
<Label className="text-xs font-semibold">{t('wpub.fileSection')}</Label>
|
||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||
<Label className="text-sm">{t('wpub.format')}</Label>
|
||
<Select value={cfg.format} onValueChange={(v) => patch({ format: v })}>
|
||
<SelectTrigger className="h-8 w-56"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="html">{t('wpub.formatHtml')}</SelectItem>
|
||
<SelectItem value="csv">{t('wpub.formatCsv')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
<Label className="text-sm">{t('wpub.folder')}</Label>
|
||
<div className="flex gap-2">
|
||
<Input className="h-8 font-mono text-xs flex-1" value={cfg.folder}
|
||
onChange={(e) => set({ folder: e.target.value })} onBlur={() => patch({ folder: cfg.folder })} />
|
||
<Button variant="outline" size="sm" className="h-8" onClick={async () => {
|
||
try { const p = await PickBackupFolder(); if (p) patch({ folder: p }); } catch { /* cancelled */ }
|
||
}}><FolderOpen className="size-3.5" /> {t('wpub.browse')}</Button>
|
||
</div>
|
||
|
||
<Label className="text-sm">{t('wpub.fileName')}</Label>
|
||
<Input className="h-8 w-56 font-mono text-xs" value={cfg.file_name}
|
||
onChange={(e) => set({ file_name: e.target.value })} onBlur={() => patch({ file_name: cfg.file_name })} />
|
||
|
||
<Label className="text-sm">{t('wpub.title')}</Label>
|
||
<Input className="h-8" placeholder={t('wpub.titlePh')} value={cfg.title}
|
||
onChange={(e) => set({ title: e.target.value })} onBlur={() => patch({ title: cfg.title })} />
|
||
|
||
<Label className="text-sm">{t('wpub.count')}</Label>
|
||
<Input type="number" min={1} max={100000} className="h-8 w-28 font-mono text-xs" value={countText}
|
||
onChange={(e) => setCountText(e.target.value)}
|
||
onBlur={() => {
|
||
const n = Math.floor(Number(countText));
|
||
if (Number.isFinite(n) && n > 0) patch({ count: n }); else setCountText(String(cfg.count));
|
||
}} />
|
||
|
||
<Label className="text-sm">{t('wpub.every')}</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Input type="number" min={0} max={1440} className="h-8 w-28 font-mono text-xs" value={everyText}
|
||
onChange={(e) => setEveryText(e.target.value)}
|
||
onBlur={() => {
|
||
const n = Math.floor(Number(everyText));
|
||
if (Number.isFinite(n) && n >= 0) patch({ interval_min: n }); else setEveryText(String(cfg.interval_min));
|
||
}} />
|
||
<span className="text-xs text-muted-foreground">{t('wpub.everyHint')}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Columns ── */}
|
||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||
<div className="flex items-center justify-between">
|
||
<Label className="text-xs font-semibold">{t('wpub.columns')}</Label>
|
||
<span className="text-[11px] text-muted-foreground">
|
||
{t('wpub.columnsCount', { n: cfg.columns?.length ?? 0, total: cols.length })}
|
||
</span>
|
||
</div>
|
||
|
||
{/* CHOSEN, in publication order — this is the list that answers "what
|
||
will the page look like", which a sectioned catalogue cannot. */}
|
||
{(cfg.columns?.length ?? 0) > 0 && (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{cfg.columns.map((k) => {
|
||
const c = cols.find((x) => x.key === k);
|
||
return (
|
||
<button key={k} type="button" onClick={() => toggleCol(k)}
|
||
title={t('wpub.removeColumn')}
|
||
className="px-2 py-0.5 rounded-full border border-primary bg-primary text-primary-foreground text-[11px] font-medium">
|
||
{c?.header ?? k} ×
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* One dropdown, alphabetical, filtered as you type. The catalogue is
|
||
123 fields: laid out on the page it buries every other setting, and
|
||
sorting by anything but the alphabet means hunting. The menu stays
|
||
open while ticking — picking eight columns should be one visit. */}
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="outline" size="sm" className="w-full justify-between h-8 text-xs font-normal">
|
||
{t('wpub.columnsPick')}
|
||
<ChevronsUpDown className="size-3.5 opacity-60" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
{/* The search box is OUTSIDE the scrolling area, not sticky inside it.
|
||
Sticky kept it in place but the rows scrolled over it: a menu item
|
||
carries its own background and its own stacking, so it wins over a
|
||
sticky sibling however high its z-index. A header that never
|
||
scrolls has nothing to lose the fight with. */}
|
||
<DropdownMenuContent align="start" className="w-72 p-0 flex flex-col max-h-80">
|
||
<div className="p-1.5 border-b border-border/60 bg-popover shrink-0">
|
||
<Input className="h-7 text-xs" placeholder={t('wpub.columnsSearch')}
|
||
value={colSearch}
|
||
onChange={(e) => setColSearch(e.target.value)}
|
||
onKeyDown={(e) => e.stopPropagation()} />
|
||
</div>
|
||
<div className="overflow-y-auto p-1">
|
||
{[...cols]
|
||
.sort((a, b) => a.header.localeCompare(b.header))
|
||
.filter((c) => {
|
||
const q = colSearch.trim().toLowerCase();
|
||
return !q || c.header.toLowerCase().includes(q) || c.key.toLowerCase().includes(q);
|
||
})
|
||
.map((c) => (
|
||
<DropdownMenuCheckboxItem
|
||
key={c.key}
|
||
checked={cfg.columns?.includes(c.key) ?? false}
|
||
onCheckedChange={() => toggleCol(c.key)}
|
||
onSelect={(e) => e.preventDefault()}
|
||
className="text-xs"
|
||
>
|
||
{c.header}
|
||
<span className="ml-auto pl-2 text-[10px] text-muted-foreground font-mono">{c.group}</span>
|
||
</DropdownMenuCheckboxItem>
|
||
))}
|
||
</div>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
<p className="text-[11px] text-muted-foreground">{t('wpub.columnsHint')}</p>
|
||
</div>
|
||
|
||
{/* ── Upload ── */}
|
||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={cfg.ftp_enabled} onCheckedChange={(c) => patch({ ftp_enabled: !!c })} />
|
||
{t('wpub.ftpEnable')}
|
||
</label>
|
||
{cfg.ftp_enabled && (
|
||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||
<Label className="text-sm">{t('wpub.ftpHost')}</Label>
|
||
<div className="flex gap-2">
|
||
<Input className="h-8 flex-1" placeholder="ftp.example.com" value={cfg.ftp_host}
|
||
onChange={(e) => set({ ftp_host: e.target.value })} onBlur={() => patch({ ftp_host: cfg.ftp_host })} />
|
||
<Input type="number" className="h-8 w-24 font-mono text-xs" value={cfg.ftp_port}
|
||
onChange={(e) => set({ ftp_port: Number(e.target.value) || 21 })}
|
||
onBlur={() => patch({ ftp_port: cfg.ftp_port })} />
|
||
</div>
|
||
<Label className="text-sm">{t('wpub.ftpUser')}</Label>
|
||
<Input className="h-8" value={cfg.ftp_user}
|
||
onChange={(e) => set({ ftp_user: e.target.value })} onBlur={() => patch({ ftp_user: cfg.ftp_user })} />
|
||
<Label className="text-sm">{t('wpub.ftpPassword')}</Label>
|
||
<Input type="password" className="h-8" value={cfg.ftp_password}
|
||
onChange={(e) => set({ ftp_password: e.target.value })} onBlur={() => patch({ ftp_password: cfg.ftp_password })} />
|
||
<Label className="text-sm">{t('wpub.ftpFolder')}</Label>
|
||
<Input className="h-8 font-mono text-xs" placeholder="/www/log" value={cfg.ftp_folder}
|
||
onChange={(e) => set({ ftp_folder: e.target.value })} onBlur={() => patch({ ftp_folder: cfg.ftp_folder })} />
|
||
<Label className="text-sm">{t('wpub.ftpFileName')}</Label>
|
||
<Input className="h-8 w-56 font-mono text-xs" value={cfg.ftp_file_name}
|
||
onChange={(e) => set({ ftp_file_name: e.target.value })} onBlur={() => patch({ ftp_file_name: cfg.ftp_file_name })} />
|
||
<span />
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={cfg.ftp_tls} onCheckedChange={(c) => patch({ ftp_tls: !!c })} />
|
||
{t('wpub.ftpTls')}
|
||
</label>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3 border-t border-border/60 pt-3 flex-wrap">
|
||
<Button size="sm" onClick={() => run('publish')} disabled={busy !== ''}>
|
||
{busy === 'publish' ? <Loader2 className="size-3.5 animate-spin" /> : <Upload className="size-3.5" />}
|
||
{t('wpub.publishNow')}
|
||
</Button>
|
||
{cfg.ftp_enabled && (
|
||
<Button variant="outline" size="sm" onClick={() => run('test')} disabled={busy !== ''}>
|
||
{busy === 'test' ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||
{t('wpub.testFtp')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</>)}
|
||
|
||
{msg && <p className="text-xs text-success break-all">{msg}</p>}
|
||
{err && <p className="text-xs text-danger break-all">{err}</p>}
|
||
</div>
|
||
);
|
||
}
|