Files
homelable/backend/app/api/routes/canvas.py
T
Pouzor babbcb1dc5 feat: add custom style theme with per-type node/edge style editor
- New 'custom' ThemeId with dedicated ThemeCard + pencil edit button
- CustomStyleModal: two-column editor for all node types (excl. groupRect/group) and edge types
  - Per node: border, background, icon color + opacity, default size (w/h)
  - Per edge: color + opacity, path style, animation
  - 'Apply to existing [Type]' per type, 'Apply All to Canvas' footer action
- canvasStore: applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles actions
- themeStore: customStyle state + setCustomStyle action
- Backend: custom_style JSON column on canvas_state (ALTER TABLE migration), saved/loaded with canvas
- App.tsx: custom_style included in save payload, restored on load (API + standalone)
- Tests: +8 frontend (themeStore + canvasStore), +3 backend (canvas API)
2026-04-24 11:42:31 +02:00

82 lines
2.8 KiB
Python

from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends
from sqlalchemy import 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, Edge, Node
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
router = APIRouter()
@router.get("", response_model=CanvasStateResponse)
async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> CanvasStateResponse:
nodes = (await db.execute(select(Node))).scalars().all()
edges = (await db.execute(select(Edge))).scalars().all()
state = await db.get(CanvasState, 1)
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
return CanvasStateResponse(
nodes=[NodeResponse.model_validate(n) for n in nodes],
edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport,
custom_style=state.custom_style if state else None,
)
@router.post("/save")
async def save_canvas(
body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]:
incoming_node_ids = {n.id for n in body.nodes}
incoming_edge_ids = {e.id for e in body.edges}
# Delete nodes removed from canvas
existing_nodes = (await db.execute(select(Node))).scalars().all()
for node in existing_nodes:
if node.id not in incoming_node_ids:
await db.delete(node)
# Delete edges removed from canvas
existing_edges = (await db.execute(select(Edge))).scalars().all()
for edge in existing_edges:
if edge.id not in incoming_edge_ids:
await db.delete(edge)
await db.flush()
# Upsert nodes
for node_data in body.nodes:
db_node = await db.get(Node, node_data.id)
if db_node:
for field, value in node_data.model_dump().items():
setattr(db_node, field, value)
else:
db.add(Node(**node_data.model_dump()))
# Upsert edges
for edge_data in body.edges:
db_edge = await db.get(Edge, edge_data.id)
if db_edge:
for field, value in edge_data.model_dump().items():
setattr(db_edge, field, value)
else:
db.add(Edge(**edge_data.model_dump()))
# Upsert viewport + custom style
state = await db.get(CanvasState, 1)
if state:
state.viewport = body.viewport
state.custom_style = body.custom_style
state.saved_at = datetime.now(timezone.utc)
else:
db.add(CanvasState(id=1, viewport=body.viewport, custom_style=body.custom_style))
await db.commit()
return {"saved": True}