fix: Library page — hover effects, workspace filter, tree, rename
1. Hover effects fixed: converted table to div-based flexbox layout (tr/td don't support display:flex). .lib-row now properly shows drag handle, checkbox, and OPEN button on hover. 2. Recents filter by workspace: /api/library/recents now accepts workspace_id parameter. Template passes active_workspace_id from sidebar context to API calls. 3. Tree hierarchy: API now enriches items with has_children via _enrich_children() batch query — shows expand chevrons for pages with sub-pages. 4. Rename on click: single click on title starts inline rename (was opening page). OPEN button still opens side peek. 5. Code cleanup: extracted _enrich_children() helper to eliminate duplicate code across 6 endpoints.
This commit is contained in:
@@ -220,6 +220,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
sidebar["nav_workspace_id"] = ws["id"] if ws else 0
|
||||
|
||||
template = env.get_template("library.html")
|
||||
sidebar["active_workspace_id"] = sidebar.get("nav_workspace_id", 0)
|
||||
return template.render(**sidebar)
|
||||
|
||||
|
||||
|
||||
@@ -100,6 +100,25 @@ def _rows_to_items(rows, uid: int = 1) -> list:
|
||||
return [_build_item(dict(r), uid) for r in rows]
|
||||
|
||||
|
||||
def _enrich_children(items: list) -> list:
|
||||
"""Add has_children info to items in a single batch query."""
|
||||
if not items:
|
||||
return items
|
||||
ids = [it["id"] for it in items]
|
||||
placeholders = ",".join(["?" for _ in ids])
|
||||
with get_conn() as conn:
|
||||
child_rows = conn.execute(
|
||||
f"SELECT parent_id, COUNT(*) as cnt FROM pages "
|
||||
f"WHERE parent_id IN ({placeholders}) AND deleted_at IS NULL "
|
||||
f"GROUP BY parent_id",
|
||||
ids,
|
||||
).fetchall()
|
||||
child_counts = {r["parent_id"]: r["cnt"] for r in child_rows}
|
||||
for it in items:
|
||||
it["has_children"] = bool(child_counts.get(it["id"], 0))
|
||||
return items
|
||||
|
||||
|
||||
# ═══════════ Tab endpoints ═══════════
|
||||
|
||||
BASE_SELECT = (
|
||||
@@ -113,6 +132,7 @@ async def library_recents(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
workspace_id: int = Query(default=0),
|
||||
):
|
||||
if source_type not in SOURCE_TYPES:
|
||||
source_type = "all"
|
||||
@@ -120,6 +140,12 @@ async def library_recents(
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
params: list = []
|
||||
|
||||
# Filter by active workspace
|
||||
if workspace_id:
|
||||
query += " AND p.workspace_id = ?"
|
||||
params.append(workspace_id)
|
||||
|
||||
query, params = _apply_source_filter(query, params, source_type)
|
||||
query += " ORDER BY p.updated_at DESC LIMIT 50"
|
||||
|
||||
@@ -127,6 +153,7 @@ async def library_recents(
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = _rows_to_items(rows, uid)
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@@ -154,6 +181,7 @@ async def library_favorites(
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = _rows_to_items(rows, uid)
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@@ -176,6 +204,7 @@ async def library_shared(
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = _rows_to_items(rows, uid)
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@@ -198,6 +227,7 @@ async def library_published(
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = _rows_to_items(rows, uid)
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@@ -220,6 +250,7 @@ async def library_private(
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = _rows_to_items(rows, uid)
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@@ -242,4 +273,5 @@ async def library_workspace(
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = _rows_to_items(rows, uid)
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
|
||||
+60
-54
@@ -11,6 +11,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div x-data="libraryPage()" x-init="init()" @keydown.escape="closePeek()">
|
||||
<div id="lib-workspace-id" style="display:none;">{{ active_workspace_id or 0 }}</div>
|
||||
|
||||
<style>
|
||||
/* ── Library layout ── */
|
||||
@@ -32,22 +33,23 @@
|
||||
.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);}
|
||||
/* ── Table (div-based for hover effects) ── */
|
||||
.lib-table{width:100%;}
|
||||
.lib-table-header{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:0;}
|
||||
.lib-table-header .th{padding:6px 8px;font-size:11px;font-weight:600;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.3px;user-select:none;white-space:nowrap;}
|
||||
.lib-table-header .th.sortable{cursor:pointer;}
|
||||
.lib-table-header .th.sortable:hover{color:var(--text-primary);}
|
||||
.lib-table-body{}
|
||||
|
||||
/* 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;}
|
||||
.lib-col-select{width:32px;text-align:center;flex-shrink:0;}
|
||||
.lib-col-drag{width:28px;flex-shrink:0;}
|
||||
.lib-col-name{flex:1;min-width:0;}
|
||||
.lib-col-author{width:160px;flex-shrink:0;}
|
||||
.lib-col-source{width:130px;flex-shrink:0;}
|
||||
.lib-col-edited{width:100px;flex-shrink:0;}
|
||||
.lib-col-visited{width:100px;flex-shrink:0;}
|
||||
.lib-col-open{width:80px;text-align:right;flex-shrink:0;}
|
||||
|
||||
/* ── Row ── */
|
||||
.lib-row{display:flex;align-items:center;height:36px;transition:background 0.1s;position:relative;}
|
||||
@@ -136,7 +138,8 @@
|
||||
/* ── 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;}
|
||||
.lib-table-header .th:nth-child(4),.lib-table-header .th:nth-child(5),.lib-table-header .th:nth-child(7){display:none;}
|
||||
.lib-row .row-cell.lib-col-author,.lib-row .row-cell.lib-col-source,.lib-row .row-cell.lib-col-visited{display:none;}
|
||||
.peek-overlay{width:100vw;max-width:100vw;}
|
||||
}
|
||||
</style>
|
||||
@@ -201,36 +204,34 @@
|
||||
</div>
|
||||
|
||||
<!-- ── 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>
|
||||
<div class="lib-table" x-show="!loading && flatItems.length > 0">
|
||||
<div class="lib-table-header">
|
||||
<div class="th lib-col-drag"></div>
|
||||
<div class="th lib-col-select">
|
||||
<div class="lib-checkbox" :class="{ checked: allSelected }" @click="toggleSelectAll()"></div>
|
||||
</div>
|
||||
<div class="th lib-col-name sortable" @click="sortBy='name';doSort()">Page name</div>
|
||||
<div class="th lib-col-author sortable" @click="sortBy='author';doSort()">Created by</div>
|
||||
<div class="th lib-col-source">Source</div>
|
||||
<div class="th lib-col-edited sortable" @click="sortBy='edited';doSort()">Last edited</div>
|
||||
<div class="th lib-col-visited sortable" @click="sortBy='visited';doSort()">Last visited</div>
|
||||
<div class="th lib-col-open"></div>
|
||||
</div>
|
||||
<div class="lib-table-body">
|
||||
<template x-for="(item, idx) in flatItems" :key="item.id">
|
||||
<tr>
|
||||
<td class="lib-col-drag">
|
||||
<div class="lib-row" :class="{ selected: !!selected[item.id] }">
|
||||
<div class="row-cell 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>
|
||||
<div class="row-cell 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>
|
||||
<div class="row-cell lib-col-name" :class="'indent-' + (item.depth || 0)">
|
||||
<div class="row-name">
|
||||
<!-- Expand chevron -->
|
||||
<button x-show="item.has_children" class="tree-chevron"
|
||||
@@ -241,8 +242,7 @@
|
||||
<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)"
|
||||
@click.stop="startRename(item)"
|
||||
x-text="item.title"></span>
|
||||
<input x-show="renamingId === item.id" class="row-title"
|
||||
x-model="renameValue"
|
||||
@@ -251,29 +251,29 @@
|
||||
@blur="commitRename(item)"
|
||||
@click.stop>
|
||||
</div>
|
||||
</td>
|
||||
<td class="lib-col-author">
|
||||
</div>
|
||||
<div class="row-cell 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">
|
||||
</div>
|
||||
<div class="row-cell lib-col-source">
|
||||
<span class="row-source" x-text="item.source_label || 'Private'"></span>
|
||||
</td>
|
||||
<td class="lib-col-edited">
|
||||
</div>
|
||||
<div class="row-cell lib-col-edited">
|
||||
<span class="row-time" x-text="formatTime(item.updated_at)"></span>
|
||||
</td>
|
||||
<td class="lib-col-visited">
|
||||
</div>
|
||||
<div class="row-cell lib-col-visited">
|
||||
<span class="row-time" x-text="formatTime(item.visited_at)"></span>
|
||||
</td>
|
||||
<td class="lib-col-open" style="text-align:right;">
|
||||
</div>
|
||||
<div class="row-cell lib-col-open" style="text-align:right;">
|
||||
<button class="btn-open" @click.stop="openPeek(item)">⤢ Open</button>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- "+ Add new" -->
|
||||
<div class="add-new-row" x-show="!loading && tab !== 'favorites' && tab !== 'shared' && tab !== 'published'"
|
||||
@@ -332,6 +332,7 @@
|
||||
function libraryPage() {
|
||||
return {
|
||||
tab: 'recents',
|
||||
workspaceId: 0,
|
||||
items: [],
|
||||
loading: false,
|
||||
searchOpen: false,
|
||||
@@ -410,6 +411,8 @@ function libraryPage() {
|
||||
|
||||
// ── Init ──
|
||||
init() {
|
||||
var wsEl = document.getElementById('lib-workspace-id');
|
||||
this.workspaceId = parseInt(wsEl ? wsEl.textContent : '0') || 0;
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var t = params.get('tab');
|
||||
if (t && ['recents','favorites','shared','published','private','workspace'].indexOf(t) !== -1) {
|
||||
@@ -441,7 +444,10 @@ function libraryPage() {
|
||||
async loadTab() {
|
||||
this.loading = true;
|
||||
try {
|
||||
var r = await fetch('/api/library/' + this.tab + '?tree=1');
|
||||
var url = '/api/library/' + this.tab;
|
||||
var sep = '?';
|
||||
if (this.workspaceId) { url += '?workspace_id=' + this.workspaceId; sep = '&'; }
|
||||
var r = await fetch(url);
|
||||
var d = await r.json();
|
||||
this.items = d.items || [];
|
||||
this._markTree(this.items, 0);
|
||||
|
||||
Reference in New Issue
Block a user