@mytec: iter2.2 ready for testing

This commit is contained in:
2026-01-31 16:16:15 +02:00
parent baf57ad77f
commit f6a39df366
9 changed files with 901 additions and 191 deletions

View File

@@ -27,6 +27,8 @@ import ThemeToggle from '@/components/ui/ThemeToggle.tsx';
import Button from '@/components/ui/Button.tsx';
import NumberInput from '@/components/ui/NumberInput.tsx';
import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx';
import { RegionWizard } from '@/components/RegionWizard.tsx';
import { isDesktop } from '@/lib/desktop.ts';
/**
* Restore a sites snapshot: replace all sites in IndexedDB + Zustand.
@@ -117,6 +119,26 @@ export default function App() {
const [showShortcuts, setShowShortcuts] = useState(false);
const [kbDeleteTarget, setKbDeleteTarget] = useState<{ id: string; name: string } | null>(null);
// Region wizard for first-run (desktop mode only)
const [showWizard, setShowWizard] = useState(false);
useEffect(() => {
if (!isDesktop()) return;
const skipped = localStorage.getItem('rfcp_region_wizard_skipped');
if (skipped) return;
api.getRegions()
.then((regions) => {
const hasDownloaded = regions.some((r) => r.downloaded);
if (!hasDownloaded) {
setShowWizard(true);
}
})
.catch(() => {
// Backend not ready yet, skip wizard
});
}, []);
// Resizable sidebar
const PANEL_MIN = 300;
const PANEL_MAX = 600;
@@ -1084,6 +1106,11 @@ export default function App() {
)}
<ToastContainer />
{/* First-run region download wizard (desktop only) */}
{showWizard && (
<RegionWizard onComplete={() => setShowWizard(false)} />
)}
</div>
);
}

View File

