Files
DOSSIER/launcher/src/App.jsx
T
karim 961b3c0396 Snapshot: Wand/Öffnung Multi-Surface-Select + Z-Drag + Brüstungs-Mitnahme
Stable working state after a long iteration session. The plugin now supports:
- Multi-Surface-Select für alle Element-Typen (Türen/Fenster/Treppen/Tragwerk)
- Wand-Z-Drag → unbound mode (UK/OK-Override, Wand vom Geschoss entkoppelt)
- Wand-Z-Drag nimmt verknüpfte Öffnungen mit (Brüstung += delta_z via Idle-Pfad)
- Öffnungs-XY-Drag snapt direktional auf Wand-Tangente
- Öffnungs-Z-Drag passt Brüstung an (Fenster sofort sync, Tür deferred)
- Wand-Delete kaskadiert Öffnungen (deferred via Idle, robust gegen _Rotate/_Move)
- Source-Cascade beim Öffnungs-Delete (deferred analog Wand-Kaskade)
- Listener-Cleanup robust gegen _reset_panels.py Reload (Refs in
  _dossier_runtime_event_refs gespeichert, vor Re-Install deregistriert)
- _count_same_id_type filtert IsDeleted (verhindert Source-Duplikat-Bug bei Move)
- Frontend: Brüstungs-Slider für Tür ("Schwelle"), Flügel-Block nur bei Fenster

Plus aus früherer Phase dieser Session:
- Dossier-Launcher Auto-Load via Rhinos StartupCommands-XML
- Default-Pfad zeigt auf gebundeltes startup.py (out-of-the-box für neue User)
- Splash-Window beim Plugin-Load mit native macOS rounded corners
- Diverse Launcher-Verbesserungen (Brüstungs-Default, tauri.conf, capabilities)

Known issue: bei Multi-Select-Move mit vielen Sub-Volumen kann sporadisch
"Unable to transform" auftreten (Rhinos Move-Operation kollidiert mit Wand-
Regen). Tür-spezifischer Defer-Pfad mildert das, Fenster läuft sync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:50:45 +02:00

2060 lines
77 KiB
React

