diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index f9517e1..7fadb9a 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -1844,3 +1844,51 @@ def _render_blocks_public(blocks: list) -> str: else: html_parts.append(f'

{c}

') 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"} diff --git a/app/routers/library.py b/app/routers/library.py index 8ac8b6b..c54657f 100644 --- a/app/routers/library.py +++ b/app/routers/library.py @@ -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} diff --git a/app/templates/library.html b/app/templates/library.html index 9b58304..0610bf7 100644 --- a/app/templates/library.html +++ b/app/templates/library.html @@ -3,269 +3,417 @@ {% block page_title %}Library{% endblock %} {% block title_prefix %}Library{% endblock %} +{% block topbar %} +{% set page_icon = "📚" %} +{% set page_title = "Library" %} +{% include '_header.html' %} +{% endblock %} + {% block content %} -
-
- 📚 +
+ + + + +
+

Library

+
+ + +
-
-
+ +
+ +
- -
- - - - - -
- -
-
- - - - + +
+ + +
+ +
+ + + +
+ +
-
- -
- +
-

Loading…

- -
- + + + + + + + + + + + + + + + + + +
+
+
Page nameCreated bySourceLast editedLast visited
+ + +
+ + Add new
-
-
+
+

-
+ + +
+ +
+ +
{% endblock %} {% block scripts %} - -