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
+6
View File
@@ -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:
+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",
}
+181
View File
@@ -0,0 +1,181 @@
<!-- ═══════════ Database Table View (Notion-style) ═══════════ -->
<div x-data="databaseTable()" x-init="init($el)" class="database-table-container">
<!-- View Selector Bar -->
<div class="db-view-bar">
<div class="db-view-tabs">
<button class="db-view-tab active"
@click="switchView('table')">
{{ fd_icon("table",14) }} Table
</button>
<button class="db-view-add" @click="addViewMenuOpen=!addViewMenuOpen"
title="Add a view">
{{ fd_icon("plus",14) }}
</button>
<!-- View type dropdown -->
<div x-show="addViewMenuOpen" @click.outside="addViewMenuOpen=false"
class="db-dropdown" x-transition>
<div class="db-dropdown-header">Add a new view</div>
<div class="db-dropdown-item" @click="addViewMenuOpen=false">{{ fd_icon("table",14) }} Table</div>
<div class="db-dropdown-item" @click="addViewMenuOpen=false">{{ fd_icon("columns",14) }} Board</div>
<div class="db-dropdown-item" @click="addViewMenuOpen=false">{{ fd_icon("list",14) }} List</div>
<div class="db-dropdown-item" @click="addViewMenuOpen=false">{{ fd_icon("calendar",14) }} Calendar</div>
</div>
</div>
<div class="db-view-actions">
<button class="db-btn-icon" title="Filter">{{ fd_icon("filter",14) }}</button>
<button class="db-btn-new" @click="addNewPage()">
<span class="db-new-icon">{{ fd_icon("plus",12) }}</span> New
</button>
</div>
</div>
<!-- Database Table -->
<div class="db-table-wrapper">
<table class="db-table">
<thead>
<tr class="db-header-row">
<!-- Drag handle column -->
<th class="db-th db-th-handle" style="width:32px"></th>
<!-- Property columns -->
<template x-for="prop in properties" :key="prop.id">
<th class="db-th db-th-property">
<div class="db-th-content">
<span class="db-prop-type-icon" x-text="propTypeIcon(prop.prop_type)"></span>
<span class="db-prop-name" x-text="prop.name"></span>
</div>
</th>
</template>
<!-- Add Property column -->
<th class="db-th db-th-add-prop">
<button class="db-add-prop-btn" @click="addProperty()"
title="Add a property">
{{ fd_icon("plus",14) }} Add property
</button>
</th>
</tr>
</thead>
<tbody>
<!-- New Page row (always first) -->
<tr class="db-row db-row-new">
<td class="db-td db-td-handle">
<span class="db-plus-icon">{{ fd_icon("plus",14) }}</span>
</td>
<td class="db-td db-td-name" colspan="99">
<button class="db-new-page-btn" @click="addNewPage()">
New page
</button>
</td>
</tr>
<!-- Data rows -->
<template x-for="(cpage, idx) in pages" :key="cpage.id">
<tr class="db-row"
@mouseenter="cpage._hover=true"
@mouseleave="cpage._hover=false"
:class="{ 'db-row-selected': cpage._selected }">
<!-- Drag handle / hover actions -->
<td class="db-td db-td-handle">
<span class="db-drag-handle" x-show="cpage._hover"
@mousedown.prevent="startDrag(idx, $event)">
{{ fd_icon("grip-vertical",14) }}
</span>
</td>
<!-- Property cells -->
<template x-for="prop in properties" :key="prop.id">
<td class="db-td db-td-cell">
<div class="db-cell-content"
:class="{'db-cell-editing': editingCell && editingCell.pageId === cpage.id && editingCell.propId === prop.id}"
@click="startEditCell(cpage.id, prop.id, $event)"
@dblclick="openPage(cpage.id)">
<!-- Render property value -->
<span x-show="!(editingCell && editingCell.pageId === cpage.id && editingCell.propId === prop.id)"
x-text="cellValue(cpage, prop) || '—'"
class="db-cell-value"></span>
<!-- Edit mode -->
<input x-show="editingCell && editingCell.pageId === cpage.id && editingCell.propId === prop.id"
x-ref="cellInput"
:value="cellValue(cpage, prop)"
@keydown.enter="commitEditCell(cpage, prop)"
@keydown.escape="cancelEditCell()"
@blur="commitEditCell(cpage, prop)"
class="db-cell-input" />
</div>
</td>
</template>
<!-- Open button on hover -->
<td class="db-td db-td-open" x-show="cpage._hover">
<button class="db-open-btn" @click="openPage(cpage.id)"
title="Open">
{{ fd_icon("external-link",14) }}
</button>
</td>
</tr>
</template>
<!-- Empty rows (fill to 5 total if less than 4 data rows) -->
<template x-for="i in emptyRows()" :key="'e'+i">
<tr class="db-row db-row-empty">
<td class="db-td db-td-handle"></td>
<template x-for="prop in properties" :key="prop.id">
<td class="db-td db-td-cell"><div class="db-cell-content"></div></td>
</template>
<td class="db-td db-td-open"></td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Add Property Modal -->
<div x-show="propModalOpen" class="db-modal-overlay" @click.self="propModalOpen=false">
<div class="db-modal" x-transition>
<div class="db-modal-header">
<h3>Add a property</h3>
<button @click="propModalOpen=false">{{ fd_icon("x",14) }}</button>
</div>
<div class="db-modal-body">
<div class="db-modal-field">
<label>Name</label>
<input x-model="newPropName" type="text" placeholder="Property name"
@keydown.enter="createProperty()" />
</div>
<div class="db-modal-field">
<label>Type</label>
<select x-model="newPropType">
<option value="text">Text</option>
<option value="number">Number</option>
<option value="select">Select</option>
<option value="multi_select">Multi-select</option>
<option value="date">Date</option>
<option value="checkbox">Checkbox</option>
<option value="url">URL</option>
<option value="email">Email</option>
<option value="phone">Phone</option>
</select>
</div>
<div class="db-modal-actions">
<button class="db-btn-cancel" @click="propModalOpen=false">Cancel</button>
<button class="db-btn-create" @click="createProperty()">Create</button>
</div>
</div>
</div>
</div>
<!-- Page title rename (inline) -->
<div x-show="renameOpen" class="db-modal-overlay" @click.self="renameOpen=false" style="z-index:600">
<div class="db-modal" x-transition style="max-width:400px">
<div class="db-modal-header">
<h3>Rename page</h3>
<button @click="renameOpen=false">{{ fd_icon("x",14) }}</button>
</div>
<div class="db-modal-body">
<div class="db-modal-field">
<input x-model="renameTitle" type="text" placeholder="Page title"
@keydown.enter="commitRename()" x-ref="renameInput" />
</div>
<div class="db-modal-actions">
<button class="db-btn-cancel" @click="renameOpen=false">Cancel</button>
<button class="db-btn-create" @click="commitRename()">Save</button>
</div>
</div>
</div>
</div>
</div>
+248
View File
@@ -0,0 +1,248 @@
<!-- ═══════════ Database Table Scripts ═══════════ -->
<script>
// Server-rendered initial data
const COLLECTION_ID = window.__DB_COLLECTION_ID || 0;
const PAGE_ID = window.__DB_PAGE_ID || 0;
Alpine.data('databaseTable', () => ({
// State
pages: [],
properties: [],
addViewMenuOpen: false,
propModalOpen: false,
newPropName: '',
newPropType: 'text',
editingCell: null,
renameOpen: false,
renameTitle: '',
renamePageId: null,
dragIdx: null,
// ── Init ──
async init(el) {
{% if collection_data %}
// Preload from server
this.properties = {{ collection_data.properties | tojson }};
this.pages = ({{ collection_data.pages | tojson }}).map(p => ({...p, _hover: false, _selected: false}));
{% else %}
await this.loadData();
{% endif %}
console.log('[DB-Table] Init: collection_id=' + COLLECTION_ID, this.properties.length + ' props, ' + this.pages.length + ' pages');
},
async loadData() {
try {
const r = await fetch('/api/collections/' + COLLECTION_ID + '/table-data');
const d = await r.json();
this.properties = d.properties || [];
this.pages = (d.pages || []).map(p => ({...p, _hover: false, _selected: false}));
} catch(e) {
console.error('[DB-Table] loadData error:', e);
}
},
// ── Helpers ──
propTypeIcon(type) {
const icons = {
title: 'Aa', text: 'Aa', number: '#', select: '☰',
multi_select: '☰', date: '📅', checkbox: '☑',
url: '🔗', email: '✉', phone: '📞'
};
return icons[type] || 'Aa';
},
cellValue(cpage, prop) {
if (!cpage.property_values_json) return '';
let pv;
try {
pv = typeof cpage.property_values_json === 'string'
? JSON.parse(cpage.property_values_json)
: cpage.property_values_json;
} catch(e) { return ''; }
const val = pv[String(prop.id)];
if (val === undefined || val === null) {
return prop.prop_type === 'title' ? cpage.title : '';
}
if (Array.isArray(val)) return val.join(', ');
return String(val);
},
emptyRows() {
// Aim for 5 visible rows total (1 New Page + data rows + empty)
const dataRows = this.pages.length;
const needed = Math.max(0, 4 - dataRows); // 4 data rows + 1 New = 5
return Array.from({length: needed}, (_, i) => i);
},
// ── Actions ──
async addNewPage() {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
try {
const r = await fetch('/api/collections/' + COLLECTION_ID + '/pages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf ? csrf[1] : ''
},
body: JSON.stringify({title: 'New page'})
});
const d = await r.json();
if (d.id) {
this.pages.push({...d, _hover: false, _selected: false});
console.log('[DB-Table] New page created:', d.id);
}
} catch(e) {
console.error('[DB-Table] addNewPage error:', e);
}
},
async addProperty() {
this.propModalOpen = true;
this.newPropName = '';
this.newPropType = 'text';
},
async createProperty() {
if (!this.newPropName.trim()) return;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
try {
const r = await fetch('/db/' + COLLECTION_ID + '/properties/api', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf ? csrf[1] : ''
},
body: JSON.stringify({
name: this.newPropName.trim(),
prop_type: this.newPropType
})
});
const d = await r.json();
if (d.id) {
this.properties.push(d);
this.propModalOpen = false;
console.log('[DB-Table] Property created:', d.name);
}
} catch(e) {
console.error('[DB-Table] createProperty error:', e);
}
},
// ── Cell editing ──
startEditCell(pageId, propId, event) {
// Click on cell starts editing
this.editingCell = {pageId, propId};
this.$nextTick(() => {
const input = this.$refs.cellInput;
if (input) {
if (Array.isArray(input)) input[0]?.focus();
else input.focus();
}
});
},
async commitEditCell(cpage, prop) {
if (!this.editingCell) return;
const input = this.$refs.cellInput;
const val = Array.isArray(input) ? input[0]?.value : input?.value;
this.editingCell = null;
if (val === undefined) return;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
try {
// Update local state first
let pv;
try {
pv = typeof cpage.property_values_json === 'string'
? JSON.parse(cpage.property_values_json)
: cpage.property_values_json;
} catch(e) { pv = {}; }
pv[String(prop.id)] = val;
cpage.property_values_json = pv;
// If it's the title property, also update the title field
if (prop.prop_type === 'title') {
cpage.title = val;
}
// Persist to server
await fetch('/db/pages/' + cpage.id + '/api', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf ? csrf[1] : ''
},
body: JSON.stringify({
title: cpage.title,
properties: pv
})
});
} catch(e) {
console.error('[DB-Table] commitEditCell error:', e);
}
},
cancelEditCell() {
this.editingCell = null;
},
// ── Open page ──
openPage(cpageId) {
// Open the collection page as a regular page
window.location.href = '/pages/' + cpageId;
},
// ── Drag (placeholder) ──
startDrag(idx, event) {
this.dragIdx = idx;
console.log('[DB-Table] Drag start:', idx);
},
// ── Rename ──
startRename(cpage) {
this.renamePageId = cpage.id;
this.renameTitle = cpage.title;
this.renameOpen = true;
this.$nextTick(() => {
const input = this.$refs.renameInput;
if (input) {
if (Array.isArray(input)) input[0]?.focus();
else input.focus();
}
});
},
async commitRename() {
if (!this.renamePageId || !this.renameTitle.trim()) {
this.renameOpen = false;
return;
}
const cpage = this.pages.find(p => p.id === this.renamePageId);
if (cpage) cpage.title = this.renameTitle.trim();
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
try {
await fetch('/db/pages/' + this.renamePageId + '/api', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf ? csrf[1] : ''
},
body: JSON.stringify({
title: this.renameTitle.trim()
})
});
} catch(e) {
console.error('[DB-Table] rename error:', e);
}
this.renameOpen = false;
this.renamePageId = null;
},
// ── View switching (placeholder) ──
switchView(type) {
console.log('[DB-Table] Switch view to:', type);
}
}));
</script>
+7 -8
View File
@@ -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');
}
+16
View File
@@ -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 = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()">' ~ fd_icon("lock",14) ~ ' Share ▾</button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% include '_header.html' %}
{% endblock %} {% block content %}
{% include "_database_table.html" %}
{% endblock %} {% block scripts %}
<script>
// Initialize database table from server-rendered data
window.__DB_PAGE_ID = {{ page.id }};
window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }};
</script>
{% include "_database_table_scripts.html" %}
{% endblock %}
+439
View File
@@ -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; }
}