diff --git a/src/App.tsx b/src/App.tsx index 9e7a5e9..b88f709 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ const IMAGE_FOLDER_KEY = "kml_map_tool.image_folder"; const POPUP_SIZE_KEY = "kml_map_tool.popup_size"; const POPUP_ENABLED_KEY = "kml_map_tool.popup_enabled"; const BBOX_MODE_KEY = "kml_map_tool.bbox_mode"; +const BBOX_OVERLAY_KEY = "kml_map_tool.bbox_overlay"; const POPUP_ASPECT_KEY = "kml_map_tool.popup_aspect"; const QUALITY_OFF_TRACK_KEY = "kml_map_tool.quality_off_track_m"; const PERF_MODE_KEY = "kml_map_tool.perf_mode"; @@ -311,6 +312,16 @@ function AppShell({ useEffect(() => { window.localStorage.setItem(BBOX_MODE_KEY, bboxMode ? "1" : "0"); }, [bboxMode]); + // Independent of edit mode — whether the bbox rectangle/label is shown on + // the popup image at all. Default ON so users see annotations immediately. + const [bboxOverlay, setBboxOverlay] = useState(() => { + if (typeof window === "undefined") return true; + const v = window.localStorage.getItem(BBOX_OVERLAY_KEY); + return v === null ? true : v === "1"; + }); + useEffect(() => { + window.localStorage.setItem(BBOX_OVERLAY_KEY, bboxOverlay ? "1" : "0"); + }, [bboxOverlay]); const [popupAspect, setPopupAspect] = useState(() => { if (typeof window === "undefined") return "4:3"; const v = window.localStorage.getItem(POPUP_ASPECT_KEY) as PopupAspect | null; @@ -573,6 +584,21 @@ function AppShell({ null | { ids: number[]; current: string } >(null); const [renameInput, setRenameInput] = useState(""); + // Auto-dismissing toast for transient confirmations (delete, rename, side + // flip). Separate from the persistent status bar at the top — toasts + // float at the bottom and disappear after ~2s. + const [toast, setToast] = useState(null); + const toastTimerRef = useRef(null); + const showToast = (text: string) => { + setToast(text); + if (toastTimerRef.current !== null) { + window.clearTimeout(toastTimerRef.current); + } + toastTimerRef.current = window.setTimeout(() => { + setToast(null); + toastTimerRef.current = null; + }, 2200); + }; const [osmToolsOpen, setOsmToolsOpen] = useState(false); const [importsOpen, setImportsOpen] = useState(false); const [osmToolPrune, setOsmToolPrune] = useState(true); @@ -994,9 +1020,11 @@ function AppShell({ try { await invoke("update_asset_position", { id, vertex, lat, lng }); setStatus(`Moved ${vertex} to ${lat.toFixed(6)}, ${lng.toFixed(6)}`); + showToast(`Moved ${vertex} point`); await refreshAfterMutation(); } catch (e) { setStatus(`Move failed: ${e}`); + showToast(`Move failed`); } } @@ -1123,6 +1151,63 @@ function AppShell({ toggleOsmEditModeRef.current(); return; } + // 'R' / 'r' opens the rename modal for the currently selected asset. + // Input-guard pattern matches the other shortcuts. + if (e.key === "r" || e.key === "R") { + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { + return; + } + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (!selectedAsset) return; + e.preventDefault(); + setRenameOpen({ + ids: [selectedAsset.id], + current: selectedAsset.asset_name ?? "", + }); + setRenameInput(selectedAsset.asset_name ?? ""); + return; + } + // Delete / Backspace: when an asset is selected and we're not in any + // edit mode that already claims those keys (OSM vertex/road delete, + // link deletion handled above), delete the asset and toast. + if ( + (e.key === "Delete" || e.key === "Backspace") && + selectedAsset && + !osmEditMode && + !selectedLink + ) { + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { + return; + } + e.preventDefault(); + const label = `${selectedAsset.asset_name} · ${selectedAsset.row_id}`; + (async () => { + try { + await invoke("delete_asset", { id: selectedAsset.id }); + setSelectedAssetId(null); + // Surgical local update — drop the row instead of refetching + // everything, so the rest of the UI stays put. + const deletedId = selectedAsset.id; + setAssets((prev) => + prev.map((a) => + a.id === deletedId ? { ...a, deleted: 1 } : a, + ), + ); + showToast(`Deleted ${label}`); + void Promise.all([ + refreshActions().catch(() => {}), + refreshDataCounts().catch(() => {}), + ]); + } catch (err) { + showToast(`Delete failed: ${err}`); + } + })(); + return; + } // 'B' / 'b' toggles bbox editing. No-op when popups are disabled. if (e.key === "b" || e.key === "B") { const target = e.target as HTMLElement | null; @@ -1209,7 +1294,7 @@ function AppShell({ }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [drawRoadMode, editingRoadId, linkPickMode, rightClickFirstId, lassoMode, selectedLink, selectedMetadataVideo, gotoPin, osmEditMode, markedVertices, focusedVideo, selectedRoadIds, lensActive]); + }, [drawRoadMode, editingRoadId, linkPickMode, rightClickFirstId, lassoMode, selectedLink, selectedMetadataVideo, gotoPin, osmEditMode, markedVertices, focusedVideo, selectedRoadIds, lensActive, selectedAsset, popupEnabled]); // Image prefetch: when an asset is selected, warm the browser cache for its // immediate neighbours so [/] navigation feels instant. @@ -1309,15 +1394,39 @@ function AppShell({ return () => window.removeEventListener("keydown", handler); }, [selectedAsset, viewport.center]); + // Set an asset's side. Used both by the popup flip button and the auto-flip + // from bbox editing (when the bbox center is in the left vs right half of + // the image). Updates the local assets array in place so the popup tag + // and any downstream UI refresh without a full re-fetch. + async function handleSideChange(assetId: number, side: "Left" | "Right") { + const cur = assets.find((a) => a.id === assetId); + if (cur && cur.side === side) return; + try { + await invoke("change_assets_side", { + ids: [assetId], + side, + }); + setAssets((prev) => + prev.map((a) => (a.id === assetId ? { ...a, side } : a)), + ); + showToast(`Side → ${side}`); + } catch (e) { + showToast(`Side change failed: ${e}`); + } + } + async function handleDeleteSelected() { if (!selectedAsset) return; + const label = `${selectedAsset.asset_name} · ${selectedAsset.row_id}`; try { await invoke("delete_asset", { id: selectedAsset.id }); - setStatus(`Deleted ${selectedAsset.asset_name} · ${selectedAsset.row_id}`); + setStatus(`Deleted ${label}`); + showToast(`Deleted ${label}`); setSelectedAssetId(null); await refreshAfterMutation(); } catch (e) { setStatus(`Delete failed: ${e}`); + showToast(`Delete failed: ${e}`); } } @@ -1345,13 +1454,16 @@ function AppShell({ }); if (count === 0) { setStatus(`No live assets matched video "${name}"`); + showToast(`No matches for "${name}"`); } else { setStatus(`Deleted ${count} assets from video "${name}"`); + showToast(`Deleted ${count} assets in "${name}"`); setBulkVideoName(""); } await refreshAfterMutation(); } catch (e) { setStatus(`Bulk delete failed: ${e}`); + showToast(`Bulk delete failed`); } } @@ -1406,8 +1518,10 @@ function AppShell({ refreshAssetNames().catch(() => {}), ]); setStatus("Database reset."); + showToast("Database reset"); } catch (e) { setStatus(`Reset failed: ${e}`); + showToast(`Reset failed`); } finally { setBusy(false); } @@ -1417,9 +1531,11 @@ function AppShell({ try { await invoke("undo_action", { actionId }); setStatus(`Undone action #${actionId}`); + showToast(`Undone #${actionId}`); await refreshAfterMutation(); } catch (e) { setStatus(`Undo failed: ${e}`); + showToast(`Undo failed`); } } @@ -1427,9 +1543,11 @@ function AppShell({ try { await invoke("redo_action", { actionId }); setStatus(`Redone action #${actionId}`); + showToast(`Redone #${actionId}`); await refreshAfterMutation(); } catch (e) { setStatus(`Redo failed: ${e}`); + showToast(`Redo failed`); } } @@ -1460,12 +1578,16 @@ function AppShell({ setStatus( `Merged ${mergeSecondary.row_id} into ${mergePrimary.row_id}`, ); + showToast( + `Merged ${mergeSecondary.row_id} → ${mergePrimary.row_id}`, + ); setMergePrimary(null); setMergeSecondary(null); setSelectedAssetId(null); await refreshAfterMutation(); } catch (e) { setStatus(`Merge failed: ${e}`); + showToast(`Merge failed`); } } @@ -1917,8 +2039,10 @@ function AppShell({ setStatus( `Created road #${newId} with ${drawRoadCoords.length} vertices.`, ); + showToast(`Created road #${newId}`); } catch (e) { setStatus(`Create road failed: ${e}`); + showToast(`Create road failed`); } } @@ -1926,14 +2050,17 @@ function AppShell({ if (editingRoadId === null) return; const ok = window.confirm(`Delete road #${editingRoadId}?`); if (!ok) return; + const roadId = editingRoadId; try { - await invoke("delete_osm_road", { id: editingRoadId }); + await invoke("delete_osm_road", { id: roadId }); await refreshOsmRoads(); await refreshDataCounts(); setEditingRoadId(null); - setStatus(`Deleted road #${editingRoadId}.`); + setStatus(`Deleted road #${roadId}.`); + showToast(`Deleted road #${roadId}`); } catch (e) { setStatus(`Delete failed: ${e}`); + showToast(`Delete road failed`); } } @@ -2021,9 +2148,11 @@ function AppShell({ ); } else { setStatus(`Loaded ${count} road segment(s)`); + showToast(`Loaded ${count} road segment(s)`); } } catch (e) { setStatus(`OSM import failed: ${e}`); + showToast(`OSM import failed`); } finally { setBusy(false); } @@ -3057,8 +3186,10 @@ function AppShell({ await fetchBboxAssets(boundsRef.current); } setStatus(`Loaded ${count} scope polygon(s)`); + showToast(`Loaded ${count} scope polygon(s)`); } catch (e) { setStatus(`KML import failed: ${e}`); + showToast(`KML import failed`); } finally { setBusy(false); } @@ -3146,6 +3277,7 @@ function AppShell({ { path: picked }, ); setStatus(`Imported ${r.fixed} fixed + ${r.range} range = ${r.fixed + r.range} asset(s)`); + showToast(`Imported ${r.fixed + r.range} asset(s)`); await Promise.all([ refreshVideoNames(), refreshVideoEntries(), @@ -3189,6 +3321,7 @@ function AppShell({ kind === "fixed" ? "import_fixed_assets" : "import_range_assets"; const count = await invoke(cmd, { path: picked }); setStatus(`Imported ${count} ${kind}`); + showToast(`Imported ${count} ${kind} asset(s)`); // Pull in the new video / class names so the filter auto-includes them. await Promise.all([ @@ -3428,6 +3561,8 @@ function AppShell({ imageFolder={imageFolder} popupEnabled={popupEnabled} bboxMode={bboxMode} + bboxOverlay={bboxOverlay} + onSideChange={handleSideChange} perfMode={perfMode} anchorIds={Array.from(anchors.keys())} colorBy={colorBy} @@ -3551,6 +3686,8 @@ function AppShell({ imageFolder={imageFolder} popupEnabled={popupEnabled} bboxMode={bboxMode} + bboxOverlay={bboxOverlay} + onSideChange={handleSideChange} perfMode={perfMode} anchorIds={Array.from(anchors.keys())} colorBy={ @@ -3653,6 +3790,31 @@ function AppShell({ + {toast !== null && ( +
+ {toast} +
+ )} +