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}
|
||||
|
||||
+617
-258
@@ -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 %}
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">📚</span>
|
||||
<div x-data="libraryPage()" x-init="init()" @keydown.escape="closePeek()">
|
||||
|
||||
<style>
|
||||
/* ── Library layout ── */
|
||||
.lib-container{max-width:1100px;margin:0 auto;padding:20px 24px;}
|
||||
.lib-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;}
|
||||
.lib-header h1{font-size:24px;font-weight:700;color:var(--text-primary);margin:0;}
|
||||
.lib-header-actions{display:flex;align-items:center;gap:8px;}
|
||||
|
||||
/* ── Tabs ── */
|
||||
.lib-tabs{display:flex;gap:2px;margin-bottom:12px;border-bottom:1px solid var(--border);padding-bottom:0;}
|
||||
.lib-tab{display:flex;align-items:center;gap:6px;padding:6px 12px;font-size:13px;color:var(--text-secondary);background:transparent;border:none;border-bottom:2px solid transparent;cursor:pointer;transition:all 0.15s;white-space:nowrap;margin-bottom:-1px;}
|
||||
.lib-tab:hover{color:var(--text-primary);background:var(--bg-hover);border-radius:6px 6px 0 0;}
|
||||
.lib-tab.active{color:var(--text-primary);border-bottom-color:var(--text-primary);}
|
||||
.lib-tab .tab-icon{font-size:14px;}
|
||||
|
||||
/* ── Multi-select bar ── */
|
||||
.lib-select-bar{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--bg-secondary);border-radius:8px;margin-bottom:8px;font-size:13px;color:var(--text-secondary);min-height:36px;}
|
||||
.lib-select-bar .sel-count{font-weight:500;color:var(--text-primary);}
|
||||
.lib-select-bar button{background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:4px 8px;border-radius:4px;font-size:13px;display:flex;align-items:center;gap:4px;transition:all 0.15s;}
|
||||
.lib-select-bar button:hover{background:var(--bg-hover);color:var(--text-primary);}
|
||||
|
||||
/* ── Table ── */
|
||||
.lib-table{width:100%;border-collapse:collapse;table-layout:fixed;}
|
||||
.lib-table th{text-align:left;padding:6px 8px;font-size:11px;font-weight:600;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.3px;border-bottom:1px solid var(--border);user-select:none;white-space:nowrap;}
|
||||
.lib-table th.sortable{cursor:pointer;}
|
||||
.lib-table th.sortable:hover{color:var(--text-primary);}
|
||||
.lib-table td{padding:0;border-bottom:1px solid var(--border);}
|
||||
|
||||
/* Column widths */
|
||||
.lib-col-select{width:32px;text-align:center;}
|
||||
.lib-col-drag{width:28px;}
|
||||
.lib-col-name{width:auto;}
|
||||
.lib-col-author{width:160px;}
|
||||
.lib-col-source{width:130px;}
|
||||
.lib-col-edited{width:100px;}
|
||||
.lib-col-visited{width:100px;}
|
||||
.lib-col-open{width:80px;text-align:right;}
|
||||
|
||||
/* ── Row ── */
|
||||
.lib-row{display:flex;align-items:center;height:36px;transition:background 0.1s;position:relative;}
|
||||
.lib-row:hover{background:var(--bg-hover);cursor:pointer;}
|
||||
.lib-row.selected{background:rgba(35,131,226,0.08);}
|
||||
.lib-row .row-cell{display:flex;align-items:center;padding:0 8px;height:100%;overflow:hidden;}
|
||||
|
||||
/* Hover-only elements */
|
||||
.lib-row .hover-only{opacity:0;transition:opacity 0.1s;}
|
||||
.lib-row:hover .hover-only{opacity:1;}
|
||||
|
||||
/* Drag handle — 6 dots */
|
||||
.drag-handle{display:flex;flex-wrap:wrap;width:14px;height:14px;cursor:grab;align-content:space-between;justify-content:space-between;flex-shrink:0;}
|
||||
.drag-handle:hover{opacity:1!important;}
|
||||
.drag-handle .dot{width:3px;height:3px;background:var(--text-tertiary);border-radius:50%;}
|
||||
|
||||
/* Checkbox */
|
||||
.lib-checkbox{width:14px;height:14px;border:1.5px solid var(--text-tertiary);border-radius:3px;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:all 0.1s;}
|
||||
.lib-checkbox:hover{border-color:var(--text-primary);}
|
||||
.lib-checkbox.checked{background:var(--accent);border-color:var(--accent);}
|
||||
.lib-checkbox.checked::after{content:'✓';color:#fff;font-size:10px;font-weight:700;}
|
||||
|
||||
/* ── Name cell ── */
|
||||
.row-name{display:flex;align-items:center;gap:6px;min-width:0;flex:1;}
|
||||
.row-icon{font-size:15px;flex-shrink:0;width:20px;text-align:center;}
|
||||
.row-title{font-size:13px;font-weight:400;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;}
|
||||
.row-title input{width:100%;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;}
|
||||
|
||||
/* Expand chevron */
|
||||
.tree-chevron{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:8px;padding:0;border-radius:3px;transition:transform 0.15s;}
|
||||
.tree-chevron:hover{color:var(--text);background:var(--bg-hover);}
|
||||
.tree-chevron.open{transform:rotate(90deg);}
|
||||
|
||||
/* Indent levels */
|
||||
.indent-1{padding-left:24px!important;}
|
||||
.indent-2{padding-left:44px!important;}
|
||||
.indent-3{padding-left:64px!important;}
|
||||
|
||||
/* ── Author cell ── */
|
||||
.row-author{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-secondary);}
|
||||
.author-avatar{width:18px;height:18px;border-radius:50%;background:var(--bg-tertiary);display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:600;color:var(--text-secondary);flex-shrink:0;}
|
||||
|
||||
/* ── Source cell ── */
|
||||
.row-source{font-size:13px;color:var(--text-secondary);}
|
||||
|
||||
/* ── Time cells ── */
|
||||
.row-time{font-size:13px;color:var(--text-secondary);white-space:nowrap;}
|
||||
|
||||
/* ── OPEN button ── */
|
||||
.btn-open{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border:1px solid var(--border);border-radius:4px;font-size:12px;color:var(--text-secondary);background:var(--bg-tertiary);cursor:pointer;white-space:nowrap;transition:all 0.1s;opacity:0;}
|
||||
.lib-row:hover .btn-open{opacity:1;}
|
||||
.btn-open:hover{background:var(--bg-hover);color:var(--text-primary);border-color:var(--text-primary);}
|
||||
|
||||
/* ── Side Peek Panel ── */
|
||||
.peek-overlay{position:fixed;top:0;right:0;width:560px;max-width:90vw;height:100vh;background:var(--bg-modal);border-left:1px solid var(--border);box-shadow:-8px 0 32px rgba(0,0,0,0.25);z-index:500;display:flex;flex-direction:column;transform:translateX(100%);transition:transform 0.2s cubic-bezier(0.16,1,0.3,1);}
|
||||
.peek-overlay.open{transform:translateX(0);}
|
||||
.peek-header{display:flex;align-items:center;gap:8px;padding:12px 16px;border-bottom:1px solid var(--border);}
|
||||
.peek-header .peek-title{flex:1;font-size:15px;font-weight:600;color:var(--text-primary);display:flex;align-items:center;gap:8px;}
|
||||
.peek-header button{background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:4px 8px;border-radius:4px;font-size:13px;}
|
||||
.peek-header button:hover{background:var(--bg-hover);color:var(--text-primary);}
|
||||
.peek-body{flex:1;overflow-y:auto;padding:16px 20px;}
|
||||
.peek-body .peek-empty{text-align:center;padding:60px 20px;color:var(--text-tertiary);}
|
||||
.peek-loading{text-align:center;padding:40px;color:var(--text-tertiary);}
|
||||
|
||||
/* ── "Add new" row ── */
|
||||
.add-new-row{padding:8px 12px;color:var(--text-tertiary);font-size:13px;cursor:pointer;transition:color 0.15s;}
|
||||
.add-new-row:hover{color:var(--text-primary);}
|
||||
|
||||
/* ── More menu dropdown ── */
|
||||
.more-menu{position:absolute;top:100%;right:0;min-width:200px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow-popover);z-index:100;padding:4px;}
|
||||
.more-menu .menu-item{padding:6px 10px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:8px;transition:background 0.1s;}
|
||||
.more-menu .menu-item:hover{background:var(--bg-hover);}
|
||||
.more-menu .menu-item .shortcut{margin-left:auto;font-size:11px;color:var(--text-tertiary);}
|
||||
.more-menu .menu-danger{color:var(--danger);}
|
||||
.more-sep{height:1px;background:var(--border);margin:4px 8px;}
|
||||
|
||||
/* ── Empty state ── */
|
||||
.lib-empty{text-align:center;padding:80px 20px;color:var(--text-tertiary);}
|
||||
.lib-empty .empty-icon{font-size:48px;margin-bottom:12px;opacity:0.5;}
|
||||
.lib-empty h3{font-size:16px;color:var(--text-secondary);margin:0 0 4px;}
|
||||
.lib-empty p{font-size:13px;margin:0;}
|
||||
|
||||
/* ── Loading ── */
|
||||
.lib-loading{text-align:center;padding:60px 20px;color:var(--text-tertiary);}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media(max-width:768px){
|
||||
.lib-col-author,.lib-col-source,.lib-col-visited{display:none;}
|
||||
.lib-table th:nth-child(4),.lib-table th:nth-child(5),.lib-table th:nth-child(7){display:none;}
|
||||
.peek-overlay{width:100vw;max-width:100vw;}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- ── Header ── -->
|
||||
<div class="lib-container">
|
||||
<div class="lib-header">
|
||||
<h1>Library</h1>
|
||||
<div class="lib-header-actions">
|
||||
<button class="btn-icon" title="Search" @click="searchOpen=!searchOpen" style="font-size:14px;">🔍</button>
|
||||
<button class="btn btn-primary" @click="newPage()" style="padding:6px 16px;font-size:13px;">New page</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-data="libraryPage()" style="padding: 0 24px;">
|
||||
<!-- Search bar (toggleable) -->
|
||||
<div x-show="searchOpen" x-transition style="margin-bottom:8px;">
|
||||
<input type="text" class="search-input" placeholder="Search library..." x-model="searchQuery" @input="doSearch()"
|
||||
style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);color:var(--text-primary);font-size:13px;outline:none;">
|
||||
</div>
|
||||
|
||||
<!-- ── Tab bar ── -->
|
||||
<div class="view-tabs">
|
||||
<button class="view-tab" :class="{ active: tab === 'recents' }" @click="switchTab('recents')">
|
||||
<!-- ── Tabs ── -->
|
||||
<div class="lib-tabs">
|
||||
<button class="lib-tab" :class="{ active: tab === 'recents' }" @click="switchTab('recents')">
|
||||
<span class="tab-icon">🕒</span> Recents
|
||||
</button>
|
||||
<button class="view-tab" :class="{ active: tab === 'favorites' }" @click="switchTab('favorites')">
|
||||
<button class="lib-tab" :class="{ active: tab === 'favorites' }" @click="switchTab('favorites')">
|
||||
<span class="tab-icon">⭐</span> Favorites
|
||||
</button>
|
||||
<button class="view-tab" :class="{ active: tab === 'shared' }" @click="switchTab('shared')">
|
||||
<button class="lib-tab" :class="{ active: tab === 'shared' }" @click="switchTab('shared')">
|
||||
<span class="tab-icon">👥</span> Shared
|
||||
</button>
|
||||
<button class="view-tab" :class="{ active: tab === 'published' }" @click="switchTab('published')">
|
||||
<button class="lib-tab" :class="{ active: tab === 'published' }" @click="switchTab('published')">
|
||||
<span class="tab-icon">🌐</span> Published
|
||||
</button>
|
||||
<button class="view-tab" :class="{ active: tab === 'private' }" @click="switchTab('private')">
|
||||
<button class="lib-tab" :class="{ active: tab === 'private' }" @click="switchTab('private')">
|
||||
<span class="tab-icon">🔒</span> Private
|
||||
</button>
|
||||
<button class="view-tab" :class="{ active: tab === 'workspace' }" @click="switchTab('workspace')">
|
||||
<button class="lib-tab" :class="{ active: tab === 'workspace' }" @click="switchTab('workspace')">
|
||||
<span class="tab-icon">📁</span> Workspace
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Toolbar with source_type filter ── -->
|
||||
<div class="view-toolbar">
|
||||
<div class="source-filter-group">
|
||||
<button class="source-filter-btn" :class="{ active: sourceType === 'all' }" @click="setSourceType('all')">All</button>
|
||||
<button class="source-filter-btn" :class="{ active: sourceType === 'local' }" @click="setSourceType('local')">Local</button>
|
||||
<button class="source-filter-btn" :class="{ active: sourceType === 'gitea' }" @click="setSourceType('gitea')">Gitea</button>
|
||||
<button class="source-filter-btn" :class="{ active: sourceType === 'github' }" @click="setSourceType('github')">GitHub</button>
|
||||
<!-- ── Multi-select bar ── -->
|
||||
<div class="lib-select-bar" x-show="selectedCount > 0" x-transition>
|
||||
<span class="sel-count" x-text="selectedCount + ' selected'"></span>
|
||||
<button @click="deleteSelected()" title="Move to Trash">🗑</button>
|
||||
<div style="position:relative;margin-left:auto;">
|
||||
<button @click.stop="moreOpen=!moreOpen" title="More actions">⋯</button>
|
||||
<div class="more-menu" x-show="moreOpen" @click.outside="moreOpen=false" x-transition style="right:0;top:100%;">
|
||||
<div class="menu-item" @click="removeFromRecents()">👁️🗨️ Remove from Recents</div>
|
||||
<div class="menu-item" @click="copyLinks()">🔗 Copy links to all <span class="shortcut">⌘C</span></div>
|
||||
<div class="menu-item" @click="moveTo()">📦 Move to <span class="shortcut">⌘⇧P</span></div>
|
||||
<div class="more-sep"></div>
|
||||
<div class="menu-item menu-danger" @click="deleteSelected()">🗑 Move to Trash <span class="shortcut">Del</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="view-toolbar-spacer"></div>
|
||||
<button class="toolbar-btn" title="Search">🔍</button>
|
||||
<button class="btn-new" @click="newPage()">New page</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Loading state ── -->
|
||||
<!-- ── Loading ── -->
|
||||
<div x-show="loading" class="lib-loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading…</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Card grid ── -->
|
||||
<div x-show="!loading" class="library-card-grid">
|
||||
<template x-for="item in items" :key="item.id">
|
||||
<div class="library-card" @click="openItem(item)">
|
||||
<!-- Icon -->
|
||||
<div class="library-card-icon" x-text="item.icon"></div>
|
||||
<!-- Info -->
|
||||
<div class="library-card-info">
|
||||
<div class="library-card-title" x-text="item.title"></div>
|
||||
<div class="library-card-meta">
|
||||
<span class="source-badge" :class="'source-' + item.source_type" x-text="item.source_badge"></span>
|
||||
<span class="library-card-workspace" x-show="item.workspace_name && item.workspace_name !== 'Private'" x-text="item.workspace_name"></span>
|
||||
<span class="library-card-date" x-text="formatDate(item.updated_at)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- ── Table ── -->
|
||||
<table class="lib-table" x-show="!loading && flatItems.length > 0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="lib-col-drag"></th>
|
||||
<th class="lib-col-select">
|
||||
<div class="lib-checkbox" :class="{ checked: allSelected }" @click="toggleSelectAll()"></div>
|
||||
</th>
|
||||
<th class="lib-col-name sortable" @click="sortBy='name';doSort()">Page name</th>
|
||||
<th class="lib-col-author sortable" @click="sortBy='author';doSort()">Created by</th>
|
||||
<th class="lib-col-source">Source</th>
|
||||
<th class="lib-col-edited sortable" @click="sortBy='edited';doSort()">Last edited</th>
|
||||
<th class="lib-col-visited sortable" @click="sortBy='visited';doSort()">Last visited</th>
|
||||
<th class="lib-col-open"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="(item, idx) in flatItems" :key="item.id">
|
||||
<tr>
|
||||
<td class="lib-col-drag">
|
||||
<div class="drag-handle hover-only" style="margin:auto;" title="Drag to move">
|
||||
<span class="dot"></span><span class="dot"></span>
|
||||
<span class="dot"></span><span class="dot"></span>
|
||||
<span class="dot"></span><span class="dot"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="lib-col-select">
|
||||
<div class="lib-checkbox" :class="{ checked: selected[item.id] }"
|
||||
@click.stop="toggleItem(item)"></div>
|
||||
</td>
|
||||
<td :class="'lib-col-name indent-' + (item.depth || 0)">
|
||||
<div class="row-name">
|
||||
<!-- Expand chevron -->
|
||||
<button x-show="item.has_children" class="tree-chevron"
|
||||
:class="{ open: expanded[item.id] }"
|
||||
@click.stop="toggleExpand(item)">▶</button>
|
||||
<span x-show="!item.has_children" style="width:18px;flex-shrink:0;"></span>
|
||||
<!-- Icon -->
|
||||
<span class="row-icon" x-text="item.icon || '📄'"></span>
|
||||
<!-- Title or inline rename -->
|
||||
<span class="row-title" x-show="renamingId !== item.id"
|
||||
@dblclick.stop="startRename(item)"
|
||||
@click.stop="openItem(item)"
|
||||
x-text="item.title"></span>
|
||||
<input x-show="renamingId === item.id" class="row-title"
|
||||
x-model="renameValue"
|
||||
@keydown.enter="commitRename(item)"
|
||||
@keydown.escape="renamingId=null"
|
||||
@blur="commitRename(item)"
|
||||
@click.stop>
|
||||
</div>
|
||||
</td>
|
||||
<td class="lib-col-author">
|
||||
<div class="row-author">
|
||||
<div class="author-avatar" x-text="item.author_initial || '?'"></div>
|
||||
<span x-text="item.author || '—'"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="lib-col-source">
|
||||
<span class="row-source" x-text="item.source_label || 'Private'"></span>
|
||||
</td>
|
||||
<td class="lib-col-edited">
|
||||
<span class="row-time" x-text="formatTime(item.updated_at)"></span>
|
||||
</td>
|
||||
<td class="lib-col-visited">
|
||||
<span class="row-time" x-text="formatTime(item.visited_at)"></span>
|
||||
</td>
|
||||
<td class="lib-col-open" style="text-align:right;">
|
||||
<button class="btn-open" @click.stop="openPeek(item)">⤢ Open</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- "+ Add new" -->
|
||||
<div class="add-new-row" x-show="!loading && tab !== 'favorites' && tab !== 'shared' && tab !== 'published'"
|
||||
@click="addNewPage()">
|
||||
+ Add new
|
||||
</div>
|
||||
|
||||
<!-- ── Empty state ── -->
|
||||
<div x-show="!loading && items.length === 0" class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;" x-text="emptyIcon"></div>
|
||||
<div x-show="!loading && flatItems.length === 0" class="lib-empty">
|
||||
<div class="empty-icon" x-text="emptyIcon"></div>
|
||||
<h3 x-text="emptyTitle"></h3>
|
||||
<p x-text="emptyText"></p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Side Peek Panel ── -->
|
||||
<div class="peek-overlay" :class="{ open: peekItem !== null }" @click.outside="closePeek()">
|
||||
<template x-if="peekItem">
|
||||
<div style="display:flex;flex-direction:column;height:100%;">
|
||||
<div class="peek-header">
|
||||
<button @click="closePeek()" title="Close">✕</button>
|
||||
<div class="peek-title">
|
||||
<span x-text="peekItem.icon || '📄'"></span>
|
||||
<span x-text="peekItem.title"></span>
|
||||
</div>
|
||||
<button @click="openItem(peekItem)" title="Open full page">↗</button>
|
||||
<button @click="toggleFavoritePeek()" title="Favorite">
|
||||
<span x-text="peekFavorited ? '⭐' : '☆'"></span>
|
||||
</button>
|
||||
<button @click="copyPeekLink()" title="Copy link">🔗</button>
|
||||
<button @click.stop="peekMoreOpen=!peekMoreOpen" title="More">⋯</button>
|
||||
<div class="more-menu" x-show="peekMoreOpen" @click.outside="peekMoreOpen=false" style="position:absolute;top:100%;right:8px;">
|
||||
<div class="menu-item" @click="duplicatePeek()">📋 Duplicate</div>
|
||||
<div class="menu-item" @click="movePeekTo()">📦 Move to</div>
|
||||
<div class="more-sep"></div>
|
||||
<div class="menu-item menu-danger" @click="deletePeek()">🗑 Move to Trash</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="peek-body">
|
||||
<div x-show="peekLoading" class="peek-loading">Loading…</div>
|
||||
<div x-show="!peekLoading && peekContent" x-html="peekContent"></div>
|
||||
<div x-show="!peekLoading && !peekContent" class="peek-empty">
|
||||
<div style="font-size:48px;margin-bottom:8px;">📄</div>
|
||||
<div style="font-size:14px;color:var(--text-secondary);">No preview available</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div><!-- /x-data -->
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<style>
|
||||
/* ── Source filter pill buttons ── */
|
||||
.source-filter-group {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
}
|
||||
.source-filter-btn {
|
||||
padding: 4px 12px;
|
||||
border-radius: 5px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.source-filter-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.source-filter-btn.active {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-popover);
|
||||
}
|
||||
|
||||
/* ── Card grid ── */
|
||||
.library-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.library-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.library-card:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.library-card-icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.library-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.library-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.library-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.source-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.source-local {
|
||||
background: rgba(100, 180, 100, 0.15);
|
||||
color: var(--green);
|
||||
}
|
||||
.source-gitea {
|
||||
background: rgba(100, 160, 220, 0.15);
|
||||
color: var(--blue);
|
||||
}
|
||||
.source-github {
|
||||
background: rgba(180, 140, 220, 0.15);
|
||||
color: var(--purple);
|
||||
}
|
||||
.library-card-workspace {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.library-card-date {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Loading spinner ── */
|
||||
.lib-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 0;
|
||||
color: var(--text-dim);
|
||||
gap: 16px;
|
||||
}
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--text-dim);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function libraryPage() {
|
||||
return {
|
||||
tab: 'recents',
|
||||
sourceType: 'all',
|
||||
items: [],
|
||||
loading: false,
|
||||
searchOpen: false,
|
||||
searchQuery: '',
|
||||
|
||||
// Selection
|
||||
selected: {},
|
||||
get selectedCount() { return Object.keys(this.selected).length; },
|
||||
get allSelected() {
|
||||
return this.flatItems.length > 0 && this.selectedCount === this.flatItems.length;
|
||||
},
|
||||
|
||||
// Tree
|
||||
expanded: {},
|
||||
renamingId: null,
|
||||
renameValue: '',
|
||||
|
||||
// Sort
|
||||
sortBy: '',
|
||||
|
||||
// More menu
|
||||
moreOpen: false,
|
||||
|
||||
// Side peek
|
||||
peekItem: null,
|
||||
peekContent: '',
|
||||
peekLoading: false,
|
||||
peekFavorited: false,
|
||||
peekMoreOpen: false,
|
||||
|
||||
// ── Computed flat list ──
|
||||
get flatItems() {
|
||||
var result = [];
|
||||
this._flatten(this.items, result, 0);
|
||||
// Apply search filter
|
||||
if (this.searchQuery.trim()) {
|
||||
var q = this.searchQuery.toLowerCase();
|
||||
result = result.filter(function(r) {
|
||||
return (r.title || '').toLowerCase().indexOf(q) !== -1;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
_flatten(nodes, out, depth) {
|
||||
if (!nodes) return;
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var n = nodes[i];
|
||||
n.depth = depth || 0;
|
||||
out.push(n);
|
||||
if (n.has_children && this.expanded[n.id]) {
|
||||
this._flatten(n.children, out, depth + 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Empty states ──
|
||||
get emptyIcon() {
|
||||
const icons = {
|
||||
recents: '🕒', favorites: '⭐', shared: '👥',
|
||||
published: '🌐', private: '🔒', workspace: '📁'
|
||||
};
|
||||
var icons = {recents:'🕒',favorites:'⭐',shared:'👥',published:'🌐',private:'🔒',workspace:'📁'};
|
||||
return icons[this.tab] || '📄';
|
||||
},
|
||||
get emptyTitle() {
|
||||
const titles = {
|
||||
recents: 'No recent pages',
|
||||
favorites: 'No favorites',
|
||||
shared: 'No shared pages',
|
||||
published: 'No published pages',
|
||||
private: 'No private pages',
|
||||
workspace: 'No workspace pages'
|
||||
};
|
||||
var titles = {recents:'No recent pages',favorites:'No favorites',shared:'No shared pages',
|
||||
published:'No published pages',private:'No private pages',workspace:'No workspace pages'};
|
||||
return titles[this.tab] || 'No items';
|
||||
},
|
||||
get emptyText() {
|
||||
const texts = {
|
||||
recents: 'Pages you\'ve recently viewed or edited will appear here.',
|
||||
favorites: 'Right-click a page in the sidebar and select "Add to Favorites".',
|
||||
shared: 'Shared pages from your team will appear here.',
|
||||
published: 'Pages published to the web will appear here.',
|
||||
private: 'Create a new page with the + button in the sidebar.',
|
||||
workspace: 'Pages inside workspaces will appear here.'
|
||||
};
|
||||
var texts = {recents:'Pages you\'ve recently viewed or edited will appear here.',
|
||||
favorites:'Right-click a page in the sidebar and select "Add to Favorites".',
|
||||
shared:'Shared pages from your team will appear here.',
|
||||
published:'Pages published to the web will appear here.',
|
||||
private:'Create a new page with the + button.',
|
||||
workspace:'Pages inside workspaces will appear here.'};
|
||||
return texts[this.tab] || '';
|
||||
},
|
||||
|
||||
// ── Init ──
|
||||
init() {
|
||||
// Read ?tab= from URL
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const urlTab = params.get('tab');
|
||||
if (urlTab && ['recents','favorites','shared','published','private','workspace'].includes(urlTab)) {
|
||||
this.tab = urlTab;
|
||||
}
|
||||
const urlSource = params.get('source_type');
|
||||
if (urlSource && ['all','local','gitea','github'].includes(urlSource)) {
|
||||
this.sourceType = urlSource;
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var t = params.get('tab');
|
||||
if (t && ['recents','favorites','shared','published','private','workspace'].indexOf(t) !== -1) {
|
||||
this.tab = t;
|
||||
}
|
||||
this.loadTab();
|
||||
},
|
||||
@@ -274,76 +422,287 @@ function libraryPage() {
|
||||
if (this.tab === t) return;
|
||||
this.tab = t;
|
||||
this.items = [];
|
||||
this.updateUrl();
|
||||
this.loadTab();
|
||||
},
|
||||
|
||||
setSourceType(st) {
|
||||
if (this.sourceType === st) return;
|
||||
this.sourceType = st;
|
||||
this.items = [];
|
||||
this.selected = {};
|
||||
this.expanded = {};
|
||||
this.searchQuery = '';
|
||||
this.searchOpen = false;
|
||||
this.updateUrl();
|
||||
this.loadTab();
|
||||
},
|
||||
|
||||
updateUrl() {
|
||||
const params = new URLSearchParams();
|
||||
if (this.tab !== 'recents') params.set('tab', this.tab);
|
||||
if (this.sourceType !== 'all') params.set('source_type', this.sourceType);
|
||||
const qs = params.toString();
|
||||
const newUrl = qs ? `/library?${qs}` : '/library';
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
var p = new URLSearchParams();
|
||||
if (this.tab !== 'recents') p.set('tab', this.tab);
|
||||
var qs = p.toString();
|
||||
window.history.replaceState({}, '', qs ? '/library?' + qs : '/library');
|
||||
},
|
||||
|
||||
// ── Load ──
|
||||
async loadTab() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/library/${this.tab}?source_type=${this.sourceType}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
this.items = data.items || [];
|
||||
} catch (e) {
|
||||
console.error('Library load error:', e);
|
||||
var r = await fetch('/api/library/' + this.tab + '?tree=1');
|
||||
var d = await r.json();
|
||||
this.items = d.items || [];
|
||||
this._markTree(this.items, 0);
|
||||
} catch(e) {
|
||||
console.error('Library load:', e);
|
||||
this.items = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
_markTree(nodes, depth) {
|
||||
if (!nodes) return;
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
nodes[i].depth = depth;
|
||||
if (nodes[i].children && nodes[i].children.length > 0) {
|
||||
nodes[i].has_children = true;
|
||||
this._markTree(nodes[i].children, depth + 1);
|
||||
} else {
|
||||
nodes[i].has_children = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
doSearch() { /* flatItems reacts to searchQuery */ },
|
||||
doSort() {
|
||||
var s = this.sortBy;
|
||||
if (!s) return;
|
||||
var items = this.items.slice();
|
||||
if (s === 'name') items.sort(function(a,b){ return (a.title||'').localeCompare(b.title||''); });
|
||||
else if (s === 'edited') items.sort(function(a,b){ return (b.updated_at||'').localeCompare(a.updated_at||''); });
|
||||
else if (s === 'visited') items.sort(function(a,b){ return (b.visited_at||'').localeCompare(a.visited_at||''); });
|
||||
else if (s === 'author') items.sort(function(a,b){ return (a.author||'').localeCompare(b.author||''); });
|
||||
this.items = items;
|
||||
},
|
||||
|
||||
// ── Tree expand ──
|
||||
toggleExpand(item) {
|
||||
this.expanded[item.id] = !this.expanded[item.id];
|
||||
this.expanded = Object.assign({}, this.expanded);
|
||||
},
|
||||
|
||||
// ── Selection ──
|
||||
toggleItem(item) {
|
||||
if (this.selected[item.id]) {
|
||||
delete this.selected[item.id];
|
||||
} else {
|
||||
this.selected[item.id] = true;
|
||||
}
|
||||
this.selected = Object.assign({}, this.selected);
|
||||
},
|
||||
|
||||
toggleSelectAll() {
|
||||
if (this.allSelected) {
|
||||
this.selected = {};
|
||||
} else {
|
||||
var s = {};
|
||||
for (var i = 0; i < this.flatItems.length; i++) {
|
||||
s[this.flatItems[i].id] = true;
|
||||
}
|
||||
this.selected = s;
|
||||
}
|
||||
},
|
||||
|
||||
clearSelection() {
|
||||
this.selected = {};
|
||||
this.moreOpen = false;
|
||||
},
|
||||
|
||||
// ── Multi-select actions ──
|
||||
async deleteSelected() {
|
||||
var ids = Object.keys(this.selected);
|
||||
if (!ids.length) return;
|
||||
if (!confirm('Move ' + ids.length + ' item(s) to Trash?')) return;
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
await fetch('/api/pages/' + ids[i] + '/trash', {method:'POST'});
|
||||
}
|
||||
this.clearSelection();
|
||||
this.loadTab();
|
||||
if (window.showToast) window.showToast('Moved to trash', 'success');
|
||||
},
|
||||
|
||||
removeFromRecents() {
|
||||
this.moreOpen = false;
|
||||
var ids = Object.keys(this.selected);
|
||||
if (!ids.length) return;
|
||||
ids.forEach(function(id) {
|
||||
fetch('/api/pages/' + id + '/visit', {method:'DELETE'}).catch(function(){});
|
||||
});
|
||||
this.clearSelection();
|
||||
if (this.tab === 'recents') this.loadTab();
|
||||
},
|
||||
|
||||
copyLinks() {
|
||||
this.moreOpen = false;
|
||||
var ids = Object.keys(this.selected);
|
||||
var links = ids.map(function(id) { return window.location.origin + '/pages/' + id; });
|
||||
navigator.clipboard.writeText(links.join('\n'));
|
||||
if (window.showToast) window.showToast('Links copied', 'success');
|
||||
},
|
||||
|
||||
moveTo() {
|
||||
this.moreOpen = false;
|
||||
if (window.showToast) window.showToast('Move not yet implemented', 'info');
|
||||
},
|
||||
|
||||
// ── Item actions ──
|
||||
openItem(item) {
|
||||
if (!item || !item.url) return;
|
||||
window.location.href = item.url;
|
||||
if (!item || !item.id) return;
|
||||
window.location.href = '/pages/' + item.id;
|
||||
},
|
||||
|
||||
formatDate(d) {
|
||||
if (!d) return '';
|
||||
startRename(item) {
|
||||
this.renamingId = item.id;
|
||||
this.renameValue = item.title;
|
||||
var self = this;
|
||||
this.$nextTick(function() {
|
||||
var inp = document.querySelector('.row-title input');
|
||||
if (inp) { inp.focus(); inp.select(); }
|
||||
});
|
||||
},
|
||||
|
||||
async commitRename(item) {
|
||||
var newName = (this.renameValue || '').trim();
|
||||
this.renamingId = null;
|
||||
if (!newName || newName === item.title) return;
|
||||
try {
|
||||
const date = new Date(d);
|
||||
if (isNaN(date.getTime())) return d;
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'Just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
} catch {
|
||||
return d;
|
||||
var r = await fetch('/api/pages/' + item.id + '/rename', {
|
||||
method:'PUT', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({title: newName})
|
||||
});
|
||||
if (r.ok) {
|
||||
item.title = newName;
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
// ── Add new ──
|
||||
async addNewPage() {
|
||||
var name = prompt('Page name:');
|
||||
if (!name || !name.trim()) return;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
try {
|
||||
var r = await fetch('/board/api/pages?section=Private', {
|
||||
method:'POST',
|
||||
headers:{'X-CSRF-Token':csrf?csrf[1]:'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({title: name.trim()})
|
||||
});
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
window.location.href = '/pages/' + d.id;
|
||||
}
|
||||
} catch(e) {
|
||||
if (window.showToast) window.showToast('Failed to create page', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
newPage() {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/board/api/pages?section=Private', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('CSRF error'); return r.json(); })
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { window.showToast('Failed: ' + err.message, 'error'); });
|
||||
newPage() { this.addNewPage(); },
|
||||
|
||||
// ── Side peek ──
|
||||
async openPeek(item) {
|
||||
this.peekItem = item;
|
||||
this.peekContent = '';
|
||||
this.peekLoading = true;
|
||||
this.peekFavorited = item.favorited || false;
|
||||
try {
|
||||
var r = await fetch('/api/pages/' + item.id + '/content');
|
||||
var d = await r.json();
|
||||
var content = d.content || '';
|
||||
// Simple preview
|
||||
if (d.format === 'blocks') {
|
||||
try {
|
||||
var blocks = typeof content === 'string' ? JSON.parse(content) : content;
|
||||
var preview = '';
|
||||
var lines = (blocks && blocks.blocks) ? blocks.blocks.slice(0, 30) : [];
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var b = lines[i];
|
||||
var txt = b.content || b.text || (typeof b === 'string' ? b : '');
|
||||
if (txt && txt.trim()) preview += '<div style="margin-bottom:6px;font-size:14px;line-height:1.5;color:var(--text-primary);">' + this._escHtml(String(txt)) + '</div>';
|
||||
}
|
||||
this.peekContent = preview || '<div style="color:var(--text-tertiary);">(empty page)</div>';
|
||||
} catch(e) {
|
||||
this.peekContent = '<pre style="font-size:13px;color:var(--text-primary);">' + this._escHtml(String(content).substring(0, 2000)) + '</pre>';
|
||||
}
|
||||
} else {
|
||||
this.peekContent = '<div style="font-size:14px;line-height:1.6;color:var(--text-primary);white-space:pre-wrap;">' + this._escHtml(String(content).substring(0, 2000)) + '</div>';
|
||||
}
|
||||
} catch(e) {
|
||||
this.peekContent = '';
|
||||
}
|
||||
this.peekLoading = false;
|
||||
},
|
||||
|
||||
closePeek() {
|
||||
this.peekItem = null;
|
||||
this.peekContent = '';
|
||||
},
|
||||
|
||||
_escHtml(s) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
async toggleFavoritePeek() {
|
||||
if (!this.peekItem) return;
|
||||
var id = this.peekItem.id;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var method = this.peekFavorited ? 'DELETE' : 'POST';
|
||||
var r = await fetch('/board/api/favorites/' + id, {
|
||||
method: method, headers: {'X-CSRF-Token':csrf?csrf[1]:''}
|
||||
});
|
||||
if (r.ok) {
|
||||
this.peekFavorited = !this.peekFavorited;
|
||||
if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites();
|
||||
}
|
||||
},
|
||||
|
||||
copyPeekLink() {
|
||||
if (!this.peekItem) return;
|
||||
navigator.clipboard.writeText(window.location.origin + '/pages/' + this.peekItem.id);
|
||||
if (window.showToast) window.showToast('Link copied', 'success');
|
||||
},
|
||||
|
||||
duplicatePeek() {
|
||||
this.peekMoreOpen = false;
|
||||
if (window.showToast) window.showToast('Duplicate not yet implemented', 'info');
|
||||
},
|
||||
|
||||
movePeekTo() {
|
||||
this.peekMoreOpen = false;
|
||||
if (window.showToast) window.showToast('Move not yet implemented', 'info');
|
||||
},
|
||||
|
||||
async deletePeek() {
|
||||
this.peekMoreOpen = false;
|
||||
if (!this.peekItem) return;
|
||||
if (!confirm('Move to Trash?')) return;
|
||||
await fetch('/api/pages/' + this.peekItem.id + '/trash', {method:'POST'});
|
||||
this.closePeek();
|
||||
this.loadTab();
|
||||
if (window.showToast) window.showToast('Moved to trash', 'success');
|
||||
},
|
||||
|
||||
// ── Time formatting ──
|
||||
formatTime(d) {
|
||||
if (!d) return '—';
|
||||
try {
|
||||
var dt = new Date(d);
|
||||
if (isNaN(dt.getTime())) return d;
|
||||
var now = new Date();
|
||||
var diff = now - dt;
|
||||
var mins = Math.floor(diff/60000);
|
||||
if (mins < 1) return 'Just now';
|
||||
if (mins < 60) return mins + 'm ago';
|
||||
var hours = Math.floor(mins/60);
|
||||
if (hours < 24) return hours + 'h ago';
|
||||
var days = Math.floor(hours/24);
|
||||
if (days < 7) return days + 'd ago';
|
||||
if (days < 30) return Math.floor(days/7) + 'w ago';
|
||||
return dt.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'});
|
||||
} catch(e) { return d; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user