feat(mobile): Handy-Optimierung – Tap-to-place, Bottom-Nav, kompakte Banner (v1.4.12)

- Tap-to-place: Symbol antippen → auf Karte tippen platziert es (Alternative zu Drag&Drop, mobil-tauglich); schwebender Hinweis mit Abbrechen
- Mobile Bottom-Navigation: Karte / Journal(Auswertung) / Symbole ohne Menü erreichbar
- FAB entfernt; Karten-Steuerung (Zoom/Standort/Massstab) hebt sich mobil über die Bottom-Nav
- Touch-Ziele in der Werkzeugleiste auf 44px vergrössert
- "Einsatz/Übung beenden" jetzt auch im Mobile-Overflow-Menü
- Banner (Übung/Offline/E-Mail/Live) mobil kompakt: kleinere Höhe, gekürzte Texte, truncate
- Einsatz-beenden-Bestätigung als In-App-Dialog statt Browser-confirm()

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pepe Ziberi
2026-07-19 15:20:27 +02:00
parent 947a757d9b
commit 6e3663cf7f
7 changed files with 161 additions and 55 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "lageplan", "name": "lageplan",
"version": "1.4.11", "version": "1.4.12",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -17,9 +17,9 @@ import { LineLabelDialog } 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 */ } }
@@ -701,6 +705,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 +865,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 +895,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 +903,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 +914,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 +938,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 +1003,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 +1044,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 +1057,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 +1106,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}

View File

@@ -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;

View File

@@ -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'

View File

@@ -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>
)} )}

View File

@@ -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'}

View File

@@ -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(() => {
@@ -891,6 +900,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