fix: Library page — hover effects, workspace filter, tree, rename
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped

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:
2026-07-18 11:10:55 -04:00
parent 28a41a30da
commit 6a2d56a7bd
3 changed files with 93 additions and 54 deletions
+1
View File
@@ -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)
+32
View File
@@ -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
View File
@@ -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);