feat: add standalone (frontend-only) mode via VITE_STANDALONE build flag

- App.tsx: bypass auth gate, swap canvas save/load to localStorage
- Sidebar.tsx: hide scan views and Scan Network button
- useStatusPolling.ts: skip WebSocket connection
- Dockerfile.frontend: accept VITE_STANDALONE build arg
- docker-publish.yml: build ghcr.io/pouzor/homelable-frontend-standalone image
- docker-compose.standalone.yml: frontend-only compose file
- install.sh: add --standalone flag

Usage:
  curl -fsSL .../install.sh | bash -s -- --standalone
  cd homelable && docker compose up -d
This commit is contained in:
Pouzor
2026-03-09 14:00:25 +01:00
parent 1cd93ed250
commit 7ba5e888b4
7 changed files with 91 additions and 24 deletions
+32 -7
View File
@@ -21,6 +21,9 @@ import { demoNodes, demoEdges } from '@/utils/demoData'
import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
export default function App() {
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, nodes, edges } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null)
@@ -37,6 +40,12 @@ export default function App() {
// Declare handleSave before the Ctrl+S effect so it is in scope
const handleSave = useCallback(async () => {
try {
if (STANDALONE) {
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges }))
markSaved()
toast.success('Canvas saved')
return
}
const nodesToSave = nodes.map((n) => ({
id: n.id,
type: n.data.type,
@@ -84,8 +93,22 @@ export default function App() {
const handleSaveRef = useRef(handleSave)
useEffect(() => { handleSaveRef.current = handleSave }, [handleSave])
// Load canvas on auth
// Load canvas on auth (or immediately in standalone mode)
useEffect(() => {
if (STANDALONE) {
try {
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
if (saved) {
const { nodes: savedNodes, edges: savedEdges } = JSON.parse(saved)
loadCanvas(savedNodes, savedEdges)
} else {
loadCanvas(demoNodes, demoEdges)
}
} catch {
loadCanvas(demoNodes, demoEdges)
}
return
}
if (!isAuthenticated) return
canvasApi.load()
.then((res) => {
@@ -253,7 +276,7 @@ export default function App() {
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
if (!isAuthenticated) return <LoginPage />
if (!STANDALONE && !isAuthenticated) return <LoginPage />
return (
<TooltipProvider>
@@ -321,11 +344,13 @@ export default function App() {
title="Edit Link"
/>
<ScanConfigModal
open={scanConfigOpen}
onClose={() => setScanConfigOpen(false)}
onScanNow={() => toast.success('Scan triggered')}
/>
{!STANDALONE && (
<ScanConfigModal
open={scanConfigOpen}
onClose={() => setScanConfigOpen(false)}
onScanNow={() => toast.success('Scan triggered')}
/>
)}
<Toaster theme="dark" position="bottom-right" />
</ReactFlowProvider>
+5 -2
View File
@@ -6,14 +6,17 @@ import { scanApi } from '@/api/client'
import { toast } from 'sonner'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history'
const VIEWS = [
const ALL_VIEWS = [
{ id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' },
{ id: 'pending' as SidebarView, icon: ScanLine, label: 'Pending Devices' },
{ id: 'hidden' as SidebarView, icon: EyeOff, label: 'Hidden Devices' },
{ id: 'history' as SidebarView, icon: Clock, label: 'Scan History' },
]
const VIEWS = STANDALONE ? ALL_VIEWS.slice(0, 1) : ALL_VIEWS
interface ScanRun {
id: string
@@ -123,7 +126,7 @@ export function Sidebar({ onAddNode, onScan, onSave, onNodeApproved }: SidebarPr
{/* Actions */}
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
<SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
<SidebarItem
icon={Save}
label="Save Canvas"
+3 -1
View File
@@ -12,13 +12,15 @@ interface StatusMessage {
devices_found?: number
}
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
export function useStatusPolling() {
const wsRef = useRef<WebSocket | null>(null)
const { updateNode, notifyScanDeviceFound } = useCanvasStore()
const { isAuthenticated, token } = useAuthStore()
useEffect(() => {
if (!isAuthenticated || !token) return
if (STANDALONE || !isAuthenticated || !token) return
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const host = window.location.hostname