diff --git a/backend/app/api/routes/designs.py b/backend/app/api/routes/designs.py index bed6d9e..ce67ab6 100644 --- a/backend/app/api/routes/designs.py +++ b/backend/app/api/routes/designs.py @@ -1,14 +1,21 @@ +import uuid + from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user from app.db.database import get_db from app.db.models import CanvasState, Design, Edge, Node -from app.schemas.designs import DesignCreate, DesignResponse, DesignUpdate +from app.schemas.designs import DesignCopy, DesignCreate, DesignResponse, DesignUpdate router = APIRouter() +# Node.type values that are canvas annotations rather than real devices. Kept in +# sync with the frontend (Sidebar counts, canvasSerializer types). +_GROUP_TYPE = "groupRect" +_TEXT_TYPE = "text" + @router.get("", response_model=list[DesignResponse]) async def list_designs( @@ -16,7 +23,31 @@ async def list_designs( _: str = Depends(get_current_user), ) -> list[DesignResponse]: designs = (await db.execute(select(Design).order_by(Design.created_at))).scalars().all() - return [DesignResponse.model_validate(d) for d in designs] + # One grouped query for all designs → node/group/text counts per design. + rows = ( + await db.execute(select(Node.design_id, Node.type, func.count()).group_by(Node.design_id, Node.type)) + ).all() + counts: dict[str, dict[str, int]] = {} + for design_id, node_type, count in rows: + if design_id is None: + continue + bucket = counts.setdefault(design_id, {"node": 0, "group": 0, "text": 0}) + if node_type == _GROUP_TYPE: + bucket["group"] += count + elif node_type == _TEXT_TYPE: + bucket["text"] += count + else: + bucket["node"] += count + + result = [] + for d in designs: + resp = DesignResponse.model_validate(d) + c = counts.get(d.id, {"node": 0, "group": 0, "text": 0}) + resp.node_count = c["node"] + resp.group_count = c["group"] + resp.text_count = c["text"] + result.append(resp) + return result @router.post("", response_model=DesignResponse, status_code=201) @@ -35,6 +66,73 @@ async def create_design( return DesignResponse.model_validate(design) +@router.post("/{source_id}/copy", response_model=DesignResponse, status_code=201) +async def copy_design( + source_id: str, + body: DesignCopy, + db: AsyncSession = Depends(get_db), + _: str = Depends(get_current_user), +) -> DesignResponse: + """Create a new design that deep-copies the source's nodes, edges and canvas state.""" + source = await db.get(Design, source_id) + if not source: + raise HTTPException(404, "Source design not found") + + new_design = Design(name=body.name, icon=body.icon, design_type=source.design_type) + db.add(new_design) + await db.flush() + + src_nodes = (await db.execute(select(Node).where(Node.design_id == source_id))).scalars().all() + src_edges = (await db.execute(select(Edge).where(Edge.design_id == source_id))).scalars().all() + + # New id per source node so edges and parent links can be re-pointed at the copy. + id_map = {n.id: str(uuid.uuid4()) for n in src_nodes} + + # Columns we set explicitly or let the DB default — never copy verbatim. + node_skip = {"id", "design_id", "parent_id", "created_at", "updated_at"} + for n in src_nodes: + cols = {c.name: getattr(n, c.name) for c in Node.__table__.columns if c.name not in node_skip} + db.add(Node(id=id_map[n.id], design_id=new_design.id, parent_id=None, **cols)) + await db.flush() # nodes must exist before we wire self-referential parent_id + + # Second pass: re-point parent links inside the copy. + for n in src_nodes: + if n.parent_id and n.parent_id in id_map: + child = await db.get(Node, id_map[n.id]) + if child: + child.parent_id = id_map[n.parent_id] + + edge_skip = {"id", "design_id", "source", "target", "created_at"} + for e in src_edges: + # Skip edges whose endpoints aren't part of this design (dangling FKs). + if e.source not in id_map or e.target not in id_map: + continue + cols = {c.name: getattr(e, c.name) for c in Edge.__table__.columns if c.name not in edge_skip} + db.add( + Edge( + id=str(uuid.uuid4()), + design_id=new_design.id, + source=id_map[e.source], + target=id_map[e.target], + **cols, + ) + ) + + # Copy canvas state (viewport, custom style, and the floor plan carried in viewport). + src_state = await db.get(CanvasState, source_id) + db.add( + CanvasState( + design_id=new_design.id, + viewport=src_state.viewport if src_state else {}, + custom_style=src_state.custom_style if src_state else None, + ) + ) + + await db.commit() + await db.refresh(new_design) + return DesignResponse.model_validate(new_design) + + @router.put("/{design_id}", response_model=DesignResponse) async def update_design( design_id: str, diff --git a/backend/app/schemas/designs.py b/backend/app/schemas/designs.py index 32ae107..f5c9f55 100644 --- a/backend/app/schemas/designs.py +++ b/backend/app/schemas/designs.py @@ -16,6 +16,13 @@ class DesignUpdate(BaseModel): icon: str | None = None +class DesignCopy(BaseModel): + """Create a new design by deep-copying an existing one's canvas.""" + + name: str + icon: str = "dashboard" + + class DesignResponse(BaseModel): id: str name: str @@ -23,5 +30,10 @@ class DesignResponse(BaseModel): icon: str | None = None created_at: datetime updated_at: datetime + # Populated by list_designs so the "copy from existing" picker can show what + # each canvas holds. None on create/update/copy responses (not computed there). + node_count: int | None = None + group_count: int | None = None + text_count: int | None = None model_config = {"from_attributes": True} diff --git a/backend/tests/test_designs.py b/backend/tests/test_designs.py index f70dbeb..3785da5 100644 --- a/backend/tests/test_designs.py +++ b/backend/tests/test_designs.py @@ -100,6 +100,118 @@ async def test_list_returns_created_designs_ordered(client: AsyncClient, headers assert ids == [a["id"], b["id"]] +# ── counts in list ────────────────────────────────────────────────────────── + +async def test_list_includes_node_group_text_counts(client: AsyncClient, headers: dict): + design = await _create(client, headers, name="Counted") + server = node_payload(label="S", type="server") + group = node_payload(label="G", type="groupRect") + text = node_payload(label="T", type="text") + save = await client.post( + "/api/v1/canvas/save", + json={"nodes": [server, group, text], "edges": [], "viewport": {}, "design_id": design["id"]}, + headers=headers, + ) + assert save.status_code == 200 + listed = (await client.get("/api/v1/designs", headers=headers)).json() + d = next(x for x in listed if x["id"] == design["id"]) + assert d["node_count"] == 1 + assert d["group_count"] == 1 + assert d["text_count"] == 1 + + +async def test_list_counts_zero_for_empty_design(client: AsyncClient, headers: dict): + design = await _create(client, headers, name="Empty") + listed = (await client.get("/api/v1/designs", headers=headers)).json() + d = next(x for x in listed if x["id"] == design["id"]) + assert d["node_count"] == 0 + assert d["group_count"] == 0 + assert d["text_count"] == 0 + + +# ── copy ────────────────────────────────────────────────────────────────────── + +async def test_copy_requires_auth(client: AsyncClient): + res = await client.post(f"/api/v1/designs/{uuid.uuid4()}/copy", json={"name": "X"}) + assert res.status_code == 401 + + +async def test_copy_missing_source_returns_404(client: AsyncClient, headers: dict): + res = await client.post(f"/api/v1/designs/{uuid.uuid4()}/copy", json={"name": "X"}, headers=headers) + assert res.status_code == 404 + + +async def test_copy_duplicates_nodes_edges_and_remaps_ids(client: AsyncClient, headers: dict): + source = await _create(client, headers, name="Source", icon="server") + n1 = node_payload(label="A") + n2 = node_payload(label="B") + e1 = edge_payload(n1["id"], n2["id"], label="link") + save = await client.post( + "/api/v1/canvas/save", + json={"nodes": [n1, n2], "edges": [e1], "viewport": {"x": 5, "y": 6, "zoom": 2}, "design_id": source["id"]}, + headers=headers, + ) + assert save.status_code == 200 + + res = await client.post( + f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy", "icon": "network"}, headers=headers, + ) + assert res.status_code == 201, res.text + copy = res.json() + assert copy["name"] == "Copy" + assert copy["icon"] == "network" + assert copy["design_type"] == "network" + assert copy["id"] != source["id"] + + # Copied canvas has the same shape but fresh node ids, and edge re-pointed. + canvas = (await client.get("/api/v1/canvas", params={"design_id": copy["id"]}, headers=headers)).json() + assert {n["label"] for n in canvas["nodes"]} == {"A", "B"} + copied_ids = {n["id"] for n in canvas["nodes"]} + assert copied_ids.isdisjoint({n1["id"], n2["id"]}) + assert len(canvas["edges"]) == 1 + edge = canvas["edges"][0] + assert edge["source"] in copied_ids + assert edge["target"] in copied_ids + assert edge["label"] == "link" + assert canvas["viewport"] == {"x": 5, "y": 6, "zoom": 2} + + +async def test_copy_remaps_parent_child_relationship(client: AsyncClient, headers: dict): + source = await _create(client, headers, name="Nested") + parent = node_payload(label="P", type="proxmox", container_mode=True) + child = node_payload(label="C", type="vm", parent_id=parent["id"]) + save = await client.post( + "/api/v1/canvas/save", + json={"nodes": [parent, child], "edges": [], "viewport": {}, "design_id": source["id"]}, + headers=headers, + ) + assert save.status_code == 200 + + res = await client.post(f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy"}, headers=headers) + assert res.status_code == 201 + copy = res.json() + + canvas = (await client.get("/api/v1/canvas", params={"design_id": copy["id"]}, headers=headers)).json() + by_label = {n["label"]: n for n in canvas["nodes"]} + # Child's parent_id points at the COPIED parent, not the original. + assert by_label["C"]["parent_id"] == by_label["P"]["id"] + assert by_label["C"]["parent_id"] != parent["id"] + + +async def test_copy_leaves_source_untouched(client: AsyncClient, headers: dict): + source = await _create(client, headers, name="Source") + n1 = node_payload(label="A") + await client.post( + "/api/v1/canvas/save", + json={"nodes": [n1], "edges": [], "viewport": {}, "design_id": source["id"]}, + headers=headers, + ) + await client.post(f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy"}, headers=headers) + src_canvas = (await client.get("/api/v1/canvas", params={"design_id": source["id"]}, headers=headers)).json() + assert len(src_canvas["nodes"]) == 1 + assert src_canvas["nodes"][0]["id"] == n1["id"] + + # ── update ──────────────────────────────────────────────────────────────────── async def test_update_design_renames(client: AsyncClient, headers: dict): diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9d64483..94a6af0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -169,6 +169,8 @@ export const designsApi = { list: () => api.get('/designs'), create: (data: { name: string; icon?: string; design_type?: string }) => api.post('/designs', data), + copy: (sourceId: string, data: { name: string; icon?: string }) => + api.post(`/designs/${sourceId}/copy`, data), update: (id: string, data: { name?: string; icon?: string }) => api.put(`/designs/${id}`, data), delete: (id: string) => api.delete(`/designs/${id}`), diff --git a/frontend/src/components/modals/DesignModal.tsx b/frontend/src/components/modals/DesignModal.tsx index 3c290f3..5cf08f4 100644 --- a/frontend/src/components/modals/DesignModal.tsx +++ b/frontend/src/components/modals/DesignModal.tsx @@ -3,8 +3,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from ' 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' +import { DESIGN_ICONS, DEFAULT_DESIGN_ICON, resolveDesignIcon } from '@/utils/designIcons' +import type { Design, FloorMapConfig } from '@/types' export interface DesignFormData { name: string @@ -15,6 +15,11 @@ export interface DesignFormData { * 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 { @@ -34,6 +39,11 @@ interface DesignModalProps { * base64. Rejects on failure (caller surfaces the error). */ onUploadImage?: (file: File) => Promise + /** + * 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({ @@ -46,10 +56,16 @@ export function DesignModal({ 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(sourceDesigns[0]?.id ?? '') + // Floor plan state (only used when showFloorMap) const [imageData, setImageData] = useState(initialFloorMap?.imageData ?? '') const [width, setWidth] = useState(initialFloorMap?.width ?? 800) @@ -85,7 +101,11 @@ export function DesignModal({ 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 ? { @@ -153,6 +173,68 @@ export function DesignModal({ + {canCopy && ( +
+
+ + +
+ + {fromExisting && ( +
+ {sourceDesigns.map((d) => { + const Icon = resolveDesignIcon(d.icon) + const selected = d.id === sourceId + return ( + + ) + })} +
+ )} +
+ )} + {showFloorMap && (
diff --git a/frontend/src/components/modals/__tests__/DesignModal.test.tsx b/frontend/src/components/modals/__tests__/DesignModal.test.tsx index 688f092..da5fd5b 100644 --- a/frontend/src/components/modals/__tests__/DesignModal.test.tsx +++ b/frontend/src/components/modals/__tests__/DesignModal.test.tsx @@ -65,6 +65,60 @@ describe('DesignModal', () => { expect(onSubmit).not.toHaveBeenCalled() }) + describe('copy from existing', () => { + const sourceDesigns = [ + { id: 's1', name: 'Home Net', icon: 'network', design_type: 'network' as const, + created_at: '', updated_at: '', node_count: 4, group_count: 1, text_count: 2 }, + { id: 's2', name: 'Lab', icon: 'server', design_type: 'network' as const, + created_at: '', updated_at: '', node_count: 7, group_count: 0, text_count: 0 }, + ] + + it('offers no copy option when there are no source designs', () => { + renderModal({ sourceDesigns: [] }) + expect(screen.queryByRole('button', { name: 'Copy from existing' })).toBeNull() + }) + + it('shows the source list with counts once "Copy from existing" is chosen', () => { + renderModal({ sourceDesigns }) + // Hidden until the user opts into copying. + expect(screen.queryByText('Home Net')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' })) + expect(screen.getByText('Home Net')).toBeDefined() + expect(screen.getByText('4 nodes · 1 groups · 2 text')).toBeDefined() + expect(screen.getByText('7 nodes · 0 groups · 0 text')).toBeDefined() + }) + + it('includes sourceId (first design by default) on submit', () => { + const { onSubmit } = renderModal({ sourceDesigns }) + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Copy of Home' } }) + fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' })) + fireEvent.click(screen.getByRole('button', { name: 'Create' })) + expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Home', icon: DEFAULT_DESIGN_ICON, sourceId: 's1' }) + }) + + it('includes the picked sourceId on submit', () => { + const { onSubmit } = renderModal({ sourceDesigns }) + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Copy of Lab' } }) + fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' })) + fireEvent.click(screen.getByRole('radio', { name: /Lab/ })) + fireEvent.click(screen.getByRole('button', { name: 'Create' })) + expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Lab', icon: DEFAULT_DESIGN_ICON, sourceId: 's2' }) + }) + + it('omits sourceId when the blank option is kept', () => { + const { onSubmit } = renderModal({ sourceDesigns }) + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Fresh' } }) + fireEvent.click(screen.getByRole('button', { name: 'Create' })) + expect(onSubmit).toHaveBeenCalledWith({ name: 'Fresh', icon: DEFAULT_DESIGN_ICON }) + expect('sourceId' in onSubmit.mock.calls[0][0]).toBe(false) + }) + + it('is hidden in edit mode (floor-plan shown)', () => { + renderModal({ sourceDesigns, showFloorMap: true, initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON } }) + expect(screen.queryByRole('button', { name: 'Copy from existing' })).toBeNull() + }) + }) + describe('floor plan section', () => { const fm = { imageData: 'data:image/png;base64,abc', diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 2d48cda..5e04f35 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -56,9 +56,17 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee if (!designModal) return try { if (designModal.mode === 'create') { - const created = STANDALONE - ? standaloneStorage.createDesign(data.name, data.icon) - : (await designsApi.create({ name: data.name, icon: data.icon })).data + let created: Design + if (data.sourceId) { + // Copy from an existing canvas (nodes, edges, viewport, floor plan). + created = STANDALONE + ? standaloneStorage.copyDesign(data.sourceId, data.name, data.icon) + : (await designsApi.copy(data.sourceId, { name: data.name, icon: data.icon })).data + } else { + created = STANDALONE + ? standaloneStorage.createDesign(data.name, data.icon) + : (await designsApi.create({ name: data.name, icon: data.icon })).data + } addDesign(created) } else if (designModal.design) { const updated = STANDALONE @@ -306,6 +314,11 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee showFloorMap={!STANDALONE && isActiveEdit} initialFloorMap={!STANDALONE && isActiveEdit ? floorMap : null} onUploadImage={handleUploadImage} + sourceDesigns={ + designModal?.mode === 'create' + ? (STANDALONE ? standaloneStorage.listDesignsWithCounts() : designs) + : [] + } /> ) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index dc5150a..9df87f4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -8,6 +8,10 @@ export interface Design { icon?: string | null created_at: string updated_at: string + /** Populated by the design list endpoint for the "copy from existing" picker. */ + node_count?: number | null + group_count?: number | null + text_count?: number | null } export type NodeType = diff --git a/frontend/src/utils/__tests__/standaloneStorage.test.ts b/frontend/src/utils/__tests__/standaloneStorage.test.ts index e60c651..be3eb5f 100644 --- a/frontend/src/utils/__tests__/standaloneStorage.test.ts +++ b/frontend/src/utils/__tests__/standaloneStorage.test.ts @@ -16,6 +16,7 @@ import type { Node, Edge } from '@xyflow/react' import type { NodeData, EdgeData } from '@/types' import { useCanvasStore } from '@/stores/canvasStore' import { demoNodes, demoEdges } from '@/utils/demoData' +import * as standaloneStorage from '@/utils/standaloneStorage' const STORAGE_KEY = 'homelable_canvas' @@ -261,3 +262,75 @@ describe('Demo data (standalone fallback)', () => { expect(router.position).toEqual({ x: 300, y: 140 }) }) }) + +describe('Standalone copy-from-existing', () => { + beforeEach(() => localStorage.clear()) + + function typedNode(id: string, type: string): Node { + return { + id, + type, + position: { x: 0, y: 0 }, + data: { label: id, type: type as NodeData['type'], status: 'unknown', services: [] }, + } + } + + it('designCounts buckets node / group / text types', () => { + const d = standaloneStorage.createDesign('Src') + standaloneStorage.saveCanvas(d.id, { + nodes: [typedNode('a', 'server'), typedNode('g', 'groupRect'), typedNode('t', 'text'), typedNode('b', 'router')], + edges: [], + }) + expect(standaloneStorage.designCounts(d.id)).toEqual({ node_count: 2, group_count: 1, text_count: 1 }) + }) + + it('designCounts returns zeros for a never-saved design', () => { + const d = standaloneStorage.createDesign('Empty') + expect(standaloneStorage.designCounts(d.id)).toEqual({ node_count: 0, group_count: 0, text_count: 0 }) + }) + + it('listDesignsWithCounts attaches counts to every design', () => { + const a = standaloneStorage.createDesign('A') + standaloneStorage.saveCanvas(a.id, { nodes: [typedNode('a', 'server')], edges: [] }) + standaloneStorage.createDesign('B') + const listed = standaloneStorage.listDesignsWithCounts() + expect(listed.find((x) => x.id === a.id)?.node_count).toBe(1) + expect(listed.find((x) => x.name === 'B')?.node_count).toBe(0) + }) + + it('copyDesign clones the source canvas into a new design', () => { + const src = standaloneStorage.createDesign('Source', 'server') + const edge: Edge = { id: 'e1', source: 'a', target: 'b', data: { type: 'ethernet' } as EdgeData } + standaloneStorage.saveCanvas(src.id, { nodes: [typedNode('a', 'server'), typedNode('b', 'router')], edges: [edge] }) + + const copy = standaloneStorage.copyDesign(src.id, 'Copy', 'network') + expect(copy.id).not.toBe(src.id) + expect(copy.name).toBe('Copy') + + const copied = standaloneStorage.loadCanvas(copy.id)! + expect(copied.nodes.map((n) => n.id).sort()).toEqual(['a', 'b']) + expect(copied.edges).toHaveLength(1) + // Source untouched. + expect(standaloneStorage.loadCanvas(src.id)!.nodes).toHaveLength(2) + }) + + it('copyDesign detaches the copy (mutating it leaves the source intact)', () => { + const src = standaloneStorage.createDesign('Source') + standaloneStorage.saveCanvas(src.id, { nodes: [typedNode('a', 'server')], edges: [] }) + const copy = standaloneStorage.copyDesign(src.id, 'Copy') + + const copied = standaloneStorage.loadCanvas(copy.id)! + copied.nodes.push(typedNode('z', 'server')) + standaloneStorage.saveCanvas(copy.id, copied) + + expect(standaloneStorage.loadCanvas(src.id)!.nodes).toHaveLength(1) + expect(standaloneStorage.loadCanvas(copy.id)!.nodes).toHaveLength(2) + }) + + it('copyDesign on a never-saved source yields an empty new design', () => { + const src = standaloneStorage.createDesign('Bare') + const copy = standaloneStorage.copyDesign(src.id, 'Copy') + expect(standaloneStorage.loadCanvas(copy.id)).toBeNull() + expect(standaloneStorage.listDesigns().some((d) => d.id === copy.id)).toBe(true) + }) +}) diff --git a/frontend/src/utils/standaloneStorage.ts b/frontend/src/utils/standaloneStorage.ts index d11619b..2cb629e 100644 --- a/frontend/src/utils/standaloneStorage.ts +++ b/frontend/src/utils/standaloneStorage.ts @@ -90,6 +90,41 @@ export function createDesign(name: string, icon?: string | null, design_type: De return design } +const GROUP_TYPE = 'groupRect' +const TEXT_TYPE = 'text' + +/** Node/group/text counts for a design's saved canvas (0s when never saved). */ +export function designCounts(designId: string): Pick { + const canvas = loadCanvas(designId) + const nodes = canvas?.nodes ?? [] + let group = 0 + let text = 0 + let node = 0 + for (const n of nodes) { + if (n.data?.type === GROUP_TYPE) group++ + else if (n.data?.type === TEXT_TYPE) text++ + else node++ + } + return { node_count: node, group_count: group, text_count: text } +} + +/** Return the design list with per-canvas counts filled in (for the copy picker). */ +export function listDesignsWithCounts(): Design[] { + return listDesigns().map((d) => ({ ...d, ...designCounts(d.id) })) +} + +/** Deep-copy a design's canvas into a new design. Returns the new design. */ +export function copyDesign(sourceId: string, name: string, icon?: string | null): Design { + const design = createDesign(name, icon) + const source = loadCanvas(sourceId) + if (source) { + // localStorage canvas already stores React Flow nodes/edges by value; a fresh + // JSON round-trip is enough to detach the copy from the source. + saveCanvas(design.id, JSON.parse(JSON.stringify(source)) as StandaloneCanvas) + } + return design +} + export function updateDesign(id: string, patch: Partial>): Design | null { const designs = listDesigns() const idx = designs.findIndex((d) => d.id === id)