diff --git a/app/db.py b/app/db.py
index ded9473..b5eeed3 100644
--- a/app/db.py
+++ b/app/db.py
@@ -152,6 +152,12 @@ def init_db():
conn.execute("ALTER TABLE pages ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
+ # Migration: add content_format for Notion-style block editor (v1.2.0+)
+ # 'markdown' = legacy textarea, 'blocks' = JSON array of block objects
+ try:
+ conn.execute("ALTER TABLE pages ADD COLUMN content_format TEXT NOT NULL DEFAULT 'markdown'")
+ except sqlite3.OperationalError:
+ pass
conn.commit()
diff --git a/app/routers/board.py b/app/routers/board.py
index 8175286..b1a560d 100644
--- a/app/routers/board.py
+++ b/app/routers/board.py
@@ -129,7 +129,7 @@ def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, m
items.append({
"id": f"page/{row['id']}",
"db_id": row["id"],
- "name": row["title"],
+ "name": row["title"] or "New page",
"icon": "π",
"url": f"/pages/{row['id']}",
"active": False,
@@ -459,7 +459,7 @@ async def extract_ai_keywords(owner: str, repo: str):
# βββββββββββ Pages Markdown βββββββββββ
@router.post("/api/pages")
-async def create_page(request: Request, title: str = Query(default="New page"),
+async def create_page(request: Request, title: str = Query(default=""),
section: str = Query(default="Private"),
project: str = Query(default=""),
parent_id: int = Query(default=0)):
@@ -496,18 +496,41 @@ async def get_page(page_id: int):
@router.put("/api/pages/{page_id}")
-async def update_page(page_id: int, title: str = Query(default=""),
- content: str = Query(default="")):
- """Update a page's title and/or content."""
+async def update_page(request: Request, page_id: int, title: str = Query(default=""),
+ content: str = Query(default=""),
+ content_format: str = Query(default="")):
+ """Update a page's title and/or content. Accepts JSON body for blocks."""
with get_conn() as conn:
if title:
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
if content:
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id))
+ if content_format:
+ conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
conn.commit()
return {"status": "ok"}
+@router.post("/api/pages/{page_id}/blocks")
+async def save_page_blocks(request: Request, page_id: int):
+ """Save blocks JSON content (Notion-style block editor)."""
+ try:
+ body = await request.json()
+ except Exception:
+ raise HTTPException(400, "Invalid JSON body")
+ blocks_json = json.dumps(body.get("blocks", []))
+ title = body.get("title", "")
+ with get_conn() as conn:
+ if title:
+ conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
+ conn.execute(
+ "UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
+ (blocks_json, page_id),
+ )
+ conn.commit()
+ return {"status": "ok", "id": page_id}
+
+
@router.put("/api/pages/{page_id}/move")
async def move_page(request: Request, page_id: int,
new_parent_id: int = Query(default=0),
@@ -547,6 +570,25 @@ async def move_page(request: Request, page_id: int,
return {"status": "ok", "id": page_id, "parent_id": new_parent_id, "order": new_order}
+@router.delete("/api/pages/{page_id}")
+async def delete_page(request: Request, page_id: int):
+ """Move a page to trash (soft delete) or permanently delete."""
+ with get_conn() as conn:
+ row = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
+ if not row:
+ raise HTTPException(404, "Page not found")
+ # Soft delete: set deleted_at timestamp
+ # For now, hard delete since we have no trash view yet
+ # If sub-pages exist, move them to root level
+ conn.execute(
+ "UPDATE pages SET parent_id=NULL, sort_order=0 WHERE parent_id=?",
+ (page_id,),
+ )
+ conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
+ conn.commit()
+ return {"status": "ok", "deleted": page_id, "title": row["title"]}
+
+
@router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page(request: Request, page_id: int):
"""Render a page as HTML."""
diff --git a/app/templates/base.html b/app/templates/base.html
index d1366e5..e12faba 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -389,6 +389,7 @@
// ββ Drag & drop tree ββ
treeSortables: [],
initTreeSortable() {
+ if (typeof Sortable === 'undefined') return;
// Initialize SortableJS on every .sidebar-items list
const self = this;
document.querySelectorAll('.sidebar-items').forEach(function(el) {
@@ -475,8 +476,29 @@
alert(`Moved to ${section}`);
break;
case 'delete':
- if (!confirm(`Delete "${pageName}"?`)) break;
- alert('Delete not yet implemented (pages go to Trash in DB)');
+ if (!confirm(`Move "${pageName}" to trash?`)) break;
+ const delNum = pageId.replace('page/', '');
+ fetch(`/board/api/pages/${delNum}`, {
+ method: 'DELETE', headers: { 'X-CSRF-Token': csrf }
+ })
+ .then(r => { if (!r.ok) throw new Error('Delete failed'); return r.json(); })
+ .then(data => {
+ // Remove from ALL sidebar sections (Recents, Private, etc.)
+ const items = document.querySelectorAll(`.sidebar-item[data-page-id="${pageId}"]`);
+ items.forEach(item => {
+ const next = item.nextElementSibling;
+ if (next && next.classList.contains('tree-children-wrapper')) {
+ next.remove();
+ }
+ item.remove();
+ });
+ // Redirect if viewing deleted page β stay in project context
+ if (window.location.pathname.includes(`/pages/${delNum}`)) {
+ const wk = this.workspaceKey;
+ window.location.href = wk && wk.includes('/') ? `/board/${wk}` : '/';
+ }
+ })
+ .catch(e => alert('Delete failed: ' + e.message));
break;
case 'openTab':
window.open('/' + pageId, '_blank');
@@ -501,7 +523,7 @@
},
newPage(section) {
const project = this.workspaceKey || '';
- fetch(`/board/api/pages?title=New%20page§ion=${encodeURIComponent(section)}&project=${encodeURIComponent(project)}`, {
+ fetch(`/board/api/pages?section=${encodeURIComponent(section)}&project=${encodeURIComponent(project)}`, {
method: 'POST',
headers: { 'X-CSRF-Token': this.getCsrfToken() }
})
@@ -512,7 +534,7 @@
newSubPage(parentId) {
const project = this.workspaceKey || '';
const pid = parentId.replace('page/', '');
- fetch(`/board/api/pages?title=New%20sub-page§ion=Private&project=${encodeURIComponent(project)}&parent_id=${pid}`, {
+ fetch(`/board/api/pages?section=Private&project=${encodeURIComponent(project)}&parent_id=${pid}`, {
method: 'POST',
headers: { 'X-CSRF-Token': this.getCsrfToken() }
})
diff --git a/app/templates/page_editor.html b/app/templates/page_editor.html
index 9548642..95b4f5c 100644
--- a/app/templates/page_editor.html
+++ b/app/templates/page_editor.html
@@ -3,229 +3,380 @@
{% block page_title %}{{ page.title }}{% endblock %}
{% block content %}
-
-
- π
-
{{ page.title }}
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Preview
-
+
+
+
π
+
{{ page.title }}
-
-
-
β Saved
-
β Unsaved
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+ πΌ Click or drop image
+
+
+
+
+
+
+
Press '/' for commands, or just start typing...
-
-
-
π
-
Get started with
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
Sub-pages
- {% for sub in sub_pages %}
-
- π {{ sub.title }}
-
- {% endfor %}
-
+
+ Saving...
+ Unsaved
+ Saved
+
+
{% endblock %}
{% block scripts %}
{% endblock %}
diff --git a/static/css/app.css b/static/css/app.css
index 99960b2..1cf9c65 100644
--- a/static/css/app.css
+++ b/static/css/app.css
@@ -1500,3 +1500,574 @@ input, select, textarea { font-family: inherit; color: inherit; }
/* Sortable ghost */
.sortable-ghost { opacity: 0.4; }
.sortable-drag { box-shadow: 0 8px 24px rgba(0,0,0,0.4) !important; }
+
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ NOTION-STYLE BLOCK EDITOR
+ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+
+/* Editor wrapper */
+.page-editor-wrapper {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0;
+ min-height: 100%;
+}
+
+/* Page title area */
+.page-cover-area {
+ padding: 48px 64px 12px;
+ max-width: 900px;
+ margin: 0 auto;
+}
+
+.page-title-block {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+}
+
+.page-icon-emoji {
+ font-size: 36px;
+ cursor: pointer;
+ flex-shrink: 0;
+ padding-top: 2px;
+ user-select: none;
+}
+
+.page-title-input {
+ flex: 1;
+ font-size: 40px;
+ font-weight: 700;
+ line-height: 1.2;
+ color: var(--text-primary);
+ outline: none;
+ min-height: 50px;
+ padding: 0;
+ border: none;
+ word-break: break-word;
+}
+.page-title-input:empty::before {
+ content: 'New page';
+ color: var(--text-dim);
+ pointer-events: none;
+}
+
+/* Blocks container */
+.blocks-container {
+ padding: 8px 64px 96px;
+ max-width: 900px;
+ margin: 0 auto;
+ min-height: 300px;
+}
+
+.blocks-empty {
+ padding: 40px 0;
+ text-align: center;
+}
+
+.blocks-empty-hint {
+ color: var(--text-dim);
+ font-size: 15px;
+}
+
+/* Block wrapper */
+.block-wrapper {
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ gap: 4px;
+ padding: 2px 0;
+ margin: 1px 0;
+ border-radius: 4px;
+ transition: background 0.1s ease;
+}
+.block-wrapper:hover {
+ background: rgba(255,255,255,0.02);
+}
+
+/* 6-dot drag handle */
+.block-handle {
+ position: absolute;
+ left: -40px;
+ top: 4px;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: grab;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+ border-radius: 3px;
+ fill: var(--text-dim);
+}
+.block-handle.visible {
+ opacity: 1;
+}
+.block-handle:hover {
+ background: var(--bg-hover);
+ fill: var(--text-secondary);
+}
+.block-handle:active {
+ cursor: grabbing;
+}
+
+/* Block actions (+ button) */
+.block-actions {
+ position: absolute;
+ right: -60px;
+ top: 2px;
+ display: flex;
+ gap: 2px;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+}
+.block-actions.visible {
+ opacity: 1;
+}
+
+.block-add-btn {
+ width: 22px;
+ height: 22px;
+ border-radius: 3px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-dim);
+ font-size: 16px;
+ cursor: pointer;
+ transition: background 0.1s;
+}
+.block-add-btn:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+.block-drag-btn {
+ width: 22px;
+ height: 22px;
+ border-radius: 3px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-dim);
+ font-size: 11px;
+ cursor: grab;
+ transition: background 0.1s;
+}
+.block-drag-btn:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+/* Block content (base) */
+.block-content {
+ flex: 1;
+ font-size: 16px;
+ line-height: 1.6;
+ color: var(--text-primary);
+ outline: none;
+ padding: 3px 2px;
+ min-height: 28px;
+ word-break: break-word;
+}
+.block-content:empty::before {
+ content: attr(data-placeholder);
+ color: #9B9A97;
+ pointer-events: none;
+}
+
+/* Headings */
+.block-h1 {
+ font-size: 32px;
+ font-weight: 700;
+ line-height: 1.2;
+ padding: 8px 2px 4px;
+}
+.block-h2 {
+ font-size: 24px;
+ font-weight: 600;
+ line-height: 1.3;
+ padding: 6px 2px 3px;
+}
+.block-h3 {
+ font-size: 20px;
+ font-weight: 600;
+ line-height: 1.3;
+ padding: 4px 2px 2px;
+}
+
+/* Bulleted list */
+.block-bullet {
+ padding-left: 18px;
+ position: relative;
+}
+.block-bullet::before {
+ content: '\2022';
+ position: absolute;
+ left: 2px;
+ top: 3px;
+ color: var(--text-primary);
+ font-size: 16px;
+}
+
+/* Numbered list */
+.block-numbered {
+ padding-left: 22px;
+ position: relative;
+}
+
+/* To-do */
+.block-todo {
+ display: flex;
+ align-items: flex-start;
+ gap: 6px;
+ padding: 2px 2px;
+}
+.block-todo .todo-checkbox {
+ margin-top: 5px;
+ width: 16px;
+ height: 16px;
+ accent-color: var(--blue);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+.block-todo div {
+ flex: 1;
+ outline: none;
+ font-size: 16px;
+ line-height: 1.6;
+ min-height: 26px;
+ word-break: break-word;
+}
+.block-todo div:empty::before {
+ content: attr(data-placeholder);
+ color: #9B9A97;
+ pointer-events: none;
+}
+
+/* Toggle */
+.block-toggle {
+ display: flex;
+ align-items: flex-start;
+ gap: 4px;
+}
+.toggle-chevron {
+ width: 20px;
+ height: 20px;
+ border-radius: 3px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-primary);
+ font-size: 10px;
+ cursor: pointer;
+ flex-shrink: 0;
+ margin-top: 4px;
+ transition: transform 0.2s ease;
+}
+.toggle-chevron.open {
+ transform: rotate(90deg);
+}
+.toggle-chevron:hover {
+ background: var(--bg-hover);
+}
+.block-toggle div {
+ flex: 1;
+ outline: none;
+ font-size: 16px;
+ line-height: 1.6;
+ min-height: 26px;
+ word-break: break-word;
+}
+.block-toggle div:empty::before {
+ content: attr(data-placeholder);
+ color: #9B9A97;
+ pointer-events: none;
+}
+
+/* Quote */
+.block-quote {
+ border-left: 3px solid var(--text-primary);
+ padding: 4px 14px;
+ margin: 4px 0;
+}
+
+/* Code block */
+.block-code {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ margin: 8px 0;
+ overflow: hidden;
+}
+.code-lang-label {
+ padding: 4px 12px;
+ font-size: 12px;
+ color: var(--text-dim);
+ background: var(--bg-tertiary);
+ border-bottom: 1px solid var(--border);
+}
+.block-code pre {
+ margin: 0;
+ padding: 12px 16px;
+ overflow-x: auto;
+}
+.block-code code {
+ outline: none;
+ font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--text-primary);
+ white-space: pre-wrap;
+ word-break: break-word;
+ display: block;
+ min-height: 28px;
+}
+.block-code code:empty::before {
+ content: 'Type code...';
+ color: #9B9A97;
+}
+
+/* Divider */
+.block-divider {
+ border: none;
+ border-top: 1px solid var(--border);
+ margin: 8px 0;
+ width: 100%;
+}
+
+/* Callout */
+.block-callout {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 14px 16px;
+ border-radius: 4px;
+ margin: 4px 0;
+}
+.block-callout .callout-icon {
+ font-size: 20px;
+ flex-shrink: 0;
+}
+.block-callout div {
+ flex: 1;
+ outline: none;
+ font-size: 16px;
+ line-height: 1.6;
+ min-height: 24px;
+}
+.block-callout div:empty::before {
+ content: attr(data-placeholder);
+ color: #9B9A97;
+ pointer-events: none;
+}
+
+/* Image block */
+.block-image-wrapper {
+ margin: 8px 0;
+}
+.image-placeholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 120px;
+ background: var(--bg-secondary);
+ border: 1px dashed var(--border);
+ border-radius: 4px;
+ color: var(--text-dim);
+ cursor: pointer;
+ transition: background 0.15s;
+}
+.image-placeholder:hover {
+ background: var(--bg-hover);
+}
+
+/* SLASH COMMAND MENU */
+.slash-menu {
+ position: fixed;
+ z-index: 999;
+ background: var(--bg-modal);
+ border: 1px solid var(--border-light);
+ border-radius: 8px;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.5);
+ width: 320px;
+ max-height: 400px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.slash-search {
+ padding: 8px;
+ border-bottom: 1px solid var(--border);
+}
+.slash-search input {
+ width: 100%;
+ padding: 6px 10px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text-primary);
+ font-size: 14px;
+ outline: none;
+}
+.slash-search input:focus {
+ border-color: var(--blue);
+}
+
+.slash-groups {
+ overflow-y: auto;
+ padding: 4px 0;
+}
+
+.slash-group {
+ padding: 0;
+}
+.slash-group + .slash-group {
+ border-top: 1px solid var(--border);
+}
+
+.slash-group-label {
+ padding: 6px 12px 4px;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.slash-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 8px 12px;
+ font-size: 14px;
+ color: var(--text-primary);
+ text-align: left;
+ cursor: pointer;
+ transition: background 0.1s;
+ border-radius: 0;
+}
+.slash-item:hover,
+.slash-item.selected {
+ background: var(--bg-hover);
+}
+
+.slash-item-icon {
+ width: 28px;
+ height: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ background: var(--bg-secondary);
+ border-radius: 4px;
+ flex-shrink: 0;
+}
+
+.slash-item-info {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+.slash-item-name {
+ font-weight: 500;
+ font-size: 14px;
+}
+.slash-item-desc {
+ font-size: 12px;
+ color: var(--text-dim);
+}
+
+.slash-item-shortcut {
+ font-size: 12px;
+ color: var(--text-dim);
+ font-family: var(--font-mono);
+ flex-shrink: 0;
+}
+
+/* FORMAT TOOLBAR (FLOATING) */
+.format-toolbar {
+ position: fixed;
+ z-index: 998;
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ background: var(--bg-modal);
+ border: 1px solid var(--border-light);
+ border-radius: 8px;
+ box-shadow: 0 4px 16px rgba(0,0,0,0.4);
+ padding: 4px 6px;
+}
+
+.format-toolbar button {
+ width: 30px;
+ height: 28px;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-primary);
+ font-size: 14px;
+ cursor: pointer;
+ transition: background 0.1s;
+}
+.format-toolbar button:hover {
+ background: var(--bg-hover);
+}
+
+.format-separator {
+ width: 1px;
+ height: 18px;
+ background: var(--border);
+ margin: 0 4px;
+}
+
+/* EDITOR STATUSBAR */
+.editor-statusbar {
+ position: fixed;
+ bottom: 0;
+ right: 0;
+ left: var(--sidebar-width);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 4px 16px;
+ background: var(--bg-primary);
+ border-top: 1px solid var(--border);
+ font-size: 12px;
+ color: var(--text-dim);
+ z-index: 50;
+ height: 28px;
+}
+.statusbar-right {
+ display: flex;
+ gap: 16px;
+}
+
+/* Inline formatting inside blocks */
+.block-content strong { font-weight: 700; }
+.block-content em { font-style: italic; }
+.block-content code {
+ background: var(--bg-tertiary);
+ padding: 1px 4px;
+ border-radius: 3px;
+ font-size: 0.9em;
+ font-family: var(--font-mono);
+ color: var(--pink);
+}
+.block-content s { text-decoration: line-through; }
+.block-content u { text-decoration: underline; }
+.block-content a { color: var(--blue); text-decoration: underline; }
+
+/* Mobile adjustments */
+@media (max-width: 768px) {
+ .page-cover-area { padding: 32px 16px 8px; }
+ .page-title-input { font-size: 28px; }
+ .blocks-container { padding: 8px 16px 96px; }
+ .block-handle { display: none; }
+ .block-actions { display: none; }
+ .editor-statusbar { left: 0; }
+}