feat(ux): 3-fach entwirrt – Auto-Bearbeitung, Status-Zeile, flache Symbol-Sidebar (v1.4.16)

Auto-Bearbeitung (Lock vereinfacht):
- Kein manuelles "Bearbeitung starten" mehr: beim Werkzeugwählen/Zeichnen/Symbol-
  Antippen wird der Editier-Lock still übernommen (ensureEditing), sofern die Karte
  frei ist. Lock/Konflikt-Anzeige erscheint nur noch bei echtem Mehrpersonen-Zugriff.
- handleStartEditing bekommt silent-Option + boolean-Rückgabe.
- "Karte freigeben" bleibt als kleine Aktion für Übergabe.

Banner → eine kompakte Status-Zeile:
- Übung/Offline/E-Mail/Bearbeitung nicht mehr bis zu 4 Zeilen gestapelt, sondern
  eine einzige Chip-Zeile, die nur zeigt was zutrifft (sonst gar nichts).

Symbol-Sidebar flacher:
- Statt zwei verschachtelter Klapp-Sektionen (Meine Symbole/Bibliothek) mit Unter-
  Kategorie-Tabs jetzt EINE Chip-Reihe (Mandant + Bibliothek) + ein Raster.
- Suche zeigt Treffer flach über alle Kategorien.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pepe Ziberi
2026-07-19 17:52:17 +02:00
parent efedbdba4d
commit b1451b665a
4 changed files with 159 additions and 240 deletions

View File

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

View File

