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 @@
+
+
+
+
+
+
+
+
+
+
+
{{ fd_icon("table",14) }} Table
+
{{ fd_icon("columns",14) }} Board
+
{{ fd_icon("list",14) }} List
+
{{ fd_icon("calendar",14) }} Calendar
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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; }
+}