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>
This commit is contained in:
2026-05-24 16:57:42 +02:00
parent f60d643bb7
commit f760d1c54b
6 changed files with 375 additions and 217 deletions
+74 -69
View File
@@ -1,27 +1,67 @@
import { useState, useMemo } from 'react'
import Icon from './Icon'
import { BarToggle, BarButton, BAR_H } from './BarControls'
/* MaterialCard — Preview-Swatch + Name + Tags + Import-Button. */
function MaterialCard({ item, imported, onImport }) {
const color = item.data?.color || '#888888'
/* 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: 6,
border: '1px solid var(--border)', borderRadius: 8,
background: 'var(--bg-section)',
overflow: 'hidden',
cursor: imported ? 'default' : 'pointer',
opacity: imported ? 0.75 : 1,
cursor: ctaDisabled ? 'default' : 'pointer',
opacity: ctaDisabled ? 0.75 : 1,
transition: 'border-color 0.15s, background 0.15s',
}}
onDoubleClick={() => { if (!imported) onImport(item.id) }}
title={imported ? 'Bereits importiert' : 'Doppelklick = importieren'}>
<div style={{
height: 64,
background: color,
borderBottom: '1px solid var(--border-light)',
}} />
<div style={{ padding: '6px 8px', display: 'flex',
flexDirection: 'column', gap: 4 }}>
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)',
@@ -29,7 +69,7 @@ function MaterialCard({ item, imported, onImport }) {
whiteSpace: 'nowrap' }}>
{item.name}
</span>
{imported && (
{ctaDisabled && (
<Icon name="check" size={12}
style={{ color: 'var(--accent)' }} />
)}
@@ -40,23 +80,17 @@ function MaterialCard({ item, imported, onImport }) {
<span key={t} style={{
fontSize: 9, color: 'var(--text-muted)',
background: 'var(--bg-input)',
padding: '1px 5px', borderRadius: 999,
padding: '1px 6px', borderRadius: 999,
border: '1px solid var(--border-light)',
}}>{t}</span>
))}
</div>
)}
<button
onClick={() => { if (!imported) onImport(item.id) }}
disabled={imported}
style={{
marginTop: 2, padding: '3px 6px', fontSize: 10,
background: imported ? 'transparent' : 'var(--accent)',
color: imported ? 'var(--text-muted)' : '#fff',
border: imported ? '1px solid var(--border)' : 'none',
borderRadius: 999, cursor: imported ? 'default' : 'pointer',
}}>
{imported ? 'Importiert' : 'Importieren'}
</button>
<BarToggle
label={ctaLabel}
active={!ctaDisabled}
onClick={() => { if (!ctaDisabled) onImport(item.id) }}
disabled={ctaDisabled} />
</div>
</div>
)
@@ -97,30 +131,7 @@ export default function LibraryBrowser({
overflow: 'hidden',
fontFamily: 'var(--font)', color: 'var(--text-primary)', fontSize: 11,
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 12px',
borderBottom: '1px solid var(--border)',
}}>
<Icon name="inventory_2" size={14}
style={{ color: 'var(--accent)', flexShrink: 0 }} />
<span style={{ flex: 1, fontWeight: 600, fontSize: 11 }}>
{manifest?.name || 'Dossier-Library'}
</span>
<button onClick={onReload}
title="Manifest neu laden"
style={{ background: 'transparent', border: 'none',
cursor: 'pointer', color: 'var(--text-muted)',
padding: '0 4px' }}>
<Icon name="refresh" size={14} />
</button>
<button onClick={onClose}
style={{ color: 'var(--text-muted)', fontSize: 16,
padding: '0 4px', lineHeight: 1 }}>×</button>
</div>
{/* Toolbar */}
{/* Toolbar — Suche + Pill-Filter + Reload */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 12px',
@@ -129,24 +140,18 @@ export default function LibraryBrowser({
<input type="text" value={search}
onChange={(ev) => setSearch(ev.target.value)}
placeholder="Suchen (Name oder Tag)…"
style={{ flex: 1, fontSize: 11, padding: '4px 8px',
borderRadius: 999 }} />
<div style={{ display: 'flex', gap: 0,
border: '1px solid var(--border)',
borderRadius: 999, overflow: 'hidden' }}>
style={{ flex: 1, height: BAR_H, padding: '0 12px',
fontSize: 11 }} />
<div style={{ display: 'flex', gap: 4 }}>
{types.map(t => (
<button key={t}
onClick={() => setTypeFilter(t)}
style={{
background: typeFilter === t ? 'var(--accent)' : 'transparent',
color: typeFilter === t ? '#fff' : 'var(--text-muted)',
border: 'none', padding: '4px 10px', fontSize: 10,
cursor: 'pointer',
}}>
{t === 'all' ? 'Alle' : t}
</button>
<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 */}
@@ -171,7 +176,7 @@ export default function LibraryBrowser({
gap: 8,
}}>
{filtered.map(it => (
<MaterialCard key={it.id}
<ItemCard key={it.id}
item={it}
imported={importedSet.has(it.id)}
onImport={onImport} />
+86 -112
View File
@@ -1,14 +1,16 @@
import { useState } from 'react'
import Icon from './Icon'
import { BarToggle, BarButton, BAR_H } from './BarControls'
import { openLibrary } from '../lib/rhinoBridge'
/* Inline Field + Tab helpers fuer kompaktes Layout im Satellite. */
/* Pill-Stil Field — Label klein in Caps, Inhalt darunter, optional Hint */
function Field({ label, hint, children, style }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3,
padding: '5px 0', ...style }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)',
fontWeight: 500, letterSpacing: 0.2 }}>{label}</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4,
padding: '6px 0', ...style }}>
<span style={{ fontSize: 9, color: 'var(--text-muted)',
fontWeight: 500, letterSpacing: '0.06em',
textTransform: 'uppercase' }}>{label}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>{children}</div>
{hint && (
<span style={{ fontSize: 9, color: 'var(--text-muted)', lineHeight: 1.4 }}>
@@ -19,56 +21,54 @@ function Field({ label, hint, children, style }) {
)
}
/* Pill-Tabs — gleicher Stil wie BarToggle aus der Oberleiste */
function TabBar({ tabs, active, onChange }) {
return (
<div style={{
display: 'flex', gap: 0,
display: 'flex', gap: 4,
padding: '8px 12px',
borderBottom: '1px solid var(--border)',
paddingLeft: 4,
}}>
{tabs.map(t => (
<button key={t.key}
onClick={() => onChange(t.key)}
style={{
background: 'transparent',
border: 'none',
borderBottom: active === t.key ? '2px solid var(--accent)' : '2px solid transparent',
padding: '8px 14px',
fontSize: 11, fontWeight: active === t.key ? 600 : 500,
color: active === t.key ? 'var(--text-primary)' : 'var(--text-muted)',
cursor: 'pointer',
marginBottom: -1,
}}>
{t.label}
</button>
<BarToggle key={t.key}
label={t.label}
active={active === t.key}
onClick={() => onChange(t.key)} />
))}
</div>
)
}
function MaterialRow({ mat, hatchPatterns, onChange, onDelete, builtin }) {
const isBuiltin = builtin || mat.source === 'builtin'
const isLibrary = mat.source === 'library'
return (
<div style={{
display: 'grid', gridTemplateColumns: '14px 1fr 90px 60px 18px',
display: 'grid', gridTemplateColumns: '18px 1fr 92px 56px 22px',
alignItems: 'center', gap: 6,
padding: '4px 8px',
borderBottom: '1px solid var(--border-light)',
background: builtin ? 'var(--bg-section)' : 'transparent',
}}>
borderRadius: 6,
background: isBuiltin ? 'var(--bg-section)' : 'transparent',
transition: 'background 0.12s',
}}
onMouseEnter={(e) => { if (!isBuiltin) e.currentTarget.style.background = 'var(--bg-item-hover)' }}
onMouseLeave={(e) => { if (!isBuiltin) e.currentTarget.style.background = 'transparent' }}
>
<input type="color" value={mat.color || '#888888'}
onChange={(ev) => onChange({ ...mat, color: ev.target.value })}
title="Farbe"
style={{ width: 14, height: 14, padding: 0, border: 'none',
style={{ width: 18, height: 18, padding: 0, border: 'none',
background: 'transparent', cursor: 'pointer' }} />
<input type="text" value={mat.name || ''}
onChange={(ev) => onChange({ ...mat, name: ev.target.value })}
disabled={builtin}
disabled={isBuiltin}
placeholder="Name"
style={{ flex: 1, fontSize: 11, minWidth: 0,
opacity: builtin ? 0.7 : 1 }} />
style={{ minWidth: 0, height: BAR_H, padding: '0 10px',
fontSize: 11,
opacity: isBuiltin ? 0.7 : 1 }} />
<select value={mat.hatch || 'Solid'}
onChange={(ev) => onChange({ ...mat, hatch: ev.target.value })}
style={{ fontSize: 11, minWidth: 0 }}>
style={{ minWidth: 0, height: BAR_H, fontSize: 10 }}>
{(hatchPatterns || []).map(h => (
<option key={h} value={h}>{h}</option>
))}
@@ -77,19 +77,30 @@ function MaterialRow({ mat, hatchPatterns, onChange, onDelete, builtin }) {
value={mat.scale ?? 1.0}
onChange={(ev) => onChange({ ...mat, scale: parseFloat(ev.target.value) || 1.0 })}
title="Hatch-Skalierung"
style={{ fontSize: 11, textAlign: 'right' }} />
{mat.source === 'library' ? (
<span style={{ fontSize: 9, color: 'var(--accent-light)' }}
style={{ minWidth: 0, height: BAR_H, padding: '0 10px',
fontSize: 11, textAlign: 'right' }} />
{isLibrary ? (
<span style={{ fontSize: 9, fontWeight: 600,
color: 'var(--accent)',
display: 'inline-flex', alignItems: 'center',
justifyContent: 'center' }}
title="Aus Dossier-Library">L</span>
) : builtin || mat.source === 'builtin' ? (
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}
) : isBuiltin ? (
<span style={{ fontSize: 9, fontWeight: 600,
color: 'var(--text-muted)',
display: 'inline-flex', alignItems: 'center',
justifyContent: 'center' }}
title="Eingebaut">B</span>
) : (
<button onClick={onDelete} title="Loeschen"
style={{ background: 'transparent', border: 'none',
padding: 0, cursor: 'pointer',
color: 'var(--text-muted)' }}>
<Icon name="close" size={12} />
color: 'var(--text-muted)',
display: 'inline-flex', alignItems: 'center',
justifyContent: 'center' }}
onMouseEnter={(e) => e.currentTarget.style.color = 'var(--accent)'}
onMouseLeave={(e) => e.currentTarget.style.color = 'var(--text-muted)'}>
<Icon name="close" size={14} />
</button>
)}
</div>
@@ -121,13 +132,15 @@ export default function ProjectSettingsDialog({
materials: [...d.materials, {
name: 'Neues Material', color: '#aaaaaa',
hatch: 'Solid', scale: 1.0,
// source/libraryId vorbereitet fuer kommendes Library-Feature:
// 'local' = manuell erstellt, 'library' = aus Cloud-Repo, libraryId
// referenziert dann das Library-Item per UUID.
source: 'local', libraryId: null,
}],
}))
const numberInputStyle = {
flex: 1, height: BAR_H, padding: '0 12px',
fontSize: 11, textAlign: 'right',
}
const wrapperStyle = embedded ? {
width: '100%', height: '100%',
background: 'var(--bg-dialog)',
@@ -144,22 +157,6 @@ export default function ProjectSettingsDialog({
<div style={wrapperStyle}>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1,
minHeight: 0, overflow: 'hidden' }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 12px',
borderBottom: '1px solid var(--border)',
}}>
<Icon name="tune" size={14}
style={{ color: 'var(--text-secondary)', flexShrink: 0 }} />
<span style={{ flex: 1, fontWeight: 600, fontSize: 11 }}>
Projekt-Einstellungen
</span>
<button onClick={onClose}
style={{ color: 'var(--text-muted)', fontSize: 16,
padding: '0 4px', lineHeight: 1 }}>×</button>
</div>
<TabBar tabs={[
{ key: 'defaults', label: 'Voreinstellungen' },
{ key: 'materials', label: 'Materialien' },
@@ -170,47 +167,46 @@ export default function ProjectSettingsDialog({
padding: '8px 14px' }}>
{tab === 'defaults' && (
<>
<div style={{ fontSize: 9, color: 'var(--text-muted)',
padding: '4px 0 8px', lineHeight: 1.5 }}>
Diese Werte werden beim Erstellen neuer Elemente als
Voreinstellung genommen. Pro-Element editierte Werte
bleiben davon unberuehrt.
<div style={{ fontSize: 10, color: 'var(--text-muted)',
padding: '6px 0 10px', lineHeight: 1.5 }}>
Voreinstellungen fuer neue Elemente. Pro-Element editierte
Werte bleiben davon unberuehrt.
</div>
<Field label="STANDARD-GESCHOSSHÖHE (m)"
<Field label="Standard-Geschosshöhe (m)"
hint="Vorgabe fuer neue Geschosse — pro Geschoss ueberschreibbar">
<input type="number" step="0.05" min="1.0" max="10"
value={draft.defaults.geschossHoehe ?? 3.0}
onChange={(ev) => setDefault('geschossHoehe', parseFloat(ev.target.value) || 3.0)}
style={{ flex: 1, textAlign: 'right' }} />
style={numberInputStyle} />
</Field>
<Field label="STANDARD-SCHNITTHÖHE (m)"
<Field label="Standard-Schnitthöhe (m)"
hint="Höhe der horizontalen Schnitt-Plane über OKFF eines Geschosses">
<input type="number" step="0.05" min="0.1" max="3"
value={draft.defaults.schnitthoehe ?? 1.0}
onChange={(ev) => setDefault('schnitthoehe', parseFloat(ev.target.value) || 1.0)}
style={{ flex: 1, textAlign: 'right' }} />
style={numberInputStyle} />
</Field>
<div style={{ height: 1, background: 'var(--border-light)',
margin: '8px 0' }} />
<Field label="STANDARD-SCHNITT-TIEFE HINTEN (m)"
hint="Default-Tiefe für neue Schnitte/Ansichten — wie weit hinter der Schnittlinie sichtbar">
margin: '12px 0' }} />
<Field label="Standard-Schnitt-Tiefe hinten (m)"
hint="Default-Tiefe fuer neue Schnitte/Ansichten">
<input type="number" step="0.5" min="0.5"
value={draft.defaults.schnittDepthBack ?? 8.0}
onChange={(ev) => setDefault('schnittDepthBack', parseFloat(ev.target.value) || 8.0)}
style={{ flex: 1, textAlign: 'right' }} />
style={numberInputStyle} />
</Field>
<div style={{ display: 'flex', gap: 6 }}>
<Field label="HÖHE UNTEN (m)" style={{ flex: 1 }}>
<div style={{ display: 'flex', gap: 8 }}>
<Field label="Höhe unten (m)" style={{ flex: 1 }}>
<input type="number" step="0.1"
value={draft.defaults.schnittHeightMin ?? -1.0}
onChange={(ev) => setDefault('schnittHeightMin', parseFloat(ev.target.value))}
style={{ flex: 1, textAlign: 'right' }} />
style={numberInputStyle} />
</Field>
<Field label="HÖHE OBEN (m)" style={{ flex: 1 }}>
<Field label="Höhe oben (m)" style={{ flex: 1 }}>
<input type="number" step="0.1"
value={draft.defaults.schnittHeightMax ?? 12.0}
onChange={(ev) => setDefault('schnittHeightMax', parseFloat(ev.target.value))}
style={{ flex: 1, textAlign: 'right' }} />
style={numberInputStyle} />
</Field>
</div>
</>
@@ -218,21 +214,19 @@ export default function ProjectSettingsDialog({
{tab === 'materials' && (
<>
<div style={{ fontSize: 9, color: 'var(--text-muted)',
padding: '4px 0 8px', lineHeight: 1.5 }}>
Eingebaute Materialien (B) koennen nicht umbenannt werden,
aber Farbe + Hatch sind anpassbar. Eigene Materialien
koennen frei angelegt werden.
<div style={{ fontSize: 10, color: 'var(--text-muted)',
padding: '6px 0 10px', lineHeight: 1.5 }}>
Eingebaute Materialien (B) Farbe + Hatch anpassbar, Name fix.
Library-Materialien (L) aus Dossier-Library importiert.
Lokale Materialien frei editierbar.
</div>
{/* Built-in materials (read-only name) */}
{builtin.map((m, i) => (
{builtin.map((m) => (
<MaterialRow key={'b_' + m.name}
mat={m}
builtin
hatchPatterns={hatchPatterns}
onChange={() => {/* read-only fuer Phase 1 */}} />
onChange={() => {/* read-only Phase 1 */}} />
))}
{/* User materials */}
{draft.materials.map((m, i) => (
<MaterialRow key={'u_' + i}
mat={m}
@@ -240,36 +234,18 @@ export default function ProjectSettingsDialog({
onChange={(nm) => setMat(i, nm)}
onDelete={() => delMat(i)} />
))}
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button onClick={addMat}
style={{
padding: '4px 10px', fontSize: 11,
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 999, cursor: 'pointer',
color: 'var(--text-primary)',
display: 'inline-flex', alignItems: 'center', gap: 4,
}}>
<Icon name="add" size={12} /> Material hinzufuegen
</button>
<button onClick={openLibrary}
title="Material aus Dossier-Library importieren"
style={{
padding: '4px 10px', fontSize: 11,
background: 'transparent',
border: '1px solid var(--accent)',
borderRadius: 999, cursor: 'pointer',
color: 'var(--accent)',
display: 'inline-flex', alignItems: 'center', gap: 4,
}}>
<Icon name="inventory_2" size={12} /> Library oeffnen
</button>
<div style={{ display: 'flex', gap: 6, marginTop: 12 }}>
<BarToggle icon="add" label="Material" onClick={addMat}
title="Neues Material anlegen" />
<BarToggle icon="inventory_2" label="Library"
onClick={openLibrary}
title="Material aus Dossier-Library importieren" />
</div>
</>
)}
</div>
{/* Footer */}
{/* Footer — Pill-Buttons */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 12px',
@@ -277,10 +253,8 @@ export default function ProjectSettingsDialog({
background: 'var(--bg-section)',
}}>
<div style={{ flex: 1 }} />
<button className="btn-text" onClick={onClose}>Abbrechen</button>
<button className="btn-contained" onClick={() => onSave(draft)}>
Übernehmen
</button>
<BarToggle label="Abbrechen" onClick={onClose} />
<BarToggle label="Übernehmen" active onClick={() => onSave(draft)} />
</div>
</div>
</div>