feat(database): full-page database conversion (Notion-style)
- 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:
+180
-1
@@ -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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user