@@ -19,7 +19,7 @@ import { useAuth } from '@/components/providers/auth-provider'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { JournalView } from '@/components/journal/journal-view'
import { Lock, Unlock, Eye, AlertTriangle, WifiOff, GraduationCap, Map as MapIcon, ClipboardList, LayoutGrid, X } from 'lucide-react'
import { Lock, Eye, AlertTriangle, WifiOff, GraduationCap, Map as MapIcon, ClipboardList, LayoutGrid, X } from 'lucide-react'
import { CustomDragLayer } from '@/components/map/custom-drag-layer'
import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour'
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
@@ -391,6 +391,20 @@ export default function AppPage() {
const canEdit = canEditMap // Abwärtskompatibel für Karten-bezogene Stellen
const isReadOnly = !!editingBy && !isEditingByMe
// Auto-Bearbeitung: sobald ein berechtigter Nutzer zu zeichnen beginnt und die Karte
// frei ist, wird der Editier-Lock still übernommen — kein manuelles „Bearbeitung starten".
const ensureEditing = useCallback(() => {
if (!currentProject || presentationLocked || !roleCanEdit) return
if (isEditingByMe || editingBy) return // ich zeichne schon / jemand anderes hält den Lock
handleStartEditing({ silent: true })
}, [currentProject, presentationLocked, roleCanEdit, isEditingByMe, editingBy, handleStartEditing])
// Werkzeugwechsel auf ein Zeichen-Tool übernimmt vorab den Lock (rechtzeitig vor dem Zeichnen)
const handleDrawModeChange = useCallback((mode: DrawMode) => {
if (mode !== 'select') ensureEditing()
setDrawMode(mode)
}, [ensureEditing, setDrawMode])
// Features-Version bei Projektwechsel initialisieren
useEffect(() => {
featuresVersionRef.current = (currentProject as any)?.featuresVersion ?? 0
@@ -647,6 +661,7 @@ export default function AppPage() {
}
const handleFeaturesChange = useCallback((newFeatures: DrawFeature[]) => {
ensureEditing() // Auto-Bearbeitung: Lock übernehmen, falls noch nicht geschehen
undoStackRef.current.push(featuresRef.current)
redoStackRef.current = []
@@ -666,7 +681,7 @@ export default function AppPage() {
setFeatures(newFeatures)
broadcastFeatures(newFeatures)
}, [addAudit, broadcastFeatures])
}, [addAudit, broadcastFeatures, ensureEditing])
// Wendet den gewählten Linientyp an: setzt properties.lineType und bei Leitung/
// Rettungsachse die konventionelle Farbe/Stärke (Normal behält die Zeichenfarbe).
@@ -723,12 +738,13 @@ export default function AppPage() {
// Tap-to-place: Symbol antippen → Sidebar schliessen, Karte zeigen, nächster Tipp platziert
const handleSymbolSelect = useCallback((symbol: { id: string; imageUrl: string }) => {
ensureEditing() // Lock schon beim Auswählen übernehmen, damit das Platzieren sofort greift
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])
}, [setIsSidebarOpen, setActiveTab, toast, ensureEditing, setDrawMode])
const handleTextPlace = useCallback((coordinates: [number, number]) => {
setTextPlaceCoords(coordinates)
@@ -909,82 +925,55 @@ export default function AppPage() {
}}
/>
{/* Übungs-Banner — verhindert Verwechslung mit echtem Einsatz */}
{currentProject?.mode === 'UEBUNG' && (
<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" />
<span>ÜBUNG kein realer Einsatz</span>
</div>
)}
{/* Offline banner */}
{isOffline && (
<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" />
<span>
<strong>Offline</strong><span className="hidden sm:inline">-Modus Änderungen werden lokal gespeichert und beim Reconnect synchronisiert.</span>
{syncQueueCount > 0 && ` (${syncQueueCount} ausstehend)`}
</span>
</div>
)}
{/* Email verification banner */}
{user && user.emailVerified === false && (
<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" />
<span>E-Mail noch nicht bestätigt.<span className="hidden sm:inline"> Bitte prüfen Sie Ihren Posteingang.</span></span>
<button
onClick={async () => {
try {
const res = await fetch('/api/auth/resend-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: user.email }),
})
if (res.ok) toast({ title: 'Bestätigungsmail gesendet', description: 'Bitte prüfen Sie Ihren Posteingang.' })
else toast({ title: 'Fehler', description: 'Konnte Bestätigungsmail nicht senden.', variant: 'destructive' })
} catch { toast({ title: 'Fehler', description: 'Verbindungsfehler.', variant: 'destructive' }) }
}}
className="shrink-0 text-xs font-semibold underline hover:no-underline text-amber-700 dark:text-amber-400"
>
Erneut senden
</button>
</div>
)}
{/* Live editing banner */}
{currentProject && (
<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 ? (
<div className="flex items-center gap-2 text-orange-600 min-w-0">
<Eye className="w-4 h-4 shrink-0" />
<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>
) : isEditingByMe ? (
<div className="flex items-center gap-2 text-green-600 min-w-0">
<Lock className="w-4 h-4 shrink-0" />
<span className="truncate">Sie bearbeiten die Karte<span className="hidden sm:inline"> andere sehen Ihre Änderungen in Echtzeit</span></span>
</div>
) : (
<div className="flex items-center gap-2 text-muted-foreground min-w-0">
<Unlock className="w-4 h-4 shrink-0" />
<span className="truncate">Niemand bearbeitet gerade</span>
</div>
{/* Kompakte Status-Zeile — eine Zeile mit Chips statt bis zu vier gestapelten Bannern.
Zeigt nur, was gerade zutrifft; im Normalfall (Einsatz, online, verifiziert,
niemand fremd am Zeichnen) erscheint sie gar nicht. */}
{(currentProject?.mode === 'UEBUNG' || isOffline || isReadOnly || isEditingByMe || (user && user.emailVerified === false)) && (
<div data-tour="edit-toggle" className="flex flex-wrap items-center gap-1.5 px-3 py-1 border-b bg-muted/30 text-xs">
{currentProject?.mode === 'UEBUNG' && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-600 text-white font-semibold px-2 py-0.5">
<GraduationCap className="w-3.5 h-3.5" /> ÜBUNG kein realer Einsatz
</span>
)}
{isOffline && (
<span className="inline-flex items-center gap-1 rounded-full bg-orange-100 text-orange-800 dark:bg-orange-950/50 dark:text-orange-300 font-medium px-2 py-0.5">
<WifiOff className="w-3.5 h-3.5" /> Offline{syncQueueCount > 0 ? ` · ${syncQueueCount} ausstehend` : ''}
</span>
)}
{isReadOnly && (
<span className="inline-flex items-center gap-1 rounded-full bg-orange-100 text-orange-800 dark:bg-orange-950/50 dark:text-orange-300 font-medium px-2 py-0.5">
<Eye className="w-3.5 h-3.5" /> <strong className="font-semibold">{editingBy?.name}</strong> bearbeitet · Live-Ansicht
</span>
)}
{isEditingByMe && (
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 text-green-800 dark:bg-green-950/50 dark:text-green-300 font-medium px-2 py-0.5">
<Lock className="w-3.5 h-3.5" /> Sie bearbeiten
<button onClick={handleStopEditing} disabled={editingLoading} className="ml-1 underline hover:no-underline disabled:opacity-50" title="Karte für andere freigeben (speichert)">
freigeben
</button>
</span>
)}
{user && user.emailVerified === false && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300 font-medium px-2 py-0.5">
<AlertTriangle className="w-3.5 h-3.5" /> E-Mail unbestätigt
<button
onClick={async () => {
try {
const res = await fetch('/api/auth/resend-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: user.email }),
})
if (res.ok) toast({ title: 'Bestätigungsmail gesendet', description: 'Bitte prüfen Sie Ihren Posteingang.' })
else toast({ title: 'Fehler', description: 'Konnte Bestätigungsmail nicht senden.', variant: 'destructive' })
} catch { toast({ title: 'Fehler', description: 'Verbindungsfehler.', variant: 'destructive' }) }
}}
className="ml-1 underline hover:no-underline"
>
senden
</button>
</span>
)}
<div data-tour="edit-toggle" className="flex items-center gap-2 shrink-0">
{roleCanEdit && !isEditingByMe && !isReadOnly && (
<Button size="sm" variant="default" onClick={handleStartEditing} disabled={editingLoading}>
<Lock className="w-3.5 h-3.5 mr-1" />
Bearbeitung starten
</Button>
)}
{isEditingByMe && (
<Button size="sm" variant="outline" onClick={handleStopEditing} disabled={editingLoading}>
<Unlock className="w-3.5 h-3.5 mr-1" />
Bearbeitung beenden
</Button>
)}
</div>
</div>
)}
@@ -993,7 +982,7 @@ export default function AppPage() {
<div data-tour="toolbar" className={`contents ${activeTab !== 'map' ? 'hidden' : ''}`}>
<LeftToolbar
drawMode={drawMode || 'select'}
onDrawModeChange={setDrawMode}
onDrawModeChange={handleDrawModeChange}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
selectedWidth={selectedWidth}

View File

@@ -6,9 +6,9 @@ import { getEmptyImage } from 'react-dnd-html5-backend'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Search, Flame, Droplets, AlertTriangle, Car, Users,
Truck, Building, Target, Upload, Loader2, X, LayoutGrid,
ChevronLeft, ChevronRight, Map, ClipboardList, PanelRightClose, PanelRightOpen,
Search, Flame, Droplets, AlertTriangle,
Building, Target, Upload, Loader2, X,
Map, ClipboardList, PanelRightClose, PanelRightOpen,
Shield, Wrench, Radio, MoreHorizontal, Heart, FolderOpen,
} from 'lucide-react'
@@ -107,13 +107,10 @@ function DraggableSymbol({ symbol, canEdit, onSelect }: {
export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, onToggle, activeTab, onTabChange, isCollapsed, onToggleCollapse, tenantId, mode }: RightSidebarProps) {
const journalLabel = mode === 'UEBUNG' ? 'Auswertung' : 'Journal'
const [searchQuery, setSearchQuery] = useState('')
const [activeCategory, setActiveCategory] = useState<string>('')
const [categories, setCategories] = useState<DisplayCategory[]>([])
const [tenantGroups, setTenantGroups] = useState<TenantSymbolGroup[]>([])
const [isLoading, setIsLoading] = useState(true)
const [showTenantSection, setShowTenantSection] = useState(true)
const [showLibrarySection, setShowLibrarySection] = useState(true)
const [expandedTenantCats, setExpandedTenantCats] = useState<Set<string>>(new Set())
const [activeGroupId, setActiveGroupId] = useState<string>('')
useEffect(() => {
async function fetchIcons() {
@@ -139,11 +136,7 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
// Separate tenant-specific legacy "Eigene" category from global library
const eigene = allCats.find(c => c.name === 'Eigene')
const globalCats = allCats.filter(c => c.name !== 'Eigene')
setCategories(globalCats)
if (globalCats.length > 0 && !activeCategory) {
setActiveCategory(globalCats[0].id)
}
// ─── New tenant symbol groups (Phase 1) ───
const groups: TenantSymbolGroup[] = (data.tenantSymbolGroups || []).map((g: any) => ({
@@ -157,20 +150,10 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
// Merge legacy "Eigene" into tenant groups if present
if (eigene && eigene.symbols.length > 0) {
const legacyGroup: TenantSymbolGroup = {
category: { id: '__legacy__', name: 'Eigene' },
symbols: eigene.symbols,
}
groups.unshift(legacyGroup)
groups.unshift({ category: { id: '__legacy__', name: 'Eigene' }, symbols: eigene.symbols })
}
setTenantGroups(groups)
// Auto-expand all tenant groups, auto-collapse library if tenant has symbols
if (groups.length > 0 && groups.some(g => g.symbols.length > 0)) {
setExpandedTenantCats(new Set(groups.map(g => g.category?.id || '__none__')))
setShowLibrarySection(false)
}
}
} catch (err) {
console.error('Failed to load icons:', err)
@@ -181,31 +164,35 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
fetchIcons()
}, [tenantId])
const toggleTenantCat = (catId: string | null) => {
const key = catId || '__none__'
setExpandedTenantCats(prev => {
const n = new Set(prev)
n.has(key) ? n.delete(key) : n.add(key)
return n
})
}
// ─── Flache Gruppen-Liste: Mandanten-Kategorien zuerst, dann Bibliothek — je ein Chip.
// Ersetzt die früheren verschachtelten Klapp-Sektionen (Meine Symbole / Bibliothek). ───
type SymbolGroup = { id: string; name: string; symbols: DisplaySymbol[]; isTenant: boolean }
const groups: SymbolGroup[] = [
...tenantGroups.map(g => ({
id: 'tenant:' + (g.category?.id || 'none'),
name: g.category?.name || 'Eigene',
symbols: g.symbols,
isTenant: true,
})),
...categories.map(c => ({ id: 'lib:' + c.id, name: c.name, symbols: c.symbols, isTenant: false })),
].filter(g => g.symbols.length > 0)
const filteredCategories = categories.map((cat) => ({
...cat,
symbols: cat.symbols.filter((s) =>
(s.name || '').toLowerCase().includes(searchQuery.toLowerCase())
),
}))
const filteredTenantGroups = tenantGroups.map(g => ({
...g,
symbols: g.symbols.filter(s =>
(s.name || '').toLowerCase().includes(searchQuery.toLowerCase())
),
})).filter(g => g.symbols.length > 0)
const totalSymbols = groups.reduce((sum, g) => sum + g.symbols.length, 0)
const currentCategory = filteredCategories.find((c) => c.id === activeCategory)
const totalSymbols = categories.reduce((sum, c) => sum + c.symbols.length, 0) +
tenantGroups.reduce((sum, g) => sum + g.symbols.length, 0)
const q = searchQuery.trim().toLowerCase()
const isSearching = q.length > 0
const searchResults = isSearching
? groups.flatMap(g => g.symbols).filter(s => (s.name || '').toLowerCase().includes(q))
: []
const activeGroup = groups.find(g => g.id === activeGroupId) || groups[0] || null
// Default-Gruppe wählen bzw. korrigieren, falls die aktuelle nicht mehr existiert
useEffect(() => {
if (groups.length && !groups.some(g => g.id === activeGroupId)) {
setActiveGroupId(groups[0].id)
}
}, [groups, activeGroupId])
return (
<>
@@ -325,121 +312,59 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
)}
<ScrollArea className="flex-1">
{/* ─── Section 1: Meine Symbole (Tenant Symbol Groups) ─── */}
{tenantId && (
<div className="border-b border-border">
<button
onClick={() => setShowTenantSection(!showTenantSection)}
className="w-full flex items-center justify-between px-2 py-1.5 text-xs font-semibold text-muted-foreground hover:bg-accent/50 transition-colors"
>
<span className="flex items-center gap-1.5">
<Upload className="w-3.5 h-3.5" />
Meine Symbole ({tenantGroups.reduce((s, g) => s + g.symbols.length, 0)})
</span>
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showTenantSection ? 'rotate-90' : ''}`} />
</button>
{showTenantSection && (
<div className="p-2 pt-0 space-y-1">
{filteredTenantGroups.length === 0 ? (
<div className="text-center text-muted-foreground py-4 text-xs">
Keine eigenen Symbole vorhanden.
<br />
<span className="text-[10px]">Symbole können unter Admin Symbole verwaltet werden.</span>
</div>
) : (
filteredTenantGroups.map(g => {
const key = g.category?.id || '__none__'
const expanded = expandedTenantCats.has(key)
return (
<div key={key} className="border rounded-md">
<button
onClick={() => toggleTenantCat(g.category?.id || null)}
className="w-full flex items-center justify-between px-2 py-1 text-[11px] font-medium hover:bg-muted/40 transition-colors"
>
<span className="flex items-center gap-1.5">
<FolderOpen className="w-3 h-3 text-muted-foreground" />
{g.category?.name || 'Ohne Kategorie'}
<span className="text-[10px] text-muted-foreground">({g.symbols.length})</span>
</span>
<ChevronRight className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
</button>
{expanded && (
<div className="px-1.5 pb-1.5">
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
{g.symbols.map(symbol => (
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} onSelect={onSymbolSelect} />
))}
</div>
</div>
)}
</div>
)
})
)}
</div>
)}
{!isLoading && groups.length === 0 && (
<div className="text-center text-muted-foreground py-8 px-3 text-xs">
Keine Symbole verfügbar.
<br />
<span className="text-[10px]">Eigene Symbole unter Admin Symbole verwalten.</span>
</div>
)}
{/* ─── Section 2: Bibliothek (Global categories) ─── */}
<div>
<button
onClick={() => setShowLibrarySection(!showLibrarySection)}
className="w-full flex items-center justify-between px-2 py-1.5 text-xs font-semibold text-muted-foreground hover:bg-accent/50 transition-colors"
>
<span className="flex items-center gap-1.5">
<LayoutGrid className="w-3.5 h-3.5" />
Bibliothek ({categories.reduce((s, c) => s + c.symbols.length, 0)})
</span>
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showLibrarySection ? 'rotate-90' : ''}`} />
</button>
{showLibrarySection && (
<>
{/* Category tabs */}
<div className="px-1.5 pb-1.5 md:px-2 md:pb-2">
<div className="flex flex-wrap gap-0.5 md:gap-1">
{categories.map((cat) => {
const IconComponent = categoryIcons[cat.name] || Target
return (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`
flex items-center gap-1 px-2 py-1.5 md:px-2.5 md:py-1.5 lg:px-3 lg:py-2 rounded-md md:rounded-lg text-xs md:text-sm font-medium transition-colors
${activeCategory === cat.id
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-accent text-muted-foreground'}
`}
title={`${cat.name} (${cat.symbols.length})`}
>
<IconComponent className="w-3.5 h-3.5 md:w-4 md:h-4" />
<span className="hidden lg:inline max-w-[70px] truncate">{cat.name}</span>
</button>
)
})}
</div>
</div>
{/* Kategorie-Chips (Mandant + Bibliothek in einer Reihe) — nur eine Ebene */}
{!isSearching && groups.length > 0 && (
<div className="sticky top-0 z-[1] bg-card/95 backdrop-blur-sm px-1.5 py-1.5 border-b border-border">
<div className="flex flex-wrap gap-1">
{groups.map(g => {
const IconComponent = g.isTenant ? FolderOpen : (categoryIcons[g.name] || Target)
const active = activeGroup?.id === g.id
return (
<button
key={g.id}
onClick={() => setActiveGroupId(g.id)}
className={`flex items-center gap-1 px-2 py-1.5 rounded-md text-xs font-medium transition-colors ${
active ? 'bg-primary text-primary-foreground' : 'bg-muted hover:bg-accent text-muted-foreground'
}`}
title={`${g.name} (${g.symbols.length})`}
>
<IconComponent className="w-3.5 h-3.5 shrink-0" />
<span className="max-w-[90px] truncate">{g.name}</span>
<span className="opacity-60">{g.symbols.length}</span>
</button>
)
})}
</div>
</div>
)}
{currentCategory && (
<div className="p-2 pt-0">
<h4 className="text-xs md:text-sm font-medium text-muted-foreground mb-1.5">
{currentCategory.name} ({currentCategory.symbols.length})
</h4>
{currentCategory.symbols.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">
Keine Symbole gefunden
</div>
) : (
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
{currentCategory.symbols.map((symbol) => (
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} onSelect={onSymbolSelect} />
))}
</div>
)}
</div>
)}
</>
)}
{/* Symbol-Raster: Suchergebnisse ODER aktive Kategorie */}
<div className="p-2">
{isSearching ? (
searchResults.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">Keine Symbole gefunden</div>
) : (
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
{searchResults.map(symbol => (
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} onSelect={onSymbolSelect} />
))}
</div>
)
) : activeGroup ? (
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
{activeGroup.symbols.map(symbol => (
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} onSelect={onSymbolSelect} />
))}
</div>
) : null}
</div>
</ScrollArea>
</>

