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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user