feat: floor plan viewport rendering, per-canvas config, server media upload

- Render floor plan inside React Flow ViewportPortal so it pans/zooms with
  nodes (was screen-fixed, desynced on pan/zoom); zoom-stable resize handles.
- Move floor plan config from the left panel into the canvas (design) edit
  modal; attach per-design and fix cross-design bleed on load.
- Store images via a new generic backend media endpoint (POST/GET/DELETE
  /api/v1/media) on disk under <data_dir>/uploads, not base64 in the canvas.
- Disable floor plans in standalone mode (no backend to upload/serve); drop
  base64 localStorage persistence. See ADR-001 in CLAUDE.md.
- Tests: backend media route, DesignModal floor plan + upload, store floorMap.

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-07-02 16:36:25 +02:00
parent 046c99e219
commit 1ed013bde2
16 changed files with 597 additions and 233 deletions
@@ -64,4 +64,108 @@ describe('DesignModal', () => {
expect(onClose).toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
})
describe('floor plan section', () => {
const fm = {
imageData: 'data:image/png;base64,abc',
posX: 40, posY: 60, width: 800, height: 600,
opacity: 0.8, locked: false, enabled: true,
}
it('is hidden by default and submit omits floorMap', () => {
const { onSubmit } = renderModal()
expect(screen.queryByText('Floor Plan')).toBeNull()
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'X' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'X', icon: DEFAULT_DESIGN_ICON })
expect('floorMap' in onSubmit.mock.calls[0][0]).toBe(false)
})
it('shows the section and preserves position while updating config', () => {
const { onSubmit } = renderModal({
showFloorMap: true,
initialFloorMap: fm,
initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON },
submitLabel: 'Save',
})
expect(screen.getByText('Floor Plan')).toBeDefined()
expect(screen.getByAltText('Floor plan preview')).toBeDefined()
// Toggle "Show on canvas" off.
const enabledBox = screen.getByLabelText('Show on canvas') as HTMLInputElement
fireEvent.click(enabledBox)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(onSubmit).toHaveBeenCalledWith({
name: 'Home',
icon: DEFAULT_DESIGN_ICON,
floorMap: { ...fm, enabled: false },
})
})
it('submits floorMap: null when the image is removed', () => {
const { onSubmit } = renderModal({
showFloorMap: true,
initialFloorMap: fm,
initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON },
submitLabel: 'Save',
})
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
expect(screen.queryByAltText('Floor plan preview')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(onSubmit).toHaveBeenCalledWith({
name: 'Home',
icon: DEFAULT_DESIGN_ICON,
floorMap: null,
})
})
it('uploads a chosen file and stores the returned server URL', async () => {
const onUploadImage = vi.fn().mockResolvedValue('/api/v1/media/deadbeef.png')
const { onSubmit } = renderModal({
showFloorMap: true,
initialFloorMap: null,
initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON },
submitLabel: 'Save',
onUploadImage,
})
const file = new File(['x'], 'plan.png', { type: 'image/png' })
const input = document.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files: [file] } })
await screen.findByAltText('Floor plan preview')
expect(onUploadImage).toHaveBeenCalledWith(file)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
const submitted = onSubmit.mock.calls[0][0]
expect(submitted.floorMap.imageData).toBe('/api/v1/media/deadbeef.png')
})
it('leaves state untouched when upload fails', async () => {
const onUploadImage = vi.fn().mockRejectedValue(new Error('boom'))
renderModal({
showFloorMap: true,
initialFloorMap: null,
initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON },
submitLabel: 'Save',
onUploadImage,
})
const file = new File(['x'], 'plan.png', { type: 'image/png' })
const input = document.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files: [file] } })
await vi.waitFor(() => expect(onUploadImage).toHaveBeenCalled())
expect(screen.queryByAltText('Floor plan preview')).toBeNull()
})
it('submits floorMap: null when shown but no image was chosen', () => {
const { onSubmit } = renderModal({
showFloorMap: true,
initialFloorMap: null,
submitLabel: 'Save',
})
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Empty' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Empty', icon: DEFAULT_DESIGN_ICON, floorMap: null })
})
})
})