From 45917c194dfc331fc68d0b19a7554ad11bc45a89 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 1 Oct 2026 20:37:07 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20A27=20phase=202b=20=E2=80=94=20+3=20801?= =?UTF-8?q?=20L=20extraits=20(recette=20config=20JSON)=20(v7.23.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 blocs interpolés extraits avec la recette de la 2a (config JSON inline + JS statique, substitutions sur le CORPS du bloc) : - 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, user full_name/login/email, is_admin (bool), auth_method — 2 routes rendent ce template, expressions « or "" » préservées pour les valeurs Undefined) - _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 (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) : '
') + ''; + }, 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 += ''; + } + 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 = '
      '+getSvgIcon('file',48)+'
      PDF Document
      Open viewer →
      '; + 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); + } + }, + }; +}