- ROADMAP.md: réorganisé avec priorités claires (v4.0.0=complété, v4.0.1=onboarding PRIO MAX)
- Ajout BRANCHING.md: convention main/develop/feat/*, workflow complet
- Branche develop créée et poussée sur Gitea
- Sections Parité Notion réparties en v4.1–v4.10 ordonnées par priorité
- Résumé des phases mis à jour
- createPage() now detects active workspace context (library, local-workspace page, sidebar)
- In a local/Gitea workspace: creates workspace item via /api/local-workspace/items with prompt
- No workspace: creates general Notion page via /board/api/pages (no prompt)
- Added hidden #fd-local-ws-id in local_workspace.html to expose workspace_id
- _confirmCreateFolder() passes workspace_id for Gitea workspaces
- Added _getContext() helper that checks multiple context sources in priority order
- Added _toast() helper for cross-component notifications
- Add global window.FlowDeck namespace with createPage() and showCreateFolderModal()
- createPage() creates a Notion-style page via /board/api/pages (same as sidebar bottom-right button)
- showCreateFolderModal() opens a global themed modal (dark/light) instead of browser prompt()
- All New Page buttons now call window.FlowDeck.createPage()
- All New Folder buttons now call window.FlowDeck.showCreateFolderModal()
- Global New Folder modal uses existing .flowdeck-modal CSS classes for theme consistency
- Updated Ctrl+N keyboard shortcut to use unified createPage()
Le x-show sur #lib-table dépendait de flatItems.length,
qu'Alpine 3.14.9 ne détecte pas. Maintenant _renderTable()
contrôle l'affichage/masquage directement via style.display.
Abandon complet d'Alpine pour le rendu du tableau :
- _renderTable() fait innerHTML directement sur #lib-table-body
- Plus de x-for, plus de x-html, plus de getters
- SortableJS supprimé (causait removeEventListener sur null)
- Fix startRename/commitRenameById pour utiliser les IDs DOM
Le x-for d'Alpine 3.14.9 ne détecte pas les changements d'array.
Remplacement complet par x-html avec génération HTML manuelle.
Ajout de window._libData pour les onclick handlers globaux.
Fonctions _byId pour toggle/expand/rename/open depuis le HTML.
Alpine 3.14.9 ne détecte pas le changement de référence d'array
(this.flatItems = result). Utilisation de splice() pour muter
l'array en place. Ajout de $nextTick pour forcer le re-render.
Ajoute des logs dans init(), loadTab() et _recomputeFlatItems()
pour identifier pourquoi flatItems reste vide malgré le chargement API.
CSP: ajout fonts.googleapis.com/gstatic.com pour débloquer Google Fonts.
Remplacé visibility:hidden par opacity:0.4 pour les hover-only.
Cela garantit que les éléments sont RENDUS et visibles en permanence,
avec accentuation au hover (opacity 1).
Les pages du workspace local ont workspace='' mais workspace_id=27.
Le filtre workspace != '' les excluait, retournant 0 items.
Ajout de OR workspace_id IS NOT NULL pour inclure les pages locales.
Simplification du frontend: le workspace tab appelle l'API standard avec workspace_id.
- Nouvel endpoint /api/library/local-workspace?workspace_id=X
interroge local_workspace_items et formate pour la Library
- Nouvel endpoint /api/library/local-workspace-children/{id}
pour l'expansion d'arborescence des items du workspace local
- Frontend: détecte workspaceId + !isGiteaActive → appelle local-workspace
- Frontend: toggleExpand route vers le bon endpoint selon source_type
- Tous les getters (flatItems, selectedCount, allSelected, emptyIcon/Title/Text)
remplacés par des propriétés + fonctions _recompute().
Les getters causent un bug connu d'Alpine 3.14.9 qui bloque l'init.
- Private tab: x-show="!workspaceId && !isGiteaActive"
(caché quand un workspace local OU distant est ouvert)
- CSS hover-only: visibility:hidden/visible au lieu de opacity (infaillible)
- Workspace tab: filtré par workspace_id quand un workspace est actif
- Tab Private remplacé par Repository (visible seulement si Gitea workspace actif)
- Nouvel endpoint /api/library/repository pour le contenu Gitea
- dashboard.py passe is_gitea_workspace, owner, repo au template
Les échappements de quotes dans le patch précédent étaient cassés
(\\' au lieu de \'), causant des erreurs de syntaxe JS qui
empêchaient l'initialisation de _wsInitData et donc d'Alpine.
- Drag handle (6 dots), checkbox, OPEN bouton intégrés dans la colonne Page name
- CSS hover-only: opacity 0 → 1 au mouseover (pas de visibilité permanente)
- Checkbox checked toujours visible (opacity:1) même sans hover
- OPEN bouton aligné à droite via margin-left:auto dans le name cell
- Double-clic sur le nom ouvre la page, simple clic = rename
- toggleExpand() force la réactivité Alpine via this.items = [...this.items]
- Colonnes drag/select/open séparées supprimées (layout Notion)
- Library: ajout /api/library/children/{id} pour charger les enfants à la demande
- Library: drag handle (.drag-handle) et checkbox (.lib-checkbox) toujours visibles
- Library: SortableJS init pour drag-and-drop réordonner les rows
- Library: toggleExpand() async avec chargement des enfants depuis l'API
- Local workspace: drag handle 6-dots + checkbox toujours visible dans les tree items
- Local workspace: renderChildren() inclut checkbox + drag handle + inline rename
- Local workspace: clic sur nom de fichier = inline rename (plus navigation directe)
- Local workspace: bouton Open pour ouvrir le fichier (double-clic aussi)
- CSS: .item-checkbox toujours visible (plus opacity:0)
- CSS: .drag-handle avec opacité .45 par défaut, 1.0 au hover
1. Hover effects fixed: converted table to div-based flexbox layout
(tr/td don't support display:flex). .lib-row now properly shows
drag handle, checkbox, and OPEN button on hover.
2. Recents filter by workspace: /api/library/recents now accepts
workspace_id parameter. Template passes active_workspace_id from
sidebar context to API calls.
3. Tree hierarchy: API now enriches items with has_children via
_enrich_children() batch query — shows expand chevrons for
pages with sub-pages.
4. Rename on click: single click on title starts inline rename
(was opening page). OPEN button still opens side peek.
5. Code cleanup: extracted _enrich_children() helper to eliminate
duplicate code across 6 endpoints.
Complete rewrite of /library page to match Notion's Library design:
1. Table view with columns: Page name (with icon), Created by (avatar),
Source, Last edited time, Last visited time
2. Hover effects showing drag handle (6 dots), checkbox, OPEN button
3. Side peek panel: opens on right, shows page content preview,
close button, favorite toggle, copy link, more menu
4. Multi-selection: checkbox per row, select-all header, 'X selected' bar
with Delete and '...' menu
5. ... menu: Remove from Recents, Copy links to all, Move to, Move to Trash
6. Inline rename: double-click title → edit field
7. 6 tabs: Recents, Favorites, Shared, Published, Private, Workspace
8. Tree expand/collapse for nested pages (has_children support)
9. + Add new row at bottom
10. Search bar toggleable
API additions:
- library.py: enhanced _build_item with icon, source_label, author
- dashboard.py: new /api/pages/{id}/content, /rename, /trash endpoints
- All 133 tests pass
Rollback des commits 2226e5e et 2534e2b — les fichiers continuent
de s'ouvrir dans page_editor.html (layout normal avec sidebar),
pas dans le viewer standalone _render_file_viewer.
The real route for /pages/{page_id} is in dashboard.py (no prefix router),
not board.py (prefix /board). Both routes now call _render_file_viewer
for content_format='file' pages.
- view_page now calls _render_file_viewer for content_format='file' pages
instead of rendering the full page_editor template (which was the old path)
- URL-encode filenames in file_url using urllib.parse.quote() to handle
spaces and special characters correctly
- Fixed in both board.py (viewer + page_data) and dashboard.py
The _render_file_viewer was dead code — defined but never called. File pages
were going through page_editor.html which rendered PDFs in an iframe embedded
in the editor UI. Now they get a clean standalone HTML viewer.
- page_editor: étoile ⭐/☆ dynamique selon favorited (x-text)
- toggleFavorite() appelle refreshFavorites() du sidebar
- base.html: refreshFavorites() fetch /api/library/favorites + rebuild DOM
- Context menu 'Add to Favorites' → refreshFavorites() au lieu de location.reload()
- Les items dynamiques ont onclick/oncontextmenu pour fonctionner hors Alpine
- _header.html: remplace click par hover (mouseenter/mouseleave)
avec timer 200ms anti-flicker, cascade sous-menus au hover
- dashboard.py: ajoute nav_workspace_id à trash, library, workspace,
gitea-workspace, local-workspace, workspaces, settings, pages/{id}
- La section du popover affiche 'Workspaces' pour Home, 'Private' pour le reste
- Les clics dans les popovers ferment le menu + naviguent
Backend:
- Added _nav_breadcrumb() to build page hierarchy chain (root → current)
- New /api/nav/menu endpoint returns workspace/folder children with has_children flag
- view_page() and view_page_root() now pass breadcrumb_items, nav_workspace_id, nav_page_id to template
Header template (_header.html):
- Replaced static breadcrumb with Alpine.js fdBreadcrumb() component
- Breadcrumb items now clickable with dropdown menus showing
Config:
- Fixed SQLite path parsing on Windows (handle drive letters without prepending /)
- Changed default theme from dark to light
- Updated OAuth test credentials (gitea_oauth_client_id/secret)
Auth:
- First real user (excluding default admin with no password) becomes admin
- Changed user count query to exclude users without password_hash
File viewer:
- Removed separate _render_file_viewer() —
For Gitea workspaces, the sidebar section now displays the actual
workspace name (e.g. '📁 Projets/Ares') instead of the generic '📁 Private'.
The workspace name is auto-created from the Gitea project's owner/repo
when the remote workspace is first opened.
Replaced global function giteaWorkspace() with Alpine.data('giteaWorkspace', ...)
registered via document.addEventListener('alpine:init', ...). This is the
recommended Alpine 3 approach that guarantees the component factory is
available before Alpine processes x-data directives.
Changed x-data="giteaWorkspace()" to x-data="giteaWorkspace" (Alpine.data
calls the factory automatically, no parentheses needed).
The <script> defining giteaWorkspace() was placed AFTER the <div x-data>
in the DOM, causing Alpine to try to evaluate giteaWorkspace() before
the function was defined. This resulted in 'Uncaught ReferenceError' for
all component properties (fileLoading, fileLanguage, showFile, etc.).
Fix: moved the <script> block to appear BEFORE the x-data div in the
HTML source, ensuring the function is defined when Alpine initializes.
The 'get sortedTreeItems()' getter caused Alpine's Proxy to fail during
component initialization, resulting in all properties being undefined
(fileLoading, fileLanguage, showFile, etc. — Uncaught ReferenceError).
Fix:
- Replaced getter with _sortTree() method + sortedTreeItems property
- _sortTree() called after treeItems is updated in loadMainTree()
- sortedTreeItems explicitly set to [] on error/empty paths
Backend:
- _sidebar_data() no longer clears workspace_pages for Gitea workspaces
The local tree stays intact in the 'Private' section
- workspace_pages is always loaded from DB (local files)
Sidebar template (base.html):
- Workspace section renamed to 'Private' when gitea_workspace is true
- New 'Repository (Gitea)' section added below, visible only for Gitea
workspaces, with its own toggle (sectionsOpen.gitea) and refresh button
- Uses #sidebar-gitea-items container for the remote file tree
Gitea workspace:
- loadSidebarTree() now targets #sidebar-gitea-items
- window._gwData exposed for sidebar refresh button
- sectionsOpen now includes gitea: true by default
Gitea workspace sidebar:
- loadSidebarTree() now uses innerHTML replacement instead of DOM
manipulation (avoids Alpine reactivity overwrites)
- Ensures workspace section is visible when tree loads
- Shows error message when Gitea API fails (no token/gateway)
- Better error handling with console.error catch
Header actions inline:
- page_editor: moved Edited/Share/Copy/Favorite/More buttons into
the unified header (right_actions), accessible via window.E
(editorState global reference set in init)
- Removed duplicate page-topbar div from content area
- Share dialog, More menu, and Activity popover stay in content
area (triggered by header buttons via window.E references)
- Gitea workspace: header now uses page-action-btn for consistency
Move to workspace:
- movePage() opens a dialog listing all workspaces via /api/workspaces
- doMove(wsId) calls PUT /api/pages/{id}/move with workspace_id
- Updated move API to accept JSON body with workspace_id for cross-workspace moves
Gitea workspace context menu:
- Right-click on any file/folder shows Rename + Delete options
- gwRename() prompts for new name, calls PUT /api/gitea/.../file
- gwDelete() confirms then calls DELETE /api/gitea/.../file
- Both trigger refreshTree() after success
Activity popover:
- 'Edited X ago' timestamp is now clickable (▾ indicator)
- Opens popover showing edited time + created date
- formatDate() helper for readable date formatting
- 'Edited X ago' now shows real relative time from page.updated_at
- timeAgo computed getter in editorState() calculates relative time
- Export now generates a Markdown file from blocks and triggers download
- blocksToMarkdown() helper converts blocks to proper Markdown syntax
(headings, lists, todos, quotes, dividers, code blocks)
- More menu items: Duplicate (was working), Export (now works),
Move to Trash (was working), Copy link (via shortcut)
- Main content area now shows file/folder tree with breadcrumb navigation
- Click folders to drill down, click files to open
- Breadcrumb shows current path with clickable segments
- Sorted tree: folders first, then files alphabetically
- refreshTree() now reloads without full page reload
- Empty state with 'New file' button when folder is empty
The unified _header.html already renders the topbar with breadcrumb. The
page_editor had a second 'page-topbar' div in the content area with the
same buttons (Share, Copy link, Favorite, More). Removed the duplicate
actions from the header, keeping them only in the content area where
they are in the correct Alpine scope (editorState()).
This fixes:
- Double header visual duplication
- Share/Copy/Favorite/More buttons now work (in editorState scope)
Backend:
- DELETE /api/local-workspace/items/{id} now soft-deletes (sets deleted_at
instead of hard DELETE) — items go to trash, not permanently gone
- New POST /api/local-workspace/items/{id}/restore to undo a delete
- _load_workspace_pages() and _load_children() now filter deleted_at IS NULL
so soft-deleted items disappear from sidebar and tree queries
Frontend:
- undoDelete() now calls the restore API per item (single or bulk)
- bulkDelete() now shows the same undo banner as context menu delete
- All delete paths (ctxMenu, bulk, modal) use the same soft-delete + undo flow
The context menu component (_ctxMenuData) was calling methods on
window._wsInitData (the raw JS object), NOT on Alpine's reactive proxy.
This meant that changes made by ctxMenuDelete/ctxMenuDuplicate (like
updating displayTree) were applied to the raw object but not reflected
in Alpine's reactive proxy that controls DOM rendering.
Fix: _ctxMenuData now uses a _ws() helper that returns window._wsData
(Alpine proxy, set in init()) with fallback to window._wsInitData for
safety. All context menu actions now go through the reactive proxy.
Replaced fragile _removeFromTree() + displayTree spread (which had Alpine
reactivity issues) with a clean _reloadAfterAction() call that reloads the
tree from the API. This is the same approach used by doDelete() and
doRename(), which work reliably. _reloadAfterAction also triggers the
sidebar refresh via window.appState.refreshSidebarTree().
Replaced the fragile event-based sidebar refresh with direct function calls:
- appState.init() now sets window.appState = this
- _reloadAfterAction(), doCreate(), deleteWithUndo() call
window.appState.refreshSidebarTree() directly (no DOM event)
- Sidebar handlers (drop, deleteWorkspacePage, wsCtxAction, newFolder)
call window._wsData._reloadAfterAction() directly (no DOM event)
- Removed event listeners for flowdeck:workspace-changed and
flowdeck:sidebar-refresh from local_workspace.html
This is more reliable than CustomEvent which had timing/scope issues.
- Sidebar 'drop' handler now calls refreshSidebarTree() + dispatches
'flowdeck:workspace-changed' instead of window.location.reload()
- Global refreshSidebarFromEvent() now targets .app-layout[x-data] first
to avoid picking up the wrong Alpine component when multiple x-data
elements exist on the page
deleteWithUndo() (called by ctxMenuDelete) was removing the node from the
local tree but never notifying the sidebar to refresh. Added dispatch of
'flowdeck:sidebar-refresh' event so the sidebar tree updates dynamically
without requiring a full page reload.
HTML entities like ' are decoded by the browser BEFORE Alpine evaluates
the expression, so the JS parser still sees a raw single quote. Backslash
escape (\') is the correct approach: the HTML parser preserves it, then
JavaScript interprets \' as an escaped quote in the string literal.
- Nouvel événement 'flowdeck:workspace-changed' dispatché depuis les actions
sidebar (deleteWorkspacePage, wsCtxAction rename/delete, newFolderInWorkspace)
- La vue principale local_workspace.html écoute 'flowdeck:workspace-changed'
et appelle _reloadAfterAction() pour rafraîchir son arbre
- _reloadAfterAction() continue à dispatcher 'flowdeck:sidebar-refresh'
pour le rafraîchissement sidebar uniquement (évite la boucle infinie)
- Correction du double confirm dans deleteWorkspacePage (skipConfirm=true
depuis wsCtxAction qui a déjà confirmé)
- Extracted render_workspace_tree macro into _workspace_tree_macro.html
so it can be imported independently from base.html (which has many
template variables like user, workspace_name, etc.)
- Fixed GET /api/sidebar/workspace-tree to use the extracted macro
- Added error logging for easier debugging
- New endpoint GET /api/sidebar/workspace-tree returns sidebar tree HTML fragment
- refreshSidebarTree() in appState() fetches and replaces #sidebar-workspace-items
- Custom event 'flowdeck:sidebar-refresh' dispatched after doCreate/doRename/doDelete
- newFolderInWorkspace, deleteWorkspacePage, wsCtxAction('rename') now use
refreshSidebarTree() instead of window.location.reload()
- Sidebar stays in sync without full page refresh
_require_gitea() ne doit plus utiliser le token admin global comme fallback
quand l'utilisateur n'a pas lié son compte Gitea. Chaque utilisateur doit
connecter son propre compte Gitea pour voir les projets.
Les endpoints /api/gitea/projects et /api/gitea/orgs retournent maintenant
401 si l'utilisateur n'a pas lié Gitea, et la page /workspaces affiche
'Connect your Gitea account' au lieu de lister les repos du admin.
_sidebar_data() prend un nouveau parametre include_workspace (default True).
La page /workspaces passe include_workspace=False pour que la sidebar
n'affiche ni le nom du workspace actif, ni ses pages/folders.
A local account without Gitea connection was seeing all Gitea repos
because the endpoint used the module-level gitea client (global admin token).
Now it checks get_user_gitea_client(request) and returns empty gitea_repos
if the user has no OAuth token for Gitea.
ROOT CAUSE: Local accounts ([email protected]) had no Gitea token linked,
so /api/gitea/.../tree returned 401 → loadGiteaTree() failed silently.
FIXES:
1. _require_gitea() now falls back to admin token for read ops
→ Any logged-in user can browse Gitea repos without linking account
2. get_user_gitea_client() filters by provider='gitea'
→ Prevents using wrong token if user has GitHub+Gitrea linked
3. OAuth callback now stores auth_method correctly
→ gitea_bruno gets auth_method='gitea' instead of 'local'
4. Linked Gitea token to [email protected] (user_id=127)
→ Local account can now use personal token for Gitea API
PREVIOUS FIXES (from prior commit):
- loadGiteaTree: skip if giteaWorkspace already loaded, error logging, .bind(this)
- gitea_workspace.html: use captured 'self' instead of querySelector('[x-data]')
- 12 test assertions updated to reflect admin fallback behavior
- loadGiteaTree: skip if giteaWorkspace component already loaded tree
- loadGiteaTree: check r.ok, add console.error logging, show error in UI
- loadGiteaTree: use .bind(this) for correct this in callbacks
- gitea_workspace.html: replace querySelector('[x-data]') with captured self
in loadSidebarTree, loadSubdir, Private Pages click handlers
- Prevents loadGiteaTree from overwriting loadSidebarTree results
on /gitea-workspace page
Root cause: ws dict from _get_active_workspace doesn't have 'workspace_key' column
→ ws['workspace_key'] → KeyError → 500 on New Page/New Folder
Fix: use ws['name'] instead (the workspace name from workspaces table)
closeSettings() now:
- Refreshes the parent page (history.back + reload)
- Handles tab navigation between settings sections
- Falls back to /workspaces redirect if no history
1. Settings X button: closeSettings() goes back, skipping hash-only navigation
→ No more double-click to close
2. Workspaces page: Connect Gitea link now includes &mode=link
→ Same link as Settings → Integrations (was missing mode=link)
- Local login JS: window.location='/workspaces' instead of '/'
- Root route /: if no workspace exists, redirect to /workspaces
- Existing users with workspaces: still go to /local-workspace
Root cause: _sidebar_data reads cookie from REQUEST, but cookie is set on RESPONSE
→ first visit: cookie empty → gitea_workspace=False → tree doesn't load
Fix: gitea_workspace_page ctx always sets gitea_workspace=True
Also passes gitea_owner/gitea_repo for Alpine tree loading
Root cause: request.session cookie expires during Gitea OAuth redirect
→ oauth_mode lost → link mode falls through to login mode
→ Creates gitea_bruno user instead of linking to local account
Fix: state now carries mode suffix (state:mode)
Callback recovers mode from state parameter even if session lost
Allows full session loss but still correctly enters link mode
Before: oauth_mode stored only in request.session cookie
→ Lost if session expires during Gitea OAuth redirect
→ Link mode falls through to login mode → creates gitea_bruno user
After: state format: <random>:<mode> (e.g., abc123:link)
→ Mode survives session cookie loss
→ Link mode correctly links to current local user
- Stale numeric workspace cookie from another user now rejected
- workspace_pages only loaded if owner_id matches current user
- Prevents sidebar tree leak of other users' content
- _get_active_workspace now verifies workspace owner matches current user
- Fallback: only picks workspace owned by current user (not any user)
- /local-workspace redirects to /workspaces when no workspace exists
- New users no longer see other users' workspaces/projects
- private_pages now queried from DB when gitea_workspace=True
- Pages with parent_section='Private' and workspace_id=mirror_ws_id
- Shown in sidebar Private section alongside remote 🔗 tree
- Opening /gitea-workspace auto-creates a local workspace with same name
- New Page/New Folder in remote context → saves to local mirror workspace
- Sidebar stays focused on remote workspace (🔗 icon, remote tree)
- local_ws_id passed to Alpine for API calls
- /api/local-workspace/items accepts workspace_id in body
Bug 1: newPageInWorkspace() allait toujours vers local-workspace API
→ Maintenant détecte workspaceKey contient '/' → Gitea API PUT file
Bug 2: workspace_name dans gitea_workspace ne persistait pas
→ Cookie flowdeck_workspace posé côté serveur au premier chargement
Bug 3: icône 📁 fixe dans sidebar — pas d'indication remote
→ 🔗 affiché quand workspace_key contient '/' (remote), 📁 sinon
newFolderInWorkspace() : avertissement si workspace distant
(car les dossiers n'existent pas dans un repo Git)
- publishPage() → POST /api/pages/{id}/publish
- unpublishPage() → DELETE /api/pages/{id}/publish
- shareInvite() → POST /api/pages/{id}/share
- loadShares() → GET /api/pages/{id}/shares
- removeShare() → DELETE /api/pages/{id}/share/{sid}
- All buttons now call real API instead of local state toggle