All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 14m56s
763 lines
28 KiB
TypeScript
763 lines
28 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from '@/components/ui/dialog'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Plus,
|
|
Pencil,
|
|
Trash2,
|
|
Search,
|
|
Upload,
|
|
Loader2,
|
|
X,
|
|
Check,
|
|
LayoutGrid,
|
|
ImageIcon,
|
|
FolderOpen,
|
|
AlertCircle,
|
|
Library,
|
|
} from 'lucide-react'
|
|
|
|
/* ─── Types ─── */
|
|
interface TenantCategory {
|
|
id: string
|
|
name: string
|
|
description: string | null
|
|
sortOrder: number
|
|
}
|
|
|
|
interface TenantSymbol {
|
|
id: string
|
|
name: string
|
|
customName: string | null
|
|
svgPath: string | null
|
|
categoryId: string | null
|
|
sortOrder: number
|
|
isUploaded: boolean
|
|
migratedFromIconId: string | null
|
|
category?: TenantCategory | null
|
|
}
|
|
|
|
interface SymbolGroup {
|
|
category: TenantCategory | null
|
|
symbols: TenantSymbol[]
|
|
}
|
|
|
|
/* ─── Component ─── */
|
|
export function SymbolManager() {
|
|
const { toast } = useToast()
|
|
|
|
/* -- Data -- */
|
|
const [loading, setLoading] = useState(true)
|
|
const [categories, setCategories] = useState<TenantCategory[]>([])
|
|
const [symbolGroups, setSymbolGroups] = useState<SymbolGroup[]>([])
|
|
const [flatSymbols, setFlatSymbols] = useState<TenantSymbol[]>([])
|
|
|
|
/* -- UI state -- */
|
|
const [search, setSearch] = useState('')
|
|
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set())
|
|
const [activeTab, setActiveTab] = useState<'symbols' | 'categories' | 'library'>('library')
|
|
|
|
/* -- Symbol editing -- */
|
|
const [editingSymbolId, setEditingSymbolId] = useState<string | null>(null)
|
|
const [editSymbolName, setEditSymbolName] = useState('')
|
|
|
|
/* -- Category editing -- */
|
|
const [newCatName, setNewCatName] = useState('')
|
|
const [editingCatId, setEditingCatId] = useState<string | null>(null)
|
|
const [editCatName, setEditCatName] = useState('')
|
|
|
|
/* -- Upload dialog -- */
|
|
const [uploadOpen, setUploadOpen] = useState(false)
|
|
const [uploadCatId, setUploadCatId] = useState<string>('')
|
|
const [uploadDragging, setUploadDragging] = useState(false)
|
|
const [uploading, setUploading] = useState(false)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
/* -- Library -- */
|
|
const [libraryIcons, setLibraryIcons] = useState<any[]>([])
|
|
const [librarySearch, setLibrarySearch] = useState('')
|
|
const [libraryLoading, setLibraryLoading] = useState(false)
|
|
const [addingIconId, setAddingIconId] = useState<string | null>(null)
|
|
|
|
/* ─── Fetch ─── */
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const [catRes, symRes] = await Promise.all([
|
|
fetch('/api/tenant/categories'),
|
|
fetch('/api/tenant/symbols?grouped=true'),
|
|
])
|
|
if (catRes.ok) {
|
|
const c = await catRes.json()
|
|
setCategories(c.categories || [])
|
|
}
|
|
if (symRes.ok) {
|
|
const s = await symRes.json()
|
|
setSymbolGroups(s.categories || [])
|
|
// Flatten all symbols from categories for ungrouped / category-management views
|
|
const all = (s.categories || []).flatMap((c: any) => c.symbols || [])
|
|
setFlatSymbols(all)
|
|
}
|
|
} catch {
|
|
toast({ title: 'Fehler beim Laden', variant: 'destructive' })
|
|
}
|
|
setLoading(false)
|
|
}, [toast])
|
|
|
|
useEffect(() => { fetchData() }, [fetchData])
|
|
|
|
/* ─── Helpers ─── */
|
|
const toggleCat = (id: string) => {
|
|
setExpandedCats(prev => {
|
|
const n = new Set(prev)
|
|
n.has(id) ? n.delete(id) : n.add(id)
|
|
return n
|
|
})
|
|
}
|
|
|
|
const getSymbolImageUrl = (sym: TenantSymbol) => {
|
|
if (sym.isUploaded && sym.svgPath) {
|
|
// MinIO presigned or direct
|
|
return `/api/tenant/symbols/${sym.id}/image`
|
|
}
|
|
if (sym.migratedFromIconId) {
|
|
return `/api/icons/${sym.migratedFromIconId}/image`
|
|
}
|
|
return `/api/icons/${sym.id}/image`
|
|
}
|
|
|
|
/* ─── Symbol CRUD ─── */
|
|
const updateSymbol = async (id: string, payload: Partial<TenantSymbol>) => {
|
|
try {
|
|
const res = await fetch('/api/tenant/symbols', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id, ...payload }),
|
|
})
|
|
if (!res.ok) throw new Error()
|
|
await fetchData()
|
|
toast({ title: 'Gespeichert' })
|
|
} catch {
|
|
toast({ title: 'Fehler beim Speichern', variant: 'destructive' })
|
|
}
|
|
}
|
|
|
|
const deleteSymbol = async (id: string) => {
|
|
if (!confirm('Symbol wirklich löschen?')) return
|
|
try {
|
|
const res = await fetch('/api/tenant/symbols', {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id }),
|
|
})
|
|
if (!res.ok) throw new Error()
|
|
await fetchData()
|
|
toast({ title: 'Symbol gelöscht' })
|
|
} catch {
|
|
toast({ title: 'Fehler beim Löschen', variant: 'destructive' })
|
|
}
|
|
}
|
|
|
|
/* ─── Category CRUD ─── */
|
|
const createCategory = async () => {
|
|
const name = newCatName.trim()
|
|
if (!name) return
|
|
try {
|
|
const res = await fetch('/api/tenant/categories', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name }),
|
|
})
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}))
|
|
toast({ title: err.error || 'Fehler', variant: 'destructive' })
|
|
return
|
|
}
|
|
setNewCatName('')
|
|
await fetchData()
|
|
toast({ title: 'Kategorie erstellt' })
|
|
} catch {
|
|
toast({ title: 'Fehler', variant: 'destructive' })
|
|
}
|
|
}
|
|
|
|
const updateCategory = async (id: string, name: string) => {
|
|
try {
|
|
const res = await fetch('/api/tenant/categories', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id, name }),
|
|
})
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}))
|
|
toast({ title: err.error || 'Fehler', variant: 'destructive' })
|
|
return
|
|
}
|
|
setEditingCatId(null)
|
|
await fetchData()
|
|
toast({ title: 'Kategorie umbenannt' })
|
|
} catch {
|
|
toast({ title: 'Fehler', variant: 'destructive' })
|
|
}
|
|
}
|
|
|
|
const deleteCategory = async (id: string) => {
|
|
const hasSymbols = flatSymbols.some(s => s.categoryId === id)
|
|
if (hasSymbols) {
|
|
toast({ title: 'Kategorie ist nicht leer', description: 'Verschiebe oder lösche zuerst die enthaltenen Symbole.', variant: 'destructive' })
|
|
return
|
|
}
|
|
if (!confirm('Kategorie wirklich löschen?')) return
|
|
try {
|
|
const res = await fetch(`/api/tenant/categories?id=${id}`, { method: 'DELETE' })
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}))
|
|
toast({ title: err.error || 'Fehler', variant: 'destructive' })
|
|
return
|
|
}
|
|
await fetchData()
|
|
toast({ title: 'Kategorie gelöscht' })
|
|
} catch {
|
|
toast({ title: 'Fehler', variant: 'destructive' })
|
|
}
|
|
}
|
|
|
|
/* ─── Upload ─── */
|
|
const handleFiles = async (files: FileList | null) => {
|
|
if (!files || files.length === 0) return
|
|
setUploading(true)
|
|
let success = 0
|
|
for (const file of Array.from(files)) {
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
if (uploadCatId) form.append('categoryId', uploadCatId)
|
|
try {
|
|
const res = await fetch('/api/tenant/symbols', { method: 'POST', body: form })
|
|
if (res.ok) success++
|
|
} catch { /* ignore single failure */ }
|
|
}
|
|
setUploading(false)
|
|
setUploadOpen(false)
|
|
await fetchData()
|
|
toast({ title: `${success} Datei(en) hochgeladen` })
|
|
}
|
|
|
|
/* ─── Library ─── */
|
|
const fetchLibrary = useCallback(async () => {
|
|
setLibraryLoading(true)
|
|
try {
|
|
const res = await fetch('/api/icons')
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setLibraryIcons(data.categories || [])
|
|
}
|
|
} catch {
|
|
toast({ title: 'Fehler beim Laden der Bibliothek', variant: 'destructive' })
|
|
}
|
|
setLibraryLoading(false)
|
|
}, [toast])
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'library') fetchLibrary()
|
|
}, [activeTab, fetchLibrary])
|
|
|
|
const addFromLibrary = async (iconId: string, name: string) => {
|
|
setAddingIconId(iconId)
|
|
try {
|
|
const res = await fetch('/api/tenant/symbols', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ iconId }),
|
|
})
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}))
|
|
toast({ title: err.error || 'Fehler', variant: 'destructive' })
|
|
} else {
|
|
toast({ title: `'${name}' hinzugefügt` })
|
|
await fetchData()
|
|
}
|
|
} catch {
|
|
toast({ title: 'Fehler beim Hinzufügen', variant: 'destructive' })
|
|
}
|
|
setAddingIconId(null)
|
|
}
|
|
|
|
/* ─── Derived data ─── */
|
|
const filteredGroups = symbolGroups.map(g => ({
|
|
...g,
|
|
symbols: g.symbols.filter(s =>
|
|
!search || s.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
(s.customName && s.customName.toLowerCase().includes(search.toLowerCase()))
|
|
),
|
|
})).filter(g => g.symbols.length > 0)
|
|
|
|
const ungroupedSymbols = flatSymbols.filter(s => !s.categoryId)
|
|
|
|
/* ─── Render ─── */
|
|
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-4">
|
|
{/* Tabs */}
|
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
<div className="flex gap-1 bg-muted p-1 rounded-lg">
|
|
{([
|
|
{ key: 'symbols', label: 'Meine Symbole', icon: LayoutGrid },
|
|
{ key: 'categories', label: 'Kategorien', icon: FolderOpen },
|
|
{ key: 'library', label: 'Bibliothek', icon: Library },
|
|
] as const).map(t => (
|
|
<button
|
|
key={t.key}
|
|
onClick={() => setActiveTab(t.key)}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
|
|
activeTab === t.key ? 'bg-background shadow-sm font-medium' : 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
<t.icon className="w-4 h-4" />
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button size="sm" variant="outline" onClick={() => setUploadOpen(true)}>
|
|
<Upload className="w-4 h-4 mr-1.5" /> Upload
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ===== TAB: Meine Symbole ===== */}
|
|
{activeTab === 'symbols' && (
|
|
<div className="space-y-4">
|
|
{/* Search */}
|
|
<div className="relative max-w-sm">
|
|
<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={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
|
|
{/* Symbol grid grouped by category */}
|
|
<div className="space-y-3">
|
|
{filteredGroups.map(g => {
|
|
const catId = g.category?.id ?? '__none__'
|
|
const isExpanded = expandedCats.has(catId)
|
|
return (
|
|
<div key={catId} className="border rounded-lg">
|
|
<button
|
|
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/30 transition-colors"
|
|
onClick={() => toggleCat(catId)}
|
|
>
|
|
<span className="font-medium text-sm flex items-center gap-2">
|
|
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
|
{g.category?.name || 'Ohne Kategorie'}
|
|
<span className="text-xs text-muted-foreground font-normal">({g.symbols.length})</span>
|
|
</span>
|
|
</button>
|
|
|
|
{isExpanded && (
|
|
<div className="border-t px-3 py-3">
|
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 gap-3">
|
|
{g.symbols.map(sym => (
|
|
<SymbolCard
|
|
key={sym.id}
|
|
sym={sym}
|
|
categories={categories}
|
|
editing={editingSymbolId === sym.id}
|
|
editName={editSymbolName}
|
|
onStartEdit={() => { setEditingSymbolId(sym.id); setEditSymbolName(sym.customName || sym.name) }}
|
|
onCancelEdit={() => { setEditingSymbolId(null); setEditSymbolName('') }}
|
|
onSaveEdit={(name) => { updateSymbol(sym.id, { customName: name }); setEditingSymbolId(null) }}
|
|
onEditNameChange={setEditSymbolName}
|
|
onMoveCategory={(catId) => updateSymbol(sym.id, { categoryId: catId || null })}
|
|
onDelete={() => deleteSymbol(sym.id)}
|
|
imageUrl={getSymbolImageUrl(sym)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{filteredGroups.length === 0 && (
|
|
<div className="text-center py-12 border rounded-lg">
|
|
<ImageIcon className="w-10 h-10 mx-auto text-muted-foreground/40 mb-3" />
|
|
<p className="text-sm text-muted-foreground">
|
|
{search ? 'Keine Symbole gefunden.' : 'Noch keine Symbole. Füge Symbole aus der Bibliothek hinzu oder lade eigene hoch.'}
|
|
</p>
|
|
<div className="flex justify-center gap-2 mt-3">
|
|
<Button size="sm" variant="outline" onClick={() => setActiveTab('library')}>
|
|
<Library className="w-4 h-4 mr-1" /> Bibliothek durchsuchen
|
|
</Button>
|
|
<Button size="sm" variant="outline" onClick={() => setUploadOpen(true)}>
|
|
<Upload className="w-4 h-4 mr-1" /> Hochladen
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ===== TAB: Kategorien ===== */}
|
|
{activeTab === 'categories' && (
|
|
<div className="space-y-4 max-w-xl">
|
|
{/* New category */}
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="Neue Kategorie..."
|
|
value={newCatName}
|
|
onChange={e => setNewCatName(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && createCategory()}
|
|
/>
|
|
<Button onClick={createCategory} disabled={!newCatName.trim()}>
|
|
<Plus className="w-4 h-4 mr-1" /> Erstellen
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Category list */}
|
|
<div className="border rounded-lg divide-y">
|
|
{categories.sort((a, b) => a.sortOrder - b.sortOrder).map(cat => {
|
|
const symbolCount = flatSymbols.filter(s => s.categoryId === cat.id).length
|
|
const isEditing = editingCatId === cat.id
|
|
return (
|
|
<div key={cat.id} className="flex items-center justify-between px-4 py-3">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<FolderOpen className="w-4 h-4 text-muted-foreground shrink-0" />
|
|
{isEditing ? (
|
|
<div className="flex items-center gap-1">
|
|
<Input
|
|
value={editCatName}
|
|
onChange={e => setEditCatName(e.target.value)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter') updateCategory(cat.id, editCatName)
|
|
if (e.key === 'Escape') setEditingCatId(null)
|
|
}}
|
|
className="h-8 text-sm"
|
|
autoFocus
|
|
/>
|
|
<Button size="sm" variant="ghost" className="h-8 w-8 p-0" onClick={() => updateCategory(cat.id, editCatName)}>
|
|
<Check className="w-4 h-4 text-green-600" />
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-8 w-8 p-0" onClick={() => setEditingCatId(null)}>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">{cat.name}</p>
|
|
<p className="text-xs text-muted-foreground">{symbolCount} Symbol(e)</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!isEditing && (
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-8 w-8 p-0"
|
|
onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
|
|
>
|
|
<Pencil className="w-3.5 h-3.5 text-muted-foreground" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-8 w-8 p-0"
|
|
onClick={() => deleteCategory(cat.id)}
|
|
disabled={symbolCount > 0}
|
|
title={symbolCount > 0 ? 'Kategorie ist nicht leer' : 'Löschen'}
|
|
>
|
|
<Trash2 className={`w-3.5 h-3.5 ${symbolCount > 0 ? 'text-muted-foreground/40' : 'text-destructive'}`} />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{categories.length === 0 && (
|
|
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
|
Noch keine Kategorien. Erstelle oben die erste Kategorie.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ===== TAB: Bibliothek ===== */}
|
|
{activeTab === 'library' && (
|
|
<div className="space-y-4">
|
|
<div className="relative max-w-sm">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="In Bibliothek suchen..."
|
|
value={librarySearch}
|
|
onChange={e => setLibrarySearch(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
|
|
{libraryLoading ? (
|
|
<div className="flex items-center gap-2 py-12 justify-center text-muted-foreground">
|
|
<Loader2 className="w-5 h-5 animate-spin" /> Bibliothek laden...
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{libraryIcons.map((cat: any) => {
|
|
const filtered = (cat.icons || []).filter((icon: any) =>
|
|
!librarySearch || (icon.name || '').toLowerCase().includes(librarySearch.toLowerCase())
|
|
)
|
|
if (filtered.length === 0) return null
|
|
return (
|
|
<div key={cat.id} className="border rounded-lg">
|
|
<div className="px-3 py-2 bg-muted/30 border-b">
|
|
<span className="font-medium text-sm">{cat.name}</span>
|
|
<span className="text-xs text-muted-foreground ml-2">({filtered.length})</span>
|
|
</div>
|
|
<div className="px-3 py-3">
|
|
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-3">
|
|
{filtered.map((icon: any) => (
|
|
<div key={icon.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={icon.url}
|
|
alt={icon.name}
|
|
className="w-10 h-10 object-contain"
|
|
draggable={false}
|
|
onError={(e) => { (e.target as HTMLImageElement).src = '/logo.svg' }}
|
|
/>
|
|
</div>
|
|
<p className="text-[11px] text-center truncate" title={icon.name}>{icon.name}</p>
|
|
<button
|
|
onClick={() => addFromLibrary(icon.id, icon.name)}
|
|
disabled={addingIconId === icon.id}
|
|
className="absolute top-1 right-1 w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-primary/90 disabled:opacity-50"
|
|
title="Zu Meinen Symbolen hinzufügen"
|
|
>
|
|
{addingIconId === icon.id ? (
|
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|
) : (
|
|
<Plus className="w-3.5 h-3.5" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ===== UPLOAD DIALOG ===== */}
|
|
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Symbole hochladen</DialogTitle>
|
|
<DialogDescription>
|
|
SVG wird empfohlen (scharf in jeder Grösse). PNG/JPEG sind auch möglich.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="text-sm font-medium mb-1.5 block">Kategorie (optional)</label>
|
|
<Select value={uploadCatId} onValueChange={setUploadCatId}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Keine Kategorie" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="">Keine Kategorie</SelectItem>
|
|
{categories.map(c => (
|
|
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div
|
|
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${
|
|
uploadDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-muted-foreground/50'
|
|
}`}
|
|
onDragOver={e => { e.preventDefault(); setUploadDragging(true) }}
|
|
onDragLeave={() => setUploadDragging(false)}
|
|
onDrop={e => {
|
|
e.preventDefault()
|
|
setUploadDragging(false)
|
|
handleFiles(e.dataTransfer.files)
|
|
}}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<Upload className="w-8 h-8 mx-auto text-muted-foreground mb-2" />
|
|
<p className="text-sm font-medium">Dateien hierher ziehen oder klicken</p>
|
|
<p className="text-xs text-muted-foreground mt-1">SVG, PNG, JPEG</p>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept=".svg,image/svg+xml,image/png,image/jpeg"
|
|
className="hidden"
|
|
onChange={e => handleFiles(e.target.files)}
|
|
/>
|
|
</div>
|
|
|
|
{uploading && (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground justify-center">
|
|
<Loader2 className="w-4 h-4 animate-spin" /> Hochladen...
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ─── Subcomponent: SymbolCard ─── */
|
|
function SymbolCard({
|
|
sym,
|
|
categories,
|
|
editing,
|
|
editName,
|
|
onStartEdit,
|
|
onCancelEdit,
|
|
onSaveEdit,
|
|
onEditNameChange,
|
|
onMoveCategory,
|
|
onDelete,
|
|
imageUrl,
|
|
}: {
|
|
sym: TenantSymbol
|
|
categories: TenantCategory[]
|
|
editing: boolean
|
|
editName: string
|
|
onStartEdit: () => void
|
|
onCancelEdit: () => void
|
|
onSaveEdit: (name: string) => void
|
|
onEditNameChange: (v: string) => void
|
|
onMoveCategory: (catId: string) => void
|
|
onDelete: () => void
|
|
imageUrl: string
|
|
}) {
|
|
return (
|
|
<div 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={imageUrl}
|
|
alt={sym.name}
|
|
className="w-12 h-12 object-contain"
|
|
draggable={false}
|
|
onError={(e) => { (e.target as HTMLImageElement).src = '/logo.svg' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Name / Edit */}
|
|
{editing ? (
|
|
<div className="flex gap-0.5">
|
|
<Input
|
|
value={editName}
|
|
onChange={e => onEditNameChange(e.target.value)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter') onSaveEdit(editName)
|
|
if (e.key === 'Escape') onCancelEdit()
|
|
}}
|
|
className="h-6 text-[10px] px-1"
|
|
autoFocus
|
|
/>
|
|
<button onClick={() => onSaveEdit(editName)} className="text-green-600 hover:text-green-700">
|
|
<Check className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button onClick={onCancelEdit} 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.customName || sym.name}${sym.isUploaded ? ' (Eigenes Upload)' : ''}`}
|
|
onClick={onStartEdit}
|
|
>
|
|
{sym.customName || sym.name}
|
|
</p>
|
|
)}
|
|
|
|
{/* Category select */}
|
|
<Select
|
|
value={sym.categoryId || '__none__'}
|
|
onValueChange={(val) => onMoveCategory(val === '__none__' ? '' : val)}
|
|
>
|
|
<SelectTrigger className="h-5 text-[10px] border-0 bg-transparent px-0 mt-0.5 hover:bg-muted/50 rounded">
|
|
<SelectValue placeholder="Kategorie" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">Ohne Kategorie</SelectItem>
|
|
{categories.map(c => (
|
|
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* Hover actions */}
|
|
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
onClick={onStartEdit}
|
|
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={onDelete}
|
|
className="w-5 h-5 rounded bg-background/80 border flex items-center justify-center text-muted-foreground hover:text-destructive"
|
|
title="Löschen"
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Uploaded badge */}
|
|
{sym.isUploaded && (
|
|
<div className="absolute top-1 left-1">
|
|
<div className="w-2 h-2 rounded-full bg-blue-500" title="Eigenes Upload" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|