Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pnpm exec lint-staged
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
dist
node_modules
pnpm-lock.yaml
*.yaml
*.md
10 changes: 10 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"singleQuote": true,
"semi": false,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}
21 changes: 13 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,16 @@ new-lockfile: ## Regenerate the pnpm lockfile
tree: ## Show project structure (requires 'tree' command)
tree -I 'node_modules|dist|.git' --dirsfirst

# loc: ## Count lines of source code
# @echo ""
# @echo " Lines of code by package:"
# @echo " ─────────────────────────"
# @printf " core: " && find packages/core/src -name '*.ts' | xargs cat | wc -l
# @printf " renderer: " && find packages/renderer/src -name '*.ts' -o -name '*.tsx' | xargs cat | wc -l
# @printf " web: " && find apps/web/src -name '*.ts' -o -name '*.tsx' | xargs cat | wc -l
# @echo ""
# ── Linting & formatting ──────────────────────────────────────────

lint: ## Run ESLint
$(PNPM) run lint

lint-fix: ## Run ESLint with auto-fix
$(PNPM) run lint:fix

format: ## Format all files with Prettier
$(PNPM) run format

format-check: ## Check formatting without writing
$(PNPM) run format:check
Empty file removed apps/.gitattributes
Empty file.
198 changes: 115 additions & 83 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,125 +1,157 @@
import React, { useState, useMemo, useCallback } from "react";
import { parse, layout } from "@homelab-stackdoc/core";
import type { PositionedGraph, ValidationError, Device } from "@homelab-stackdoc/core";
import { YamlEditor } from "./components/YamlEditor";
import { PreviewPane } from "./components/PreviewPane";
import { SAMPLE_YAML } from "./sampleYaml";
import React, { useState, useMemo, useCallback } from 'react'
import { parse, layout } from '@homelab-stackdoc/core'
import { PreviewPane } from './components/PreviewPane'
import SAMPLE_YAML from './sample.yaml?raw'
import { YamlEditor } from './components/YamlEditor'
import type { Device } from '@homelab-stackdoc/core'

/** Recursively collects all devices (including children) into a flat map */
function buildDeviceMap(devices: Device[]): Map<string, Device> {
const map = new Map<string, Device>();
const map = new Map<string, Device>()
const walk = (devs: Device[]) => {
for (const d of devs) {
map.set(d.id, d);
if (d.children) walk(d.children);
map.set(d.id, d)
if (d.children) walk(d.children)
}
};
walk(devices);
return map;
}
walk(devices)
return map
}

export const App: React.FC = () => {
const [yaml, setYaml] = useState(SAMPLE_YAML);
const [splitRatio, setSplitRatio] = useState(0.15);
const [resizing, setResizing] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleButtonStyle: React.CSSProperties = {
position: 'absolute',
top: 52,
left: 8,
zIndex: 20,
width: 32,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(12, 21, 39, 0.9)',
border: '1px solid rgba(0, 229, 255, 0.12)',
borderRadius: 6,
color: '#78909c',
cursor: 'pointer',
fontFamily: "'JetBrains Mono', monospace",
fontSize: 14,
padding: 0,
transition: 'all 0.15s',
}

const toggleExpand = useCallback((id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
export const App: React.FC = () => {
const [yaml, setYaml] = useState(SAMPLE_YAML)
const [splitRatio, setSplitRatio] = useState(0.2)
const [resizing, setResizing] = useState(false)
const [editorVisible, setEditorVisible] = useState(true)

// Core pipeline: parse → validate → layout
const { graph, errors, deviceMap } = useMemo<{
graph: PositionedGraph | null;
errors: ValidationError[];
deviceMap: Map<string, Device>;
}>(() => {
const result = parse(yaml);
const { graph, errors, deviceMap, connections } = useMemo(() => {
const result = parse(yaml)
if (!result.ok) {
return { graph: null, errors: result.errors, deviceMap: new Map() };
return { graph: null, errors: result.errors, deviceMap: new Map(), connections: [] }
}
try {
const positioned = layout(result.document, { expanded });
const dMap = buildDeviceMap(result.document.devices);
return { graph: positioned, errors: result.warnings, deviceMap: dMap };
const positioned = layout(result.document)
const dMap = buildDeviceMap(result.document.devices)
return {
graph: positioned,
errors: result.warnings,
deviceMap: dMap,
connections: result.document.connections ?? [],
}
} catch (e) {
return {
graph: null,
errors: [
{
path: "",
path: '',
message: `Layout error: ${e instanceof Error ? e.message : String(e)}`,
severity: "error" as const,
severity: 'error' as const,
},
],
deviceMap: new Map(),
};
connections: [],
}
}
}, [yaml, expanded]);
}, [yaml])

// Split-pane resizer
const onResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setResizing(true);

e.preventDefault()
setResizing(true)
const onMove = (moveEvent: MouseEvent) => {
const ratio = moveEvent.clientX / window.innerWidth;
setSplitRatio(Math.min(0.6, Math.max(0.15, ratio)));
};
const ratio = moveEvent.clientX / window.innerWidth
setSplitRatio(Math.min(0.6, Math.max(0.15, ratio)))
}
const onUp = () => {
setResizing(false);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}, []);
setResizing(false)
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}, [])

return (
<div
style={{
display: "flex",
height: "100vh",
width: "100vw",
overflow: "hidden",
background: "#080f1e",
display: 'flex',
height: '100vh',
width: '100vw',
overflow: 'hidden',
background: '#080f1e',
}}
>
<div style={{ width: `${splitRatio * 100}%`, height: "100%" }}>
<YamlEditor value={yaml} onChange={setYaml} errors={errors} />
</div>
{/* Editor pane */}
{editorVisible && (
<>
<div style={{ width: `${splitRatio * 100}%`, height: '100%' }}>
<YamlEditor value={yaml} onChange={setYaml} errors={errors} />
</div>
<div
onMouseDown={onResizeStart}
style={{
width: 5,
cursor: 'col-resize',
flexShrink: 0,
background: resizing ? 'rgba(0,229,255,0.3)' : 'rgba(0,229,255,0.08)',
transition: 'background 0.15s',
}}
/>
</>
)}

<div
onMouseDown={onResizeStart}
style={{
width: 5,
cursor: "col-resize",
background: resizing
? "rgba(0,229,255,0.3)"
: "rgba(0,229,255,0.08)",
transition: "background 0.15s",
flexShrink: 0,
}}
/>
{/* Canvas pane */}
<div style={{ flex: 1, height: '100%', position: 'relative' }}>
{/* Editor toggle button */}
<button
onClick={() => setEditorVisible((v) => !v)}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'rgba(0, 229, 255, 0.35)'
e.currentTarget.style.color = '#00e5ff'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(0, 229, 255, 0.12)'
e.currentTarget.style.color = '#78909c'
}}
title={editorVisible ? 'Hide editor' : 'Show editor'}
style={toggleButtonStyle}
>
<svg width={16} height={16} viewBox="0 0 24 24" fill="currentColor">
{editorVisible ? (
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
) : (
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
)}
</svg>
</button>

<div style={{ flex: 1, height: "100%" }}>
<PreviewPane
graph={graph}
errors={errors}
expanded={expanded}
onToggleExpand={toggleExpand}
deviceMap={deviceMap}
connections={connections}
yaml={yaml}
/>
</div>
</div>
);
};
)
}
Loading
Loading