- 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)
373 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|