feat(nav): partial HTMX navigation — swap main content without full reload
FlowDeck CI / lint (push) Successful in 45s
FlowDeck CI / test (push) Successful in 4m17s
FlowDeck CI / docker (push) Successful in 41s

Intercept internal links and load only .main-wrapper via htmx.ajax, keeping the sidebar/header shell in place. Adds history pushState/popstate handling, sidebar active-state resync and document title update. Moves page script blocks inside #main-content so they run on swap, and makes editor/board scripts idempotent. Routes navigateTo/breadcrumb/library/local-workspace through window.fdNavigate and fixes the duplicated navigateTo definition.
This commit is contained in:
2026-09-12 21:26:56 -04:00
parent da3e09c215
commit e707becbf8
9 changed files with 197 additions and 27 deletions
+5 -1
View File
@@ -247,7 +247,11 @@ document.addEventListener('alpine:init', function () {
this.activeItems = [];
},
go: function (url) { if (url) window.location.href = url; }
go: function (url) {
if (!url) return;
if (window.fdNavigate) window.fdNavigate(url);
else window.location.href = url;
}
};
});
});
+3
View File
@@ -73,6 +73,9 @@ document.addEventListener('alpine:init', function () {
var self = this;
this._timer = setInterval(function () { self.refreshCount(); }, 30000);
},
destroy() {
if (this._timer) { clearInterval(this._timer); this._timer = null; }
},
toggle() { this.open = !this.open; if (this.open) this.load(); },
timeAgo(s) {
if (!s) return '';
+21 -3
View File
@@ -37,6 +37,19 @@ window.__fdRT = (function () {
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]; }
@@ -459,9 +472,14 @@ window.__fdRT = (function () {
}
const tEl = document.getElementById('_titleEl');
if (tEl) tEl.addEventListener('input', titleInput);
document.addEventListener('selectionchange', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); }, true);
window.addEventListener('scroll', scheduleDraw, true);
window.addEventListener('resize', scheduleDraw);
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) {
+4
View File
@@ -3,6 +3,8 @@
</script>
<script data-cfasync="false">
/* eslint-disable */
if (!window.__fdEditorScriptsLoaded) {
window.__fdEditorScriptsLoaded = true;
const CMDS=[{name:'BASIC',items:[
{id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'},
{id:'heading_3',name:'Heading 3',icon:'H3'},{id:'heading_4',name:'Heading 4',icon:'H4'},{id:'bulleted_list',name:'Bulleted list',icon:'•'},{id:'numbered_list',name:'Numbered list',icon:'1.'},
@@ -2120,4 +2122,6 @@ applyAIBlocks(text){
default: return c;
}
}
window.editorState = editorState;
}
</script>
+13 -13
View File
@@ -211,7 +211,7 @@
<div class="um-sep"></div>
<div class="um-version">FlowDeck v{{ app_version }}</div>
<div class="um-sep"></div>
<a href="/auth/logout" class="um-item um-danger" @click="workspaceMenuOpen=false">{{ fd_icon("log-out", 16) }} Log out</a>
<a href="/auth/logout" class="um-item um-danger" hx-boost="false" @click="workspaceMenuOpen=false">{{ fd_icon("log-out", 16) }} Log out</a>
</div>
</div>
@@ -265,7 +265,7 @@
{% else %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("folder",16) }}</span><span class="page-name text-dim">Open a workspace to see your files</span></li>
{% endif %}
<li class="sidebar-item" style="margin-top:4px;border-top:1px solid var(--border);padding-top:8px;" onclick="window.location='/workspaces'">
<li class="sidebar-item" style="margin-top:4px;border-top:1px solid var(--border);padding-top:8px;" onclick="window.fdNavigate ? window.fdNavigate('/workspaces') : window.location='/workspaces'">
<span class="page-icon page-icon-svg">{{ fd_icon("settings",16) }}</span><span class="page-name text-dim">Manage Workspaces</span>
</li>
</ul>
@@ -598,6 +598,7 @@
<!-- Content -->
<div class="content-area" id="main-content">
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
</div>
</div>
@@ -1255,11 +1256,6 @@
toggleWorkspaceMenu() {
this.workspaceMenuOpen = !this.workspaceMenuOpen;
},
navigateTo(url) {
this.mobileSidebarOpen = false;
this.sidebarCollapsed = true;
window.location.href = url;
},
openQuickFind() { if (window.FlowDeckPalette) window.FlowDeckPalette.open(); },
// ── Tree state ──
@@ -1328,12 +1324,15 @@
},
navigateTo(url) {
this.mobileSidebarOpen = false;
this.sidebarCollapsed = true;
// Preserve workspace context when navigating to library/trash
if ((url === '/library' || url === '/trash') && this.workspaceKey && this.workspaceKey.includes('/')) {
const parts = this.workspaceKey.split('/');
url = `${url}?owner=${parts[0]}&repo=${parts.slice(1).join('/')}`;
}
window.location.href = url;
if (window.fdNavigate) window.fdNavigate(url);
else window.location.href = url;
},
showContextMenu(event, pageId, pageName) {
@@ -1669,7 +1668,8 @@
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
},
openWorkspacePage(pageId) {
window.location = `/pages/${pageId}`;
if (window.fdNavigate) window.fdNavigate('/pages/' + pageId);
else window.location = `/pages/${pageId}`;
},
// ── Tree expand/collapse + folder navigation ──
@@ -1689,7 +1689,8 @@
localStorage.setItem('fd_expanded_folders', JSON.stringify(this.expandedFolders));
localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen));
} catch(e) {}
window.location = `/local-workspace?folder=${id}`;
if (window.fdNavigate) window.fdNavigate(`/local-workspace?folder=${id}`);
else window.location = `/local-workspace?folder=${id}`;
},
createEmptyPage() {
@@ -1841,8 +1842,7 @@
}
});
</script>
<script src="/static/js/app.js?v=2.4.6" defer></script>
{% block scripts %}{% endblock %}
<script src="/static/js/app.js?v=2.4.7" defer></script>
<style>
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
@@ -2034,7 +2034,7 @@
function choose(i){
var it = state.items[i]; if(!it) return;
if(it.run){ var ok = it.run(); if(ok) close(); return; }
if(it.url){ window.location.href=it.url; close(); return; }
if(it.url){ if(window.fdNavigate) window.fdNavigate(it.url); else window.location.href=it.url; close(); return; }
}
function onKey(e){
+3 -3
View File
@@ -152,9 +152,9 @@
{% block scripts %}
<script>
const owner = '{{ owner }}';
const repo = '{{ repo }}';
const initialView = '{{ initial_view }}';
var owner = '{{ owner }}';
var repo = '{{ repo }}';
var initialView = '{{ initial_view }}';
// Auto-switch to view from URL param
document.addEventListener('DOMContentLoaded', function() {
+4 -2
View File
@@ -1303,7 +1303,8 @@ function libraryPage() {
// ── Item actions ──
openItem(item) {
if (!item || !item.id) return;
window.location.href = '/pages/' + item.id;
if (window.fdNavigate) window.fdNavigate('/pages/' + item.id);
else window.location.href = '/pages/' + item.id;
},
startRename(item) {
@@ -1361,7 +1362,8 @@ function libraryPage() {
return;
}
}
window.location.href = '/pages/' + d.id;
if (window.fdNavigate) window.fdNavigate('/pages/' + d.id);
else window.location.href = '/pages/' + d.id;
}
} catch(e) {
if (window.showToast) window.showToast('Failed to create page', 'error');
+6 -5
View File
@@ -867,7 +867,7 @@ window._wsInitData = (function() {
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() { window.location = '/pages/' + node.id; };
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');
@@ -1248,7 +1248,7 @@ window._wsInitData = (function() {
} else {
// Click to rename, double-click to open
html += '<span class="name file" onclick="event.stopPropagation();window._wsData.startRenderChildRename('+escId+',\''+escName+'\')"'
+ ' ondblclick="window.location=\'/pages/'+escId+'\'"'
+ ' ondblclick="window.fdNavigate?window.fdNavigate(\'/pages/'+escId+'\'):window.location=\'/pages/'+escId+'\'"'
+ ' title="Click to rename, double-click to open"'
+ ' onmouseenter="var d=window._wsData;if(d)d.showPreview(event,{id:'+escId+',name:\''+escName+'\',is_folder:false,content_format:\''+(c.content_format||'')+'\'})"'
+ ' onmouseleave="var d=window._wsData;if(d)d.hidePreview()">'
@@ -1640,11 +1640,12 @@ window._wsInitData = (function() {
navigateToPath() {
var path = (this.$el.querySelector('.path-input') || {}).value || '';
var parts = path.replace(/^\/workspace\/?/, '').split('/').filter(Boolean);
if (parts.length === 0) { window.location = '/local-workspace'; return; }
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) {
window.location = '/local-workspace?folder=' + 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');
}
@@ -1963,7 +1964,7 @@ window._wsInitData = (function() {
return false;
},
openPage(id) { window.location = '/pages/'+id; },
openPage(id) { if (window.fdNavigate) window.fdNavigate('/pages/' + id); else window.location = '/pages/' + id; },
openSidePeek(id) {
var node = this._findNodeById(this.tree, id) || this._findNodeById(this.displayTree, id);
if (node) this.selectForPreview(node);
+138
View File
@@ -233,3 +233,141 @@
});
})();
// ── Partial navigation (HTMX) ───────────────────────────────
// The shell (sidebar + topbar) stays in place and only `.main-wrapper`
// is swapped, so clicking the left navigation no longer reloads the
// whole document. Internal links are intercepted and loaded with
// htmx.ajax(); history is managed manually so the back button works.
(function() {
'use strict';
function isPlainLeftClick(evt) {
return evt.button === 0 && !evt.ctrlKey && !evt.metaKey && !evt.shiftKey && !evt.altKey;
}
// Returns true when a link should be handled by the partial navigator.
// External links, downloads, auth, API/static files and links already
// managed by Alpine or HTMX keep their default behaviour.
function shouldIntercept(a, evt) {
if (!a || !a.getAttribute || !isPlainLeftClick(evt)) return false;
if (a.hasAttribute('data-no-nav')) return false;
var target = (a.getAttribute('target') || '').toLowerCase();
if (target && target !== '_self') return false;
if (a.hasAttribute('download')) return false;
if (a.getAttribute('hx-boost') === 'false') return false;
var attrs = a.getAttributeNames();
for (var i = 0; i < attrs.length; i++) {
var n = attrs[i];
if (n.indexOf('@click') === 0 || n.indexOf('x-on:click') === 0) return false;
if (n.indexOf('hx-') === 0) return false;
}
var href = a.getAttribute('href') || '';
if (!href || href.charAt(0) === '#') return false;
if (/^(mailto:|tel:|javascript:)/i.test(href)) return false;
var u;
try { u = new URL(a.href, window.location.href); } catch { return false; }
if (u.origin !== window.location.origin) return false;
var p = u.pathname;
if (p === '/auth' || p.indexOf('/auth/') === 0) return false;
if (p.indexOf('/api/') === 0) return false;
if (p.indexOf('/static/') === 0) return false;
if (p === '/p' || p.indexOf('/p/') === 0) return false;
return true;
}
var loading = false;
function fdLoad(url, push) {
if (!window.htmx) { window.location.href = url; return; }
if (loading) return;
loading = true;
document.documentElement.classList.add('fd-navigating');
function done() {
loading = false;
document.documentElement.classList.remove('fd-navigating');
}
var p;
try {
p = window.htmx.ajax('GET', url, {
target: '.main-wrapper',
select: '.main-wrapper',
swap: 'outerHTML'
});
} catch {
done();
window.location.href = url;
return;
}
if (p && typeof p.then === 'function') {
p.then(function() {
done();
if (push) { try { history.pushState({ fd: true }, '', url); } catch { /* ignore */ } }
}).catch(function() {
done();
window.location.href = url;
});
} else {
done();
if (push) { try { history.pushState({ fd: true }, '', url); } catch { /* ignore */ } }
}
}
document.addEventListener('click', function(evt) {
var a = evt.target && evt.target.closest ? evt.target.closest('a[href]') : null;
if (!shouldIntercept(a, evt)) return;
evt.preventDefault();
fdLoad(a.href, true);
});
// Back/forward buttons reload the content for the target URL.
window.addEventListener('popstate', function() {
fdLoad(window.location.href, false);
});
// Programmatic navigation used by Alpine helpers.
window.fdNavigate = function(url, opts) {
opts = opts || {};
if (!url) return;
if (opts.full || !window.htmx) { window.location.href = url; return; }
fdLoad(url, true);
};
function syncAfterSwap(xhr) {
try {
if (xhr && xhr.responseText) {
var doc = new DOMParser().parseFromString(xhr.responseText, 'text/html');
var title = doc.querySelector('title');
if (title && title.textContent) document.title = title.textContent;
}
} catch { /* ignore */ }
var path = window.location.pathname;
var search = window.location.search;
document.querySelectorAll('.sidebar-item.active').forEach(function(el) {
el.classList.remove('active');
});
document.querySelectorAll('.sidebar-item').forEach(function(el) {
var href = el.getAttribute('href');
var a = el.querySelector('a[href]');
if (a) href = a.getAttribute('href');
if (href && (href === path || href === path + search)) el.classList.add('active');
var pid = el.getAttribute('data-page-id');
if (pid && ('/pages/' + String(pid).replace('page/', '')) === path) el.classList.add('active');
var dbid = el.getAttribute('data-id');
if (dbid && search.indexOf('folder=' + dbid) !== -1) el.classList.add('active');
});
if (window.appState) {
var m = search.match(/[?&]folder=(\d+)/);
window.appState.activeNodeId = m ? parseInt(m[1], 10) : null;
}
window.scrollTo(0, 0);
}
document.body.addEventListener('htmx:afterSwap', function(evt) {
syncAfterSwap(evt.detail && evt.detail.xhr);
});
})();