Settings -> Web publishing. Renders the last N QSOs with the columns the operator picks, writes the file locally, and optionally uploads it by FTP or explicit FTPS. Local write FIRST, upload second, always. A network failure then leaves a good file on disk that can be published another way, instead of a truncated one on the server. The local write itself goes to a temp file and renames over the target, so a reader — or a syncing client — never sees a half-written page. The HTML page is fully self-contained: inline CSS, inline sort script, no font, no CDN, no external request at all. It has to work on hosting that blocks third-party requests, and a page about someone's hobby should not report its readers to anyone. Columns are a curated set, not "every ADIF field". This is published to the public: RST and QSL status belong on it, the operator's home address does not. Two triggers, both debounced through one path: a QSO is logged, or the optional timer fires. Fifteen seconds of coalescing means a run of contacts produces one upload rather than one per QSO, and nobody reading a web page can tell the difference. The config is one JSON blob under a single settings key, and that key is marked sensitive: the FTP password lives inside it, so the whole blob is encrypted at rest with the others. A locked vault reads back empty, which correctly reads as "not configured" — publishing must not run with a password it cannot decrypt.
212 lines
10 KiB
TypeScript
212 lines
10 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Upload, FolderOpen, Loader2 } 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 { 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 };
|
|
|
|
export function WebPublishPanel() {
|
|
const { t } = useI18n();
|
|
const [cfg, setCfg] = useState<Cfg | null>(null);
|
|
const [cols, setCols] = useState<Col[]>([]);
|
|
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">
|
|
<Label className="text-xs font-semibold">{t('wpub.columns')}</Label>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{cols.map((c) => {
|
|
const on = cfg.columns?.includes(c.key);
|
|
return (
|
|
<button key={c.key} type="button" onClick={() => toggleCol(c.key)}
|
|
className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
|
|
on ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
|
|
{c.header}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<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>
|
|
);
|
|
}
|