@mytec: 2nd iteration implemented for tests
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMap } from 'react-leaflet';
|
import { useMap } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet.heat';
|
import 'leaflet.heat';
|
||||||
@@ -28,9 +28,39 @@ function rsrpToIntensity(rsrp: number): number {
|
|||||||
return Math.max(0, Math.min(1, (rsrp - min) / (max - min)));
|
return Math.max(0, Math.min(1, (rsrp - min) / (max - min)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate adaptive heatmap radius and blur based on zoom level.
|
||||||
|
* Lower zoom (zoomed out) = larger radius for smoother appearance.
|
||||||
|
* Higher zoom (zoomed in) = smaller radius to avoid blocky squares.
|
||||||
|
*
|
||||||
|
* Zoom 6 (country): radius=35, blur=18
|
||||||
|
* Zoom 10 (region): radius=25, blur=13
|
||||||
|
* Zoom 14 (city): radius=15, blur=8
|
||||||
|
* Zoom 18 (street): radius=8, blur=6
|
||||||
|
*/
|
||||||
|
function getHeatmapParams(zoom: number) {
|
||||||
|
const radius = Math.max(8, Math.min(40, 50 - zoom * 2.5));
|
||||||
|
const blur = Math.max(6, Math.min(20, 30 - zoom * 1.5));
|
||||||
|
return { radius: Math.round(radius), blur: Math.round(blur) };
|
||||||
|
}
|
||||||
|
|
||||||
export default function Heatmap({ points, visible }: HeatmapProps) {
|
export default function Heatmap({ points, visible }: HeatmapProps) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
|
const [mapZoom, setMapZoom] = useState(map.getZoom());
|
||||||
|
|
||||||
|
// Track zoom changes
|
||||||
|
useEffect(() => {
|
||||||
|
const handleZoomEnd = () => {
|
||||||
|
setMapZoom(map.getZoom());
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('zoomend', handleZoomEnd);
|
||||||
|
return () => {
|
||||||
|
map.off('zoomend', handleZoomEnd);
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
// Recreate heatmap layer when points, visibility, or zoom changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || points.length === 0) return;
|
if (!visible || points.length === 0) return;
|
||||||
|
|
||||||
@@ -40,9 +70,11 @@ export default function Heatmap({ points, visible }: HeatmapProps) {
|
|||||||
rsrpToIntensity(p.rsrp),
|
rsrpToIntensity(p.rsrp),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const { radius, blur } = getHeatmapParams(mapZoom);
|
||||||
|
|
||||||
const heatLayer = L.heatLayer(heatData, {
|
const heatLayer = L.heatLayer(heatData, {
|
||||||
radius: 15,
|
radius,
|
||||||
blur: 20,
|
blur,
|
||||||
maxZoom: 17,
|
maxZoom: 17,
|
||||||
max: 1.0,
|
max: 1.0,
|
||||||
minOpacity: 0.3,
|
minOpacity: 0.3,
|
||||||
@@ -61,7 +93,7 @@ export default function Heatmap({ points, visible }: HeatmapProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
map.removeLayer(heatLayer);
|
map.removeLayer(heatLayer);
|
||||||
};
|
};
|
||||||
}, [map, points, visible]);
|
}, [map, points, visible, mapZoom]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'leaflet/dist/leaflet.css';
|
|||||||
import type { Map as LeafletMap } from 'leaflet';
|
import type { Map as LeafletMap } from 'leaflet';
|
||||||
import type { Site } from '@/types/index.ts';
|
import type { Site } from '@/types/index.ts';
|
||||||
import { useSitesStore } from '@/store/sites.ts';
|
import { useSitesStore } from '@/store/sites.ts';
|
||||||
|
import { useSettingsStore } from '@/store/settings.ts';
|
||||||
import SiteMarker from './SiteMarker.tsx';
|
import SiteMarker from './SiteMarker.tsx';
|
||||||
|
|
||||||
interface MapViewProps {
|
interface MapViewProps {
|
||||||
@@ -42,6 +43,8 @@ function MapRefSetter({ mapRef }: { mapRef: React.MutableRefObject<LeafletMap |
|
|||||||
export default function MapView({ onMapClick, onEditSite, children }: MapViewProps) {
|
export default function MapView({ onMapClick, onEditSite, children }: MapViewProps) {
|
||||||
const sites = useSitesStore((s) => s.sites);
|
const sites = useSitesStore((s) => s.sites);
|
||||||
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
|
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
|
||||||
|
const showTerrain = useSettingsStore((s) => s.showTerrain);
|
||||||
|
const setShowTerrain = useSettingsStore((s) => s.setShowTerrain);
|
||||||
const mapRef = useRef<LeafletMap | null>(null);
|
const mapRef = useRef<LeafletMap | null>(null);
|
||||||
|
|
||||||
const handleFitToSites = useCallback(() => {
|
const handleFitToSites = useCallback(() => {
|
||||||
@@ -62,10 +65,19 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
|
|||||||
className={`w-full h-full ${isPlacingMode ? 'cursor-crosshair' : ''}`}
|
className={`w-full h-full ${isPlacingMode ? 'cursor-crosshair' : ''}`}
|
||||||
>
|
>
|
||||||
<MapRefSetter mapRef={mapRef} />
|
<MapRefSetter mapRef={mapRef} />
|
||||||
|
{/* Base OSM layer */}
|
||||||
<TileLayer
|
<TileLayer
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
/>
|
/>
|
||||||
|
{/* Terrain overlay (OpenTopoMap, semi-transparent when enabled) */}
|
||||||
|
{showTerrain && (
|
||||||
|
<TileLayer
|
||||||
|
attribution='Map data: © OpenStreetMap, SRTM | Style: © <a href="https://opentopomap.org">OpenTopoMap</a>'
|
||||||
|
url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"
|
||||||
|
opacity={0.6}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<MapClickHandler onMapClick={onMapClick} />
|
<MapClickHandler onMapClick={onMapClick} />
|
||||||
{sites
|
{sites
|
||||||
.filter((s) => s.visible)
|
.filter((s) => s.visible)
|
||||||
@@ -97,6 +109,16 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
|
|||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTerrain(!showTerrain)}
|
||||||
|
className={`bg-white dark:bg-dark-surface shadow-lg rounded px-3 py-2 text-sm
|
||||||
|
hover:bg-gray-50 dark:hover:bg-dark-border transition-colors
|
||||||
|
text-gray-700 dark:text-dark-text min-h-[36px]
|
||||||
|
${showTerrain ? 'ring-2 ring-blue-500' : ''}`}
|
||||||
|
title={showTerrain ? 'Hide terrain' : 'Show terrain elevation'}
|
||||||
|
>
|
||||||
|
Topo
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
96
frontend/src/components/panels/BatchEdit.tsx
Normal file
96
frontend/src/components/panels/BatchEdit.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useSitesStore } from '@/store/sites.ts';
|
||||||
|
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||||
|
import Button from '@/components/ui/Button.tsx';
|
||||||
|
|
||||||
|
export default function BatchEdit() {
|
||||||
|
const selectedSiteIds = useSitesStore((s) => s.selectedSiteIds);
|
||||||
|
const batchUpdateHeight = useSitesStore((s) => s.batchUpdateHeight);
|
||||||
|
const batchSetHeight = useSitesStore((s) => s.batchSetHeight);
|
||||||
|
const clearSelection = useSitesStore((s) => s.clearSelection);
|
||||||
|
const addToast = useToastStore((s) => s.addToast);
|
||||||
|
|
||||||
|
const [customHeight, setCustomHeight] = useState('');
|
||||||
|
|
||||||
|
if (selectedSiteIds.length === 0) return null;
|
||||||
|
|
||||||
|
const handleAdjustHeight = async (delta: number) => {
|
||||||
|
await batchUpdateHeight(delta);
|
||||||
|
addToast(
|
||||||
|
`Adjusted ${selectedSiteIds.length} site(s) by ${delta > 0 ? '+' : ''}${delta}m`,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetHeight = async () => {
|
||||||
|
const height = parseInt(customHeight, 10);
|
||||||
|
if (isNaN(height) || height < 1 || height > 100) {
|
||||||
|
addToast('Height must be between 1-100m', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await batchSetHeight(height);
|
||||||
|
addToast(`Set ${selectedSiteIds.length} site(s) to ${height}m`, 'success');
|
||||||
|
setCustomHeight('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-200">
|
||||||
|
Batch Edit ({selectedSiteIds.length} selected)
|
||||||
|
</h3>
|
||||||
|
<Button onClick={clearSelection} size="sm" variant="ghost">
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick height adjustments */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5 block">
|
||||||
|
Adjust Height:
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => handleAdjustHeight(10)}>
|
||||||
|
+10m
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => handleAdjustHeight(5)}>
|
||||||
|
+5m
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => handleAdjustHeight(-5)}>
|
||||||
|
-5m
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => handleAdjustHeight(-10)}>
|
||||||
|
-10m
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Set exact height */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5 block">
|
||||||
|
Set Height:
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={customHeight}
|
||||||
|
onChange={(e) => setCustomHeight(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSetHeight()}
|
||||||
|
placeholder="meters"
|
||||||
|
className="flex-1 px-3 py-1.5 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text rounded-md text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSetHeight}
|
||||||
|
disabled={!customHeight}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { Site } from '@/types/index.ts';
|
|||||||
import { useSitesStore } from '@/store/sites.ts';
|
import { useSitesStore } from '@/store/sites.ts';
|
||||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||||
import Button from '@/components/ui/Button.tsx';
|
import Button from '@/components/ui/Button.tsx';
|
||||||
|
import BatchEdit from './BatchEdit.tsx';
|
||||||
|
|
||||||
interface SiteListProps {
|
interface SiteListProps {
|
||||||
onEditSite: (site: Site) => void;
|
onEditSite: (site: Site) => void;
|
||||||
@@ -15,6 +16,10 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
|||||||
const selectedSiteId = useSitesStore((s) => s.selectedSiteId);
|
const selectedSiteId = useSitesStore((s) => s.selectedSiteId);
|
||||||
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
|
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
|
||||||
const togglePlacingMode = useSitesStore((s) => s.togglePlacingMode);
|
const togglePlacingMode = useSitesStore((s) => s.togglePlacingMode);
|
||||||
|
const selectedSiteIds = useSitesStore((s) => s.selectedSiteIds);
|
||||||
|
const toggleSiteSelection = useSitesStore((s) => s.toggleSiteSelection);
|
||||||
|
const selectAllSites = useSitesStore((s) => s.selectAllSites);
|
||||||
|
const clearSelection = useSitesStore((s) => s.clearSelection);
|
||||||
const addToast = useToastStore((s) => s.addToast);
|
const addToast = useToastStore((s) => s.addToast);
|
||||||
|
|
||||||
const handleDelete = async (id: string, name: string) => {
|
const handleDelete = async (id: string, name: string) => {
|
||||||
@@ -22,8 +27,11 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
|||||||
addToast(`"${name}" deleted`, 'info');
|
addToast(`"${name}" deleted`, 'info');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const allSelected = sites.length > 0 && selectedSiteIds.length === sites.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm">
|
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm">
|
||||||
|
{/* Header */}
|
||||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-dark-border flex items-center justify-between">
|
<div className="px-4 py-3 border-b border-gray-200 dark:border-dark-border flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
|
<h2 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
|
||||||
Sites ({sites.length})
|
Sites ({sites.length})
|
||||||
@@ -42,20 +50,61 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Select All row (only when sites exist) */}
|
||||||
|
{sites.length > 0 && (
|
||||||
|
<div className="px-4 py-1.5 border-b border-gray-100 dark:border-dark-border flex items-center justify-between">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer text-xs text-gray-500 dark:text-dark-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={() => (allSelected ? clearSelection() : selectAllSites())}
|
||||||
|
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-dark-border accent-blue-600"
|
||||||
|
/>
|
||||||
|
{allSelected ? 'Deselect All' : 'Select All'}
|
||||||
|
</label>
|
||||||
|
{selectedSiteIds.length > 0 && (
|
||||||
|
<span className="text-xs text-blue-600 dark:text-blue-400">
|
||||||
|
{selectedSiteIds.length} selected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Batch edit panel (appears when sites are selected) */}
|
||||||
|
{selectedSiteIds.length > 0 && (
|
||||||
|
<div className="px-3 pt-3">
|
||||||
|
<BatchEdit />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sites list */}
|
||||||
{sites.length === 0 ? (
|
{sites.length === 0 ? (
|
||||||
<div className="p-4 text-center text-sm text-gray-400 dark:text-dark-muted">
|
<div className="p-4 text-center text-sm text-gray-400 dark:text-dark-muted">
|
||||||
No sites yet. Click on the map or use "+ Manual" to add one.
|
No sites yet. Click on the map or use "+ Manual" to add one.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-100 dark:divide-dark-border max-h-60 overflow-y-auto">
|
<div className="divide-y divide-gray-100 dark:divide-dark-border max-h-60 overflow-y-auto">
|
||||||
{sites.map((site) => (
|
{sites.map((site) => {
|
||||||
|
const isBatchSelected = selectedSiteIds.includes(site.id);
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={site.id}
|
key={site.id}
|
||||||
className={`px-4 py-2.5 flex items-center gap-3 cursor-pointer
|
className={`px-4 py-2.5 flex items-center gap-2.5 cursor-pointer
|
||||||
hover:bg-gray-50 dark:hover:bg-dark-border/50 transition-colors
|
hover:bg-gray-50 dark:hover:bg-dark-border/50 transition-colors
|
||||||
${selectedSiteId === site.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`}
|
${selectedSiteId === site.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''}
|
||||||
|
${isBatchSelected && selectedSiteId !== site.id ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}`}
|
||||||
onClick={() => selectSite(site.id)}
|
onClick={() => selectSite(site.id)}
|
||||||
>
|
>
|
||||||
|
{/* Batch checkbox */}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isBatchSelected}
|
||||||
|
onChange={() => toggleSiteSelection(site.id)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-dark-border accent-blue-600 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Color indicator */}
|
{/* Color indicator */}
|
||||||
<div
|
<div
|
||||||
className="w-4 h-4 rounded-full flex-shrink-0 border-2 border-white dark:border-dark-border shadow"
|
className="w-4 h-4 rounded-full flex-shrink-0 border-2 border-white dark:border-dark-border shadow"
|
||||||
@@ -98,7 +147,8 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ type Theme = 'light' | 'dark' | 'system';
|
|||||||
|
|
||||||
interface SettingsState {
|
interface SettingsState {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
showTerrain: boolean;
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
|
setShowTerrain: (show: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(theme: Theme) {
|
function applyTheme(theme: Theme) {
|
||||||
@@ -23,10 +25,12 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
theme: 'system' as Theme,
|
theme: 'system' as Theme,
|
||||||
|
showTerrain: false,
|
||||||
setTheme: (theme: Theme) => {
|
setTheme: (theme: Theme) => {
|
||||||
set({ theme });
|
set({ theme });
|
||||||
applyTheme(theme);
|
applyTheme(theme);
|
||||||
},
|
},
|
||||||
|
setShowTerrain: (show: boolean) => set({ showTerrain: show }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'rfcp-settings',
|
name: 'rfcp-settings',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface SitesState {
|
|||||||
selectedSiteId: string | null;
|
selectedSiteId: string | null;
|
||||||
editingSiteId: string | null;
|
editingSiteId: string | null;
|
||||||
isPlacingMode: boolean;
|
isPlacingMode: boolean;
|
||||||
|
selectedSiteIds: string[];
|
||||||
|
|
||||||
loadSites: () => Promise<void>;
|
loadSites: () => Promise<void>;
|
||||||
addSite: (data: SiteFormData) => Promise<Site>;
|
addSite: (data: SiteFormData) => Promise<Site>;
|
||||||
@@ -22,6 +23,13 @@ interface SitesState {
|
|||||||
setEditingSite: (id: string | null) => void;
|
setEditingSite: (id: string | null) => void;
|
||||||
togglePlacingMode: () => void;
|
togglePlacingMode: () => void;
|
||||||
setPlacingMode: (val: boolean) => void;
|
setPlacingMode: (val: boolean) => void;
|
||||||
|
|
||||||
|
// Batch operations
|
||||||
|
toggleSiteSelection: (siteId: string) => void;
|
||||||
|
selectAllSites: () => void;
|
||||||
|
clearSelection: () => void;
|
||||||
|
batchUpdateHeight: (adjustment: number) => Promise<void>;
|
||||||
|
batchSetHeight: (height: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSitesStore = create<SitesState>((set, get) => ({
|
export const useSitesStore = create<SitesState>((set, get) => ({
|
||||||
@@ -29,6 +37,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
selectedSiteId: null,
|
selectedSiteId: null,
|
||||||
editingSiteId: null,
|
editingSiteId: null,
|
||||||
isPlacingMode: false,
|
isPlacingMode: false,
|
||||||
|
selectedSiteIds: [],
|
||||||
|
|
||||||
loadSites: async () => {
|
loadSites: async () => {
|
||||||
const dbSites = await db.sites.toArray();
|
const dbSites = await db.sites.toArray();
|
||||||
@@ -96,4 +105,83 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
setEditingSite: (id: string | null) => set({ editingSiteId: id }),
|
setEditingSite: (id: string | null) => set({ editingSiteId: id }),
|
||||||
togglePlacingMode: () => set((s) => ({ isPlacingMode: !s.isPlacingMode })),
|
togglePlacingMode: () => set((s) => ({ isPlacingMode: !s.isPlacingMode })),
|
||||||
setPlacingMode: (val: boolean) => set({ isPlacingMode: val }),
|
setPlacingMode: (val: boolean) => set({ isPlacingMode: val }),
|
||||||
|
|
||||||
|
// Batch operations
|
||||||
|
toggleSiteSelection: (siteId: string) => {
|
||||||
|
set((state) => {
|
||||||
|
const isSelected = state.selectedSiteIds.includes(siteId);
|
||||||
|
return {
|
||||||
|
selectedSiteIds: isSelected
|
||||||
|
? state.selectedSiteIds.filter((id) => id !== siteId)
|
||||||
|
: [...state.selectedSiteIds, siteId],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
selectAllSites: () => {
|
||||||
|
set((state) => ({
|
||||||
|
selectedSiteIds: state.sites.map((s) => s.id),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSelection: () => {
|
||||||
|
set({ selectedSiteIds: [] });
|
||||||
|
},
|
||||||
|
|
||||||
|
batchUpdateHeight: async (adjustment: number) => {
|
||||||
|
const { sites, selectedSiteIds } = get();
|
||||||
|
const selectedSet = new Set(selectedSiteIds);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const updatedSites = sites.map((site) => {
|
||||||
|
if (!selectedSet.has(site.id)) return site;
|
||||||
|
return {
|
||||||
|
...site,
|
||||||
|
height: Math.max(1, Math.min(100, site.height + adjustment)),
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist to IndexedDB
|
||||||
|
const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id));
|
||||||
|
for (const site of toUpdate) {
|
||||||
|
await db.sites.put({
|
||||||
|
id: site.id,
|
||||||
|
data: JSON.stringify(site),
|
||||||
|
createdAt: site.createdAt.getTime(),
|
||||||
|
updatedAt: now.getTime(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ sites: updatedSites });
|
||||||
|
},
|
||||||
|
|
||||||
|
batchSetHeight: async (height: number) => {
|
||||||
|
const { sites, selectedSiteIds } = get();
|
||||||
|
const selectedSet = new Set(selectedSiteIds);
|
||||||
|
const clampedHeight = Math.max(1, Math.min(100, height));
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const updatedSites = sites.map((site) => {
|
||||||
|
if (!selectedSet.has(site.id)) return site;
|
||||||
|
return {
|
||||||
|
...site,
|
||||||
|
height: clampedHeight,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist to IndexedDB
|
||||||
|
const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id));
|
||||||
|
for (const site of toUpdate) {
|
||||||
|
await db.sites.put({
|
||||||
|
id: site.id,
|
||||||
|
data: JSON.stringify(site),
|
||||||
|
createdAt: site.createdAt.getTime(),
|
||||||
|
updatedAt: now.getTime(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ sites: updatedSites });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user