Files
DOSSIER/src/components/LibraryBrowser.jsx
T
karim f760d1c54b Library Phase A.2 (Symbol/Object-Import) + Oberleiste-Pill-Restyle
Library Phase A.2:
- import_symbol/import_object via File3dm.Read + InstanceDefinitions.Add
- Stabile Block-Namen 'dossier_lib_<libraryId>' fuer Dedupe
- Seed-Manifest erweitert um Nordpfeil (symbol) + Laubbaum (object)
- ItemCard rendert type-spezifische Preview (Color-Swatch fuer material,
  Material-Icon fuer symbol/object)

Oberleiste-Pill-Restyle:
- OberleisteApp: Version unter DOSSIER-Logo, Settings-Icons vertikal
  gestapelt
- ProjectSettingsDialog: Pill-Tabs, BarToggle-Footer, MaterialRow mit
  Hover-Highlight, Header entfernt (Eto.Form hat eigenen)
- LibraryBrowser: BarButton-Reload, Pill-Typ-Filter, MaterialCard mit
  BarToggle-Pill, Header entfernt
- Globaler select-Stil: bg-input statt bg-item (dunkler im Dark-Mode,
  konsistent zu Oberleiste-BarCombo)

Routing:
- OberleisteBridge delegiert OPEN_PROJECT_SETTINGS + OPEN_LIBRARY an
  EbenenBridge (sticky ebenen_bridge_ref) — vorher kamen die Messages an
  der falschen Bridge an und wurden verschluckt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 16:57:42 +02:00

208 lines
7.1 KiB
React

import { useState, useMemo } from 'react'
import Icon from './Icon'
import { BarToggle, BarButton, BAR_H } from './BarControls'
/* Preview — abhaengig vom Typ:
material → Color-Swatch
symbol/object → Typ-Icon-Placeholder (spaeter: PNG-Thumbnail aus
library/previews/) */
function ItemPreview({ item }) {
if (item.type === 'material') {
return (
<div style={{
height: 64,
background: item.data?.color || '#888888',
borderBottom: '1px solid var(--border-light)',
}} />
)
}
const iconName = item.type === 'symbol' ? 'navigation' : 'forest'
return (
<div style={{
height: 64,
background: 'var(--bg-input)',
borderBottom: '1px solid var(--border-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Icon name={iconName} size={28}
style={{ color: 'var(--text-muted)' }} />
</div>
)
}
/* ItemCard — Preview + Name + Tags + Import-Pill */
function ItemCard({ item, imported, onImport }) {
// material wird "importiert" gezeigt wenn schon in Project-Settings.
// symbol/object koennen N-mal eingefuegt werden → "Importiert" wird
// nicht persistent, jeder Klick legt eine weitere Instanz an.
const isMaterial = item.type === 'material'
const ctaLabel = isMaterial
? (imported ? 'Importiert' : 'Importieren')
: 'Einfügen'
const ctaDisabled = isMaterial && imported
return (
<div style={{
display: 'flex', flexDirection: 'column',
border: '1px solid var(--border)', borderRadius: 8,
background: 'var(--bg-section)',
overflow: 'hidden',
cursor: ctaDisabled ? 'default' : 'pointer',
opacity: ctaDisabled ? 0.75 : 1,
transition: 'border-color 0.15s, background 0.15s',
}}
onMouseEnter={(e) => {
if (ctaDisabled) return
e.currentTarget.style.borderColor = 'var(--accent)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border)'
}}
onDoubleClick={() => { if (!ctaDisabled) onImport(item.id) }}
title={ctaDisabled ? 'Bereits importiert' : 'Doppelklick = importieren'}>
<ItemPreview item={item} />
<div style={{ padding: '8px 10px', display: 'flex',
flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ flex: 1, fontSize: 11, fontWeight: 600,
color: 'var(--text-primary)',
overflow: 'hidden', textOverflow: 'ellipsis',
whiteSpace: 'nowrap' }}>
{item.name}
</span>
{ctaDisabled && (
<Icon name="check" size={12}
style={{ color: 'var(--accent)' }} />
)}
</div>
{(item.tags || []).length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
{(item.tags || []).slice(0, 4).map(t => (
<span key={t} style={{
fontSize: 9, color: 'var(--text-muted)',
background: 'var(--bg-input)',
padding: '1px 6px', borderRadius: 999,
border: '1px solid var(--border-light)',
}}>{t}</span>
))}
</div>
)}
<BarToggle
label={ctaLabel}
active={!ctaDisabled}
onClick={() => { if (!ctaDisabled) onImport(item.id) }}
disabled={ctaDisabled} />
</div>
</div>
)
}
export default function LibraryBrowser({
manifest, importedIds, libraryRoot,
onImport, onReload, onClose,
}) {
const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState('all')
const items = manifest?.items || []
const importedSet = useMemo(() => new Set(importedIds || []),
[importedIds])
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
return items.filter(it => {
if (typeFilter !== 'all' && it.type !== typeFilter) return false
if (!q) return true
if ((it.name || '').toLowerCase().includes(q)) return true
if ((it.tags || []).some(t => t.toLowerCase().includes(q))) return true
return false
})
}, [items, search, typeFilter])
const types = useMemo(() => {
const s = new Set(items.map(i => i.type))
return ['all', ...Array.from(s)]
}, [items])
return (
<div style={{
width: '100%', height: '100%',
background: 'var(--bg-dialog)',
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
fontFamily: 'var(--font)', color: 'var(--text-primary)', fontSize: 11,
}}>
{/* Toolbar — Suche + Pill-Filter + Reload */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 12px',
borderBottom: '1px solid var(--border)',
}}>
<input type="text" value={search}
onChange={(ev) => setSearch(ev.target.value)}
placeholder="Suchen (Name oder Tag)…"
style={{ flex: 1, height: BAR_H, padding: '0 12px',
fontSize: 11 }} />
<div style={{ display: 'flex', gap: 4 }}>
{types.map(t => (
<BarToggle key={t}
label={t === 'all' ? 'Alle' : t}
active={typeFilter === t}
onClick={() => setTypeFilter(t)} />
))}
</div>
<BarButton icon="refresh" onClick={onReload}
title="Manifest neu laden" />
</div>
{/* Grid */}
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto',
padding: '10px 12px' }}>
{filtered.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center',
color: 'var(--text-muted)', fontSize: 11 }}>
{items.length === 0
? 'Library ist leer. Manifest unter:'
: 'Nichts gefunden — Filter aendern?'}
{items.length === 0 && libraryRoot && (
<div style={{ marginTop: 6, fontSize: 9, fontFamily: 'monospace' }}>
{libraryRoot}/library.json
</div>
)}
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: 8,
}}>
{filtered.map(it => (
<ItemCard key={it.id}
item={it}
imported={importedSet.has(it.id)}
onImport={onImport} />
))}
</div>
)}
</div>
{/* Footer */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 12px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-section)',
fontSize: 9, color: 'var(--text-muted)',
}}>
<span>{filtered.length} / {items.length} Items</span>
<div style={{ flex: 1 }} />
<span title={libraryRoot}
style={{ fontFamily: 'monospace',
overflow: 'hidden', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', maxWidth: 320 }}>
{libraryRoot || ''}
</span>
</div>
</div>
)
}