Homelable could only own the root of an origin. Behind an existing proxy at `https://home.example/homelab/` the built `index.html` still asked for `/assets/*`, which fell through to whatever owned the root — and when that answered `text/html` for a `<script>` under `nosniff`, the browser failed the load on an HTTP 200. The only workaround was patching the checkout and rebuilding on every upgrade. One build-time knob, `VITE_BASE_PATH`, becomes Vite's `base`. Vite rewrites the asset URLs it emits; everything the app builds by hand goes through the new `utils/basePath.ts` — the axios instances, the WebSocket URL, the live-view route, the local brand icons, and the OIDC login href. `resolveServerPath` covers what the *backend* hands back, which is always root-absolute because it cannot know where the SPA is mounted: uploaded floor-plan URLs already stored in a canvas are resolved at render time, so plans predating the move keep loading. The OIDC callback used to redirect to `/`, dropping subpath users at the origin root after login; it now reads the prefix back out of `OIDC_REDIRECT_URI`. The default is `/`, and stays a no-op there by construction: every helper returns the string it returned before, the root build output is unchanged and both nginx sites are byte-identical to what shipped — the Docker image copies `docker/nginx.conf` verbatim and the installer keeps its original heredoc. Only a non-default prefix takes the generated config. Those generated configs use `root`, never `alias`, since `alias` plus `try_files` mis-resolves `$uri` — the Docker build lands the bundle in the matching subdirectory, and the installer symlinks it under `/var/www/homelable`. Both accept either reverse-proxy style, prefix forwarded intact or already stripped, with no redirect loop between them, and `absolute_redirect off` stops the no-slash 301 from eating the port. Closes #334. ha-relevant: maybe
183 lines
6.9 KiB
TypeScript
183 lines
6.9 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { ViewportPortal, useReactFlow, useStore } from '@xyflow/react'
|
|
import { useCanvasStore } from '@/stores/canvasStore'
|
|
import { resolveServerPath } from '@/utils/basePath'
|
|
|
|
interface ResizeState {
|
|
startMouseX: number
|
|
startMouseY: number
|
|
startX: number
|
|
startY: number
|
|
startW: number
|
|
startH: number
|
|
edges: Set<'n' | 's' | 'e' | 'w'>
|
|
}
|
|
|
|
/**
|
|
* Floor plan background rendered INSIDE the React Flow viewport (via
|
|
* ViewportPortal) so it pans and zooms together with the nodes. Position and
|
|
* size are stored in flow coordinates.
|
|
*
|
|
* It always sits at the bottom of the canvas (behind nodes and edges). When
|
|
* unlocked it can still be grabbed/resized in areas not covered by a node;
|
|
* resize handles appear only while it is selected. Double-clicking an unlocked
|
|
* plan opens its edit modal.
|
|
*/
|
|
export function FloorMapLayer() {
|
|
const floorMap = useCanvasStore((s) => s.floorMap)
|
|
const updateFloorMap = useCanvasStore((s) => s.updateFloorMap)
|
|
const requestFloorMapEdit = useCanvasStore((s) => s.requestFloorMapEdit)
|
|
const { screenToFlowPosition } = useReactFlow()
|
|
const zoom = useStore((s) => s.transform[2])
|
|
|
|
const resizeRef = useRef<ResizeState | null>(null)
|
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
const [selected, setSelected] = useState(false)
|
|
|
|
const locked = floorMap?.locked ?? false
|
|
|
|
// While selected (and unlocked), deselect on any click outside the plan.
|
|
// A locked plan can't be selected, and handles/edit are gated on !locked, so
|
|
// a residual selection is harmless.
|
|
useEffect(() => {
|
|
if (locked || !selected) return
|
|
const onDocDown = (ev: MouseEvent) => {
|
|
if (!wrapperRef.current?.contains(ev.target as Node)) setSelected(false)
|
|
}
|
|
document.addEventListener('mousedown', onDocDown)
|
|
return () => document.removeEventListener('mousedown', onDocDown)
|
|
}, [selected, locked])
|
|
|
|
const onDragStart = useCallback((e: React.MouseEvent) => {
|
|
if (!floorMap) return
|
|
e.stopPropagation()
|
|
setSelected(true)
|
|
const startX = e.clientX
|
|
const startY = e.clientY
|
|
const origPosX = floorMap.posX
|
|
const origPosY = floorMap.posY
|
|
|
|
const onMove = (ev: MouseEvent) => {
|
|
const start = screenToFlowPosition({ x: startX, y: startY })
|
|
const cur = screenToFlowPosition({ x: ev.clientX, y: ev.clientY })
|
|
updateFloorMap({ posX: origPosX + (cur.x - start.x), posY: origPosY + (cur.y - start.y) })
|
|
}
|
|
const onUp = () => {
|
|
window.removeEventListener('mousemove', onMove)
|
|
window.removeEventListener('mouseup', onUp)
|
|
}
|
|
window.addEventListener('mousemove', onMove)
|
|
window.addEventListener('mouseup', onUp)
|
|
}, [floorMap, updateFloorMap, screenToFlowPosition])
|
|
|
|
const onResizeStart = useCallback((e: React.MouseEvent, edges: Set<'n' | 's' | 'e' | 'w'>) => {
|
|
if (!floorMap) return
|
|
e.stopPropagation()
|
|
resizeRef.current = {
|
|
startMouseX: e.clientX,
|
|
startMouseY: e.clientY,
|
|
startX: floorMap.posX,
|
|
startY: floorMap.posY,
|
|
startW: floorMap.width,
|
|
startH: floorMap.height,
|
|
edges,
|
|
}
|
|
|
|
const onMove = (ev: MouseEvent) => {
|
|
const rs = resizeRef.current
|
|
if (!rs) return
|
|
const start = screenToFlowPosition({ x: rs.startMouseX, y: rs.startMouseY })
|
|
const cur = screenToFlowPosition({ x: ev.clientX, y: ev.clientY })
|
|
const dx = cur.x - start.x
|
|
const dy = cur.y - start.y
|
|
let x = rs.startX, y = rs.startY, w = rs.startW, h = rs.startH
|
|
if (rs.edges.has('w')) { x += dx; w -= dx }
|
|
if (rs.edges.has('e')) w += dx
|
|
if (rs.edges.has('n')) { y += dy; h -= dy }
|
|
if (rs.edges.has('s')) h += dy
|
|
const MIN = 80
|
|
if (w < MIN) {
|
|
if (rs.edges.has('w')) x = rs.startX + rs.startW - MIN
|
|
w = MIN
|
|
}
|
|
if (h < MIN) {
|
|
if (rs.edges.has('n')) y = rs.startY + rs.startH - MIN
|
|
h = MIN
|
|
}
|
|
updateFloorMap({ posX: x, posY: y, width: w, height: h })
|
|
}
|
|
const onUp = () => {
|
|
resizeRef.current = null
|
|
window.removeEventListener('mousemove', onMove)
|
|
window.removeEventListener('mouseup', onUp)
|
|
}
|
|
window.addEventListener('mousemove', onMove)
|
|
window.addEventListener('mouseup', onUp)
|
|
}, [floorMap, updateFloorMap, screenToFlowPosition])
|
|
|
|
if (!floorMap || !floorMap.enabled) return null
|
|
|
|
const { imageData, posX, posY, width, height, opacity } = floorMap
|
|
|
|
// Handles live in flow space, so counter-scale by zoom to keep a ~constant
|
|
// on-screen size regardless of the current zoom level.
|
|
const hsz = 10 / zoom
|
|
const half = hsz / 2
|
|
const hs: React.CSSProperties = {
|
|
position: 'absolute',
|
|
width: hsz,
|
|
height: hsz,
|
|
background: '#00d4ff',
|
|
border: `${2 / zoom}px solid #0d1117`,
|
|
borderRadius: 2 / zoom,
|
|
zIndex: 10,
|
|
}
|
|
|
|
return (
|
|
<ViewportPortal>
|
|
<div
|
|
ref={wrapperRef}
|
|
style={{
|
|
position: 'absolute',
|
|
left: posX,
|
|
top: posY,
|
|
width,
|
|
height,
|
|
opacity,
|
|
// Always at the bottom of the canvas, behind nodes and edges.
|
|
zIndex: -1,
|
|
pointerEvents: locked ? 'none' : 'auto',
|
|
cursor: locked ? 'default' : 'move',
|
|
}}
|
|
onMouseDown={locked ? undefined : onDragStart}
|
|
onDoubleClick={locked ? undefined : (e) => { e.stopPropagation(); requestFloorMapEdit() }}
|
|
>
|
|
<img
|
|
src={resolveServerPath(imageData)}
|
|
alt="Floor plan"
|
|
draggable={false}
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'fill',
|
|
pointerEvents: 'none',
|
|
userSelect: 'none',
|
|
}}
|
|
/>
|
|
{!locked && selected && (
|
|
<>
|
|
<div style={{ ...hs, cursor: 'nw-resize', top: -half, left: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['n','w']))} />
|
|
<div style={{ ...hs, cursor: 'n-resize', top: -half, left: '50%', marginLeft: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['n']))} />
|
|
<div style={{ ...hs, cursor: 'ne-resize', top: -half, right: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['n','e']))} />
|
|
<div style={{ ...hs, cursor: 'e-resize', top: '50%', marginTop: -half, right: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['e']))} />
|
|
<div style={{ ...hs, cursor: 'se-resize', bottom: -half, right: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['s','e']))} />
|
|
<div style={{ ...hs, cursor: 's-resize', bottom: -half, left: '50%', marginLeft: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['s']))} />
|
|
<div style={{ ...hs, cursor: 'sw-resize', bottom: -half, left: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['s','w']))} />
|
|
<div style={{ ...hs, cursor: 'w-resize', top: '50%', marginTop: -half, left: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['w']))} />
|
|
</>
|
|
)}
|
|
</div>
|
|
</ViewportPortal>
|
|
)
|
|
}
|