Compare commits
2 Commits
947a757d9b
...
703265f47e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
703265f47e | ||
|
|
6e3663cf7f |
@@ -76,7 +76,11 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross
|
|||||||
|
|
||||||
## Phase 5 – Kür
|
## Phase 5 – Kür
|
||||||
|
|
||||||
- [ ] **5.1 – Linien-Typ-Abfrage** 🟡 (Rettungsachse / Leitung / normal)
|
- [x] **5.1 – Linien-Typ-Abfrage** 🟡 ✅
|
||||||
|
- Beim Zeichnen einer Linie/eines Pfeils fragt der Dialog nun den Typ ab:
|
||||||
|
**Normal** (gewählte Farbe), **Leitung** (blau, dicker), **Rettungsachse** (grün,
|
||||||
|
gestrichelt – als freizuhaltende Achse klar erkennbar).
|
||||||
|
- Typ in `properties.lineType`; eigener MapLibre-Layer mit Dash-Muster für Rettungsachse.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.4.11",
|
"version": "1.4.13",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ import { RightSidebar } from '@/components/layout/right-sidebar'
|
|||||||
import { ProjectDialog } from '@/components/dialogs/project-dialog'
|
import { ProjectDialog } from '@/components/dialogs/project-dialog'
|
||||||
import { ExerciseCockpit } from '@/components/exercise/exercise-cockpit'
|
import { ExerciseCockpit } from '@/components/exercise/exercise-cockpit'
|
||||||
import { TextDialog } from '@/components/dialogs/text-dialog'
|
import { TextDialog } from '@/components/dialogs/text-dialog'
|
||||||
import { LineLabelDialog } from '@/components/dialogs/line-label-dialog'
|
import { LineLabelDialog, LINE_KINDS, type LineKind } from '@/components/dialogs/line-label-dialog'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { useAuth } from '@/components/providers/auth-provider'
|
import { useAuth } from '@/components/providers/auth-provider'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { JournalView } from '@/components/journal/journal-view'
|
import { JournalView } from '@/components/journal/journal-view'
|
||||||
import { Lock, Unlock, Eye, AlertTriangle, WifiOff, GraduationCap } from 'lucide-react'
|
import { Lock, Unlock, Eye, AlertTriangle, WifiOff, GraduationCap, Map as MapIcon, ClipboardList, LayoutGrid, X } from 'lucide-react'
|
||||||
import { CustomDragLayer } from '@/components/map/custom-drag-layer'
|
import { CustomDragLayer } from '@/components/map/custom-drag-layer'
|
||||||
import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour'
|
import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour'
|
||||||
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
|
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
|
||||||
@@ -47,6 +47,9 @@ export default function AppPage() {
|
|||||||
|
|
||||||
const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false)
|
const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false)
|
||||||
const [newProjectMode, setNewProjectMode] = useState<ProjectMode>('EINSATZ')
|
const [newProjectMode, setNewProjectMode] = useState<ProjectMode>('EINSATZ')
|
||||||
|
const [isEndConfirmOpen, setIsEndConfirmOpen] = useState(false)
|
||||||
|
// Tap-to-place: angetipptes Symbol wartet auf Platzierung per Karten-Tipp (Mobil)
|
||||||
|
const [pendingSymbol, setPendingSymbol] = useState<{ id: string; imageUrl: string } | null>(null)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen] = useState(false)
|
const [isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen] = useState(false)
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
@@ -627,9 +630,10 @@ export default function AppPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Einsatz beenden: speichern, Bearbeitungs-Lock lösen und zurück zur Übersicht.
|
// Einsatz beenden: speichern, Bearbeitungs-Lock lösen und zurück zur Übersicht.
|
||||||
const handleEndProject = async () => {
|
// Bestätigung erfolgt über einen In-App-Dialog (kein natives confirm()).
|
||||||
|
const performEndProject = async () => {
|
||||||
if (!currentProject) return
|
if (!currentProject) return
|
||||||
if (!confirm(`Einsatz "${currentProject.title}" beenden? Alle Änderungen werden gespeichert und der Einsatz geschlossen.`)) return
|
setIsEndConfirmOpen(false)
|
||||||
const title = currentProject.title
|
const title = currentProject.title
|
||||||
try { await handleSaveProject() } catch { /* Save meldet Fehler selbst */ }
|
try { await handleSaveProject() } catch { /* Save meldet Fehler selbst */ }
|
||||||
if (isEditingByMe) { try { await handleStopEditing() } catch { /* ignore */ } }
|
if (isEditingByMe) { try { await handleStopEditing() } catch { /* ignore */ } }
|
||||||
@@ -664,22 +668,38 @@ export default function AppPage() {
|
|||||||
broadcastFeatures(newFeatures)
|
broadcastFeatures(newFeatures)
|
||||||
}, [addAudit, broadcastFeatures])
|
}, [addAudit, broadcastFeatures])
|
||||||
|
|
||||||
const handleLineLabelConfirm = useCallback((label: string) => {
|
// Wendet den gewählten Linientyp an: setzt properties.lineType und – bei Leitung/
|
||||||
if (pendingLineFeature && label) {
|
// Rettungsachse – die konventionelle Farbe/Stärke (Normal behält die Zeichenfarbe).
|
||||||
setFeatures(prev => prev.map(f =>
|
const applyLineKind = useCallback((f: DrawFeature, kind: LineKind, label: string): DrawFeature => {
|
||||||
f.id === pendingLineFeature.id
|
const def = LINE_KINDS.find(k => k.value === kind)
|
||||||
? { ...f, properties: { ...f.properties, label } }
|
const properties: Record<string, any> = { ...f.properties, lineType: kind }
|
||||||
: f
|
if (def && kind !== 'normal') {
|
||||||
))
|
properties.color = def.color
|
||||||
|
properties.width = def.width
|
||||||
|
}
|
||||||
|
if (label) properties.label = label
|
||||||
|
return { ...f, properties }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const finalizeLineFeature = useCallback((kind: LineKind, label: string) => {
|
||||||
|
if (pendingLineFeature) {
|
||||||
|
const next = featuresRef.current.map(f =>
|
||||||
|
f.id === pendingLineFeature.id ? applyLineKind(f, kind, label) : f
|
||||||
|
)
|
||||||
|
setFeatures(next)
|
||||||
|
broadcastFeatures(next)
|
||||||
}
|
}
|
||||||
setPendingLineFeature(null)
|
setPendingLineFeature(null)
|
||||||
setIsLineLabelDialogOpen(false)
|
setIsLineLabelDialogOpen(false)
|
||||||
}, [pendingLineFeature])
|
}, [pendingLineFeature, applyLineKind, broadcastFeatures])
|
||||||
|
|
||||||
const handleLineLabelSkip = useCallback(() => {
|
const handleLineLabelConfirm = useCallback((label: string, kind: LineKind) => {
|
||||||
setPendingLineFeature(null)
|
finalizeLineFeature(kind, label)
|
||||||
setIsLineLabelDialogOpen(false)
|
}, [finalizeLineFeature])
|
||||||
}, [])
|
|
||||||
|
const handleLineLabelSkip = useCallback((kind: LineKind) => {
|
||||||
|
finalizeLineFeature(kind, '')
|
||||||
|
}, [finalizeLineFeature])
|
||||||
|
|
||||||
const handleSymbolDrop = useCallback((iconId: string, coordinates: [number, number], imageUrl?: string) => {
|
const handleSymbolDrop = useCallback((iconId: string, coordinates: [number, number], imageUrl?: string) => {
|
||||||
const currentZoom = mapRef.current?.getZoom() || 17
|
const currentZoom = mapRef.current?.getZoom() || 17
|
||||||
@@ -701,6 +721,15 @@ export default function AppPage() {
|
|||||||
handleFeaturesChange([...featuresRef.current, newFeature])
|
handleFeaturesChange([...featuresRef.current, newFeature])
|
||||||
}, [handleFeaturesChange, defaultSymbolScale])
|
}, [handleFeaturesChange, defaultSymbolScale])
|
||||||
|
|
||||||
|
// Tap-to-place: Symbol antippen → Sidebar schliessen, Karte zeigen, nächster Tipp platziert
|
||||||
|
const handleSymbolSelect = useCallback((symbol: { id: string; imageUrl: string }) => {
|
||||||
|
setPendingSymbol(symbol)
|
||||||
|
setIsSidebarOpen(false)
|
||||||
|
setActiveTab('map')
|
||||||
|
setDrawMode('select')
|
||||||
|
toast({ title: 'Symbol platzieren', description: 'Tippe auf die Karte, um das Symbol zu setzen.' })
|
||||||
|
}, [setIsSidebarOpen, setActiveTab, toast])
|
||||||
|
|
||||||
const handleTextPlace = useCallback((coordinates: [number, number]) => {
|
const handleTextPlace = useCallback((coordinates: [number, number]) => {
|
||||||
setTextPlaceCoords(coordinates)
|
setTextPlaceCoords(coordinates)
|
||||||
setIsTextDialogOpen(true)
|
setIsTextDialogOpen(true)
|
||||||
@@ -852,7 +881,7 @@ export default function AppPage() {
|
|||||||
<Topbar
|
<Topbar
|
||||||
project={currentProject}
|
project={currentProject}
|
||||||
onNewProject={handleNewProject}
|
onNewProject={handleNewProject}
|
||||||
onEndProject={handleEndProject}
|
onEndProject={() => setIsEndConfirmOpen(true)}
|
||||||
onSaveProject={handleSaveProject}
|
onSaveProject={handleSaveProject}
|
||||||
onLoadProject={handleProjectLoaded}
|
onLoadProject={handleProjectLoaded}
|
||||||
onDeleteProject={handleDeleteProject}
|
onDeleteProject={handleDeleteProject}
|
||||||
@@ -882,7 +911,7 @@ export default function AppPage() {
|
|||||||
|
|
||||||
{/* Übungs-Banner — verhindert Verwechslung mit echtem Einsatz */}
|
{/* Übungs-Banner — verhindert Verwechslung mit echtem Einsatz */}
|
||||||
{currentProject?.mode === 'UEBUNG' && (
|
{currentProject?.mode === 'UEBUNG' && (
|
||||||
<div className="flex items-center justify-center gap-2 px-4 py-1.5 bg-blue-600 text-white text-sm font-semibold tracking-wide">
|
<div className="flex items-center justify-center gap-2 px-4 py-1 md:py-1.5 bg-blue-600 text-white text-xs md:text-sm font-semibold tracking-wide">
|
||||||
<GraduationCap className="w-4 h-4 shrink-0" />
|
<GraduationCap className="w-4 h-4 shrink-0" />
|
||||||
<span>ÜBUNG — kein realer Einsatz</span>
|
<span>ÜBUNG — kein realer Einsatz</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -890,10 +919,10 @@ export default function AppPage() {
|
|||||||
|
|
||||||
{/* Offline banner */}
|
{/* Offline banner */}
|
||||||
{isOffline && (
|
{isOffline && (
|
||||||
<div className="flex items-center justify-center gap-3 px-4 py-2 bg-orange-50 dark:bg-orange-950/40 border-b border-orange-200 dark:border-orange-800 text-sm text-orange-800 dark:text-orange-300">
|
<div className="flex items-center justify-center gap-2 md:gap-3 px-3 md:px-4 py-1.5 md:py-2 bg-orange-50 dark:bg-orange-950/40 border-b border-orange-200 dark:border-orange-800 text-xs md:text-sm text-orange-800 dark:text-orange-300">
|
||||||
<WifiOff className="w-4 h-4 shrink-0" />
|
<WifiOff className="w-4 h-4 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
<strong>Offline-Modus</strong> — Änderungen werden lokal gespeichert und beim Reconnect synchronisiert.
|
<strong>Offline</strong><span className="hidden sm:inline">-Modus — Änderungen werden lokal gespeichert und beim Reconnect synchronisiert.</span>
|
||||||
{syncQueueCount > 0 && ` (${syncQueueCount} ausstehend)`}
|
{syncQueueCount > 0 && ` (${syncQueueCount} ausstehend)`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -901,9 +930,9 @@ export default function AppPage() {
|
|||||||
|
|
||||||
{/* Email verification banner */}
|
{/* Email verification banner */}
|
||||||
{user && user.emailVerified === false && (
|
{user && user.emailVerified === false && (
|
||||||
<div className="flex items-center justify-center gap-3 px-4 py-2 bg-amber-50 dark:bg-amber-950/40 border-b border-amber-200 dark:border-amber-800 text-sm text-amber-800 dark:text-amber-300">
|
<div className="flex items-center justify-center gap-2 md:gap-3 px-3 md:px-4 py-1.5 md:py-2 bg-amber-50 dark:bg-amber-950/40 border-b border-amber-200 dark:border-amber-800 text-xs md:text-sm text-amber-800 dark:text-amber-300">
|
||||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||||
<span>Ihre E-Mail-Adresse wurde noch nicht bestätigt. Bitte prüfen Sie Ihren Posteingang.</span>
|
<span>E-Mail noch nicht bestätigt.<span className="hidden sm:inline"> Bitte prüfen Sie Ihren Posteingang.</span></span>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
@@ -925,24 +954,24 @@ export default function AppPage() {
|
|||||||
|
|
||||||
{/* Live editing banner */}
|
{/* Live editing banner */}
|
||||||
{currentProject && (
|
{currentProject && (
|
||||||
<div className="flex items-center justify-between px-4 py-1.5 bg-muted/50 border-b text-sm">
|
<div className="flex items-center justify-between gap-2 px-3 md:px-4 py-1.5 bg-muted/50 border-b text-xs md:text-sm">
|
||||||
{isReadOnly ? (
|
{isReadOnly ? (
|
||||||
<div className="flex items-center gap-2 text-orange-600">
|
<div className="flex items-center gap-2 text-orange-600 min-w-0">
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4 shrink-0" />
|
||||||
<span><strong>{editingBy?.name}</strong> bearbeitet die Karte — Live-Ansicht{roleCanEdit ? ' · Journal bleibt für Sie bearbeitbar' : ''}</span>
|
<span className="truncate"><strong>{editingBy?.name}</strong> bearbeitet die Karte<span className="hidden sm:inline"> — Live-Ansicht{roleCanEdit ? ' · Journal bleibt für Sie bearbeitbar' : ''}</span></span>
|
||||||
</div>
|
</div>
|
||||||
) : isEditingByMe ? (
|
) : isEditingByMe ? (
|
||||||
<div className="flex items-center gap-2 text-green-600">
|
<div className="flex items-center gap-2 text-green-600 min-w-0">
|
||||||
<Lock className="w-4 h-4" />
|
<Lock className="w-4 h-4 shrink-0" />
|
||||||
<span>Sie bearbeiten die Karte — andere sehen Ihre Änderungen in Echtzeit</span>
|
<span className="truncate">Sie bearbeiten die Karte<span className="hidden sm:inline"> — andere sehen Ihre Änderungen in Echtzeit</span></span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground min-w-0">
|
||||||
<Unlock className="w-4 h-4" />
|
<Unlock className="w-4 h-4 shrink-0" />
|
||||||
<span>Niemand bearbeitet gerade</span>
|
<span className="truncate">Niemand bearbeitet gerade</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div data-tour="edit-toggle" className="flex items-center gap-2">
|
<div data-tour="edit-toggle" className="flex items-center gap-2 shrink-0">
|
||||||
{roleCanEdit && !isEditingByMe && !isReadOnly && (
|
{roleCanEdit && !isEditingByMe && !isReadOnly && (
|
||||||
<Button size="sm" variant="default" onClick={handleStartEditing} disabled={editingLoading}>
|
<Button size="sm" variant="default" onClick={handleStartEditing} disabled={editingLoading}>
|
||||||
<Lock className="w-3.5 h-3.5 mr-1" />
|
<Lock className="w-3.5 h-3.5 mr-1" />
|
||||||
@@ -990,12 +1019,14 @@ export default function AppPage() {
|
|||||||
mapRef={mapRef}
|
mapRef={mapRef}
|
||||||
onDrawModeChange={setDrawMode}
|
onDrawModeChange={setDrawMode}
|
||||||
undoDrawPointRef={undoDrawPointRef}
|
undoDrawPointRef={undoDrawPointRef}
|
||||||
|
pendingSymbol={pendingSymbol ? { iconId: pendingSymbol.id, imageUrl: pendingSymbol.imageUrl } : null}
|
||||||
|
onSymbolPlaced={() => setPendingSymbol(null)}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Journal / Übungs-Cockpit — always mounted, hidden via CSS */}
|
{/* Journal / Übungs-Cockpit — always mounted, hidden via CSS */}
|
||||||
<main className={`flex-1 flex flex-col min-h-0 bg-background ${activeTab !== 'journal' ? 'hidden' : ''}`}>
|
<main className={`flex-1 flex flex-col min-h-0 bg-background pb-14 md:pb-0 ${activeTab !== 'journal' ? 'hidden' : ''}`}>
|
||||||
{currentProject?.mode === 'UEBUNG' ? (
|
{currentProject?.mode === 'UEBUNG' ? (
|
||||||
<ExerciseCockpit
|
<ExerciseCockpit
|
||||||
projectId={currentProject?.id || null}
|
projectId={currentProject?.id || null}
|
||||||
@@ -1029,6 +1060,7 @@ export default function AppPage() {
|
|||||||
<RightSidebar
|
<RightSidebar
|
||||||
data-tour="sidebar"
|
data-tour="sidebar"
|
||||||
onSymbolDrop={handleSymbolDrop}
|
onSymbolDrop={handleSymbolDrop}
|
||||||
|
onSymbolSelect={handleSymbolSelect}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
isOpen={isSidebarOpen}
|
isOpen={isSidebarOpen}
|
||||||
onToggle={() => setIsSidebarOpen(!isSidebarOpen)}
|
onToggle={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||||
@@ -1041,6 +1073,48 @@ export default function AppPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tap-to-place Hinweis — schwebt über der Bottom-Nav, mit Abbrechen */}
|
||||||
|
{pendingSymbol && (
|
||||||
|
<div className="fixed left-1/2 -translate-x-1/2 z-40 bottom-[76px] md:bottom-6 flex items-center gap-2 px-3 py-2 rounded-full bg-primary text-primary-foreground shadow-lg text-sm">
|
||||||
|
<img src={pendingSymbol.imageUrl} alt="" className="w-5 h-5 object-contain bg-white rounded" crossOrigin="anonymous" />
|
||||||
|
<span>Auf die Karte tippen zum Platzieren</span>
|
||||||
|
<button onClick={() => setPendingSymbol(null)} className="ml-1 p-0.5 rounded-full hover:bg-white/20" title="Abbrechen">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Bottom-Navigation — Karte / Journal / Symbole erreichbar ohne Menü */}
|
||||||
|
{currentProject && (
|
||||||
|
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-30 flex items-stretch border-t border-border bg-card shadow-[0_-2px_8px_rgba(0,0,0,0.08)] pb-[env(safe-area-inset-bottom,0px)]">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTabChange('map')}
|
||||||
|
className={`flex-1 flex flex-col items-center justify-center gap-0.5 py-2 text-[11px] font-medium transition-colors ${
|
||||||
|
activeTab === 'map' ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MapIcon className="w-5 h-5" />
|
||||||
|
Karte
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleTabChange('journal')}
|
||||||
|
className={`flex-1 flex flex-col items-center justify-center gap-0.5 py-2 text-[11px] font-medium transition-colors ${
|
||||||
|
activeTab === 'journal' ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ClipboardList className="w-5 h-5" />
|
||||||
|
{currentProject.mode === 'UEBUNG' ? 'Auswertung' : 'Journal'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { handleTabChange('map'); setIsSidebarOpen(true) }}
|
||||||
|
className="flex-1 flex flex-col items-center justify-center gap-0.5 py-2 text-[11px] font-medium text-muted-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="w-5 h-5" />
|
||||||
|
Symbole
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
<ProjectDialog
|
<ProjectDialog
|
||||||
open={isProjectDialogOpen}
|
open={isProjectDialogOpen}
|
||||||
onOpenChange={setIsProjectDialogOpen}
|
onOpenChange={setIsProjectDialogOpen}
|
||||||
@@ -1048,6 +1122,29 @@ export default function AppPage() {
|
|||||||
initialMode={newProjectMode}
|
initialMode={newProjectMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Einsatz beenden — In-App-Bestätigung statt native confirm() */}
|
||||||
|
<Dialog open={isEndConfirmOpen} onOpenChange={setIsEndConfirmOpen}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{currentProject?.mode === 'UEBUNG' ? 'Übung beenden?' : 'Einsatz beenden?'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{currentProject?.title
|
||||||
|
? <>„{currentProject.title}" wird gespeichert und geschlossen.</>
|
||||||
|
: 'Der aktuelle Eintrag wird gespeichert und geschlossen.'}
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsEndConfirmOpen(false)}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
onClick={performEndProject}
|
||||||
|
>
|
||||||
|
{currentProject?.mode === 'UEBUNG' ? 'Übung beenden' : 'Einsatz beenden'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<TextDialog
|
<TextDialog
|
||||||
open={isTextDialogOpen}
|
open={isTextDialogOpen}
|
||||||
onOpenChange={setIsTextDialogOpen}
|
onOpenChange={setIsTextDialogOpen}
|
||||||
|
|||||||
@@ -98,6 +98,15 @@
|
|||||||
transform: scale(1.4);
|
transform: scale(1.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobil: Karten-Steuerung (Zoom/Standort/Massstab/Attribution) über die
|
||||||
|
fixe Bottom-Navigation heben, damit sie nicht verdeckt wird. */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.maplibregl-ctrl-bottom-right,
|
||||||
|
.maplibregl-ctrl-bottom-left {
|
||||||
|
bottom: calc(60px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Touch-friendly: disable text selection on interactive elements */
|
/* Touch-friendly: disable text selection on interactive elements */
|
||||||
.touch-none {
|
.touch-none {
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -12,28 +13,44 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
export type LineKind = 'normal' | 'leitung' | 'rettungsachse'
|
||||||
|
|
||||||
|
// Zentrale Definition der Linientypen — Farbe/Stärke werden beim Zeichnen übernommen.
|
||||||
|
export const LINE_KINDS: { value: LineKind; label: string; color: string; width: number; dashed: boolean; hint: string }[] = [
|
||||||
|
{ value: 'normal', label: 'Normal', color: '', width: 0, dashed: false, hint: 'Freie Linie in gewählter Farbe' },
|
||||||
|
{ value: 'leitung', label: 'Leitung', color: '#2563eb', width: 4, dashed: false, hint: 'Schlauchleitung (blau)' },
|
||||||
|
{ value: 'rettungsachse', label: 'Rettungsachse', color: '#16a34a', width: 6, dashed: true, hint: 'Freihalten — Rettungs-/Zufahrtsachse (grün, gestrichelt)' },
|
||||||
|
]
|
||||||
|
|
||||||
interface LineLabelDialogProps {
|
interface LineLabelDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
onConfirm: (label: string) => void
|
onConfirm: (label: string, kind: LineKind) => void
|
||||||
onSkip: () => void
|
onSkip: (kind: LineKind) => void
|
||||||
lineType: 'linestring' | 'arrow' | 'polygon'
|
lineType: 'linestring' | 'arrow' | 'polygon' | 'dangerzone'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LineLabelDialog({ open, onOpenChange, onConfirm, onSkip, lineType }: LineLabelDialogProps) {
|
export function LineLabelDialog({ open, onOpenChange, onConfirm, onSkip, lineType }: LineLabelDialogProps) {
|
||||||
const [label, setLabel] = useState('')
|
const [label, setLabel] = useState('')
|
||||||
|
const [kind, setKind] = useState<LineKind>('normal')
|
||||||
|
|
||||||
|
// Linientyp-Auswahl nur für echte Linien/Pfeile (nicht für Flächen/Gefahrenzonen)
|
||||||
|
const showKindSelector = lineType === 'linestring' || lineType === 'arrow'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) setLabel('')
|
if (open) {
|
||||||
|
setLabel('')
|
||||||
|
setKind('normal')
|
||||||
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
onConfirm(label.trim())
|
onConfirm(label.trim(), kind)
|
||||||
setLabel('')
|
setLabel('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSkip = () => {
|
const handleSkip = () => {
|
||||||
onSkip()
|
onSkip(kind)
|
||||||
setLabel('')
|
setLabel('')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,8 +64,10 @@ export function LineLabelDialog({ open, onOpenChange, onConfirm, onSkip, lineTyp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = lineType === 'polygon' ? 'Fläche beschriften' : 'Leitung beschriften'
|
const title = lineType === 'polygon' || lineType === 'dangerzone'
|
||||||
const placeholder = lineType === 'polygon'
|
? 'Fläche beschriften'
|
||||||
|
: showKindSelector ? 'Linie festlegen' : 'Leitung beschriften'
|
||||||
|
const placeholder = lineType === 'polygon' || lineType === 'dangerzone'
|
||||||
? 'z.B. Brandzone, Sperrgebiet...'
|
? 'z.B. Brandzone, Sperrgebiet...'
|
||||||
: 'z.B. 1, L2, Zuleitung...'
|
: 'z.B. 1, L2, Zuleitung...'
|
||||||
|
|
||||||
@@ -58,7 +77,41 @@ export function LineLabelDialog({ open, onOpenChange, onConfirm, onSkip, lineTyp
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3 py-2">
|
<div className="space-y-4 py-2">
|
||||||
|
{showKindSelector && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Linientyp</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{LINE_KINDS.map((k) => (
|
||||||
|
<button
|
||||||
|
key={k.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setKind(k.value)}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center gap-1 rounded-lg border-2 px-1.5 py-2 text-xs font-medium transition-all',
|
||||||
|
kind === k.value
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-muted-foreground'
|
||||||
|
)}
|
||||||
|
title={k.hint}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-1 w-8 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: k.color || '#64748b',
|
||||||
|
...(k.dashed
|
||||||
|
? { background: `repeating-linear-gradient(90deg, ${k.color} 0 5px, transparent 5px 9px)` }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{k.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{LINE_KINDS.find(k => k.value === kind)?.hint}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="line-label">Bezeichnung (optional)</Label>
|
<Label htmlFor="line-label">Bezeichnung (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -67,16 +120,16 @@ export function LineLabelDialog({ open, onOpenChange, onConfirm, onSkip, lineTyp
|
|||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
autoFocus
|
autoFocus={!showKindSelector}
|
||||||
/>
|
/>
|
||||||
</div>
|
<p className="text-[11px] text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground">
|
Wird am Mittelpunkt auf der Karte angezeigt. Leer lassen für keine Beschriftung.
|
||||||
Wird am Mittelpunkt der Leitung auf der Karte angezeigt. Leer lassen für keine Beschriftung.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<DialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
<Button variant="outline" onClick={handleSkip}>
|
<Button variant="outline" onClick={handleSkip}>
|
||||||
Ohne Label
|
{showKindSelector ? 'Übernehmen, ohne Label' : 'Ohne Label'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleConfirm} disabled={!label.trim()}>
|
<Button onClick={handleConfirm} disabled={!label.trim()}>
|
||||||
Beschriften
|
Beschriften
|
||||||
|
|||||||
@@ -75,20 +75,20 @@ export function LeftToolbar({
|
|||||||
}: LeftToolbarProps) {
|
}: LeftToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<aside className="w-11 md:w-14 lg:w-20 border-r border-border bg-card flex flex-col items-center py-1 md:py-2 shrink-0 overflow-y-auto overflow-x-hidden z-10">
|
<aside className="w-14 lg:w-20 border-r border-border bg-card flex flex-col items-center py-1.5 lg:py-2 shrink-0 overflow-y-auto overflow-x-hidden z-10">
|
||||||
{/* Draw Tools */}
|
{/* Draw Tools — Touch-Ziele mind. 44px auf Mobil/Tablet */}
|
||||||
<div className="flex flex-col gap-px md:gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
{drawTools.map((tool) => (
|
{drawTools.map((tool) => (
|
||||||
<Tooltip key={tool.mode}>
|
<Tooltip key={tool.mode}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={drawMode === tool.mode ? 'default' : 'ghost'}
|
variant={drawMode === tool.mode ? 'default' : 'ghost'}
|
||||||
size="icon"
|
size="icon"
|
||||||
className="w-8 h-8 md:w-10 md:h-10 lg:w-12 lg:h-12"
|
className="w-11 h-11 lg:w-12 lg:h-12"
|
||||||
onClick={() => onDrawModeChange(tool.mode)}
|
onClick={() => onDrawModeChange(tool.mode)}
|
||||||
disabled={!canEdit && tool.mode !== 'select'}
|
disabled={!canEdit && tool.mode !== 'select'}
|
||||||
>
|
>
|
||||||
<tool.icon className="w-4 h-4 md:w-5 md:h-5 lg:w-6 lg:h-6" />
|
<tool.icon className="w-5 h-5 lg:w-6 lg:h-6" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
@@ -98,20 +98,20 @@ export function LeftToolbar({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-6 md:w-8 lg:w-12 h-px bg-border my-1" />
|
<div className="w-8 lg:w-12 h-px bg-border my-1" />
|
||||||
|
|
||||||
{/* Undo/Redo - 2-col grid to save vertical space */}
|
{/* Undo/Redo — mobil gestapelt (44px), Desktop 2-spaltig */}
|
||||||
<div className="grid grid-cols-2 gap-px md:gap-0.5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0.5">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="w-5 h-5 md:w-6 md:h-6 lg:w-8 lg:h-8"
|
className="w-11 h-11 lg:w-8 lg:h-8"
|
||||||
onClick={onUndo}
|
onClick={onUndo}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
>
|
>
|
||||||
<Undo2 className="w-3.5 h-3.5 md:w-4 md:h-4 lg:w-5 lg:h-5" />
|
<Undo2 className="w-5 h-5 lg:w-5 lg:h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
@@ -124,11 +124,11 @@ export function LeftToolbar({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="w-5 h-5 md:w-6 md:h-6 lg:w-8 lg:h-8"
|
className="w-11 h-11 lg:w-8 lg:h-8"
|
||||||
onClick={onRedo}
|
onClick={onRedo}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
>
|
>
|
||||||
<Redo2 className="w-3.5 h-3.5 md:w-4 md:h-4 lg:w-5 lg:h-5" />
|
<Redo2 className="w-5 h-5 lg:w-5 lg:h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
@@ -138,15 +138,15 @@ export function LeftToolbar({
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-6 md:w-8 lg:w-12 h-px bg-border my-1" />
|
<div className="w-8 lg:w-12 h-px bg-border my-1" />
|
||||||
|
|
||||||
{/* Color Picker - 2-col grid */}
|
{/* Color Picker - 2-col grid, grössere Swatches für Touch */}
|
||||||
<div className="grid grid-cols-2 gap-0.5 md:gap-1">
|
<div className="grid grid-cols-2 gap-1">
|
||||||
{colors.map((color) => (
|
{colors.map((color) => (
|
||||||
<button
|
<button
|
||||||
key={color.value}
|
key={color.value}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-4 h-4 md:w-5 md:h-5 lg:w-7 lg:h-7 rounded border-2 transition-all',
|
'w-6 h-6 lg:w-7 lg:h-7 rounded border-2 transition-all',
|
||||||
selectedColor === color.value
|
selectedColor === color.value
|
||||||
? 'border-primary ring-1 ring-primary ring-offset-1 scale-110'
|
? 'border-primary ring-1 ring-primary ring-offset-1 scale-110'
|
||||||
: 'border-muted hover:border-muted-foreground'
|
: 'border-muted hover:border-muted-foreground'
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface TenantSymbolGroup {
|
|||||||
|
|
||||||
interface RightSidebarProps {
|
interface RightSidebarProps {
|
||||||
onSymbolDrop: (iconId: string, coordinates: [number, number], imageUrl?: string) => void
|
onSymbolDrop: (iconId: string, coordinates: [number, number], imageUrl?: string) => void
|
||||||
|
onSymbolSelect?: (symbol: { id: string; imageUrl: string }) => void
|
||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
isOpen?: boolean
|
isOpen?: boolean
|
||||||
onToggle?: () => void
|
onToggle?: () => void
|
||||||
@@ -56,9 +57,10 @@ const categoryIcons: Record<string, typeof Flame> = {
|
|||||||
'Eigene': Upload,
|
'Eigene': Upload,
|
||||||
}
|
}
|
||||||
|
|
||||||
function DraggableSymbol({ symbol, canEdit }: {
|
function DraggableSymbol({ symbol, canEdit, onSelect }: {
|
||||||
symbol: DisplaySymbol
|
symbol: DisplaySymbol
|
||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
|
onSelect?: (symbol: DisplaySymbol) => void
|
||||||
}) {
|
}) {
|
||||||
const [{ isDragging }, drag, preview] = useDrag(() => ({
|
const [{ isDragging }, drag, preview] = useDrag(() => ({
|
||||||
type: 'SYMBOL',
|
type: 'SYMBOL',
|
||||||
@@ -77,6 +79,7 @@ function DraggableSymbol({ symbol, canEdit }: {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={drag as unknown as React.LegacyRef<HTMLDivElement>}
|
ref={drag as unknown as React.LegacyRef<HTMLDivElement>}
|
||||||
|
onClick={() => { if (canEdit && onSelect) onSelect(symbol) }}
|
||||||
className={`
|
className={`
|
||||||
flex flex-col items-center gap-1 p-1.5 md:p-2 lg:p-2.5 rounded-lg border-2 transition-all
|
flex flex-col items-center gap-1 p-1.5 md:p-2 lg:p-2.5 rounded-lg border-2 transition-all
|
||||||
border-transparent hover:border-border hover:bg-accent
|
border-transparent hover:border-border hover:bg-accent
|
||||||
@@ -84,7 +87,7 @@ function DraggableSymbol({ symbol, canEdit }: {
|
|||||||
${isDragging ? 'opacity-0' : ''}
|
${isDragging ? 'opacity-0' : ''}
|
||||||
${!canEdit ? 'opacity-50 cursor-not-allowed' : ''}
|
${!canEdit ? 'opacity-50 cursor-not-allowed' : ''}
|
||||||
`}
|
`}
|
||||||
title={symbol.name}
|
title={canEdit ? `${symbol.name} — antippen zum Platzieren, oder auf die Karte ziehen` : symbol.name}
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 md:w-11 md:h-11 lg:w-14 lg:h-14 flex items-center justify-center rounded-lg bg-white border border-gray-200 dark:border-gray-600">
|
<div className="w-10 h-10 md:w-11 md:h-11 lg:w-14 lg:h-14 flex items-center justify-center rounded-lg bg-white border border-gray-200 dark:border-gray-600">
|
||||||
<img
|
<img
|
||||||
@@ -101,7 +104,7 @@ function DraggableSymbol({ symbol, canEdit }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTab, onTabChange, isCollapsed, onToggleCollapse, tenantId, mode }: RightSidebarProps) {
|
export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, onToggle, activeTab, onTabChange, isCollapsed, onToggleCollapse, tenantId, mode }: RightSidebarProps) {
|
||||||
const journalLabel = mode === 'UEBUNG' ? 'Auswertung' : 'Journal'
|
const journalLabel = mode === 'UEBUNG' ? 'Auswertung' : 'Journal'
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [activeCategory, setActiveCategory] = useState<string>('')
|
const [activeCategory, setActiveCategory] = useState<string>('')
|
||||||
@@ -206,17 +209,6 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile toggle button */}
|
|
||||||
{!isOpen && onToggle && (
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className="md:hidden fixed bottom-4 right-4 z-50 w-14 h-14 bg-primary text-primary-foreground rounded-full shadow-lg flex items-center justify-center active:scale-95 transition-transform"
|
|
||||||
title="Symbole"
|
|
||||||
>
|
|
||||||
<LayoutGrid className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Backdrop on mobile */}
|
{/* Backdrop on mobile */}
|
||||||
{isOpen && onToggle && (
|
{isOpen && onToggle && (
|
||||||
<div className="md:hidden fixed inset-0 bg-black/40 z-40" onClick={onToggle} />
|
<div className="md:hidden fixed inset-0 bg-black/40 z-40" onClick={onToggle} />
|
||||||
@@ -375,7 +367,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
<div className="px-1.5 pb-1.5">
|
<div className="px-1.5 pb-1.5">
|
||||||
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
|
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
|
||||||
{g.symbols.map(symbol => (
|
{g.symbols.map(symbol => (
|
||||||
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} />
|
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} onSelect={onSymbolSelect} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -440,7 +432,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
|
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
|
||||||
{currentCategory.symbols.map((symbol) => (
|
{currentCategory.symbols.map((symbol) => (
|
||||||
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} />
|
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} onSelect={onSymbolSelect} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -355,6 +355,12 @@ export function Topbar({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
{project && onEndProject && (
|
||||||
|
<DropdownMenuItem onClick={onEndProject} className="text-red-600 focus:text-red-700">
|
||||||
|
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||||
|
{project.mode === 'UEBUNG' ? 'Übung beenden' : 'Einsatz beenden'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem onClick={onToggleTheme}>
|
<DropdownMenuItem onClick={onToggleTheme}>
|
||||||
{isDarkMode ? <Sun className="w-4 h-4 mr-2" /> : <Moon className="w-4 h-4 mr-2" />}
|
{isDarkMode ? <Sun className="w-4 h-4 mr-2" /> : <Moon className="w-4 h-4 mr-2" />}
|
||||||
{isDarkMode ? 'Tagmodus' : 'Nachtmodus'}
|
{isDarkMode ? 'Tagmodus' : 'Nachtmodus'}
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ interface MapViewProps {
|
|||||||
mapRef?: React.MutableRefObject<maplibregl.Map | null>
|
mapRef?: React.MutableRefObject<maplibregl.Map | null>
|
||||||
onDrawModeChange?: (mode: DrawMode) => void
|
onDrawModeChange?: (mode: DrawMode) => void
|
||||||
undoDrawPointRef?: React.MutableRefObject<(() => boolean) | null>
|
undoDrawPointRef?: React.MutableRefObject<(() => boolean) | null>
|
||||||
|
/** Tap-to-place: wenn gesetzt, platziert der nächste Karten-Tipp dieses Symbol (Mobil-freundlich). */
|
||||||
|
pendingSymbol?: { iconId: string; imageUrl?: string } | null
|
||||||
|
onSymbolPlaced?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapView({
|
export function MapView({
|
||||||
@@ -100,6 +103,8 @@ export function MapView({
|
|||||||
mapRef: externalMapRef,
|
mapRef: externalMapRef,
|
||||||
onDrawModeChange,
|
onDrawModeChange,
|
||||||
undoDrawPointRef,
|
undoDrawPointRef,
|
||||||
|
pendingSymbol,
|
||||||
|
onSymbolPlaced,
|
||||||
}: MapViewProps) {
|
}: MapViewProps) {
|
||||||
const mapContainer = useRef<HTMLDivElement | null>(null)
|
const mapContainer = useRef<HTMLDivElement | null>(null)
|
||||||
const map = useRef<maplibregl.Map | null>(null)
|
const map = useRef<maplibregl.Map | null>(null)
|
||||||
@@ -165,6 +170,8 @@ export function MapView({
|
|||||||
const onSymbolDropRef = useRef(onSymbolDrop)
|
const onSymbolDropRef = useRef(onSymbolDrop)
|
||||||
const onTextPlaceRef = useRef(onTextPlace)
|
const onTextPlaceRef = useRef(onTextPlace)
|
||||||
const onDrawModeChangeRef = useRef(onDrawModeChange)
|
const onDrawModeChangeRef = useRef(onDrawModeChange)
|
||||||
|
const pendingSymbolRef = useRef(pendingSymbol)
|
||||||
|
const onSymbolPlacedRef = useRef(onSymbolPlaced)
|
||||||
|
|
||||||
// Lock/unlock map panning based on draw mode
|
// Lock/unlock map panning based on draw mode
|
||||||
// Apple-style: touchZoomRotate (two-finger pinch/zoom) ALWAYS enabled
|
// Apple-style: touchZoomRotate (two-finger pinch/zoom) ALWAYS enabled
|
||||||
@@ -191,6 +198,8 @@ export function MapView({
|
|||||||
useEffect(() => { onSymbolDropRef.current = onSymbolDrop }, [onSymbolDrop])
|
useEffect(() => { onSymbolDropRef.current = onSymbolDrop }, [onSymbolDrop])
|
||||||
useEffect(() => { onTextPlaceRef.current = onTextPlace }, [onTextPlace])
|
useEffect(() => { onTextPlaceRef.current = onTextPlace }, [onTextPlace])
|
||||||
useEffect(() => { onDrawModeChangeRef.current = onDrawModeChange }, [onDrawModeChange])
|
useEffect(() => { onDrawModeChangeRef.current = onDrawModeChange }, [onDrawModeChange])
|
||||||
|
useEffect(() => { pendingSymbolRef.current = pendingSymbol }, [pendingSymbol])
|
||||||
|
useEffect(() => { onSymbolPlacedRef.current = onSymbolPlaced }, [onSymbolPlaced])
|
||||||
|
|
||||||
// Expose undo-draw-point function to parent via ref
|
// Expose undo-draw-point function to parent via ref
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -801,12 +810,12 @@ export function MapView({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Line layer
|
// Line layer — alle Linien AUSSER Rettungsachse (die bekommt ihren eigenen gestrichelten Layer)
|
||||||
m.addLayer({
|
m.addLayer({
|
||||||
id: 'draw-lines',
|
id: 'draw-lines',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'draw-features',
|
source: 'draw-features',
|
||||||
filter: ['==', ['geometry-type'], 'LineString'],
|
filter: ['all', ['==', ['geometry-type'], 'LineString'], ['!=', ['get', 'lineType'], 'rettungsachse']],
|
||||||
layout: {
|
layout: {
|
||||||
'line-cap': 'round',
|
'line-cap': 'round',
|
||||||
'line-join': 'round',
|
'line-join': 'round',
|
||||||
@@ -817,6 +826,23 @@ export function MapView({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Rettungsachse — gestrichelt, damit sie als freizuhaltende Achse klar erkennbar ist
|
||||||
|
m.addLayer({
|
||||||
|
id: 'draw-lines-rettungsachse',
|
||||||
|
type: 'line',
|
||||||
|
source: 'draw-features',
|
||||||
|
filter: ['all', ['==', ['geometry-type'], 'LineString'], ['==', ['get', 'lineType'], 'rettungsachse']],
|
||||||
|
layout: {
|
||||||
|
'line-cap': 'butt',
|
||||||
|
'line-join': 'round',
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'line-color': ['get', 'color'],
|
||||||
|
'line-width': ['coalesce', ['get', 'width'], 6],
|
||||||
|
'line-dasharray': [2, 1.5],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// Point layer
|
// Point layer
|
||||||
m.addLayer({
|
m.addLayer({
|
||||||
id: 'draw-points',
|
id: 'draw-points',
|
||||||
@@ -891,6 +917,15 @@ export function MapView({
|
|||||||
const width = selectedWidthRef.current
|
const width = selectedWidthRef.current
|
||||||
const coords: [number, number] = [e.lngLat.lng, e.lngLat.lat]
|
const coords: [number, number] = [e.lngLat.lng, e.lngLat.lat]
|
||||||
|
|
||||||
|
// Tap-to-place: ein zuvor angetipptes Symbol wird hier platziert (Mobil-freundlich,
|
||||||
|
// Alternative zum Drag&Drop). Hat Vorrang vor allen Zeichen-Modi.
|
||||||
|
if (pendingSymbolRef.current) {
|
||||||
|
const p = pendingSymbolRef.current
|
||||||
|
onSymbolDropRef.current(p.iconId, coords, p.imageUrl)
|
||||||
|
onSymbolPlacedRef.current?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === 'select') {
|
if (mode === 'select') {
|
||||||
// Detect click near a line/polygon for vertex editing
|
// Detect click near a line/polygon for vertex editing
|
||||||
const pixel = e.point
|
const pixel = e.point
|
||||||
@@ -1360,6 +1395,7 @@ export function MapView({
|
|||||||
color: (f.properties.color as string) || '#000000',
|
color: (f.properties.color as string) || '#000000',
|
||||||
width: (f.properties.width as number) || 3,
|
width: (f.properties.width as number) || 3,
|
||||||
isDangerZone: f.properties.isDangerZone ? 1 : 0,
|
isDangerZone: f.properties.isDangerZone ? 1 : 0,
|
||||||
|
lineType: (f.properties.lineType as string) || 'normal',
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user