feat(1.4.3): add 'Bibliothek' tab to admin for single-icon selection
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 20m6s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 20m6s
This commit is contained in:
@@ -3,6 +3,11 @@
|
|||||||
Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
|
Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
|
||||||
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
||||||
|
|
||||||
|
## [1.4.3] – 2026-05-20 — Feature: Einzelne Symbole aus Bibliothek wählen
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
- **Admin → Symbol-Manager**: Neuer Tab „Bibliothek“ zeigt alle 117 globalen Symbole gruppiert nach Kategorie. Pro Symbol ein „+“-Button, um es mit einem Klick zu „Meinen Symbolen“ hinzuzufügen (via `POST /api/tenant/symbols` mit `iconId`).
|
||||||
|
|
||||||
## [1.4.2] – 2026-05-20 — Hotfix: Admin leer & Legacy-Symbole
|
## [1.4.2] – 2026-05-20 — Hotfix: Admin leer & Legacy-Symbole
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.4.2",
|
"version": "1.4.3",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Package,
|
Package,
|
||||||
|
Library,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
/* ─── Types ─── */
|
/* ─── Types ─── */
|
||||||
@@ -84,7 +85,7 @@ export function SymbolManager() {
|
|||||||
/* -- UI state -- */
|
/* -- UI state -- */
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set())
|
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set())
|
||||||
const [activeTab, setActiveTab] = useState<'symbols' | 'categories' | 'import'>('symbols')
|
const [activeTab, setActiveTab] = useState<'symbols' | 'categories' | 'import' | 'library'>('symbols')
|
||||||
|
|
||||||
/* -- Symbol editing -- */
|
/* -- Symbol editing -- */
|
||||||
const [editingSymbolId, setEditingSymbolId] = useState<string | null>(null)
|
const [editingSymbolId, setEditingSymbolId] = useState<string | null>(null)
|
||||||
@@ -106,6 +107,12 @@ export function SymbolManager() {
|
|||||||
const [importOpen, setImportOpen] = useState(false)
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
const [importingPkg, setImportingPkg] = useState<string | null>(null)
|
const [importingPkg, setImportingPkg] = useState<string | null>(null)
|
||||||
|
|
||||||
|
/* -- Library -- */
|
||||||
|
const [libraryIcons, setLibraryIcons] = useState<any[]>([])
|
||||||
|
const [librarySearch, setLibrarySearch] = useState('')
|
||||||
|
const [libraryLoading, setLibraryLoading] = useState(false)
|
||||||
|
const [addingIconId, setAddingIconId] = useState<string | null>(null)
|
||||||
|
|
||||||
/* ─── Fetch ─── */
|
/* ─── Fetch ─── */
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -298,6 +305,46 @@ export function SymbolManager() {
|
|||||||
setImportingPkg(null)
|
setImportingPkg(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── 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 ─── */
|
/* ─── Derived data ─── */
|
||||||
const filteredGroups = symbolGroups.map(g => ({
|
const filteredGroups = symbolGroups.map(g => ({
|
||||||
...g,
|
...g,
|
||||||
@@ -326,6 +373,7 @@ export function SymbolManager() {
|
|||||||
{([
|
{([
|
||||||
{ key: 'symbols', label: 'Meine Symbole', icon: LayoutGrid },
|
{ key: 'symbols', label: 'Meine Symbole', icon: LayoutGrid },
|
||||||
{ key: 'categories', label: 'Kategorien', icon: FolderOpen },
|
{ key: 'categories', label: 'Kategorien', icon: FolderOpen },
|
||||||
|
{ key: 'library', label: 'Bibliothek', icon: Library },
|
||||||
{ key: 'import', label: 'Vorlagen importieren', icon: Download },
|
{ key: 'import', label: 'Vorlagen importieren', icon: Download },
|
||||||
] as const).map(t => (
|
] as const).map(t => (
|
||||||
<button
|
<button
|
||||||
@@ -572,6 +620,74 @@ export function SymbolManager() {
|
|||||||
</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 ===== */}
|
{/* ===== UPLOAD DIALOG ===== */}
|
||||||
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
|
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
|
|||||||
Reference in New Issue
Block a user