@mytec: iter2.2 ready for testing
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
165
frontend/src/components/RegionWizard.tsx
Normal file
165
frontend/src/components/RegionWizard.tsx
Normal 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
|
||||
{' '}·{' '}
|
||||
{(progress?.progress || 0).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user