@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 L from 'leaflet';
|
||||
import 'leaflet.heat';
|
||||
@@ -28,9 +28,39 @@ function rsrpToIntensity(rsrp: number): number {
|
||||
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) {
|
||||
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(() => {
|
||||
if (!visible || points.length === 0) return;
|
||||
|
||||
@@ -40,9 +70,11 @@ export default function Heatmap({ points, visible }: HeatmapProps) {
|
||||
rsrpToIntensity(p.rsrp),
|
||||
]);
|
||||
|
||||
const { radius, blur } = getHeatmapParams(mapZoom);
|
||||
|
||||
const heatLayer = L.heatLayer(heatData, {
|
||||
radius: 15,
|
||||
blur: 20,
|
||||
radius,
|
||||
blur,
|
||||
maxZoom: 17,
|
||||
max: 1.0,
|
||||
minOpacity: 0.3,
|
||||
@@ -61,7 +93,7 @@ export default function Heatmap({ points, visible }: HeatmapProps) {
|
||||
return () => {
|
||||
map.removeLayer(heatLayer);
|
||||
};
|
||||
}, [map, points, visible]);
|
||||
}, [map, points, visible, mapZoom]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'leaflet/dist/leaflet.css';
|
||||
import type { Map as LeafletMap } from 'leaflet';
|
||||
import type { Site } from '@/types/index.ts';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
import { useSettingsStore } from '@/store/settings.ts';
|
||||
import SiteMarker from './SiteMarker.tsx';
|
||||
|
||||
interface MapViewProps {
|
||||
@@ -42,6 +43,8 @@ function MapRefSetter({ mapRef }: { mapRef: React.MutableRefObject<LeafletMap |
|
||||
export default function MapView({ onMapClick, onEditSite, children }: MapViewProps) {
|
||||
const sites = useSitesStore((s) => s.sites);
|
||||
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 handleFitToSites = useCallback(() => {
|
||||
@@ -62,10 +65,19 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
|
||||
className={`w-full h-full ${isPlacingMode ? 'cursor-crosshair' : ''}`}
|
||||
>
|
||||
<MapRefSetter mapRef={mapRef} />
|
||||
{/* Base OSM layer */}
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
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} />
|
||||
{sites
|
||||
.filter((s) => s.visible)
|
||||
@@ -97,6 +109,16 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
|
||||
>
|
||||
Reset
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
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 { useToastStore } from '@/components/ui/Toast.tsx';
|
||||
import Button from '@/components/ui/Button.tsx';
|
||||
import BatchEdit from './BatchEdit.tsx';
|
||||
|
||||
interface SiteListProps {
|
||||
onEditSite: (site: Site) => void;
|
||||
@@ -15,6 +16,10 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
const selectedSiteId = useSitesStore((s) => s.selectedSiteId);
|
||||
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
|
||||
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 handleDelete = async (id: string, name: string) => {
|
||||
@@ -22,8 +27,11 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
addToast(`"${name}" deleted`, 'info');
|
||||
};
|
||||
|
||||
const allSelected = sites.length > 0 && selectedSiteIds.length === sites.length;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
|
||||
Sites ({sites.length})
|
||||
@@ -42,63 +50,105 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
</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 ? (
|
||||
<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.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 dark:divide-dark-border max-h-60 overflow-y-auto">
|
||||
{sites.map((site) => (
|
||||
<div
|
||||
key={site.id}
|
||||
className={`px-4 py-2.5 flex items-center gap-3 cursor-pointer
|
||||
hover:bg-gray-50 dark:hover:bg-dark-border/50 transition-colors
|
||||
${selectedSiteId === site.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`}
|
||||
onClick={() => selectSite(site.id)}
|
||||
>
|
||||
{/* Color indicator */}
|
||||
{sites.map((site) => {
|
||||
const isBatchSelected = selectedSiteIds.includes(site.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex-shrink-0 border-2 border-white dark:border-dark-border shadow"
|
||||
style={{ backgroundColor: site.color }}
|
||||
/>
|
||||
key={site.id}
|
||||
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
|
||||
${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)}
|
||||
>
|
||||
{/* 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"
|
||||
/>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-800 dark:text-dark-text truncate">
|
||||
{site.name}
|
||||
{/* Color indicator */}
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex-shrink-0 border-2 border-white dark:border-dark-border shadow"
|
||||
style={{ backgroundColor: site.color }}
|
||||
/>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-800 dark:text-dark-text truncate">
|
||||
{site.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-dark-muted">
|
||||
{site.frequency} MHz · {site.power} dBm ·{' '}
|
||||
{site.antennaType === 'omni'
|
||||
? 'Omni'
|
||||
: `Sector ${site.azimuth ?? 0}°`}
|
||||
{' '}· {site.height}m
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-dark-muted">
|
||||
{site.frequency} MHz · {site.power} dBm ·{' '}
|
||||
{site.antennaType === 'omni'
|
||||
? 'Omni'
|
||||
: `Sector ${site.azimuth ?? 0}°`}
|
||||
{' '}· {site.height}m
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditSite(site);
|
||||
}}
|
||||
className="px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded min-w-[44px] min-h-[32px] flex items-center justify-center"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(site.id, site.name);
|
||||
}}
|
||||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded min-w-[32px] min-h-[32px] flex items-center justify-center"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditSite(site);
|
||||
}}
|
||||
className="px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded min-w-[44px] min-h-[32px] flex items-center justify-center"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(site.id, site.name);
|
||||
}}
|
||||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded min-w-[32px] min-h-[32px] flex items-center justify-center"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,9 @@ type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface SettingsState {
|
||||
theme: Theme;
|
||||
showTerrain: boolean;
|
||||
setTheme: (theme: Theme) => void;
|
||||
setShowTerrain: (show: boolean) => void;
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
@@ -23,10 +25,12 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
theme: 'system' as Theme,
|
||||
showTerrain: false,
|
||||
setTheme: (theme: Theme) => {
|
||||
set({ theme });
|
||||
applyTheme(theme);
|
||||
},
|
||||
setShowTerrain: (show: boolean) => set({ showTerrain: show }),
|
||||
}),
|
||||
{
|
||||
name: 'rfcp-settings',
|
||||
|
||||
@@ -13,6 +13,7 @@ interface SitesState {
|
||||
selectedSiteId: string | null;
|
||||
editingSiteId: string | null;
|
||||
isPlacingMode: boolean;
|
||||
selectedSiteIds: string[];
|
||||
|
||||
loadSites: () => Promise<void>;
|
||||
addSite: (data: SiteFormData) => Promise<Site>;
|
||||
@@ -22,6 +23,13 @@ interface SitesState {
|
||||
setEditingSite: (id: string | null) => void;
|
||||
togglePlacingMode: () => 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) => ({
|
||||
@@ -29,6 +37,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
selectedSiteId: null,
|
||||
editingSiteId: null,
|
||||
isPlacingMode: false,
|
||||
selectedSiteIds: [],
|
||||
|
||||
loadSites: async () => {
|
||||
const dbSites = await db.sites.toArray();
|
||||
@@ -96,4 +105,83 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
setEditingSite: (id: string | null) => set({ editingSiteId: id }),
|
||||
togglePlacingMode: () => set((s) => ({ isPlacingMode: !s.isPlacingMode })),
|
||||
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