diff --git a/CHANGELOG.md b/CHANGELOG.md
index 49769a5..9d72c50 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,35 @@
# Changelog - FlowDeck
+## v7.23.0 (2026-10-01) — Audit : A27 phase 2b (+3 801 L)
+
+### Changed
+
+- **A27 (phase 2b)** — 4 blocs interpolés extraits avec la recette config
+ JSON (identique à la 2a) :
+ · `local_workspace.html` → `local_workspace.js` (2 031 L, `lw-config` :
+ current_folder_id, workspace_id)
+ · `settings.html` → `settings.js` (1 093 L, `st-config` : avatar_url,
+ avatar_color, user full_name/login/email, is_admin, auth_method —
+ **2 routes rendent ce template**, les expressions `or ""` sont préservées
+ pour les valeurs Undefined, `is_admin` reste un booléen)
+ · `_page_editor_realtime.html` → `page_editor_realtime.js` (531 L,
+ `rt-config` : SELF id/login/full_name/color)
+ · `board.html` → `board.js` (146 L, `bd-config` : owner/repo/initial_view)
+- BONUS sécurité : les valeurs passent par `|tojson` (échappement JSON
+ explicite) au lieu d'être interpolées dans des strings JS
+- Tags : config JSON inline (nonce conservé) + `
\ No newline at end of file
+
+
\ No newline at end of file
diff --git a/app/templates/board.html b/app/templates/board.html
index 10d7d3c..3f419ec 100644
--- a/app/templates/board.html
+++ b/app/templates/board.html
@@ -151,151 +151,6 @@
{% endblock %}
{% block scripts %}
-
+
+
{% endblock %}
diff --git a/app/templates/local_workspace.html b/app/templates/local_workspace.html
index 352d2ce..f8f0dee 100644
--- a/app/templates/local_workspace.html
+++ b/app/templates/local_workspace.html
@@ -480,2038 +480,8 @@
.preview-leave-end{transform:translateX(100%);opacity:0;}
-
+
+
-
+
+
{% endblock %}
diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json
index bb693df..1c17a0b 100644
--- a/docs/openapi-v2.json
+++ b/docs/openapi-v2.json
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
- "version": "7.22.0"
+ "version": "7.23.0"
},
"paths": {
"/auth/register": {
diff --git a/static/js/board.js b/static/js/board.js
new file mode 100644
index 0000000..d3f9397
--- /dev/null
+++ b/static/js/board.js
@@ -0,0 +1,147 @@
+const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.parse(el.textContent):{}}catch(e){return {}}})();
+
+ var owner = BD.owner;
+ var repo = BD.repo;
+ var initialView = BD.initial_view;
+
+ // Auto-switch to view from URL param
+ document.addEventListener('DOMContentLoaded', function() {
+ if (initialView && initialView !== 'kanban') {
+ const tab = document.querySelector(`.view-tab[data-view="${initialView}"]`);
+ if (tab) tab.click();
+ }
+ });
+
+ function setActiveTab(el) {
+ document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
+ el.classList.add('active');
+ }
+
+ function kanbanBoard() {
+ return {
+ collapsedGroups: [],
+ toggleGroup(id) {
+ const idx = this.collapsedGroups.indexOf(id);
+ idx >= 0 ? this.collapsedGroups.splice(idx, 1) : this.collapsedGroups.push(id);
+ },
+ newCard(groupId, status) {
+ document.getElementById('new-issue-form').style.display = 'block';
+ document.querySelector('#new-issue-form').__x.$data.status = status;
+ },
+ newGroup() { console.log('New group'); }
+ };
+ }
+
+ function filterSystem() {
+ return {
+ activeFilters: [],
+ statusFilters: [],
+ showStatusMenu: false,
+ statusOptions: [
+ { value: 'todo', label: 'To-do', color: 'var(--gray)' },
+ { value: 'progress', label: 'In progress', color: 'var(--blue)' },
+ { value: 'done', label: 'Complete', color: 'var(--green)' },
+ ],
+ get statusFilterLabel() {
+ return this.statusFilters.length ? this.statusFilters.join(', ') : 'All';
+ },
+ toggleStatus(val) {
+ const idx = this.statusFilters.indexOf(val);
+ idx >= 0 ? this.statusFilters.splice(idx, 1) : this.statusFilters.push(val);
+ },
+ addFilter() {
+ const prop = prompt('Filter by property (status, assignee, label):');
+ if (!prop) return;
+ const val = prompt('Value:');
+ if (!val) return;
+ this.activeFilters.push({ property: prop, value: val });
+ this.refreshView();
+ },
+ removeFilter(i) { this.activeFilters.splice(i, 1); },
+ resetFilters() { this.activeFilters = []; this.statusFilters = []; },
+ refreshView() {
+ const activeTab = document.querySelector('.view-tab.active');
+ if (activeTab) activeTab.click();
+ }
+ };
+ }
+
+ function sortSystem() {
+ return {
+ sorts: [],
+ showSortPanel: false,
+ addSort() {
+ const field = prompt('Sort by (name, status, assignee, deadline):');
+ if (!field) return;
+ this.sorts.push({ field, dir: 'asc' });
+ this.applySorts();
+ },
+ removeSort(i) { this.sorts.splice(i, 1); this.applySorts(); },
+ toggleDir(i) { this.sorts[i].dir = this.sorts[i].dir === 'asc' ? 'desc' : 'asc'; this.applySorts(); },
+ clearSorts() { this.sorts = []; this.applySorts(); },
+ applySorts() {
+ const activeTab = document.querySelector('.view-tab.active');
+ const url = new URL(activeTab.getAttribute('hx-get'), window.location.origin);
+ this.sorts.forEach(s => url.searchParams.append('sort', s.field + ':' + s.dir));
+ htmx.ajax('GET', url.pathname + url.search, { target: '#view-content', swap: 'innerHTML' });
+ }
+ };
+ }
+
+ function newIssueForm() {
+ return {
+ title: '', status: 'todo',
+ create() {
+ if (!this.title.trim()) return;
+ const csrf = document.cookie.match(/csrf_token=([^;]+)/);
+ fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
+ method: 'POST',
+ headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
+ })
+ .then(r => r.json())
+ .then(data => {
+ this.title = '';
+ this.hide();
+ // Refresh current view
+ const activeTab = document.querySelector('.view-tab.active');
+ if (activeTab) activeTab.click();
+ });
+ },
+ hide() { document.getElementById('new-issue-form').style.display = 'none'; },
+ show() { document.getElementById('new-issue-form').style.display = 'block'; }
+ };
+ }
+
+ function showNewIssue() {
+ const form = document.getElementById('new-issue-form');
+ form.style.display = form.style.display === 'none' ? 'block' : 'none';
+ }
+
+ // Init SortableJS after HTMX swaps
+ document.addEventListener('htmx:afterSwap', function(evt) {
+ if (evt.target.id === 'view-content') {
+ document.querySelectorAll('.kanban-cards').forEach(el => {
+ if (el._sortable) el._sortable.destroy();
+ el._sortable = new Sortable(el, {
+ group: 'kanban',
+ animation: 200,
+ ghostClass: 'sortable-ghost',
+ dragClass: 'sortable-drag',
+ onEnd: function(evt) {
+ const cardId = evt.item.dataset.cardId;
+ const toStatus = evt.to.dataset.status;
+ const csrf = document.cookie.match(/csrf_token=([^;]+)/);
+ fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
+ method: 'POST',
+ headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
+ })
+ .then(() => {
+ // ponytail: refresh current view after move
+ const activeTab = document.querySelector('.view-tab.active');
+ if (activeTab) activeTab.click();
+ });
+ }
+ });
+ });
+ }
+ });
diff --git a/static/js/local_workspace.js b/static/js/local_workspace.js
new file mode 100644
index 0000000..e24743f
--- /dev/null
+++ b/static/js/local_workspace.js
@@ -0,0 +1,2032 @@
+const LW=(()=>{try{const el=document.getElementById('lw-config');return el?JSON.parse(el.textContent):{}}catch(e){return {}}})();
+
+console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
+window._wsInitData = (function() {
+ return {
+ tree:[],
+ displayTree:[],
+ breadcrumb: [],
+ loaded:false,
+ showCreate:false,
+ showRename:false,
+ showDelete:false,
+ showAll:false,
+ createType:'file',
+ newName:'',
+ parentFolder:null,
+ target:null,
+ expanded: (function() {
+ try { return JSON.parse(localStorage.getItem('fd_ws_expanded') || '{}'); }
+ catch(e) { return {}; }
+ })(),
+ currentFolder: LW.current_folder_id,
+ searchQuery: '',
+ filterType: '',
+ filterOpen: false,
+ editingPath: false,
+ folderStack: [],
+
+ // Inline rename
+ renamingId: null,
+ renamingName: '',
+ renameInputRef: null,
+
+ // Preview cache
+ previewCache: {},
+
+ // Clipboard for copy/paste
+ clipboard: [],
+
+ // Sort
+ sortBy: '',
+ viewMode: localStorage.getItem('fd_default_view') || 'tree',
+ viewMenuOpen: false,
+ sortOpen: false,
+ searchOpen: false,
+ viewIcons: {tree:'',list:'',details:'',title:'',content:''},
+ viewLabels: {tree:'Tree',list:'List',details:'Details',title:'Cards',content:'Content'},
+
+ // Context menu — state lives in the shared fdCtx Alpine store (_ctx_menu.html)
+
+ // Tags
+ workspaceTags: [],
+ tagFilter: '',
+ tagInputId: null,
+ tagsVersion: 0,
+
+ // Preview panel
+ previewItem: null,
+ previewPanelContent: '',
+
+ // Multi-select
+ selectedIds: {},
+ selectedIdsCount: 0,
+ lastSelectedId: null,
+ allSelected: false,
+ moreOpen: false,
+
+ // Move-to modal
+ moveModalOpen: false,
+ moveIds: [],
+ moveSearchQuery: '',
+ moveCandidates: [],
+ _moveAllCandidates: [],
+
+ // Undo
+ undoVisible: false,
+ undoMessage: '',
+ undoItemId: null,
+ undoTimeout: null,
+
+ // Upload tracking
+ uploadSpeed: '',
+ uploadStartTime: 0,
+
+ // Path bar
+ pathValue: '/workspace/',
+
+ async navigateToRoot() {
+ this.folderStack = [];
+ this.navIndex = 0;
+ this._saveExpandedState();
+ await this._loadFolder(0);
+ },
+
+ async navigateToBreadcrumb(idx) {
+ this.folderStack = this.folderStack.slice(0, idx + 1);
+ this.navIndex = idx;
+ this._saveExpandedState();
+ var entry = this.folderStack[idx];
+ await this._loadFolder(entry ? entry.id : 0);
+ },
+
+ startPathEdit() {
+ this.editingPath = true;
+ var self = this;
+ this.$nextTick(function() {
+ var inp = self.$refs.pathEdit;
+ if (inp) { inp.focus(); inp.select(); }
+ });
+ },
+
+ // Drag indicators
+ dragCount: 0,
+ dragOverParentId: null,
+
+ // Online/offline
+ online: navigator.onLine,
+ pendingSyncCount: 0,
+ pinnedIds: (function() {
+ try { return JSON.parse(localStorage.getItem('fd_ws_pinned') || '[]'); }
+ catch(e) { return []; }
+ })(),
+
+ // Navigation history
+ navHistory: [],
+ navIndex: -1,
+ canGoBack: false,
+ canGoForward: false,
+
+ // Preview popup
+ previewVisible: false,
+ previewContent: '',
+ previewName: '',
+ previewX: 0,
+ previewY: 0,
+
+ // Drag & drop state
+ isDragging: false,
+ dropTarget: null,
+ dropTargetName: '',
+ uploading: false,
+ uploadStatus: '',
+ uploadProgress: 0,
+ draggedItemId: null,
+
+ async init() {
+ try {
+ // Expose globally for event handlers (BEFORE await)
+ window._wsData = this;
+ var url = '/api/local-workspace/tree';
+ if (this.currentFolder && this.currentFolder > 0) {
+ url += '?folder=' + this.currentFolder;
+ }
+ var r = await fetch(url);
+ var d = await r.json();
+ // Keep tree hierarchical, only roots in displayTree
+ var roots = d.tree || [];
+ this._markTree(roots, 0);
+ this.tree = roots;
+ this.displayTree = roots;
+ this.breadcrumb = d.breadcrumb || [];
+ this.loaded = true;
+ // Initialize folder stack from breadcrumb
+ this.folderStack = (this.breadcrumb || []).map(function(b) { return {id: b.id, name: b.name}; });
+ this.navIndex = this.folderStack.length > 0 ? this.folderStack.length - 1 : 0;
+ // Expose globally for renderChildren onclick handlers
+ window._wsData = this;
+ // Listen for online/offline
+ var self = this;
+ window.addEventListener('online', function() { self.online = true; });
+ window.addEventListener('offline', function() { self.online = false; });
+ // v6.0.0 PWA: reflect offline queue state from the client module
+ if (window.FlowOffline && window.FlowOffline.onChange) {
+ window.FlowOffline.onChange(function(s) {
+ self.online = s.online;
+ self.pendingSyncCount = s.pendingSyncCount;
+ });
+ }
+ // Side-peek editor posts renames here so the tree/peek title stay in sync.
+ window.addEventListener('message', function(ev) {
+ if (ev.origin !== window.location.origin) return;
+ var m = ev.data;
+ if (!m || m.type !== 'fd-page-renamed' || m.id == null) return;
+ var node = self._findNodeById(self.tree, m.id);
+ if (node) node.name = m.title || 'Untitled';
+ self.displayTree = self.displayTree.slice();
+ var t = document.getElementById('ws-preview-title');
+ if (t && self.previewItem && self.previewItem.id === m.id) t.textContent = m.title || 'Untitled';
+ var side = document.querySelector('.sidebar-item.ws-tree-item[data-id="' + m.id + '"] .page-name');
+ if (side) side.textContent = m.title || 'Untitled';
+ });
+ // Load workspace tags
+ this.loadWorkspaceTags();
+ } catch(e) { console.error('Tree load error:', e); this.loaded = true; }
+ this._recompute();
+ this._initResizeHandle();
+ },
+
+ _initResizeHandle() {
+ var panel = document.getElementById('ws-preview-panel');
+ var handle = document.getElementById('ws-preview-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) {
+ window._wsData.closePreviewPanel();
+ }
+ 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) {}
+ },
+
+ _recompute() {
+ // Recompute all former getters as plain properties
+ this.canGoBack = this.navIndex > 0;
+ this.canGoForward = this.navIndex < this.folderStack.length - 1;
+ this.pathValue = '/workspace/' + this.folderStack.filter(function(b){return b.id > 0}).map(function(b){return b.name}).join('/');
+ // flatTree
+ var ft = [];
+ this._flattenTree(this.tree, ft);
+ this.flatTree = ft;
+ // filteredTableItems
+ this._computeFilteredTableItems();
+ this._syncSelection();
+ },
+
+ // ── Pin/Favorite items ──
+ togglePin(id) {
+ var idx = this.pinnedIds.indexOf(id);
+ if (idx === -1) this.pinnedIds.push(id);
+ else this.pinnedIds.splice(idx, 1);
+ try { localStorage.setItem('fd_ws_pinned', JSON.stringify(this.pinnedIds)); }
+ catch(e) {}
+ this._haptic();
+ },
+ isPinned(id) {
+ return this.pinnedIds.indexOf(id) !== -1;
+ },
+
+ // ── Multi-select ──
+ toggleSelect(node, ev) {
+ var e = ev || {};
+ if (e.shiftKey && this.lastSelectedId) {
+ // Range select
+ var allIds = this._collectVisibleIds(this.displayTree);
+ var start = allIds.indexOf(this.lastSelectedId);
+ var end = allIds.indexOf(node.id);
+ if (start !== -1 && end !== -1) {
+ var from = Math.min(start, end), to = Math.max(start, end);
+ for (var i = from; i <= to; i++) this.selectedIds[allIds[i]] = true;
+ }
+ } else if (e.ctrlKey || e.metaKey) {
+ // Toggle single, keep others
+ if (!!this.selectedIds[node.id]) delete this.selectedIds[node.id];
+ else this.selectedIds[node.id] = true;
+ } else {
+ // Notion-style: toggle this item, keep the rest of the selection
+ if (!!this.selectedIds[node.id]) delete this.selectedIds[node.id];
+ else this.selectedIds[node.id] = true;
+ }
+ this.lastSelectedId = node.id;
+ // Force reactivity
+ this.selectedIds = Object.assign({}, this.selectedIds);
+ this._syncSelection();
+ },
+ _collectVisibleIds(nodes) {
+ var ids = [];
+ for (var i=0; i 0) {
+ var r = this._findInTree(n.children, targetId, currentPath.concat([n.name]));
+ if (r) return r;
+ }
+ }
+ return null;
+ },
+
+ // ── Preview panel (loads editor in iframe via /pages/{id}?embed=1) ──
+ selectForPreview(node) {
+ var panel = document.getElementById('ws-preview-panel');
+ if (!panel) return;
+ this.previewItem = node;
+ document.getElementById('ws-preview-icon').innerHTML = this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
+ document.getElementById('ws-preview-title').textContent = node.name;
+ var openBtn = document.getElementById('ws-preview-open-btn');
+ openBtn.onclick = function() { if (window.fdNavigate) window.fdNavigate('/pages/' + node.id); else window.location = '/pages/' + node.id; };
+ var favBtn = document.getElementById('ws-preview-fav-btn');
+ favBtn.onclick = function() { window.showToast('Favorite toggled', 'success'); };
+ var copyBtn = document.getElementById('ws-preview-copy-btn');
+ copyBtn.onclick = function() {
+ var url = window.location.origin + '/pages/' + node.id;
+ navigator.clipboard.writeText(url).then(function() {
+ window.showToast('Link copied', 'success');
+ }).catch(function() {
+ window.showToast('Failed to copy', 'error');
+ });
+ };
+ // Load editor in iframe
+ var iframe = document.getElementById('ws-preview-iframe');
+ if (iframe) iframe.src = '/pages/' + node.id + '?embed=1';
+ // Show panel
+ panel.style.display = 'flex';
+ requestAnimationFrame(function() {
+ requestAnimationFrame(function() {
+ panel.style.transform = 'translateX(0)';
+ });
+ });
+ },
+
+ closePreviewPanel() {
+ var panel = document.getElementById('ws-preview-panel');
+ if (!panel) return;
+ panel.style.transform = 'translateX(100%)';
+ var self = this;
+ setTimeout(function() {
+ panel.style.display = 'none';
+ self.previewItem = null;
+ }, 200);
+ },
+
+ async _loadPreviewContentVanilla(node) {
+ var body = document.getElementById('ws-preview-body');
+ if (!body) return;
+ if (node.is_folder) {
+ body.innerHTML = ''
+ + '
' + getSvgIcon('folder',32) + ''
+ + '
' + (node.child_count||0) + ' item(s)
'
+ + '
Open folder'
+ + '
';
+ return;
+ }
+ try {
+ var name = (node.name || '').toLowerCase();
+
+ // Images: try direct file URL
+ if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(name)) {
+ body.innerHTML = ''
+ + '
 + ')
'
+ + '
';
+ return;
+ }
+ // PDF: show icon + open link
+ if (/\.pdf$/.test(name)) {
+ body.innerHTML = ''
+ + '
' + getSvgIcon('file',32) + ''
+ + '
' + this._escHtml(node.name) + '
'
+ + '
' + (node.size_display || '') + '
'
+ + '
Open in viewer'
+ + '
';
+ return;
+ }
+
+ // Fetch content from API
+ var r = await this._fetchRetry('/api/local-workspace/page-content/' + node.id, true);
+ var content = r.content || '';
+ var format = r.format || '';
+ var preview = '';
+
+ if (format === 'file') {
+ // Uploaded file — show metadata
+ preview = ''
+ + '
' + getSvgIcon('file',32) + '
'
+ + '
' + this._escHtml(node.name) + '
'
+ + '
' + (node.size_display || '') + '
'
+ + '
Open file'
+ + '
';
+
+ } else if (format === 'blocks') {
+ // FlowDeck internal page — blocks can be array or {blocks:[...]}
+ try {
+ var blocks = typeof content === 'string' ? JSON.parse(content) : content;
+ var blockList = Array.isArray(blocks) ? blocks : (blocks && blocks.blocks ? blocks.blocks : []);
+ if (blockList.length > 0) {
+ preview = blockList.slice(0, 50).map(function(b) {
+ var txt = b.content || b.text || (typeof b === 'string' ? b : '');
+ var tag = 'div';
+ var style = 'margin-bottom:6px;line-height:1.5;';
+ if (b.type && b.type.indexOf('heading') === 0) {
+ var lvl = parseInt(b.type.split('_')[1]) || 1;
+ tag = 'h' + Math.min(lvl + 2, 6);
+ style += 'font-weight:600;';
+ }
+ if (b.type === 'code' || b.type === 'code_block') {
+ tag = 'pre';
+ style += 'background:var(--bg-tertiary);padding:10px 14px;border-radius:6px;font-family:monospace;font-size:12px;white-space:pre-wrap;';
+ }
+ return '<' + tag + ' style="' + style + '">' + (txt ? this._escHtml(txt) : '
') + '' + tag + '>';
+ }, this).join('');
+ } else {
+ preview = 'Empty document
';
+ }
+ } catch(e) {
+ preview = '' + this._escHtml(String(content).substring(0, 3000)) + '
';
+ }
+
+ } else if (format === 'markdown') {
+ // Markdown — render as preformatted for now
+ preview = '' + this._escHtml(String(content).substring(0, 5000)) + '
';
+
+ } else {
+ // Unknown format — show as text
+ var display = String(content || '').trim();
+ if (display) {
+ preview = '' + this._escHtml(display.substring(0, 5000)) + '
';
+ } else {
+ preview = 'No content
';
+ }
+ }
+ body.innerHTML = preview || '(empty)
';
+ } catch(e) {
+ body.innerHTML = 'Preview not available
';
+ }
+ },
+
+ _escHtml(s) {
+ return String(s||'').replace(/&/g,'&').replace(//g,'>');
+ },
+
+ // ── Content snippet for content view ──
+ contentSnippetMap: {},
+
+ // ── Context menu (menu unifié partagé via le store Alpine fdCtx) ──
+ onContextMenu(ev) {
+ var el = ev.target.closest('[data-ws-id]');
+ if (!el) return;
+ var id = parseInt(el.getAttribute('data-ws-id'));
+ if (!id) return;
+ var node = this._findNodeById(this.displayTree, id) || this._findNodeById(this.tree, id);
+ if (!node) return;
+ var store = window.Alpine && Alpine.store('fdCtx');
+ if (!store) return;
+ var self = this;
+ if (!this.workspaceTags || this.workspaceTags.length === 0) this.loadWorkspaceTags();
+ store.setAvail(this._ctxAvailTags(node));
+ var handlers = {
+ open: function (n) { self.openPage(n.id); },
+ openTab: function (n) { window.open('/pages/' + n.id, '_blank'); },
+ peek: function (n) { self.openSidePeek(n.id); },
+ folder: function (n) { if (n.is_folder) self.navigateToFolder(n.id); },
+ rename: function (n) { self.startRename(n); },
+ setIcon: function (icon) { self.setItemIcon(node, icon); },
+ duplicate: function (n) { self.duplicateItem(n); },
+ link: function (n) { self.copyItemLink(n); },
+ download: function (n) { self.downloadItem(n); },
+ copyContent: function (n) { self.copyItemContent(n); },
+ move: function (n) { self._ctxMove(n); },
+ fav: function (n) { self.toggleFavorite(n); },
+ delete: function (n) { self.deleteWithUndo(n); },
+ tagExisting: function (t) { self.ctxAddExistingTag(t); },
+ tagAdd: function (name, color) { self.ctxAddNewTag(name, color); },
+ tagRemove: function (tagId) { self.ctxRemoveTag(tagId); }
+ };
+ store.openMenu(ev, node, 'workspace', 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 (e) {
+ if (window.showToast) window.showToast('Copy failed', 'error');
+ }
+ },
+
+ _ctxAvailTags(node) {
+ return (this.workspaceTags || []).filter(function(t) {
+ return !((node && node.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));
+ },
+
+ copyItemLink(node) {
+ if (!node) return;
+ navigator.clipboard.writeText(window.location.origin + '/pages/' + node.id);
+ if (window.showToast) window.showToast('Link copied', 'success');
+ },
+
+ async toggleFavorite(node) {
+ if (!node) return;
+ try {
+ var method = node.favorited ? 'DELETE' : 'POST';
+ var r = await fetch('/board/api/favorites/' + node.id, { method: method });
+ if (r.ok) {
+ node.favorited = !node.favorited;
+ if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites();
+ if (window.showToast) window.showToast(node.favorited ? 'Added to Favorites' : 'Removed from Favorites', 'success');
+ }
+ } catch(e) {}
+ },
+
+ async setItemIcon(node, icon) {
+ if (!node || !node.id) return;
+ icon = (icon || '').trim();
+ try {
+ var r = await fetch('/board/api/pages/' + node.id + '/icon', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json',
+ 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''},
+ body: JSON.stringify({icon: icon})
+ });
+ if (!r.ok) throw new Error('icon update failed');
+ node.page_icon = icon;
+ this.displayTree = [...this.tree];
+ this._reloadAfterAction();
+ if (window.showToast) window.showToast('Icon updated', 'success');
+ } catch(e) {
+ if (window.showToast) window.showToast('Failed to update icon', 'error');
+ }
+ },
+
+ _ctxMove(node) {
+ if (!node) return;
+ this.selectedIds = {};
+ this.selectedIds[node.id] = true;
+ this.openMovePicker();
+ },
+
+ // ── Long-press (mobile context menu) ──
+ _touchStartX: 0,
+ _touchStartY: 0,
+ _touchStartTime: 0,
+ _touchMoved: false,
+ _touchTarget: null,
+ onTouchStart(ev) {
+ if (!ev.touches || ev.touches.length !== 1) return;
+ this._touchStartX = ev.touches[0].clientX;
+ this._touchStartY = ev.touches[0].clientY;
+ this._touchStartTime = Date.now();
+ this._touchMoved = false;
+ this._touchTarget = ev.target;
+ },
+ onTouchMove(ev) {
+ if (!ev.touches || ev.touches.length !== 1) return;
+ var dx = Math.abs(ev.touches[0].clientX - this._touchStartX);
+ var dy = Math.abs(ev.touches[0].clientY - this._touchStartY);
+ if (dx > 10 || dy > 10) this._touchMoved = true;
+ },
+ onTouchEnd(ev) {
+ if (this._touchMoved) return;
+ var elapsed = Date.now() - this._touchStartTime;
+ if (elapsed < 600) return;
+ // Long press detected — find target element
+ var el = this._touchTarget;
+ if (!el) return;
+ var idEl = el.closest('[data-ws-id]');
+ if (!idEl) return;
+ // Prevent default context menu if it hasn't fired yet
+ ev.preventDefault();
+ var fakeEv = {
+ clientX: this._touchStartX,
+ clientY: this._touchStartY,
+ target: idEl,
+ preventDefault: function(){}
+ };
+ this.onContextMenu(fakeEv);
+ },
+
+ ctxAddExistingTag(tag) {
+ this.ctxAddNewTag(tag.name, tag.color);
+ },
+
+ ctxRemoveTag(tagId) {
+ var store = window.Alpine && Alpine.store('fdCtx');
+ var node = store && store.node;
+ if (!node) return;
+ this.removeTag(node.id, tagId);
+ node.tags = (node.tags || []).filter(function(t) { return t.id !== tagId; });
+ this._ctxRefreshAvail();
+ },
+
+ async ctxAddNewTag(tagName, color) {
+ tagName = (tagName || '').trim();
+ if (!tagName) return;
+ var store = window.Alpine && Alpine.store('fdCtx');
+ var node = store && store.node;
+ if (!node) return;
+ color = color || (store && store.newTagColor) || '#787774';
+ try {
+ var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', {
+ method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
+ body: JSON.stringify({name: tagName, color: color})
+ });
+ if (r.ok) {
+ var d = await r.json();
+ var cur = node.tags || [];
+ if (!cur.some(function(t) { return t.id === d.tag.id; })) node.tags = cur.concat([d.tag]);
+ this._updateNodeTags(this.tree, node.id, d.tag, 'add');
+ this.displayTree = [...this.tree];
+ this.tagsVersion++;
+ this._bumpTagCount(d.tag, 1);
+ await this.loadWorkspaceTags();
+ this._computeFilteredTableItems();
+ this._ctxRefreshAvail();
+ }
+ } catch(e) {}
+ },
+
+ _markTree(nodes, depth) {
+ for (var i=0; i 0) {
+ this._markTree(n.children, depth + 1);
+ }
+ }
+ },
+
+ renderChildren(children, depth) {
+ if (!children || children.length === 0) return '';
+ var html = '';
+ for (var i=0; i 0;
+ var icon = this._fileIcon(c.name, c.is_folder, c.content_format, c.page_icon);
+ var escName = this.esc(c.name);
+ var escId = c.id;
+ html += '';
+ // Checkbox
+ html += '
';
+ // Drag handle
+ html += '
'
+ + ''
+ + ''
+ + '
';
+ if (c.is_folder && hasKids) {
+ html += '
';
+ } else {
+ html += '
';
+ }
+ html += '
'+icon+'';
+ if (c.is_folder) {
+ var countBadge = (c.child_count > 0) ? '
('+c.child_count+')' : '';
+ html += '
'+c.name+countBadge+'';
+ } else {
+ // Click to rename, double-click to open
+ html += '
'
+ + (c.is_shared ? '👥' : '')
+ + ''+c.name+''
+ + ''
+ + '↢ Open'
+ + '';
+ }
+ // Open button for files — to the right of title (like Library)
+ html += '
';
+ if (c.is_folder) {
+ html += '
';
+ html += '';
+ html += '';
+ html += ''+getSvgIcon('external-link',14)+'';
+ html += '';
+ }
+ html += '
';
+ html += '
';
+ // Tags
+ if (c.tags && c.tags.length > 0) {
+ html += '
';
+ for (var ti=0; ti'+tn+'';
+ }
+ html += '';
+ }
+ html += '
';
+ if (hasKids) {
+ html += ''+this.renderChildren(c.children, depth+1)+'
';
+ }
+ html += '';
+ }
+ return html;
+ },
+
+ // Inline rename for renderChildren (static HTML)
+ startRenderChildRename(id, currentName) {
+ var nameSpan = document.getElementById('rc-name-' + id);
+ var input = document.getElementById('rc-input-' + id);
+ if (!nameSpan || !input) return;
+ nameSpan.style.display = 'none';
+ input.style.display = '';
+ input.value = currentName;
+ var w = this._fitNameWidth(currentName);
+ var avail = input.parentElement ? input.parentElement.clientWidth : 0;
+ if (avail > 60) input.style.width = Math.min(w, avail) + 'px'; else input.style.width = w + 'px';
+ input.focus();
+ input.select();
+ },
+
+ async commitRenderChildRename(id) {
+ var nameSpan = document.getElementById('rc-name-' + id);
+ var input = document.getElementById('rc-input-' + id);
+ if (!input) return;
+ var newName = (input.value || '').trim();
+ input.style.display = 'none';
+ if (nameSpan) nameSpan.style.display = '';
+ if (!newName) return;
+ try {
+ var r = await fetch('/api/local-workspace/items/' + id, {
+ method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
+ body: JSON.stringify({name: newName})
+ });
+ if (r.ok) {
+ if (nameSpan) nameSpan.textContent = newName;
+ // Update tree node
+ var node = this._findNodeById(this.tree, id);
+ if (node) { node.name = newName; }
+ this._reloadAfterAction();
+ }
+ } catch(e) {}
+ },
+
+ esc(s) {
+ if (!s) return '';
+ return s.replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"');
+ },
+
+ _fitNameWidth(text) {
+ var off = document.createElement('span');
+ off.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;font-size:13px;';
+ off.textContent = (text || '').toString();
+ document.body.appendChild(off);
+ var w = off.offsetWidth;
+ document.body.removeChild(off);
+ return Math.max(w + 16, 60);
+ },
+
+ // ── Event delegation for renderChildren action buttons ──
+ handleTreeAction(ev) {
+ var btn = (ev.target.closest && ev.target.closest('[data-ws-action]'))
+ || (ev.target.parentElement && ev.target.parentElement.closest('[data-ws-action]'));
+ if (!btn) return;
+ ev.stopPropagation();
+ var action = btn.getAttribute('data-ws-action');
+ var id = parseInt(btn.getAttribute('data-ws-id'));
+ var name = btn.getAttribute('data-ws-name') || '';
+ if (!id) return;
+
+ switch(action) {
+ case 'new-file':
+ window.FlowDeck.createPage(id);
+ break;
+ case 'new-folder':
+ window.FlowDeck.showCreateFolderModal(id);
+ break;
+ case 'rename':
+ this.startRename({db_id: id, name: name});
+ break;
+ case 'delete':
+ this.deleteWithUndo({db_id: id, name: name});
+ break;
+ }
+ },
+
+ toggleExpand(id, btn) {
+ this.expanded[id] = !(this.expanded[id] || false);
+ this.expanded = Object.assign({}, this.expanded);
+ this._saveExpandedState();
+ // For renderChildren static HTML: toggle chevron + sibling
+ if (btn) {
+ btn.classList.toggle('open', this.expanded[id]);
+ // Navigate: btn → .ws-tree-item → - →
+ var itemDiv = btn.closest('.ws-tree-item');
+ if (itemDiv && itemDiv.parentElement) {
+ var children = itemDiv.parentElement.children;
+ for (var i = 0; i < children.length; i++) {
+ if (children[i].tagName === 'UL' && children[i].classList.contains('ws-tree')) {
+ children[i].style.display = this.expanded[id] ? '' : 'none';
+ break;
+ }
+ }
+ }
+ }
+ var self = this;
+ this.$nextTick(function() { self._syncCheckboxes(); });
+ },
+
+ async navigateToFolder(id) {
+ // Find the folder name for the stack
+ var folderNode = this._findNodeById(this.tree, id);
+ var folderName = folderNode ? folderNode.name : 'Folder';
+ // Push to folder stack for breadcrumb
+ if (this.navIndex < this.folderStack.length - 1) {
+ this.folderStack = this.folderStack.slice(0, this.navIndex + 1);
+ }
+ this.folderStack.push({id: id, name: folderName});
+ this.navIndex = this.folderStack.length - 1;
+ // Fetch children of this folder
+ await this._loadFolder(id);
+ },
+
+ async goToParent() {
+ if (this.folderStack.length <= 1) { await this._loadFolder(0); return; }
+ this.folderStack.pop();
+ this.navIndex = this.folderStack.length - 1;
+ var parentId = this.folderStack.length > 0 ? this.folderStack[this.folderStack.length - 1].id : 0;
+ await this._loadFolder(parentId);
+ },
+
+ async _loadFolder(folderId) {
+ var url = '/api/local-workspace/tree';
+ if (folderId && folderId > 0) url += '?folder=' + folderId;
+ try {
+ var r = await fetch(url);
+ var d = await r.json();
+ var roots = d.tree || [];
+ this._markTree(roots, 0);
+ this.tree = roots;
+ this.displayTree = [...roots];
+ this.showAll = false;
+ this.breadcrumb = d.breadcrumb || [];
+ this.currentFolder = folderId || 0;
+ this._saveExpandedState();
+ this.previewItem = null;
+ this._recompute();
+ var self = this;
+ this.$nextTick(function() { self._syncCheckboxes(); });
+ } catch(e) { console.error('Load folder error:', e); }
+ },
+
+ _findNodeById(nodes, id) {
+ for (var i=0; i= this.folderStack.length - 1) return;
+ this.navIndex++;
+ var entry = this.folderStack[this.navIndex];
+ this._saveExpandedState();
+ await this._loadFolder(entry ? entry.id : 0);
+ },
+
+ _saveExpandedState() {
+ try { localStorage.setItem('fd_ws_expanded', JSON.stringify(this.expanded)); }
+ catch(e) {}
+ },
+ _reloadAfterAction() {
+ this._saveExpandedState();
+ this._loadFolder(this.currentFolder);
+ // Directly refresh sidebar tree
+ if (window.appState && window.appState.refreshSidebarTree) {
+ window.appState.refreshSidebarTree();
+ }
+ },
+
+ // ── Inline Rename ──
+ startInlineRename(node, ev) {
+ this.renamingId = node.id;
+ this.renamingName = node.name;
+ var self = this;
+ this.$nextTick(function() {
+ var inp = self.$el.querySelector('.inline-rename-input:not([style*="display: none"])');
+ if (inp) {
+ var w = self._fitNameWidth(self.renamingName);
+ var avail = inp.parentElement ? inp.parentElement.clientWidth : 0;
+ if (avail > 60) inp.style.width = Math.min(w, avail) + 'px'; else inp.style.width = w + 'px';
+ inp.focus(); inp.select();
+ }
+ });
+ },
+ async commitRename(node) {
+ var n = this.renamingName.trim();
+ this.renamingId = null;
+ if (!n || n === node.name) return;
+ var r = await fetch('/api/local-workspace/items/' + node.id, {
+ method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
+ body: JSON.stringify({name: n})
+ });
+ if (r.ok) {
+ node.name = n; node.db_name = n;
+ this._saveExpandedState();
+ if (window.appState && window.appState.refreshSidebarTree) window.appState.refreshSidebarTree();
+ if (window.FlowDeck && window.FlowDeck.syncPageTitle) window.FlowDeck.syncPageTitle(node.id, n);
+ var self = this;
+ if (this.folderStack && this.folderStack.length) {
+ this.folderStack.forEach(function(f) { if (String(f.id) === String(node.id)) f.name = n; });
+ }
+ if (this.breadcrumb && this.breadcrumb.length) {
+ this.breadcrumb.forEach(function(b) { if (String(b.id) === String(node.id)) b.name = n; });
+ }
+ if (this.previewItem && String(this.previewItem.id) === String(node.id)) {
+ this.previewItem.name = n;
+ var pt = document.getElementById('ws-preview-title');
+ if (pt) pt.textContent = n;
+ }
+ }
+ },
+ cancelRename() { this.renamingId = null; },
+
+ // ── Keyboard shortcuts ──
+ onKeydown(ev) {
+ var tag = ev.target && ev.target.tagName;
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return; // Don't hijack typing
+ if (this.renamingId) return; // Don't intercept during rename
+ if (ev.key === 'Delete' && !ev.ctrlKey && !ev.metaKey) {
+ ev.preventDefault();
+ // Delete selected item — implement when multi-select exists
+ }
+ if (ev.key === 'F2' && !ev.ctrlKey) {
+ ev.preventDefault();
+ // Find hovered or first file item and rename
+ var hovered = document.querySelector('.ws-tree-item:hover');
+ if (hovered) {
+ var id = parseInt(hovered.getAttribute('data-ws-id') || hovered.getAttribute('data-folder-id'));
+ var name = (hovered.querySelector('.name') || {}).textContent || '';
+ if (id) this.startInlineRename({id: id, name: name}, ev);
+ }
+ }
+ if (ev.ctrlKey && ev.key === 'c') {
+ // Copy selected items to clipboard
+ ev.preventDefault();
+ this.clipboard = []; // Will implement with multi-select
+ }
+ if (ev.ctrlKey && ev.key === 'v' && this.clipboard.length > 0) {
+ ev.preventDefault();
+ // Paste items — move them to current folder
+ var self = this;
+ var targetId = this.currentFolder > 0 ? this.currentFolder : null;
+ this.clipboard.forEach(function(id) {
+ fetch('/api/local-workspace/items/' + id + '/move', {
+ method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
+ body: JSON.stringify({parent_id: targetId})
+ }).then(function(r) { if (r.ok) self._reloadAfterAction(); });
+ });
+ this.clipboard = [];
+ }
+ },
+
+ // ── Duplicate ──
+ async duplicateItem(node) {
+ var r = await fetch('/api/local-workspace/items', {
+ method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
+ body: JSON.stringify({name: node.name + ' (copy)', type: node.type || 'page',
+ parent_id: this.currentFolder > 0 ? this.currentFolder : null})
+ });
+ if (r.ok) this._reloadAfterAction();
+ },
+
+ // ── Sort ──
+ doSort() {
+ var s = this.sortBy;
+ if (!s) { this.displayTree = [...this.tree]; this.showAll = false; return; }
+ var sorted = this.tree.slice();
+ if (s === 'name-asc') sorted.sort(function(a,b) { return (a.name||'').localeCompare(b.name||''); });
+ else if (s === 'name-desc') sorted.sort(function(a,b) { return (b.name||'').localeCompare(a.name||''); });
+ else if (s === 'date-asc') sorted.sort(function(a,b) { return (a.updated_at||'').localeCompare(b.updated_at||''); });
+ else if (s === 'date-desc') sorted.sort(function(a,b) { return (b.updated_at||'').localeCompare(a.updated_at||''); });
+ else if (s === 'type') sorted.sort(function(a,b) { return (a.is_folder===b.is_folder) ? 0 : a.is_folder ? -1 : 1; });
+ this.displayTree = sorted;
+ this._recompute();
+ },
+
+ // ── Undo delete ──
+ async deleteWithUndo(node) {
+ var self = this;
+ this.undoItemId = node.db_id || node.id;
+ this.undoMessage = 'Deleted "' + (node.name || 'item') + '"';
+ this.undoVisible = true;
+ // Delete via API
+ var r = await fetch('/api/local-workspace/items/' + this.undoItemId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
+ if (!r.ok) { this.undoVisible = false; return; }
+ // Reload from API for reliability (sidebar refresh handled by _reloadAfterAction)
+ this._reloadAfterAction();
+ // Auto-dismiss undo banner after 5s
+ this.undoTimeout = setTimeout(function() {
+ self.undoVisible = false;
+ self.undoItemId = null;
+ }, 5000);
+ },
+ undoDelete() {
+ if (this.undoTimeout) clearTimeout(this.undoTimeout);
+ this.undoVisible = false;
+ var ids = (this.undoItemId || '').toString().split(',');
+ this.undoItemId = null;
+ if (!ids.length || !ids[0]) return;
+ // Restore via API (soft-delete undo) — single or bulk
+ var self = this;
+ var restored = 0;
+ var total = ids.length;
+ function restoreOne(i) {
+ if (i >= total) {
+ self._reloadAfterAction();
+ return;
+ }
+ fetch('/api/local-workspace/items/' + ids[i] + '/restore', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST' })
+ .then(function(r) { if (r.ok) restored++; })
+ .finally(function() { restoreOne(i + 1); });
+ }
+ restoreOne(0);
+ },
+ _removeFromTree(nodes, id) {
+ for (var i=0; i 0;
+ return true;
+ }
+ }
+ return false;
+ },
+
+ // ── Upload speed tracking ──
+ _trackUploadSpeed(totalSize) {
+ var self = this;
+ this.uploadStartTime = Date.now();
+ this.uploadSpeed = '';
+ var checkSpeed = setInterval(function() {
+ if (!self.uploading) { clearInterval(checkSpeed); return; }
+ var elapsed = (Date.now() - self.uploadStartTime) / 1000;
+ if (elapsed > 0) {
+ self.uploadSpeed = (totalSize / elapsed / 1024).toFixed(0) + ' KB/s';
+ }
+ }, 500);
+ },
+
+ // ── Path bar navigation ──
+ navigateToPath() {
+ var path = (this.$el.querySelector('.path-input') || {}).value || '';
+ var parts = path.replace(/^\/workspace\/?/, '').split('/').filter(Boolean);
+ if (parts.length === 0) { if (window.fdNavigate) window.fdNavigate('/local-workspace'); else window.location = '/local-workspace'; return; }
+ // Walk tree to find matching folder
+ var targetId = this._findFolderByPath(this.tree, parts, 0);
+ if (targetId) {
+ if (window.fdNavigate) window.fdNavigate('/local-workspace?folder=' + targetId);
+ else window.location = '/local-workspace?folder=' + targetId;
+ } else {
+ window.showToast('Path not found: ' + parts.join('/'), 'error');
+ }
+ },
+ _findFolderByPath(nodes, parts, idx) {
+ if (idx >= parts.length) return null;
+ for (var i=0; i';
+ this.previewContent = html; this.previewCache[cacheKey] = html;
+ } else if (isPdf) {
+ var html = '';
+ this.previewContent = html; this.previewCache[cacheKey] = html;
+ } else {
+ this.previewContent = 'Loading...';
+ this._fetchRetry(url).then(function(t) {
+ var content = t.substring(0, 500);
+ self.previewContent = content; self.previewCache[cacheKey] = content;
+ }).catch(function() { self.previewContent = '(preview not available)'; });
+ }
+ } else {
+ this.previewContent = 'Loading...';
+ this._fetchRetry('/api/local-workspace/page-content/' + node.id, true)
+ .then(function(d) {
+ var content = self._extractPreview(d.content || '', d.format);
+ self.previewContent = content; self.previewCache[cacheKey] = content;
+ }).catch(function() { self.previewContent = '(preview not available)'; });
+ }
+ },
+
+ // ── Fetch with retry ──
+ _fetchRetry(url, json) {
+ var maxRetries = 2;
+ var delay = 300;
+ var self = this;
+ return new Promise(function(resolve, reject) {
+ function attempt(n) {
+ fetch(url).then(function(r) {
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ return json ? r.json() : r.text();
+ }).then(resolve).catch(function(e) {
+ if (n < maxRetries) {
+ setTimeout(function() { attempt(n+1); }, delay * (n+1));
+ } else { reject(e); }
+ });
+ }
+ attempt(0);
+ });
+ },
+
+ // ── Touch haptic feedback ──
+ _haptic() {
+ if (navigator.vibrate) navigator.vibrate(10);
+ },
+
+ // ── Expand / Collapse all ──
+ expandAll() {
+ var self = this;
+ this._walkTree(this.tree, function(n) {
+ if (n.is_folder && n.children && n.children.length > 0) {
+ self.expanded[n.id] = true;
+ }
+ });
+ this.expanded = Object.assign({}, this.expanded);
+ // Toggle static HTML for renderChildren
+ this._toggleAllUl(this.$el, true);
+ },
+ collapseAll() {
+ this.expanded = {};
+ this._toggleAllUl(this.$el, false);
+ },
+ _walkTree(nodes, fn) {
+ for (var i=0; i'+html+'';
+ }
+ if (isFolder) return ''+getSvgIcon('folder',14)+'';
+ // FlowDeck internal pages (no extension, blocks/markdown format)
+ if (contentFormat && contentFormat !== 'file') return ''+getSvgIcon('edit',14)+'';
+ var n = (name || '').toLowerCase();
+ if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return ''+getSvgIcon('image',14)+'';
+ if (/\.pdf$/.test(n)) return ''+getSvgIcon('file',14)+'';
+ if (/\.(md|markdown)$/.test(n)) return ''+getSvgIcon('edit',14)+'';
+ if (/\.(py)$/.test(n)) return ''+getSvgIcon('file',14)+'';
+ if (/\.(js|jsx|ts|tsx)$/.test(n)) return ''+getSvgIcon('file',14)+'';
+ if (/\.(html?|xml)$/.test(n)) return ''+getSvgIcon('file',14)+'';
+ if (/\.css$/.test(n)) return ''+getSvgIcon('file',14)+'';
+ if (/\.json$/.test(n)) return ''+getSvgIcon('file',14)+'';
+ if (/\.sql$/.test(n)) return ''+getSvgIcon('file',14)+'';
+ if (/\.(sh|bash|zsh)$/.test(n)) return ''+getSvgIcon('file',14)+'';
+ if (/\.ps1$/.test(n)) return ''+getSvgIcon('file',14)+'';
+ if (/\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$/.test(n)) return ''+getSvgIcon('file',14)+'';
+ if (/\.(txt|log)$/.test(n)) return ''+getSvgIcon('file',14)+'';
+ if (/\.(zip|tar|gz|rar|7z)$/.test(n)) return ''+getSvgIcon('file',14)+'';
+ return ''+getSvgIcon('file',14)+'';
+ },
+
+ // ── Search / Filter ──
+ doFilter() {
+ var q = (this.searchQuery || '').toLowerCase().trim();
+ var ft = this.filterType || '';
+ if (!q && !ft && !this.tagFilter) { this.displayTree = [...this.tree]; this.showAll = false; this._computeFilteredTableItems(); return; }
+ // Apply all filters (search + type + tag) to tree view
+ this.displayTree = this._filterTree([...this.tree], q, ft);
+ if (this.tagFilter) {
+ this.displayTree = this._filterTreeByTag(this.displayTree, this.tagFilter);
+ }
+ this._computeFilteredTableItems();
+ },
+ _filterTreeByTag(nodes, tagName) {
+ var result = [];
+ for (var i=0; i 0) {
+ var clone = Object.assign({}, n);
+ clone.children = filteredChildren;
+ result.push(clone);
+ }
+ }
+ return result;
+ },
+ filterByType(type) {
+ this.filterType = (this.filterType === type) ? '' : type;
+ this.doFilter();
+ },
+ _filterTree(nodes, query, fileType) {
+ var result = [];
+ for (var i=0; i 0) && typeMatch) {
+ var clone = Object.assign({}, n);
+ clone.children = filteredChildren;
+ result.push(clone);
+ }
+ }
+ return result;
+ },
+
+ // ── Preview on hover ──
+ _extractPreview(content, format) {
+ if (!content) return '(empty)';
+ // Try to extract text from Notion block JSON
+ if (format === 'blocks' || format === 'markdown') {
+ try {
+ var blocks = typeof content === 'string' ? JSON.parse(content) : content;
+ if (blocks && blocks.blocks && Array.isArray(blocks.blocks)) {
+ var lines = [];
+ for (var i=0; i 0 ? this.currentFolder : null;
+ if (folderEl) {
+ var folderLink = folderEl.querySelector('.name.folder');
+ if (folderLink) {
+ var fid = folderEl.getAttribute('data-folder-id');
+ if (fid) targetParentId = parseInt(fid);
+ }
+ }
+ // Don't drop on self or same parent
+ if (draggedId === targetParentId) return;
+ this._moveItem(draggedId, targetParentId);
+ return;
+ }
+
+ // ── External drop (files from computer) ──
+ var folderEl = ev.target.closest('.ws-tree-item');
+ var parentId = this.currentFolder > 0 ? this.currentFolder : null;
+ var parentName = 'root';
+
+ if (folderEl) {
+ var folderLink = folderEl.querySelector('.name.folder');
+ if (folderLink) {
+ parentName = folderLink.textContent.trim();
+ // Use data-folder-id attribute (works for both x-for and renderChildren)
+ var fid = folderEl.getAttribute('data-folder-id');
+ if (fid) parentId = parseInt(fid);
+ }
+ }
+
+ var items = ev.dataTransfer.items;
+ if (!items) return;
+
+ var self = this;
+ var dtFiles = ev.dataTransfer.files;
+
+ // Check if any item is a directory (WebKit only)
+ var hasDirectory = false;
+ for (var i = 0; i < items.length; i++) {
+ var entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
+ if (entry && entry.isDirectory) { hasDirectory = true; break; }
+ }
+
+ if (hasDirectory) {
+ // Walk directory tree and upload structure
+ this._processDirectoryDrop(items, parentId, parentName);
+ } else if (dtFiles.length > 0) {
+ // Simple file upload
+ this._uploadFiles(dtFiles, parentId, parentName);
+ }
+ },
+
+ _processDirectoryDrop(items, parentId, parentName) {
+ var self = this;
+ var allItems = []; // {path: string, type: 'file'|'folder', entry: FileSystemEntry}
+ var pending = items.length;
+
+ function walkEntry(entry, basePath) {
+ var path = basePath ? basePath + '/' + entry.name : entry.name;
+ if (entry.isFile) {
+ allItems.push({path: path, type: 'file', entry: entry});
+ pending--;
+ if (pending === 0) doUpload();
+ } else if (entry.isDirectory) {
+ allItems.push({path: path, type: 'folder', entry: entry});
+ var reader = entry.createReader();
+ reader.readEntries(function(entries) {
+ pending += entries.length;
+ for (var i = 0; i < entries.length; i++) {
+ walkEntry(entries[i], path);
+ }
+ pending--;
+ if (pending === 0) doUpload();
+ });
+ }
+ }
+
+ for (var i = 0; i < items.length; i++) {
+ var entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
+ if (entry) walkEntry(entry, '');
+ else pending--;
+ }
+ if (pending === 0) doUpload();
+
+ function doUpload() {
+ // Build structure
+ var structure = [];
+ var files = [];
+ for (var i = 0; i < allItems.length; i++) {
+ structure.push({path: allItems[i].path, type: allItems[i].type});
+ }
+
+ // Resolve file entries to actual File objects
+ var fileEntries = [];
+ for (var i = 0; i < allItems.length; i++) {
+ if (allItems[i].type === 'file') fileEntries.push(allItems[i]);
+ }
+
+ var resolved = 0;
+ var formData = new FormData();
+ formData.append('structure', JSON.stringify(structure));
+ if (parentId) formData.append('parent_id', parentId);
+
+ function addFile(idx) {
+ if (idx >= fileEntries.length) {
+ // All resolved, upload
+ self._doUpload(formData, parentId, parentName);
+ return;
+ }
+ var fe = fileEntries[idx];
+ fe.entry.file(function(file) {
+ formData.append('files', file, fe.path);
+ resolved++;
+ addFile(idx + 1);
+ }, function() {
+ resolved++;
+ addFile(idx + 1);
+ });
+ }
+ addFile(0);
+ }
+ },
+
+ _uploadFiles(fileList, parentId, parentName) {
+ var formData = new FormData();
+ for (var i = 0; i < fileList.length; i++) {
+ formData.append('files', fileList[i]);
+ }
+ if (parentId) formData.append('parent_id', parentId);
+ this._doUpload(formData, parentId, parentName);
+ },
+
+ async _moveItem(itemId, newParentId) {
+ var body = { parent_id: newParentId || null };
+ try {
+ var r = await fetch('/api/local-workspace/items/' + itemId + '/move', {
+ method: 'PUT',
+ headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ });
+ if (r.ok) {
+ this._reloadAfterAction();
+ } else {
+ window.showToast('Move failed', 'error');
+ }
+ } catch(e) {
+ window.showToast('Move failed: ' + e.message, 'error');
+ }
+ },
+
+ async _doUpload(formData, parentId, parentName) {
+ var self = this;
+ this.uploading = true;
+ this.uploadStatus = 'Uploading to ' + parentName + '...';
+ this.uploadProgress = 10;
+
+ var folderHandled = formData.has('structure');
+ var url = folderHandled ? '/api/local-workspace/upload-folder' : '/api/local-workspace/upload';
+
+ try {
+ var r = await fetch(url, { method: 'POST', body: formData });
+ this.uploadProgress = 80;
+ var d = await r.json();
+ if (r.ok) {
+ this.uploadStatus = '✓ ' + d.items.length + ' item(s) uploaded';
+ this.uploadProgress = 100;
+ var items = d.items || [];
+ setTimeout(function() {
+ self.uploading = false;
+ self._reloadAfterAction();
+ }, 800);
+ } else {
+ this.uploadStatus = '✗ ' + (d.error || 'Upload failed');
+ this.uploadProgress = 0;
+ setTimeout(function() { self.uploading = false; }, 2000);
+ }
+ } catch(e) {
+ this.uploadStatus = '✗ Network error';
+ this.uploadProgress = 0;
+ setTimeout(function() { self.uploading = false; }, 2000);
+ }
+ },
+
+ // ── Tag management ──
+ async loadWorkspaceTags() {
+ try {
+ var r = await fetch('/api/local-workspace/tags', { cache: 'no-store' });
+ var d = await r.json();
+ this.workspaceTags = d.tags || [];
+ } catch(e) { this.workspaceTags = []; }
+ },
+
+ // Mise à jour OPTIMISTE du compteur d'un tag dans la barre de tags :
+ // rafraîchit immédiatement la pastille (delta +1/-1) sans attendre le
+ // re-fetch (qui viendra ensuite confirmer/surécrire avec les valeurs serveur).
+ _bumpTagCount(tag, delta) {
+ if (!tag) return;
+ var tags = (this.workspaceTags || []).slice();
+ var i = -1;
+ for (var k = 0; k < tags.length; k++) {
+ if (String(tags[k].id) === String(tag.id)) { i = k; break; }
+ }
+ if (i >= 0) {
+ tags[i] = Object.assign({}, tags[i], { count: Math.max(0, (tags[i].count || 0) + delta) });
+ } else if (delta > 0) {
+ tags.push({ id: tag.id, name: tag.name || '', color: tag.color || '#787774', count: delta });
+ }
+ // Réassigne un nouveau tableau → réactivité Alpine
+ this.workspaceTags = tags;
+ },
+
+ toggleTagFilter(tagName) {
+ this.tagFilter = (this.tagFilter === tagName) ? '' : tagName;
+ this.filterType = '';
+ this.searchQuery = '';
+ this.doFilter();
+ },
+
+ startTagInput(node) {
+ this.tagInputId = node.id;
+ var self = this;
+ this.$nextTick(function() {
+ var inp = self.$el.querySelector('.tag-input:not([style*="display: none"])');
+ if (inp) inp.focus();
+ });
+ },
+
+ async addTag(itemId, tagName) {
+ tagName = (tagName || '').trim();
+ if (!tagName) { this.tagInputId = null; return; }
+ try {
+ var r = await fetch('/api/local-workspace/items/' + itemId + '/tags', {
+ method: 'POST',
+ headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
+ body: JSON.stringify({name: tagName})
+ });
+ if (r.ok) {
+ var d = await r.json();
+ // Update node tags in tree
+ this._updateNodeTags(this.tree, itemId, d.tag, 'add');
+ this.displayTree = [...this.tree];
+ this.tagsVersion++;
+ this._bumpTagCount(d.tag, 1);
+ await this.loadWorkspaceTags();
+ this._computeFilteredTableItems();
+ }
+ } catch(e) {}
+ this.tagInputId = null;
+ },
+
+ async removeTag(itemId, tagId) {
+ try {
+ var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
+ method: 'DELETE'
+ });
+ if (r.ok) {
+ this._updateNodeTags(this.tree, itemId, null, 'remove', tagId);
+ this.displayTree = [...this.tree];
+ this.tagsVersion++;
+ this._bumpTagCount({ id: tagId }, -1);
+ await this.loadWorkspaceTags();
+ this._computeFilteredTableItems();
+ }
+ } catch(e) {}
+ },
+
+ _updateNodeTags(nodes, itemId, tag, action, removeTagId) {
+ for (var i=0; i 0 && this.selectedIdsCount >= total;
+ this._syncCheckboxes();
+ },
+ _syncCheckboxes() {
+ var boxes = document.querySelectorAll('input.item-checkbox[data-ws-id]');
+ for (var i = 0; i < boxes.length; i++) {
+ boxes[i].checked = !!this.selectedIds[parseInt(boxes[i].getAttribute('data-ws-id'))];
+ }
+ },
+
+ // ── Multi-select actions (Library-style select bar) ──
+ _selectedIds() {
+ return Object.keys(this.selectedIds).map(Number);
+ },
+ copySelectedLinks() {
+ this.moreOpen = false;
+ var ids = Object.keys(this.selectedIds);
+ if (!ids.length) return;
+ 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');
+ },
+ async openMovePicker() {
+ var ids = this._selectedIds();
+ if (!ids.length) return;
+ this.moreOpen = false;
+ this.moveIds = ids;
+ this.moveSearchQuery = '';
+ this.moveModalOpen = true;
+ try {
+ // Fetch the full workspace tree so every folder is a valid destination
+ var r = await fetch('/api/local-workspace/tree');
+ var d = await r.json();
+ var folders = [];
+ this._collectFolders(d.tree || [], folders, 0);
+ this._moveAllCandidates = folders;
+ this.moveCandidates = folders.slice(0, 50);
+ } catch(e) {
+ this._moveAllCandidates = [];
+ this.moveCandidates = [];
+ }
+ },
+ _collectFolders(nodes, out, depth) {
+ for (var i = 0; i < nodes.length; i++) {
+ var n = nodes[i];
+ if (n.is_folder) out.push({ id: n.id, name: n.name, depth: depth });
+ if (n.children) this._collectFolders(n.children, out, depth + 1);
+ }
+ },
+ doMoveSearch() {
+ var q = (this.moveSearchQuery || '').toLowerCase().trim();
+ if (!q) { this.moveCandidates = this._moveAllCandidates.slice(0, 50); return; }
+ this.moveCandidates = this._moveAllCandidates.filter(function(it) {
+ return (it.name || '').toLowerCase().indexOf(q) !== -1;
+ }).slice(0, 50);
+ },
+ async confirmBulkMove(parentId) {
+ this.moveModalOpen = false;
+ var ids = this.moveIds;
+ this.moveIds = [];
+ if (!ids || !ids.length) return;
+ for (var i = 0; i < ids.length; i++) {
+ try {
+ await fetch('/api/local-workspace/items/' + ids[i] + '/move', {
+ method: 'PUT',
+ headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
+ body: JSON.stringify({ parent_id: parentId || null })
+ });
+ } catch(e) {}
+ }
+ this.clearSelection();
+ this._reloadAfterAction();
+ if (window.showToast) window.showToast('Item' + (ids.length > 1 ? 's' : '') + ' moved', 'success');
+ }
+
+ };
+})();
+// Injecter toutes les props comme globales pour Alpine (résout les 36 erreurs de scope)
+var _wsKeys = Object.keys(window._wsInitData);
+for (var _i = 0; _i < _wsKeys.length; _i++) {
+ try { window[_wsKeys[_i]] = window._wsInitData[_wsKeys[_i]]; } catch(e) {}
+}
+_wsInitData = window._wsInitData;
+console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
diff --git a/static/js/page_editor_realtime.js b/static/js/page_editor_realtime.js
new file mode 100644
index 0000000..5cca1c1
--- /dev/null
+++ b/static/js/page_editor_realtime.js
@@ -0,0 +1,532 @@
+const RT=(()=>{try{const el=document.getElementById('rt-config');return el?JSON.parse(el.textContent):{}}catch(e){return {}}})();
+
+/* eslint-disable */
+/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
+window.__fdRT = (function () {
+ const SELF = {
+ id: RT.id,
+ login: RT.login,
+ full_name: RT.full_name,
+ color: RT.color,
+ };
+ const COLORS = [
+ "#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
+ "#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333",
+ ];
+ let E = null; // editorState (window.E)
+ let ws = null;
+ let mode = 'off'; // 'ws' | 'poll'
+ let open = false;
+ let base = []; // dernier snapshot serveur des blocs
+ let version = 0;
+ let pendingOps = 0;
+ let needSync = false;
+ let peers = []; // liste des présents (hors moi)
+ let remoteCursors = {}; // userId -> {peer, block, offset}
+ let myCursor = null; // {block, offset}
+ let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null;
+ let _pid = 0;
+
+ // Detach global listeners from a previous editor instance so that
+ // partial (HTMX) navigation doesn't accumulate stale handlers.
+ function unbindGlobal() {
+ const g = window.__fdRTGlobal;
+ if (!g) return;
+ try {
+ document.removeEventListener('selectionchange', g.onSel, true);
+ window.removeEventListener('scroll', g.onScroll, true);
+ window.removeEventListener('resize', g.onResize);
+ } catch (e) { /* noop */ }
+ window.__fdRTGlobal = null;
+ }
+
+ const clone = (o) => JSON.parse(JSON.stringify(o));
+
+ function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; }
+
+ function initials(p) {
+ const n = (p && (p.full_name || p.login)) || '';
+ const parts = String(n).trim().split(/\s+/);
+ if (!parts[0]) return '?';
+ return ((parts[0][0] || '') + (parts.length > 1 ? (parts[1][0] || '') : '')).toUpperCase().slice(0, 2);
+ }
+
+ function send(obj) {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ try { ws.send(JSON.stringify(obj)); } catch (e) { /* noop */ }
+ }
+ }
+
+ /* ── ops miroir du serveur (apply_op/merge) ── */
+ function applyOpJS(blocks, op) {
+ const t = op && op.type;
+ if (t === 'insert') {
+ const blk = op.block || {};
+ const id = blk.id || ('rb' + Date.now().toString(36));
+ blk.id = id;
+ if (typeof ensureBlockIds === 'function') ensureBlockIds([blk]);
+ let idx = op.index != null ? op.index : blocks.length;
+ idx = Math.max(0, Math.min(idx, blocks.length));
+ return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx));
+ }
+ if (t === 'update') {
+ const nb = op.block || {};
+ if (!nb.id) return blocks;
+ return blocks.map(b => (b.id === nb.id ? nb : b));
+ }
+ if (t === 'delete') {
+ const bid = op.id;
+ return blocks.filter(b => b.id !== bid);
+ }
+ if (t === 'move') {
+ const bid = op.id, idx = op.index || 0;
+ const out = blocks.filter(b => b.id !== bid);
+ const moved = blocks.find(b => b.id === bid);
+ if (!moved) return blocks;
+ const i2 = Math.max(0, Math.min(idx, out.length));
+ out.splice(i2, 0, moved);
+ return out;
+ }
+ return blocks;
+ }
+
+ /* Diff base → courants : update/delete/move/insert (ordre pour le serveur). */
+ function diffOps(cur) {
+ if (!base.length && !cur.length) return [];
+ const ops = [];
+ const baseById = {}, curById = {};
+ base.forEach(b => { baseById[b.id] = b; });
+ cur.forEach(b => { curById[b.id] = b; });
+
+ cur.forEach(b => {
+ if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) {
+ // v6.4.0 : on embarque la `base` dont dérive la saisie → le serveur
+ // fait un merge 3-voix au lieu d'écraser le bloc (LWW).
+ ops.push({ type: 'update', block: clone(b), base: clone(baseById[b.id]) });
+ }
+ });
+ base.forEach(b => {
+ if (curById[b.id] === undefined) ops.push({ type: 'delete', id: b.id });
+ });
+
+ // structure : simule l'état serveur (base − supprimés) pour indices valides
+ let sim = base.filter(b => curById[b.id] !== undefined).map(b => clone(b));
+ const simById = {}; sim.forEach(b => { simById[b.id] = b; });
+ const finalOrder = cur.map(b => b.id);
+ let si = 0;
+ finalOrder.forEach(id => {
+ if (simById[id] !== undefined) {
+ const curPos = sim.findIndex(b => b.id === id);
+ if (curPos !== si) ops.push({ type: 'move', id, index: si });
+ const [mv] = sim.splice(curPos, 1);
+ sim.splice(si, 0, mv);
+ si++;
+ } else {
+ ops.push({ type: 'insert', index: si, block: clone(curById[id]) });
+ sim.splice(si, 0, curById[id]);
+ simById[id] = curById[id];
+ si++;
+ }
+ });
+ return ops;
+ }
+
+ /* ── rendu + présence ── */
+ function activeBlockId() {
+ const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
+ return a ? a.bid : null;
+ }
+
+ function refocus(fid) {
+ if (!fid) return;
+ setTimeout(() => {
+ const el = E.getEl(fid);
+ if (el) { el.focus(); try { (typeof ce === 'function') && ce(el); } catch (e) { /* noop */ } }
+ }, 30);
+ }
+
+ function renderPresence() {
+ let host = document.querySelector('.topbar-right.header-actions');
+ if (!host) return;
+ let box = document.getElementById('rtPresence');
+ if (!box) {
+ box = document.createElement('span');
+ box.id = 'rtPresence';
+ box.className = 'rt-presence';
+ host.insertBefore(box, host.firstChild);
+ }
+ const shown = peers.filter(p => p.id !== (SELF.id || 0));
+ if (!shown.length) { box.style.display = 'none'; return; }
+ box.style.display = 'inline-flex';
+ box.innerHTML = '';
+ shown.forEach(p => {
+ const c = document.createElement('span');
+ c.className = 'rt-avatar';
+ c.title = (p.full_name || p.login) + ' est en train d\u2019éditer' + (mode === 'poll' ? ' (polling)' : '');
+ c.textContent = initials(p);
+ c.style.background = p.color || colorOf(p.id);
+ box.appendChild(c);
+ });
+ if (mode === 'poll') {
+ let off = document.getElementById('rtOffline');
+ if (!off) {
+ off = document.createElement('span');
+ off.id = 'rtOffline';
+ off.className = 'rt-offline';
+ off.textContent = '●';
+ off.title = 'Realtime indisponible — rafraîchissement toutes les 10 s';
+ host.insertBefore(off, box.nextSibling || null);
+ }
+ off.style.display = 'inline';
+ }
+ }
+
+ /* ── curseurs ── */
+ function rangeFromOffset(el, offset) {
+ try {
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
+ let n = walker.nextNode(), count = 0;
+ while (n) {
+ const len = (n.nodeValue || '').length;
+ if (count + len >= offset) {
+ const r = document.createRange();
+ r.setStart(n, Math.min(offset - count, len));
+ r.collapse(true);
+ return r;
+ }
+ count += len;
+ n = walker.nextNode();
+ }
+ const r = document.createRange();
+ r.selectNodeContents(el);
+ r.collapse(false);
+ return r;
+ } catch (e) { return null; }
+ }
+
+ function drawCursors() {
+ const layer = document.getElementById('rtCursors');
+ if (!layer) return;
+ layer.innerHTML = '';
+ const ids = Object.keys(remoteCursors);
+ if (!ids.length) return;
+ ids.forEach(uid => {
+ const c = remoteCursors[uid];
+ if (!c || !c.block) return;
+ const el = E.getEl(c.block);
+ if (!el) return;
+ const r = rangeFromOffset(el, c.offset || 0);
+ let x, y, h;
+ if (r) {
+ const rc = r.getBoundingClientRect();
+ if (!rc.width && !rc.height) return; // hors viewport positionné
+ x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
+ } else {
+ const rc = el.getBoundingClientRect();
+ x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
+ }
+ const m = document.createElement('div');
+ m.className = 'rt-cursor';
+ m.style.left = (x - 1) + 'px';
+ m.style.top = (y - 1) + 'px';
+ m.style.height = h + 'px';
+ m.style.background = c.peer.color || colorOf(c.peer.id);
+ const nm = document.createElement('span');
+ nm.className = 'rt-cursor-name';
+ nm.textContent = initials(c.peer);
+ nm.style.background = m.style.background;
+ m.appendChild(nm);
+ layer.appendChild(m);
+ });
+ }
+
+ function emitCursor() {
+ const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
+ let block = null, offset = 0;
+ if (a && a.el && a.bid) {
+ block = a.bid;
+ try { offset = (typeof cp === 'function') ? cp(a.el) : 0; } catch (e) { offset = 0; }
+ }
+ const changed = !myCursor || myCursor.block !== block || myCursor.offset !== offset;
+ myCursor = { block, offset };
+ if (!changed) return;
+ send({ t: 'sel', block, offset });
+ }
+
+ function scheduleDraw() {
+ clearTimeout(drawT);
+ drawT = setTimeout(drawCursors, 40);
+ }
+
+ /* ── application des changements distants ── */
+ function applySync(blocks, title) {
+ if (!E || !E.blocks) return;
+ // v5.13.1: guarantee ids before comparing/merging. Server rooms may still
+ // carry legacy id-less blocks; without this they collide on
+ // data-bid="undefined" and the merge below duplicated every line.
+ blocks = (blocks || []).slice();
+ if (typeof ensureBlockIds === 'function') ensureBlockIds(blocks);
+ const curIds = JSON.stringify((E.blocks || []).map(b => b.id));
+ const srvIds = JSON.stringify(blocks.map(b => b.id));
+ if (curIds === srvIds) {
+ base = clone(E.blocks);
+ E.dirty = false;
+ return;
+ }
+ const fid = activeBlockId();
+ const curById = {};
+ (E.blocks || []).forEach(b => { curById[b.id] = b; });
+ let out;
+ try {
+ if (!blocks.length) {
+ // serveur vide : ne pas effacer le contenu local (page neuve).
+ out = (E.blocks || []).slice();
+ } else {
+ out = blocks.map(b => {
+ const cb = curById[b.id];
+ if (cb && b.id === fid) return cb; // garde la frappe locale en cours
+ return b;
+ });
+ }
+ } catch (e) { return; }
+ E.blocks = out;
+ const tEl = document.getElementById('_titleEl');
+ if (title && title !== E.pageTitle && document.activeElement !== tEl) {
+ tEl.textContent = title;
+ E.pageTitle = title;
+ }
+ E.dirty = true;
+ base = clone(E.blocks);
+ E.render();
+ refocus(fid);
+ scheduleDraw();
+ }
+
+ function applyRemoteOp(op) {
+ const fid = activeBlockId();
+ if (op.type === 'update') {
+ const nb = op.block || {};
+ if (nb.id === fid) {
+ // bloc en cours d'édition : on garde la valeur locale, le serveur a déjà
+ // l'op ; la prochaine frappe locale repartira (LWW).
+ base = applyOpJS(base, op);
+ return;
+ }
+ E.blocks = applyOpJS(E.blocks, op);
+ base = applyOpJS(base, op);
+ E.dirty = true;
+ E.render();
+ refocus(fid);
+ } else {
+ E.blocks = applyOpJS(E.blocks, op);
+ base = applyOpJS(base, op);
+ E.dirty = true;
+ E.render();
+ refocus(fid);
+ }
+ scheduleDraw();
+ }
+
+ /* ── diffusion des modifications locales ── */
+ function emit() {
+ if (mode !== 'ws' || !open || !E || !E.blocks) return;
+ if (needSync) { send({ t: 'sync_req' }); return; }
+ const cur = E.blocks.filter(b => b && b.id);
+ const ops = diffOps(cur);
+ if (!ops.length) return;
+ ops.forEach(op => { send({ t: 'op', op, v: version }); pendingOps++; });
+ base = clone(cur);
+ }
+
+ function onMsg(m) {
+ if (!m || !m.t) return;
+ if (m.t === 'sync') {
+ version = m.version || 0;
+ base = clone(m.blocks || []);
+ needSync = false;
+ pendingOps = 0;
+ if (typeof m.blocks === 'undefined') return;
+ applySync(m.blocks || [], m.title || '');
+ } else if (m.t === 'ack') {
+ version = m.v || 0;
+ if (pendingOps > 0) pendingOps--;
+ // v6.4.0 : le serveur renvoie le bloc fusionné (merge 3-voix). On
+ // l'adopte comme nouvelle base ; s'il diffère de notre saisie locale
+ // c'est qu'un autre utilisateur avait modifié le même bloc.
+ if (m.merged) {
+ const mid = m.merged.id;
+ const localBlock = (E && E.blocks || []).find(b => b.id === mid);
+ const differs = localBlock && JSON.stringify(localBlock) !== JSON.stringify(m.merged);
+ base = applyOpJS(base, { type: 'update', block: m.merged });
+ if (differs && E) {
+ const fid = activeBlockId();
+ if (fid !== mid) {
+ E.blocks = applyOpJS(E.blocks, { type: 'update', block: m.merged });
+ E.dirty = true;
+ E.render();
+ refocus(fid);
+ }
+ if (m.conflict && window.showToast) {
+ window.showToast('Editing conflict merged on a block', 'info');
+ }
+ }
+ }
+ if (m.stale || needSync) { needSync = true; send({ t: 'sync_req' }); }
+ } else if (m.t === 'op') {
+ if (m.v) version = m.v;
+ if (m.from === (SELF.id || 0)) { base = applyOpJS(base, m.op); return; }
+ applyRemoteOp(m.op);
+ } else if (m.t === 'title') {
+ const tEl = document.getElementById('_titleEl');
+ if (m.from !== (SELF.id || 0) && tEl && document.activeElement !== tEl && E) {
+ tEl.textContent = m.title || '';
+ E.pageTitle = m.title || '';
+ }
+ if (m.v) version = m.v;
+ scheduleDraw();
+ } else if (m.t === 'sel') {
+ if (m.from === (SELF.id || 0)) return;
+ if (!m.block) { delete remoteCursors[m.from]; scheduleDraw(); return; }
+ remoteCursors[m.from] = { peer: m.peer || { id: m.from }, block: m.block, offset: m.offset || 0 };
+ scheduleDraw();
+ } else if (m.t === 'welcome') {
+ peers = (m.peers || []).filter(p => p.id !== (SELF.id || 0));
+ renderPresence();
+ } else if (m.t === 'peer_join') {
+ if (m.peer && m.peer.id !== (SELF.id || 0)) {
+ peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]);
+ renderPresence();
+ }
+ } else if (m.t === 'peer_leave') {
+ peers = peers.filter(p => p.id !== m.id);
+ delete remoteCursors[m.id];
+ renderPresence();
+ scheduleDraw();
+ } else if (m.t === 'synced_update') {
+ // A synced block was updated — re-sync the whole page
+ if (m.synced_id) {
+ needSync = true;
+ send({ t: 'sync_req' });
+ }
+ }
+ }
+
+ /* ── connexion WS + fallback polling ── */
+ function startPolling() {
+ if (pollT) return;
+ mode = 'poll';
+ renderPresence();
+ scheduleDraw();
+ pollT = setInterval(poll, 10000);
+ }
+
+ function stopPolling() {
+ if (pollT) { clearInterval(pollT); pollT = null; }
+ const off = document.getElementById('rtOffline');
+ if (off) off.style.display = 'none';
+ }
+
+ async function poll() {
+ if (!E || !_pid) return;
+ try {
+ const r = await fetch('/board/api/pages/' + _pid, { credentials: 'same-origin' });
+ if (!r.ok) return;
+ const d = await r.json();
+ if ((d.content_format || 'blocks') !== 'blocks' || !d.content) return;
+ const serverBlocks = JSON.parse(d.content);
+ // en cas de modifs locales non persistées : on garde local (LWW au prochain save)
+ if (E.dirty) return;
+ const cur = JSON.stringify((E.blocks || []).map(b => b.id));
+ const srv = JSON.stringify(serverBlocks.map(b => b.id));
+ if (cur === srv) return;
+ version = version; // pas de version via polling — on adopte l'état serveur
+ applySync(serverBlocks, d.title || E.pageTitle);
+ } catch (e) { /* noop */ }
+ }
+
+ function connect() {
+ if (!E || !_pid || ws) return;
+ const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
+ try {
+ ws = new WebSocket(proto + window.location.host + '/ws/pages/' + _pid);
+ } catch (e) {
+ startPolling();
+ return;
+ }
+ ws.onopen = () => {
+ open = true;
+ mode = 'ws';
+ stopPolling();
+ send({ t: 'hello' });
+ };
+ ws.onmessage = (ev) => {
+ let m;
+ try { m = JSON.parse(ev.data); } catch (e) { return; }
+ onMsg(m);
+ };
+ ws.onclose = () => {
+ open = false;
+ ws = null;
+ if (retryT) clearTimeout(retryT);
+ retryT = setTimeout(connect, 15000);
+ startPolling();
+ };
+ ws.onerror = () => { try { ws.close(); } catch (e) { /* noop */ } };
+ setTimeout(() => {
+ if (!open) { try { ws && ws.close(); } catch (e) { /* noop */ } }
+ }, 5000);
+ }
+
+ function titleInput() {
+ const tEl = document.getElementById('_titleEl');
+ if (!tEl) return;
+ clearTimeout(titleT);
+ titleT = setTimeout(() => {
+ send({ t: 'title', title: (tEl.textContent || '').trim() });
+ }, 500);
+ }
+
+ function wire() {
+ const ct = document.getElementById('_blocksCt');
+ if (ct) {
+ ct.addEventListener('input', () => {
+ clearTimeout(emitT);
+ emitT = setTimeout(emit, 350);
+ setTimeout(emitCursor, 80);
+ }, true);
+ ct.addEventListener('keyup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
+ ct.addEventListener('click', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
+ ct.addEventListener('mouseup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
+ }
+ const tEl = document.getElementById('_titleEl');
+ if (tEl) tEl.addEventListener('input', titleInput);
+ unbindGlobal();
+ const onSel = () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); };
+ const onScroll = () => scheduleDraw();
+ const onResize = () => scheduleDraw();
+ document.addEventListener('selectionchange', onSel, true);
+ window.addEventListener('scroll', onScroll, true);
+ window.addEventListener('resize', onResize);
+ window.__fdRTGlobal = { onSel, onScroll, onResize };
+ }
+
+ function start(ed) {
+ if (!ed) return;
+ if ((ed.contentFormat || 'blocks') !== 'blocks') return;
+ E = ed;
+ _pid = ed.pid || 0;
+ if (!_pid) return;
+ base = clone(ed.blocks || []);
+ wire();
+ connect();
+ }
+
+ // poussée immédiate des ops après une mutation programmatique (v5.10.0)
+ function syncNow() {
+ if (mode !== 'ws' || !open || !E || !E.blocks) return;
+ clearTimeout(emitT);
+ emit();
+ }
+
+ return { start, syncNow };
+})();
diff --git a/static/js/settings.js b/static/js/settings.js
new file mode 100644
index 0000000..41d8e7a
--- /dev/null
+++ b/static/js/settings.js
@@ -0,0 +1,1094 @@
+const ST=(()=>{try{const el=document.getElementById('st-config');return el?JSON.parse(el.textContent):{}}catch(e){return {}}})();
+
+function settingsInit() {
+ return {
+ activeSection: 'account',
+ allTags: [],
+ newTagName: '',
+ newTagColor: '#787774',
+ deletingTag: null,
+ renamingTag: null,
+ renameValue: '',
+ presetColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
+ avatarUrl: ST.avatar_url,
+ avatarColor: ST.avatar_color,
+ avatarColors: ['#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#448361','#337EA9','#9065B0','#C94D8B','#787774','#3A3A3A'],
+ defaultView: localStorage.getItem('fd_default_view') || 'tree',
+ userInfo: {full_name: ST.full_name, login: ST.login, email: ST.email},
+ userIsAdmin: ST.is_admin,
+ notifPrefs: {comments: true, mentions: true, reminders: true, assignments: true},
+ userTimezone: '', userZones: [],
+ // Auth & Integrations
+ authMethod: ST.auth_method,
+ giteaLinked: false,
+ githubLinked: false,
+ // Theme
+ theme: localStorage.getItem('fd_theme') || 'light',
+ // Account form
+ accountForm: {login:ST.login, full_name:ST.full_name, email:ST.email, password:''},
+ accountSaving: false,
+ accountMsg: '',
+ pwVisible: false,
+ // Admin data
+ adminUsers: [],
+ adminStats: {},
+ auditEntries: [],
+ auditLogs: [],
+ showCreateUser: false,
+ newUser: {login:'',name:'',email:'',password:'',is_admin:false},
+ editingUser: null,
+ editUserForm: {login:'',name:'',email:'',password:'',is_admin:false,is_active:true},
+ // LLM / Agent config
+ llmProviders: [],
+ llmProviderModels: [],
+ llmForm: {provider:'offline', model:'', api_key:'', api_base:''},
+ llmHasKey: false,
+ llmSaving: false,
+ llmTesting: false,
+ llmMsg: '', llmMsgOk: false,
+ llmTestMsg: '', llmTestOk: false,
+ llmKeyVisible: false,
+ llmDefaultProvider: 'offline',
+ llmActiveCount: 0,
+ llmDefaultFunctional: false,
+ llmSummaryTitle: '',
+ llmSummaryDesc: '',
+ // Per-user provider keys (multi-providers)
+ keyForms: {},
+ llmSearch: '',
+ selectedProviderId: null,
+
+ // Automations (v5.1.0)
+ automations: [],
+ autoRuns: [],
+ editAutomationId: null,
+ globalCollections: [],
+ af: { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' },
+
+ // v5.2.0 API tokens & sessions
+ apiTokens: [],
+ newToken: null,
+ tokenName: '',
+ sessions: [],
+ // v6.2.0 Web Clipper
+ clipperDevices: [],
+ clipTestUrl: 'https://example.com',
+ clipTesting: false,
+ clipTestMsg: '',
+ clipTestOk: false,
+ // v5.2.0 Backups (admin)
+ backups: [],
+ backupRunning: false,
+ backupMsg: '',
+ backupOk: false,
+
+ // v6.7.0 SSO / Enterprise (admin)
+ ssoCfg: {id:null, provider_type:'saml', name:'', entity_id:'', sso_url:'', slo_url:'', x509_certificate:'', issuer_url:'', client_id:'', client_secret:'', client_secret_set:false, scope:'openid profile email', attribute_mapping:{}, auto_provision:true, sso_only:false, sign_requests:false, default_workspace_id:''},
+ ssoAttrJson: '{}',
+ ssoGroups: [],
+ ssoWorkspaces: [],
+ ssoSource: 'db',
+ ssoProvisioned: 0,
+ ssoSaving: false,
+ ssoMsg: '',
+ ssoOk: false,
+
+ async init() {
+ await this.loadTags();
+ await this.loadGiteaStatus();
+ await this.loadGithubStatus();
+ await this.loadNotifPrefs();
+ await this.loadUserLlm();
+ if (this.userIsAdmin) await this.loadLlmConfig();
+ },
+
+ async loadNotifPrefs() {
+ try {
+ var r = await fetch('/api/notifications/prefs', {credentials:'same-origin'});
+ var d = await r.json();
+ this.notifPrefs = Object.assign({comments:true, mentions:true, reminders:true, assignments:true}, d.prefs || {});
+ } catch(e) {}
+ try {
+ var tz = await fetch('/api/notifications/timezone', {credentials:'same-origin'});
+ var td = await tz.json();
+ this.userTimezone = td.timezone || '';
+ this.userZones = td.zones || [];
+ } catch(e) {}
+ },
+ async saveNotifPrefs() {
+ try {
+ await fetch('/api/notifications/prefs', {
+ method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
+ body: JSON.stringify(this.notifPrefs)
+ });
+ } catch(e) {}
+ },
+ async saveTimezone() {
+ try {
+ var r = await fetch('/api/notifications/timezone', {
+ method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
+ body: JSON.stringify({timezone: this.userTimezone})
+ });
+ if (r.ok && typeof toast === 'function') toast('Timezone saved');
+ } catch(e) {}
+ },
+
+ async loadTags() {
+ try {
+ var r = await fetch('/api/settings/tags/all');
+ var d = await r.json();
+ this.allTags = d.tags || [];
+ } catch(e) { this.allTags = []; }
+ },
+
+ async createTag() {
+ var n = this.newTagName.trim();
+ if (!n) return;
+ var r = await fetch('/api/settings/tags', {
+ method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
+ body: JSON.stringify({name: n, color: this.newTagColor})
+ });
+ if (r.ok) { this.newTagName = ''; this.newTagColor = '#787774'; await this.loadTags(); }
+ },
+
+ async updateTagColor(id, color) {
+ await fetch('/api/settings/tags/' + id, {
+ method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
+ body: JSON.stringify({color: color})
+ });
+ await this.loadTags();
+ },
+
+ async deleteTag(id) {
+ if (!confirm('Delete this tag?')) return;
+ await fetch('/api/settings/tags/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
+ await this.loadTags();
+ },
+
+ async renameTag(tag) {
+ this.renamingTag = tag;
+ this.renameValue = tag.name;
+ // Focus the input after Alpine renders the modal
+ var self = this;
+ this.$nextTick(function() {
+ var input = document.querySelector('.modal-box input[type=text]');
+ if (input) { input.focus(); input.select(); }
+ });
+ },
+
+ async confirmRenameTag() {
+ var tag = this.renamingTag;
+ var newName = this.renameValue.trim();
+ if (!tag || !newName || newName.toLowerCase() === tag.name.toLowerCase()) {
+ this.renamingTag = null; return;
+ }
+ await fetch('/api/settings/tags/' + tag.id, {
+ method: 'PUT', headers: {'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
+ body: JSON.stringify({name: newName})
+ });
+ this.renamingTag = null;
+ await this.loadTags();
+ },
+
+ async confirmDeleteTag() {
+ var tag = this.deletingTag;
+ if (!tag) return;
+ await fetch('/api/settings/tags/' + tag.id, { method: 'DELETE' });
+ this.deletingTag = null;
+ await this.loadTags();
+ },
+
+ // ── Admin ──
+ getCsrfToken() {
+ return document.cookie.split('; ').find(r => r.startsWith('csrf_token='))?.split('=')[1] || '';
+ },
+ async adminFetch(url, opts) {
+ opts = opts || {};
+ opts.headers = opts.headers || {};
+ opts.headers['X-CSRF-Token'] = this.getCsrfToken();
+ return await fetch(url, opts);
+ },
+ async loadAdminUsers() {
+ try {
+ var r = await this.adminFetch('/api/admin/users');
+ var d = await r.json();
+ this.adminUsers = d.users || [];
+ } catch(e) { this.adminUsers = []; }
+ await this.loadAdminStats();
+ },
+
+ async loadAdminStats() {
+ try {
+ var r = await this.adminFetch('/api/admin/stats');
+ this.adminStats = await r.json();
+ } catch(e) { this.adminStats = {}; }
+ },
+
+ async loadAdminAudit() {
+ await this.loadAuditLogs();
+ },
+
+ // ── Unified Audit API (v7.3.0) ──
+ auditSource: 'all',
+ auditActor: '',
+ auditAction: '',
+ auditOffset: 0,
+ auditPageSize: 50,
+ auditLoading: false,
+ auditHasMore: false,
+ async loadAuditLogs(reset) {
+ if (reset !== false) { this.auditOffset = 0; }
+ this.auditLoading = true;
+ try {
+ const params = new URLSearchParams({ source: this.auditSource, limit: this.auditPageSize, offset: this.auditOffset });
+ if (this.auditActor) params.set('actor', this.auditActor);
+ if (this.auditAction) params.set('action', this.auditAction);
+ const r = await fetch('/api/v2/audit/logs?' + params.toString());
+ if (!r.ok) { throw new Error('HTTP ' + r.status); }
+ const d = await r.json();
+ const logs = d.logs || [];
+ this.auditLogs = (reset !== false) ? logs : this.auditLogs.concat(logs);
+ this.auditHasMore = logs.length >= this.auditPageSize;
+ } catch(e) {
+ if (reset !== false) this.auditLogs = [];
+ this.auditHasMore = false;
+ }
+ this.auditLoading = false;
+ },
+ auditCsvUrl() {
+ const params = new URLSearchParams({ source: this.auditSource, limit: 500, format: 'csv' });
+ if (this.auditActor) params.set('actor', this.auditActor);
+ if (this.auditAction) params.set('action', this.auditAction);
+ return '/api/v2/audit/logs?' + params.toString();
+ },
+
+ async adminCreateUser() {
+ var u = this.newUser;
+ if (!u.login || !u.password) return;
+ try {
+ var r = await this.adminFetch('/api/admin/users', {
+ method: 'POST', headers: {'Content-Type':'application/json'},
+ body: JSON.stringify({login:u.login, name:u.name, email:u.email, password:u.password, is_admin:u.is_admin?1:0})
+ });
+ if (r.ok) {
+ this.newUser = {login:'',name:'',email:'',password:'',is_admin:false};
+ this.showCreateUser = false;
+ await this.loadAdminUsers();
+ } else {
+ var d = await r.json();
+ window.showToast(d.error || 'Failed to create user', 'error');
+ }
+ } catch(e) { window.showToast('Network error', 'error'); }
+ },
+
+ async adminUpdateUser() {
+ var f = this.editUserForm;
+ if (!f.login) return;
+ var body = {name: f.name, email: f.email, is_admin: f.is_admin?1:0, is_active: f.is_active?1:0};
+ if (f.password) body.password = f.password;
+ try {
+ var r = await this.adminFetch('/api/admin/users/' + this.editingUser.id, {
+ method: 'PUT', headers: {'Content-Type':'application/json'},
+ body: JSON.stringify(body)
+ });
+ if (r.ok) {
+ this.editingUser = null;
+ await this.loadAdminUsers();
+ }
+ } catch(e) {}
+ },
+
+ async adminDeleteUser(id) {
+ if (!confirm('Permanently delete this user and all their data?')) return;
+ try {
+ await this.adminFetch('/api/admin/users/' + id, { method: 'DELETE' });
+ await this.loadAdminUsers();
+ } catch(e) {}
+ },
+
+ // ── LLM / Agent ──
+ keyForm(id) {
+ if (!this.keyForms[id]) {
+ this.keyForms[id] = {api_key:'', api_base:'', model:'', models:[], has_key:false,
+ keyVisible:false, modelLoading:false, saving:false, testing:false,
+ deleting:false, last_error:'', msg:'', ok:false};
+ }
+ return this.keyForms[id];
+ },
+ keyModels(id) {
+ var p = this.providerById(id);
+ var base = ((p && p.models) || []).slice();
+ var f = this.keyForms[id];
+ if (f && f.models) f.models.forEach(m => { if (base.indexOf(m) === -1) base.push(m); });
+ return base;
+ },
+ providerById(id) {
+ return (this.llmProviders || []).find(x => x.id === id) || null;
+ },
+ userProviders() {
+ return (this.llmProviders || []).filter(x => x.id !== 'offline');
+ },
+ selectedProvider() {
+ return this.providerById(this.selectedProviderId);
+ },
+ filteredProviders() {
+ var q = (this.llmSearch || '').trim().toLowerCase();
+ var list = this.userProviders();
+ if (!q) return list;
+ return list.filter(function(p){
+ return (p.name || '').toLowerCase().indexOf(q) !== -1 ||
+ (p.id || '').toLowerCase().indexOf(q) !== -1;
+ });
+ },
+ selectProvider(id) {
+ this.selectedProviderId = id;
+ this.keyForm(id);
+ },
+ llmShortStatus(p) {
+ if (!p) return '';
+ if (p.id === 'offline') return 'mock';
+ if (p.verified) return 'actif';
+ if (p.configured) return 'à tester';
+ return '—';
+ },
+ // Set the verified/last_error state on the shared provider entry + its keyForm
+ _setProviderState(id, ok, error) {
+ var p = this.providerById(id);
+ var f = this.keyForm(id);
+ if (p) {
+ p.verified = !!ok;
+ p.last_error = ok ? '' : (error || '');
+ p.functional = p.id === 'offline' ? true : !!ok;
+ if (!ok) { p.configured = p.has_key || !p.requires_key; }
+ }
+ f.last_error = ok ? '' : (error || '');
+ this.syncLlmStatus();
+ },
+ syncLlmStatus() {
+ var list = this.llmProviders || [];
+ var active = list.filter(p => p.verified && p.id !== 'offline');
+ this.llmActiveCount = active.length;
+ var def = list.find(p => p.id === this.llmDefaultProvider) || null;
+ this.llmDefaultFunctional = !!(def && def.functional && def.id !== 'offline');
+ if (active.length) {
+ if (this.llmDefaultFunctional && def) {
+ this.llmSummaryTitle = 'L\u2019agent est prêt à l\u2019emploi';
+ this.llmSummaryDesc = 'Le fournisseur par défaut « ' + def.name + ' » est activé et fonctionnel : vos conversations l\u2019utiliseront automatiquement.';
+ } else {
+ this.llmSummaryTitle = this.llmActiveCount + ' fournisseur(s) fonctionnel(s) pour votre compte';
+ this.llmSummaryDesc = 'Choisissez-en un dans le sélecteur du panneau Agent (🤖 en bas à droite) pour chaque conversation' +
+ (this.userIsAdmin ? ' — ou activez le fournisseur par défaut de l\u2019espace ci-dessous.' : '.');
+ }
+ } else {
+ this.llmSummaryTitle = 'Agent en mode hors-ligne (mock)';
+ this.llmSummaryDesc = 'Aucun fournisseur activé et fonctionnel. Enregistrez une clé ci-dessous puis lancez « Tester » : dès le test réussi, le fournisseur et ses modèles apparaissent dans le panneau Agent.';
+ }
+ },
+ llmBadge(p) {
+ if (!p) return '';
+ if (p.id === 'offline') return 'Toujours dispo (mock)';
+ if (p.verified) return p.source === 'global' ? 'Actif (défaut espace)' : 'Activé et fonctionnel';
+ if (p.source === 'global') return 'Défaut espace — à tester (admin)';
+ if (!p.requires_key) return 'Local — à tester';
+ if (p.configured) return 'Configuré — à tester';
+ return 'Non configuré';
+ },
+ llmBadgeClass(p) {
+ if (!p) return 'off';
+ if (p.id === 'offline') return 'off';
+ if (p.verified) return 'ok';
+ if (p.configured) return 'warn';
+ return 'off';
+ },
+ globalNeedsKey() {
+ var cur = this.providerById(this.llmForm.provider);
+ return !!(cur && cur.requires_key);
+ },
+ llmGlobalBadge() {
+ var cur = this.providerById(this.llmForm.provider);
+ if (!cur) return '—';
+ if (cur.id === 'offline') return 'Mode hors-ligne';
+ if (cur.id !== this.llmDefaultProvider) return 'Changement non enregistré';
+ if (cur.verified) return 'Actif pour tous';
+ if (this.llmHasKey) return 'Configuré — à tester';
+ return 'Non configuré';
+ },
+ llmGlobalBadgeClass() {
+ var cur = this.providerById(this.llmForm.provider);
+ if (!cur || cur.id === 'offline') return 'off';
+ if (cur.id !== this.llmDefaultProvider) return 'warn';
+ if (cur.verified) return 'ok';
+ return 'off';
+ },
+ async loadUserLlm() {
+ try {
+ var r = await fetch('/api/agent/providers');
+ var d = await r.json();
+ this.llmProviders = d.providers || [];
+ this.llmDefaultProvider = d.default_provider || d.provider || 'offline';
+ var kr = await fetch('/api/agent/keys');
+ var kd = await kr.json();
+ var keys = kd.keys || [];
+ this.llmProviders.forEach(p => {
+ var k = keys.find(x => x.provider === p.id);
+ var f = this.keyForm(p.id);
+ // Pré-remplit l'URL avec celle réellement utilisée par l'application
+ // (base par défaut du fournisseur) tant qu'aucune URL personnalisée n'est stockée.
+ f.api_base = (k && k.api_base) || p.base_url || '';
+ f.model = (k && k.default_model) || (p.default_model) || '';
+ f.models = (k && k.models) || [];
+ f.has_key = !!(k && k.has_key);
+ f.last_error = (k && k.last_error) || p.last_error || '';
+ });
+ if (!this.selectedProviderId || !this.providerById(this.selectedProviderId)) {
+ var pool = this.userProviders();
+ var pick = pool.find(x => x.verified) || pool.find(x => x.configured) || pool[0];
+ this.selectedProviderId = pick ? pick.id : null;
+ }
+ if (this.selectedProviderId) this.keyForm(this.selectedProviderId);
+ this.syncLlmStatus();
+ } catch(e) {}
+ },
+ async loadProviderModels(id) {
+ var f = this.keyForm(id);
+ if (f.modelLoading) return;
+ f.modelLoading = true; f.msg = ''; f.ok = false;
+ try {
+ var r = await fetch('/api/agent/keys/' + id + '/models', {
+ method: 'POST',
+ headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
+ body: JSON.stringify({api_key: f.api_key, api_base: f.api_base})
+ });
+ var d = await r.json();
+ if (d.ok) {
+ f.models = d.models || [];
+ if (f.models.length) {
+ if (f.models.indexOf(f.model) === -1) f.model = f.models[0];
+ f.msg = f.models.length + ' modèles chargés depuis le fournisseur';
+ f.ok = true;
+ // Une récupération réussie avec la clé stockée prouve la connexion.
+ if (!f.api_key) this._setProviderState(id, true, '');
+ } else {
+ f.msg = 'Connexion OK, mais aucun modèle renvoyé par le fournisseur';
+ }
+ } else {
+ f.msg = 'Erreur: ' + (d.error || 'échec de connexion');
+ }
+ } catch(e) { f.msg = 'Erreur réseau'; }
+ finally { f.modelLoading = false; }
+ },
+ async saveUserKey(id) {
+ var f = this.keyForm(id);
+ f.saving = true; f.msg = ''; f.ok = false;
+ try {
+ var body = {api_key: f.api_key, api_base: f.api_base, default_model: f.model};
+ if (f.models && f.models.length) body.models = f.models;
+ var r = await fetch('/api/agent/keys/' + id, {
+ method: 'PUT',
+ headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
+ body: JSON.stringify(body)
+ });
+ var d = await r.json();
+ if (r.ok) {
+ f.has_key = !!d.has_key;
+ f.api_key = '';
+ if (f.has_key || !this.globalNeedsKeyFor(id)) {
+ f.msg = 'Clé enregistrée — test de connexion en cours…';
+ f.ok = true;
+ await this.testUserKey(id);
+ } else {
+ f.msg = 'Enregistré pour ' + id + ' — aucune clé stockée';
+ f.ok = true;
+ }
+ var p = this.providerById(id);
+ if (p) { p.has_key = f.has_key; p.configured = f.has_key || !p.requires_key; }
+ } else {
+ f.msg = d.detail || 'Erreur lors de l\'enregistrement';
+ }
+ } catch(e) { f.msg = 'Erreur réseau'; }
+ finally { f.saving = false; }
+ },
+ globalNeedsKeyFor(id) {
+ var p = this.providerById(id);
+ return !!(p && p.requires_key);
+ },
+ async testUserKey(id) {
+ var f = this.keyForm(id);
+ if (f.testing) return;
+ f.testing = true; f.msg = ''; f.ok = false;
+ try {
+ var body = {api_base: f.api_base, model: f.model};
+ if (f.api_key) body.api_key = f.api_key;
+ var r = await fetch('/api/agent/keys/' + id + '/test', {
+ method: 'POST',
+ headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
+ body: JSON.stringify(body)
+ });
+ var d = await r.json();
+ if (d.ok) {
+ this._setProviderState(id, true, '');
+ f.msg = '✓ ' + id + ' fonctionnel' + (d.model ? ' (' + d.model + ')' : '') + (d.reply ? ' — ' + d.reply : '');
+ f.ok = true;
+ if (f.models.indexOf(d.model) === -1 && d.model) f.models.push(d.model);
+ } else {
+ this._setProviderState(id, false, d.error || 'échec de connexion');
+ f.msg = '✗ Test échoué : ' + (d.error || 'échec de connexion');
+ }
+ } catch(e) {
+ this._setProviderState(id, false, 'Erreur réseau');
+ f.msg = 'Erreur réseau';
+ }
+ finally { f.testing = false; }
+ },
+ async deleteUserKey(id) {
+ var p = this.providerById(id);
+ var pname = (p && p.name) || id;
+ if (!confirm('Supprimer la clé API pour ' + pname + ' ?\nLe fournisseur ne sera plus disponible dans le panneau Agent.')) return;
+ var f = this.keyForm(id);
+ f.deleting = true; f.msg = ''; f.ok = false;
+ try {
+ var r = await fetch('/api/agent/keys/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
+ var d = await r.json();
+ if (r.ok) {
+ f.has_key = false; f.api_key = ''; f.models = []; f.model = ''; f.last_error = '';
+ var p = this.providerById(id);
+ if (p) { p.has_key = false; p.configured = !p.requires_key; }
+ this._setProviderState(id, false, '');
+ f.msg = 'Clé supprimée pour ' + id + ' — le fournisseur n\u2019est plus disponible dans le panneau Agent.';
+ f.ok = true;
+ } else {
+ f.msg = d.detail || 'Erreur lors de la suppression';
+ }
+ } catch(e) { f.msg = 'Erreur réseau'; }
+ finally { f.deleting = false; }
+ },
+ async loadLlmConfig() {
+ try {
+ var r = await fetch('/api/agent/providers');
+ var d = await r.json();
+ this.llmProviders = d.providers || [];
+ this.llmDefaultProvider = d.default_provider || d.provider || 'offline';
+ this.llmHasKey = !!d.has_api_key;
+ var defp = this.providerById(d.provider || 'offline');
+ // Affiche l'URL réellement utilisée par l'application (base par défaut
+ // du fournisseur) quand aucune URL personnalisée n'est encore stockée.
+ this.llmForm = {provider: d.provider || 'offline', model: d.model || '', api_key: '',
+ api_base: d.api_base || (defp && defp.base_url) || ''};
+ this.applyLlmModels();
+ this.syncLlmStatus();
+ } catch(e) {}
+ },
+ applyLlmModels() {
+ var p = this.providerById(this.llmForm.provider);
+ this.llmProviderModels = p ? (p.models || []) : [];
+ if (this.llmProviderModels.length && this.llmProviderModels.indexOf(this.llmForm.model) === -1) {
+ this.llmForm.model = (p && p.default_model) || this.llmProviderModels[0];
+ }
+ },
+ onLlmProviderChange() {
+ this.applyLlmModels();
+ var cur = this.providerById(this.llmForm.provider);
+ if (cur) {
+ this.llmForm.model = (cur.default_model || this.llmProviderModels[0] || '');
+ // Affiche l'URL utilisée par l'application pour ce fournisseur.
+ this.llmForm.api_base = cur.base_url || '';
+ }
+ },
+ async saveLlmConfig() {
+ this.llmSaving = true;
+ this.llmMsg = ''; this.llmMsgOk = false;
+ try {
+ var r = await this.adminFetch('/api/agent/providers', {
+ method: 'PATCH',
+ headers: {'Content-Type':'application/json'},
+ body: JSON.stringify({provider: this.llmForm.provider, model: this.llmForm.model,
+ api_key: this.llmForm.api_key, api_base: this.llmForm.api_base})
+ });
+ var d = await r.json();
+ if (r.ok) {
+ this.llmHasKey = !!d.has_api_key;
+ this.llmDefaultProvider = d.provider || this.llmDefaultProvider;
+ this.llmMsgOk = true;
+ this.llmMsg = 'Configuration enregistrée : ' + d.provider + ' / ' + d.model +
+ (d.available ? ' — lancez « Tester la connexion » pour l\u2019activer pour tous.' : ' — clé manquante pour ce provider');
+ // Re-évalue le badge du fournisseur par défaut localement.
+ var cur = this.providerById(d.provider);
+ if (cur) { cur.configured = !!d.has_api_key || !cur.requires_key; }
+ this.llmForm.api_key = '';
+ this.syncLlmStatus();
+ } else {
+ this.llmMsg = d.detail || 'Erreur lors de l\'enregistrement';
+ }
+ } catch(e) { this.llmMsg = 'Erreur réseau'; }
+ finally { this.llmSaving = false; }
+ },
+ async resetGlobalLlm() {
+ if (!confirm("Réinitialiser le fournisseur par défaut à « hors-ligne » ?\nTous les utilisateurs perdront l'accès au fournisseur IA actuel.")) return;
+ this.llmSaving = true;
+ this.llmMsg = ''; this.llmMsgOk = false;
+ try {
+ var r = await this.adminFetch('/api/agent/providers', {
+ method: 'PATCH',
+ headers: {'Content-Type':'application/json'},
+ body: JSON.stringify({provider: 'offline', model: '', api_key: '', api_base: ''})
+ });
+ var d = await r.json();
+ if (r.ok) {
+ this.llmForm = {provider: 'offline', model: '', api_key: '', api_base: ''};
+ this.llmHasKey = false;
+ this.llmDefaultProvider = 'offline';
+ this.llmProviderModels = [];
+ this.llmMsgOk = true;
+ this.llmMsg = 'Fournisseur réinitialisé : mode hors-ligne actif.';
+ this.syncLlmStatus();
+ } else {
+ this.llmMsg = d.detail || 'Erreur lors de la réinitialisation';
+ }
+ } catch(e) { this.llmMsg = 'Erreur réseau'; }
+ finally { this.llmSaving = false; }
+ },
+ async testLlm() {
+ if (!this.llmForm.provider) return;
+ this.llmTesting = true;
+ this.llmTestMsg = 'Test en cours…'; this.llmTestOk = false;
+ try {
+ var r = await this.adminFetch('/api/agent/providers/test', {
+ method: 'POST',
+ headers: {'Content-Type':'application/json'},
+ body: JSON.stringify({provider: this.llmForm.provider, model: this.llmForm.model,
+ api_key: this.llmForm.api_key, api_base: this.llmForm.api_base})
+ });
+ var d = await r.json();
+ this.llmTestOk = !!d.ok;
+ this.llmTestMsg = d.ok
+ ? ('✓ ' + (d.model || '') + ' fonctionnel' + (d.reply ? ' — ' + d.reply : ''))
+ : ('✗ ' + (d.error || 'échec de connexion'));
+ // Reflète l'état sur le fournisseur courant du formulaire global.
+ var cur = this.providerById(this.llmForm.provider);
+ if (cur && this.llmForm.provider === this.llmDefaultProvider) {
+ this._setProviderState(this.llmForm.provider, d.ok, d.error || '');
+ if (d.ok) cur.verified_model = d.model || '';
+ }
+ } catch(e) { this.llmTestOk = false; this.llmTestMsg = 'Erreur réseau'; }
+ finally { this.llmTesting = false; }
+ },
+ getCompletedLlmTest() { return this.llmTestMsg; },
+
+ async saveAccount() {
+ this.accountSaving = true;
+ this.accountMsg = '';
+ var body = {};
+ var f = this.accountForm;
+ if (f.full_name !== this.userInfo.full_name) body.full_name = f.full_name;
+ if (f.login !== this.userInfo.login) body.login = f.login;
+ if (f.email !== this.userInfo.email) body.email = f.email;
+ if (f.password) body.password = f.password;
+ if (Object.keys(body).length === 0) { this.accountSaving = false; return; }
+ try {
+ var r = await fetch('/api/settings/account', {
+ method: 'PUT', headers: {'Content-Type':'application/json', 'X-CSRF-Token': this.getCsrfToken()},
+ body: JSON.stringify(body)
+ });
+ var d = await r.json();
+ if (r.ok) {
+ this.userInfo = {login: d.user.login, full_name: d.user.full_name, email: d.user.email};
+ this.accountForm.password = '';
+ this.accountMsg = '✓ Saved!';
+ setTimeout(() => { this.accountMsg = ''; }, 3000);
+ } else {
+ this.accountMsg = '✗ ' + (d.error || 'Error');
+ }
+ } catch(e) { this.accountMsg = '✗ Network error'; }
+ this.accountSaving = false;
+ },
+
+ async uploadAvatar(ev) {
+ var file = ev.target.files[0];
+ if (!file) return;
+ var form = new FormData();
+ form.append('file', file);
+ var r = await fetch('/api/settings/avatar', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST', body: form });
+ if (r.ok) {
+ var d = await r.json();
+ this.avatarUrl = d.avatar_url + '?t=' + Date.now();
+ this.avatarColor = '';
+ }
+ },
+
+ async selectAvatarColor(color) {
+ this.avatarColor = color;
+ var r = await fetch('/api/settings/avatar-color', {
+ method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
+ body: JSON.stringify({color: color})
+ });
+ if (r.ok) { this.avatarUrl = ''; }
+ },
+
+ applyTheme() {
+ localStorage.setItem('fd_theme', this.theme);
+ document.documentElement.setAttribute('data-theme', this.theme);
+ },
+
+ saveDefaultView() {
+ localStorage.setItem('fd_default_view', this.defaultView);
+ },
+ async loadGiteaStatus() {
+ try {
+ var r = await fetch('/api/gitea/status');
+ var d = await r.json();
+ this.giteaLinked = d.linked || false;
+ } catch(e) { this.giteaLinked = false; }
+ },
+
+ async disconnectGitea() {
+ if (!confirm('Disconnect Gitea? You can reconnect anytime.')) return;
+ try {
+ var r = await fetch('/api/gitea/disconnect', { method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken()} });
+ if (r.ok) { this.giteaLinked = false; }
+ } catch(e) {}
+ },
+ async loadGithubStatus() {
+ try {
+ var r = await fetch('/api/github/status');
+ var d = await r.json();
+ this.githubLinked = d.linked || false;
+ } catch(e) { this.githubLinked = false; }
+ },
+ async disconnectGithub() {
+ if (!confirm('Disconnect GitHub? You can reconnect anytime.')) return;
+ try {
+ var r = await fetch('/api/github/disconnect', { method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken()} });
+ if (r.ok) { this.githubLinked = false; }
+ } catch(e) {}
+ },
+ async loadAutomations() {
+ try { await this.loadCollectionsForForm(); } catch(e) {}
+ try {
+ var r = await fetch('/workspace/automations', {credentials:'same-origin'});
+ var d = await r.json();
+ this.automations = d.automations || [];
+ } catch(e) { this.automations = []; }
+ },
+ async loadCollectionsForForm() {
+ try {
+ var r = await fetch('/db/api', {credentials:'same-origin'});
+ var d = await r.json();
+ this.globalCollections = d.collections || [];
+ } catch(e) { this.globalCollections = []; }
+ },
+ async saveAutomation() {
+ if (!this.af.name.trim()) { window.showToast && window.showToast('Nom requis', 'error'); return; }
+ try { JSON.parse(this.af.condition_json || '[]'); } catch(e) { window.showToast && window.showToast('Conditions JSON invalide', 'error'); return; }
+ try { JSON.parse(this.af.actions_json || '[]'); } catch(e) { window.showToast && window.showToast('Actions JSON invalide', 'error'); return; }
+ var payload = {
+ name: this.af.name,
+ trigger_type: this.af.trigger_type,
+ event: this.af.event,
+ cron_expression: this.af.cron_expression,
+ collection_id: this.af.collection_id ? parseInt(this.af.collection_id, 10) : null,
+ condition_json: this.af.condition_json,
+ actions_json: this.af.actions_json,
+ enabled: true
+ };
+ var url = '/workspace/automations' + (this.editAutomationId ? '/' + this.editAutomationId : '');
+ var method = this.editAutomationId ? 'PUT' : 'POST';
+ try {
+ var r = await fetch(url, { method: method, credentials:'same-origin',
+ headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
+ body: JSON.stringify(payload) });
+ var d = await r.json();
+ if (!r.ok) { window.showToast && window.showToast(d.detail || 'Échec', 'error'); return; }
+ window.showToast && window.showToast(this.editAutomationId ? 'Automation mise à jour' : 'Automation créée');
+ this.cancelEditAutomation();
+ await this.loadAutomations();
+ } catch(e) { window.showToast && window.showToast('Erreur réseau', 'error'); }
+ },
+ editAutomation(a) {
+ this.editAutomationId = a.id;
+ this.af = {
+ name: a.name || '',
+ trigger_type: a.trigger_type || 'event',
+ event: a.event || 'page.created',
+ cron_expression: a.cron_expression || '*/15 * * * *',
+ collection_id: a.collection_id ? String(a.collection_id) : '',
+ condition_json: a.condition_json || '[]',
+ actions_json: a.actions_json || '[]'
+ };
+ },
+ cancelEditAutomation() {
+ this.editAutomationId = null;
+ this.af = { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' };
+ },
+ async toggleAutomation(id, currentlyEnabled) {
+ try {
+ var row = this.automations.find(function(x){ return x.id === id; });
+ if (!row) return;
+ var payload = {
+ name: row.name,
+ trigger_type: row.trigger_type || 'event',
+ event: row.event || 'page.created',
+ cron_expression: row.cron_expression || '',
+ collection_id: row.collection_id || null,
+ condition_json: row.condition_json || '[]',
+ actions_json: row.actions_json || '[]',
+ enabled: !currentlyEnabled
+ };
+ var r = await fetch('/workspace/automations/' + id, { method:'PUT', credentials:'same-origin',
+ headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
+ body: JSON.stringify(payload) });
+ if (r.ok) await this.loadAutomations();
+ } catch(e) {}
+ },
+ async runAutomation(id) {
+ try {
+ this.autoRuns = [];
+ this.autoRuns.push({ created_at: new Date().toLocaleString(), trigger_source: 'manual', status: 'running', detail: '…' });
+ var r = await fetch('/workspace/automations/' + id + '/run', { method:'POST', credentials:'same-origin',
+ headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()}, body: '{}' });
+ var d = await r.json();
+ this.autoRuns = [];
+ this.autoRuns.push({ created_at: new Date().toLocaleString(), trigger_source: 'manual', status: d.status || 'fired', detail: d.detail || '' });
+ window.showToast && window.showToast('Résultat : ' + (d.status || '') + ' — ' + (d.detail || ''));
+ await this.loadAutomations();
+ } catch(e) {}
+ },
+ async deleteAutomation(id) {
+ if (!confirm('Supprimer cette automation ?')) return;
+ try {
+ var r = await fetch('/workspace/automations/' + id, { method:'DELETE', credentials:'same-origin',
+ headers: {'X-CSRF-Token': this.getCsrfToken()} });
+ if (r.ok) await this.loadAutomations();
+ } catch(e) {}
+ },
+ // ── v5.2.0 API tokens ──
+ async loadApiTokens() {
+ try {
+ var r = await fetch('/api/settings/tokens', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, credentials:'same-origin'});
+ var d = await r.json();
+ this.apiTokens = d.tokens || [];
+ } catch(e) { this.apiTokens = []; }
+ },
+ async createApiToken() {
+ var name = this.tokenName.trim();
+ if (!name) return;
+ try {
+ var r = await fetch('/api/settings/tokens', {method:'POST', credentials:'same-origin',
+ headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
+ var d = await r.json();
+ if (!r.ok) { window.showToast && window.showToast(d.detail || 'Erreur', 'error'); return; }
+ this.newToken = d;
+ this.tokenName = '';
+ await this.loadApiTokens();
+ } catch(e) { window.showToast && window.showToast('Erreur réseau', 'error'); }
+ },
+ async copyNewToken() {
+ if (!this.newToken) return;
+ try { await navigator.clipboard.writeText(this.newToken.token); window.showToast && window.showToast('Token copié'); }
+ catch(e) { window.showToast && window.showToast(this.newToken.token, 'error'); }
+ },
+ async revokeApiToken(t) {
+ if (!confirm('Révoquer le token « ' + t.name + ' » ? Il ne fonctionnera plus immédiatement.')) return;
+ try {
+ var r = await fetch('/api/settings/tokens/' + t.id, {method:'DELETE', credentials:'same-origin',
+ headers: {'X-CSRF-Token': this.getCsrfToken()} });
+ if (r.ok) await this.loadApiTokens();
+ } catch(e) {}
+ },
+ // ── v5.2.0 Sessions ──
+ async loadSessions() {
+ try {
+ var r = await fetch('/api/settings/sessions', {credentials:'same-origin'});
+ var d = await r.json();
+ this.sessions = d.sessions || [];
+ } catch(e) { this.sessions = []; }
+ },
+ async revokeSession(s) {
+ var msg = s.is_current ? 'Déconnecter cet appareil ?' : 'Révoquer cette session ?';
+ if (!confirm(msg)) return;
+ try {
+ var r = await fetch('/api/settings/sessions/' + s.id + '/revoke', {method:'POST', credentials:'same-origin',
+ headers: {'X-CSRF-Token': this.getCsrfToken()}, body: '{}' });
+ if (r.ok) {
+ if (s.is_current) window.location = '/auth/login?provider=local';
+ await this.loadSessions();
+ }
+ } catch(e) {}
+ },
+ // ── v6.2.0 Web Clipper ──
+ async loadClipperDevices() {
+ try {
+ var r = await fetch('/api/v2/web-clipper/devices', {credentials:'same-origin'});
+ var d = await r.json();
+ this.clipperDevices = d.devices || [];
+ } catch(e) { this.clipperDevices = []; }
+ },
+ async revokeClipperDevice(d) {
+ if (!confirm('Révoquer l\'extension « ' + (d.device_name || d.device_id) + ' » ?')) return;
+ try {
+ var r = await fetch('/api/v2/web-clipper/devices/' + d.id, {method:'DELETE', credentials:'same-origin', headers: {'X-CSRF-Token': this.getCsrfToken()}});
+ if (r.ok) await this.loadClipperDevices();
+ } catch(e) {}
+ },
+ async testClip() {
+ this.clipTesting = true; this.clipTestMsg = ''; this.clipTestOk = false;
+ var url = (this.clipTestUrl || '').trim();
+ if (!url) { this.clipTestMsg = 'URL requise'; this.clipTesting = false; return; }
+ try {
+ var r = await fetch('/api/v2/web-clipper/clip', {method:'POST', credentials:'same-origin',
+ headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
+ body: JSON.stringify({url: url, title: 'Test clipper — ' + url, content: '
Test clip
Contenu de test depuis Settings.
', content_type: 'article', device_id: 'settings-test'})});
+ var d = await r.json();
+ if (r.ok) { this.clipTestOk = true; this.clipTestMsg = '✓ Page créée : #' + d.page_id + ' — ' + d.title; await this.loadClipperDevices(); }
+ else this.clipTestMsg = d.detail || 'Erreur';
+ } catch(e) { this.clipTestMsg = 'Erreur réseau'; }
+ finally { this.clipTesting = false; }
+ },
+ // ── v6.7.0 SSO / Enterprise (admin) ──
+ ssoStatusTitle() {
+ if (!this.ssoCfg.id && this.ssoSource !== 'env') return '';
+ if (this.ssoSource === 'env') return 'Configuration lue depuis le .env (SSO_*)';
+ return 'SSO actif — ' + (this.ssoCfg.name || (this.ssoCfg.provider_type === 'saml' ? 'SAML 2.0' : 'OpenID Connect'));
+ },
+ ssoStatusDesc() {
+ var kind = this.ssoCfg.provider_type === 'saml' ? 'SAML 2.0' : 'OpenID Connect';
+ return kind + ' · ' + this.ssoProvisioned + ' utilisateur(s) provisionné(s) · login local ' + (this.ssoCfg.sso_only ? 'DÉSACTIVÉ (SSO only)' : 'autorisé');
+ },
+ ssoMetadataUrl() { return window.location.origin + '/auth/saml/metadata'; },
+ async copySsoMetadata() {
+ try {
+ await navigator.clipboard.writeText(this.ssoMetadataUrl());
+ if (typeof toast === 'function') toast('SP metadata URL copiée');
+ } catch(e) {}
+ },
+ async loadSsoConfig() {
+ this.ssoMsg = '';
+ try {
+ var r = await fetch('/api/v2/sso/config', {credentials:'same-origin'});
+ if (r.ok) {
+ var d = await r.json();
+ this.ssoSource = d.source || 'db';
+ this.ssoProvisioned = d.provisioned_users || 0;
+ if (d.configured === false) {
+ this.ssoCfg = Object.assign({}, this.ssoCfg, {id:null, entity_id:'', sso_url:'', slo_url:'', x509_certificate:'', issuer_url:'', client_id:'', client_secret:'', client_secret_set:false, name:''});
+ } else {
+ this.ssoCfg = {
+ id: d.id || null,
+ provider_type: d.provider_type || 'saml',
+ name: d.name || '',
+ entity_id: d.entity_id || '',
+ sso_url: d.sso_url || '',
+ slo_url: d.slo_url || '',
+ x509_certificate: d.x509_certificate || '',
+ issuer_url: d.issuer_url || '',
+ client_id: d.client_id || '',
+ client_secret: '',
+ client_secret_set: !!d.client_secret_set,
+ scope: d.scope || 'openid profile email',
+ attribute_mapping: d.attribute_mapping || {},
+ auto_provision: !!d.auto_provision,
+ sso_only: !!d.sso_only,
+ sign_requests: !!d.sign_requests,
+ default_workspace_id: d.default_workspace_id || ''
+ };
+ this.ssoAttrJson = JSON.stringify(d.attribute_mapping || {}, null, 2);
+ this.ssoGroups = (d.groups_mapping || []).map(function(g){ return {sso_group: g.sso_group || '', workspace_role: g.workspace_role || 'editor', workspace_id: g.workspace_id || ''}; });
+ }
+ }
+ } catch(e) {}
+ try {
+ var w = await fetch('/api/v2/sso/workspaces', {credentials:'same-origin'});
+ if (w.ok) {
+ var wd = await w.json();
+ this.ssoWorkspaces = wd.workspaces || [];
+ this.ssoProvisioned = wd.provisioned_users || this.ssoProvisioned;
+ }
+ } catch(e) {}
+ },
+ async saveSsoConfig() {
+ this.ssoMsg = '';
+ var amap = {};
+ try { amap = JSON.parse(this.ssoAttrJson || '{}'); }
+ catch(e) { this.ssoMsg = 'Attribute mapping : JSON invalide'; this.ssoOk = false; return; }
+ if (amap === null || typeof amap !== 'object' || Array.isArray(amap)) {
+ this.ssoMsg = 'Attribute mapping : doit être un objet JSON'; this.ssoOk = false; return;
+ }
+ this.ssoSaving = true;
+ var payload = Object.assign({}, this.ssoCfg, {
+ attribute_mapping: amap,
+ groups_mapping: this.ssoGroups.map(function(g){ return {sso_group: (g.sso_group||'').trim(), workspace_role: g.workspace_role || 'editor', workspace_id: g.workspace_id ? parseInt(g.workspace_id, 10) : null}; }),
+ auto_provision: this.ssoCfg.auto_provision ? 1 : 0,
+ sso_only: this.ssoCfg.sso_only ? 1 : 0,
+ sign_requests: this.ssoCfg.sign_requests ? 1 : 0,
+ default_workspace_id: this.ssoCfg.default_workspace_id ? parseInt(this.ssoCfg.default_workspace_id, 10) : null
+ });
+ delete payload.id;
+ try {
+ var r = await this.adminFetch('/api/v2/sso/config', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)});
+ var d = null; try { d = await r.json(); } catch(e) {}
+ if (!r.ok) { this.ssoMsg = (d && (d.detail || d.error)) || ('Erreur ' + r.status); this.ssoOk = false; }
+ else { this.ssoMsg = 'Configuration enregistrée'; this.ssoOk = true; await this.loadSsoConfig(); }
+ } catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
+ finally { this.ssoSaving = false; }
+ },
+ async disableSso() {
+ if (!confirm('Désactiver le SSO ? Les utilisateurs pourront de nouveau se connecter localement.')) return;
+ this.ssoMsg = '';
+ try {
+ var r = await this.adminFetch('/api/v2/sso/config', {method:'DELETE'});
+ if (r.ok) { this.ssoMsg = 'SSO désactivé'; this.ssoOk = true; await this.loadSsoConfig(); }
+ else { this.ssoMsg = 'Erreur ' + r.status; this.ssoOk = false; }
+ } catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
+ },
+ async syncSsoGroups() {
+ this.ssoMsg = '';
+ try {
+ var r = await this.adminFetch('/api/v2/sso/sync', {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'});
+ var d = null; try { d = await r.json(); } catch(e) {}
+ if (r.ok) { this.ssoMsg = 'Groupes re-synchronisés — ' + (d && d.updated || 0) + '/' + (d && d.users || 0) + ' utilisateur(s)'; this.ssoOk = true; await this.loadSsoConfig(); }
+ else { this.ssoMsg = (d && d.detail) || ('Erreur ' + r.status); this.ssoOk = false; }
+ } catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
+ },
+ addSsoGroup() { this.ssoGroups.push({sso_group:'', workspace_role:'editor', workspace_id: this.ssoCfg.default_workspace_id || ''}); },
+ removeSsoGroup(i) { this.ssoGroups.splice(i, 1); },
+ // ── v5.2.0 Backups (admin) ──
+ async loadBackups() {
+ try {
+ var r = await this.adminFetch('/api/settings/backups');
+ var d = await r.json();
+ this.backups = d.backups || [];
+ } catch(e) { this.backups = []; }
+ },
+ async runBackup() {
+ this.backupRunning = true; this.backupMsg = ''; this.backupOk = false;
+ try {
+ var r = await this.adminFetch('/api/settings/backups/run', {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'});
+ var d = await r.json();
+ if (r.ok) { this.backupOk = true; this.backupMsg = '✓ Sauvegarde créée : ' + d.filename; await this.loadBackups(); }
+ else { this.backupMsg = d.detail || 'Erreur'; }
+ } catch(e) { this.backupMsg = 'Erreur réseau'; }
+ finally { this.backupRunning = false; }
+ },
+ closeSettings() {
+ if (window.opener && !window.opener.closed) {
+ window.opener.location.reload();
+ window.close();
+ return;
+ }
+ // If navigated directly to settings, redirect to workspaces (forces refresh)
+ if (!document.referrer || document.referrer === window.location.href) {
+ window.location.href = '/workspaces?ts=' + Date.now();
+ return;
+ }
+ // Go back and force refresh
+ var prev = document.referrer;
+ if (prev && prev.includes('/settings')) {
+ // Came from another settings tab — go back two steps then refresh
+ history.go(-2);
+ setTimeout(function() { window.location.reload(); }, 100);
+ } else {
+ history.back();
+ setTimeout(function() { window.location.reload(); }, 100);
+ }
+ },
+ };
+}