feat(database): full-page database conversion (Notion-style)
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped

- Migration: add collection_id column to pages table
- POST /api/pages/{id}/convert-to-database: transforms page into database
- GET /api/collections/{id}/table-data: returns props + pages for table view
- POST /api/collections/{id}/pages: creates new row in collection
- New page_editor_collection.html template for database view
- _database_table.html: Notion-style table with columns, rows, New Page, Add Property
- Alpine.js component for cell editing, new page creation, property mgmt
- CSS: complete database table styling (view bar, table, cells, modals, dropdowns)
- Frontend: createDatabase() now converts page to full-page DB (was inline embed)
This commit is contained in:
2026-07-22 14:34:39 -04:00
parent f7d87e6555
commit 24a760c5eb
7 changed files with 1077 additions and 9 deletions
+180 -1
View File
@@ -335,6 +335,33 @@ async def view_page_root(request: Request, page_id: int):
page_data["file_size"] = file_size
page_data["file_name"] = filename
# For collection (database) pages, load collection + properties + pages
collection_data = None
if page.get("content_format") == "collection" and page.get("collection_id"):
with get_conn() as conn:
col = conn.execute(
"SELECT * FROM collections WHERE id=?", (page["collection_id"],)
).fetchone()
if col:
props = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(page["collection_id"],),
).fetchall()
]
cpages = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(page["collection_id"],),
).fetchall()
]
collection_data = {
"collection": dict(col),
"properties": props,
"pages": cpages,
}
page_data["collection_id"] = page["collection_id"]
with get_conn() as conn:
nav_crumbs = _nav_breadcrumb(conn, page_id)
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs],
@@ -342,11 +369,16 @@ async def view_page_root(request: Request, page_id: int):
"page_share_mode": page.get("share_mode", "private"),
"page_published": bool(page.get("published", 0)),
"page_data": page_data,
"collection_data": collection_data,
"breadcrumb_items": nav_crumbs,
"nav_workspace_id": page.get("workspace_id") or 0,
"nav_page_id": page_id,
"embed_mode": embed}
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
# Select template: collection pages use database table view
if page.get("content_format") == "collection" and not embed:
template = env.get_template("page_editor_collection.html")
else:
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
response = template.render(**ctx)
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
@@ -2122,3 +2154,150 @@ async def api_trash_page(page_id: int):
)
conn.commit()
return {"status": "ok"}
@router.post("/api/pages/{page_id:int}/convert-to-database")
async def api_convert_to_database(page_id: int, request: Request):
"""Convert a page into a full-page database (Notion-style).
Creates a collection linked to this page, adds the default 'Name' property,
and sets the page's content_format to 'collection'.
"""
import json as _json
try:
body = await request.json()
except Exception:
body = {}
db_name = (body.get("name") or "").strip()
with get_conn() as conn:
page = conn.execute(
"SELECT id, title, workspace_id FROM pages WHERE id=? AND deleted_at IS NULL",
(page_id,),
).fetchone()
if not page:
return JSONResponse({"error": "Page not found"}, status_code=404)
if not db_name:
db_name = page["title"] or "New Database"
# Create the collection
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
VALUES (?, '', '📋', '[]', 0, ?, ?)""",
(db_name, page_id, page["workspace_id"]),
)
collection_id = cur.lastrowid
# Create default "Name" property (text, position 0)
conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, position, required, visible_in_views)
VALUES (?, 'Name', 'title', 0, 1, 1)""",
(collection_id,),
)
# Create default "Table" view
conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json, position)
VALUES (?, 'Table', 'table', ?, 0)""",
(collection_id, _json.dumps({"visible_properties": ["Name"]})),
)
# Update the page to be a database page
conn.execute(
"UPDATE pages SET content_format='collection', collection_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(collection_id, page_id),
)
conn.commit()
return {
"status": "converted",
"collection_id": collection_id,
"name": db_name,
"view_url": f"/pages/{page_id}",
}
@router.get("/api/collections/{collection_id:int}/table-data")
async def api_collection_table_data(collection_id: int):
"""Get collection properties + pages for rendering the table view."""
with get_conn() as conn:
coll = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not coll:
return JSONResponse({"error": "Collection not found"}, status_code=404)
properties = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
pages = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
return {
"collection": dict(coll),
"properties": properties,
"pages": pages,
}
@router.post("/api/collections/{collection_id:int}/pages")
async def api_create_collection_page(collection_id: int, request: Request):
"""Create a new page (row) in a collection."""
try:
body = await request.json()
except Exception:
body = {}
title = body.get("title", "New page").strip() or "New page"
icon = body.get("icon", "file")
with get_conn() as conn:
coll = conn.execute(
"SELECT id FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not coll:
return JSONResponse({"error": "Collection not found"}, status_code=404)
# Get next position
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
# Load default property values from collection properties
props = conn.execute(
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
default_values = {}
for p in props:
if p["prop_type"] == "title":
default_values[str(p["id"])] = title
cur = conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, icon, position, property_values_json)
VALUES (?, ?, ?, ?, ?)""",
(collection_id, title, icon, max_pos, _json.dumps(default_values)),
)
page_id = cur.lastrowid
conn.commit()
return {
"id": page_id,
"title": title,
"position": max_pos,
"status": "created",
}