import React, { useEffect, useState, useMemo, useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'
import UpdateNotifier from './components/UpdateNotifier.jsx'
import Icon from './components/Icon.jsx'
import {
checkForAppUpdate,
installAppUpdate,
skipUpdateVersion,
getLastUpdateCheck,
formatLastCheck,
isTauri,
} from './utils/updater.js'
// Transitive Dependency-Aufloesung: gibt Set aller Module zurueck, die
// aktiviert sein muessen, wenn `selected` aktiviert sind.
function resolveDeps(selected, allModules) {
const byId = Object.fromEntries(allModules.map(m => [m.id, m]))
const out = new Set(selected)
let changed = true
while (changed) {
changed = false
for (const id of [...out]) {
const m = byId[id]
if (!m) continue
for (const dep of (m.dependsOn || [])) {
if (!out.has(dep)) { out.add(dep); changed = true }
}
}
}
return out
}
function formatRelative(iso) {
if (!iso) return ''
try {
const d = new Date(iso)
const now = new Date()
const diff = (now - d) / 1000
if (diff < 60) return 'vor wenigen Sek.'
if (diff < 3600) return `vor ${Math.floor(diff/60)} Min.`
if (diff < 86400) return `vor ${Math.floor(diff/3600)} h`
if (diff < 86400 * 7) return `vor ${Math.floor(diff/86400)} Tagen`
return d.toLocaleDateString('de-CH')
} catch { return '' }
}
function formatFileSize(bytes) {
if (!bytes && bytes !== 0) return ''
const mb = bytes / (1024 * 1024)
if (mb >= 1) return `${mb.toFixed(1)} MB`
return `${(bytes / 1024).toFixed(0)} KB`
}
// Sortierung: gepinnte zuerst (intern nach lastOpened), dann der Rest nach
// lastOpened. Stabil — ein Toggle der Pin-Eigenschaft veraendert die Reihenfolge
// vorhersagbar.
function sortProjects(list) {
return [...list].sort((a, b) => {
if (!!a.pinned !== !!b.pinned) return a.pinned ? -1 : 1
return (b.lastOpened || '').localeCompare(a.lastOpened || '')
})
}
function newTagId() {
return 'tag_' + Math.random().toString(36).slice(2, 9)
}
// Schwarz/Weiss-Kontrast fuer Tag-Chip-Text
function isDarkColor(hex) {
if (!hex) return true
const h = hex.replace('#', '')
const n = h.length === 3
? [0,1,2].map(i => parseInt(h[i] + h[i], 16))
: [0,2,4].map(i => parseInt(h.slice(i, i+2), 16))
// Perceived brightness
return (n[0] * 0.299 + n[1] * 0.587 + n[2] * 0.114) < 140
}
export default function App() {
const [recent, setRecent] = useState([])
const [modules, setModules] = useState([])
const [dialog, setDialog] = useState(null) // null | { mode: 'new'|'edit', project? }
const [settingsOpen, setSettingsOpen] = useState(false)
const [settingsTab, setSettingsTab] = useState('rhino')
const [busy, setBusy] = useState(false)
const [query, setQuery] = useState('')
// Window-Layouts + aktuell gewaehltes Default — fuer Quick-Switcher in Topbar
const [layouts, setLayouts] = useState([])
const [activeLayout, setActiveLayout] = useState('')
// Rhino-Status: alle 5s pollen
const [rhinoRunning, setRhinoRunning] = useState(false)
// Tags aus den globalen Settings — Settings-Tab editiert sie, Dialog + Card
// konsumieren sie. Single Source of Truth ist die Tauri-settings.json.
const [tags, setTags] = useState([])
// File-Meta pro Pfad (Map). Wird beim Mount + nach OpenProject neu geladen.
const [fileMeta, setFileMeta] = useState({})
// Aktiv gefilterte Tag-IDs (Topbar-Filter). Leeres Set = alle anzeigen.
const [tagFilter, setTagFilter] = useState(new Set())
// Snapshot-Toast (Pfad oder null)
const [snapshotToast, setSnapshotToast] = useState(null)
// View-Tabs: 'recent' = kuerzlich geoeffnete, 'all' = alle mit Sort/Group
const [view, setView] = useState('recent')
const [sortBy, setSortBy] = useState('lastOpened') // lastOpened | name | createdAt
const [groupByTag, setGroupByTag] = useState(false)
// Pro-Projekt-Pfad -> bool (.rhl-Lock vorhanden?)
const [openMap, setOpenMap] = useState({})
// Pro-Pfad -> dataUrl (PNG-Thumbnail) | null
const [thumbMap, setThumbMap] = useState({})
useEffect(() => {
invoke('list_recent').then(r => setRecent(sortProjects(r || []))).catch(console.error)
invoke('read_modules_manifest').then(setModules).catch(console.error)
invoke('list_window_layouts').then(setLayouts).catch(() => {})
invoke('read_dossier_settings').then(ds => setActiveLayout(ds?.windowLayout || '')).catch(() => {})
invoke('read_settings').then(s => setTags(s?.tags || [])).catch(() => {})
}, [])
// File-Meta laden sobald recent sich aendert
useEffect(() => {
let alive = true
Promise.all((recent || []).map(p =>
invoke('get_file_meta', { path: p.path })
.then(m => [p.path, m]).catch(() => [p.path, null])
)).then(pairs => {
if (!alive) return
const map = {}
for (const [k, v] of pairs) map[k] = v
setFileMeta(map)
})
return () => { alive = false }
}, [recent])
// Thumbnails laden — Initial + alle 30s neu (Cache-Refresh nach Saves)
useEffect(() => {
let alive = true
const load = async () => {
if (recent.length === 0) return
const pairs = await Promise.all(recent.map(p =>
invoke('read_thumbnail', { path: p.path })
.then(r => [p.path, r?.dataUrl || null])
.catch(() => [p.path, null])
))
if (!alive) return
const next = {}
for (const [k, v] of pairs) next[k] = v
setThumbMap(next)
}
load()
const t = setInterval(load, 30000)
return () => { alive = false; clearInterval(t) }
}, [recent])
// Rhino-Status-Poll + Open-Status pro File (.rhl-Check fuer primary + extras)
useEffect(() => {
let alive = true
const check = async () => {
try {
const running = await invoke('is_rhino_running')
if (!alive) return
setRhinoRunning(!!running)
if (running && recent.length > 0) {
// Alle zu checkenden Pfade sammeln (primary + extras) ohne Duplikate
const paths = new Set()
for (const p of recent) {
paths.add(p.path)
for (const f of (p.extraFiles || [])) paths.add(f)
}
const results = await Promise.all([...paths].map(pth =>
invoke('is_project_open', { path: pth })
.then(b => [pth, !!b]).catch(() => [pth, false])
))
if (!alive) return
const map = {}
for (const [k, v] of results) map[k] = v
setOpenMap(map)
} else {
if (alive) setOpenMap({})
}
} catch {}
}
check()
const t = setInterval(check, 5000)
return () => { alive = false; clearInterval(t) }
}, [recent])
// Tray neu bauen sobald recent sich aendert (Reihenfolge, Inhalt)
const syncTray = useCallback(() => {
invoke('refresh_tray_menu').catch(() => {})
}, [])
// Gefiltert nach Query + aktiven Tag-Filtern
const filteredRecent = useMemo(() => {
const q = query.trim().toLowerCase()
return recent.filter(p => {
if (q) {
const inText =
(p.name || '').toLowerCase().includes(q) ||
(p.path || '').toLowerCase().includes(q)
if (!inText) return false
}
if (tagFilter.size > 0) {
const pTags = new Set(p.tagIds || [])
for (const t of tagFilter) if (!pTags.has(t)) return false
}
return true
})
}, [recent, query, tagFilter])
// 'Recent'-View: nur die letzten 8, ohne Tag-Filter
const recentSlice = useMemo(() => {
return [...recent]
.sort((a, b) => {
if (!!a.pinned !== !!b.pinned) return a.pinned ? -1 : 1
return (b.lastOpened || '').localeCompare(a.lastOpened || '')
})
.slice(0, 8)
}, [recent])
// 'Alle'-View: Sortierung nach gewaehltem Kriterium (Pin immer oben)
const allSorted = useMemo(() => {
const cmp = (a, b) => {
if (!!a.pinned !== !!b.pinned) return a.pinned ? -1 : 1
switch (sortBy) {
case 'name': return (a.name || '').localeCompare(b.name || '')
case 'createdAt': return (b.createdAt || b.lastOpened || '').localeCompare(a.createdAt || a.lastOpened || '')
case 'lastOpened':
default: return (b.lastOpened || '').localeCompare(a.lastOpened || '')
}
}
return [...filteredRecent].sort(cmp)
}, [filteredRecent, sortBy])
// Wenn group-by-tag aktiv: Map tagId -> Projekte. Untagged kommt zuletzt.
const allGrouped = useMemo(() => {
if (!groupByTag) return null
const groups = new Map()
for (const t of tags) groups.set(t.id, [])
groups.set('__untagged__', [])
for (const p of allSorted) {
const ids = (p.tagIds || []).filter(id => groups.has(id))
if (ids.length === 0) {
groups.get('__untagged__').push(p)
} else {
for (const id of ids) groups.get(id).push(p)
}
}
// Leere Gruppen wegwerfen
return Array.from(groups.entries()).filter(([_, ps]) => ps.length > 0)
}, [allSorted, groupByTag, tags])
const togglePin = async (proj) => {
const next = sortProjects(recent.map(p =>
p.path === proj.path ? { ...p, pinned: !p.pinned } : p
))
setRecent(next)
await invoke('save_recent', { projects: next }).catch(() => {})
syncTray()
}
const snapshotProject = async (proj) => {
try {
const target = await invoke('create_snapshot', { path: proj.path })
setSnapshotToast(target)
setTimeout(() => setSnapshotToast(null), 2400)
} catch (ex) {
alert(`Snapshot fehlgeschlagen: ${ex}`)
}
}
const toggleTagFilter = (tagId) => {
setTagFilter(prev => {
const next = new Set(prev)
if (next.has(tagId)) next.delete(tagId); else next.add(tagId)
return next
})
}
const onPickLayout = async (name) => {
setActiveLayout(name)
// Default speichern (fuer Auto-Apply beim naechsten Start)
const cur = await invoke('read_dossier_settings').catch(() => ({}))
await invoke('save_dossier_settings', {
settings: { ...cur, windowLayout: name || null },
}).catch(() => {})
if (name) {
// Bei aktiver Rhino-Session: direkt anwenden via Polling-Flag
await invoke('apply_layout_now', { name }).catch(() => {})
}
}
// Tray-Menue navigiert ueber das `dossier:navigate` Event.
useEffect(() => {
let unlisten = null
listen('dossier:navigate', (e) => {
const view = e.payload
if (view === 'settings') { setSettingsTab('rhino'); setSettingsOpen(true) }
else if (view === 'new') { setDialog({ mode: 'new' }) }
else if (view === 'check-update') {
setSettingsTab('updates')
setSettingsOpen(true)
// Modal-Pfad ueber UpdateNotifier wuerde nur bei verfuegbarem Update
// erscheinen — fuer den manuellen Check zeigen wir den Settings-Tab,
// der direkt den Check-Button hat.
}
}).then(fn => { unlisten = fn })
return () => { if (unlisten) unlisten() }
}, [])
const openProject = async (proj, fileOverride = null) => {
if (busy) return
const target = fileOverride || proj.path
setBusy(true)
try {
// Per-Projekt-Layout NUR beim Oeffnen der Primary-Datei anwenden —
// bei Extra-Files wollen wir vermutlich nicht das Layout switchen.
if (!fileOverride && proj.windowLayout) {
try { await invoke('apply_layout_now', { name: proj.windowLayout }) }
catch (ex) { console.warn('per-project layout:', ex) }
}
await invoke('open_rhino', { path3dm: target })
const next = recent
.map(p => p.path === proj.path ? { ...p, lastOpened: new Date().toISOString() } : p)
.sort((a, b) => (b.lastOpened || '').localeCompare(a.lastOpened || ''))
await invoke('save_recent', { projects: next })
setRecent(next)
syncTray()
} catch (ex) {
alert(`Rhino-Start fehlgeschlagen: ${ex}`)
} finally {
setBusy(false)
}
}
const revealProject = async (proj) => {
try { await invoke('show_in_finder', { path: proj.path }) }
catch (ex) { alert(`Im Finder zeigen: ${ex}`) }
}
const editProject = (proj) => {
setDialog({ mode: 'edit', project: proj })
}
const saveProject = async ({ name, path, modules: mods, useTemplate, windowLayout,
tagIds, extraFiles }) => {
if (!path) { alert('Bitte eine .3dm-Datei auswaehlen.'); return }
if (!name.trim()) { alert('Bitte einen Projekt-Namen angeben.'); return }
try {
if (useTemplate) {
try { await invoke('copy_template_to', { targetPath: path }) }
catch (ex) { console.warn('Template-Kopie:', ex) }
}
await invoke('write_project_config', { path3dm: path, name: name.trim(), modules: mods })
const existing = recent.find(p => p.path === path)
const others = recent.filter(p => p.path !== path)
const now = new Date().toISOString()
const merged = {
name: name.trim(),
path,
modules: mods,
windowLayout: windowLayout || null,
tagIds: tagIds || [],
extraFiles: extraFiles || [],
pinned: existing?.pinned || false,
createdAt: existing?.createdAt || now,
lastOpened: existing?.lastOpened || now,
}
const next = sortProjects([merged, ...others])
await invoke('save_recent', { projects: next })
setRecent(next)
syncTray()
setDialog(null)
} catch (ex) {
alert(`Speichern fehlgeschlagen: ${ex}`)
}
}
const removeProject = async (proj) => {
const next = recent.filter(p => p.path !== proj.path)
await invoke('save_recent', { projects: next })
setRecent(next)
syncTray()
}
return (
<div className="app">
<div className="topbar">
<div className="brand-wrap">
<span className="brand">DOSSIER<span className="brand-dot">.</span></span>
<span className="version">v0.6.3</span>
</div>
<span className="pre-badge">Pre-Release</span>
<span className={`status-pill ${rhinoRunning ? 'is-on' : ''}`}
title={rhinoRunning ? 'Rhino läuft' : 'Rhino offline'}>
<span className="dot" />
Rhino {rhinoRunning ? 'aktiv' : 'offline'}
</span>
<div className="spacer" />
{layouts.length > 0 && (
<div className="layout-switcher" title="Window-Layout wechseln">
<span className="ls-label">LAYOUT</span>
<select value={activeLayout}
onChange={e => onPickLayout(e.target.value)}>
<option value=""> keines</option>
{layouts.map(l => <option key={l} value={l}>{l}</option>)}
</select>
</div>
)}
<button className="primary pill" onClick={() => setDialog({ mode: 'new' })}>
<Icon name="plus" size={14} /> Neues Projekt
</button>
<button className="ghost icon-btn"
onClick={() => { setSettingsTab('rhino'); setSettingsOpen(true) }}
title="Einstellungen">
<Icon name="settings" />
</button>
</div>
<div className="main">
<div className="main-inner">
<div className="view-tabs">
<button className={`view-tab ${view === 'recent' ? 'is-active' : ''}`}
onClick={() => setView('recent')}>
Kürzlich
</button>
<button className={`view-tab ${view === 'all' ? 'is-active' : ''}`}
onClick={() => setView('all')}>
Alle Projekte
{recent.length > 0 && <span className="view-tab-count">{recent.length}</span>}
</button>
<div className="view-tabs-spacer" />
{recent.length > 0 && (
<div className="search-wrap">
<input type="text" className="search-input"
placeholder="Suchen…"
value={query}
onChange={e => setQuery(e.target.value)} />
{query && (
<button className="ghost icon-btn search-clear"
onClick={() => setQuery('')}
title="Suche zurücksetzen">
<Icon name="close" size={14} />
</button>
)}
</div>
)}
</div>
{view === 'all' && (
<div className="view-controls">
<span className="view-controls-label">Sortieren</span>
<select value={sortBy} onChange={e => setSortBy(e.target.value)}>
<option value="lastOpened">Zuletzt geöffnet</option>
<option value="name">Name</option>
<option value="createdAt">Erstellt</option>
</select>
{tags.length > 0 && (
<label className="view-controls-group">
<input type="checkbox" checked={groupByTag}
onChange={e => setGroupByTag(e.target.checked)} />
Nach Tags gruppieren
</label>
)}
</div>
)}
{view === 'all' && tags.length > 0 && !groupByTag && (
<div className="tag-filter-bar">
<span className="tag-filter-label">Filter:</span>
{tags.map(t => {
const active = tagFilter.has(t.id)
return (
<button key={t.id}
className={`tag-chip btn-tag ${active ? 'is-active' : ''}`}
style={{
background: active ? t.color : 'transparent',
borderColor: t.color,
color: active ? (isDarkColor(t.color) ? '#fff' : '#0a1715') : t.color,
}}
onClick={() => toggleTagFilter(t.id)}>
{t.name}
</button>
)
})}
{tagFilter.size > 0 && (
<button className="ghost" style={{ fontSize: 10 }}
onClick={() => setTagFilter(new Set())}>
Zurücksetzen
</button>
)}
</div>
)}
{recent.length === 0 ? (
<div className="empty">
<span className="eyebrow">Noch leer</span>
Lege dein erstes Dossier-Projekt an <br />
wähle eine bestehende <code>.3dm</code> oder erstelle eine neue.
<div style={{ marginTop: 18 }}>
<button className="primary pill" onClick={() => setDialog({ mode: 'new' })}>
<Icon name="plus" size={14} /> Neues Projekt
</button>
</div>
</div>
) : view === 'recent' ? (
<div className="project-list">
{recentSlice.map(p =>
<ProjectCard key={p.path} p={p}
tags={tags} fileMeta={fileMeta} openMap={openMap} thumbMap={thumbMap}
busy={busy}
onOpen={openProject} onEdit={editProject}
onReveal={revealProject} onSnapshot={snapshotProject}
onTogglePin={togglePin} onRemove={removeProject} />
)}
</div>
) : groupByTag && allGrouped ? (
<div className="project-list">
{allGrouped.map(([tagId, ps]) => {
const tag = tagId === '__untagged__' ? null : tags.find(t => t.id === tagId)
return (
<div key={tagId} className="project-group">
<div className="project-group-header">
{tag ? (
<span className="tag-chip" style={{
background: tag.color,
color: isDarkColor(tag.color) ? '#fff' : '#0a1715',
}}>{tag.name}</span>
) : (
<span className="tag-chip" style={{
background: 'transparent', borderColor: 'var(--border)', color: 'var(--text4)',
}}>Ohne Tag</span>
)}
<span className="project-group-count">{ps.length}</span>
</div>
{ps.map(p =>
<ProjectCard key={p.path} p={p}
tags={tags} fileMeta={fileMeta} openMap={openMap} thumbMap={thumbMap}
busy={busy}
onOpen={openProject} onEdit={editProject}
onReveal={revealProject} onSnapshot={snapshotProject}
onTogglePin={togglePin} onRemove={removeProject} />
)}
</div>
)
})}
</div>
) : allSorted.length === 0 ? (
<div className="empty">
<span className="eyebrow">Keine Treffer</span>
Kein Projekt entspricht den Filterkriterien.
</div>
) : (
<div className="project-list">
{allSorted.map(p =>
<ProjectCard key={p.path} p={p}
tags={tags} fileMeta={fileMeta} openMap={openMap} thumbMap={thumbMap}
busy={busy}
onOpen={openProject} onEdit={editProject}
onReveal={revealProject} onSnapshot={snapshotProject}
onTogglePin={togglePin} onRemove={removeProject} />
)}
</div>
)}
</div>
</div>
{dialog && (
<ProjectDialog
mode={dialog.mode}
project={dialog.project}
modules={modules}
onCancel={() => setDialog(null)}
onSave={saveProject}
/>
)}
{settingsOpen && (
<SettingsDialog
initialTab={settingsTab}
onClose={() => { setSettingsOpen(false); invoke('read_settings').then(s => setTags(s?.tags || [])).catch(() => {}) }}
/>
)}
<UpdateNotifier />
{snapshotToast && (
<div className="toast">
<Icon name="snapshot" size={14} />
<span>Snapshot erstellt: <code>{snapshotToast.split('/').pop()}</code></span>
</div>
)}
</div>
)
}
function ProjectCard({ p, tags, fileMeta, openMap, thumbMap = {}, busy,
onOpen, onEdit, onReveal, onSnapshot, onTogglePin, onRemove }) {
const [expanded, setExpanded] = useState(false)
const meta = fileMeta[p.path]
const extras = p.extraFiles || []
const thumb = thumbMap[p.path]
// Open-Status: PRIMARY oder einer der Extras offen
const isOpen = !!openMap[p.path] || extras.some(f => openMap[f])
const projTags = (p.tagIds || [])
.map(id => tags.find(t => t.id === id))
.filter(Boolean)
return (
<div className={`project-card ${p.pinned ? 'is-pinned' : ''} ${isOpen ? 'is-open' : ''}`}
onDoubleClick={() => onOpen(p)}>
<div className="card-row">
{thumb ? (
<div className="card-thumb">
<img src={thumb} alt="" />
</div>
) : (
<div className="card-thumb is-empty"
title="Beim nächsten Speichern in Rhino wird automatisch ein Thumbnail erzeugt">
<Icon name="snapshot" size={18} />
</div>
)}
<div className="card-content">
<div className="name-row">
<span className="name">{p.name}</span>
<span className="module-count">{p.modules?.length || 0} Module</span>
{extras.length > 0 && (
<button className="module-count expand-chip"
onClick={() => setExpanded(x => !x)}
title={expanded ? 'Dateien einklappen' : 'Alle Dateien anzeigen'}>
+{extras.length} Datei{extras.length === 1 ? '' : 'en'}
<Icon name={expanded ? 'chevron_up' : 'chevron_down'} size={12} />
</button>
)}
{projTags.map(t => (
<span key={t.id} className="tag-chip"
style={{
background: t.color,
color: isDarkColor(t.color) ? '#fff' : '#0a1715',
}}>
{t.name}
</span>
))}
</div>
<div className="path" title={p.path}>{p.path}</div>
</div>
<div className="status-col" title={isOpen ? 'In Rhino geöffnet' : ''}>
{isOpen && <span className="open-dot" />}
</div>
<div className="meta">
<div>{formatRelative(p.lastOpened)}</div>
{meta?.exists && (
<div style={{ color: 'var(--text4)', fontSize: 9, marginTop: 2 }}>
{formatFileSize(meta.size)}
{meta.modifiedIso && ` · ${formatRelative(meta.modifiedIso)}`}
</div>
)}
{meta && !meta.exists && (
<div style={{ color: 'var(--danger)', fontSize: 9, marginTop: 2 }}>
Datei fehlt
</div>
)}
</div>
<div className="actions">
<button className={`ghost icon-btn ${p.pinned ? 'is-active' : ''}`}
onClick={() => onTogglePin(p)}
title={p.pinned ? 'Pin entfernen' : 'Anpinnen'}>
<Icon name={p.pinned ? 'pin_filled' : 'pin'} />
</button>
<button className="ghost icon-btn"
onClick={() => onSnapshot(p)}
title="Snapshot erstellen (Kopie mit Zeitstempel in _snapshots/)">
<Icon name="snapshot" />
</button>
<button className="ghost icon-btn"
onClick={() => onReveal(p)}
title="Im Finder zeigen">
<Icon name="folder" />
</button>
<button className="ghost icon-btn"
onClick={() => onEdit(p)}
title="Bearbeiten">
<Icon name="edit" />
</button>
<button className="primary pill" disabled={busy} onClick={() => onOpen(p)}>
Öffnen
</button>
<button className="ghost danger icon-btn"
onClick={() => onRemove(p)}
title="Aus Liste entfernen">
<Icon name="close" size={14} />
</button>
</div>
</div>
{expanded && extras.length > 0 && (
<div className="file-list">
<FileRow path={p.path} isPrimary
isOpen={!!openMap[p.path]}
busy={busy}
onOpen={() => onOpen(p)} />
{extras.map(f => (
<FileRow key={f} path={f}
isOpen={!!openMap[f]}
busy={busy}
onOpen={() => onOpen(p, f)} />
))}
</div>
)}
</div>
)
}
function FileRow({ path, isPrimary = false, isOpen, busy, onOpen }) {
const name = path.split('/').pop()
return (
<div className="file-row">
<div className="file-row-status">
{isOpen && <span className="open-dot" />}
</div>
<div className="file-row-name">
{isPrimary && <span className="file-row-badge">Hauptdatei</span>}
<span title={path}>{name}</span>
</div>
<div className="file-row-path" title={path}>{path}</div>
<button className="primary pill" disabled={busy} onClick={onOpen}>
Öffnen
</button>
</div>
)
}
function SettingsDialog({ initialTab = 'rhino', onClose }) {
const [tab, setTab] = useState(initialTab)
return (
<div className="dialog-bg" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
<div className="dialog" style={{ width: 640 }}>
<header style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<span>Einstellungen</span>
<div style={{ display: 'flex', gap: 4, marginLeft: 'auto', flexWrap: 'wrap' }}>
<TabBtn active={tab === 'rhino'} onClick={() => setTab('rhino')}>Rhino</TabBtn>
<TabBtn active={tab === 'view'} onClick={() => setTab('view')}>View</TabBtn>
<TabBtn active={tab === 'ebenen'} onClick={() => setTab('ebenen')}>Ebenen</TabBtn>
<TabBtn active={tab === 'tags'} onClick={() => setTab('tags')}>Tags</TabBtn>
<TabBtn active={tab === 'layout'} onClick={() => setTab('layout')}>Layout</TabBtn>
<TabBtn active={tab === 'updates'} onClick={() => setTab('updates')}>Updates</TabBtn>
</div>
</header>
<div className="body">
{tab === 'rhino' && <RhinoSettings />}
{tab === 'view' && <ViewSettings />}
{tab === 'ebenen' && <EbenenSchemaSettings />}
{tab === 'tags' && <TagsSettings />}
{tab === 'layout' && <LayoutSettings />}
{tab === 'updates' && <UpdatesSettings />}
</div>
<footer>
<button className="primary pill" onClick={onClose}>Schliessen</button>
</footer>
</div>
</div>
)
}
function TabBtn({ active, onClick, children }) {
return (
<button
onClick={onClick}
style={{
background: active ? 'var(--accent)' : 'transparent',
borderColor: active ? 'var(--accent)' : 'var(--border)',
color: active ? 'white' : 'var(--text-muted)',
fontSize: 11,
padding: '4px 10px',
}}
>
{children}
</button>
)
}
function RhinoSettings() {
const [rhinoApp, setRhinoApp] = useState('')
const [templatePath, setTemplatePath] = useState('')
const [autoLoadPlugin, setAutoLoadPlugin] = useState(false)
const [pluginStartupPath, setPluginStartupPath] = useState('')
const [defaultStartup, setDefaultStartup] = useState('')
const [loaded, setLoaded] = useState(false)
const [saved, setSaved] = useState(false)
const [pluginLoading, setPluginLoading] = useState(false)
useEffect(() => {
Promise.all([
invoke('read_settings').catch(() => ({})),
invoke('get_default_plugin_startup_path').catch(() => ''),
]).then(([s, def]) => {
setRhinoApp(s.rhinoApp || 'Rhinoceros 8')
setTemplatePath(s.templatePath || '')
setAutoLoadPlugin(!!s.autoLoadPlugin)
setPluginStartupPath(s.pluginStartupPath || '')
setDefaultStartup(def || '')
setLoaded(true)
})
}, [])
const pickRhino = async () => {
const sel = await openDialog({
title: 'Rhino-App waehlen',
filters: [{ name: 'Anwendung', extensions: ['app'] }],
defaultPath: '/Applications',
})
if (typeof sel === 'string') setRhinoApp(sel)
}
const pickTemplate = async () => {
const sel = await openDialog({
title: 'Template-3dm waehlen',
filters: [{ name: 'Rhino', extensions: ['3dm'] }],
})
if (typeof sel === 'string') setTemplatePath(sel)
}
const pickStartup = async () => {
const sel = await openDialog({
title: 'startup.py des Dossier-Plugins',
filters: [{ name: 'Python', extensions: ['py'] }],
})
if (typeof sel === 'string') setPluginStartupPath(sel)
}
const clearTemplate = () => setTemplatePath('')
const useDefault = () => setRhinoApp('Rhinoceros 8')
const useDefaultStartup = () => setPluginStartupPath('')
const loadNow = async () => {
try {
setPluginLoading(true)
await invoke('trigger_plugin_load_now')
setTimeout(() => setPluginLoading(false), 4000)
} catch (ex) {
setPluginLoading(false)
alert(`Plugin-Load: ${ex}`)
}
}
const save = async () => {
try {
const cur = await invoke('read_settings').catch(() => ({}))
await invoke('save_settings', {
settings: {
...cur,
rhinoApp: rhinoApp.trim() || 'Rhinoceros 8',
templatePath: templatePath.trim() || null,
autoLoadPlugin: !!autoLoadPlugin,
pluginStartupPath: pluginStartupPath.trim() || null,
},
})
setSaved(true)
setTimeout(() => setSaved(false), 1500)
} catch (ex) {
alert(`Speichern fehlgeschlagen: ${ex}`)
}
}
if (!loaded) return null
return (
<>
<div className="row">
<label>Rhino-Anwendung</label>
<div className="file-picker">
<input type="text" value={rhinoApp}
onChange={e => setRhinoApp(e.target.value)}
placeholder="Rhinoceros 8" />
<button onClick={pickRhino}>Wählen</button>
</div>
<div className="hint">
App-Name (in <code className="nowrap">/Applications</code> gesucht)
oder absoluter Pfad zur <code className="nowrap">.app</code>.
</div>
<div className="hint-chips">
<span className="hint-chips-label">Beispiele</span>
<code className="nowrap">Rhinoceros 8</code>
<code className="nowrap">/Applications/Rhino 8.app</code>
<code className="nowrap">/Applications/Rhino 8 WIP.app</code>
<button onClick={useDefault} className="hint-action">Standard verwenden</button>
</div>
</div>
<div className="row">
<label>Projekt-Template (.3dm)</label>
<div className="file-picker">
<input type="text" value={templatePath}
onChange={e => setTemplatePath(e.target.value)}
placeholder="(keine — Rhino-Standard wird verwendet)" />
<button onClick={pickTemplate}>Wählen</button>
{templatePath && <button className="ghost" onClick={clearTemplate}>Entfernen</button>}
</div>
<div className="hint">
Wird bei <em>Neues Projekt</em> als Startdatei kopiert inkl. aller
Layer, Schraffuren, Linientypen und Plot-Stile. So bleibt deine
Ebenen-Hierarchie über alle Projekte konsistent.
</div>
</div>
<div className="row">
<label>Plugin-Autoload</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8,
textTransform: 'none', letterSpacing: 0, fontSize: 12,
color: 'var(--text)' }}>
<input type="checkbox" checked={autoLoadPlugin}
onChange={e => setAutoLoadPlugin(e.target.checked)}
style={{ width: 14, height: 14 }} />
Dossier-Plugin automatisch nach Rhino-Start laden
</label>
<div className="file-picker" style={{ marginTop: 6 }}>
<input type="text" value={pluginStartupPath}
onChange={e => setPluginStartupPath(e.target.value)}
placeholder={defaultStartup || 'gebündelte startup.py wird verwendet'} />
<button onClick={pickStartup}>Wählen</button>
<button className="ghost" onClick={useDefaultStartup}>Standard</button>
</div>
<div className="hint">
Aktiv: der Launcher trägt <code className="nowrap">_RunPythonScript &lt;Pfad&gt;</code>
in Rhinos <em>Run these commands every time a model is opened</em> Liste
ein (Options General Command Lists). Damit lädt das Plugin bei
jedem Rhino-Start automatisch egal ob via Launcher oder direkt.
Idempotent: wird nichts überschrieben, wenn der Eintrag schon da ist.
</div>
<div className="hint-chips" style={{ marginTop: 4 }}>
<button className="ghost" onClick={loadNow} disabled={pluginLoading}>
<Icon name="refresh" size={14} />
{pluginLoading ? 'Setze Eintrag…' : 'Autostart-Eintrag jetzt setzen'}
</button>
<span style={{ fontSize: 10, color: 'var(--text4)' }}>
(greift beim nächsten Rhino-Start Rhino dafür kurz beenden)
</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
{saved && <span style={{ fontSize: 11, color: 'var(--accent)', alignSelf: 'center' }}> Gespeichert</span>}
<button className="primary pill" onClick={save}>Speichern</button>
</div>
</>
)
}
function TagsSettings() {
const [tags, setTags] = useState([])
const [loaded, setLoaded] = useState(false)
const [draftName, setDraftName] = useState('')
const [draftColor, setDraftColor] = useState('#5fa896')
useEffect(() => {
invoke('read_settings').then(s => {
setTags(s?.tags || [])
setLoaded(true)
}).catch(() => setLoaded(true))
}, [])
const persist = async (next) => {
setTags(next)
const cur = await invoke('read_settings').catch(() => ({}))
await invoke('save_settings', {
settings: { ...cur, tags: next },
}).catch(ex => alert(`Tags speichern: ${ex}`))
}
const addTag = () => {
const name = draftName.trim()
if (!name) return
if (tags.some(t => t.name.toLowerCase() === name.toLowerCase())) {
alert('Es gibt schon einen Tag mit diesem Namen.')
return
}
const next = [...tags, { id: newTagId(), name, color: draftColor }]
persist(next)
setDraftName('')
}
const removeTag = (id) => {
if (!confirm('Tag löschen? Projekte mit diesem Tag verlieren ihn.')) return
persist(tags.filter(t => t.id !== id))
}
const updateTag = (id, patch) => {
persist(tags.map(t => t.id === id ? { ...t, ...patch } : t))
}
if (!loaded) return null
return (
<>
<div className="row">
<label>Tag anlegen</label>
<div className="tag-create">
<input type="color" value={draftColor}
onChange={e => setDraftColor(e.target.value)}
className="color-input"
title="Farbe" />
<input type="text" value={draftName}
onChange={e => setDraftName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addTag()}
placeholder="z.B. Wohnbau, Wettbewerb, Studie…" />
<button className="primary pill" onClick={addTag}
disabled={!draftName.trim()}>
<Icon name="plus" size={14} /> Hinzufügen
</button>
</div>
</div>
<div className="row">
<label>Vorhandene Tags ({tags.length})</label>
{tags.length === 0 ? (
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.5 }}>
Noch keine Tags. Mit dem Formular oben kannst du Kategorien
anlegen Name + Farbe. Sie sind dann im Projekt-Dialog wählbar
und filtern die Projektliste.
</div>
) : (
<div className="tag-list">
{tags.map(t => (
<div key={t.id} className="tag-row">
<input type="color" value={t.color}
onChange={e => updateTag(t.id, { color: e.target.value })}
className="color-input"
title="Farbe" />
<input type="text" value={t.name}
onChange={e => updateTag(t.id, { name: e.target.value })}
style={{ flex: 1 }} />
<span className="tag-chip"
style={{
background: t.color,
color: isDarkColor(t.color) ? '#fff' : '#0a1715',
}}>
{t.name || '—'}
</span>
<button className="ghost danger icon-btn"
onClick={() => removeTag(t.id)}
title="Tag löschen">
<Icon name="trash" />
</button>
</div>
))}
</div>
)}
</div>
</>
)
}
// Mapping Frontend-Key -> Label. Reihenfolge spielt fuer die UI eine Rolle.
const VIEW_COLOR_FIELDS = [
{ key: 'background', label: 'Viewport-Hintergrund', default: '#f0f0f0' },
{ key: 'gridLine', label: 'Raster (Minor)', default: '#bcbcbc' },
{ key: 'gridMajor', label: 'Raster (Major)', default: '#9e9e9e' },
{ key: 'gridX', label: 'Raster X-Achse', default: '#c84040' },
{ key: 'gridY', label: 'Raster Y-Achse', default: '#40a040' },
{ key: 'worldX', label: 'Welt X-Achse', default: '#ff0000' },
{ key: 'worldY', label: 'Welt Y-Achse', default: '#00b000' },
{ key: 'worldZ', label: 'Welt Z-Achse', default: '#0080ff' },
]
// Default-Schema fuer neue Projekte. Muss im Sync mit INITIAL_EBENEN in
// src/App.jsx des Rhino-Panels bleiben — wenn der User keine eigene Schema
// definiert, schickt das Plugin diese hier als FIRST_RUN-Default.
const DEFAULT_LAYER_SCHEMA = [
{ code: '00', name: 'RASTER', color: '#484850', lw: 0.13 },
{ code: '01', name: 'VERMESSUNG', color: '#707078', lw: 0.18 },
{ code: '10', name: 'SITUATION', color: '#909090', lw: 0.18 },
{ code: '11', name: 'STRASSE', color: '#a89070', lw: 0.18 },
{ code: '12', name: 'GEBAEUDE', color: '#888888', lw: 0.25 },
{ code: '13', name: 'BAEUME', color: '#50a050', lw: 0.13 },
{ code: '14', name: 'HOEHENLINIEN', color: '#909050', lw: 0.18 },
{ code: '20', name: 'WAENDE', color: '#0a0a0a', lw: 0.50 },
{ code: '21', name: 'TUEREN_FENSTER', color: '#5080c8', lw: 0.25 },
{ code: '22', name: 'MOEBEL', color: '#909090', lw: 0.13 },
{ code: '25', name: 'STUETZEN', color: '#c87050', lw: 0.50 },
{ code: '30', name: 'DECKEN', color: '#605850', lw: 0.35 },
{ code: '31', name: 'DAECHER', color: '#7a4a3a', lw: 0.35 },
{ code: '35', name: 'TRAEGER', color: '#a87858', lw: 0.50 },
{ code: '50', name: 'TEXT', color: '#d0d0d0', lw: 0.13 },
{ code: '60', name: 'PLANGRAFIK', color: '#c0a040', lw: 0.13 },
{ code: '90', name: 'REFERENZEN', color: '#585860', lw: 0.13 },
{ code: '99', name: 'KONSTRUKTION', color: '#404048', lw: 0.13 },
]
// Built-in Presets — nicht in den Settings gespeichert, immer verfuegbar.
const BUILTIN_PRESETS = [
{
id: 'builtin:rhino',
name: 'Rhino-Standard',
builtin: true,
colors: {}, // leere Colors = Rhino-Defaults nicht ueberschreiben
},
{
id: 'builtin:dossier',
name: 'Dossier-Standard',
builtin: true,
colors: {
background: '#1c2421',
gridLine: '#2a3330',
gridMajor: '#3a4540',
gridX: '#7a4a3a',
gridY: '#4a7a5a',
worldX: '#c84a3a',
worldY: '#5fa896',
worldZ: '#4a8ac8',
},
},
]
function ViewSettings() {
const [colors, setColors] = useState({})
const [autoApply, setAutoApply] = useState(false)
const [loaded, setLoaded] = useState(false)
const [saved, setSaved] = useState(false)
const [importBusy, setImportBusy] = useState(false)
const [customPresets, setCustomPresets] = useState([])
const allPresets = useMemo(() => [...BUILTIN_PRESETS, ...customPresets], [customPresets])
useEffect(() => {
invoke('read_dossier_settings').then(ds => {
setColors(ds?.viewportColors || {})
setAutoApply(!!ds?.autoApplyViewColors)
setLoaded(true)
}).catch(() => setLoaded(true))
invoke('read_settings').then(s => setCustomPresets(s?.viewColorPresets || []))
.catch(() => {})
}, [])
// Match: welcher Preset entspricht den aktuellen Colors exakt?
const currentPresetId = useMemo(() => {
const keys = VIEW_COLOR_FIELDS.map(f => f.key)
const norm = (obj) => keys.map(k => (obj?.[k] || '').toLowerCase()).join('|')
const cur = norm(colors)
return allPresets.find(p => norm(p.colors) === cur)?.id || ''
}, [colors, allPresets])
const applyPreset = (presetId) => {
const p = allPresets.find(pp => pp.id === presetId)
if (!p) return
const next = { ...p.colors }
setColors(next)
persist(next, autoApply)
}
const persistCustomPresets = async (next) => {
setCustomPresets(next)
const cur = await invoke('read_settings').catch(() => ({}))
await invoke('save_settings', {
settings: { ...cur, viewColorPresets: next },
}).catch(ex => console.warn('preset save:', ex))
}
const saveAsPreset = async () => {
const name = prompt('Name für dieses Preset:')
if (!name || !name.trim()) return
const id = 'custom_' + Math.random().toString(36).slice(2, 9)
persistCustomPresets([...customPresets, { id, name: name.trim(), colors }])
}
const deletePreset = async (id) => {
const p = customPresets.find(pp => pp.id === id)
if (!p) return
if (!confirm(`Preset „${p.name}" löschen?`)) return
persistCustomPresets(customPresets.filter(pp => pp.id !== id))
}
const exportPreset = async (id) => {
const p = allPresets.find(pp => pp.id === id) || { name: 'export', colors }
const payload = JSON.stringify({
dossier: { kind: 'view-color-preset', version: 1 },
name: p.name, colors: p.colors,
}, null, 2)
const target = await saveDialog({
title: 'Preset exportieren',
defaultPath: `dossier-view-${p.name.replace(/\s+/g, '-').toLowerCase()}.json`,
filters: [{ name: 'JSON', extensions: ['json'] }],
})
if (typeof target !== 'string') return
try {
await invoke('write_text_file', { path: target, content: payload })
alert(`Preset exportiert:\n${target}`)
} catch (ex) {
alert(`Export: ${ex}`)
}
}
const importPreset = async () => {
const sel = await openDialog({
title: 'Preset importieren',
filters: [{ name: 'JSON', extensions: ['json'] }],
})
if (typeof sel !== 'string') return
try {
const raw = await invoke('read_text_file', { path: sel })
const parsed = JSON.parse(raw)
if (!parsed?.colors || !parsed?.name) {
alert('Format unbekannt — JSON muss "name" und "colors" enthalten.')
return
}
const id = 'custom_' + Math.random().toString(36).slice(2, 9)
persistCustomPresets([...customPresets, { id, name: parsed.name, colors: parsed.colors }])
alert(`Preset „${parsed.name}" importiert.`)
} catch (ex) {
alert(`Import: ${ex}`)
}
}
// Lokale Aenderung sofort persistieren, ohne Apply
const persist = async (nextColors, nextAuto) => {
const cur = await invoke('read_dossier_settings').catch(() => ({}))
await invoke('save_dossier_settings', {
settings: {
...cur,
viewportColors: nextColors,
autoApplyViewColors: !!nextAuto,
},
}).catch(ex => console.warn('view-colors save:', ex))
}
const setColor = (key, value) => {
const next = { ...colors, [key]: value || null }
setColors(next)
persist(next, autoApply)
}
const setAutoApplyFlag = (v) => {
setAutoApply(v)
persist(colors, v)
}
const applyNow = async () => {
try {
await invoke('apply_view_colors_now')
setSaved(true)
setTimeout(() => setSaved(false), 1800)
} catch (ex) {
alert(`Anwenden fehlgeschlagen: ${ex}`)
}
}
const resetAll = () => {
if (!confirm('Alle Farben zurücksetzen?')) return
setColors({})
persist({}, autoApply)
}
const importDisplayMode = async () => {
const sel = await openDialog({
title: 'Display-Mode (.ini) wählen',
filters: [{ name: 'Rhino Display Mode', extensions: ['ini'] }],
})
if (typeof sel !== 'string') return
try {
setImportBusy(true)
await invoke('queue_import_display_mode', { path: sel })
alert(`Importiert. Rhino-Plugin übernimmt beim nächsten Idle-Tick (~1 s).\n\n${sel}`)
} catch (ex) {
alert(`Import fehlgeschlagen: ${ex}`)
} finally {
setImportBusy(false)
}
}
if (!loaded) return null
return (
<>
<div className="row">
<label>Preset</label>
<div className="preset-bar">
<select value={currentPresetId}
onChange={e => applyPreset(e.target.value)}
style={{ flex: 1 }}>
<option value=""> Eigene Konfiguration</option>
<optgroup label="Standard">
{BUILTIN_PRESETS.map(p =>
<option key={p.id} value={p.id}>{p.name}</option>
)}
</optgroup>
{customPresets.length > 0 && (
<optgroup label="Eigene">
{customPresets.map(p =>
<option key={p.id} value={p.id}>{p.name}</option>
)}
</optgroup>
)}
</select>
<button className="ghost" onClick={saveAsPreset}
title="Aktuelle Farben als neues Preset speichern">
<Icon name="plus" size={14} /> Speichern als
</button>
{currentPresetId && currentPresetId.startsWith('custom_') && (
<button className="ghost icon-btn danger"
onClick={() => deletePreset(currentPresetId)}
title="Aktives Preset löschen">
<Icon name="trash" />
</button>
)}
</div>
<div className="hint-chips" style={{ marginTop: 4 }}>
<button className="ghost" style={{ fontSize: 10 }} onClick={importPreset}>
Preset importieren
</button>
{currentPresetId && (
<button className="ghost" style={{ fontSize: 10 }}
onClick={() => exportPreset(currentPresetId)}>
Aktives Preset exportieren
</button>
)}
</div>
</div>
<div className="row">
<label>Viewport-Farben</label>
<div className="color-grid">
{VIEW_COLOR_FIELDS.map(f => (
<div key={f.key} className="color-row">
<input type="color"
value={colors[f.key] || f.default}
onChange={e => setColor(f.key, e.target.value)}
className="color-input" />
<div className="color-row-text">
<div className="color-row-label">{f.label}</div>
<div className="color-row-hex">{colors[f.key] || '— Rhino-Default'}</div>
</div>
{colors[f.key] && (
<button className="ghost icon-btn"
onClick={() => setColor(f.key, null)}
title="Auf Rhino-Default zurücksetzen">
<Icon name="close" size={14} />
</button>
)}
</div>
))}
</div>
</div>
<div className="row">
<label style={{ display: 'flex', alignItems: 'center', gap: 8,
textTransform: 'none', letterSpacing: 0, fontSize: 12,
color: 'var(--text)' }}>
<input type="checkbox" checked={autoApply}
onChange={e => setAutoApplyFlag(e.target.checked)}
style={{ width: 14, height: 14 }} />
Beim Rhino-Start automatisch anwenden
</label>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
{saved && <span style={{ fontSize: 11, color: 'var(--accent)', alignSelf: 'center' }}> Angewendet</span>}
<button className="ghost" onClick={resetAll}>Alle zurücksetzen</button>
<button className="primary pill" onClick={applyNow}>Jetzt anwenden</button>
</div>
<div className="row" style={{ marginTop: 18, borderTop: '1px solid var(--border)', paddingTop: 18 }}>
<label>Display-Modes importieren</label>
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.6 }}>
Importiert eine Display-Mode <code>.ini</code>-Datei in Rhino.
Die Stile lassen sich in Rhinos <em>Options Display Modes
Export</em> erzeugen. So baust du dir deine Dossier-Stile
(Hardline, Soft-Shaded, Plan-1:50, etc.) einmal und kannst sie auf
jedem Mac in zwei Klicks importieren.
</div>
<button className="primary pill" onClick={importDisplayMode}
disabled={importBusy} style={{ marginTop: 10 }}>
<Icon name="plus" size={14} />
{importBusy ? 'Importiere…' : '.ini-Datei wählen'}
</button>
</div>
</>
)
}
function EbenenSchemaSettings() {
const [schema, setSchema] = useState([])
const [loaded, setLoaded] = useState(false)
const [saved, setSaved] = useState(false)
const [importing, setImporting] = useState(false)
const reload = useCallback(() => {
return invoke('read_dossier_settings').then(ds => {
const s = (ds?.layerSchema && ds.layerSchema.length)
? ds.layerSchema
: DEFAULT_LAYER_SCHEMA
setSchema(s)
setLoaded(true)
})
}, [])
useEffect(() => {
reload().catch(() => { setSchema(DEFAULT_LAYER_SCHEMA); setLoaded(true) })
}, [reload])
const persist = async (next) => {
setSchema(next)
const cur = await invoke('read_dossier_settings').catch(() => ({}))
await invoke('save_dossier_settings', {
settings: { ...cur, layerSchema: next },
}).catch(ex => console.warn('schema save:', ex))
setSaved(true)
setTimeout(() => setSaved(false), 1500)
}
const updateRow = (i, patch) => {
persist(schema.map((row, idx) => idx === i ? { ...row, ...patch } : row))
}
const addRow = () => {
// Naechste freie Code aus dem hoechsten + 1
const max = schema.reduce((m, r) => Math.max(m, parseInt(r.code, 10) || 0), 0)
const code = String(max + 1).padStart(2, '0')
persist([...schema, { code, name: 'NEU', color: '#888888', lw: 0.13 }])
}
const removeRow = (i) => {
if (!confirm(`Ebene „${schema[i].code}_${schema[i].name}" entfernen?`)) return
persist(schema.filter((_, idx) => idx !== i))
}
const moveRow = (i, dir) => {
const j = i + dir
if (j < 0 || j >= schema.length) return
const next = [...schema]
;[next[i], next[j]] = [next[j], next[i]]
persist(next)
}
const resetToDefault = () => {
if (!confirm('Schema auf Dossier-Standard zurücksetzen?')) return
persist(DEFAULT_LAYER_SCHEMA)
}
const importFromRhino = async () => {
if (!confirm('Aktuelles Schema mit den Ebenen aus dem laufenden Rhino-Projekt überschreiben?')) return
try {
setImporting(true)
await invoke('request_ebenen_export')
// Plugin verarbeitet im naechsten Idle-Tick (~1s). Wir pollen kurz.
for (let i = 0; i < 8; i++) {
await new Promise(r => setTimeout(r, 600))
const ds = await invoke('read_dossier_settings').catch(() => null)
if (ds && !ds.pendingExportEbenen) {
await reload()
setImporting(false)
return
}
}
setImporting(false)
alert('Rhino hat nicht innerhalb von 5 s reagiert. Läuft Rhino mit aktivem Dossier-Plugin?')
} catch (ex) {
setImporting(false)
alert(`Import: ${ex}`)
}
}
const exportSchema = async () => {
const payload = JSON.stringify({
dossier: { kind: 'layer-schema', version: 1 },
schema,
}, null, 2)
const target = await saveDialog({
title: 'Schema exportieren',
defaultPath: 'dossier-ebenen-schema.json',
filters: [{ name: 'JSON', extensions: ['json'] }],
})
if (typeof target !== 'string') return
try {
await invoke('write_text_file', { path: target, content: payload })
alert(`Schema exportiert:\n${target}`)
} catch (ex) {
alert(`Export: ${ex}`)
}
}
const importSchema = async () => {
const sel = await openDialog({
title: 'Schema importieren',
filters: [{ name: 'JSON', extensions: ['json'] }],
})
if (typeof sel !== 'string') return
try {
const raw = await invoke('read_text_file', { path: sel })
const parsed = JSON.parse(raw)
const next = parsed?.schema || parsed
if (!Array.isArray(next)) {
alert('Format unbekannt — JSON muss ein Array mit Layer-Templates enthalten.')
return
}
// Validierung: jeder Eintrag braucht code, name, color, lw
const clean = next.filter(r => r.code && r.name && r.color !== undefined && r.lw !== undefined)
if (clean.length === 0) {
alert('Keine gültigen Einträge im Import gefunden.')
return
}
persist(clean)
alert(`${clean.length} Ebenen importiert.`)
} catch (ex) {
alert(`Import: ${ex}`)
}
}
if (!loaded) return null
return (
<>
<div className="row">
<label>Default-Sublayer für neue Projekte</label>
<div className="hint">
Diese Liste wird bei <em>Neues Projekt</em> in Rhino als Anfangs-Schema
verwendet. Pro Sublayer: Code (für die Hierarchie 01_WAND, ), Name,
Farbe und Stiftdicke. Reihenfolge entspricht der Anzeige in Rhinos
Layer-Panel.
</div>
</div>
<div className="schema-grid">
<div className="schema-header">
<div>Code</div>
<div>Name</div>
<div style={{ textAlign: 'center' }}>Farbe</div>
<div>Stift (mm)</div>
<div></div>
</div>
{schema.map((row, i) => (
<div key={i} className="schema-row">
<input type="text" value={row.code}
maxLength={3}
onChange={e => updateRow(i, { code: e.target.value.toUpperCase() })}
style={{ fontFamily: 'var(--font-mono)', textAlign: 'center' }} />
<input type="text" value={row.name}
onChange={e => updateRow(i, { name: e.target.value.toUpperCase() })}
style={{ fontFamily: 'var(--font-mono)' }} />
<input type="color" value={row.color}
onChange={e => updateRow(i, { color: e.target.value })}
className="color-input"
style={{ margin: '0 auto', display: 'block' }} />
<input type="number" value={row.lw}
step={0.01} min={0.05} max={2.0}
onChange={e => updateRow(i, { lw: parseFloat(e.target.value) || 0.13 })}
style={{ fontFamily: 'var(--font-mono)' }} />
<div style={{ display: 'flex', gap: 2 }}>
<button className="ghost icon-btn"
onClick={() => moveRow(i, -1)}
disabled={i === 0}
title="Nach oben"></button>
<button className="ghost icon-btn"
onClick={() => moveRow(i, 1)}
disabled={i === schema.length - 1}
title="Nach unten"></button>
<button className="ghost danger icon-btn"
onClick={() => removeRow(i)}
title="Entfernen">
<Icon name="trash" size={14} />
</button>
</div>
</div>
))}
</div>
<div className="hint-chips" style={{ marginTop: 14 }}>
<button className="ghost" onClick={addRow}>
<Icon name="plus" size={14} /> Zeile hinzufügen
</button>
<button className="ghost" onClick={importFromRhino} disabled={importing}>
{importing ? 'Lese aus Rhino …' : 'Aus laufendem Rhino importieren'}
</button>
<button className="ghost" onClick={importSchema}>Aus JSON</button>
<button className="ghost" onClick={exportSchema}>Exportieren</button>
<button className="ghost danger" onClick={resetToDefault}>
Auf Dossier-Standard zurücksetzen
</button>
{saved && <span style={{ fontSize: 11, color: 'var(--accent)' }}> Gespeichert</span>}
</div>
</>
)
}
function LayoutSettings() {
const [layouts, setLayouts] = useState([])
const [selected, setSelected] = useState('')
const [autoApply, setAutoApply] = useState(false)
const [loaded, setLoaded] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
Promise.all([
invoke('list_window_layouts').catch(() => []),
invoke('read_dossier_settings').catch(() => ({})),
]).then(([list, ds]) => {
setLayouts(list || [])
setSelected(ds?.windowLayout || '')
setAutoApply(!!ds?.autoApplyLayout)
setLoaded(true)
})
}, [])
const save = async () => {
try {
await invoke('save_dossier_settings', {
settings: { windowLayout: selected || null, autoApplyLayout: autoApply },
})
setSaved(true)
setTimeout(() => setSaved(false), 1500)
} catch (ex) {
alert(`Speichern fehlgeschlagen: ${ex}`)
}
}
if (!loaded) return null
return (
<div className="row">
<label>Standard-Layout für Dossier</label>
{layouts.length === 0 ? (
<div style={{ padding: 12, background: 'var(--bg-panel)', border: '1px solid var(--border)',
borderRadius: 6, fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>
Keine Rhino Window-Layouts gefunden. Erstelle erst eines in Rhino via
<em> Window Window Layouts Save</em>, dann hier auswählen.
</div>
) : (
<select value={selected}
onChange={e => setSelected(e.target.value)}
style={{
fontFamily: 'inherit', fontSize: 13,
background: 'var(--bg-base)', color: 'var(--text)',
border: '1px solid var(--border)', borderRadius: 6,
padding: '6px 10px',
}}>
<option value="">(keines)</option>
{layouts.map(l => <option key={l} value={l}>{l}</option>)}
</select>
)}
<label style={{ display: 'flex', alignItems: 'center', gap: 8, textTransform: 'none', letterSpacing: 0,
fontSize: 12, color: 'var(--text)', marginTop: 8 }}>
<input type="checkbox" checked={autoApply}
onChange={e => setAutoApply(e.target.checked)}
style={{ width: 14, height: 14 }} />
Beim Rhino-Start automatisch anwenden
</label>
<div style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.5, marginTop: 4 }}>
Dossier liest diese Einstellung beim Öffnen eines Projekts und ruft Rhinos
Window-Layout auf. So spart man sich manuelles Wiederherstellen von Panel-Anordnungen.
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 12 }}>
{saved && <span style={{ fontSize: 11, color: 'var(--accent)', alignSelf: 'center' }}> Gespeichert</span>}
<button className="primary pill" onClick={save}>Speichern</button>
</div>
</div>
)
}
function UpdatesSettings() {
const [version, setVersion] = useState('')
const [lastCheck, setLastCheck] = useState(() => getLastUpdateCheck())
const [state, setState] = useState('idle')
const [update, setUpdate] = useState(null)
const [error, setError] = useState(null)
const [downloaded, setDownloaded] = useState(0)
const [total, setTotal] = useState(0)
useEffect(() => {
if (!isTauri()) return
import('@tauri-apps/api/app').then(({ getVersion }) => {
getVersion().then(setVersion).catch(() => {})
})
}, [])
const runCheck = useCallback(async () => {
setError(null)
if (!isTauri()) {
setState('not-tauri')
return
}
setState('checking')
try {
const res = await checkForAppUpdate({ respectSkip: false })
setLastCheck(getLastUpdateCheck())
if (res.available) {
setUpdate(res.update)
setState('available')
} else {
setUpdate(null)
setState('no-update')
}
} catch (e) {
console.error('Update-Check fehlgeschlagen:', e)
setError(String(e?.message || e))
setState('idle')
}
}, [])
const install = async () => {
if (!update) return
setError(null)
try {
setState('downloading')
setDownloaded(0)
setTotal(0)
await installAppUpdate(update, (event) => {
if (event.event === 'Started') {
setTotal(event.data.contentLength || 0)
} else if (event.event === 'Progress') {
setDownloaded((d) => d + (event.data.chunkLength || 0))
} else if (event.event === 'Finished') {
setState('installing')
}
})
} catch (e) {
setError(String(e?.message || e))
setState('available')
}
}
const skipVersion = () => {
skipUpdateVersion(update?.version)
setUpdate(null)
setState('idle')
}
const isBusy = state === 'downloading' || state === 'installing'
const pct = total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : null
return (
<div className="row">
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0',
fontSize: 12, borderBottom: '1px solid var(--border)' }}>
<span style={{ color: 'var(--text-muted)' }}>Aktuelle Version</span>
<strong>{version || '—'}</strong>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0',
fontSize: 12, borderBottom: '1px solid var(--border)', marginBottom: 12 }}>
<span style={{ color: 'var(--text-muted)' }}>Letzte Prüfung</span>
<span>{formatLastCheck(lastCheck)}</span>
</div>
<button className="primary pill" onClick={runCheck} disabled={state === 'checking' || isBusy}>
{state === 'checking' ? 'Wird geprüft …' : 'Nach Updates suchen'}
</button>
{state === 'no-update' && (
<div style={{ marginTop: 10, padding: '10px 12px', background: 'rgba(90, 158, 90, 0.12)',
border: '1px solid var(--accent)', borderRadius: 6, fontSize: 12,
color: 'var(--accent)', lineHeight: 1.5 }}>
Dossier ist auf dem neuesten Stand.
</div>
)}
{state === 'available' && update && (
<div style={{ marginTop: 10, padding: '12px 14px', background: 'var(--bg-panel)',
border: '1px solid var(--accent)', borderRadius: 8 }}>
<div style={{ fontSize: 10, letterSpacing: '0.12em', color: 'var(--accent)',
fontWeight: 600, marginBottom: 4 }}>UPDATE VERFÜGBAR</div>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 6 }}>Dossier {update.version}</div>
{update.body && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5,
whiteSpace: 'pre-wrap', marginBottom: 10, maxHeight: 160, overflowY: 'auto' }}>
{update.body}
</div>
)}
<button className="primary pill" style={{ width: '100%', marginBottom: 6 }} onClick={install} disabled={isBusy}>
{isBusy ? 'Bitte warten …' : 'Installieren und neu starten'}
</button>
<button onClick={skipVersion} disabled={isBusy} style={{ width: '100%', fontSize: 12 }}>
Diese Version überspringen
</button>
</div>
)}
{isBusy && (
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 6, letterSpacing: '0.04em' }}>
{state === 'downloading'
? (pct !== null ? `Wird heruntergeladen … ${pct}%` : 'Wird heruntergeladen …')
: 'Wird installiert …'}
</div>
<div style={{ height: 4, background: 'var(--bg-elev)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{
height: '100%',
width: pct !== null ? `${pct}%` : '100%',
background: 'var(--accent)',
transition: 'width 0.2s',
animation: pct === null ? 'dossier-us-pulse 1.2s ease-in-out infinite' : undefined,
}} />
</div>
<style>{`@keyframes dossier-us-pulse { 0%,100% { opacity: 0.5; } 50% { opacity: 1; } }`}</style>
</div>
)}
{state === 'not-tauri' && (
<div style={{ marginTop: 10, padding: '10px 12px', background: 'var(--bg-panel)',
border: '1px solid var(--border)', borderRadius: 6, fontSize: 12,
color: 'var(--text-muted)' }}>
Updates sind nur in der Desktop-App verfügbar.
</div>
)}
{error && (
<div style={{ marginTop: 10, padding: '10px 12px', background: 'rgba(200, 112, 80, 0.12)',
border: '1px solid var(--danger)', borderRadius: 6, fontSize: 12,
color: 'var(--danger)' }}>
{error}
</div>
)}
<p style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.6, marginTop: 14 }}>
Updates werden automatisch beim Start der App geprüft.
</p>
</div>
)
}
function ProjectDialog({ mode, project, modules, onCancel, onSave }) {
const [name, setName] = useState(project?.name || '')
const [path, setPath] = useState(project?.path || '')
const [picked, setPicked] = useState(new Set(project?.modules || ['ebenen', 'oberleiste']))
const [useTemplate, setUseTemplate] = useState(true) // nur bei mode='new' wirksam
const [hasTemplate, setHasTemplate] = useState(false)
const [windowLayout, setWindowLayout] = useState(project?.windowLayout || '')
const [layouts, setLayouts] = useState([])
const [tagIds, setTagIds] = useState(new Set(project?.tagIds || []))
const [allTags, setAllTags] = useState([])
const [extraFiles, setExtraFiles] = useState(project?.extraFiles || [])
useEffect(() => {
invoke('read_settings').then(s => {
if (mode === 'new') setHasTemplate(!!s?.templatePath)
setAllTags(s?.tags || [])
}).catch(() => {})
invoke('list_window_layouts').then(setLayouts).catch(() => {})
}, [mode])
const toggleTag = (id) => {
setTagIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const addExtraFile = async () => {
const sel = await openDialog({
title: 'Weitere Projektdatei (.3dm) wählen',
filters: [{ name: 'Rhino', extensions: ['3dm'] }],
})
if (typeof sel !== 'string') return
if (sel === path) { alert('Das ist bereits die Hauptdatei.'); return }
if (extraFiles.includes(sel)) return
setExtraFiles([...extraFiles, sel])
}
const removeExtraFile = (p) => {
setExtraFiles(extraFiles.filter(x => x !== p))
}
const effective = useMemo(() => resolveDeps(picked, modules), [picked, modules])
const locked = useMemo(() => {
const out = new Set()
for (const id of effective) {
if (!picked.has(id)) out.add(id)
}
return out
}, [picked, effective, modules])
const toggle = (id) => {
if (locked.has(id)) return
setPicked(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const pickFile = async () => {
// Bei "neu" wollen wir wirklich einen NEUEN Pfad — saveDialog zeigt das
// dem User klar an ("Save As") und akzeptiert auch noch nicht existierende
// Dateien. Bei "edit" zeigen wir openDialog (Datei muss existieren).
const sel = mode === 'new'
? await saveDialog({
title: '3dm-Datei anlegen',
defaultPath: name.trim() ? `${name.trim()}.3dm` : 'untitled.3dm',
filters: [{ name: 'Rhino', extensions: ['3dm'] }],
})
: await openDialog({
title: '3dm-Datei waehlen',
filters: [{ name: 'Rhino', extensions: ['3dm'] }],
})
if (typeof sel === 'string') setPath(sel)
}
return (
<div className="dialog-bg" onClick={(e) => { if (e.target === e.currentTarget) onCancel() }}>
<div className="dialog">
<header>{mode === 'new' ? 'Neues Projekt' : 'Projekt bearbeiten'}</header>
<div className="body">
<div className="row">
<label>Projekt-Name</label>
<input type="text" value={name} onChange={e => setName(e.target.value)}
placeholder="z.B. Wohnhaus Brunner" autoFocus />
</div>
<div className="row">
<label>3dm-Datei</label>
<div className="file-picker">
<input type="text" value={path} readOnly
placeholder={mode === 'new' ? 'Speicherort wählen…' : 'Pfad zur 3dm-Datei'} />
<button onClick={pickFile}>{mode === 'new' ? 'Anlegen…' : 'Wählen…'}</button>
</div>
{mode === 'new' && hasTemplate && (
<label style={{ display: 'flex', alignItems: 'center', gap: 8,
textTransform: 'none', letterSpacing: 0, fontSize: 12,
color: 'var(--text)', marginTop: 6 }}>
<input type="checkbox" checked={useTemplate}
onChange={e => setUseTemplate(e.target.checked)}
style={{ width: 14, height: 14 }} />
Vorlage aus Einstellungen verwenden
</label>
)}
{mode === 'new' && !hasTemplate && (
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.5, marginTop: 4 }}>
Keine Vorlage gesetzt die Datei wird mit Rhinos Standard angelegt.
In den Einstellungen kannst du eine Template-3dm definieren.
</div>
)}
</div>
<div className="row">
<label>Zusätzliche Projektdateien (.3dm)</label>
{extraFiles.length === 0 ? (
<div className="hint">
Keine. Klick Datei hinzufügen", wenn dein Projekt aus mehreren
<code className="nowrap">.3dm</code>-Dateien besteht (z.B. Plan + Details + Präsentation).
</div>
) : (
<div className="extra-files">
{extraFiles.map(f => (
<div key={f} className="extra-file-row">
<Icon name="folder" size={14} />
<span className="extra-file-path" title={f}>{f.split('/').pop()}</span>
<button className="ghost danger icon-btn"
onClick={() => removeExtraFile(f)}
title="Entfernen">
<Icon name="close" size={12} />
</button>
</div>
))}
</div>
)}
<button className="ghost" onClick={addExtraFile} style={{ marginTop: 6 }}>
<Icon name="plus" size={14} /> Datei hinzufügen
</button>
</div>
<div className="row">
<label>Tags</label>
{allTags.length === 0 ? (
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.5 }}>
Noch keine Tags definiert. In den Einstellungen → Tags
kannst du eigene Kategorien (Name + Farbe) anlegen.
</div>
) : (
<div className="tag-picker">
{allTags.map(t => {
const active = tagIds.has(t.id)
return (
<button key={t.id}
className={`tag-chip btn-tag ${active ? 'is-active' : ''}`}
style={{
background: active ? t.color : 'transparent',
borderColor: t.color,
color: active ? (isDarkColor(t.color) ? '#fff' : '#0a1715') : t.color,
}}
onClick={() => toggleTag(t.id)}>
{t.name}
</button>
)
})}
</div>
)}
</div>
<div className="row">
<label>Window-Layout für dieses Projekt</label>
{layouts.length === 0 ? (
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.5 }}>
Keine Rhino-Window-Layouts gefunden. In Rhino:
<em> Window → Window Layouts → Save…</em>
</div>
) : (
<select value={windowLayout}
onChange={e => setWindowLayout(e.target.value)}
style={{ width: '100%' }}>
<option value="">— Standard aus Einstellungen verwenden</option>
{layouts.map(l => <option key={l} value={l}>{l}</option>)}
</select>
)}
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.5, marginTop: 4 }}>
Wird beim Öffnen dieses Projekts in Rhino angewendet.
Überschreibt das globale Default-Layout.
</div>
</div>
<div className="row">
<label>Module ({[...effective].length} aktiv)</label>
<div className="module-grid">
{modules.map(m => {
const isActive = effective.has(m.id)
const isLocked = locked.has(m.id)
return (
<div
key={m.id}
className={`module-row ${isActive ? 'active' : ''} ${isLocked ? 'locked' : ''}`}
onClick={() => toggle(m.id)}
title={isLocked ? 'Wird von einem anderen Modul gebraucht' : ''}
>
<div className="check">{isActive ? (isLocked ? '🔒' : '✓') : ''}</div>
<div className="info">
<div className="name">{m.name}</div>
<div className="desc">{m.description}</div>
{(m.dependsOn || []).length > 0 && (
<div className="dep">braucht: {m.dependsOn.join(', ')}</div>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
<footer>
<button onClick={onCancel}>Abbrechen</button>
<button className="primary pill"
onClick={() => onSave({
name, path,
modules: [...effective],
useTemplate: mode === 'new' && useTemplate && hasTemplate,
windowLayout: windowLayout || null,
tagIds: [...tagIds],
extraFiles,
})}>
{mode === 'new' ? 'Anlegen' : 'Speichern'}
</button>
</footer>
</div>
</div>
)
}