feat(workspace,editor): creation dans le dossier courant + sync live du titre (sidebar/header)
FlowDeck CI / lint (push) Successful in 54s
FlowDeck CI / test (push) Successful in 5m50s
FlowDeck CI / docker (push) Successful in 47s

- creation fichier/dossier a la racine du dossier courant ou d'un dossier cible (context-menu, boutons de survol) via createPage/showCreateFolderModal(parentId)
- instances de modeles en tant qu'enfant d'un dossier (parent_id) ; nom vide -> 'Untitled'
- sidebar de la librairie rafraichi apres delete/move/duplicate/rename (_syncSidebar)
- editeur: le titre se synchronise en direct dans le sidebar, le breadcrumb et l'onglet (pages et fichiers) et persiste via PUT /board/api/pages/{id}
This commit is contained in:
2026-09-14 17:05:03 -04:00
parent 4038e9bdad
commit 41c1d315d3
8 changed files with 110 additions and 15 deletions
+15 -4
View File
@@ -292,6 +292,12 @@ async def use_page_template(request: Request, template_id: int):
body_ws = (body.get("workspace") or "").strip()
if body_ws:
ws_key = body_ws
# Optional target folder: instantiate the template as a child of it.
parent_id = body.get("parent_id")
try:
parent_id = int(parent_id) if parent_id not in (None, "", 0, "0") else None
except (TypeError, ValueError):
parent_id = None
try:
parsed_blocks = json.loads(blocks_json)
except (json.JSONDecodeError, TypeError):
@@ -299,7 +305,12 @@ async def use_page_template(request: Request, template_id: int):
_ensure_block_ids(parsed_blocks)
blocks_json = json.dumps(parsed_blocks)
with get_conn() as conn:
if ws_id is not None:
if parent_id is not None:
next_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE parent_id=?",
(parent_id,),
).fetchone()[0]
elif ws_id is not None:
next_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace_id=? AND parent_id IS NULL",
(ws_id,),
@@ -310,9 +321,9 @@ async def use_page_template(request: Request, template_id: int):
(ws_key,),
).fetchone()[0]
cur = conn.execute(
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, sort_order)
VALUES (?,?,?,?,?, 'Private', ?)""",
(ws_key, ws_id, title or name, blocks_json, "blocks", next_order),
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id, sort_order)
VALUES (?,?,?,?,?, 'Private', ?, ?)""",
(ws_key, ws_id, title or name, blocks_json, "blocks", parent_id, next_order),
)
conn.commit()
page_id = cur.lastrowid
+1 -1
View File
@@ -1352,7 +1352,7 @@ async def nav_menu(request: Request, workspace_id: int = None, parent_id: int =
async def create_local_workspace_item(request: Request):
"""Create a new file in the active workspace."""
body = await request.json()
name = body.get("name", "Untitled").strip()
name = (body.get("name") or "").strip() or "Untitled"
item_type = body.get("type", "page")
parent_id = body.get("parent_id")
explicit_ws_id = body.get("workspace_id")
+10
View File
@@ -168,6 +168,16 @@ document.addEventListener('alpine:init', function () {
this.wsId = d.workspace_id || 0;
} catch (e) { this.crumbs = []; }
}
// Keep the breadcrumb in sync when the open page is renamed in the editor.
var self = this;
window.addEventListener('flowdeck:page-renamed', function (e) {
var d = e && e.detail;
if (!d || d.id == null) return;
var id = String(d.id);
self.crumbs = self.crumbs.map(function (c) {
return String(c.id) === id ? Object.assign({}, c, { label: d.title || 'Untitled' }) : c;
});
});
},
get display() {
+32 -3
View File
@@ -2274,16 +2274,45 @@ applyAIBlocks(text){
setTimeout(()=>{const fe=this.getEl(focusId);if(fe){fe.focus();ce(fe);}},60);
},
autoSave(){if(this.fileData)return;clearTimeout(this.st);this.st=setTimeout(()=>this.save(),1500);},
// Reflect the page title in the left navigation, the header breadcrumb
// and the browser tab as soon as it changes.
_syncTitleUI(){
const title=(this.pageTitle||'').trim()||'Untitled';
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=title;});
document.querySelectorAll(`.sidebar-item.ws-tree-item[data-id="${this.pid}"]`).forEach(item=>{item.setAttribute('data-name',title);const n=item.querySelector('.page-name');if(n)n.textContent=title;});
document.title='FlowDeck — '+title;
window.dispatchEvent(new CustomEvent('flowdeck:page-renamed',{detail:{id:this.pid,title:title}}));
if(window.parent&&window.parent!==window){try{window.parent.postMessage({type:'fd-page-renamed',id:this.pid,title:title},window.location.origin);}catch(e){}}
},
save(cb){
if(this.fileData)return;
if(this.fileData){
// File page: only the title is editable here. Sync the sidebar,
// header breadcrumb and browser tab live, and persist the title
// without rewriting the file's blocks.
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'';
this._syncTitleUI();
if(this._fileTitleT)clearTimeout(this._fileTitleT);
this._fileTitleT=setTimeout(()=>{
this._fileTitleT=null;
const title=(this.pageTitle||'').trim();
if(!title)return;
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/board/api/pages/${this.pid}?title=${encodeURIComponent(title)}`,{method:'PUT',headers:{'X-CSRF-Token':csrf?csrf[1]:''}})
.then(()=>{this.dirty=false;})
.catch(()=>{});
},600);
if(cb)cb();
return;
}
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'';
this._syncTitleUI();
if(this.saving){ if(cb) cb(); return; }
this.sync();
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'';
this.saving=true;
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
this._syncTitleUI();
if(cb)cb();
}).catch(()=>{this.saving=false;});
},
+22 -3
View File
@@ -897,6 +897,15 @@
return { wsId: 0, isGitea: false, workspaceKey: wk };
},
/** Folder currently open on the local-workspace page (0 when at root). */
_currentFolderId: function() {
var ws = window._wsData;
if (ws && typeof ws.currentFolder === 'number' && ws.currentFolder > 0) {
return ws.currentFolder;
}
return 0;
},
/** Return a toast function that works across pages. */
_toast: function(msg, type) {
if (window.appState && window.appState.toast) window.appState.toast(msg, type);
@@ -907,14 +916,19 @@
* Create a new page: opens the v5.12.0 template picker (built-in + the
* user's saved templates), then instantiates the chosen one.
*/
createPage: function() {
createPage: function(parentId) {
var self = this;
// Explicit folder (row/context-menu button) wins; otherwise create in the
// folder the user is currently browsing on the local-workspace page.
if (parentId == null || parentId === '') parentId = this._currentFolderId();
this._createParentId = parentId || 0;
// Direct creation path (fallback / no-template choice).
this._createPlainPage = function() {
var ctx = self._getContext();
if (ctx.wsId > 0) {
var body = { name: '', type: 'page' };
if (ctx.isGitea) body.workspace_id = ctx.wsId;
if (self._createParentId) body.parent_id = self._createParentId;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': self.getCsrfToken() },
@@ -986,6 +1000,7 @@
var ctx = self._getContext();
if (ctx.wsId > 0) { body.workspace_id = ctx.wsId; }
else if (ctx.workspaceKey) { body.workspace = ctx.workspaceKey; }
if (self._createParentId) { body.parent_id = self._createParentId; }
if (parts[0] === 'key') { body.key = parts.slice(1).join(':'); url = '/board/api/page-templates/0/use'; }
else { url = '/board/api/page-templates/' + parts[1] + '/use'; }
fetch(url, {
@@ -999,8 +1014,10 @@
.catch(function(){ self._createPlainPage(); });
},
/** Show the global New Folder modal. */
showCreateFolderModal: function() {
/** Show the global New Folder modal (parentId = target folder, optional). */
showCreateFolderModal: function(parentId) {
if (parentId == null || parentId === '') parentId = this._currentFolderId();
this._folderParentId = parentId || 0;
var modal = document.getElementById('fd-global-folder-modal');
if (!modal) return;
modal.style.display = 'flex';
@@ -1019,6 +1036,8 @@
var ctx = this._getContext();
var body = { name: name, type: 'folder' };
if (ctx.wsId > 0 && ctx.isGitea) body.workspace_id = ctx.wsId;
if (this._folderParentId) body.parent_id = this._folderParentId;
this._folderParentId = 0;
var self = this;
fetch('/api/local-workspace/items', {
+11
View File
@@ -824,6 +824,11 @@ function libraryPage() {
return m ? m[1] : '';
},
_syncSidebar() {
if (window.appState && window.appState.refreshSidebarTree) window.appState.refreshSidebarTree();
if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites();
},
_isDescendant(node, targetId) {
if (!node || !node.children) return false;
for (var i = 0; i < node.children.length; i++) {
@@ -846,6 +851,7 @@ function libraryPage() {
}
this.clearSelection();
this.loadTab();
this._syncSidebar();
},
// ── Empty states ──
@@ -1088,6 +1094,7 @@ function libraryPage() {
}
this.clearSelection();
this.loadTab();
this._syncSidebar();
if (window.showToast) window.showToast('Moved to trash', 'success');
},
@@ -1097,6 +1104,7 @@ function libraryPage() {
var csrf = this._getCsrf();
await fetch('/board/api/pages/' + item.id, {method:'DELETE', headers:{'X-CSRF-Token': csrf}});
this.loadTab();
this._syncSidebar();
if (window.showToast) window.showToast('Moved to trash', 'success');
},
@@ -1257,6 +1265,7 @@ function libraryPage() {
await fetch(updUrl, { method: 'PUT', headers: {'X-CSRF-Token': csrf} });
}
this.loadTab();
this._syncSidebar();
if (window.showToast) window.showToast('Page duplicated', 'success');
} catch(e) {
if (window.showToast) window.showToast('Failed to duplicate page', 'error');
@@ -1332,6 +1341,7 @@ function libraryPage() {
}
} catch(e) {}
this._renderTable();
this._syncSidebar();
},
// ── Add new ──
@@ -1423,6 +1433,7 @@ function libraryPage() {
await fetch('/board/api/pages/' + this.peekItem.id, {method:'DELETE', headers:{'X-CSRF-Token': csrf}});
this.closePeek();
this.loadTab();
this._syncSidebar();
if (window.showToast) window.showToast('Moved to trash', 'success');
},
+18 -4
View File
@@ -648,6 +648,19 @@ window._wsInitData = (function() {
var self = this;
window.addEventListener('online', function() { self.online = true; });
window.addEventListener('offline', function() { self.online = false; });
// 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; }
@@ -864,6 +877,7 @@ window._wsInitData = (function() {
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');
@@ -1360,10 +1374,10 @@ window._wsInitData = (function() {
switch(action) {
case 'new-file':
window.FlowDeck.createPage();
window.FlowDeck.createPage(id);
break;
case 'new-folder':
window.FlowDeck.showCreateFolderModal();
window.FlowDeck.showCreateFolderModal(id);
break;
case 'rename':
this.startRename({db_id: id, name: name});
@@ -2724,8 +2738,8 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<div class="actions">
<template x-if="node.is_folder">
<span style="display:flex;gap:2px">
<button class="ws-act" @click.stop="window.FlowDeck.createPage()" title="New file">{{ fd_icon("file",14) }}</button>
<button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal()" title="New folder">{{ fd_icon("folder",14) }}</button>
<button class="ws-act" @click.stop="window.FlowDeck.createPage(node.id)" title="New file">{{ fd_icon("file",14) }}</button>
<button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal(node.id)" title="New folder">{{ fd_icon("folder",14) }}</button>
<a class="ws-act" href="/local-workspace" title="Open workspace page" style="text-decoration:none;display:inline-flex;align-items:center;">{{ fd_icon("external-link",14) }}</a>
</span>
</template>
+1
View File
@@ -2,6 +2,7 @@
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
{% set page_title = page.title %}
{% set nav_page_id = page.id %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn" @click="window.E && window.E.toggleComments()" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="window.E && window.E.commentCount>0 ? window.E.commentCount : \'\'"></span></button><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% include '_header.html' %}
{% endblock %} {% block content %}