From 48816d4389afc672385a4a86e5598643b47187e1 Mon Sep 17 00:00:00 2001 From: Desmond Edem Date: Mon, 18 May 2026 03:39:44 +0100 Subject: [PATCH] feat: add app navigation --- apps/web/src/components/AppNav.tsx | 185 ++++++++++++++++++++++++ apps/web/src/components/PreviewPane.tsx | 42 +----- apps/web/src/components/SharePanel.tsx | 22 ++- apps/web/src/pages/EditorPage.tsx | 171 +++++++++++++--------- apps/web/src/pages/GalleryPage.tsx | 66 +-------- apps/web/src/pages/LandingPage.tsx | 140 +----------------- apps/web/src/pages/MyConfigsPage.tsx | 55 +------ apps/web/src/pages/SharedViewPage.tsx | 166 ++++++++++++--------- apps/web/src/pages/TemplatesPage.tsx | 55 +------ 9 files changed, 414 insertions(+), 488 deletions(-) create mode 100644 apps/web/src/components/AppNav.tsx diff --git a/apps/web/src/components/AppNav.tsx b/apps/web/src/components/AppNav.tsx new file mode 100644 index 0000000..c273d71 --- /dev/null +++ b/apps/web/src/components/AppNav.tsx @@ -0,0 +1,185 @@ +import React, { useState } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { colors, fonts } from '@homelab-stackdoc/renderer' +import { UserMenu } from './UserMenu' + +interface AppNavProps { + title?: string + kicker?: string + primaryAction?: React.ReactNode +} + +const NavLink: React.FC<{ to: string; children: React.ReactNode }> = ({ to, children }) => { + const navigate = useNavigate() + const location = useLocation() + const active = location.pathname === to + + return ( + + ) +} + +const ExternalNavLink: React.FC<{ href: string; children: React.ReactNode }> = ({ + href, + children, +}) => ( + { + e.currentTarget.style.color = colors.primary + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = colors.textSecondary + }} + style={{ + color: colors.textSecondary, + fontFamily: fonts.mono, + fontSize: 11, + letterSpacing: '0.04em', + textDecoration: 'none', + padding: '4px 0', + }} + > + {children} + +) + +const DefaultNewDiagramButton: React.FC = () => { + const navigate = useNavigate() + const [hovered, setHovered] = useState(false) + + return ( + + ) +} + +export const AppNav: React.FC = ({ title, kicker, primaryAction }) => ( +
+ {/* Brand — anchor, not navigate(), so middle-click opens a new tab. */} + + >_ + stackdoc + + + + LIVE + + + {title && ( + + {title} + + )} + + {kicker && ( + + {kicker} + + )} + +
+ + + + {primaryAction ?? } + + +
+) diff --git a/apps/web/src/components/PreviewPane.tsx b/apps/web/src/components/PreviewPane.tsx index 4bd19c4..fe7b953 100644 --- a/apps/web/src/components/PreviewPane.tsx +++ b/apps/web/src/components/PreviewPane.tsx @@ -1,16 +1,13 @@ -import html2canvas from 'html2canvas' -import React, { useRef, useState, useCallback } from 'react' +import React from 'react' import { TopologyCanvas } from '@homelab-stackdoc/renderer' import type { PositionedGraph, ValidationError, Device, Connection } from '@homelab-stackdoc/core' -import { SharePanel } from './SharePanel' interface PreviewPaneProps { graph: PositionedGraph | null errors: ValidationError[] deviceMap: Map connections: Connection[] - yaml: string - editingSlug?: string + captureRef: React.RefObject } export const PreviewPane: React.FC = ({ @@ -18,35 +15,8 @@ export const PreviewPane: React.FC = ({ errors, deviceMap, connections, - yaml, - editingSlug, + captureRef, }) => { - const captureRef = useRef(null) - const [isExporting, setIsExporting] = useState(false) - - const handleExportPng = useCallback(async () => { - if (!captureRef.current || !graph) return - setIsExporting(true) - try { - const canvas = await html2canvas(captureRef.current, { - backgroundColor: '#080f1e', - scale: 2, - useCORS: true, - logging: false, - width: captureRef.current.offsetWidth, - height: captureRef.current.offsetHeight, - }) - const link = document.createElement('a') - link.download = `homelab-topology-${Date.now()}.png` - link.href = canvas.toDataURL('image/png') - link.click() - } catch (err) { - console.error('PNG export failed:', err) - } finally { - setIsExporting(false) - } - }, [graph]) - if (errors.some((e) => e.severity === 'error') || !graph) { return (
= ({
-
) } diff --git a/apps/web/src/components/SharePanel.tsx b/apps/web/src/components/SharePanel.tsx index 5b159fd..afbfb22 100644 --- a/apps/web/src/components/SharePanel.tsx +++ b/apps/web/src/components/SharePanel.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useState, useRef, useEffect } from 'react' import { createConfig, updateConfig } from '../lib/api' import { useAuth } from '../context/AuthContext' @@ -116,6 +116,18 @@ export const SharePanel: React.FC = ({ const [justShared, setJustShared] = useState(false) const [shareResult, setShareResult] = useState(null) const [shareError, setShareError] = useState(null) + const wrapperRef = useRef(null) + + useEffect(() => { + if (!open) return + const onClick = (e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('mousedown', onClick) + return () => document.removeEventListener('mousedown', onClick) + }, [open]) const publicUrl = editingSlug ? `${window.location.origin}/s/${editingSlug}` : shareResult @@ -216,11 +228,10 @@ export const SharePanel: React.FC = ({ return (
{/* Toggle button */} @@ -264,6 +275,7 @@ export const SharePanel: React.FC = ({ position: 'absolute', top: 40, right: 0, + zIndex: 30, width: 320, padding: 8, background: colors.background, diff --git a/apps/web/src/pages/EditorPage.tsx b/apps/web/src/pages/EditorPage.tsx index 8b019ea..e06d7b9 100644 --- a/apps/web/src/pages/EditorPage.tsx +++ b/apps/web/src/pages/EditorPage.tsx @@ -1,9 +1,11 @@ -import React, { useState, useMemo, useCallback } from 'react' +import html2canvas from 'html2canvas' +import React, { useState, useMemo, useCallback, useRef } from 'react' import { parse, layout } from '@homelab-stackdoc/core' +import { AppNav } from '../components/AppNav' import { buildDeviceMap } from '../lib/device' import { PreviewPane } from '../components/PreviewPane' import SAMPLE_YAML from '../sample.yaml?raw' -import { UserMenu } from '../components/UserMenu' +import { SharePanel } from '../components/SharePanel' import { YamlEditor } from '../components/YamlEditor' interface EditorPageProps { @@ -38,6 +40,9 @@ export const EditorPage: React.FC = ({ initialYaml, editingSlug const [resizing, setResizing] = useState(false) const [editorVisible, setEditorVisible] = useState(true) + const captureRef = useRef(null) + const [isExporting, setIsExporting] = useState(false) + const { graph, errors, deviceMap, connections, networkCount } = useMemo(() => { const result = parse(yaml) if (!result.ok) { @@ -92,88 +97,114 @@ export const EditorPage: React.FC = ({ initialYaml, editingSlug window.addEventListener('mouseup', onUp) }, []) + const handleExportPng = useCallback(async () => { + if (!captureRef.current || !graph) return + setIsExporting(true) + try { + const canvas = await html2canvas(captureRef.current, { + backgroundColor: '#080f1e', + scale: 2, + useCORS: true, + logging: false, + width: captureRef.current.offsetWidth, + height: captureRef.current.offsetHeight, + }) + const link = document.createElement('a') + link.download = `homelab-topology-${Date.now()}.png` + link.href = canvas.toDataURL('image/png') + link.click() + } catch (err) { + console.error('PNG export failed:', err) + } finally { + setIsExporting(false) + } + }, [graph]) + + const title = editingSlug ? `EDITING: ${editingSlug}` : 'EDITOR' + return (
- {/* Editor pane */} - {editorVisible && ( - <> -
- -
-
- - )} + } + /> - {/* Canvas pane */} -
- {/* Editor toggle button */} - +
+ {/* Editor pane */} + {editorVisible && ( + <> +
+ +
+
+ + )} - {/* User menu — sits below the canvas top header (which contains the - legend) and below the SharePanel button so neither overlaps. */} -
- -
+ {/* Canvas pane */} +
+ {/* Editor toggle button */} + - + +
) diff --git a/apps/web/src/pages/GalleryPage.tsx b/apps/web/src/pages/GalleryPage.tsx index 7fb1cc4..9d82cf9 100644 --- a/apps/web/src/pages/GalleryPage.tsx +++ b/apps/web/src/pages/GalleryPage.tsx @@ -1,14 +1,9 @@ import React from 'react' -import { useNavigate } from 'react-router-dom' +import { AppNav } from '../components/AppNav' import { Gallery } from '../components/Gallery' -import { UserMenu } from '../components/UserMenu' const colors = { background: '#080f1e', - border: 'rgba(0, 229, 255, 0.12)', - textPrimary: '#e0f7fa', - textSecondary: '#78909c', - textMuted: '#455a64', } const fonts = { @@ -16,8 +11,6 @@ const fonts = { } export const GalleryPage: React.FC = () => { - const navigate = useNavigate() - return (
{ fontFamily: fonts.mono, }} > -
-
- -

- GALLERY -

- - // what the community is running - -
- -
+
= ({ to, children }) => { - const navigate = useNavigate() - return ( - - ) -} - -const ExternalNavLink: React.FC<{ href: string; children: React.ReactNode }> = ({ - href, - children, -}) => ( - { - e.currentTarget.style.color = colors.primary - }} - onMouseLeave={(e) => { - e.currentTarget.style.color = colors.textSecondary - }} - style={{ - color: colors.textSecondary, - fontFamily: fonts.mono, - fontSize: 11, - letterSpacing: '0.04em', - textDecoration: 'none', - padding: '4px 0', - }} - > - {children} - -) - const Badge: React.FC<{ children: React.ReactNode }> = ({ children }) => ( { const navigate = useNavigate() const [primaryCtaHovered, setPrimaryCtaHovered] = useState(false) const [ghostCtaHovered, setGhostCtaHovered] = useState(false) - const [newDiagramHovered, setNewDiagramHovered] = useState(false) return (
{ fontFamily: fonts.mono, }} > - {/* Nav strip */} -
- - >_ - stackdoc - - - - LIVE - - -
- - - - - - -
+ {/* Hero */}
{ fontFamily: fonts.mono, }} > - {/* Header */} -
-
- -

- MY CONFIGS -

-
- -
+ - {/* Body */}
void }> = ({ onClick }) => { + const [hovered, setHovered] = useState(false) + return ( + + ) +} + export const SharedView: React.FC = () => { const { slug } = useParams<{ slug: string }>() const navigate = useNavigate() @@ -81,7 +110,7 @@ export const SharedView: React.FC = () => { }, [slug, navigate]) const handleOpenInEditor = useCallback(() => { - navigate('/', { state: { yaml: config?.yaml } }) + navigate('/editor', { state: { yaml: config?.yaml } }) }, [config, navigate]) const handleEditOwnConfig = useCallback(() => { @@ -104,22 +133,35 @@ export const SharedView: React.FC = () => { } }, [slug, config, navigate]) + // Nav primary action: owners see EDIT (jumps to /edit/); visitors get + // the AppNav's default NEW DIAGRAM button via the unset prop. + const navPrimaryAction = isOwner ? : undefined + // Loading state if (loading) { return (
- Loading topology... + +
+ Loading topology... +
) } @@ -129,35 +171,29 @@ export const SharedView: React.FC = () => { return (
-
404
-
{error || 'Config not found'}
- +
404
+
{error || 'Config not found'}
+
) } @@ -167,35 +203,44 @@ export const SharedView: React.FC = () => { return (
-
-
This config has invalid YAML
- +
+
This config has invalid YAML
+ +
) } @@ -211,6 +256,8 @@ export const SharedView: React.FC = () => { flexDirection: 'column', }} > + + {/* Topology canvas — takes remaining vertical space. The canvas's own bottom-anchored controls (zoom, fit, reset) now clear the shared bottom bar because they're positioned within this flex item, not @@ -223,19 +270,6 @@ export const SharedView: React.FC = () => { }} > - - {/* User menu — sits below the canvas top header so the legend keeps - its full width. */} -
- -
{/* Bottom bar — shared config info */} diff --git a/apps/web/src/pages/TemplatesPage.tsx b/apps/web/src/pages/TemplatesPage.tsx index a8ede7c..811fe78 100644 --- a/apps/web/src/pages/TemplatesPage.tsx +++ b/apps/web/src/pages/TemplatesPage.tsx @@ -1,13 +1,9 @@ import React from 'react' -import { useNavigate } from 'react-router-dom' +import { AppNav } from '../components/AppNav' import { Templates } from '../components/Templates' -import { UserMenu } from '../components/UserMenu' const colors = { background: '#080f1e', - border: 'rgba(0, 229, 255, 0.12)', - textPrimary: '#e0f7fa', - textSecondary: '#78909c', } const fonts = { @@ -15,8 +11,6 @@ const fonts = { } export const TemplatesPage: React.FC = () => { - const navigate = useNavigate() - return (
{ fontFamily: fonts.mono, }} > -
-
- -

- TEMPLATES -

-
- -
+