411 lines
17 KiB
TypeScript
411 lines
17 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { useDrag } from 'react-dnd'
|
|
import { getEmptyImage } from 'react-dnd-html5-backend'
|
|
import { Input } from '@/components/ui/input'
|
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
import {
|
|
Search, Flame, Droplets, AlertTriangle, Car, Users,
|
|
Truck, Building, Target, Upload, Loader2, X, LayoutGrid,
|
|
ChevronLeft, ChevronRight, Map, ClipboardList, PanelRightClose, PanelRightOpen,
|
|
Shield, Wrench, Radio, MoreHorizontal, Heart,
|
|
} from 'lucide-react'
|
|
|
|
interface DisplaySymbol {
|
|
id: string
|
|
name: string
|
|
imageUrl: string
|
|
}
|
|
|
|
interface DisplayCategory {
|
|
id: string
|
|
name: string
|
|
symbols: DisplaySymbol[]
|
|
}
|
|
|
|
interface RightSidebarProps {
|
|
onSymbolDrop: (iconId: string, coordinates: [number, number], imageUrl?: string) => void
|
|
canEdit: boolean
|
|
isOpen?: boolean
|
|
onToggle?: () => void
|
|
activeTab?: 'map' | 'journal'
|
|
onTabChange?: (tab: 'map' | 'journal') => void
|
|
isCollapsed?: boolean
|
|
onToggleCollapse?: () => void
|
|
tenantId?: string | null
|
|
}
|
|
|
|
const categoryIcons: Record<string, typeof Flame> = {
|
|
'Feuer / Brand': Flame,
|
|
'Wasser': Droplets,
|
|
'Gefahren / Stoffe': AlertTriangle,
|
|
'Rettung / Personen': Heart,
|
|
'Leitern / Geräte': Wrench,
|
|
'Gebäude / Schäden': Building,
|
|
'Einsatzführung': Radio,
|
|
'Organisationen': Shield,
|
|
'Entwicklung / Taktik': Target,
|
|
'Verschiedenes': MoreHorizontal,
|
|
'Eigene': Upload,
|
|
}
|
|
|
|
function DraggableSymbol({ symbol, canEdit }: {
|
|
symbol: DisplaySymbol
|
|
canEdit: boolean
|
|
}) {
|
|
const [{ isDragging }, drag, preview] = useDrag(() => ({
|
|
type: 'SYMBOL',
|
|
item: { iconId: symbol.id, imageUrl: symbol.imageUrl },
|
|
canDrag: canEdit,
|
|
collect: (monitor) => ({
|
|
isDragging: monitor.isDragging(),
|
|
}),
|
|
}), [symbol.id, symbol.imageUrl, canEdit])
|
|
|
|
// Suppress native HTML5 drag ghost — we use CustomDragLayer instead
|
|
useEffect(() => {
|
|
preview(getEmptyImage(), { captureDraggingState: true })
|
|
}, [preview])
|
|
|
|
return (
|
|
<div
|
|
ref={drag as unknown as React.LegacyRef<HTMLDivElement>}
|
|
className={`
|
|
flex flex-col items-center gap-1 p-1.5 md:p-2 lg:p-2.5 rounded-lg border-2 transition-all
|
|
border-transparent hover:border-border hover:bg-accent
|
|
cursor-grab active:cursor-grabbing active:scale-95
|
|
${isDragging ? 'opacity-0' : ''}
|
|
${!canEdit ? 'opacity-50 cursor-not-allowed' : ''}
|
|
`}
|
|
title={symbol.name}
|
|
>
|
|
<div className="w-10 h-10 md:w-11 md:h-11 lg:w-14 lg:h-14 flex items-center justify-center rounded-lg bg-white border border-gray-200 dark:border-gray-600">
|
|
<img
|
|
src={symbol.imageUrl}
|
|
alt={symbol.name}
|
|
className="w-8 h-8 md:w-9 md:h-9 lg:w-12 lg:h-12 object-contain"
|
|
crossOrigin="anonymous"
|
|
/>
|
|
</div>
|
|
<span className="text-[10px] md:text-[11px] text-center truncate w-full font-medium text-muted-foreground">
|
|
{symbol.name}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTab, onTabChange, isCollapsed, onToggleCollapse, tenantId }: RightSidebarProps) {
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [activeCategory, setActiveCategory] = useState<string>('')
|
|
const [categories, setCategories] = useState<DisplayCategory[]>([])
|
|
const [tenantIcons, setTenantIcons] = useState<DisplaySymbol[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [showTenantSection, setShowTenantSection] = useState(true)
|
|
const [showLibrarySection, setShowLibrarySection] = useState(true)
|
|
|
|
useEffect(() => {
|
|
async function fetchIcons() {
|
|
setIsLoading(true)
|
|
try {
|
|
const res = await fetch('/api/icons', { cache: 'no-store' })
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
const allCats: DisplayCategory[] = (data.categories || [])
|
|
.filter((cat: any) => cat.icons && cat.icons.length > 0)
|
|
.map((cat: any) => ({
|
|
id: cat.id,
|
|
name: cat.name,
|
|
symbols: cat.icons.map((icon: any) => ({
|
|
id: icon.id,
|
|
name: icon.name,
|
|
imageUrl: icon.url || `/api/icons/${icon.id}/image`,
|
|
})),
|
|
}))
|
|
|
|
// Separate tenant-specific icons ("Eigene" category) from global library
|
|
const eigene = allCats.find(c => c.name === 'Eigene')
|
|
const globalCats = allCats.filter(c => c.name !== 'Eigene')
|
|
|
|
// Merge: mySymbols (custom collection) + legacy "Eigene" category uploads
|
|
const mySymbols: DisplaySymbol[] = (data.mySymbols || []).map((s: any) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
imageUrl: s.url || `/api/icons/${s.id}/image`,
|
|
}))
|
|
const legacyOwn = eigene?.symbols || []
|
|
// Deduplicate: mySymbols takes priority over legacy
|
|
const mySymbolIds = new Set(mySymbols.map(s => s.id))
|
|
const mergedTenant = [...mySymbols, ...legacyOwn.filter(s => !mySymbolIds.has(s.id))]
|
|
|
|
setTenantIcons(mergedTenant)
|
|
setCategories(globalCats)
|
|
if (globalCats.length > 0) setActiveCategory(globalCats[0].id)
|
|
|
|
// Auto-collapse library if tenant has own symbols
|
|
if (mergedTenant.length > 0) {
|
|
setShowLibrarySection(false)
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load icons:', err)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
fetchIcons()
|
|
}, [tenantId])
|
|
|
|
const filteredCategories = categories.map((cat) => ({
|
|
...cat,
|
|
symbols: cat.symbols.filter((s) =>
|
|
s.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
),
|
|
}))
|
|
const filteredTenantIcons = tenantIcons.filter(s =>
|
|
s.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
)
|
|
|
|
const currentCategory = filteredCategories.find((c) => c.id === activeCategory)
|
|
const totalSymbols = categories.reduce((sum, c) => sum + c.symbols.length, 0) + tenantIcons.length
|
|
|
|
return (
|
|
<>
|
|
{/* Mobile toggle button */}
|
|
{!isOpen && onToggle && (
|
|
<button
|
|
onClick={onToggle}
|
|
className="md:hidden fixed bottom-4 right-4 z-50 w-14 h-14 bg-primary text-primary-foreground rounded-full shadow-lg flex items-center justify-center active:scale-95 transition-transform"
|
|
title="Symbole"
|
|
>
|
|
<LayoutGrid className="w-6 h-6" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Backdrop on mobile */}
|
|
{isOpen && onToggle && (
|
|
<div className="md:hidden fixed inset-0 bg-black/40 z-40" onClick={onToggle} />
|
|
)}
|
|
|
|
{/* Collapsed state: thin strip with toggle + tab icons */}
|
|
{isCollapsed && (
|
|
<aside className="hidden md:flex w-10 border-l border-border bg-card flex-col items-center py-2 gap-2 shrink-0">
|
|
<button
|
|
onClick={onToggleCollapse}
|
|
className="p-1.5 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
|
title="Seitenleiste einblenden"
|
|
>
|
|
<PanelRightOpen className="w-4 h-4" />
|
|
</button>
|
|
<div className="w-6 h-px bg-border" />
|
|
{onTabChange && (
|
|
<>
|
|
<button
|
|
onClick={() => onTabChange('map')}
|
|
className={`p-1.5 rounded-md transition-colors ${
|
|
activeTab === 'map' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
}`}
|
|
title="Karte"
|
|
>
|
|
<Map className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => onTabChange('journal')}
|
|
className={`p-1.5 rounded-md transition-colors ${
|
|
activeTab === 'journal' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
}`}
|
|
title="Journal"
|
|
>
|
|
<ClipboardList className="w-4 h-4" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</aside>
|
|
)}
|
|
|
|
<aside className={`
|
|
w-72 md:w-48 lg:w-56 xl:w-72 border-l border-border bg-card flex flex-col shrink-0
|
|
md:relative md:translate-x-0 md:z-auto
|
|
fixed right-0 top-0 bottom-0 z-50 transition-transform duration-200
|
|
${isOpen ? 'translate-x-0' : 'translate-x-full md:translate-x-0'}
|
|
${isCollapsed ? 'md:hidden' : ''}
|
|
`}>
|
|
|
|
{/* Tab switcher: Karte / Journal */}
|
|
{onTabChange && (
|
|
<div className="flex items-center border-b border-border shrink-0">
|
|
<button
|
|
onClick={() => onTabChange('map')}
|
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
|
|
activeTab === 'map'
|
|
? 'bg-primary/10 text-primary border-b-2 border-primary'
|
|
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
}`}
|
|
>
|
|
<Map className="w-3.5 h-3.5" />
|
|
Karte
|
|
</button>
|
|
<button
|
|
onClick={() => onTabChange('journal')}
|
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
|
|
activeTab === 'journal'
|
|
? 'bg-primary/10 text-primary border-b-2 border-primary'
|
|
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
}`}
|
|
>
|
|
<ClipboardList className="w-3.5 h-3.5" />
|
|
Journal
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Symbol panel — only shown on map tab */}
|
|
{activeTab === 'map' && (
|
|
<>
|
|
<div className="p-2 md:p-2 lg:p-3 border-b border-border">
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<h3 className="font-semibold text-sm md:text-base">Symbole</h3>
|
|
<div className="flex items-center gap-1">
|
|
{onToggleCollapse && (
|
|
<button onClick={onToggleCollapse} className="hidden md:block p-1 text-muted-foreground hover:text-foreground" title="Seitenleiste einklappen">
|
|
<PanelRightClose className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
{onToggle && (
|
|
<button onClick={onToggle} className="md:hidden p-1 text-muted-foreground hover:text-foreground">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 md:h-5 md:w-5 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Suchen..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-8 md:pl-10 h-9 md:h-10 lg:h-11 text-sm md:text-base"
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1.5">{totalSymbols} Symbole verfügbar</p>
|
|
</div>
|
|
|
|
{isLoading && (
|
|
<div className="flex items-center justify-center gap-2 p-6 text-muted-foreground text-xs">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
Lade Symbole...
|
|
</div>
|
|
)}
|
|
|
|
<ScrollArea className="flex-1">
|
|
{/* ─── Section 1: Meine Symbole (Tenant-specific) ─── */}
|
|
{tenantId && (
|
|
<div className="border-b border-border">
|
|
<button
|
|
onClick={() => setShowTenantSection(!showTenantSection)}
|
|
className="w-full flex items-center justify-between px-2 py-1.5 text-xs font-semibold text-muted-foreground hover:bg-accent/50 transition-colors"
|
|
>
|
|
<span className="flex items-center gap-1.5">
|
|
<Upload className="w-3.5 h-3.5" />
|
|
Meine Symbole ({filteredTenantIcons.length})
|
|
</span>
|
|
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showTenantSection ? 'rotate-90' : ''}`} />
|
|
</button>
|
|
{showTenantSection && (
|
|
<div className="p-2 pt-0">
|
|
{filteredTenantIcons.length === 0 ? (
|
|
<div className="text-center text-muted-foreground py-4 text-xs">
|
|
Keine eigenen Symbole vorhanden.
|
|
<br />
|
|
<span className="text-[10px]">Symbole können unter Einstellungen → Symbole hochgeladen werden.</span>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
|
|
{filteredTenantIcons.map((symbol) => (
|
|
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── Section 2: Bibliothek (Global categories) ─── */}
|
|
<div>
|
|
<button
|
|
onClick={() => setShowLibrarySection(!showLibrarySection)}
|
|
className="w-full flex items-center justify-between px-2 py-1.5 text-xs font-semibold text-muted-foreground hover:bg-accent/50 transition-colors"
|
|
>
|
|
<span className="flex items-center gap-1.5">
|
|
<LayoutGrid className="w-3.5 h-3.5" />
|
|
Bibliothek ({categories.reduce((s, c) => s + c.symbols.length, 0)})
|
|
</span>
|
|
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showLibrarySection ? 'rotate-90' : ''}`} />
|
|
</button>
|
|
{showLibrarySection && (
|
|
<>
|
|
{/* Category tabs */}
|
|
<div className="px-1.5 pb-1.5 md:px-2 md:pb-2">
|
|
<div className="flex flex-wrap gap-0.5 md:gap-1">
|
|
{categories.map((cat) => {
|
|
const IconComponent = categoryIcons[cat.name] || Target
|
|
return (
|
|
<button
|
|
key={cat.id}
|
|
onClick={() => setActiveCategory(cat.id)}
|
|
className={`
|
|
flex items-center gap-1 px-2 py-1.5 md:px-2.5 md:py-1.5 lg:px-3 lg:py-2 rounded-md md:rounded-lg text-xs md:text-sm font-medium transition-colors
|
|
${activeCategory === cat.id
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'bg-muted hover:bg-accent text-muted-foreground'}
|
|
`}
|
|
title={`${cat.name} (${cat.symbols.length})`}
|
|
>
|
|
<IconComponent className="w-3.5 h-3.5 md:w-4 md:h-4" />
|
|
<span className="hidden lg:inline max-w-[70px] truncate">{cat.name}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{currentCategory && (
|
|
<div className="p-2 pt-0">
|
|
<h4 className="text-xs md:text-sm font-medium text-muted-foreground mb-1.5">
|
|
{currentCategory.name} ({currentCategory.symbols.length})
|
|
</h4>
|
|
{currentCategory.symbols.length === 0 ? (
|
|
<div className="text-center text-muted-foreground py-8 text-sm">
|
|
Keine Symbole gefunden
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
|
|
{currentCategory.symbols.map((symbol) => (
|
|
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</>
|
|
)}
|
|
|
|
{/* Journal tab: collapse button only, no symbols */}
|
|
{activeTab === 'journal' && onToggleCollapse && (
|
|
<div className="p-2 border-b border-border flex items-center justify-between">
|
|
<span className="text-xs text-muted-foreground">Journal aktiv</span>
|
|
<button onClick={onToggleCollapse} className="hidden md:block p-1 text-muted-foreground hover:text-foreground" title="Seitenleiste einklappen">
|
|
<PanelRightClose className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</aside>
|
|
</>
|
|
)
|
|
}
|