View File

@@ -186,8 +186,9 @@ export function useRealtimeSync({
}
}, [currentProject?.id, isEditingByMe])
const handleStartEditing = useCallback(async () => {
if (!currentProject?.id) return
// Rückgabe true = Lock erhalten. `silent` unterdrückt den Erfolgs-Toast (für Auto-Bearbeitung).
const handleStartEditing = useCallback(async (opts?: { silent?: boolean }): Promise<boolean> => {
if (!currentProject?.id) return false
setEditingLoading(true)
try {
const res = await fetch(`/api/projects/${currentProject.id}/editing`, {
@@ -198,7 +199,7 @@ export function useRealtimeSync({
if (!res.ok) {
const data = await res.json()
toast({ title: 'Gesperrt', description: data.error || 'Bearbeitung nicht möglich', variant: 'destructive' })
return
return false
}
setIsEditingByMe(true)
const editingInfo = { id: user!.id, name: user!.name, since: new Date().toISOString() }
@@ -210,9 +211,13 @@ export function useRealtimeSync({
editingBy: editingInfo,
sessionId: sessionIdRef.current,
})
toast({ title: 'Bearbeitung gestartet', description: 'Sie können jetzt zeichnen und Einträge erstellen.' })
if (!opts?.silent) {
toast({ title: 'Bearbeitung gestartet', description: 'Sie können jetzt zeichnen und Einträge erstellen.' })
}
return true
} catch (e) {
toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht starten.', variant: 'destructive' })
return false
} finally {
setEditingLoading(false)
}