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:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.4.15",
|
"version": "1.4.16",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { useAuth } from '@/components/providers/auth-provider'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } 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, 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 { 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'
|
||||||
@@ -391,6 +391,20 @@ export default function AppPage() {
|
|||||||
const canEdit = canEditMap // Abwärtskompatibel für Karten-bezogene Stellen
|
const canEdit = canEditMap // Abwärtskompatibel für Karten-bezogene Stellen
|
||||||
const isReadOnly = !!editingBy && !isEditingByMe
|
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
|
// Features-Version bei Projektwechsel initialisieren
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
featuresVersionRef.current = (currentProject as any)?.featuresVersion ?? 0
|
featuresVersionRef.current = (currentProject as any)?.featuresVersion ?? 0
|
||||||
@@ -647,6 +661,7 @@ export default function AppPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleFeaturesChange = useCallback((newFeatures: DrawFeature[]) => {
|
const handleFeaturesChange = useCallback((newFeatures: DrawFeature[]) => {
|
||||||
|
ensureEditing() // Auto-Bearbeitung: Lock übernehmen, falls noch nicht geschehen
|
||||||
undoStackRef.current.push(featuresRef.current)
|
undoStackRef.current.push(featuresRef.current)
|
||||||
redoStackRef.current = []
|
redoStackRef.current = []
|
||||||
|
|
||||||
@@ -666,7 +681,7 @@ export default function AppPage() {
|
|||||||
|
|
||||||
setFeatures(newFeatures)
|
setFeatures(newFeatures)
|
||||||
broadcastFeatures(newFeatures)
|
broadcastFeatures(newFeatures)
|
||||||
}, [addAudit, broadcastFeatures])
|
}, [addAudit, broadcastFeatures, ensureEditing])
|
||||||
|
|
||||||
// Wendet den gewählten Linientyp an: setzt properties.lineType und – bei Leitung/
|
// Wendet den gewählten Linientyp an: setzt properties.lineType und – bei Leitung/
|
||||||
// Rettungsachse – die konventionelle Farbe/Stärke (Normal behält die Zeichenfarbe).
|
// 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
|
// Tap-to-place: Symbol antippen → Sidebar schliessen, Karte zeigen, nächster Tipp platziert
|
||||||
const handleSymbolSelect = useCallback((symbol: { id: string; imageUrl: string }) => {
|
const handleSymbolSelect = useCallback((symbol: { id: string; imageUrl: string }) => {
|
||||||
|
ensureEditing() // Lock schon beim Auswählen übernehmen, damit das Platzieren sofort greift
|
||||||
setPendingSymbol(symbol)
|
setPendingSymbol(symbol)
|
||||||
setIsSidebarOpen(false)
|
setIsSidebarOpen(false)
|
||||||
setActiveTab('map')
|
setActiveTab('map')
|
||||||
setDrawMode('select')
|
setDrawMode('select')
|
||||||
toast({ title: 'Symbol platzieren', description: 'Tippe auf die Karte, um das Symbol zu setzen.' })
|
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]) => {
|
const handleTextPlace = useCallback((coordinates: [number, number]) => {
|
||||||
setTextPlaceCoords(coordinates)
|
setTextPlaceCoords(coordinates)
|
||||||
@@ -909,30 +925,37 @@ export default function AppPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Übungs-Banner — verhindert Verwechslung mit echtem Einsatz */}
|
{/* 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' && (
|
{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">
|
<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-4 h-4 shrink-0" />
|
<GraduationCap className="w-3.5 h-3.5" /> ÜBUNG — kein realer Einsatz
|
||||||
<span>ÜBUNG — kein realer Einsatz</span>
|
</span>
|
||||||
</div>
|
)}
|
||||||
)}
|
{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">
|
||||||
{/* Offline banner */}
|
<WifiOff className="w-3.5 h-3.5" /> Offline{syncQueueCount > 0 ? ` · ${syncQueueCount} ausstehend` : ''}
|
||||||
{isOffline && (
|
</span>
|
||||||
<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" />
|
{isReadOnly && (
|
||||||
<span>
|
<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">
|
||||||
<strong>Offline</strong><span className="hidden sm:inline">-Modus — Änderungen werden lokal gespeichert und beim Reconnect synchronisiert.</span>
|
<Eye className="w-3.5 h-3.5" /> <strong className="font-semibold">{editingBy?.name}</strong> bearbeitet · Live-Ansicht
|
||||||
{syncQueueCount > 0 && ` (${syncQueueCount} ausstehend)`}
|
</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>
|
</span>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Email verification banner */}
|
|
||||||
{user && user.emailVerified === false && (
|
{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">
|
<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-4 h-4 shrink-0" />
|
<AlertTriangle className="w-3.5 h-3.5" /> E-Mail unbestätigt
|
||||||
<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 {
|
||||||
@@ -945,46 +968,12 @@ export default function AppPage() {
|
|||||||
else toast({ title: 'Fehler', description: 'Konnte Bestätigungsmail nicht senden.', variant: 'destructive' })
|
else toast({ title: 'Fehler', description: 'Konnte Bestätigungsmail nicht senden.', variant: 'destructive' })
|
||||||
} catch { toast({ title: 'Fehler', description: 'Verbindungsfehler.', 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"
|
className="ml-1 underline hover:no-underline"
|
||||||
>
|
>
|
||||||
Erneut senden
|
senden
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
)}
|
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -993,7 +982,7 @@ export default function AppPage() {
|
|||||||
<div data-tour="toolbar" className={`contents ${activeTab !== 'map' ? 'hidden' : ''}`}>
|
<div data-tour="toolbar" className={`contents ${activeTab !== 'map' ? 'hidden' : ''}`}>
|
||||||
<LeftToolbar
|
<LeftToolbar
|
||||||
drawMode={drawMode || 'select'}
|
drawMode={drawMode || 'select'}
|
||||||
onDrawModeChange={setDrawMode}
|
onDrawModeChange={handleDrawModeChange}
|
||||||
selectedColor={selectedColor}
|
selectedColor={selectedColor}
|
||||||
onColorChange={setSelectedColor}
|
onColorChange={setSelectedColor}
|
||||||
selectedWidth={selectedWidth}
|
selectedWidth={selectedWidth}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { getEmptyImage } from 'react-dnd-html5-backend'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import {
|
import {
|
||||||
Search, Flame, Droplets, AlertTriangle, Car, Users,
|
Search, Flame, Droplets, AlertTriangle,
|
||||||
Truck, Building, Target, Upload, Loader2, X, LayoutGrid,
|
Building, Target, Upload, Loader2, X,
|
||||||
ChevronLeft, ChevronRight, Map, ClipboardList, PanelRightClose, PanelRightOpen,
|
Map, ClipboardList, PanelRightClose, PanelRightOpen,
|
||||||
Shield, Wrench, Radio, MoreHorizontal, Heart, FolderOpen,
|
Shield, Wrench, Radio, MoreHorizontal, Heart, FolderOpen,
|
||||||
} from 'lucide-react'
|
} 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) {
|
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 [categories, setCategories] = useState<DisplayCategory[]>([])
|
const [categories, setCategories] = useState<DisplayCategory[]>([])
|
||||||
const [tenantGroups, setTenantGroups] = useState<TenantSymbolGroup[]>([])
|
const [tenantGroups, setTenantGroups] = useState<TenantSymbolGroup[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [showTenantSection, setShowTenantSection] = useState(true)
|
const [activeGroupId, setActiveGroupId] = useState<string>('')
|
||||||
const [showLibrarySection, setShowLibrarySection] = useState(true)
|
|
||||||
const [expandedTenantCats, setExpandedTenantCats] = useState<Set<string>>(new Set())
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchIcons() {
|
async function fetchIcons() {
|
||||||
@@ -139,11 +136,7 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
|
|||||||
// Separate tenant-specific legacy "Eigene" category from global library
|
// Separate tenant-specific legacy "Eigene" category from global library
|
||||||
const eigene = allCats.find(c => c.name === 'Eigene')
|
const eigene = allCats.find(c => c.name === 'Eigene')
|
||||||
const globalCats = allCats.filter(c => c.name !== 'Eigene')
|
const globalCats = allCats.filter(c => c.name !== 'Eigene')
|
||||||
|
|
||||||
setCategories(globalCats)
|
setCategories(globalCats)
|
||||||
if (globalCats.length > 0 && !activeCategory) {
|
|
||||||
setActiveCategory(globalCats[0].id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── New tenant symbol groups (Phase 1) ───
|
// ─── New tenant symbol groups (Phase 1) ───
|
||||||
const groups: TenantSymbolGroup[] = (data.tenantSymbolGroups || []).map((g: any) => ({
|
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
|
// Merge legacy "Eigene" into tenant groups if present
|
||||||
if (eigene && eigene.symbols.length > 0) {
|
if (eigene && eigene.symbols.length > 0) {
|
||||||
const legacyGroup: TenantSymbolGroup = {
|
groups.unshift({ category: { id: '__legacy__', name: 'Eigene' }, symbols: eigene.symbols })
|
||||||
category: { id: '__legacy__', name: 'Eigene' },
|
|
||||||
symbols: eigene.symbols,
|
|
||||||
}
|
|
||||||
groups.unshift(legacyGroup)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTenantGroups(groups)
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to load icons:', err)
|
console.error('Failed to load icons:', err)
|
||||||
@@ -181,31 +164,35 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
|
|||||||
fetchIcons()
|
fetchIcons()
|
||||||
}, [tenantId])
|
}, [tenantId])
|
||||||
|
|
||||||
const toggleTenantCat = (catId: string | null) => {
|
// ─── Flache Gruppen-Liste: Mandanten-Kategorien zuerst, dann Bibliothek — je ein Chip.
|
||||||
const key = catId || '__none__'
|
// Ersetzt die früheren verschachtelten Klapp-Sektionen (Meine Symbole / Bibliothek). ───
|
||||||
setExpandedTenantCats(prev => {
|
type SymbolGroup = { id: string; name: string; symbols: DisplaySymbol[]; isTenant: boolean }
|
||||||
const n = new Set(prev)
|
const groups: SymbolGroup[] = [
|
||||||
n.has(key) ? n.delete(key) : n.add(key)
|
...tenantGroups.map(g => ({
|
||||||
return n
|
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 totalSymbols = groups.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])
|
||||||
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 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)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -325,121 +312,59 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
{/* ─── Section 1: Meine Symbole (Tenant Symbol Groups) ─── */}
|
{!isLoading && groups.length === 0 && (
|
||||||
{tenantId && (
|
<div className="text-center text-muted-foreground py-8 px-3 text-xs">
|
||||||
<div className="border-b border-border">
|
Keine Symbole verfügbar.
|
||||||
<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 />
|
<br />
|
||||||
<span className="text-[10px]">Symbole können unter Admin → Symbole verwaltet werden.</span>
|
<span className="text-[10px]">Eigene Symbole unter Admin → Symbole verwalten.</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── Section 2: Bibliothek (Global categories) ─── */}
|
{/* Kategorie-Chips (Mandant + Bibliothek in einer Reihe) — nur eine Ebene */}
|
||||||
<div>
|
{!isSearching && groups.length > 0 && (
|
||||||
<button
|
<div className="sticky top-0 z-[1] bg-card/95 backdrop-blur-sm px-1.5 py-1.5 border-b border-border">
|
||||||
onClick={() => setShowLibrarySection(!showLibrarySection)}
|
<div className="flex flex-wrap gap-1">
|
||||||
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"
|
{groups.map(g => {
|
||||||
>
|
const IconComponent = g.isTenant ? FolderOpen : (categoryIcons[g.name] || Target)
|
||||||
<span className="flex items-center gap-1.5">
|
const active = activeGroup?.id === g.id
|
||||||
<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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={cat.id}
|
key={g.id}
|
||||||
onClick={() => setActiveCategory(cat.id)}
|
onClick={() => setActiveGroupId(g.id)}
|
||||||
className={`
|
className={`flex items-center gap-1 px-2 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||||
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
|
active ? 'bg-primary text-primary-foreground' : 'bg-muted hover:bg-accent text-muted-foreground'
|
||||||
${activeCategory === cat.id
|
}`}
|
||||||
? 'bg-primary text-primary-foreground'
|
title={`${g.name} (${g.symbols.length})`}
|
||||||
: '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" />
|
<IconComponent className="w-3.5 h-3.5 shrink-0" />
|
||||||
<span className="hidden lg:inline max-w-[70px] truncate">{cat.name}</span>
|
<span className="max-w-[90px] truncate">{g.name}</span>
|
||||||
|
<span className="opacity-60">{g.symbols.length}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentCategory && (
|
{/* Symbol-Raster: Suchergebnisse ODER aktive Kategorie */}
|
||||||
<div className="p-2 pt-0">
|
<div className="p-2">
|
||||||
<h4 className="text-xs md:text-sm font-medium text-muted-foreground mb-1.5">
|
{isSearching ? (
|
||||||
{currentCategory.name} ({currentCategory.symbols.length})
|
searchResults.length === 0 ? (
|
||||||
</h4>
|
<div className="text-center text-muted-foreground py-8 text-sm">Keine Symbole gefunden</div>
|
||||||
{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">
|
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
|
||||||
{currentCategory.symbols.map((symbol) => (
|
{searchResults.map(symbol => (
|
||||||
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} onSelect={onSymbolSelect} />
|
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} onSelect={onSymbolSelect} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -186,8 +186,9 @@ export function useRealtimeSync({
|
|||||||
}
|
}
|
||||||
}, [currentProject?.id, isEditingByMe])
|
}, [currentProject?.id, isEditingByMe])
|
||||||
|
|
||||||
const handleStartEditing = useCallback(async () => {
|
// Rückgabe true = Lock erhalten. `silent` unterdrückt den Erfolgs-Toast (für Auto-Bearbeitung).
|
||||||
if (!currentProject?.id) return
|
const handleStartEditing = useCallback(async (opts?: { silent?: boolean }): Promise<boolean> => {
|
||||||
|
if (!currentProject?.id) return false
|
||||||
setEditingLoading(true)
|
setEditingLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/projects/${currentProject.id}/editing`, {
|
const res = await fetch(`/api/projects/${currentProject.id}/editing`, {
|
||||||
@@ -198,7 +199,7 @@ export function useRealtimeSync({
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
toast({ title: 'Gesperrt', description: data.error || 'Bearbeitung nicht möglich', variant: 'destructive' })
|
toast({ title: 'Gesperrt', description: data.error || 'Bearbeitung nicht möglich', variant: 'destructive' })
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
setIsEditingByMe(true)
|
setIsEditingByMe(true)
|
||||||
const editingInfo = { id: user!.id, name: user!.name, since: new Date().toISOString() }
|
const editingInfo = { id: user!.id, name: user!.name, since: new Date().toISOString() }
|
||||||
@@ -210,9 +211,13 @@ export function useRealtimeSync({
|
|||||||
editingBy: editingInfo,
|
editingBy: editingInfo,
|
||||||
sessionId: sessionIdRef.current,
|
sessionId: sessionIdRef.current,
|
||||||
})
|
})
|
||||||
|
if (!opts?.silent) {
|
||||||
toast({ title: 'Bearbeitung gestartet', description: 'Sie können jetzt zeichnen und Einträge erstellen.' })
|
toast({ title: 'Bearbeitung gestartet', description: 'Sie können jetzt zeichnen und Einträge erstellen.' })
|
||||||
|
}
|
||||||
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht starten.', variant: 'destructive' })
|
toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht starten.', variant: 'destructive' })
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
setEditingLoading(false)
|
setEditingLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user