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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user