Ajout : - e2e/csp_preview.spec.js — apercu CSP strict SANS deployer : le build officiel @alpinejs/csp (e2e/fixtures/alpine.csp.js, 0 eval) est servi a la place de alpine.min.js par interception Playwright ; toute expression que le parseur maison ne digere pas = pageerror (filet). Premiere surface VERTE : library (composant lie, icones SVG via Alpine.effect, recherche ouverte + focalisee, 0 erreur). Changed : - 16 composants x-data="fn()" enregistres via Alpine.data (registre = seule resolution du build CSP, probe « Undefined variable » ; scripts classiques executes pendant le parsing => alpine:init toujours joint) : appState, libraryPage, workspacesPage, editorState, board x4, settings/import/table_view/team_load/trash/workspace/welcome/accounts/ card_detail. - base.html (shell) migre : x-effect document.* -> syncSidebarClass(), $nextTick(arrow) -> initSidebarSort(), window.FlowDeck.* -> fdCreatePage/fdCreateFolder/fdGwRefresh, Object.keys/Math.min/ window.innerWidth dans x-for et :style -> sidebarSections()/ sectionMenuPos() — tout = simple appel de methode. - x-html restants du shell -> x-init + Alpine.effect : icone agent, carte projet, library x3 ; recherche library -> toggleSearch() (Alpine.nextTick) ; openMoveSelected() pour Object.keys en expression. - eslint : 70 warnings -> 0/0 (globals getCsrf depuis A38 ph1, /* exported openCardDetail */ + /* global owner, repo */, 3 ;; residuels). suite **1093/1093** · ruff OK · eslint 0/0 · E2E 3/3 (csp_preview + smoke x2) · docs a jour (ROADMAP ph3 LOT 1, CHANGELOG, WORKLOAD, VERSION)
1066 lines
40 KiB
JavaScript
1066 lines
40 KiB
JavaScript
/* exported libraryPage -- appeles depuis les attributs HTML des templates */
|
|
|
|
// A20 phase 3 : enregistrement Alpine.data — le build CSP ne résout que le
|
|
// registre (probe : globale window → « Undefined variable: libraryPage »).
|
|
document.addEventListener('alpine:init', function () {
|
|
Alpine.data('libraryPage', libraryPage);
|
|
});
|
|
|
|
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: '',
|
|
|
|
// Toolbar dropdowns
|
|
filterOpen: false,
|
|
sortOpen: false,
|
|
viewOpen: false,
|
|
sourceFilter: 'all',
|
|
shareDir: 'all',
|
|
showAuthor: false,
|
|
showSource: false,
|
|
|
|
// Tags
|
|
workspaceTags: [],
|
|
tagFilter: '',
|
|
|
|
// Sort
|
|
sortBy: '',
|
|
|
|
// More menu
|
|
moreOpen: false,
|
|
|
|
// Move-to modal
|
|
moveModalOpen: false,
|
|
moveIds: [],
|
|
moveSearchQuery: '',
|
|
|
|
// No workspace guard
|
|
noWorkspace: false,
|
|
moveCandidates: [],
|
|
_moveAllCandidates: [],
|
|
|
|
// Drag & drop
|
|
_dragId: null,
|
|
|
|
// Side peek
|
|
peekItem: null,
|
|
peekContent: '',
|
|
peekLoading: false,
|
|
peekFavorited: false,
|
|
peekMoreOpen: false,
|
|
|
|
// ── Computed flat list ──
|
|
flatItems: [],
|
|
|
|
_renderTable() {
|
|
var self = this;
|
|
var body = document.getElementById('lib-table-body');
|
|
var table = document.getElementById('lib-table');
|
|
var emptyDiv = document.getElementById('lib-empty');
|
|
var loadingDiv = document.getElementById('lib-loading');
|
|
if (!body) return;
|
|
|
|
// Show/hide table vs empty state
|
|
if (this.flatItems.length === 0) {
|
|
if (table) table.style.display = 'none';
|
|
if (emptyDiv) emptyDiv.style.display = '';
|
|
return;
|
|
}
|
|
if (table) table.style.display = '';
|
|
if (emptyDiv) emptyDiv.style.display = 'none';
|
|
if (loadingDiv) loadingDiv.style.display = 'none';
|
|
var html = '';
|
|
for (var i = 0; i < this.flatItems.length; i++) {
|
|
var item = this.flatItems[i];
|
|
|
|
// ── Synthetic "+ Add new" placeholder row for an expanded parent ──
|
|
if (item._isAddNew) {
|
|
var pad = 30 + (item.depth || 0) * 20;
|
|
html += '<div class="add-new-under" style="padding-left:' + pad + 'px" onclick="window.FlowDeck.createPage()">';
|
|
html += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add new</div>';
|
|
continue;
|
|
}
|
|
|
|
var selClass = this.selected[item.id] ? ' selected' : '';
|
|
var indentPx = 4 + (item.depth || 0) * 20;
|
|
var checkClass = this.selected[item.id] ? ' checked' : '';
|
|
var chevOpen = this.expanded[item.id] ? ' open' : '';
|
|
var isRenaming = this.renamingId === item.id;
|
|
html += '<div class="lib-row' + selClass + '" data-lib-id="' + item.id + '" draggable="true" '
|
|
+ 'ondragstart="window._libData.onRowDragStart(event,' + item.id + ')" '
|
|
+ 'ondragover="window._libData.onRowDragOver(event,' + item.id + ')" '
|
|
+ 'ondragleave="window._libData.onRowDragLeave(event,' + item.id + ')" '
|
|
+ 'ondrop="window._libData.onRowDrop(event,' + item.id + ')" '
|
|
+ 'ondragend="window._libData.onRowDragEnd(event)" '
|
|
+ 'oncontextmenu="window._libData.onRowContextMenu(event,' + item.id + ')" '
|
|
+ 'onclick="window._libData.onRowClick(event,' + item.id + ')">';
|
|
html += '<div class="row-cell lib-col-name" style="padding-left:' + indentPx + 'px"><div class="row-name">';
|
|
// Drag handle
|
|
html += '<div class="drag-handle hover-only" title="Drag to move">';
|
|
html += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="9" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="9" cy="18" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="18" r="1.5" fill="currentColor" stroke="none"/></svg></div>';
|
|
// Checkbox
|
|
html += '<div class="lib-checkbox hover-only' + checkClass + '" onclick="event.stopPropagation();window._libData.toggleItemById(' + item.id + ')">';
|
|
if (this.selected[item.id]) html += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>';
|
|
html += '</div>';
|
|
// Chevron or spacer
|
|
if (item.has_children) {
|
|
html += '<button class="tree-chevron' + chevOpen + '" onclick="event.stopPropagation();window._libData.toggleExpandById(' + item.id + ')">';
|
|
html += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 6 15 12 9 18"/></svg></button>';
|
|
} else {
|
|
html += '<span style="width:18px;flex-shrink:0;"></span>';
|
|
}
|
|
// Icon — monochrome, comme local-workspace (emoji custom si défini)
|
|
var isFolder = item.is_folder || item.has_children;
|
|
html += '<span class="row-icon">' + self._renderIcon(item) + '</span>';
|
|
// Title or rename input
|
|
if (isRenaming) {
|
|
html += '<input id="rename-inp-' + item.id + '" value="' + self._escAttr(self.renameValue) + '" onkeydown="if(event.key===\'Enter\')window._libData.commitRenameById(' + item.id + ');if(event.key===\'Escape\')window._libData.cancelRename();" onblur="window._libData.commitRenameById(' + item.id + ')" style="flex:1;min-width:80px;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;" onclick="event.stopPropagation()">';
|
|
} else {
|
|
html += '<span class="row-title" title="' + self._escAttr(item.title) + '">' + self._escHtml(item.title) + '</span>';
|
|
}
|
|
// Tags
|
|
if (item.tags && item.tags.length) {
|
|
html += '<span class="row-tags">';
|
|
for (var ti = 0; ti < item.tags.length; ti++) {
|
|
var tg = item.tags[ti];
|
|
html += '<span class="tag-chip" style="background:' + self._escAttr(tg.color || '#787774') + '" title="' + self._escAttr(tg.name) + '">' + self._escHtml(tg.name) + '</span>';
|
|
}
|
|
html += '</span>';
|
|
}
|
|
// OPEN button (side peek) — hidden for folders
|
|
if (!isFolder) {
|
|
html += '<button class="btn-open hover-only" onclick="event.stopPropagation();window._libData.openPeekById(' + item.id + ')" title="Open in side peek">';
|
|
html += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg> Open</button>';
|
|
}
|
|
html += '</div></div>';
|
|
// Author
|
|
html += '<div class="row-cell lib-col-author"><div class="row-author">';
|
|
html += '<div class="author-avatar">' + (item.author_initial || '?') + '</div>';
|
|
html += '<span>' + (item.author || '\u2014') + '</span></div></div>';
|
|
// Source
|
|
html += '<div class="row-cell lib-col-source"><span class="row-source">' + (item.source_label || 'Private') + '</span></div>';
|
|
// Edited
|
|
html += '<div class="row-cell lib-col-edited"><span class="row-time">' + self.formatTime(item.updated_at) + '</span></div>';
|
|
html += '</div>';
|
|
}
|
|
body.innerHTML = html;
|
|
},
|
|
|
|
_escHtml(s) { var d=document.createElement('div'); d.textContent = s||''; return d.innerHTML; },
|
|
_escAttr(s) { return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); },
|
|
openMoveSelected() {
|
|
this.openMovePicker(Object.keys(this.selected).map(Number));
|
|
},
|
|
// ── A20 phase 3 (surface library) : formes compatibles build CSP ──
|
|
// $nextTick / x-html ne passent pas par l'évaluateur maison du build CSP
|
|
// (arrow inline = parse error ; x-html = interdit) → méthodes JS réelles,
|
|
// réactivité via Alpine.effect — valable sous les deux builds.
|
|
toggleSearch() {
|
|
this.searchOpen = !this.searchOpen;
|
|
if (this.searchOpen) Alpine.nextTick(() => {
|
|
var el = document.getElementById('lib-search-input');
|
|
if (el) el.focus();
|
|
});
|
|
},
|
|
bindHtmlIcon(el) {
|
|
Alpine.effect(() => { el.innerHTML = getSvgIcon(this.emptyIcon, 48); });
|
|
},
|
|
bindHtmlItem(el, item) {
|
|
Alpine.effect(() => { el.innerHTML = this._renderIcon(item); });
|
|
},
|
|
_renderIcon(item) {
|
|
if (!item) return getSvgIcon('file', 14);
|
|
// Custom emoji (image URL), icon name, or unicode emoji
|
|
if (item.page_icon) return '<span style="font-size:14px;line-height:1;display:inline-flex;align-items:center;">' + (window.fdIconHtml ? window.fdIconHtml(item.page_icon, 14) : this._escHtml(item.page_icon)) + '</span>';
|
|
if (item.is_folder || item.has_children) return getSvgIcon('folder', 14);
|
|
if (item.content_format && item.content_format !== 'file') return getSvgIcon('edit', 14);
|
|
var n = (item.title || '').toLowerCase();
|
|
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return getSvgIcon('image', 14);
|
|
return getSvgIcon('file', 14);
|
|
},
|
|
|
|
_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;
|
|
});
|
|
}
|
|
if (this.tagFilter) {
|
|
var tf = this.tagFilter;
|
|
result = result.filter(function(r) {
|
|
return (r.tags || []).some(function(t) { return t.name === tf; });
|
|
});
|
|
}
|
|
// 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;
|
|
this._renderTable();
|
|
},
|
|
|
|
// ── Global dispatch (bypasses Alpine x-for reactivity issues) ──
|
|
toggleItemById(id) {
|
|
var item = this._findItem(id);
|
|
if (!item) return;
|
|
this.toggleItem(item);
|
|
},
|
|
toggleExpandById(id) {
|
|
var item = this._findItem(id);
|
|
if (!item) return;
|
|
this.toggleExpand(item);
|
|
},
|
|
startRenameById(id) {
|
|
var item = this._findItem(id);
|
|
if (!item) return;
|
|
this.startRename(item);
|
|
},
|
|
commitRenameById(id) {
|
|
var item = this._findItem(id);
|
|
if (!item) { this.renamingId = null; this._renderTable(); return; }
|
|
// Read value from the input
|
|
var inp = document.getElementById('rename-inp-' + id);
|
|
if (inp) this.renameValue = inp.value;
|
|
this.commitRename(item);
|
|
},
|
|
openItemById(id) {
|
|
var item = this._findItem(id);
|
|
if (!item) return;
|
|
this.openItem(item);
|
|
},
|
|
openPeekById(id) {
|
|
var item = this._findItem(id);
|
|
if (!item) return;
|
|
this.openPeek(item);
|
|
},
|
|
cancelRename() {
|
|
this.renamingId = null;
|
|
this._renderTable();
|
|
},
|
|
_findItem(id) {
|
|
for (var i = 0; i < this.flatItems.length; i++) {
|
|
if (this.flatItems[i].id === id) return this.flatItems[i];
|
|
}
|
|
return null;
|
|
},
|
|
|
|
// ── Row click / context menu ──
|
|
onRowClick(evt, id) {
|
|
var item = this._findItem(id);
|
|
if (!item) return;
|
|
this.openItem(item);
|
|
},
|
|
onRowContextMenu(evt, id) {
|
|
evt.preventDefault();
|
|
var item = this._findItem(id);
|
|
if (!item) return;
|
|
if (!(window.Alpine && Alpine.store('fdCtx'))) return;
|
|
/* MENU UNIFIÉ (partial _ctx_menu.html + store fdCtx) — handlers library
|
|
complète l'union. Open folder/tags non prévus côté library :
|
|
fdCtx les cache automatiquement via handlers absents. */
|
|
var self = this;
|
|
var handlers = {
|
|
open: function (n) { self.openItem(n); },
|
|
openTab: function (n) { self.openInNewTab(n); },
|
|
peek: function (n) { self.openPeek(n); },
|
|
rename: function (n) { self.startRename(n); },
|
|
setIcon: function (icon) { self.setItemIcon(item, icon); },
|
|
duplicate: function (n) { self.duplicateItem(n); },
|
|
link: function (n) { self.copyLink(n); },
|
|
download: function (n) { self.downloadItem(n); },
|
|
copyContent: function (n) { self.copyItemContent(n); },
|
|
move: function (n) { self.openMovePicker([n.id]); },
|
|
fav: function (n) { self.toggleFavoriteItem(n); },
|
|
delete: function (n) { self.deleteItem(n); },
|
|
tagExisting: function (t) { self.ctxAddExistingTag(t); },
|
|
tagAdd: function (name, color) { self.ctxAddNewTag(name, color); },
|
|
tagRemove: function (tagId) { self.ctxRemoveTag(tagId); }
|
|
};
|
|
if (this.tab === 'recents') handlers.recent = function (n) { self.removeItemFromRecents(n); };
|
|
var store = Alpine.store('fdCtx');
|
|
store.setAvail(self._ctxAvailTags(item));
|
|
store.openMenu(evt, item, 'library', handlers);
|
|
},
|
|
|
|
downloadItem(node) {
|
|
if (!node || node.is_folder) return;
|
|
var url = '/api/pages/' + node.id + '/download';
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.style.display = 'none';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
if (window.showToast) window.showToast('Download started', 'success');
|
|
},
|
|
|
|
async copyItemContent(node) {
|
|
if (!node || node.is_folder) return;
|
|
try {
|
|
var r = await fetch('/api/pages/' + node.id + '/file-content');
|
|
var d = await r.json();
|
|
if (d.ok && d.content !== undefined) {
|
|
await navigator.clipboard.writeText(d.content);
|
|
if (window.showToast) window.showToast('Content copied to clipboard', 'success');
|
|
} else {
|
|
if (window.showToast) window.showToast('Not a text file', 'error');
|
|
}
|
|
} catch {
|
|
if (window.showToast) window.showToast('Copy failed', 'error');
|
|
}
|
|
},
|
|
|
|
// ── Drag & drop (move / reparent) ──
|
|
onRowDragStart(evt, id) {
|
|
if (!evt.target.closest('.drag-handle')) { evt.preventDefault(); return; }
|
|
this._dragId = id;
|
|
evt.dataTransfer.effectAllowed = 'move';
|
|
evt.dataTransfer.setData('text/plain', String(id));
|
|
evt.currentTarget.classList.add('dragging');
|
|
},
|
|
onRowDragOver(evt, id) {
|
|
if (this._dragId === null || this._dragId === id) return;
|
|
evt.preventDefault();
|
|
evt.currentTarget.classList.add('drag-over');
|
|
},
|
|
onRowDragLeave(evt, id) {
|
|
evt.currentTarget.classList.remove('drag-over');
|
|
},
|
|
async onRowDrop(evt, id) {
|
|
evt.preventDefault();
|
|
evt.currentTarget.classList.remove('drag-over');
|
|
var draggedId = this._dragId;
|
|
this._dragId = null;
|
|
if (!draggedId || draggedId === id) return;
|
|
var target = this._findItem(id);
|
|
var dragged = this._findItem(draggedId);
|
|
if (!target || !dragged) return;
|
|
// Prevent dropping a page onto one of its own (already-loaded) descendants
|
|
if (this._isDescendant(dragged, id)) {
|
|
if (window.showToast) window.showToast('Cannot move a page into its own subpage', 'error');
|
|
return;
|
|
}
|
|
await this._movePages([draggedId], id);
|
|
if (window.showToast) window.showToast('Page moved', 'success');
|
|
},
|
|
onRowDragEnd(evt) {
|
|
this._dragId = null;
|
|
document.querySelectorAll('.lib-row.dragging').forEach(function(el){ el.classList.remove('dragging'); });
|
|
document.querySelectorAll('.lib-row.drag-over').forEach(function(el){ el.classList.remove('drag-over'); });
|
|
},
|
|
|
|
_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);
|
|
if (this._canAddChild()) {
|
|
out.push({ _isAddNew: true, _parentId: n.id, depth: (depth || 0) + 1, id: 'add-' + n.id });
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
_canAddChild() {
|
|
return this.tab !== 'favorites' && this.tab !== 'shared' && this.tab !== 'published' && this.tab !== 'repository';
|
|
},
|
|
|
|
_getCsrf() {
|
|
var m = getCsrf();
|
|
return m;
|
|
},
|
|
|
|
_syncSidebar() {
|
|
if (window.appState && window.appState.refreshSidebarTree) window.appState.refreshSidebarTree();
|
|
if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites();
|
|
},
|
|
|
|
_isDescendant(node, targetId) {
|
|
if (!node || !node.children) return false;
|
|
for (var i = 0; i < node.children.length; i++) {
|
|
if (node.children[i].id === targetId) return true;
|
|
if (this._isDescendant(node.children[i], targetId)) return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
async _movePages(ids, parentId) {
|
|
var csrf = this._getCsrf();
|
|
for (var i = 0; i < ids.length; i++) {
|
|
try {
|
|
await fetch('/board/api/pages/' + ids[i] + '/move', {
|
|
method: 'PUT',
|
|
headers: {'Content-Type':'application/json', 'X-CSRF-Token': csrf},
|
|
body: JSON.stringify({ parent_id: parentId || 0 })
|
|
});
|
|
} catch { /* volontaire */ }
|
|
}
|
|
this.clearSelection();
|
|
this.loadTab();
|
|
this._syncSidebar();
|
|
},
|
|
|
|
// ── Empty states ──
|
|
emptyIcon: 'file',
|
|
emptyTitle: 'No items',
|
|
emptyText: '',
|
|
|
|
_recomputeEmpty() {
|
|
var icons = {recents:'clock',favorites:'star',shared:'users',published:'globe',private:'lock',workspace:'folder',repository:'folder'};
|
|
this.emptyIcon = icons[this.tab] || 'file';
|
|
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() {
|
|
window._libData = this; // expose for onclick handlers
|
|
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 || '';
|
|
// Detect no workspace: no local wsId AND no Gitea active
|
|
if (!this.workspaceId && !this.isGiteaActive) {
|
|
this.noWorkspace = true;
|
|
this.loading = false; // don't show loading spinner
|
|
return;
|
|
}
|
|
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();
|
|
this.loadWorkspaceTags();
|
|
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.shareDir = 'all';
|
|
this.tagFilter = '';
|
|
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;
|
|
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 = '&';
|
|
} else if (this.tab !== 'repository' && this.tab !== 'recents' && this.tab !== 'favorites') {
|
|
// Hierarchical tree view: show only root-level pages at the top
|
|
url += sep + 'tree=1';
|
|
sep = '&';
|
|
} else if (this.tab !== 'repository' && this.tab !== 'workspace' && this.sourceFilter && this.sourceFilter !== 'all') {
|
|
url += sep + 'source_type=' + encodeURIComponent(this.sourceFilter); sep = '&';
|
|
}
|
|
if (this.tab === 'shared' && this.shareDir && this.shareDir !== 'all') {
|
|
url += sep + 'dir=' + encodeURIComponent(this.shareDir); sep = '&';
|
|
}
|
|
var r = await fetch(url);
|
|
var d = await r.json();
|
|
// Backend already computes has_children correctly for each item (children
|
|
// are loaded lazily on expand) — do NOT overwrite it here.
|
|
this.items = d.items || [];
|
|
this._recomputeFlatItems();
|
|
this._renderTable();
|
|
var self = this;
|
|
this.$nextTick(function() { self.loading = false; });
|
|
} catch(e) {
|
|
console.error('[Library] load error:', e);
|
|
this.items = [];
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
doSearch() { this._recomputeFlatItems(); },
|
|
|
|
setSourceFilter(v) {
|
|
this.sourceFilter = v;
|
|
this.filterOpen = false;
|
|
this.loadTab();
|
|
},
|
|
|
|
setShareDir(v) {
|
|
if (this.shareDir === v) return;
|
|
this.shareDir = v;
|
|
this.loadTab();
|
|
},
|
|
|
|
setSort(field) {
|
|
this.sortBy = field;
|
|
this.sortOpen = false;
|
|
this.doSort();
|
|
},
|
|
|
|
toggleColumn(col) {
|
|
if (col === 'author') this.showAuthor = !this.showAuthor;
|
|
else if (col === 'source') this.showSource = !this.showSource;
|
|
},
|
|
|
|
doSort() {
|
|
var s = this.sortBy;
|
|
if (!s) return;
|
|
function sortNodes(nodes) {
|
|
if (!nodes) return;
|
|
nodes.sort(function(a,b){
|
|
if (s === 'name') return (a.title||'').localeCompare(b.title||'');
|
|
if (s === 'edited') return (b.updated_at||'').localeCompare(a.updated_at||'');
|
|
if (s === 'author') return (a.author||'').localeCompare(b.author||'');
|
|
return 0;
|
|
});
|
|
for (var i = 0; i < nodes.length; i++) {
|
|
if (nodes[i].children && nodes[i].children.length) sortNodes(nodes[i].children);
|
|
}
|
|
}
|
|
sortNodes(this.items);
|
|
this._recomputeFlatItems();
|
|
},
|
|
|
|
// ── Tree expand ──
|
|
async toggleExpand(item) {
|
|
if (this.expanded[item.id]) {
|
|
// Collapse
|
|
delete this.expanded[item.id];
|
|
this.expanded = Object.assign({}, this.expanded);
|
|
this._recomputeFlatItems();
|
|
this._renderTable();
|
|
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 {
|
|
item.children = [];
|
|
}
|
|
}
|
|
this._recomputeFlatItems();
|
|
this._renderTable();
|
|
},
|
|
|
|
_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();
|
|
this._renderTable();
|
|
},
|
|
|
|
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();
|
|
this._renderTable();
|
|
},
|
|
|
|
clearSelection() {
|
|
this.selected = {};
|
|
this.moreOpen = false;
|
|
this._recomputeFlatItems();
|
|
this._renderTable();
|
|
},
|
|
|
|
// ── 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;
|
|
var csrf = this._getCsrf();
|
|
for (var i = 0; i < ids.length; i++) {
|
|
await fetch('/board/api/pages/' + ids[i], {method:'DELETE', headers:{'X-CSRF-Token': csrf}});
|
|
}
|
|
this.clearSelection();
|
|
this.loadTab();
|
|
this._syncSidebar();
|
|
if (window.showToast) window.showToast('Moved to trash', 'success');
|
|
},
|
|
|
|
async deleteItem(item) {
|
|
if (!item) return;
|
|
if (!confirm('Move "' + item.title + '" to Trash?')) return;
|
|
var csrf = this._getCsrf();
|
|
await fetch('/board/api/pages/' + item.id, {method:'DELETE', headers:{'X-CSRF-Token': csrf}});
|
|
this.loadTab();
|
|
this._syncSidebar();
|
|
if (window.showToast) window.showToast('Moved to trash', 'success');
|
|
},
|
|
|
|
removeFromRecents() {
|
|
// No server-side "visited" tracking exists yet — remove from the
|
|
// current view only (session-scoped).
|
|
this.moreOpen = false;
|
|
var ids = Object.keys(this.selected).map(Number);
|
|
if (!ids.length) return;
|
|
this.items = this.items.filter(function(it) { return ids.indexOf(it.id) === -1; });
|
|
this.clearSelection();
|
|
this._recomputeFlatItems();
|
|
},
|
|
|
|
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');
|
|
},
|
|
|
|
copyLink(item) {
|
|
if (!item) return;
|
|
navigator.clipboard.writeText(window.location.origin + '/pages/' + item.id);
|
|
if (window.showToast) window.showToast('Link copied', 'success');
|
|
},
|
|
|
|
openInNewTab(item) {
|
|
if (!item || !item.id) return;
|
|
window.open('/pages/' + item.id, '_blank');
|
|
},
|
|
|
|
removeItemFromRecents(item) {
|
|
if (!item) return;
|
|
var id = item.id;
|
|
this.items = this.items.filter(function(it) { return it.id !== id; });
|
|
this._recomputeFlatItems();
|
|
if (window.showToast) window.showToast('Removed from Recents', 'success');
|
|
},
|
|
|
|
async setItemIcon(item, icon) {
|
|
if (!item || !item.id) return;
|
|
icon = (icon || '').trim();
|
|
try {
|
|
var r = await fetch('/board/api/pages/' + item.id + '/icon', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this._getCsrf()},
|
|
body: JSON.stringify({icon: icon})
|
|
});
|
|
if (!r.ok) throw new Error('icon update failed');
|
|
item.page_icon = icon;
|
|
this._renderTable();
|
|
if (window.showToast) window.showToast('Icon updated', 'success');
|
|
} catch {
|
|
if (window.showToast) window.showToast('Failed to update icon', 'error');
|
|
}
|
|
},
|
|
|
|
// ── Tags ──
|
|
async loadWorkspaceTags() {
|
|
try {
|
|
var r = await fetch('/api/settings/tags/all');
|
|
var d = await r.json();
|
|
this.workspaceTags = d.tags || [];
|
|
} catch { this.workspaceTags = []; }
|
|
},
|
|
|
|
toggleTagFilter(tagName) {
|
|
this.tagFilter = (this.tagFilter === tagName) ? '' : tagName;
|
|
this._recomputeFlatItems();
|
|
},
|
|
|
|
_ctxAvailTags(item) {
|
|
return (this.workspaceTags || []).filter(function(t) {
|
|
return !((item && item.tags) || []).some(function(nt) { return nt.id === t.id; });
|
|
});
|
|
},
|
|
|
|
_ctxRefreshAvail() {
|
|
var store = window.Alpine && Alpine.store('fdCtx');
|
|
if (store && store.node) store.setAvail(this._ctxAvailTags(store.node));
|
|
},
|
|
|
|
ctxAddExistingTag(tag) {
|
|
this.ctxAddNewTag(tag.name, tag.color);
|
|
},
|
|
|
|
ctxRemoveTag(tagId) {
|
|
var store = window.Alpine && Alpine.store('fdCtx');
|
|
var item = store && store.node;
|
|
if (!item) return;
|
|
var self = this;
|
|
fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, {headers: {'X-CSRF-Token': getCsrf()}, method: 'DELETE' })
|
|
.then(function(r) {
|
|
if (!r.ok) return;
|
|
item.tags = (item.tags || []).filter(function(t) { return t.id !== tagId; });
|
|
self.loadWorkspaceTags();
|
|
self._ctxRefreshAvail();
|
|
self._recomputeFlatItems();
|
|
});
|
|
},
|
|
|
|
async ctxAddNewTag(tagName, color) {
|
|
tagName = (tagName || '').trim();
|
|
if (!tagName) return;
|
|
var store = window.Alpine && Alpine.store('fdCtx');
|
|
var item = store && store.node;
|
|
if (!item) return;
|
|
color = color || (store && store.newTagColor) || '#787774';
|
|
try {
|
|
var r = await fetch('/api/local-workspace/items/' + item.id + '/tags', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf()},
|
|
body: JSON.stringify({name: tagName, color: color})
|
|
});
|
|
if (r.ok) {
|
|
var d = await r.json();
|
|
var cur = item.tags || [];
|
|
if (!cur.some(function(t) { return t.id === d.tag.id; })) item.tags = cur.concat([d.tag]);
|
|
await this.loadWorkspaceTags();
|
|
this._ctxRefreshAvail();
|
|
this._recomputeFlatItems();
|
|
}
|
|
} catch { /* volontaire */ }
|
|
},
|
|
|
|
async toggleFavoriteItem(item) {
|
|
if (!item) return;
|
|
var csrf = this._getCsrf();
|
|
var method = item.favorited ? 'DELETE' : 'POST';
|
|
var r = await fetch('/board/api/favorites/' + item.id, { method: method, headers: {'X-CSRF-Token': csrf} });
|
|
if (r.ok) {
|
|
item.favorited = !item.favorited;
|
|
this._renderTable();
|
|
if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites();
|
|
if (this.tab === 'favorites') this.loadTab();
|
|
}
|
|
},
|
|
|
|
async duplicateItem(item) {
|
|
if (!item) return;
|
|
try {
|
|
var r = await fetch('/board/api/pages/' + item.id);
|
|
var page = await r.json();
|
|
var csrf = this._getCsrf();
|
|
var section = page.parent_section || 'Private';
|
|
var parentId = page.parent_id || 0;
|
|
var ws = page.workspace || '';
|
|
var createUrl = '/board/api/pages?title=' + encodeURIComponent((page.title || 'Untitled') + ' (Copy)')
|
|
+ '§ion=' + encodeURIComponent(section) + '&project=' + encodeURIComponent(ws)
|
|
+ '&parent_id=' + parentId;
|
|
var cr = await fetch(createUrl, { method: 'POST', headers: {'X-CSRF-Token': csrf} });
|
|
var created = await cr.json();
|
|
if (page.content) {
|
|
var updUrl = '/board/api/pages/' + created.id + '?content=' + encodeURIComponent(page.content)
|
|
+ '&content_format=' + encodeURIComponent(page.content_format || '');
|
|
await fetch(updUrl, { method: 'PUT', headers: {'X-CSRF-Token': csrf} });
|
|
}
|
|
this.loadTab();
|
|
this._syncSidebar();
|
|
if (window.showToast) window.showToast('Page duplicated', 'success');
|
|
} catch {
|
|
if (window.showToast) window.showToast('Failed to duplicate page', 'error');
|
|
}
|
|
},
|
|
|
|
// ── Move to (picker modal) ──
|
|
async openMovePicker(ids) {
|
|
this.moreOpen = false;
|
|
this.peekMoreOpen = false;
|
|
if (!ids || !ids.length) return;
|
|
this.moveIds = ids;
|
|
this.moveSearchQuery = '';
|
|
this.moveModalOpen = true;
|
|
try {
|
|
var r = await fetch('/api/library/recents?workspace_id=' + (this.workspaceId || 0));
|
|
var d = await r.json();
|
|
var all = (d.items || []).filter(function(it) { return ids.indexOf(it.id) === -1; });
|
|
this._moveAllCandidates = all;
|
|
this.moveCandidates = all.slice(0, 30);
|
|
} catch {
|
|
this._moveAllCandidates = [];
|
|
this.moveCandidates = [];
|
|
}
|
|
},
|
|
|
|
doMoveSearch() {
|
|
var q = (this.moveSearchQuery || '').toLowerCase().trim();
|
|
if (!q) { this.moveCandidates = this._moveAllCandidates.slice(0, 30); return; }
|
|
this.moveCandidates = this._moveAllCandidates.filter(function(it) {
|
|
return (it.title || '').toLowerCase().indexOf(q) !== -1;
|
|
}).slice(0, 30);
|
|
},
|
|
|
|
async confirmMove(parentId) {
|
|
this.moveModalOpen = false;
|
|
var ids = this.moveIds;
|
|
this.moveIds = [];
|
|
if (!ids || !ids.length) return;
|
|
await this._movePages(ids, parentId);
|
|
if (window.showToast) window.showToast('Page moved', 'success');
|
|
},
|
|
|
|
// ── Item actions ──
|
|
openItem(item) {
|
|
if (!item || !item.id) return;
|
|
if (window.fdNavigate) window.fdNavigate('/pages/' + item.id);
|
|
else window.location.href = '/pages/' + item.id;
|
|
},
|
|
|
|
startRename(item) {
|
|
this.renamingId = item.id;
|
|
this.renameValue = item.title;
|
|
this._renderTable();
|
|
// Focus the rename input
|
|
setTimeout(function() {
|
|
var inp = document.getElementById('rename-inp-' + item.id);
|
|
if (inp) { inp.focus(); inp.select(); }
|
|
}, 50);
|
|
},
|
|
|
|
async commitRename(item) {
|
|
var newName = (this.renameValue || '').trim();
|
|
this.renamingId = null;
|
|
if (!newName || newName === item.title) { this._renderTable(); return; }
|
|
try {
|
|
var csrf = this._getCsrf();
|
|
var r = await fetch('/board/api/pages/' + item.id + '?title=' + encodeURIComponent(newName), {
|
|
method: 'PUT', headers: {'X-CSRF-Token': csrf}
|
|
});
|
|
if (r.ok) {
|
|
item.title = newName;
|
|
}
|
|
} catch { /* volontaire */ }
|
|
this._renderTable();
|
|
this._syncSidebar();
|
|
},
|
|
|
|
// ── Add new ──
|
|
async addNewPage(parentId) {
|
|
var name = prompt('Page name:');
|
|
if (!name || !name.trim()) return;
|
|
var csrf = this._getCsrf();
|
|
var section = 'Private';
|
|
try {
|
|
var url = '/board/api/pages?title=' + encodeURIComponent(name.trim())
|
|
+ '§ion=' + encodeURIComponent(section)
|
|
+ '&parent_id=' + (parentId || 0);
|
|
var r = await fetch(url, { method: 'POST', headers: {'X-CSRF-Token': csrf} });
|
|
if (r.ok) {
|
|
var d = await r.json();
|
|
if (parentId) {
|
|
// Refresh the parent's children so the new page appears inline
|
|
var parent = this._findItem(parentId);
|
|
if (parent) {
|
|
this.expanded[parentId] = true;
|
|
try {
|
|
var cr = await fetch('/api/library/children/' + parentId);
|
|
var cd = await cr.json();
|
|
parent.children = cd.items || [];
|
|
} catch { /* volontaire */ }
|
|
this._recomputeFlatItems();
|
|
this._renderTable();
|
|
return;
|
|
}
|
|
}
|
|
if (window.fdNavigate) window.fdNavigate('/pages/' + d.id);
|
|
else window.location.href = '/pages/' + d.id;
|
|
}
|
|
} catch {
|
|
if (window.showToast) window.showToast('Failed to create page', 'error');
|
|
}
|
|
},
|
|
|
|
newPage() { this.addNewPage(); },
|
|
|
|
// ── Side peek ──
|
|
async openPeek(item) {
|
|
this.peekItem = item;
|
|
this.peekFavorited = item.favorited || false;
|
|
},
|
|
|
|
closePeek() {
|
|
this.peekItem = null;
|
|
},
|
|
|
|
async toggleFavoritePeek() {
|
|
if (!this.peekItem) return;
|
|
var id = this.peekItem.id;
|
|
var csrf = this._getCsrf();
|
|
var method = this.peekFavorited ? 'DELETE' : 'POST';
|
|
var r = await fetch('/board/api/favorites/' + id, {
|
|
method: method, headers: {'X-CSRF-Token': csrf}
|
|
});
|
|
if (r.ok) {
|
|
this.peekFavorited = !this.peekFavorited;
|
|
this.peekItem.favorited = 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 (!this.peekItem) return;
|
|
this.duplicateItem(this.peekItem);
|
|
},
|
|
|
|
movePeekTo() {
|
|
this.peekMoreOpen = false;
|
|
if (!this.peekItem) return;
|
|
this.openMovePicker([this.peekItem.id]);
|
|
},
|
|
|
|
async deletePeek() {
|
|
this.peekMoreOpen = false;
|
|
if (!this.peekItem) return;
|
|
if (!confirm('Move to Trash?')) return;
|
|
var csrf = this._getCsrf();
|
|
await fetch('/board/api/pages/' + this.peekItem.id, {method:'DELETE', headers:{'X-CSRF-Token': csrf}});
|
|
this.closePeek();
|
|
this.loadTab();
|
|
this._syncSidebar();
|
|
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 { return d; }
|
|
}
|
|
};
|
|
}
|
|
// Init peek resize handle
|
|
(function() {
|
|
var panel = document.querySelector('.peek-overlay');
|
|
var handle = document.getElementById('lib-peek-resize-handle');
|
|
if (!panel || !handle) return;
|
|
var startX, startWidth, dragging = false;
|
|
handle.addEventListener('pointerdown', function(e) {
|
|
e.preventDefault();
|
|
handle.setPointerCapture(e.pointerId);
|
|
startX = e.clientX;
|
|
startWidth = panel.offsetWidth;
|
|
dragging = true;
|
|
document.body.style.userSelect = 'none';
|
|
});
|
|
handle.addEventListener('pointermove', function(e) {
|
|
if (!dragging) return;
|
|
var deltaX = startX - e.clientX;
|
|
var newWidth = Math.max(300, Math.min(window.innerWidth * 0.9, startWidth + deltaX));
|
|
panel.style.width = newWidth + 'px';
|
|
});
|
|
handle.addEventListener('pointerup', function(e) {
|
|
if (!dragging) return;
|
|
document.body.style.userSelect = '';
|
|
var moved = Math.abs(e.clientX - startX);
|
|
if (moved < 3) {
|
|
var el = document.querySelector('.peek-overlay');
|
|
if (el && el.__x) { el.__x.$data.peekItem = null; }
|
|
}
|
|
dragging = false;
|
|
try { localStorage.setItem('fd_peek_width', panel.style.width); } catch { /* volontaire */ }
|
|
handle.releasePointerCapture(e.pointerId);
|
|
});
|
|
try {
|
|
var saved = localStorage.getItem('fd_peek_width');
|
|
if (saved) panel.style.width = saved;
|
|
} catch { /* volontaire */ }
|
|
})();
|