- 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)
249 lines
8.0 KiB
HTML
249 lines
8.0 KiB
HTML
<!-- ═══════════ 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>
|