From 24a760c5ebdf60b52dc162987139c412f5ff8e6c Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 22 Jul 2026 14:34:39 -0400 Subject: [PATCH] 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) --- app/db.py | 6 + app/routers/dashboard.py | 181 ++++++++- app/templates/_database_table.html | 181 +++++++++ app/templates/_database_table_scripts.html | 248 ++++++++++++ app/templates/_page_editor_scripts.html | 15 +- app/templates/page_editor_collection.html | 16 + static/css/app.css | 439 +++++++++++++++++++++ 7 files changed, 1077 insertions(+), 9 deletions(-) create mode 100644 app/templates/_database_table.html create mode 100644 app/templates/_database_table_scripts.html create mode 100644 app/templates/page_editor_collection.html diff --git a/app/db.py b/app/db.py index 71005f8..1cae6a5 100644 --- a/app/db.py +++ b/app/db.py @@ -487,6 +487,12 @@ def init_db(): pass conn.commit() + # v4.6.0: Add collection_id to pages (page ↔ collection link for full-page DBs) + try: + conn.execute("ALTER TABLE pages ADD COLUMN collection_id INTEGER REFERENCES collections(id)") + except sqlite3.OperationalError: + pass + # v4.2.0: Collection Templates (enhanced) + Dashboards # Add description, is_recurring, recurrence_rule to page_templates try: diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 2065372..1079378 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -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", + } diff --git a/app/templates/_database_table.html b/app/templates/_database_table.html new file mode 100644 index 0000000..a421004 --- /dev/null +++ b/app/templates/_database_table.html @@ -0,0 +1,181 @@ + +
+ +
+
+ + + +
+
Add a new view
+
{{ fd_icon("table",14) }} Table
+
{{ fd_icon("columns",14) }} Board
+
{{ fd_icon("list",14) }} List
+
{{ fd_icon("calendar",14) }} Calendar
+
+
+
+ + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ +
+ {{ fd_icon("plus",14) }} + + +
+
+ + +
+
+
+

Add a property

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+

Rename page

