boxframe 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- boxframe/__init__.py +8 -0
- boxframe/api/__init__.py +4 -0
- boxframe/api/layouts.py +349 -0
- boxframe/api/projects.py +143 -0
- boxframe/api/test_cleanup.py +62 -0
- boxframe/config.py +14 -0
- boxframe/database.py +62 -0
- boxframe/main.py +177 -0
- boxframe/models/__init__.py +5 -0
- boxframe/models/block.py +51 -0
- boxframe/models/layout.py +29 -0
- boxframe/models/project.py +18 -0
- boxframe/services/__init__.py +11 -0
- boxframe/services/layout_service.py +339 -0
- boxframe/services/renderer.py +546 -0
- boxframe/static/css/editor.css +553 -0
- boxframe/static/css/style.css +211 -0
- boxframe/static/editor/index.html +183 -0
- boxframe/static/index.html +23 -0
- boxframe/static/js/alpine.min.js +5 -0
- boxframe/static/js/core/history.js +368 -0
- boxframe/static/js/core/renderer.js +570 -0
- boxframe/static/js/core/store.js +388 -0
- boxframe/static/js/editor.js +1081 -0
- boxframe/templates/pages/404.html +11 -0
- boxframe/templates/pages/base.html +28 -0
- boxframe/templates/pages/editor.html +190 -0
- boxframe/templates/pages/project.html +76 -0
- boxframe/templates/pages/projects.html +55 -0
- boxframe-0.1.0.dist-info/METADATA +370 -0
- boxframe-0.1.0.dist-info/RECORD +33 -0
- boxframe-0.1.0.dist-info/WHEEL +4 -0
- boxframe-0.1.0.dist-info/entry_points.txt +2 -0
boxframe/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""boxframe — Pseudo-graphic UI mockup editor for AI agents."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("boxframe")
|
|
7
|
+
except PackageNotFoundError: # installed without metadata (e.g. run from source)
|
|
8
|
+
__version__ = "0.0.0"
|
boxframe/api/__init__.py
ADDED
boxframe/api/layouts.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Layout API routes.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from boxframe.database import get_db
|
|
12
|
+
from boxframe.services.layout_service import LayoutService
|
|
13
|
+
from boxframe.services.renderer import PseudoGraphicRenderer
|
|
14
|
+
|
|
15
|
+
router = APIRouter(prefix="/api/layouts", tags=["layouts"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ── Pydantic schemas ──────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
class BlockOut(BaseModel):
|
|
21
|
+
id: str
|
|
22
|
+
block_type: str
|
|
23
|
+
x: int
|
|
24
|
+
y: int
|
|
25
|
+
width: int
|
|
26
|
+
height: int
|
|
27
|
+
content: str
|
|
28
|
+
border_style: str
|
|
29
|
+
parent_id: str | None
|
|
30
|
+
meta: dict
|
|
31
|
+
order: int
|
|
32
|
+
created_at: datetime
|
|
33
|
+
updated_at: datetime
|
|
34
|
+
|
|
35
|
+
class Config:
|
|
36
|
+
from_attributes = True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LayoutCreate(BaseModel):
|
|
40
|
+
name: str = Field(..., min_length=1, max_length=255)
|
|
41
|
+
# Optional: a layout may have a width, a height, both, or neither.
|
|
42
|
+
# No upper limit: a set dimension is a visual bounds line in the
|
|
43
|
+
# editor — blocks may be placed outside it, it is not a constraint.
|
|
44
|
+
width: int | None = Field(default=None, ge=1)
|
|
45
|
+
height: int | None = Field(default=None, ge=1)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LayoutUpdate(BaseModel):
|
|
49
|
+
name: str | None = None
|
|
50
|
+
width: int | None = None
|
|
51
|
+
height: int | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class LayoutOut(BaseModel):
|
|
55
|
+
id: str
|
|
56
|
+
project_id: str
|
|
57
|
+
name: str
|
|
58
|
+
width: int | None = None
|
|
59
|
+
height: int | None = None
|
|
60
|
+
blocks: list[BlockOut] = []
|
|
61
|
+
created_at: datetime
|
|
62
|
+
updated_at: datetime
|
|
63
|
+
|
|
64
|
+
class Config:
|
|
65
|
+
from_attributes = True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class BlockCreate(BaseModel):
|
|
69
|
+
block_type: str = Field(..., description="Type of block (box, button, input, etc.)")
|
|
70
|
+
x: int = 0
|
|
71
|
+
y: int = 0
|
|
72
|
+
width: int = 20
|
|
73
|
+
height: int = 3
|
|
74
|
+
content: str = ""
|
|
75
|
+
border_style: str = "solid"
|
|
76
|
+
parent_id: str | None = None
|
|
77
|
+
meta: dict = {}
|
|
78
|
+
order: int = 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class BlockUpdate(BaseModel):
|
|
82
|
+
block_type: str | None = None
|
|
83
|
+
x: int | None = None
|
|
84
|
+
y: int | None = None
|
|
85
|
+
width: int | None = None
|
|
86
|
+
height: int | None = None
|
|
87
|
+
content: str | None = None
|
|
88
|
+
border_style: str | None = None
|
|
89
|
+
parent_id: str | None = None
|
|
90
|
+
meta: dict | None = None
|
|
91
|
+
order: int | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class BlockIn(BaseModel):
|
|
95
|
+
"""A block in a full-state replace payload.
|
|
96
|
+
|
|
97
|
+
id is optional: given ids are preserved (undo/redo restores the same
|
|
98
|
+
block identity), missing ids are generated server-side.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
id: str | None = None
|
|
102
|
+
block_type: str = "box"
|
|
103
|
+
x: int = 0
|
|
104
|
+
y: int = 0
|
|
105
|
+
width: int = 20
|
|
106
|
+
height: int = 3
|
|
107
|
+
content: str = ""
|
|
108
|
+
border_style: str = "solid"
|
|
109
|
+
parent_id: str | None = None
|
|
110
|
+
meta: dict = {}
|
|
111
|
+
order: int = 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class BlocksReplace(BaseModel):
|
|
115
|
+
blocks: list[BlockIn]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class RenderOut(BaseModel):
|
|
119
|
+
ascii: str
|
|
120
|
+
html: str | None = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ExportOut(BaseModel):
|
|
124
|
+
layout_json: dict | None = None
|
|
125
|
+
markdown: str | None = None
|
|
126
|
+
ascii: str | None = None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ── Layout CRUD ───────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
@router.post("/{project_id}/layouts", response_model=LayoutOut)
|
|
132
|
+
async def create_layout(project_id: str, data: LayoutCreate, db: AsyncSession = Depends(get_db)):
|
|
133
|
+
service = LayoutService(db)
|
|
134
|
+
layout = await service.create_layout(project_id, data.name, data.width, data.height)
|
|
135
|
+
return layout
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@router.get("/{layout_id}", response_model=LayoutOut)
|
|
139
|
+
async def get_layout(layout_id: str, db: AsyncSession = Depends(get_db)):
|
|
140
|
+
service = LayoutService(db)
|
|
141
|
+
layout = await service.get_layout(layout_id)
|
|
142
|
+
if not layout:
|
|
143
|
+
raise HTTPException(status_code=404, detail="Layout not found")
|
|
144
|
+
return layout
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@router.delete("/{layout_id}")
|
|
148
|
+
async def delete_layout(layout_id: str, db: AsyncSession = Depends(get_db)):
|
|
149
|
+
service = LayoutService(db)
|
|
150
|
+
layout = await service.get_layout(layout_id)
|
|
151
|
+
if not layout:
|
|
152
|
+
raise HTTPException(status_code=404, detail="Layout not found")
|
|
153
|
+
await service.delete_layout(layout_id)
|
|
154
|
+
return {"ok": True}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── Block CRUD ────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
@router.post("/{layout_id}/blocks", response_model=BlockOut)
|
|
160
|
+
async def create_block(layout_id: str, data: BlockCreate, db: AsyncSession = Depends(get_db)):
|
|
161
|
+
service = LayoutService(db)
|
|
162
|
+
block = await service.create_block(
|
|
163
|
+
layout_id=layout_id,
|
|
164
|
+
block_type=data.block_type,
|
|
165
|
+
x=data.x,
|
|
166
|
+
y=data.y,
|
|
167
|
+
width=data.width,
|
|
168
|
+
height=data.height,
|
|
169
|
+
content=data.content,
|
|
170
|
+
border_style=data.border_style,
|
|
171
|
+
parent_id=data.parent_id,
|
|
172
|
+
meta=data.meta,
|
|
173
|
+
order=data.order,
|
|
174
|
+
)
|
|
175
|
+
return block
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@router.put("/{layout_id}/blocks/{block_id}", response_model=BlockOut)
|
|
179
|
+
async def update_block(layout_id: str, block_id: str, data: BlockUpdate, db: AsyncSession = Depends(get_db)):
|
|
180
|
+
service = LayoutService(db)
|
|
181
|
+
block = await service.get_block(block_id)
|
|
182
|
+
if not block or block.layout_id != layout_id:
|
|
183
|
+
raise HTTPException(status_code=404, detail="Block not found")
|
|
184
|
+
|
|
185
|
+
update_data = data.model_dump(exclude_none=True)
|
|
186
|
+
if not update_data:
|
|
187
|
+
return block
|
|
188
|
+
|
|
189
|
+
updated = await service.update_block(block_id, **update_data)
|
|
190
|
+
return updated
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@router.delete("/{layout_id}/blocks/{block_id}")
|
|
194
|
+
async def delete_block(layout_id: str, block_id: str, db: AsyncSession = Depends(get_db)):
|
|
195
|
+
service = LayoutService(db)
|
|
196
|
+
block = await service.get_block(block_id)
|
|
197
|
+
if not block or block.layout_id != layout_id:
|
|
198
|
+
raise HTTPException(status_code=404, detail="Block not found")
|
|
199
|
+
await service.delete_block(block_id)
|
|
200
|
+
return {"ok": True}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@router.delete("/{layout_id}/blocks")
|
|
204
|
+
async def clear_layout_blocks(layout_id: str, db: AsyncSession = Depends(get_db)):
|
|
205
|
+
"""Remove all blocks from a layout (the layout itself is kept)."""
|
|
206
|
+
service = LayoutService(db)
|
|
207
|
+
layout = await service.get_layout(layout_id)
|
|
208
|
+
if not layout:
|
|
209
|
+
raise HTTPException(status_code=404, detail="Layout not found")
|
|
210
|
+
deleted = await service.delete_all_blocks(layout_id)
|
|
211
|
+
return {"ok": True, "deleted": deleted}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@router.put("/{layout_id}/blocks", response_model=LayoutOut)
|
|
215
|
+
async def replace_layout_blocks(layout_id: str, data: BlocksReplace, db: AsyncSession = Depends(get_db)):
|
|
216
|
+
"""Replace all blocks of a layout with the given set (full state restore).
|
|
217
|
+
|
|
218
|
+
Backs the editor's undo/redo: the client sends a state snapshot and the
|
|
219
|
+
layout is atomically restored to it. Block ids from the payload are kept.
|
|
220
|
+
"""
|
|
221
|
+
service = LayoutService(db)
|
|
222
|
+
layout = await service.get_layout(layout_id)
|
|
223
|
+
if not layout:
|
|
224
|
+
raise HTTPException(status_code=404, detail="Layout not found")
|
|
225
|
+
updated = await service.replace_blocks(layout_id, [b.model_dump() for b in data.blocks])
|
|
226
|
+
return updated
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── Rendering ─────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
@router.get("/{layout_id}/render", response_model=RenderOut)
|
|
232
|
+
async def render_layout(
|
|
233
|
+
layout_id: str,
|
|
234
|
+
char_width_px: float = Query(default=12.0, ge=1, le=50),
|
|
235
|
+
char_height_px: float = Query(default=14.4, ge=1, le=50),
|
|
236
|
+
db: AsyncSession = Depends(get_db),
|
|
237
|
+
):
|
|
238
|
+
"""Render layout to pseudo-graphic ASCII art.
|
|
239
|
+
|
|
240
|
+
char_width_px / char_height_px — measured pixel dimensions of a single
|
|
241
|
+
character. JS measures the real .ascii-art character size and passes
|
|
242
|
+
them back so overlays match exactly.
|
|
243
|
+
"""
|
|
244
|
+
service = LayoutService(db)
|
|
245
|
+
ascii_art = await service.render_layout(layout_id)
|
|
246
|
+
if ascii_art is None:
|
|
247
|
+
raise HTTPException(status_code=404, detail="Layout not found")
|
|
248
|
+
|
|
249
|
+
# Generate HTML preview with block overlays and resize handles
|
|
250
|
+
layout = await service.get_layout(layout_id)
|
|
251
|
+
html = ""
|
|
252
|
+
if layout:
|
|
253
|
+
# Canvas = layout size expanded to fit blocks outside the bounds
|
|
254
|
+
# (the ASCII grid is rendered the same way).
|
|
255
|
+
canvas_w, canvas_h = service.canvas_size(layout)
|
|
256
|
+
# Compute exact pixel dimensions so the art layer and overlays share
|
|
257
|
+
# the same size.
|
|
258
|
+
pre_width = canvas_w * char_width_px
|
|
259
|
+
pre_height = round(canvas_h * char_height_px, 1)
|
|
260
|
+
pad = 16.0
|
|
261
|
+
# Padding lives on .render-wrapper; the art layer has no margin so
|
|
262
|
+
# overlay coordinates (padding_offset + grid * char_size) align
|
|
263
|
+
# exactly. It is a <div>, not a <pre>: the HTML parser strips the
|
|
264
|
+
# first newline right after a <pre> start tag, which would drop the
|
|
265
|
+
# first row when the art starts with an empty row (topmost block
|
|
266
|
+
# not at y=0) and shift the art up one row relative to the overlays.
|
|
267
|
+
ascii_html = (
|
|
268
|
+
f'<div class="ascii-art" style="font-family: monospace; font-size: 12px; '
|
|
269
|
+
f'line-height: 1.2; display: block; white-space: pre; '
|
|
270
|
+
f'width:{pre_width}px; height:{pre_height}px; '
|
|
271
|
+
f'background: #1a1a2e; color: #e0e0e0;">'
|
|
272
|
+
f"{ascii_art.replace('&', '&').replace('<', '<').replace('>', '>')}</div>"
|
|
273
|
+
)
|
|
274
|
+
blocks_data = _serialize_blocks_for_html(layout.blocks)
|
|
275
|
+
block_previews_html = PseudoGraphicRenderer.render_html_preview(
|
|
276
|
+
blocks_data,
|
|
277
|
+
char_width_px=char_width_px,
|
|
278
|
+
char_height_px=char_height_px,
|
|
279
|
+
padding_offset=pad,
|
|
280
|
+
)
|
|
281
|
+
# Layout bounds overlay: a set width/height draws a visual line on
|
|
282
|
+
# the editor canvas. It does not affect block placement or the
|
|
283
|
+
# canvas size, and it is not part of the ASCII art.
|
|
284
|
+
bounds_html = PseudoGraphicRenderer.render_bounds_html(
|
|
285
|
+
layout.width,
|
|
286
|
+
layout.height,
|
|
287
|
+
char_width_px=char_width_px,
|
|
288
|
+
char_height_px=char_height_px,
|
|
289
|
+
padding_offset=pad,
|
|
290
|
+
)
|
|
291
|
+
html = (
|
|
292
|
+
f'<div class="render-wrapper" '
|
|
293
|
+
f'style="width:{pre_width}px; height:{pre_height}px;">'
|
|
294
|
+
f"{ascii_html}"
|
|
295
|
+
f"{bounds_html}"
|
|
296
|
+
f"{block_previews_html}"
|
|
297
|
+
f"</div>"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
return RenderOut(ascii=ascii_art, html=html)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _serialize_blocks_for_html(blocks: list) -> list[dict]:
|
|
304
|
+
"""Serialize ORM blocks to dicts for HTML preview generation."""
|
|
305
|
+
by_id = {b.id: b for b in blocks}
|
|
306
|
+
result = []
|
|
307
|
+
for block in blocks:
|
|
308
|
+
if block.parent_id and block.parent_id in by_id:
|
|
309
|
+
continue
|
|
310
|
+
bd = {
|
|
311
|
+
"id": block.id,
|
|
312
|
+
"x": block.x,
|
|
313
|
+
"y": block.y,
|
|
314
|
+
"width": block.width,
|
|
315
|
+
"height": block.height,
|
|
316
|
+
"block_type": block.block_type,
|
|
317
|
+
"content": block.content,
|
|
318
|
+
"border_style": block.border_style,
|
|
319
|
+
"order": block.order,
|
|
320
|
+
"children": [
|
|
321
|
+
{
|
|
322
|
+
"id": c.id,
|
|
323
|
+
"x": c.x,
|
|
324
|
+
"y": c.y,
|
|
325
|
+
"width": c.width,
|
|
326
|
+
"height": c.height,
|
|
327
|
+
"block_type": c.block_type,
|
|
328
|
+
"content": c.content,
|
|
329
|
+
"border_style": c.border_style,
|
|
330
|
+
"order": c.order,
|
|
331
|
+
}
|
|
332
|
+
for c in blocks
|
|
333
|
+
if c.parent_id == block.id
|
|
334
|
+
],
|
|
335
|
+
}
|
|
336
|
+
result.append(bd)
|
|
337
|
+
return result
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@router.get("/{layout_id}/export", response_model=ExportOut)
|
|
341
|
+
async def export_layout(layout_id: str, db: AsyncSession = Depends(get_db)):
|
|
342
|
+
"""Export layout in multiple formats."""
|
|
343
|
+
service = LayoutService(db)
|
|
344
|
+
|
|
345
|
+
json_data = await service.export_json(layout_id)
|
|
346
|
+
markdown = await service.export_markdown(layout_id)
|
|
347
|
+
ascii_art = await service.render_layout(layout_id)
|
|
348
|
+
|
|
349
|
+
return ExportOut(layout_json=json_data, markdown=markdown, ascii=ascii_art)
|
boxframe/api/projects.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project API routes.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from boxframe.database import get_db
|
|
12
|
+
from boxframe.models.block import BLOCK_TYPES, BORDER_STYLES
|
|
13
|
+
from boxframe.services.layout_service import LayoutService
|
|
14
|
+
|
|
15
|
+
router = APIRouter(prefix="/api/projects", tags=["projects"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ── Pydantic schemas ──────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
class ProjectCreate(BaseModel):
|
|
21
|
+
name: str = Field(..., min_length=1, max_length=255)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProjectOut(BaseModel):
|
|
25
|
+
id: str
|
|
26
|
+
name: str
|
|
27
|
+
created_at: datetime
|
|
28
|
+
updated_at: datetime
|
|
29
|
+
|
|
30
|
+
class Config:
|
|
31
|
+
from_attributes = True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LayoutOut(BaseModel):
|
|
35
|
+
id: str
|
|
36
|
+
name: str
|
|
37
|
+
width: int | None = None
|
|
38
|
+
height: int | None = None
|
|
39
|
+
created_at: datetime
|
|
40
|
+
updated_at: datetime
|
|
41
|
+
|
|
42
|
+
class Config:
|
|
43
|
+
from_attributes = True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BlockSchema(BaseModel):
|
|
47
|
+
id: str | None = None
|
|
48
|
+
block_type: str
|
|
49
|
+
x: int = 0
|
|
50
|
+
y: int = 0
|
|
51
|
+
width: int = 20
|
|
52
|
+
height: int = 3
|
|
53
|
+
content: str = ""
|
|
54
|
+
border_style: str = "solid"
|
|
55
|
+
parent_id: str | None = None
|
|
56
|
+
meta: dict = {}
|
|
57
|
+
order: int = 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class BlockOut(BaseModel):
|
|
61
|
+
id: str
|
|
62
|
+
block_type: str
|
|
63
|
+
x: int
|
|
64
|
+
y: int
|
|
65
|
+
width: int
|
|
66
|
+
height: int
|
|
67
|
+
content: str
|
|
68
|
+
border_style: str
|
|
69
|
+
parent_id: str | None
|
|
70
|
+
meta: dict
|
|
71
|
+
order: int
|
|
72
|
+
created_at: datetime
|
|
73
|
+
updated_at: datetime
|
|
74
|
+
|
|
75
|
+
class Config:
|
|
76
|
+
from_attributes = True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Routes ────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
@router.get("/", response_model=list[ProjectOut])
|
|
82
|
+
async def list_projects(db: AsyncSession = Depends(get_db)):
|
|
83
|
+
service = LayoutService(db)
|
|
84
|
+
projects = await service.list_projects()
|
|
85
|
+
return projects
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.post("/", response_model=ProjectOut)
|
|
89
|
+
async def create_project(data: ProjectCreate, db: AsyncSession = Depends(get_db)):
|
|
90
|
+
service = LayoutService(db)
|
|
91
|
+
project = await service.create_project(data.name)
|
|
92
|
+
return project
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.get("/{project_id}", response_model=ProjectOut)
|
|
96
|
+
async def get_project(project_id: str, db: AsyncSession = Depends(get_db)):
|
|
97
|
+
service = LayoutService(db)
|
|
98
|
+
project = await service.get_project(project_id)
|
|
99
|
+
if not project:
|
|
100
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
101
|
+
return project
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@router.delete("/{project_id}")
|
|
105
|
+
async def delete_project(project_id: str, db: AsyncSession = Depends(get_db)):
|
|
106
|
+
service = LayoutService(db)
|
|
107
|
+
project = await service.get_project(project_id)
|
|
108
|
+
if not project:
|
|
109
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
110
|
+
await service.delete_project(project_id)
|
|
111
|
+
return {"ok": True}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@router.get("/{project_id}/info", response_model=dict)
|
|
115
|
+
async def project_info(project_id: str, db: AsyncSession = Depends(get_db)):
|
|
116
|
+
"""Return project metadata including available block types and border styles."""
|
|
117
|
+
service = LayoutService(db)
|
|
118
|
+
project = await service.get_project(project_id)
|
|
119
|
+
if not project:
|
|
120
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
"project": {"id": project.id, "name": project.name},
|
|
124
|
+
"layouts": [{"id": l.id, "name": l.name} for l in project.layouts],
|
|
125
|
+
"block_types": BLOCK_TYPES,
|
|
126
|
+
"border_styles": BORDER_STYLES,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class LayoutCreate(BaseModel):
|
|
131
|
+
name: str = Field(..., min_length=1, max_length=255)
|
|
132
|
+
# Optional: a layout may have a width, a height, both, or neither.
|
|
133
|
+
# A set dimension is a visual bounds line in the editor, not a constraint.
|
|
134
|
+
width: int | None = Field(default=None, ge=1)
|
|
135
|
+
height: int | None = Field(default=None, ge=1)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@router.post("/{project_id}/layouts", response_model=LayoutOut)
|
|
139
|
+
async def create_layout(project_id: str, data: LayoutCreate, db: AsyncSession = Depends(get_db)):
|
|
140
|
+
"""Create a new layout for a project."""
|
|
141
|
+
service = LayoutService(db)
|
|
142
|
+
layout = await service.create_layout(project_id, data.name, data.width, data.height)
|
|
143
|
+
return layout
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test cleanup API route.
|
|
3
|
+
|
|
4
|
+
Deletes all projects, layouts, and blocks whose names start with
|
|
5
|
+
the ``E2E: `` prefix. This keeps the production database clean
|
|
6
|
+
when running E2E tests against a shared server.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends
|
|
10
|
+
from sqlalchemy import delete, select
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
|
+
|
|
13
|
+
from boxframe.database import get_db
|
|
14
|
+
from boxframe.models.block import Block
|
|
15
|
+
from boxframe.models.layout import Layout
|
|
16
|
+
from boxframe.models.project import Project
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/api/test", tags=["test"])
|
|
19
|
+
|
|
20
|
+
E2E_PREFIX = "E2E: "
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.delete("/cleanup")
|
|
24
|
+
async def cleanup_e2e_data(db: AsyncSession = Depends(get_db)):
|
|
25
|
+
"""Delete all E2E test data (projects, layouts, blocks with E2E prefix)."""
|
|
26
|
+
# 1. Collect layout IDs belonging to E2E projects
|
|
27
|
+
e2e_projects = await db.execute(
|
|
28
|
+
select(Project.id).where(Project.name.like(f"{E2E_PREFIX}%"))
|
|
29
|
+
)
|
|
30
|
+
e2e_project_ids = set(e2e_projects.scalars().all())
|
|
31
|
+
|
|
32
|
+
# 2. Collect layout IDs for those projects
|
|
33
|
+
e2e_layouts = await db.execute(
|
|
34
|
+
select(Layout.id).where(Layout.project_id.in_(e2e_project_ids))
|
|
35
|
+
)
|
|
36
|
+
e2e_layout_ids = set(e2e_layouts.scalars().all())
|
|
37
|
+
|
|
38
|
+
# 3. Delete blocks belonging to those layouts
|
|
39
|
+
deleted_blocks = 0
|
|
40
|
+
if e2e_layout_ids:
|
|
41
|
+
result = await db.execute(delete(Block).where(Block.layout_id.in_(e2e_layout_ids)))
|
|
42
|
+
deleted_blocks = result.rowcount
|
|
43
|
+
|
|
44
|
+
# 4. Delete layouts
|
|
45
|
+
deleted_layouts = 0
|
|
46
|
+
if e2e_layout_ids:
|
|
47
|
+
result = await db.execute(delete(Layout).where(Layout.id.in_(e2e_layout_ids)))
|
|
48
|
+
deleted_layouts = result.rowcount
|
|
49
|
+
|
|
50
|
+
# 5. Delete projects
|
|
51
|
+
deleted_projects = 0
|
|
52
|
+
if e2e_project_ids:
|
|
53
|
+
result = await db.execute(delete(Project).where(Project.id.in_(e2e_project_ids)))
|
|
54
|
+
deleted_projects = result.rowcount
|
|
55
|
+
|
|
56
|
+
await db.commit()
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
"deleted_projects": deleted_projects,
|
|
60
|
+
"deleted_layouts": deleted_layouts,
|
|
61
|
+
"deleted_blocks": deleted_blocks,
|
|
62
|
+
}
|
boxframe/config.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Settings(BaseSettings):
|
|
5
|
+
app_name: str = "boxframe"
|
|
6
|
+
database_url: str = "sqlite+aiosqlite:///./boxframe.db"
|
|
7
|
+
debug: bool = False
|
|
8
|
+
log_dir: str = "log"
|
|
9
|
+
|
|
10
|
+
class Config:
|
|
11
|
+
env_prefix = "BOXFRAME_"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
settings = Settings()
|
boxframe/database.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from collections.abc import AsyncGenerator
|
|
2
|
+
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
4
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
5
|
+
|
|
6
|
+
from boxframe.config import settings
|
|
7
|
+
|
|
8
|
+
engine = create_async_engine(settings.database_url, echo=settings.debug)
|
|
9
|
+
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Base(DeclarativeBase):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|
17
|
+
async with async_session() as session:
|
|
18
|
+
try:
|
|
19
|
+
yield session
|
|
20
|
+
finally:
|
|
21
|
+
await session.close()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def init_db():
|
|
25
|
+
async with engine.begin() as conn:
|
|
26
|
+
await conn.run_sync(_migrate_layouts_size_nullable)
|
|
27
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _migrate_layouts_size_nullable(conn) -> None:
|
|
31
|
+
"""One-off migration: layouts.width/height became nullable.
|
|
32
|
+
|
|
33
|
+
SQLite cannot drop NOT NULL in place, so the table is rebuilt when the
|
|
34
|
+
old (non-nullable) schema is detected. Existing data is preserved.
|
|
35
|
+
"""
|
|
36
|
+
result = conn.exec_driver_sql("PRAGMA table_info(layouts)")
|
|
37
|
+
cols = {row[1]: row for row in result.fetchall()}
|
|
38
|
+
if not cols:
|
|
39
|
+
return # table does not exist yet — create_all will make it
|
|
40
|
+
# PRAGMA table_info row: (cid, name, type, notnull, dflt_value, pk)
|
|
41
|
+
if not cols["width"][3] and not cols["height"][3]:
|
|
42
|
+
return # already nullable
|
|
43
|
+
conn.exec_driver_sql(
|
|
44
|
+
"""
|
|
45
|
+
CREATE TABLE layouts_migrated (
|
|
46
|
+
id VARCHAR(36) PRIMARY KEY,
|
|
47
|
+
project_id VARCHAR(36) NOT NULL REFERENCES projects(id),
|
|
48
|
+
name VARCHAR(255) NOT NULL,
|
|
49
|
+
width INTEGER,
|
|
50
|
+
height INTEGER,
|
|
51
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
52
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
53
|
+
)
|
|
54
|
+
"""
|
|
55
|
+
)
|
|
56
|
+
conn.exec_driver_sql(
|
|
57
|
+
"INSERT INTO layouts_migrated "
|
|
58
|
+
"(id, project_id, name, width, height, created_at, updated_at) "
|
|
59
|
+
"SELECT id, project_id, name, width, height, created_at, updated_at FROM layouts"
|
|
60
|
+
)
|
|
61
|
+
conn.exec_driver_sql("DROP TABLE layouts")
|
|
62
|
+
conn.exec_driver_sql("ALTER TABLE layouts_migrated RENAME TO layouts")
|