Files
flowdeck/app/templates/library.html
T
bruno c2eb088bb2
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
fix(library): splice au lieu d'assignation pour Alpine 3.14.9 x-for
Alpine 3.14.9 ne détecte pas le changement de référence d'array
(this.flatItems = result). Utilisation de splice() pour muter
l'array en place. Ajout de $nextTick pour forcer le re-render.
2026-07-19 14:45:06 -04:00

825 lines
35 KiB
HTML

{% extends "base.html" %}
{% block page_icon %}📚{% endblock %}
{% 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 x-data="libraryPage()" x-init="init()" @keydown.escape="closePeek()">
<div id="lib-workspace-id" style="display:none;">{{ active_workspace_id or 0 }}</div>
<div id="lib-gitea-active" style="display:none;">{{ '1' if is_gitea_workspace else '0' }}</div>
<div id="lib-gitea-owner" style="display:none;">{{ gitea_workspace_owner or '' }}</div>
<div id="lib-gitea-repo" style="display:none;">{{ gitea_workspace_repo or '' }}</div>
<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 (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 — Notion-style: no separate drag/select columns */
.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;}
/* ── Row ── */
.lib-row{display:flex;align-items:center;height:36px;transition:background 0.1s;position:relative;}
.lib-row:hover{background:var(--bg-hover);}
.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;}
/* ── Name cell — contains drag handle, checkbox, chevron, icon, name, OPEN ── */
.row-name{display:flex;align-items:center;gap:4px;min-width:0;flex:1;position:relative;}
.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;cursor:pointer;}
.row-title:hover{color:var(--accent);}
.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;}
/* ── Hover-only elements (appear on row hover) ── */
.hover-only{opacity:0.4;transition:opacity 0.12s;flex-shrink:0;}
.lib-row:hover .hover-only{opacity:1;}
/* Drag handle — 6 dots (always slightly visible, full on hover) */
.drag-handle{display:flex;flex-wrap:wrap;width:16px;height:16px;cursor:grab;align-content:space-between;justify-content:space-between;flex-shrink:0;}
.drag-handle .dot{width:3px;height:3px;background:var(--text-tertiary);border-radius:50%;}
.drag-handle:active{cursor:grabbing;}
/* Checkbox (always visible, accentuated on hover) */
.lib-checkbox{width:15px;height:15px;border:1.5px solid var(--text-tertiary);border-radius:3px;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:border-color 0.1s;}
.lib-checkbox:hover{border-color:var(--text-primary);}
.lib-checkbox.checked{background:var(--accent);border-color:var(--accent);opacity:1;}
.lib-checkbox.checked::after{content:'✓';color:#fff;font-size:10px;font-weight:700;}
/* OPEN button (always slightly visible, full on hover) */
.btn-open{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border:1px solid var(--border);border-radius:4px;font-size:11px;color:var(--text-secondary);background:var(--bg-tertiary);cursor:pointer;white-space:nowrap;transition:all 0.1s;flex-shrink:0;text-decoration:none;margin-left:auto;opacity:0.5;}
.lib-row:hover .btn-open{opacity:1;}
/* 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-0{padding-left:4px;}
.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;}
/* "+ Add new" under expanded parent */
.add-new-under{padding:4px 8px 4px 28px;color:var(--text-tertiary);font-size:12px;cursor:pointer;transition:color 0.15s;}
.add-new-under:hover{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-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>
<!-- ── 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>
<!-- 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>
<!-- ── 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="lib-tab" :class="{ active: tab === 'favorites' }" @click="switchTab('favorites')">
<span class="tab-icon">⭐</span> Favorites
</button>
<button class="lib-tab" :class="{ active: tab === 'shared' }" @click="switchTab('shared')">
<span class="tab-icon">👥</span> Shared
</button>
<button class="lib-tab" :class="{ active: tab === 'published' }" @click="switchTab('published')">
<span class="tab-icon">🌐</span> Published
</button>
<button class="lib-tab" :class="{ active: tab === 'private' }" @click="switchTab('private')" x-show="!workspaceId && !isGiteaActive">
<span class="tab-icon">🔒</span> Private
</button>
<button class="lib-tab" :class="{ active: tab === 'repository' }" @click="switchTab('repository')" x-show="isGiteaActive">
<span class="tab-icon">📦</span> Repository
</button>
<button class="lib-tab" :class="{ active: tab === 'workspace' }" @click="switchTab('workspace')">
<span class="tab-icon">📁</span> Workspace
</button>
</div>
<!-- ── 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>
<!-- ── Loading ── -->
<div x-show="loading" class="lib-loading">
<p>Loading…</p>
</div>
<!-- ── Table ── -->
<div class="lib-table" x-show="!loading && flatItems.length > 0" id="lib-table">
<div class="lib-table-header">
<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>
<div class="lib-table-body" id="lib-table-body">
<template x-for="(item, idx) in flatItems" :key="item.id">
<div class="lib-row" :class="{ selected: !!selected[item.id] }" :data-lib-id="item.id">
<div class="row-cell lib-col-name" :class="'indent-' + (item.depth || 0)">
<div class="row-name">
<!-- Drag handle (hover-only) -->
<div class="drag-handle hover-only" 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>
<!-- Checkbox (hover-only, always visible if checked) -->
<div class="lib-checkbox hover-only" :class="{ checked: selected[item.id] }"
@click.stop="toggleItem(item)"></div>
<!-- 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"
@click.stop="startRename(item)"
@dblclick.stop="openItem(item)"
x-text="item.title"
title="Click to rename, double-click to open"></span>
<input x-show="renamingId === item.id"
x-model="renameValue"
@keydown.enter="commitRename(item)"
@keydown.escape="renamingId=null"
@blur="commitRename(item)"
@click.stop
style="flex:1;min-width:80px;">
<!-- OPEN button (hover-only, right-aligned) -->
<button class="btn-open hover-only" @click.stop="openPeek(item)" title="Open preview">⤢ Open</button>
</div>
</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>
</div>
<div class="row-cell lib-col-source">
<span class="row-source" x-text="item.source_label || 'Private'"></span>
</div>
<div class="row-cell lib-col-edited">
<span class="row-time" x-text="formatTime(item.updated_at)"></span>
</div>
<div class="row-cell lib-col-visited">
<span class="row-time" x-text="formatTime(item.visited_at)"></span>
</div>
</div>
</template>
</div>
</div>
<!-- "+ 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 && 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 %}
<script>
function libraryPage() {
return {
tab: 'recents',
workspaceId: 0,
isGiteaActive: false,
giteaOwner: '',
giteaRepo: '',
items: [],
loading: false,
searchOpen: false,
searchQuery: '',
// Selection
selected: {},
selectedCount: 0,
allSelected: false,
// 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 (NOT a getter — Alpine 3.14.9 bug) ──
flatItems: [],
_recomputeFlatItems() {
var result = [];
this._flatten(this.items, result, 0);
if (this.searchQuery.trim()) {
var q = this.searchQuery.toLowerCase();
result = result.filter(function(r) {
return (r.title || '').toLowerCase().indexOf(q) !== -1;
});
}
// Use splice for Alpine 3.14.9 reactivity (array reference reassign fails)
this.flatItems.splice(0, this.flatItems.length, ...result);
this.selectedCount = Object.keys(this.selected).length;
this.allSelected = this.flatItems.length > 0 && this.selectedCount === this.flatItems.length;
},
_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 ──
emptyIcon: '📄',
emptyTitle: 'No items',
emptyText: '',
_recomputeEmpty() {
var icons = {recents:'🕒',favorites:'⭐',shared:'👥',published:'🌐',private:'🔒',workspace:'📁',repository:'📦'};
this.emptyIcon = icons[this.tab] || '📄';
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',
repository:'Empty repository'};
this.emptyTitle = titles[this.tab] || 'No items';
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.',
repository:'Repository files and pages will appear here.'};
this.emptyText = texts[this.tab] || '';
},
// ── Init ──
init() {
console.log('[Library] init start');
var wsEl = document.getElementById('lib-workspace-id');
this.workspaceId = parseInt(wsEl ? wsEl.textContent : '0') || 0;
var giteaEl = document.getElementById('lib-gitea-active');
this.isGiteaActive = (giteaEl ? giteaEl.textContent : '0') === '1';
this.giteaOwner = (document.getElementById('lib-gitea-owner') || {}).textContent || '';
this.giteaRepo = (document.getElementById('lib-gitea-repo') || {}).textContent || '';
console.log('[Library] workspaceId=' + this.workspaceId + ' isGitea=' + this.isGiteaActive);
var params = new URLSearchParams(window.location.search);
var t = params.get('tab');
if (t && ['recents','favorites','shared','published','private','workspace','repository'].indexOf(t) !== -1) {
this.tab = t;
}
this._recomputeEmpty();
console.log('[Library] loading tab: ' + this.tab);
this.loadTab();
},
switchTab(t) {
if (this.tab === t) return;
if (t === 'repository' && !this.isGiteaActive) return;
this.tab = t;
this.items = [];
this.selected = {};
this.selectedCount = 0;
this.allSelected = false;
this.expanded = {};
this.searchQuery = '';
this.searchOpen = false;
this._recomputeEmpty();
this.updateUrl();
this.loadTab();
},
updateUrl() {
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;
console.log('[Library] loadTab: ' + this.tab + ' workspaceId=' + this.workspaceId);
try {
var url = '/api/library/' + this.tab;
var sep = '?';
if (this.workspaceId && (this.tab === 'recents' || this.tab === 'workspace')) {
url += sep + 'workspace_id=' + this.workspaceId; sep = '&';
}
if (this.tab === 'repository' && this.isGiteaActive) {
url += sep + 'gitea_owner=' + encodeURIComponent(this.giteaOwner) + '&gitea_repo=' + encodeURIComponent(this.giteaRepo);
sep = '&';
}
console.log('[Library] fetching: ' + url);
var r = await fetch(url);
var d = await r.json();
this.items = d.items || [];
console.log('[Library] loaded ' + this.items.length + ' items');
this._markTree(this.items, 0);
this._recomputeFlatItems();
console.log('[Library] flatItems after recompute: ' + this.flatItems.length);
// Force Alpine to re-render (known issue with x-for in 3.14.9)
var self = this;
this.$nextTick(function() { self.loading = false; });
} catch(e) {
console.error('[Library] load error:', e);
this.items = [];
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() { this._recomputeFlatItems(); },
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 ──
async toggleExpand(item) {
if (this.expanded[item.id]) {
// Collapse
delete this.expanded[item.id];
this.expanded = Object.assign({}, this.expanded);
this._recomputeFlatItems();
return;
}
// Expand — load children if not loaded yet
this.expanded[item.id] = true;
this.expanded = Object.assign({}, this.expanded);
if (!item.children || !item.children.length) {
try {
var r = await fetch('/api/library/children/' + item.id);
var d = await r.json();
item.children = d.items || [];
// Mark the tree for depth calculation
this._markChildren(item.children, (item.depth || 0) + 1);
} catch(e) {
item.children = [];
}
}
this._recomputeFlatItems();
},
_markChildren(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) {
this._markChildren(nodes[i].children, depth + 1);
}
}
},
// ── 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);
this._recomputeFlatItems();
},
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;
}
this._recomputeFlatItems();
},
clearSelection() {
this.selected = {};
this.moreOpen = false;
this._recomputeFlatItems();
},
// ── 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.id) return;
window.location.href = '/pages/' + item.id;
},
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 {
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() { 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; }
}
};
}
// SortableJS — drag-and-drop reorder in library table
document.addEventListener('alpine:init', function() {
var initSortable = function() {
var body = document.getElementById('lib-table-body');
if (!body || typeof Sortable === 'undefined') return;
// Clean up any existing instance
if (body._sortable) body._sortable.destroy();
body._sortable = new Sortable(body, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'sortable-ghost',
onEnd: async function(evt) {
var itemEl = evt.item;
var itemId = itemEl.getAttribute('data-lib-id');
var prevEl = evt.item.previousElementSibling;
var nextEl = evt.item.nextElementSibling;
var prevId = prevEl ? prevEl.getAttribute('data-lib-id') : null;
var nextId = nextEl ? nextEl.getAttribute('data-lib-id') : null;
if (!itemId) return;
try {
await fetch('/api/pages/' + itemId + '/move', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({before_id: nextId || null, after_id: prevId || null})
});
} catch(e) {}
}
});
};
// Watch for table body updates with MutationObserver
var target = document.getElementById('lib-table');
if (target) {
var observer = new MutationObserver(function() {
setTimeout(initSortable, 200);
});
observer.observe(target, {childList: true, subtree: true});
}
initSortable();
});
</script>
{% endblock %}