Files
flowdeck/app/templates/library.html
T
bruno c36082be6a fix(ctxmenu): repair library rendering and complete unified row menu
- library.html: remove leftover syntax error + orphan </template></div> that
  broke libraryPage() (page showed 'undefined' and no files), call the correct
  fdCtx.openMenu and use self instead of a throwaway libraryPage() instance
- _ctx_menu.html: full conventional menu with shortcuts, tags + colour picker,
  edit-icon picker; closes on outside click and Escape
- local_workspace.html: migrate onContextMenu to the shared fdCtx store with
  complete handlers; drop legacy window._ctxMenuData DOM hacks
- dashboard.py: expose page_icon + favorited in the workspace tree
2026-09-11 22:00:54 -04:00

1350 lines
65 KiB
HTML

{% extends "base.html" %}
{% block page_icon %}{{ fd_icon("book",18) }}{% endblock %}
{% block page_title %}Library{% endblock %}
{% block title_prefix %}Library{% endblock %}
{% block topbar %}
{% set page_icon = "book" %}
{% 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:16px;}
.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;}
.lib-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;background:transparent;border:none;color:var(--text-secondary);cursor:pointer;transition:background 0.1s,color 0.1s;}
.lib-icon-btn:hover,.lib-icon-btn.active{background:var(--bg-hover);color:var(--text-primary);}
.lib-icon-btn svg{width:16px;height:16px;}
.lib-btn-primary{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;font-size:13px;font-weight:500;color:#fff;background:var(--accent);border:none;border-radius:6px;cursor:pointer;transition:background 0.1s;}
.lib-btn-primary:hover{background:var(--accent-hover);}
.lib-btn-primary svg{width:14px;height:14px;}
/* ── Tabs row (tabs left, toolbar icons right) ── */
.lib-tabs-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;}
.lib-tabs{display:flex;gap:2px;align-items:center;overflow-x:auto;scrollbar-width:none;}
.lib-tabs::-webkit-scrollbar{display:none;}
.lib-tab{display:flex;align-items:center;gap:6px;padding:6px 12px;font-size:13px;font-weight:500;color:var(--text-secondary);background:transparent;border:none;border-radius:6px;cursor:pointer;transition:all 0.12s;white-space:nowrap;}
.lib-tab:hover{color:var(--text-primary);background:var(--bg-hover);}
.lib-tab.active{color:var(--text-primary);background:var(--bg-active);}
.lib-tab svg{width:14px;height:14px;flex-shrink:0;}
.lib-toolbar{display:flex;align-items:center;gap:2px;flex-shrink:0;}
.lib-toolbar-wrap{position:relative;}
/* ── Dropdowns (filter/sort/view) ── */
.lib-dropdown{position:absolute;top:calc(100% + 4px);right:0;min-width:210px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow-popover);z-index:200;padding:6px;}
.lib-dropdown .dd-label{padding:6px 8px 2px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.3px;}
.lib-dropdown .dd-item{padding:6px 8px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:8px;transition:background 0.1s;}
.lib-dropdown .dd-item:hover{background:var(--bg-hover);}
.lib-dropdown .dd-item.active{color:var(--accent);}
.lib-dropdown .dd-item .check{width:14px;flex-shrink:0;color:var(--accent);}
.lib-search-bar{margin-bottom:10px;}
.lib-search-bar input{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;}
/* ── Multi-select bar ── */
.lib-select-bar{display:flex;align-items:center;gap:4px;padding:6px 10px;background:var(--bg-secondary);border-radius:8px;margin-bottom:8px;font-size:13px;color:var(--text-secondary);min-height:38px;}
.lib-select-bar .sel-count{font-weight:500;color:var(--text-primary);margin-right:8px;white-space:nowrap;}
.lib-select-bar button{background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:6px 8px;border-radius:6px;font-size:13px;display:flex;align-items:center;gap:6px;transition:all 0.12s;}
.lib-select-bar button:hover{background:var(--bg-hover);color:var(--text-primary);}
.lib-select-bar button svg{width:14px;height:14px;}
/* ── 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{display:flex;align-items:center;gap:5px;padding:6px 8px;font-size:12px;font-weight:500;color:var(--text-dim);user-select:none;white-space:nowrap;}
.lib-table-header .th svg{width:13px;height:13px;flex-shrink:0;}
/* Column widths — Notion-style */
.lib-col-name{flex:1;min-width:0;}
.lib-col-author{width:150px;flex-shrink:0;display:none;}
.lib-col-source{width:120px;flex-shrink:0;display:none;}
.lib-col-edited{width:110px;flex-shrink:0;text-align:right;justify-content:flex-end;}
.lib-table.show-author .lib-col-author{display:flex;}
.lib-table.show-source .lib-col-source{display:flex;}
/* ── Row ── */
.lib-row{display:flex;align-items:center;height:34px;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.drag-over{outline:2px solid var(--accent);outline-offset:-2px;background:rgba(35,131,226,0.12);}
.lib-row.dragging{opacity:0.4;}
.lib-row .row-cell{display:flex;align-items:center;padding:0 8px;height:100%;overflow:hidden;}
.lib-row .row-cell.lib-col-edited{color:var(--text-secondary);font-size:13px;white-space:nowrap;}
/* ── Name cell — contains drag handle, checkbox, chevron, icon, name, OPEN ── */
.row-name{display:flex;align-items:center;gap:6px;min-width:0;flex:1;position:relative;}
.row-icon{font-size:15px;flex-shrink:0;width:20px;text-align:center;display:inline-flex;align-items:center;justify-content:center;}
.row-icon svg{width:16px;height:16px;color:var(--text-dim);}
.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{text-decoration:underline;}
.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;}
/* ── Shared direction sub-filter pils ── */
.lib-share-directory{display:flex;align-items:center;gap:6px;padding:4px 16px;}
.lib-dir-pill{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--text-secondary);cursor:pointer;transition:background 0.15s,color 0.15s,border-color 0.15s;}
.lib-dir-pill:hover{background:var(--bg-hover);color:var(--text-primary);}
.lib-dir-pill.active{background:var(--accent);border-color:var(--accent);color:#fff;}
/* ── Hover-only elements (hidden until row hover, or when checked/dragging) ── */
.hover-only{opacity:0;transition:opacity 0.12s;flex-shrink:0;}
.lib-row:hover .hover-only,.lib-row.selected .hover-only{opacity:1;}
/* Drag handle — grip icon */
.drag-handle{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;cursor:grab;flex-shrink:0;margin-right:2px;}
.drag-handle:active{cursor:grabbing;}
.drag-handle svg{width:14px;height:14px;color:var(--text-dim);opacity:0.75;}
/* Checkbox */
.lib-checkbox{width:15px;height:15px;border:1.5px solid var(--text-dim);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!important;}
.lib-checkbox.checked svg{width:10px;height:10px;color:#fff;}
/* OPEN button */
.btn-open{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border:1px solid var(--border);border-radius:4px;font-size:11px;font-weight:500;color:var(--text-primary);background:var(--bg-tertiary);cursor:pointer;white-space:nowrap;transition:all 0.1s;flex-shrink:0;text-decoration:none;margin-left:auto;}
.btn-open:hover{background:var(--bg-active);}
.btn-open svg{width:12px;height:12px;}
/* 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-dim);cursor:pointer;padding:0;border-radius:3px;transition:transform 0.15s,background 0.1s;}
.tree-chevron:hover{color:var(--text-primary);background:var(--bg-active);}
.tree-chevron svg{width:11px;height:11px;transition:transform 0.15s;}
.tree-chevron.open svg{transform:rotate(90deg);}
/* Indent levels (computed inline via CSS var for arbitrary depth) */
.row-indent{flex-shrink:0;}
/* ── 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);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
/* "+ Add new" under expanded parent */
.add-new-under{padding:5px 8px;color:var(--text-dim);font-size:12px;cursor:pointer;transition:color 0.15s;display:flex;align-items:center;gap:4px;}
.add-new-under:hover{color:var(--text-primary);}
.add-new-under svg{width:12px;height:12px;}
/* ── 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:4px;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;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;}
.peek-header button{background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:6px;border-radius:4px;font-size:13px;display:flex;align-items:center;}
.peek-header button:hover{background:var(--bg-hover);color:var(--text-primary);}
.peek-header button svg{width:15px;height:15px;}
.peek-body{flex:1;overflow-y:auto;padding:16px 20px;}
.peek-body .peek-empty{text-align:center;padding:60px 20px;color:var(--text-dim);}
.peek-loading{text-align:center;padding:40px;color:var(--text-dim);}
/* ── "Add new" row ── */
.add-new-row{padding:8px 12px;color:var(--text-dim);font-size:13px;cursor:pointer;transition:color 0.15s;display:flex;align-items:center;gap:4px;}
.add-new-row:hover{color:var(--text-primary);}
.add-new-row svg{width:13px;height:13px;}
/* ── More / context menu dropdown ── */
.more-menu{position:absolute;min-width:200px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow-popover);z-index:300;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 svg{width:14px;height:14px;flex-shrink:0;}
.more-menu .menu-item .shortcut{margin-left:auto;font-size:11px;color:var(--text-dim);}
.more-menu .menu-danger{color:var(--danger);}
.more-sep{height:1px;background:var(--border);margin:4px 8px;}
/* ── Move-to modal ── */
.move-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:600;display:flex;align-items:center;justify-content:center;}
.move-modal{width:420px;max-height:60vh;background:var(--bg-modal);border-radius:10px;box-shadow:var(--shadow-modal);display:flex;flex-direction:column;overflow:hidden;}
.move-modal-header{padding:14px 16px;border-bottom:1px solid var(--border);font-size:14px;font-weight:600;color:var(--text-primary);}
.move-modal-search{padding:8px 12px;border-bottom:1px solid var(--border);}
.move-modal-search input{width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:13px;outline:none;}
.move-modal-list{flex:1;overflow-y:auto;padding:4px;}
.move-modal-item{padding:7px 10px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:6px;display:flex;align-items:center;gap:8px;}
.move-modal-item:hover{background:var(--bg-hover);}
/* ── Empty state ── */
.lib-empty{text-align:center;padding:80px 20px;color:var(--text-dim);}
.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-dim);}
/* ── Responsive ── */
@media(max-width:768px){
.lib-col-author,.lib-col-source{display:none!important;}
.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="lib-btn-primary" x-show="!noWorkspace" onclick="window.FlowDeck.createPage()">
<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>
New page
</button>
</div>
</div>
<!-- ── Tabs + toolbar row ── -->
<div class="lib-tabs-row">
<div class="lib-tabs">
<button class="lib-tab" :class="{ active: tab === 'recents' }" @click="switchTab('recents')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 16 14"/></svg> Recents
</button>
<button class="lib-tab" :class="{ active: tab === 'favorites' }" @click="switchTab('favorites')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> Favorites
</button>
<button class="lib-tab" :class="{ active: tab === 'shared' }" @click="switchTab('shared')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg> Shared
</button>
<button class="lib-tab" :class="{ active: tab === 'private' }" @click="switchTab('private')" x-show="!workspaceId && !isGiteaActive">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg> Private
</button>
<button class="lib-tab" :class="{ active: tab === 'published' }" @click="switchTab('published')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><line x1="3" y1="12" x2="21" y2="12"/><path d="M12 3a15 15 0 010 18 15 15 0 010-18"/></svg> Published
</button>
<button class="lib-tab" :class="{ active: tab === 'workspace' }" @click="switchTab('workspace')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg> Workspace
</button>
<button class="lib-tab" :class="{ active: tab === 'repository' }" @click="switchTab('repository')" x-show="isGiteaActive">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4a2 2 0 001-1.73z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg> Repository
</button>
</div>
<div class="lib-toolbar">
<div class="lib-toolbar-wrap" x-show="tab !== 'repository'">
<button class="lib-icon-btn" :class="{active: filterOpen}" title="Filter" @click.stop="filterOpen=!filterOpen; sortOpen=false; viewOpen=false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
</button>
<div class="lib-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition>
<div class="dd-label">Source</div>
<div class="dd-item" :class="{active: sourceFilter==='all'}" @click="setSourceFilter('all')"><span class="check" x-text="sourceFilter==='all' ? '✓' : ''"></span> All sources</div>
<div class="dd-item" :class="{active: sourceFilter==='local'}" @click="setSourceFilter('local')"><span class="check" x-text="sourceFilter==='local' ? '✓' : ''"></span> Local</div>
<div class="dd-item" :class="{active: sourceFilter==='gitea'}" @click="setSourceFilter('gitea')"><span class="check" x-text="sourceFilter==='gitea' ? '✓' : ''"></span> Gitea</div>
<div class="dd-item" :class="{active: sourceFilter==='github'}" @click="setSourceFilter('github')"><span class="check" x-text="sourceFilter==='github' ? '✓' : ''"></span> GitHub</div>
</div>
</div>
<div class="lib-toolbar-wrap">
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; filterOpen=false; viewOpen=false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
</button>
<div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
<div class="dd-label">Sort by</div>
<div class="dd-item" :class="{active: sortBy==='name'}" @click="setSort('name')"><span class="check" x-text="sortBy==='name' ? '✓' : ''"></span> Name</div>
<div class="dd-item" :class="{active: sortBy==='edited'}" @click="setSort('edited')"><span class="check" x-text="sortBy==='edited' ? '✓' : ''"></span> Last edited</div>
<div class="dd-item" :class="{active: sortBy==='author'}" @click="setSort('author')"><span class="check" x-text="sortBy==='author' ? '✓' : ''"></span> Created by</div>
</div>
</div>
<button class="lib-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; $nextTick(()=>{ if(searchOpen) document.getElementById('lib-search-input').focus(); })">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button>
<div class="lib-toolbar-wrap">
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="viewOpen=!viewOpen; filterOpen=false; sortOpen=false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="9" cy="6" 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"/></svg>
</button>
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
<div class="dd-label">Show columns</div>
<div class="dd-item" @click="toggleColumn('author')"><span class="check" x-text="showAuthor ? '✓' : ''"></span> Created by</div>
<div class="dd-item" @click="toggleColumn('source')"><span class="check" x-text="showSource ? '✓' : ''"></span> Source</div>
</div>
</div>
</div>
</div>
<!-- Shared direction sub-filter (Par moi / Avec moi) -->
<div class="lib-share-directory" x-show="tab === 'shared'">
<button class="lib-dir-pill" :class="{ active: shareDir === 'all' }" @click="setShareDir('all')">Tous</button>
<button class="lib-dir-pill" :class="{ active: shareDir === 'made' }" @click="setShareDir('made')">Par moi</button>
<button class="lib-dir-pill" :class="{ active: shareDir === 'received' }" @click="setShareDir('received')">Avec moi</button>
</div>
<!-- Search bar (toggleable) -->
<div class="lib-search-bar" x-show="searchOpen" x-transition>
<input id="lib-search-input" type="text" placeholder="Search in this list..." x-model="searchQuery" @input="doSearch()">
</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="toggleSelectAll()" :title="allSelected ? 'Clear selection' : 'Select all'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" x-show="!allSelected"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" x-show="allSelected" x-cloak><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<button @click="clearSelection()" title="Clear selection">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<button @click="copyLinks()" title="Copy links">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
</button>
<button @click="openMovePicker(Object.keys(selected).map(Number))" title="Move to">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
</button>
<div style="position:relative;margin-left:auto;">
<button @click.stop="moreOpen=!moreOpen" title="More actions">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
</button>
<div class="more-menu" x-show="moreOpen" @click.outside="moreOpen=false" x-transition style="right:0;top:100%;">
<div class="menu-item" x-show="tab==='recents'" @click="removeFromRecents()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
Remove from Recents
</div>
<div class="menu-item" @click="copyLinks()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
Copy links to all
</div>
<div class="menu-item" @click="openMovePicker(Object.keys(selected).map(Number))">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
Move to
</div>
<div class="more-sep"></div>
<div class="menu-item menu-danger" @click="deleteSelected()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
Move to Trash <span class="shortcut">Del</span>
</div>
</div>
</div>
</div>
<!-- ── Loading ── -->
<div class="lib-loading" id="lib-loading" x-show="loading">
<p x-show="!noWorkspace">Loading…</p>
<div x-show="noWorkspace" style="padding:40px;text-align:center;">
<div style="margin-bottom:16px;">{{ fd_icon("folder",32) }}</div>
<h3 style="font-size:18px;margin:0 0 8px;">No workspace open</h3>
<p style="color:var(--text-dim);font-size:14px;margin:0 0 20px;">Open a workspace to browse and manage your library items.</p>
<a href="/workspaces" class="btn btn-primary" style="display:inline-block;text-decoration:none;padding:8px 20px;">Go to Workspaces</a>
</div>
</div>
<!-- ── Table ── -->
<div class="lib-table" id="lib-table" :class="{'show-author': showAuthor, 'show-source': showSource}">
<div class="lib-table-header">
<div class="th lib-col-name">
<input type="checkbox" style="width:14px;height:14px;accent-color:var(--accent);cursor:pointer;margin-left:2px;margin-right:6px;" :checked="allSelected" @click="toggleSelectAll()" title="Select / unselect all">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Page name
</div>
<div class="th lib-col-author">Created by</div>
<div class="th lib-col-source">Source</div>
<div class="th lib-col-edited">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 16 14"/></svg>
Last edited time
</div>
</div>
<div class="lib-table-body" id="lib-table-body">
</div>
</div>
<!-- "+ Add new" (root level) -->
<div class="add-new-row" x-show="!loading && !noWorkspace && tab !== 'favorites' && tab !== 'shared' && tab !== 'published' && tab !== 'repository'"
onclick="window.FlowDeck.createPage()">
<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>
<!-- ── Empty state ── -->
<div class="lib-empty" id="lib-empty">
<div class="empty-icon" x-html="getSvgIcon(emptyIcon,48)"></div>
<h3 x-text="emptyTitle"></h3>
<p x-text="emptyText"></p>
</div>
</div>
{% include "_ctx_menu.html" %}
<!-- ── Move-to modal ── -->
<div class="move-modal-overlay" x-show="moveModalOpen" @click.self="moveModalOpen=false" x-transition>
<div class="move-modal" @click.outside="moveModalOpen=false" x-show="moveModalOpen">
<div class="move-modal-header">Move to…</div>
<div class="move-modal-search">
<input type="text" placeholder="Search pages..." x-model="moveSearchQuery" @input="doMoveSearch()">
</div>
<div class="move-modal-list">
<div class="move-modal-item" @click="confirmMove(0)">{{ fd_icon("file",14) }} <span>Root (no parent)</span></div>
<template x-for="it in moveCandidates" :key="it.id">
<div class="move-modal-item" @click="confirmMove(it.id)">
<span x-html="it.icon ? getSvgIcon(it.icon,14) : getSvgIcon('file',14)"></span> <span x-text="it.title"></span>
</div>
</template>
</div>
</div>
</div>
<!-- ── Side Peek Panel ── -->
<div class="peek-overlay" :class="{ open: peekItem !== null }" @click.outside="closePeek()">
<div id="lib-peek-resize-handle" style="position:absolute;left:-4px;top:0;width:8px;height:100%;cursor:col-resize;z-index:501;" title="Drag to resize — click to close"></div>
<template x-if="peekItem">
<div style="display:flex;flex-direction:column;height:100%;">
<div class="peek-header">
<button @click="closePeek()" title="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<div class="peek-title">
<span x-html="peekItem.icon ? getSvgIcon(peekItem.icon,14) : getSvgIcon('file',14)"></span>
<span x-text="peekItem.title"></span>
</div>
<button @click="openItem(peekItem)" title="Open full page">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</button>
<button @click="toggleFavoritePeek()" title="Favorite">
<svg viewBox="0 0 24 24" :fill="peekFavorited ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
</button>
<button @click="copyPeekLink()" title="Copy link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
</button>
<button @click.stop="peekMoreOpen=!peekMoreOpen" title="More">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
</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()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
Duplicate
</div>
<div class="menu-item" @click="movePeekTo()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
Move to
</div>
<div class="more-sep"></div>
<div class="menu-item menu-danger" @click="deletePeek()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
Move to Trash
</div>
</div>
</div>
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;">
<iframe id="lib-peek-iframe" src="" style="width:100%;height:100%;border:none;flex:1;"></iframe>
</div>
</div>
</template>
</div>
</div><!-- /x-data -->
{% endblock %}
{% block scripts %}
<script data-cfasync="false">
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,
// 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 — folders get a folder icon
var isFolder = item.is_folder || item.has_children;
if (isFolder) {
html += '<span class="row-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/></svg></span>';
} else {
html += '<span class="row-icon">' + self._renderIcon(item.icon, 'file') + '</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>';
}
// 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,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); },
_renderIcon(icon, fallback) {
var name = icon || fallback || 'file';
// Only allow known icon names, otherwise use fallback
var valid = ['folder','file','star','link','trash','book','home','settings','lock','globe','image','edit','calendar','users','user','search','tag','bar-chart','grid','list','align-left','zap','paperclip','key','refresh','upload'];
if (valid.indexOf(name) !== -1) return getSvgIcon(name, 14);
// Custom emoji / non-ASCII icon (e.g. 📝) — render as-is
if (/[^\x00-\x7F]/.test(name)) return '<span style="font-size:14px;line-height:1;">' + this._escHtml(name) + '</span>';
return getSvgIcon(fallback || '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;
});
}
// 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); },
move: function (n) { self.openMovePicker([n.id]); },
fav: function (n) { self.toggleFavoriteItem(n); },
delete: function (n) { self.deleteItem(n); }
};
if (this.tab === 'recents') handlers.recent = function (n) { self.removeItemFromRecents(n); };
Alpine.store('fdCtx').openMenu(evt, item, 'library', handlers);
},
// ── 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 = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
},
_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(e) {}
}
this.clearSelection();
this.loadTab();
},
// ── 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();
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.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;
var self = this;
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(e) {
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();
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();
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();
if (!icon) return;
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.icon = icon;
this._renderTable();
if (window.showToast) window.showToast('Icon updated', 'success');
} catch(e) {
if (window.showToast) window.showToast('Failed to update icon', 'error');
}
},
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)')
+ '&section=' + 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();
if (window.showToast) window.showToast('Page duplicated', 'success');
} catch(e) {
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(e) {
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;
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(e) {}
this._renderTable();
},
// ── 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())
+ '&section=' + 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(e) {}
this._recomputeFlatItems();
this._renderTable();
return;
}
}
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.peekFavorited = item.favorited || false;
var iframe = document.getElementById('lib-peek-iframe');
if (iframe) iframe.src = '/pages/' + item.id + '?embed=1';
},
closePeek() {
this.peekItem = null;
var iframe = document.getElementById('lib-peek-iframe');
if (iframe) iframe.src = '';
},
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();
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; }
}
};
}
// 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(ex) {}
handle.releasePointerCapture(e.pointerId);
});
try {
var saved = localStorage.getItem('fd_peek_width');
if (saved) panel.style.width = saved;
} catch(ex) {}
})();
</script>
{% endblock %}