Files
homelable/frontend/src/components/modals/DesignModal.tsx
T
Pouzor 0b4bd5680d feat: create a new canvas by copying an existing one
Add a 'Copy from existing' option to the New Canvas modal. It lists every
canvas with node/group/text counts; picking one deep-copies its nodes, edges,
parent/child links and canvas state (viewport, custom style, floor plan) into
a fresh design.

Backend: POST /designs/{source_id}/copy remaps node ids, re-points edges and
parent links, and clones canvas state; GET /designs now returns per-design
counts for the picker. Standalone mode clones the localStorage canvas.

Closes #216

ha-relevant: maybe
2026-07-07 15:53:42 +02:00

310 lines
13 KiB
TypeScript

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, resolveDesignIcon } from '@/utils/designIcons'
import type { Design, 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
/**
* When set, create the new canvas by deep-copying this existing design instead
* of starting blank. Only offered in create mode with `sourceDesigns` present.
*/
sourceId?: string
}
interface DesignModalProps {
open: boolean
onClose: () => void
onSubmit: (data: DesignFormData) => void
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>
/**
* Existing designs offered as a copy source (create mode only). When non-empty,
* a "Copy from existing" option appears; choosing it clones the picked canvas.
*/
sourceDesigns?: Design[]
}
export function DesignModal({
open,
onClose,
onSubmit,
initial,
title = 'New Canvas',
submitLabel = 'Create',
showFloorMap = false,
initialFloorMap = null,
onUploadImage,
sourceDesigns = [],
}: DesignModalProps) {
const [name, setName] = useState(initial?.name ?? '')
const [icon, setIcon] = useState(initial?.icon ?? DEFAULT_DESIGN_ICON)
// "Copy from existing" is create-mode only (no floor-plan section shown).
const canCopy = !showFloorMap && sourceDesigns.length > 0
const [fromExisting, setFromExisting] = useState(false)
const [sourceId, setSourceId] = useState<string>(sourceDesigns[0]?.id ?? '')
// 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
if (canCopy && fromExisting && !sourceId) return
const data: DesignFormData = { name: trimmed, icon }
if (canCopy && fromExisting && sourceId) {
data.sourceId = sourceId
}
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 max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="design-name">Name</Label>
<Input
id="design-name"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit() }}
placeholder="e.g. Home Network, Rack Power"
autoFocus
/>
</div>
<div className="space-y-1.5">
<Label>Icon</Label>
<div className="grid grid-cols-8 gap-1.5">
{DESIGN_ICONS.map((entry) => {
const Icon = entry.icon
const selected = entry.key === icon
return (
<button
key={entry.key}
type="button"
aria-label={entry.label}
aria-pressed={selected}
title={entry.label}
onClick={() => setIcon(entry.key)}
className={`flex items-center justify-center aspect-square rounded-md border transition-colors cursor-pointer ${
selected
? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]'
: 'border-border text-muted-foreground hover:text-foreground hover:border-[#30363d]'
}`}
>
<Icon size={16} />
</button>
)
})}
</div>
</div>
{canCopy && (
<div className="space-y-2 pt-2 border-t border-border">
<div className="grid grid-cols-2 gap-1.5">
<button
type="button"
aria-pressed={!fromExisting}
onClick={() => setFromExisting(false)}
className={`text-xs rounded-md border py-2 transition-colors cursor-pointer ${
!fromExisting
? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]'
: 'border-border text-muted-foreground hover:text-foreground'
}`}
>
Blank canvas
</button>
<button
type="button"
aria-pressed={fromExisting}
onClick={() => setFromExisting(true)}
className={`text-xs rounded-md border py-2 transition-colors cursor-pointer ${
fromExisting
? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]'
: 'border-border text-muted-foreground hover:text-foreground'
}`}
>
Copy from existing
</button>
</div>
{fromExisting && (
<div className="space-y-1 max-h-48 overflow-y-auto pr-1" role="radiogroup" aria-label="Source canvas">
{sourceDesigns.map((d) => {
const Icon = resolveDesignIcon(d.icon)
const selected = d.id === sourceId
return (
<button
key={d.id}
type="button"
role="radio"
aria-checked={selected}
onClick={() => setSourceId(d.id)}
className={`w-full flex items-center gap-2.5 rounded-md border px-2.5 py-2 text-left transition-colors cursor-pointer ${
selected
? 'border-[#00d4ff] bg-[#00d4ff]/10'
: 'border-border hover:border-[#30363d]'
}`}
>
<Icon size={16} className={selected ? 'text-[#00d4ff]' : 'text-muted-foreground'} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{d.name}</div>
<div className="text-xs text-muted-foreground">
{d.node_count ?? 0} nodes · {d.group_count ?? 0} groups · {d.text_count ?? 0} text
</div>
</div>
</button>
)
})}
</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>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={!name.trim()}>{submitLabel}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}