@@ -0,0 +1,165 @@
import { useState, useEffect, useRef } from 'react';
import { api } from '@/services/api.ts';
import type { RegionInfo, DownloadProgress } from '@/services/api.ts';
export function RegionWizard({ onComplete }: { onComplete: () => void }) {
const [regions, setRegions] = useState<RegionInfo[]>([]);
const [selectedRegion, setSelectedRegion] = useState<string | null>(null);
const [downloading, setDownloading] = useState(false);
const [progress, setProgress] = useState<DownloadProgress | null>(null);
const [error, setError] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
api.getRegions()
.then(setRegions)
.catch((err) => {
console.error('Failed to load regions:', err);
setError('Failed to connect to backend');
});
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, []);
const startDownload = async () => {
if (!selectedRegion) return;
setDownloading(true);
setError(null);
try {
const { task_id } = await api.downloadRegion(selectedRegion);
pollRef.current = setInterval(async () => {
try {
const prog = await api.getDownloadProgress(task_id);
setProgress(prog);
if (prog.status === 'done') {
if (pollRef.current) clearInterval(pollRef.current);
setDownloading(false);
// Brief delay so user sees "Complete!" before closing
setTimeout(() => onComplete(), 1000);
} else if (prog.status === 'error') {
if (pollRef.current) clearInterval(pollRef.current);
setDownloading(false);
setError(prog.error || 'Download failed');
}
} catch {
// Polling error, keep trying
}
}, 1000);
} catch (err) {
setDownloading(false);
setError(err instanceof Error ? err.message : 'Download failed');
}
};
const skipDownload = () => {
localStorage.setItem('rfcp_region_wizard_skipped', 'true');
onComplete();
};
return (
<div className="fixed inset-0 z-[9999] bg-black/90 flex items-center justify-center">
<div className="bg-slate-900 rounded-xl p-8 max-w-lg w-[90%] text-white shadow-2xl border border-slate-700">
<h1 className="text-3xl font-bold bg-gradient-to-r from-cyan-400 to-emerald-400 bg-clip-text text-transparent">
Welcome to RFCP
</h1>
<h2 className="text-sm text-slate-400 mt-1 mb-5">
RF Coverage Planner
</h2>
<p className="text-sm text-slate-300 mb-5">
Select a region to download for offline use.
This includes terrain elevation and building data.
</p>
{error && (
<div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-sm text-red-300">
{error}
</div>
)}
{!downloading ? (
<>
{/* Region list */}
<div className="space-y-2 mb-6">
{regions.map((region) => (
<button
key={region.id}
onClick={() => setSelectedRegion(region.id)}
className={`w-full flex items-center gap-3 p-3.5 rounded-lg border-2 transition-all text-left ${
selectedRegion === region.id
? 'border-cyan-400 bg-slate-800'
: 'border-transparent bg-slate-800/50 hover:bg-slate-800'
} ${region.downloaded ? 'opacity-60' : ''}`}
>
<div className="flex-1">
<div className="font-medium text-sm">{region.name}</div>
{region.download_progress > 0 && region.download_progress < 100 && (
<div className="text-xs text-slate-400 mt-0.5">
{region.download_progress.toFixed(0)}% cached
</div>
)}
</div>
<div className="text-xs text-slate-400">
~{region.estimated_size_gb} GB
</div>
{region.downloaded && (
<span className="text-xs bg-emerald-500 text-black px-2 py-0.5 rounded font-medium">
Downloaded
</span>
)}
</button>
))}
{regions.length === 0 && !error && (
<div className="text-center py-6 text-slate-400 text-sm">
Loading regions...
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-3">
<button
onClick={startDownload}
disabled={!selectedRegion}
className="flex-1 py-2.5 px-4 rounded-lg font-semibold text-sm text-black bg-gradient-to-r from-cyan-400 to-emerald-400 hover:from-cyan-300 hover:to-emerald-300 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
>
Download Selected Region
</button>
<button
onClick={skipDownload}
className="py-2.5 px-4 rounded-lg text-sm text-slate-400 border border-slate-600 hover:border-slate-400 hover:text-slate-200 transition-all"
>
Skip (Online Mode)
</button>
</div>
</>
) : (
/* Download progress */
<div className="mt-2">
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-cyan-400 to-emerald-400 transition-all duration-300"
style={{ width: `${progress?.progress || 0}%` }}
/>
</div>
<div className="mt-3 text-center text-sm text-slate-400">
{progress?.current_step || 'Starting...'}
</div>
<div className="mt-1 text-center text-xs text-slate-500">
{(progress?.downloaded_mb || 0).toFixed(1)} MB downloaded
{' '}&middot;{' '}
{(progress?.progress || 0).toFixed(0)}%
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -147,6 +147,64 @@ class ApiService {
const data = await response.json();
return data.elevation;
}
// === Region / Caching API ===
async getRegions(): Promise<RegionInfo[]> {
const response = await fetch(`${API_BASE}/api/regions/available`);
if (!response.ok) throw new Error('Failed to fetch regions');
return response.json();
}
async downloadRegion(regionId: string): Promise<{ task_id: string; status: string }> {
const response = await fetch(`${API_BASE}/api/regions/download/${regionId}`, {
method: 'POST',
});
if (!response.ok) throw new Error('Failed to start download');
return response.json();
}
async getDownloadProgress(taskId: string): Promise<DownloadProgress> {
const response = await fetch(`${API_BASE}/api/regions/download/${taskId}/progress`);
if (!response.ok) throw new Error('Failed to get progress');
return response.json();
}
async getCacheStats(): Promise<CacheStats> {
const response = await fetch(`${API_BASE}/api/regions/cache/stats`);
if (!response.ok) throw new Error('Failed to get cache stats');
return response.json();
}
}
// === Region types ===
export interface RegionInfo {
id: string;
name: string;
bbox: number[];
srtm_tiles: number;
estimated_size_gb: number;
downloaded: boolean;
download_progress: number;
}
export interface DownloadProgress {
task_id: string;
region_id: string;
status: string;
progress: number;
current_step: string;
downloaded_mb: number;
error?: string;
}
export interface CacheStats {
terrain_mb: number;
terrain_tiles: number;
buildings_mb: number;
water_mb: number;
vegetation_mb: number;
}
export const api = new ApiService();