+ +
+
+
+ +
+
+ + +
+
+
+
+
diff --git a/app/templates/_database_table_scripts.html b/app/templates/_database_table_scripts.html new file mode 100644 index 0000000..34166d4 --- /dev/null +++ b/app/templates/_database_table_scripts.html @@ -0,0 +1,248 @@ + + diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html index eab25fe..2b837f4 100644 --- a/app/templates/_page_editor_scripts.html +++ b/app/templates/_page_editor_scripts.html @@ -325,20 +325,19 @@ // ── v4.5.0+: Get Started actions ── inlineDBs:[], async createDatabase(){ - const name=prompt('Database name:','New Database'); + const name=prompt('Database name:',this.pageTitle||'New Database'); if(!name||!name.trim())return; const csrf=document.cookie.match(/csrf_token=([^;]+)/); try{ - const r=await fetch('/db/inline/api',{ + const r=await fetch(`/api/pages/${this.pid}/convert-to-database`,{ method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''}, - body:JSON.stringify({name:name.trim(),parent_page_id:this.pid}) + body:JSON.stringify({name:name.trim()}) }); const d=await r.json(); - if(d.id){ - this.inlineDBs.push({id:d.id,name:d.name,view_type:'table'}); - this.showToast('Database created — '+d.name); - this.saveDBsToPage(); - this.renderInlineDBs(); + if(d.status==='converted'){ + this.showToast('Database created — reloading...'); + // Reload to show the database table view + setTimeout(()=>{window.location.href=d.view_url;},500); }else{ this.showToast('Failed to create database'); } diff --git a/app/templates/page_editor_collection.html b/app/templates/page_editor_collection.html new file mode 100644 index 0000000..a42b027 --- /dev/null +++ b/app/templates/page_editor_collection.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} {% block page_icon %}{{ fd_icon("file",14) }}{% endblock %} {% block +page_title %}{{ page.title }}{% endblock %} {% block topbar %} +{% set page_icon = "file" %} +{% set page_title = page.title %} +{% set right_actions = 'Edited ▾' %} +{% include '_header.html' %} +{% endblock %} {% block content %} +{% include "_database_table.html" %} +{% endblock %} {% block scripts %} + +{% include "_database_table_scripts.html" %} +{% endblock %} diff --git a/static/css/app.css b/static/css/app.css index cc98843..6b5a8cc 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -3425,3 +3425,442 @@ button.breadcrumb-current { max-width: 260px; } color: var(--text-dim); font-family: var(--font-sans); } + +/* ═══════════════════════════════════════════════════════════ + Database Table View (Notion-style) + ═══════════════════════════════════════════════════════════ */ + +.database-table-container { + width: 100%; + max-width: 100%; + padding: 0 96px; + margin-top: 24px; + box-sizing: border-box; +} + +/* ── View Bar ── */ +.db-view-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 0 8px 0; + border-bottom: 1px solid var(--border); + margin-bottom: 0; +} + +.db-view-tabs { + display: flex; + align-items: center; + gap: 2px; +} + +.db-view-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 14px; + font-family: var(--font-sans); + cursor: pointer; + transition: background 0.15s; +} +.db-view-tab.active { + background: var(--bg-tertiary); + color: var(--text-primary); +} +.db-view-tab:hover { background: var(--bg-hover); } + +.db-view-add { + display: flex; + align-items: center; + justify-content: center; + width: 28px; height: 28px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--text-dim); + cursor: pointer; + font-size: 14px; + font-family: var(--font-sans); +} +.db-view-add:hover { background: var(--bg-hover); } + +.db-view-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.db-btn-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; height: 28px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--text-dim); + cursor: pointer; +} +.db-btn-icon:hover { background: var(--bg-hover); } + +.db-btn-new { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 12px; + border-radius: 6px; + border: none; + background: var(--accent); + color: white; + font-size: 13px; + font-family: var(--font-sans); + cursor: pointer; + transition: background 0.15s; +} +.db-btn-new:hover { opacity: 0.9; } +.db-new-icon { display: flex; align-items: center; } + +/* ── Table ── */ +.db-table-wrapper { + overflow-x: auto; + border-radius: 4px; +} + +.db-table { + width: 100%; + border-collapse: collapse; + table-layout: auto; +} + +.db-header-row { + border-bottom: 1px solid var(--border); +} + +.db-th { + padding: 0; + vertical-align: middle; + position: relative; +} + +.db-th-handle { + width: 32px; + min-width: 32px; +} + +.db-th-property { + min-width: 200px; + border-right: 1px solid var(--border); +} + +.db-th-content { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + font-size: 12px; + color: var(--text-dim); + font-weight: 500; + font-family: var(--font-sans); +} + +.db-prop-type-icon { + font-size: 11px; + color: var(--text-dim); +} + +.db-prop-name { + white-space: nowrap; +} + +.db-th-add-prop { + min-width: 140px; +} + +.db-add-prop-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 8px; + border: none; + background: transparent; + color: var(--text-dim); + font-size: 12px; + font-family: var(--font-sans); + cursor: pointer; + white-space: nowrap; + transition: color 0.15s; +} +.db-add-prop-btn:hover { color: var(--text-primary); } + +/* ── Rows ── */ +.db-row { + border-bottom: 1px solid var(--border-light, #EDEDEC); + transition: background 0.1s; +} +.db-row:hover { background: var(--bg-hover); } +.db-row-new { border-bottom: 1px solid var(--border); } +.db-row-empty { height: 32px; } +.db-row-selected { background: var(--bg-active); } + +.db-td { + padding: 3px 8px; + vertical-align: middle; + position: relative; + height: 32px; +} + +.db-td-handle { + width: 32px; + min-width: 32px; + text-align: center; + padding: 3px 0; +} + +.db-drag-handle { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: grab; + color: var(--text-dim); + opacity: 0.6; + transition: opacity 0.1s; +} +.db-drag-handle:hover { opacity: 1; } + +.db-plus-icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-dim); +} + +.db-new-page-btn { + display: flex; + align-items: center; + width: 100%; + padding: 6px 8px; + border: none; + background: transparent; + color: var(--text-dim); + font-size: 14px; + font-family: var(--font-sans); + cursor: pointer; + text-align: left; +} +.db-new-page-btn:hover { color: var(--text-primary); } + +.db-td-cell { + min-width: 200px; + border-right: 1px solid var(--border); + cursor: pointer; +} + +.db-cell-content { + display: flex; + align-items: center; + min-height: 22px; + padding: 0 2px; + border-radius: 3px; +} +.db-cell-content:hover { background: var(--bg-hover); } + +.db-cell-editing .db-cell-value { display: none; } + +.db-cell-value { + font-size: 14px; + color: var(--text-primary); + font-family: var(--font-sans); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.db-cell-input { + width: 100%; + padding: 3px 6px; + border: 1px solid var(--accent, #2383E2); + border-radius: 3px; + font-size: 14px; + font-family: var(--font-sans); + color: var(--text-primary); + background: var(--bg-primary); + outline: none; + box-sizing: border-box; +} + +.db-td-open { + width: 40px; + min-width: 40px; + text-align: center; + padding: 3px 0; +} + +.db-open-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; height: 28px; + border-radius: 4px; + border: none; + background: transparent; + color: var(--text-dim); + cursor: pointer; +} +.db-open-btn:hover { background: var(--bg-tertiary); color: var(--text-primary); } + +/* ── Dropdown ── */ +.db-dropdown { + position: absolute; + top: 100%; + left: 0; + margin-top: 4px; + min-width: 200px; + background: var(--bg-modal); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + z-index: 500; + padding: 4px 0; +} + +.db-dropdown-header { + padding: 6px 12px; + font-size: 11px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + font-family: var(--font-sans); +} + +.db-dropdown-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + font-size: 14px; + color: var(--text-primary); + font-family: var(--font-sans); + cursor: pointer; + transition: background 0.1s; +} +.db-dropdown-item:hover { background: var(--bg-hover); } + +/* ── Modal ── */ +.db-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.3); + z-index: 500; + display: flex; + align-items: center; + justify-content: center; +} + +.db-modal { + background: var(--bg-modal); + border-radius: 8px; + box-shadow: 0 8px 30px rgba(0,0,0,0.15); + width: 420px; + max-width: 90vw; + overflow: hidden; +} + +.db-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} +.db-modal-header h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + font-family: var(--font-sans); + color: var(--text-primary); +} +.db-modal-header button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; height: 28px; + border-radius: 6px; + border: none; + background: transparent; + cursor: pointer; + color: var(--text-dim); +} +.db-modal-header button:hover { background: var(--bg-hover); } + +.db-modal-body { padding: 16px; } + +.db-modal-field { + margin-bottom: 12px; +} +.db-modal-field label { + display: block; + margin-bottom: 4px; + font-size: 12px; + color: var(--text-dim); + font-family: var(--font-sans); +} +.db-modal-field input, +.db-modal-field select { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 14px; + font-family: var(--font-sans); + color: var(--text-primary); + background: var(--bg-primary); + outline: none; + box-sizing: border-box; +} +.db-modal-field input:focus, +.db-modal-field select:focus { + border-color: var(--accent, #2383E2); +} + +.db-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +.db-btn-cancel { + padding: 6px 14px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-primary); + color: var(--text-secondary); + font-size: 13px; + font-family: var(--font-sans); + cursor: pointer; +} +.db-btn-cancel:hover { background: var(--bg-hover); } + +.db-btn-create { + padding: 6px 14px; + border-radius: 6px; + border: none; + background: var(--accent, #2383E2); + color: white; + font-size: 13px; + font-family: var(--font-sans); + cursor: pointer; +} +.db-btn-create:hover { opacity: 0.9; } + +/* ── Responsive ── */ +@media (max-width: 768px) { + .database-table-container { padding: 0 16px; } + .db-th-property { min-width: 120px; } + .db-td-cell { min-width: 120px; } +}