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:
@@ -1,13 +1,20 @@
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
||||
import type { FloorMapConfig } from '@/types'
|
||||
|
||||
export interface DesignFormData {
|
||||
name: string
|
||||
icon: string
|
||||
/**
|
||||
* Floor plan for THIS canvas. Only present when the floor-plan section is
|
||||
* shown (edit mode on the active canvas). `null` means "remove the floor
|
||||
* plan"; `undefined` means "leave it untouched".
|
||||
*/
|
||||
floorMap?: FloorMapConfig | null
|
||||
}
|
||||
|
||||
interface DesignModalProps {
|
||||
@@ -17,21 +24,91 @@ interface DesignModalProps {
|
||||
initial?: DesignFormData
|
||||
title?: string
|
||||
submitLabel?: string
|
||||
/** Show the floor-plan section (only meaningful when editing the active canvas). */
|
||||
showFloorMap?: boolean
|
||||
/** Current floor plan of the canvas being edited (position preserved on save). */
|
||||
initialFloorMap?: FloorMapConfig | null
|
||||
/**
|
||||
* Upload a selected image and resolve to its server URL. Required whenever
|
||||
* the floor-plan section is shown — images are stored server-side, never as
|
||||
* base64. Rejects on failure (caller surfaces the error).
|
||||
*/
|
||||
onUploadImage?: (file: File) => Promise<string>
|
||||
}
|
||||
|
||||
export function DesignModal({ open, onClose, onSubmit, initial, title = 'New Canvas', submitLabel = 'Create' }: DesignModalProps) {
|
||||
export function DesignModal({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
initial,
|
||||
title = 'New Canvas',
|
||||
submitLabel = 'Create',
|
||||
showFloorMap = false,
|
||||
initialFloorMap = null,
|
||||
onUploadImage,
|
||||
}: DesignModalProps) {
|
||||
const [name, setName] = useState(initial?.name ?? '')
|
||||
const [icon, setIcon] = useState(initial?.icon ?? DEFAULT_DESIGN_ICON)
|
||||
|
||||
// Floor plan state (only used when showFloorMap)
|
||||
const [imageData, setImageData] = useState(initialFloorMap?.imageData ?? '')
|
||||
const [width, setWidth] = useState(initialFloorMap?.width ?? 800)
|
||||
const [height, setHeight] = useState(initialFloorMap?.height ?? 600)
|
||||
const [opacity, setOpacity] = useState(initialFloorMap?.opacity ?? 0.8)
|
||||
const [locked, setLocked] = useState(initialFloorMap?.locked ?? false)
|
||||
const [enabled, setEnabled] = useState(initialFloorMap?.enabled ?? true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = '' // allow re-selecting the same file after a failure
|
||||
if (!file || !onUploadImage) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const url = await onUploadImage(file)
|
||||
setImageData(url)
|
||||
// Read natural dimensions from the served image (non-blocking).
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
setWidth(img.naturalWidth)
|
||||
setHeight(img.naturalHeight)
|
||||
}
|
||||
img.src = url
|
||||
} catch {
|
||||
// Caller surfaces the error toast; leave existing state untouched.
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
onSubmit({ name: trimmed, icon })
|
||||
const data: DesignFormData = { name: trimmed, icon }
|
||||
if (showFloorMap) {
|
||||
data.floorMap = imageData
|
||||
? {
|
||||
imageData,
|
||||
// Preserve position from the existing config; new plans start at 0,0.
|
||||
posX: initialFloorMap?.posX ?? 0,
|
||||
posY: initialFloorMap?.posY ?? 0,
|
||||
width,
|
||||
height,
|
||||
opacity,
|
||||
locked,
|
||||
enabled,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onSubmit(data)
|
||||
}
|
||||
|
||||
const hasImage = !!imageData
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -75,6 +152,69 @@ export function DesignModal({ open, onClose, onSubmit, initial, title = 'New Can
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFloorMap && (
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<Label>Floor Plan</Label>
|
||||
{!hasImage ? (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-2 border-2 border-dashed border-[#30363d] rounded-lg p-6 cursor-pointer hover:border-[#00d4ff]/50 transition-colors"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp" className="hidden" onChange={handleFile} />
|
||||
<span className="text-muted-foreground text-sm">{uploading ? 'Uploading…' : 'Click to select a floor plan image'}</span>
|
||||
<span className="text-muted-foreground/50 text-xs">PNG, JPEG or WebP · max 10 MB</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative rounded-lg overflow-hidden border border-[#30363d]" style={{ maxHeight: 160 }}>
|
||||
<img src={imageData} alt="Floor plan preview" className="w-full h-full object-contain" style={{ opacity }} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" className="cursor-pointer" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||||
{uploading ? 'Uploading…' : 'Replace Image'}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" className="cursor-pointer" onClick={() => setImageData('')}>
|
||||
Remove
|
||||
</Button>
|
||||
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp" className="hidden" onChange={handleFile} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Width (px)</Label>
|
||||
<Input type="number" value={width} onChange={(e) => setWidth(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Height (px)</Label>
|
||||
<Input type="number" value={height} onChange={(e) => setHeight(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Opacity: {Math.round(opacity * 100)}%</Label>
|
||||
<input
|
||||
type="range" min="0.05" max="1" step="0.05" value={opacity}
|
||||
onChange={(e) => setOpacity(Number(e.target.value))}
|
||||
className="w-full accent-[#00d4ff]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={locked} onChange={(e) => setLocked(e.target.checked)} className="accent-[#00d4ff] w-3.5 h-3.5" />
|
||||
<span className="text-xs text-muted-foreground">Lock position & size</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} className="accent-[#00d4ff] w-3.5 h-3.5" />
|
||||
<span className="text-xs text-muted-foreground">Show on canvas</span>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { FloorMapConfig } from '@/types'
|
||||
|
||||
interface FloorMapModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (config: FloorMapConfig) => void
|
||||
onRemove: () => void
|
||||
initial: FloorMapConfig | null
|
||||
}
|
||||
|
||||
export function FloorMapModal({ open, onClose, onSubmit, onRemove, initial }: FloorMapModalProps) {
|
||||
const [imageData, setImageData] = useState(initial?.imageData ?? '')
|
||||
const [width, setWidth] = useState(initial?.width ?? 800)
|
||||
const [height, setHeight] = useState(initial?.height ?? 600)
|
||||
const [opacity, setOpacity] = useState(initial?.opacity ?? 0.8)
|
||||
const [locked, setLocked] = useState(initial?.locked ?? false)
|
||||
const [enabled, setEnabled] = useState(initial?.enabled ?? true)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const dataUrl = ev.target?.result as string
|
||||
setImageData(dataUrl)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
setWidth(img.naturalWidth)
|
||||
setHeight(img.naturalHeight)
|
||||
}
|
||||
img.src = dataUrl
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!imageData) return
|
||||
onSubmit({
|
||||
imageData,
|
||||
posX: initial?.posX ?? 0,
|
||||
posY: initial?.posY ?? 0,
|
||||
width,
|
||||
height,
|
||||
opacity,
|
||||
locked,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
|
||||
const hasImage = !!imageData
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose() }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{initial ? 'Edit Floor Plan' : 'Import Floor Plan'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
{!hasImage ? (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-2 border-2 border-dashed border-[#30363d] rounded-lg p-8 cursor-pointer hover:border-[#00d4ff]/50 transition-colors"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp" className="hidden" onChange={handleFile} />
|
||||
<span className="text-muted-foreground text-sm">Click to select a floor plan image</span>
|
||||
<span className="text-muted-foreground/50 text-xs">PNG, JPEG or WebP</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative rounded-lg overflow-hidden border border-[#30363d]" style={{ maxHeight: 200 }}>
|
||||
<img src={imageData} alt="Floor plan preview" className="w-full h-full object-contain" style={{ opacity }} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" className="cursor-pointer" onClick={() => fileRef.current?.click()}>
|
||||
Replace Image
|
||||
</Button>
|
||||
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image.webp" className="hidden" onChange={handleFile} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Width (px)</Label>
|
||||
<Input type="number" value={width} onChange={(e) => setWidth(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Height (px)</Label>
|
||||
<Input type="number" value={height} onChange={(e) => setHeight(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Opacity: {Math.round(opacity * 100)}%</Label>
|
||||
<input
|
||||
type="range" min="0.05" max="1" step="0.05" value={opacity}
|
||||
onChange={(e) => setOpacity(Number(e.target.value))}
|
||||
className="w-full accent-[#00d4ff]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={locked} onChange={(e) => setLocked(e.target.checked)} className="accent-[#00d4ff] w-3.5 h-3.5" />
|
||||
<span className="text-xs text-muted-foreground">Lock position & size</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} className="accent-[#00d4ff] w-3.5 h-3.5" />
|
||||
<span className="text-xs text-muted-foreground">Show on canvas</span>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
||||
{hasImage && initial && (
|
||||
<Button size="sm" variant="destructive" className="cursor-pointer" onClick={onRemove}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button size="sm" variant="ghost" className="cursor-pointer" onClick={onClose}>Cancel</Button>
|
||||
<Button size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer" disabled={!hasImage} onClick={handleSubmit}>
|
||||
{initial ? 'Apply' : 'Import'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user