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)
This commit is contained in:
372
src/components/admin/symbol-manager.tsx
Normal file
372
src/components/admin/symbol-manager.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user