feat: Notion-style Library page — complete redesign
Complete rewrite of /library page to match Notion's Library design:
1. Table view with columns: Page name (with icon), Created by (avatar),
Source, Last edited time, Last visited time
2. Hover effects showing drag handle (6 dots), checkbox, OPEN button
3. Side peek panel: opens on right, shows page content preview,
close button, favorite toggle, copy link, more menu
4. Multi-selection: checkbox per row, select-all header, 'X selected' bar
with Delete and '...' menu
5. ... menu: Remove from Recents, Copy links to all, Move to, Move to Trash
6. Inline rename: double-click title → edit field
7. 6 tabs: Recents, Favorites, Shared, Published, Private, Workspace
8. Tree expand/collapse for nested pages (has_children support)
9. + Add new row at bottom
10. Search bar toggleable
API additions:
- library.py: enhanced _build_item with icon, source_label, author
- dashboard.py: new /api/pages/{id}/content, /rename, /trash endpoints
- All 133 tests pass
This commit is contained in:
@@ -1844,3 +1844,51 @@ def _render_blocks_public(blocks: list) -> str:
|
||||
else:
|
||||
html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>')
|
||||
return "\n".join(html_parts)
|
||||
|
||||
|
||||
# ═══════════ Library page actions API ═══════════
|
||||
|
||||
@router.get("/api/pages/{page_id:int}/content")
|
||||
async def api_page_content(page_id: int):
|
||||
"""Get page content for side peek preview."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT title, content, content_format FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
return {
|
||||
"title": row["title"],
|
||||
"content": row["content"],
|
||||
"format": row["content_format"] or "blocks",
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id:int}/rename")
|
||||
async def api_rename_page(page_id: int, request: Request):
|
||||
"""Inline rename a page title."""
|
||||
import json as _json
|
||||
body = await request.json()
|
||||
title = (body.get("title") or "").strip()
|
||||
if not title:
|
||||
return JSONResponse({"error": "Title required"}, status_code=400)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
|
||||
(title, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok", "title": title}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/trash")
|
||||
async def api_trash_page(page_id: int):
|
||||
"""Soft-delete a page (move to trash)."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_section='Trash', deleted_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
+82
-86
@@ -21,12 +21,6 @@ def _get_user_id(request: Request) -> int:
|
||||
|
||||
|
||||
def _detect_source_type(workspace: str) -> str:
|
||||
"""Detect source type from workspace field.
|
||||
|
||||
- '' or plain name (no slash) → local
|
||||
- 'owner/repo' format → gitea
|
||||
- 'github:owner/repo' format → github
|
||||
"""
|
||||
ws = (workspace or "").strip()
|
||||
if not ws:
|
||||
return "local"
|
||||
@@ -37,88 +31,102 @@ def _detect_source_type(workspace: str) -> str:
|
||||
return "local"
|
||||
|
||||
|
||||
def _source_badge(source_type: str) -> str:
|
||||
return {"local": "Local", "gitea": "Gitea", "github": "GitHub"}.get(source_type, "Local")
|
||||
def _source_label(source_type: str, workspace: str) -> str:
|
||||
if source_type == "gitea":
|
||||
return workspace
|
||||
elif source_type == "github":
|
||||
return workspace.replace("github:", "", 1)
|
||||
return workspace or "Private"
|
||||
|
||||
|
||||
def _build_item(db_row: dict) -> dict:
|
||||
"""Convert a DB row into a library item dict."""
|
||||
def _build_item(db_row: dict, uid: int = 1) -> dict:
|
||||
workspace = db_row.get("workspace", "") or ""
|
||||
source_type = _detect_source_type(workspace)
|
||||
# Extract workspace display name
|
||||
if source_type == "gitea":
|
||||
workspace_name = workspace # "owner/repo"
|
||||
elif source_type == "github":
|
||||
workspace_name = workspace.replace("github:", "", 1)
|
||||
else:
|
||||
workspace_name = workspace or "Private"
|
||||
|
||||
# URL construction
|
||||
page_id = db_row["id"]
|
||||
if source_type == "gitea":
|
||||
parts = workspace.split("/", 1)
|
||||
owner = parts[0] if len(parts) > 0 else ""
|
||||
repo = parts[1] if len(parts) > 1 else ""
|
||||
url = f"/gitea-workspace?owner={owner}&repo={repo}"
|
||||
elif source_type == "github":
|
||||
gh_ws = workspace.replace("github:", "", 1)
|
||||
parts = gh_ws.split("/", 1)
|
||||
owner = parts[0] if len(parts) > 0 else ""
|
||||
repo = parts[1] if len(parts) > 1 else ""
|
||||
url = f"/gitea-workspace?owner={owner}&repo={repo}"
|
||||
title = db_row.get("title") or "Untitled"
|
||||
|
||||
# URL
|
||||
if source_type in ("gitea", "github"):
|
||||
ws = workspace.replace("github:", "", 1) if source_type == "github" else workspace
|
||||
parts = ws.split("/", 1)
|
||||
url = f"/gitea-workspace?owner={parts[0]}&repo={parts[1] if len(parts) > 1 else ''}"
|
||||
else:
|
||||
url = f"/pages/{page_id}"
|
||||
|
||||
# Icon
|
||||
content_format = db_row.get("content_format", "blocks")
|
||||
if db_row.get("is_folder"):
|
||||
icon = "📁"
|
||||
elif content_format == "file":
|
||||
icon = "📄"
|
||||
fn = title.lower()
|
||||
if fn.endswith(".pdf"): icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜"
|
||||
else:
|
||||
icon = "📝"
|
||||
|
||||
return {
|
||||
"id": page_id,
|
||||
"title": db_row.get("title") or "Untitled",
|
||||
"icon": "📄",
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"source_type": source_type,
|
||||
"source_badge": _source_badge(source_type),
|
||||
"workspace_name": workspace_name,
|
||||
"source_label": _source_label(source_type, workspace),
|
||||
"workspace": workspace,
|
||||
"workspace_name": _source_label(source_type, workspace),
|
||||
"author": db_row.get("author") or "",
|
||||
"author_initial": (db_row.get("author") or "?")[0].upper(),
|
||||
"updated_at": db_row.get("updated_at", ""),
|
||||
"visited_at": db_row.get("visited_at") or "",
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
"url": url,
|
||||
"content_format": content_format,
|
||||
"favorited": bool(db_row.get("favorited", 0)),
|
||||
}
|
||||
|
||||
|
||||
def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[str, list]:
|
||||
"""Add WHERE clause for source_type filter. Mutates params in place."""
|
||||
if source_type == "local":
|
||||
query += " AND (workspace = '' OR workspace NOT LIKE '%/%' AND workspace NOT LIKE 'github:%')"
|
||||
query += " AND (p.workspace = '' OR (p.workspace NOT LIKE '%/%' AND p.workspace NOT LIKE 'github:%'))"
|
||||
elif source_type == "gitea":
|
||||
query += " AND workspace LIKE '%/%' AND workspace NOT LIKE 'github:%'"
|
||||
query += " AND p.workspace LIKE '%/%' AND p.workspace NOT LIKE 'github:%'"
|
||||
elif source_type == "github":
|
||||
query += " AND workspace LIKE 'github:%'"
|
||||
# 'all' → no filter
|
||||
query += " AND p.workspace LIKE 'github:%'"
|
||||
return query, params
|
||||
|
||||
|
||||
# ── Tab endpoints ──
|
||||
def _rows_to_items(rows, uid: int = 1) -> list:
|
||||
return [_build_item(dict(r), uid) for r in rows]
|
||||
|
||||
|
||||
# ═══════════ Tab endpoints ═══════════
|
||||
|
||||
BASE_SELECT = (
|
||||
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
|
||||
"p.parent_id, p.share_mode, p.parent_section"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/recents")
|
||||
async def library_recents(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
):
|
||||
"""Recent pages ordered by updated_at DESC."""
|
||||
if source_type not in SOURCE_TYPES:
|
||||
source_type = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = (
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"WHERE parent_section != 'Trash' AND deleted_at IS NULL"
|
||||
)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
params: list = []
|
||||
|
||||
query, params = _apply_source_filter(query, params, source_type)
|
||||
query += " ORDER BY updated_at DESC LIMIT 50"
|
||||
query += " ORDER BY p.updated_at DESC LIMIT 50"
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = [_build_item(dict(r)) for r in rows]
|
||||
items = _rows_to_items(rows, uid)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@@ -126,26 +134,26 @@ async def library_recents(
|
||||
async def library_favorites(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
):
|
||||
"""Favorite pages for the current user."""
|
||||
if source_type not in SOURCE_TYPES:
|
||||
source_type = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = (
|
||||
"SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f "
|
||||
"JOIN pages p ON p.id = f.page_id "
|
||||
"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
f"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
|
||||
f"p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
|
||||
f"FROM favorites f JOIN pages p ON p.id = f.page_id "
|
||||
f"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
)
|
||||
params: list = [uid]
|
||||
|
||||
query, params = _apply_source_filter(query, params, source_type)
|
||||
query += " ORDER BY f.position"
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = [_build_item(dict(r)) for r in rows]
|
||||
items = _rows_to_items(rows, uid)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@@ -153,24 +161,21 @@ async def library_favorites(
|
||||
async def library_shared(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
):
|
||||
"""Pages shared with others (share_mode != 'private')."""
|
||||
if source_type not in SOURCE_TYPES:
|
||||
source_type = "all"
|
||||
|
||||
query = (
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"WHERE share_mode != 'private' AND parent_section != 'Trash' AND deleted_at IS NULL"
|
||||
)
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.share_mode != 'private' AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
params: list = []
|
||||
|
||||
query, params = _apply_source_filter(query, params, source_type)
|
||||
query += " ORDER BY updated_at DESC LIMIT 50"
|
||||
query += " ORDER BY p.updated_at DESC LIMIT 50"
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = [_build_item(dict(r)) for r in rows]
|
||||
items = _rows_to_items(rows, uid)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@@ -178,24 +183,21 @@ async def library_shared(
|
||||
async def library_published(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
):
|
||||
"""Published pages (published = 1)."""
|
||||
if source_type not in SOURCE_TYPES:
|
||||
source_type = "all"
|
||||
|
||||
query = (
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"WHERE published = 1 AND parent_section != 'Trash' AND deleted_at IS NULL"
|
||||
)
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.published = 1 AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
params: list = []
|
||||
|
||||
query, params = _apply_source_filter(query, params, source_type)
|
||||
query += " ORDER BY updated_at DESC LIMIT 50"
|
||||
query += " ORDER BY p.updated_at DESC LIMIT 50"
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = [_build_item(dict(r)) for r in rows]
|
||||
items = _rows_to_items(rows, uid)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@@ -203,24 +205,21 @@ async def library_published(
|
||||
async def library_private(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
):
|
||||
"""Private pages (parent_section = 'Private')."""
|
||||
if source_type not in SOURCE_TYPES:
|
||||
source_type = "all"
|
||||
|
||||
query = (
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"WHERE parent_section = 'Private' AND deleted_at IS NULL"
|
||||
)
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL"
|
||||
params: list = []
|
||||
|
||||
query, params = _apply_source_filter(query, params, source_type)
|
||||
query += " ORDER BY updated_at DESC LIMIT 50"
|
||||
query += " ORDER BY p.updated_at DESC LIMIT 50"
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = [_build_item(dict(r)) for r in rows]
|
||||
items = _rows_to_items(rows, uid)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@@ -228,22 +227,19 @@ async def library_private(
|
||||
async def library_workspace(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
):
|
||||
"""Pages from workspaces (non-empty workspace field)."""
|
||||
if source_type not in SOURCE_TYPES:
|
||||
source_type = "all"
|
||||
|
||||
query = (
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"WHERE workspace != '' AND parent_section != 'Trash' AND deleted_at IS NULL"
|
||||
)
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.workspace != '' AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
params: list = []
|
||||
|
||||
query, params = _apply_source_filter(query, params, source_type)
|
||||
query += " ORDER BY updated_at DESC LIMIT 50"
|
||||
query += " ORDER BY p.updated_at DESC LIMIT 50"
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = [_build_item(dict(r)) for r in rows]
|
||||
items = _rows_to_items(rows, uid)
|
||||
return {"items": items}
|
||||
|
||||
Reference in New Issue
Block a user