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
185 changes: 185 additions & 0 deletions apps/web/src/components/AppNav.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
onClick={() => navigate(to)}
onMouseEnter={(e) => {
e.currentTarget.style.color = colors.primary
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = active ? colors.primary : colors.textSecondary
}}
style={{
background: 'transparent',
border: 'none',
color: active ? colors.primary : colors.textSecondary,
fontFamily: fonts.mono,
fontSize: 11,
letterSpacing: '0.04em',
cursor: 'pointer',
padding: '4px 0',
}}
>
{children}
</button>
)
}

const ExternalNavLink: React.FC<{ href: string; children: React.ReactNode }> = ({
href,
children,
}) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={(e) => {
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}
</a>
)

const DefaultNewDiagramButton: React.FC = () => {
const navigate = useNavigate()
const [hovered, setHovered] = useState(false)

return (
<button
onClick={() => navigate('/editor')}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex',
alignItems: 'center',
gap: 5,
padding: '5px 12px',
background: hovered ? 'rgba(0, 229, 255, 0.1)' : 'transparent',
border: `1px solid ${colors.primary}`,
borderRadius: 5,
color: colors.primary,
cursor: 'pointer',
fontFamily: fonts.mono,
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.06em',
transition: 'background 0.15s',
}}
>
NEW DIAGRAM
</button>
)
}

export const AppNav: React.FC<AppNavProps> = ({ title, kicker, primaryAction }) => (
<header
style={{
display: 'flex',
alignItems: 'center',
gap: 24,
padding: '14px 28px',
borderBottom: `1px solid ${colors.border}`,
background: 'rgba(8, 15, 30, 0.92)',
backdropFilter: 'blur(8px)',
position: 'sticky',
top: 0,
zIndex: 50,
}}
>
{/* Brand — anchor, not navigate(), so middle-click opens a new tab. */}
<a
href="/"
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
textDecoration: 'none',
color: colors.textPrimary,
fontSize: 13,
fontWeight: 700,
letterSpacing: '0.04em',
}}
>
<span style={{ color: colors.primary }}>&gt;_</span>
stackdoc
</a>

<span
style={{
padding: '2px 6px',
border: `1px solid ${colors.green}40`,
borderRadius: 3,
color: colors.green,
fontSize: 8,
fontWeight: 700,
letterSpacing: '0.1em',
}}
>
LIVE
</span>

{title && (
<span
style={{
color: colors.textPrimary,
fontSize: 13,
fontWeight: 700,
letterSpacing: '0.04em',
}}
>
{title}
</span>
)}

{kicker && (
<span
style={{
color: colors.textMuted,
fontSize: 11,
fontWeight: 400,
letterSpacing: 0,
}}
>
{kicker}
</span>
)}

<div style={{ flex: 1 }} />

<nav style={{ display: 'flex', gap: 18, alignItems: 'center' }}>
<NavLink to="/templates">templates</NavLink>
<NavLink to="/gallery">gallery</NavLink>
<ExternalNavLink href="https://github.com/meetKazuki/infra-stackdoc">github</ExternalNavLink>
</nav>

{primaryAction ?? <DefaultNewDiagramButton />}

<UserMenu />
</header>
)
42 changes: 3 additions & 39 deletions apps/web/src/components/PreviewPane.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,22 @@
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<string, Device>
connections: Connection[]
yaml: string
editingSlug?: string
captureRef: React.RefObject<HTMLDivElement>
}

export const PreviewPane: React.FC<PreviewPaneProps> = ({
graph,
errors,
deviceMap,
connections,
yaml,
editingSlug,
captureRef,
}) => {
const captureRef = useRef<HTMLDivElement>(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 (
<div
Expand Down Expand Up @@ -76,12 +46,6 @@ export const PreviewPane: React.FC<PreviewPaneProps> = ({
<div ref={captureRef} style={{ height: '100%' }}>
<TopologyCanvas graph={graph} deviceMap={deviceMap} connections={connections} />
</div>
<SharePanel
yaml={yaml}
onExportPng={handleExportPng}
isExporting={isExporting}
editingSlug={editingSlug}
/>
</div>
)
}
22 changes: 17 additions & 5 deletions apps/web/src/components/SharePanel.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -116,6 +116,18 @@ export const SharePanel: React.FC<SharePanelProps> = ({
const [justShared, setJustShared] = useState(false)
const [shareResult, setShareResult] = useState<string | null>(null)
const [shareError, setShareError] = useState<string | null>(null)
const wrapperRef = useRef<HTMLDivElement>(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

Expand Down Expand Up @@ -216,11 +228,10 @@ export const SharePanel: React.FC<SharePanelProps> = ({

return (
<div
ref={wrapperRef}
style={{
position: 'absolute',
top: 52,
right: 16,
zIndex: 20,
position: 'relative',
display: 'inline-flex',
}}
>
{/* Toggle button */}
Expand Down Expand Up @@ -264,6 +275,7 @@ export const SharePanel: React.FC<SharePanelProps> = ({
position: 'absolute',
top: 40,
right: 0,
zIndex: 30,
width: 320,
padding: 8,
background: colors.background,
Expand Down
Loading
Loading