Files
Lageplan/src/components/admin/symbol-manager.tsx
Pepe Ziberi 5917fa88ad v1.3.0: Refactoring Phase 3+4, Symbol-Verwaltung Redesign, Schlauch-Labels Fix
- Refactoring: Error Boundaries, apiFetch Wrapper, Socket Status-Tracking
- Refactoring: UI Kontrast (theme-aware colors), unused imports bereinigt
- Symbol-Verwaltung: Neues Split-Panel (Meine Symbole + Bibliothek)
- Symbol-Verwaltung: Umbenennen (TLF rot/blau), Duplikate erlaubt
- Symbol-Verwaltung: Karten-Sidebar zeigt eigene Symbole bevorzugt
- Schlauch-Labels: Groessere Schrift (13px/10px), verschiebbar (Drag)
- Schema: TenantSymbol customName, sortOrder, unique constraint entfernt
- Open Source Referenz entfernt (kostenloses Projekt)
2026-02-25 00:06:39 +01:00

373 lines
15 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import {
ChevronDown,
ChevronRight,
Plus,
Pencil,
Trash2,
Search,
Upload,
Loader2,
X,
Check,
LayoutGrid,
ImageIcon,
Info,
} from 'lucide-react'
interface LibraryIcon {
id: string
name: string
mimeType: string
iconType: string
categoryId: string
categoryName: string
}
interface MySymbol {
id: string
iconId: string
name: string
customName: string | null
baseName: string
mimeType: string
iconType: string
categoryName: string
sortOrder: number
}
export function SymbolManager() {
const { toast } = useToast()
const [loading, setLoading] = useState(true)
const [library, setLibrary] = useState<LibraryIcon[]>([])
const [mySymbols, setMySymbols] = useState<MySymbol[]>([])
// Library UI state
const [librarySearch, setLibrarySearch] = useState('')
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
const [libraryCollapsed, setLibraryCollapsed] = useState(false)
// My Symbols UI state
const [editingId, setEditingId] = useState<string | null>(null)
const [editName, setEditName] = useState('')
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await fetch('/api/tenant/symbols')
if (res.ok) {
const data = await res.json()
setLibrary(data.library || [])
setMySymbols(data.mySymbols || [])
// Auto-collapse library if tenant has own symbols
if ((data.mySymbols || []).length > 0) {
setLibraryCollapsed(true)
}
}
} catch {}
setLoading(false)
}, [])
useEffect(() => { fetchData() }, [fetchData])
// Add symbol from library to my collection
const addSymbol = async (iconId: string, customName?: string) => {
try {
const res = await fetch('/api/tenant/symbols', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ iconId, customName }),
})
if (res.ok) {
const symbol = await res.json()
setMySymbols(prev => [...prev, symbol])
toast({ title: 'Symbol hinzugefügt' })
}
} catch {
toast({ title: 'Fehler', variant: 'destructive' })
}
}
// Rename a symbol
const renameSymbol = async (id: string, customName: string) => {
try {
await fetch('/api/tenant/symbols', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, customName }),
})
setMySymbols(prev => prev.map(s =>
s.id === id ? { ...s, name: customName || s.baseName, customName: customName || null } : s
))
setEditingId(null)
setEditName('')
toast({ title: 'Umbenannt' })
} catch {
toast({ title: 'Fehler', variant: 'destructive' })
}
}
// Remove a symbol
const removeSymbol = async (id: string) => {
try {
await fetch('/api/tenant/symbols', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
setMySymbols(prev => prev.filter(s => s.id !== id))
toast({ title: 'Symbol entfernt' })
} catch {
toast({ title: 'Fehler', variant: 'destructive' })
}
}
// Toggle category expand/collapse
const toggleCategory = (cat: string) => {
setExpandedCategories(prev => {
const next = new Set(prev)
next.has(cat) ? next.delete(cat) : next.add(cat)
return next
})
}
// Group library icons by category
const filteredLibrary = library.filter(icon =>
!librarySearch || icon.name.toLowerCase().includes(librarySearch.toLowerCase())
)
const libraryGrouped = filteredLibrary.reduce<Record<string, LibraryIcon[]>>((acc, icon) => {
const key = icon.categoryName
if (!acc[key]) acc[key] = []
acc[key].push(icon)
return acc
}, {})
if (loading) {
return (
<div className="flex items-center gap-2 py-12 justify-center text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" /> Symbole laden...
</div>
)
}
return (
<div className="space-y-6">
{/* ===== MEINE SYMBOLE (always on top, prominent) ===== */}
<div className="border-2 border-primary/20 rounded-lg">
<div className="flex items-center justify-between px-4 py-3 bg-primary/5 border-b border-primary/20">
<h3 className="font-semibold text-sm flex items-center gap-2">
<LayoutGrid className="w-4 h-4 text-primary" />
Meine Symbole
<span className="text-xs text-muted-foreground font-normal">({mySymbols.length})</span>
</h3>
</div>
{mySymbols.length === 0 ? (
<div className="p-8 text-center">
<ImageIcon className="w-10 h-10 mx-auto text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground mb-1">Noch keine eigenen Symbole definiert.</p>
<p className="text-xs text-muted-foreground">
Füge Symbole aus der Bibliothek unten hinzu oder lade eigene SVGs hoch.
</p>
</div>
) : (
<div className="p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-3">
{mySymbols.map(sym => (
<div
key={sym.id}
className="group relative border rounded-lg p-2 transition-all hover:shadow-md hover:border-primary/30"
>
<div className="aspect-square flex items-center justify-center mb-1.5 bg-muted/50 rounded">
<img
src={`/api/icons/${sym.iconId}/image`}
alt={sym.name}
className="w-12 h-12 object-contain"
draggable={false}
/>
</div>
{/* Name / Edit */}
{editingId === sym.id ? (
<div className="flex gap-0.5">
<Input
value={editName}
onChange={e => setEditName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') renameSymbol(sym.id, editName)
if (e.key === 'Escape') { setEditingId(null); setEditName('') }
}}
className="h-6 text-[10px] px-1"
autoFocus
/>
<button onClick={() => renameSymbol(sym.id, editName)} className="text-green-600 hover:text-green-700">
<Check className="w-3.5 h-3.5" />
</button>
<button onClick={() => { setEditingId(null); setEditName('') }} className="text-muted-foreground hover:text-foreground">
<X className="w-3.5 h-3.5" />
</button>
</div>
) : (
<p
className="text-[11px] text-center truncate cursor-pointer hover:text-primary"
title={`${sym.name}${sym.customName ? ` (Basis: ${sym.baseName})` : ''} — Klick zum Umbenennen`}
onClick={() => { setEditingId(sym.id); setEditName(sym.name) }}
>
{sym.name}
</p>
)}
{/* Hover actions */}
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => { setEditingId(sym.id); setEditName(sym.name) }}
className="w-5 h-5 rounded bg-background/80 border flex items-center justify-center text-muted-foreground hover:text-primary"
title="Umbenennen"
>
<Pencil className="w-3 h-3" />
</button>
<button
onClick={() => removeSymbol(sym.id)}
className="w-5 h-5 rounded bg-background/80 border flex items-center justify-center text-muted-foreground hover:text-destructive"
title="Entfernen"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
{/* Custom name badge */}
{sym.customName && (
<div className="absolute top-1 left-1">
<div className="w-2 h-2 rounded-full bg-primary" title="Eigener Name" />
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* ===== UPLOAD HINWEIS ===== */}
<div className="flex items-start gap-3 px-4 py-3 bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<Info className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
<div className="text-xs text-blue-700 dark:text-blue-300">
<strong>Tipp:</strong> Eigene Symbole am besten als <strong>SVG</strong> hochladen diese werden in jeder Grösse scharf dargestellt.
PNG/JPEG sind auch möglich, können aber bei Vergrösserung unscharf werden.
</div>
</div>
{/* ===== STANDARD-BIBLIOTHEK (collapsible) ===== */}
<div className="border rounded-lg">
<button
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors"
onClick={() => setLibraryCollapsed(!libraryCollapsed)}
>
<h3 className="font-semibold text-sm flex items-center gap-2">
{libraryCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
Standard-Bibliothek
<span className="text-xs text-muted-foreground font-normal">({library.length} Symbole)</span>
</h3>
<span className="text-xs text-muted-foreground">
{libraryCollapsed ? 'Aufklappen' : 'Zuklappen'}
</span>
</button>
{!libraryCollapsed && (
<div className="border-t px-4 py-3 space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Symbole suchen..."
value={librarySearch}
onChange={e => setLibrarySearch(e.target.value)}
className="pl-9"
/>
</div>
{/* Categories */}
{Object.entries(libraryGrouped).sort(([a], [b]) => a.localeCompare(b)).map(([catName, icons]) => (
<div key={catName} className="border rounded-lg">
<button
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/30 transition-colors text-sm"
onClick={() => toggleCategory(catName)}
>
<span className="font-medium flex items-center gap-2">
{expandedCategories.has(catName) ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
{catName}
<span className="text-xs text-muted-foreground font-normal">({icons.length})</span>
</span>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
onClick={(e) => {
e.stopPropagation()
// Add all icons from this category
icons.forEach(icon => addSymbol(icon.id))
}}
>
<Plus className="w-3 h-3 mr-1" /> Alle hinzufügen
</Button>
</button>
{expandedCategories.has(catName) && (
<div className="border-t px-3 py-3">
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-2">
{icons.map(icon => {
const alreadyAdded = mySymbols.some(s => s.iconId === icon.id)
return (
<button
key={icon.id}
onClick={() => addSymbol(icon.id)}
className={`group relative border rounded-lg p-2 transition-all hover:shadow-sm hover:border-primary/40 ${
alreadyAdded ? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800' : ''
}`}
title={`${icon.name} — Klick zum Hinzufügen`}
>
<div className="aspect-square flex items-center justify-center mb-1 bg-muted/30 rounded">
<img
src={`/api/icons/${icon.id}/image`}
alt={icon.name}
className="w-10 h-10 object-contain"
draggable={false}
/>
</div>
<p className="text-[10px] text-center truncate">{icon.name}</p>
{/* Add overlay */}
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 opacity-0 group-hover:opacity-100 rounded-lg transition-opacity">
<Plus className="w-5 h-5 text-primary" />
</div>
{/* Already added indicator */}
{alreadyAdded && (
<div className="absolute top-1 right-1 w-3 h-3 rounded-full bg-green-500 flex items-center justify-center">
<Check className="w-2 h-2 text-white" />
</div>
)}
</button>
)
})}
</div>
</div>
)}
</div>
))}
{Object.keys(libraryGrouped).length === 0 && (
<div className="text-center text-muted-foreground py-6 text-sm">
{librarySearch ? 'Keine Symbole gefunden.' : 'Keine Standard-Symbole vorhanden.'}
</div>
)}
</div>
)}
</div>
</div>
)
}