Compare commits

...
Author SHA1 Message Date
bruno 10cbd7f52e feat(v4.2.0): Database Templates & Dashboards
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Enhance page_templates: add description, is_recurring, recurrence_rule columns
- New table collection_dashboards for widget-based dashboard layouts
- API: PUT/DELETE /workspace/collections/{id}/templates/page/{tid} — template CRUD
- API: CRUD /workspace/collections/{id}/dashboards (create/list/update/delete)
- Dashboard layout_json supports columns + widgets with view_id/x/y/w/h
- 6 new tests (159 total)
2026-07-21 22:09:48 -04:00
bruno ec96619341 docs: mark v4.1.0 as completed in ROADMAP
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
2026-07-21 21:56:31 -04:00
bruno d7c1f4c03e feat(v4.1.0): Data Sources & Linked Databases
- Add collection_data_sources table for multi-source collections
- API: GET/POST/DELETE /db/{id}/sources/api — data source management
- API: POST /db/{id}/linked/api — create linked database (copies views/properties)
- API: POST /db/{id}/toggle-inline/api — toggle full-page vs inline
- API: POST /db/inline/api — create inline database
- Linked DB inherits workspace_id (permissions) from source
- Migration: add workspace_id + created_by to collections table
- 10 new tests (153 total)
2026-07-21 21:55:45 -04:00
bruno 3fcfa8fdbb style: bouton New page → icône document+plus (même que sidebar)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-21 21:30:55 -04:00
bruno ce2bae1f1b feat: fichier filter dropdown + fonctionnel sur toutes les vues
FlowDeck CI / test (push) Failing after 12s
FlowDeck CI / docker (push) Has been skipped
- Dropdown Filter dans la toolbar (icône filtre, à côté de Sort)
- Menu avec Folders, Pages, PDF, Images, Code, Text + Clear filter
- doFilter() applique maintenant filterType au tree view via _filterTree()
- Filter chips visibles dans toutes les vues comme indicateur
- L'enhanced table prend le relais quand un filtre est actif dans les vues non-tree

143 tests passent
2026-07-21 20:47:32 -04:00
bruno 85b4d624b8 refactor: local-workspace UI → Library-style
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
CSS:
- Ajouté: ws-container, ws-tabs-row, ws-tab, ws-icon-btn, ws-btn-primary
- Ajouté: ws-dropdown, ws-search-bar, ws-select-bar, ws-row
- Ajouté: row-name, row-icon, row-title, hover-only, ws-checkbox
- Ajouté: tree-chevron, btn-open, more-menu (Library-style)
- Conservé: ws-tree, ws-tree-item, ws-act, breadcrumb-row pour compatibilité

Navbar:
- Breadcrumb + toolbar fusionnés en une seule row propre
- Dropdowns style Library (View, Sort)
- Search toggle avec barre coulissante
- Bulk bar → ws-select-bar avec icônes SVG

Context menu:
- .ctx-menu → .more-menu (style Library)
- Toutes les refs JS mises à jour

143 tests passent
2026-07-21 15:53:36 -04:00
bruno ef8d4e6bca fix: page not found → redirect workspaces (dashboard.py manquait)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-21 14:07:48 -04:00
bruno 27352c84e5 feat: 404 pages → redirect to /workspaces
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- main.py: @app.exception_handler(404) → RedirectResponse(/workspaces)
- dashboard.py: page_detail 404 → RedirectResponse(/workspaces)
- tests: test_styled_404_page → 302 redirect
- API paths (containing /api) still get JSON 404, not redirect
- 143 tests passent
2026-07-21 13:51:31 -04:00
bruno bf329a4ba0 fix: PDF viewer — URL encode filename + error fallback
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- dashboard.py: quote(filename) dans file_url (espaces/caractères spéciaux)
- page_editor.html: iframe PDF → onerror fallback avec lien Open in new tab
- 143 tests passent
2026-07-21 13:41:34 -04:00
bruno b505cfc156 fix: sidebar workspace section → + external-link to /local-workspace
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
Maintenant au bon endroit: base.html sidebar-section-actions (L229-233)
Pas dans local_workspace.html tree (où il n'était pas visible).
143 tests passent
2026-07-21 13:24:44 -04:00
bruno 3079d53cab fix: sidebar root external-link → href static (pas Alpine :href)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
Alpine ne résolvait pas wsId dans le scope x-for.
Maintenant: href='/local-workspace' (route qui utilise le cookie ws actif).
+ hard refresh nécessaire (Ctrl+Shift+R) pour vider cache ancien JS.
143 tests passent
2026-07-21 11:59:05 -04:00
bruno d3bada54f0 feat: sidebar tree — add external-link icon to /local-workspace
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- local_workspace.html: hover actions (folder) → + external-link → /local-workspace
- Ajouté dans JS renderChildren ET template x-for
- 143 tests passent
2026-07-21 11:54:12 -04:00
bruno eac6e5aed3 fix: 2 context menu width fixes
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
- base.html showContextMenu: edge detection (menu never overflows right/bottom)
- app.css .more-menu: min-width:200px → unset + width:max-content
- 143 tests passent
2026-07-21 11:25:36 -04:00
bruno 55853db625 fix: library empty state → outline SVG + 19 new JS getSvgIcon icons
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- library.html L342: x-text→x-html + getSvgIcon(emptyIcon,48)
- base.html getSvgIcon: +19 icons: link, lock, globe, edit, clock, users,
  book, settings, tag, bar-chart, grid, align-left, calendar, bot, refresh,
  inbox, help-circle, check-square, plus
- 143 tests passent
2026-07-21 11:15:52 -04:00
bruno fd1a4319b4 fix: library row icons → outline SVG + context menu width
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- library.html L591: item.icon (emoji) → self._renderIcon('file')
- library.html: +_renderIcon() helper with validNames filter
- app.css: context-menu min-width:200px → width:max-content + max-width:240px
- 143 tests passent
2026-07-21 11:02:25 -04:00
bruno b818bc908f fix: _header page_icon rendered word → SVG icon
FlowDeck CI / test (push) Failing after 6s
FlowDeck CI / docker (push) Has been skipped
_header.html L54: {{ page_icon }} affichait le mot 'home'/'file'/'paperclip'
au lieu de l'icône SVG. Maintenant détecte les noms connus → fd_icon().
Fallback pour les valeurs non-reconnues: affiche tel quel.
143 tests passent
2026-07-21 10:22:33 -04:00
bruno 8a94c3b676 fix: Alpine x-text error — fd_icon SVG double-quotes broke JS expression
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
page_editor.html L5: le bouton star utilisait x-text avec fd_icon('star',14)
contenant des guillemets doubles SVG → Expression Error.
→ x-html + getSvgIcon('star',14) (JS, pas de guillemets dans l'expression)
→ share/lock/link: utilisent déjà la concaténation ~ (safe)
143 tests passent
2026-07-21 10:11:21 -04:00
bruno 53d31572e6 fix: sidebar workspace tree + pages DB — emojis → outlined icons
Root cause: 3 sources d'icônes colorées dans le sidebar:
1. _workspace_tree_macro.html L28: {{ page.icon }} raw → filtré via valid_icons
2. db.py: DEFAULT '📄' → 'file'
3. board.py _file_icon(): 15 émojis → 'file'/'edit'/'image'
4. board.py/dashboard.py: JSON icon: 📄/📁 → file/folder
5. base.html L931: JS favorites icon → validNames filter
6. base.html render_tree_item: valid_icons list étendue + fallback

Migration: nouvelles pages utilisent 'file'/'folder'. Pages existantes
avec émojis → les templates utilisent le fallback automatiquement.
143 tests passent
2026-07-21 09:28:28 -04:00
bruno 3fcc12ad8c feat: sweep complet — tous les émojis → outline SVG icons
Templates modifiés (22 fichiers):
- _icons.html: +14 new icons (paperclip, external-link, sparkles, lightbulb, tag,
  file-text, save, upload, trending-up, zap, alert-triangle, user, eye-off)
- base.html: +8 JS icons, 🦎🐙🔑🔗👁📋🔲📑❓⚠️→SVG
- page_editor.html: 43→0 émojis (📎📄🔒🔗⭐📋📁✨📝🗄📑📊📅🗓🖼📥🌐✏💬💡 etc)
- settings.html: 30→0 (⚙️📋🏷🧩👥💾🙈👁🔒🌙☀️🌳📊📝⚠🦎✅🐙🔗✏🗑)
- local_workspace.html: 25→0 (📄📁✏🗑⚠📋➕)
- gitea_workspace.html: 25→0 (🔗📄📤🔄⚙📁📄🔒✏🗑💾)
- board.html: 13→0 (📁📊📋☰📈👥✕✓🗑⚡🔍)
- workspace.html: 11→0 (🏠🔑⚙📁🔗📂🐙)
- landing.html: 8→0 (📚🚀📝📊🦎🌐🔒⚡)
- trash.html, table_view, dashboard, accounts, card_detail, public_page,
  workspaces, team_load, notes, _header, detailed_board, card, board_fragment
- Ajouté import _icons.html aux fragments standalone
- 143 tests passent
2026-07-21 08:27:10 -04:00
bruno b88a972531 fix: 4 colored JS icons in /local-workspace hover → outline SVG
- 📄 New file → getSvgIcon('file',14)
- 📁 New folder → getSvgIcon('folder',14)
- ✏️ Rename → getSvgIcon('edit',14)
- 🗑 Delete → getSvgIcon('trash',14)
- 143 tests passent
2026-07-21 08:10:30 -04:00
bruno 3d10400f8f fix: workspace cards edit/delete + remaining workspaces emojis
- ✏️→edit SVG (card rename button)
- 🔗→link SVG (Gitea section title, Connect button)
- 🔍→removed (search placeholder)
- 🔒→lock SVG (private project indicator)
- 143 tests passent
2026-07-21 08:03:31 -04:00
bruno 9e45ea8870 feat: version dynamique via fichier VERSION
- Fichier VERSION à la racine (4.0.3)
- _get_app_version() avec cache dans dashboard.py
  - Lecture depuis VERSION (dev) ou /app/VERSION (Docker)
  - Fallback '0.0.0' si fichier absent
- board.py: import _get_app_version depuis dashboard
- Les deux _sidebar_data() remplacent '4.0.3' par _get_app_version()
- Déploiement: changer VERSION → mettre à jour sans toucher le code
- 143 tests passent
2026-07-21 07:58:24 -04:00
bruno d94dd3da99 fix: workspaces topbar 🔑⚙ → outline SVG key+settings
- _icons.html: +key icon (Lucide style)
- getSvgIcon JS: +key (matching SVG)
- workspaces.html: 🔑 Login → fd_icon('key'), ⚙ Settings → fd_icon('settings')
- 143 tests passent
2026-07-21 07:55:46 -04:00
bruno aa434e9fe9 fix: derniers émojis — workspaces 🏠, hover actions, sidebar context menu
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
- workspaces.html: 🏠→home SVG (page_icon, header)
- local_workspace.html: ✏️🗑 ✕→SVG (rename, delete, clear buttons, rename modal)
- base.html: ✏️🗑️📁📄→SVG (context menus Rename/Delete/New File/Folder)
  + pageIcon default '📁'→'folder', modal title '📁 New Folder'→SVG
- 143 tests passent
2026-07-20 23:13:01 -04:00
bruno 45911438f0 fix: Home button pointe vers workspace par défaut au lieu de /workspaces
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Si local_workspaces existe: redirige vers /local-workspace?ws=<premier id>
- Sinon (fallback): redirige vers /workspaces
- 143 tests passent
2026-07-20 22:48:56 -04:00
bruno 5ac8dd10b2 fix: ajout '..' parent folder dans vues List, Details, Cards, Content
FlowDeck CI / test (push) Failing after 16s
FlowDeck CI / docker (push) Has been skipped
- List (compact): rangée '..' avec icône folder avant les données
- Details (enhanced): rangée '..' avec toutes les colonnes
- Cards (title grid): carte '..' dans la grille
- Content (content list): élément '..' avec preview 'Parent folder'
- x-show='currentFolder > 0' pour n'afficher que dans un sous-dossier
- 143 tests passent
2026-07-20 22:47:35 -04:00
bruno eca4067c82 fix: view button 'list List' duplicate + 📁 Folder dans colonne Type
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- View button: supprimé x-text='viewMode' (double affichage 'listList')
- Col. Type: 📁 Folder → Folder (details, compact, enhanced table, preview panel)
- _fileTypeLabel: déjà clean (Page/PDF/Code/Text/... sans emoji)
- 143 tests passent
2026-07-20 22:11:52 -04:00
bruno fff502a700 fix: /local-workspace — final emoji sweep + view menu icons
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- _fileIcon(): 📜→getSvgIcon('file') (JS/TS + code languages)
- Breadcrumb: ☰🏠←→📁✏→ SVG (chevron-right, home, chevron-left/right, folder, edit)
- View menu: +5 outline icons (folder, list, bar-chart, grid, align-left)
- View button: 🌳→viewMode text only
- Filter chips: 📄→getSvgIcon('file')
- Parent folder: 📂→folder SVG
- Context menu: 📄📂✏📋→file, folder, edit, copy SVG
- Drop overlay: 📥→download SVG
- _icons.html: +chevron-left, chevron-right, list, bar-chart, grid, align-left, corner-down-right, copy
- 143 tests passent
2026-07-20 21:56:46 -04:00
bruno fbd4b836f7 fix: /local-workspace SVG rendering + ALL emoji icons converted
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- BUG: x-text→x-html pour _fileIcon() (SVG s'affichait en texte brut)
- _fileIcon(): 11 types de fichiers → SVG (🐍📕🖼️📜🌐🎨🗃️💻⚡📦📄)
- title-card: x-text '📁' → x-html getSvgIcon('folder',24)
- preview empty states (PDF): 📕 → getSvgIcon('file')
- toolbar: 🔍📄📁🗑 → SVG icons
- filter chips: 📁📝📕🖼️📜 → getSvgIcon()
- view menu: 🌳📋📊🏷️📝 → text-only (clean)
- _icons.html: +image, +download
- getSvgIcon JS: +image, +download, +search, +home
- 143 tests passent
2026-07-20 21:39:40 -04:00
bruno b835dd6b5e fix: WORKSPSACES section + remaining emoji icons replaced
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- base.html: séparateur entre version et Log out, _local_workspaces_for_user
- board.py: _local_workspaces_for_user helper, app_version, fix emoji icons
- dashboard.py: _local_workspaces_for_user helper
- library.html: 📚📁📄🕒⭐👥🌐🔒📦 → SVG + icon map
- workspaces.html: 📁🔍🗑📝 → SVG
- local_workspace.html: page_icon, empty states, icon functions → SVG
- settings.html: 👤🔔 → SVG nav icons
- _icons.html: +'bell' icon
- CSS: .nav-icon-inline, .empty-state-icon
- 143 tests passent
2026-07-20 20:58:02 -04:00
bruno 5372f4f22f feat: outline SVG icons + version + workspaces in user menu
FlowDeck CI / test (push) Failing after 12s
FlowDeck CI / docker (push) Has been skipped
- _icons.html: 25 outline SVG icons (Feather/Lucide style, stroke=currentColor)
- base.html: toutes les icônes emoji remplacées par {{ fd_icon() }}
  - Sidebar sections, empty states, user menu, footer links, context menu
  - JS getSvgIcon() helper pour rendu dynamique
- User menu: ajout version (FlowDeck v4.0.3) + liste workspaces locaux
  - Entre Switch workspace et Log out, avec ✓ sur le workspace actif
- dashboard.py: _sidebar_data enrichi (app_version, local_workspaces)
- CSS: .page-icon-svg, .um-version, .um-section-label, .um-item svg
- Dark + light theme compatible (stroke:currentColor)
- 143 tests passent
2026-07-20 20:18:50 -04:00
bruno 39b485622d feat: CSRF token auto-refresh après expiration session
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- app/main.py: GET /api/csrf-token → retourne un token frais JSON + cookie
- app/middleware/csrf.py: /api/csrf-token ajouté aux EXCLUDED_PATHS
- app/templates/base.html:
  - FlowDeck.refreshCsrfToken() → appelle /api/csrf-token
  - FlowDeck.csrfFetch() → wrapper fetch avec auto-refresh sur 403 CSRF
  - Si un POST/PUT/DELETE reçoit 403 'CSRF', refresh automatique + retry
- ROADMAP: item CSRF token refresh marqué ✅
- 143 tests passent
2026-07-20 19:32:43 -04:00
bruno aba771400d docs: fusionner ROADMAP v3.0 (docs/) → ROADMAP racine + supprimer ancien
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- docs/ROADMAP.md: 16 éléments utiles manquants détectés et ajoutés
- Nouvelle section v5.2.0 Infrastructure & Polish (design tokens, API tokens,
  sessions, onboarding wizard, migrations Alembic, backup, linting, CI/CD)
- Duplication v5.2.0 corrigée → v5.3.0 Database Avancée
- docs/ROADMAP.md supprimé (redondant, tout est dans le ROADMAP racine)
2026-07-20 16:34:02 -04:00
bruno 2ca27d3398 docs: NOTION_DATABASE_TASKS_GUIDE.md v2.0 + ROADMAP Database/Views/Tasks
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
- NOTION_DATABASE_TASKS_GUIDE.md: réécriture complète (822→~750 lignes)
  - 16 sections: anatomie DB, 22 propriétés, relations/rollups, formules
  - 10 Database Views détaillées, filtres/tris/groupes
  - Data Sources & Linked Databases, Templates, Dashboards
  - Tasks, Sub-items, Dependencies, Sprints, My Tasks
  - Modèle de données complet (tables existantes + 5 nouvelles)
  - Plan d'implémentation en 5 phases (v4.1–v4.5)
- ROADMAP: v4.1–v4.5 redéfini = Database/Views/Tasks
  - v4.1: Data Sources & Linked
  - v4.2: Templates & Dashboards
  - v4.3: 10 Database Views complètes
  - v4.4: Tasks, Sub-items & Dependencies
  - v4.5: Sprints & My Tasks
  - Versions suivantes renumérotées (v4.6→v6.0)
- Sources: Guide_Complet_Notion_database.md + 15 URLs docs officielles Notion
2026-07-20 15:58:52 -04:00
bruno 9422753e8b Add Notion databases and views guide
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-20 15:48:11 -04:00
bruno 3a94b06af4 docs: ROADMAP — v4.7.0 FlowDeck Agent (IA native complète)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Remplace l'entrée 'AI Assistant' minimale par FlowDeck Agent détaillé
- 6 sections: Concept, Fonctionnalités, Architecture, Tables, Cas d'usage, Migration
- 10+ outils actionnables, boucle ReAct, SSE streaming, multi-LLM
- 5 phases de migration, 6 nouvelles tables DB
- Références: Flowdeck_Agent_integration.md + Guide_Complet_Notion_Agent_2026.md
- Résumé des phases mis à jour
2026-07-20 14:56:27 -04:00
bruno b0d222b81a Add Notion Agent and FlowDeck integration docs
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
Document Notion Agent (2026) concepts and a FlowDeck Agent design
that orchestrates tool calls through existing FastAPI routers, plus
a chat UI screenshot.
2026-07-20 14:38:42 -04:00
bruno 0ea447ee6f docs: ARCHITECTURE.md v4.0 — mise à jour complète
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Version 3.0 → 4.0, date 2026-07-20
- Templates: reflète la structure actuelle (base, _header, _workspace_tree_macro, etc.)
- Routers: ajoutés (local_workspace, library, private, public_api, admin, workspace)
- Data layer: nouvelles tables v4.0 (page_shares, recents, tags, gitea_private_pages)
- Nouvelle section 5: Système d'authentification (3 types de comptes, sidebar behavior, cookie flowdeck_workspace)
- Layout diagram: sidebar Notion complet avec toutes les sections
- Section 16: Versions à venir (v4.1.0 → v5.0.0)
- Nettoyage références obsolètes
2026-07-20 13:27:13 -04:00
bruno 63773cb904 fix: 4 correctifs UX — workspace +, library empty, settings spacing
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Has been skipped
- Workspaces: bouton bleu '+' maintenant centré, 48px, min-height 140px (plus coupé)
- Library no-workspace: 'Open a Workspace...' avec lien Go to Workspaces au lieu de Loading
- Library: boutons '+ Add new' et 'New page' cachés quand pas de workspace
- Settings: espacement bouton Upload photo (margin-bottom, margin-top augmentés)
- 143 tests passent
2026-07-20 11:21:14 -04:00
bruno ec3ad6b1de fix: aligner Search à droite dans la nav sidebar
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
2026-07-20 11:07:35 -04:00
42 changed files with 4394 additions and 1454 deletions
+208 -57
View File
@@ -1,7 +1,8 @@
# Architecture FlowDeck — Document Complet v3.0 # Architecture FlowDeck — Document Complet v4.0
> **Version:** 3.0 · **Date:** 2026-07-14 · **Auteur:** Hermes-Deepin > **Version:** 4.0 · **Date:** 2026-07-20 · **Auteur:** Hermes-Deepin
> **Cible:** Clone Notion intégré à Gitea/GitHub — multi-comptes, multi-intégrations > **Cible:** Clone Notion intégré à Gitea/GitHub — multi-comptes, multi-intégrations
> **Version déployée:** v4.0.x (production : https://flowdeck.dracodev.net)
--- ---
@@ -47,34 +48,31 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
│ │ │ │
│ ┌─────────────────────────────────────────────────────────┐ │ │ ┌─────────────────────────────────────────────────────────┐ │
│ │ Templates Jinja2 (SSR) │ │ │ │ Templates Jinja2 (SSR) │ │
│ │ ├─ dashboard.html — Projets + sidebar │ │ │ │ ├─ base.html — Layout Notion commun (sidebar) │ │
│ │ ├─ _header.html — Topbar + breadcrumbs │ │
│ │ ├─ _workspace_tree_macro.html — Macro arbre sidebar │ │
│ │ ├─ landing.html — Landing page non-auth │ │
│ │ ├─ workspaces.html — Page Workspaces (locale/Gitea) │ │
│ │ ├─ library.html — Bibliothèque multi-onglets │ │
│ │ ├─ local_workspace.html — Workspace local (explorer) │ │
│ │ ├─ page_editor.html — Éditeur de blocs │ │
│ │ ├─ trash.html — Corbeille (local only) │ │
│ │ ├─ settings.html — Settings / My Account / Admin │ │
│ │ ├─ accounts.html — Gestion comptes admin │ │
│ │ ├─ board.html — Kanban (wrapper) │ │ │ │ ├─ board.html — Kanban (wrapper) │ │
│ │ ├─ board_fragment.html — Cartes Kanban │ │ │ │ ├─ board_fragment.html — Cartes Kanban │ │
│ │ ├─ table_view.html — Vue table │ │ │ │ ├─ table_view.html — Vue table │ │
│ │ ├─ calendar_view.html — Vue calendrier (v1.6) │ │ │ │ ├─ login.html — Login/Register (local + OAuth) │ │
│ │ ├─ timeline_view.html — Vue Gantt (v1.6) │ │ │ │ ├─ public_page.html — Page publiée (/p/slug) │ │
│ │ ├─ gallery_view.html — Vue galerie (v1.6) │ │ │ │ └─ (legacy: dashboard, detailed_board, card_detail, etc.) │ │
│ │ ├─ list_view.html — Vue liste (v1.6) │ │
│ │ ├─ status_overview.html — Donut SVG │ │
│ │ ├─ team_load.html — Barres stacked │ │
│ │ ├─ detailed_board.html— Board détaillé │ │
│ │ ├─ card_detail.html — Modale détail carte │ │
│ │ ├─ card.html — Carte individuelle │ │
│ │ ├─ page_editor.html — Éditeur de blocs │ │
│ │ ├─ my_tasks.html — Dashboard unifié (v1.9) │ │
│ │ ├─ library.html — Bibliothèque de pages │ │
│ │ ├─ trash.html — Corbeille │ │
│ │ ├─ accounts.html — Gestion comptes │ │
│ │ ├─ notes.html — Notes Markdown │ │
│ │ └─ base.html — Layout commun │ │
│ └─────────────────────────────────────────────────────────┘ │ │ └─────────────────────────────────────────────────────────┘ │
│ │ │ │
│ ┌─────────────────────────────────────────────────────────┐ │ │ ┌─────────────────────────────────────────────────────────┐ │
│ │ JavaScript (CDN, pas de build step) │ │ │ │ JavaScript (servi localement, pas de CDN externe) │ │
│ │ ├─ HTMX 1.9 — AJAX sans JS │ │
│ │ ├─ Alpine.js 3.14 — Interactivité légère │ │ │ │ ├─ Alpine.js 3.14 — Interactivité légère │ │
│ │ ├─ HTMX 1.9 — AJAX sans JS (legacy) │ │
│ │ ├─ SortableJS 1.15 — Drag & drop │ │ │ │ ├─ SortableJS 1.15 — Drag & drop │ │
│ │ └─ highlight.js — Syntax highlighting (v2.0) │ │ │ │ └─ Prism.js — Syntax highlighting │ │
│ └─────────────────────────────────────────────────────────┘ │ │ └─────────────────────────────────────────────────────────┘ │
│ │ │ │
│ ┌─────────────────────────────────────────────────────────┐ │ │ ┌─────────────────────────────────────────────────────────┐ │
@@ -95,17 +93,27 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
│ │ │ │
│ ┌─────────────────────────────────────────────────────────┐ │ │ ┌─────────────────────────────────────────────────────────┐ │
│ │ ROUTERS │ │ │ │ ROUTERS │ │
│ │ ├─ dashboard.py — / Dashboard │ │ │ │ ├─ main.py — FastAPI app, lifespan, CORS, 404 │ │
│ │ ├─ dashboard.py — /, /workspaces, /library, /help │ │
│ │ │ — /trash, /settings, /accounts │ │
│ │ │ — /p/{slug} (pages publiques) │ │
│ │ │ — _sidebar_data() — données sidebar │ │
│ │ ├─ board.py — /board/{o}/{r} Board + vues │ │ │ │ ├─ board.py — /board/{o}/{r} Board + vues │ │
│ │ │ — /board/api/... APIs board │ │ │ │ │ — /board/api/pages CRUD pages │ │
│ │ │ — /board/api/trash Trash │ │
│ │ ├─ api.py — /api/... CRUD + sync │ │ │ │ ├─ api.py — /api/... CRUD + sync │ │
│ │ ├─ notes.py — /notes/{o}/{r} Notes │ │ │ │ ├─ auth.py — /auth/... OAuth2 + local │ │
│ │ ├─ auth.py — /auth/... OAuth2 │ │ │ │ ├─ local_workspace.py— /api/local-workspace CRUD + upload │ │
│ │ ├─ webhooks.py — /webhooks/... Gitea hooks │ │ │ │ ├─ my_tasks.py — /my-tasks Dashboard │ │
│ │ ├─ my_tasks.py — /my-tasks (v1.9) │ │ │ │ ├─ library.py — /api/library/* API bibliothèque│ │
│ │ ├─ pages.py — /pages/... Pages CRUD │ │ │ │ ├─ pages.py — /pages/... Pages CRUD │ │
│ │ ├─ collections.py — /db/... Collections │ │ │ │ ├─ collections.py — /db/... Collections │ │
│ │ └─ editor.py — /api/editor/... Block editor │ │ │ │ ├─ editor.py — /api/editor/... Block editor │ │
│ │ ├─ private.py — /api/private/* Section privée │ │
│ │ ├─ public_api.py — /api/public/* Public API │ │
│ │ ├─ workspace.py — Workspaces API + Gitea projets │ │
│ │ ├─ webhooks.py — /webhooks/... Gitea hooks │ │
│ │ └─ admin.py — /api/admin/* Admin users │ │
│ └─────────────────────────────────────────────────────────┘ │ │ └─────────────────────────────────────────────────────────┘ │
│ │ │ │
│ ┌─────────────────────────────────────────────────────────┐ │ │ ┌─────────────────────────────────────────────────────────┐ │
@@ -138,6 +146,11 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
│ │ │ ├─ property_values (legacy → collection_pages) │ │ │ │ ├─ property_values (legacy → collection_pages) │
│ │ │ ├─ ai_keywords │ │ │ │ │ │ ├─ ai_keywords │ │ │
│ │ │ ├─ pages │ │ │ │ │ │ ├─ pages │ │ │
│ │ │ ├─ page_shares (v4.0) │ │ │
│ │ │ ├─ recents (v4.0) │ │ │
│ │ │ ├─ tags (v4.0) │ │ │
│ │ │ ├─ page_tags (v4.0) │ │ │
│ │ │ ├─ gitea_private_pages (v4.0) │ │ │
│ │ │ ├─ users │ │ │ │ │ │ ├─ users │ │ │
│ │ │ ├─ user_tokens │ │ │ │ │ │ ├─ user_tokens │ │ │
│ │ │ ├─ workspaces (v2.0) │ │ │ │ │ │ ├─ workspaces (v2.0) │ │ │
@@ -685,42 +698,146 @@ POST /favorites → Ajouter favori
DELETE /favorites/{id} → Retirer favori DELETE /favorites/{id} → Retirer favori
``` ```
### 4.6 Routes Sidebar & Library (v4.0)
```
GET /library → Bibliothèque dynamique
GET /api/library/recents → Pages récentes
GET /api/library/favorites → Favoris
GET /api/library/shared → Pages partagées
GET /api/library/published → Pages publiées
GET /api/library/private → Pages privées
GET /api/library/workspace → Pages du workspace
GET /api/local-workspace/items → Arborescence workspace
POST /api/local-workspace/items → Créer page/dossier
PUT /api/local-workspace/items/{id} → Renommer
DELETE /api/local-workspace/items/{id} → Soft delete
POST /api/local-workspace/upload → Upload fichier
GET /api/files/{ws_id}/{filename} → Servir fichier uploadé
GET /help → Page d'aide complète
GET /api/public/pages/{slug} → Page publique
```
### 4.7 Routes Share & Publish (v4.0)
```
POST /api/pages/{id}/share → Partager une page
DELETE /api/pages/{id}/share/{share_id} → Retirer partage
GET /api/pages/{id}/shares → Liste partages
POST /api/pages/{id}/publish → Publier page
DELETE /api/pages/{id}/publish → Dépublier
```
--- ---
## 5. Frontend ## 5. Système d'authentification (v4.0)
FlowDeck supporte **3 méthodes d'authentification** et la **connexion d'intégrations multiples** à un même compte local.
### 5.1 Types de comptes
| Type | Auth | Workspaces | Intégrations |
|------|------|------------|--------------|
| **Local pur** | email + password | Locaux uniquement | Aucune |
| **Local + intégrations** | email + password | Locaux + distants | Gitea et/ou GitHub |
| **OAuth pur** | Gitea ou GitHub | Distants uniquement | Non-déconnectable |
### 5.2 Comportement du sidebar selon le type de compte
```
LOCAL PUR:
├─ 📁 [nom workspace local]
├─ 📅 Meetings
├─ 🕒 Recents
├─ ⭐ Favorites
├─ 🤖 Agents
├─ 👥 Shared ← visible si pages partagées
├─ 🌐 Published ← visible si pages publiées
├─ (Private caché)
└─ 📚 Library / ✅ My Tasks / 🗑️ Trash / ❓ Help
LOCAL + GITEA/GITHUB:
├─ 🔗 [owner/repo] ← workspace distant actif
├─ 📅 Meetings
├─ 🕒 Recents
├─ ⭐ Favorites
├─ 🤖 Agents
├─ 👥 Shared
├─ 🌐 Published
├─ 🔒 Private ← visible (fichiers locaux liés au projet distant)
└─ 📚 / ✅ / 🗑️ / ❓
OAUTH PUR (GITEA/GITHUB):
├─ 🔗 [owner/repo] ← identité = compte Gitea/GitHub
├─ 🕒 Recents
├─ ⭐ Favorites
├─ 👥 Shared
├─ 🌐 Published
├─ (Private caché)
└─ 📚 / ✅ / ❓ (Trash caché — pas de workspace local)
```
### 5.3 Règles d'affichage
- **Section Private** : visible UNIQUEMENT si compte local + workspace distant actif
- **Trash** : visible si `auth_method == 'local'` ou si des pages locales existent
- **Boutons New File/New Folder** : cachés si `has_active_workspace == False`
- **Message "No workspace open"** : affiché dans la section Workspace si aucun workspace actif
- **Header sidebar** : toujours le compte local si auth_method=local, même avec intégrations
### 5.4 cookie `flowdeck_workspace`
- `""` → aucun workspace actif
- `"42"` → workspace local ID 42
- `"gitea:owner:repo"` → workspace distant Gitea
- Nettoyage automatique si cookie invalide (workspace supprimé ou autre user)
---
## 6. Frontend
### 5.1 Layout ### 5.1 Layout
``` ```ascii
┌──────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────┐
│ TOPBAR (44px, sticky) │ │ TOPBAR (unified-header) │
│ ┌──────────┬──────────────────────────────────┬────────────┐ │ │ ┌──────┬───────────────────────────────────────┬───────────┐ │
│ │ Logo │ Breadcrumbs: WS > Collection │ Share... │ │ │ │ «» │ Breadcrumbs: 🏠 Workspaces │ Share ⚙ │ │
│ │ FlowDeck │ (ou WS > Collection > Page) │ Settings │ │ │ └──────┴───────────────────────────────────────┴───────────┘ │
│ └──────────┴──────────────────────────────────┴────────────┘ │
├────────────┬─────────────────────────────────────────────────┤ ├────────────┬─────────────────────────────────────────────────┤
│ SIDEBAR │ CONTENU PRINCIPAL │ │ SIDEBAR │ CONTENU PRINCIPAL │
│ (240px) │ │ │ (240px) │ │
│ │ ┌─────────────────────────────────────────────┐│ │ │ ┌─ Help Page ────────────────────────────────┐│
│ Workspace │ │ DATABASE VIEW ││ │ ┌────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ││
│ header │ │ ┌──────┬──────┬──────┬──────┬──────────┐ ││ │ │ Header │ │ │ │ 🚀 Getting │ │ 📝 Pages │ (...) ││
│ │ │ │Title │Statut│Priori│Assign│Due Date │ ││ │ │ [D] │ │ │ │ Started │ │ & Editor │ ││
│ 🔍 Search │ │ ├──────┼──────┼──────┼──────┼──────────┤ ││ │ │ Draco │ │ │ └──────────────┘ └──────────────┘ ││
│ │ │ │Tâche1│Todo │ P1 │Bruno │15 juil │ ││ │ └────────┘ │ └─────────────────────────────────────────────┘│
│ ▼ Recents │ │ │Tâche2│Doing │ P2 │ — │20 juil │ ││ │ │ │
│ · Proj A │ │ │Tâche3│Done │ P3 │Marie │10 juil │ ││ │ 🏠 💬 📨 🔍│ ┌─ Library ─────────────────────────────────┐│
│ · Proj B │ │ └──────┴──────┴──────┴──────┴──────────┘ ││ │ │ │ [Recents|Favorites|Shared|Published|...] ││
│ │ │ [+ New] [Filter] [Sort] [Search] [...] ││ │ 📁 Worksp. │ │ ┌──────┬──────────┬──────────┬───────────┐ ││
│ ▼ Private │ └─────────────────────────────────────────────┘│ │ 📄 New Pg │ │ │Name │Created by│Source │Last edited│ ││
│ · Notes │ │ │ 📁 New Fl │ │ ├──────┼──────────┼──────────┼───────────┤ ││
│ │ ┌─ Barre d'outils ────────────────────────────┐│ │ 📄 page 1 │ │ │doc A │Draco │Local │10 min ago │ ││
│ ▼ Shared │ │ [Table▼] [Kanban] [Calendar] [Gallery] ... ││ │ 📄 page 2 │ │ │doc B │bruno │Gitea │2 hours ago│ ││
│ · ... │ │ [Group▼] [Filter▼] [Sort▼] [Properties▼] ││ │ │ │ └──────┴──────────┴──────────┴───────────┘ ││
│ 📅 Meeting │ └─────────────────────────────────────────────┘│
│ 🕒 Recents │ │
│ ⭐ Favoris │ ┌─ Workspaces ───────────────────────────────┐│
│ 🤖 Agents │ │ ┌──────────┐ ┌──────────┐ ┌───┐ ┌───┐ ││
│ 👥 Shared │ │ │Project A │ │Project B │ │ + │ │ + │ ││
│ 🌐 Publ. │ │ │3 pages │ │12 pages │ │ │ │ │ ││
│ │ │ └──────────┘ └──────────┘ └───┘ └───┘ ││
│ ══════════ │ └─────────────────────────────────────────────┘│
│ 📚 Library │ │
│ ✅ My Task │ ┌─ Settings ─────────────────────────────────┐│
│ 🗑️ Trash │ │ My Account | Admin | Integrations ││
│ ❓ Help │ │ [Upload photo] [Full Name] [Email] [...] ││
│ │ └─────────────────────────────────────────────┘│ │ │ └─────────────────────────────────────────────┘│
│ ────────── │ │ │ 💬 [+📄] │ │
│ [+New page]│ ┌─ Contenu de la vue ─────────────────────────┐│
│ (sticky │ │ (table / board / calendar / gallery / ...) ││
│ footer) │ └─────────────────────────────────────────────┘│
└────────────┴─────────────────────────────────────────────────┘ └────────────┴─────────────────────────────────────────────────┘
``` ```
@@ -1553,7 +1670,41 @@ docker compose restart flowdeck
- [Notion Help — Databases](https://www.notion.com/help/intro-to-databases) - [Notion Help — Databases](https://www.notion.com/help/intro-to-databases)
- [Notion Help — Tasks & Dependencies](https://www.notion.com/help/tasks-and-dependencies) - [Notion Help — Tasks & Dependencies](https://www.notion.com/help/tasks-and-dependencies)
- [Notion Help — Relations & Rollups](https://www.notion.com/help/relations-and-rollups) - [Notion Help — Relations & Rollups](https://www.notion.com/help/relations-and-rollups)
- [Notion Data Sources (2025)](https://www.notionapps.com/blog/notion-data-sources-update-2025/)
- [react-notion-x](https://github.com/NotionX/react-notion-x)
- [FlowDeck — NOTION_DATABASE_TASKS_GUIDE.md](docs/NOTION_DATABASE_TASKS_GUIDE.md) - [FlowDeck — NOTION_DATABASE_TASKS_GUIDE.md](docs/NOTION_DATABASE_TASKS_GUIDE.md)
- [FlowDeck — ROADMAP.md](ROADMAP.md) - [FlowDeck — ROADMAP.md](ROADMAP.md)
---
## 16. Versions à venir
### v4.1.0 — Content Blocks enrichis (priorité standard)
- **Callout boxes** — Blocs d'alerte (info, warning, tip, danger)
- **Table of Contents** — Auto-généré depuis les headings
- **LaTeX / KaTeX** — Formules mathématiques
- **Toggle lists** — Listes pliables/dépliables
- **Multi-columns** — Mise en page 2-3 colonnes
### v4.2.0 — Export (priorité standard)
- Export **Markdown** avec images
- Export **PDF** (via WeasyPrint ou headless Chrome)
- Export **HTML** standalone (page auto-suffisante)
### v4.3.0 — Collaboration (priorité basse)
- **Commentaires inline** sur les pages
- **@mentions** pour notifier des utilisateurs
- **Notifications email** (changements, mentions)
- **Permissions par workspace** (viewer, editor, admin)
### v5.0.0 — Plateforme avancée (futur)
- **AI Assistants** — Génération de contenu, résumés, suggestions
- **Embeds** — Vidéos, PDF, Figma, Google Docs
- **Automatisations** — Règles déclenchées sur événements (Notion-style)
- **Base de données avancée** — Relations inter-collections, rollups
- **Kanban flexible** — Colonnes custom, WIP limits
- **API publique REST** — Tokens d'accès pour intégrations tierces
- **Volume Docker persistant** — `/data` monté pour survie des données
- **PostgreSQL** — Migration optionnelle pour scaling
+176 -29
View File
@@ -195,14 +195,74 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
- [x] **Validation inputs** — login/register déjà validés, titre vide → "Untitled" - [x] **Validation inputs** — login/register déjà validés, titre vide → "Untitled"
- [x] **Rate limiting** — 100 req/min/IP déjà en place, testé OK - [x] **Rate limiting** — 100 req/min/IP déjà en place, testé OK
- [x] **Session expiry UX** — redirect avec ?expired=1, bannière sur la page login - [x] **Session expiry UX** — redirect avec ?expired=1, bannière sur la page login
- [ ] **CSRF token refresh** — après expiration session (à faire) - [x] **CSRF token refresh** — auto-refresh sur 403, endpoint `/api/csrf-token`
- [x] **Mobile responsive** — sidebar slide-in, modales centrées, landing adaptative - [x] **Mobile responsive** — sidebar slide-in, modales centrées, landing adaptative
- [x] **Tests de non-régression** — +10 tests (landing, register, 404, validation, duplicate, expiry) - [x] **Tests de non-régression** — +10 tests (landing, register, 404, validation, duplicate, expiry)
- [x] **143 tests passent** - [x] **143 tests passent**
--- ---
### v4.1.0 — Content Blocks enrichis (Notion Parity Tier 2) ### v4.1.0 — Database: Data Sources & Linked Databases ✅ (2026-07-21)
> **Objectif** : Collections multi-sources, databases liées (linked). **COMPLETED**.
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 1](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
- [x] **Table `collection_data_sources`** — multi-sources par collection (FK `collection_id` + `source_collection_id`, flag `is_linked`)
- [x] **API sources** — `GET /db/{id}/sources/api` (liste), `POST /db/{id}/sources/api` (ajouter), `DELETE /db/{id}/sources/{sid}/api` (retirer)
- [x] **API linked DB** — `POST /db/{id}/linked/api` (crée une collection liée : copie vues + propriétés + data source `is_linked=1`)
- [x] **Full-page vs Inline** — `POST /db/{id}/toggle-inline/api` (toggle), `POST /db/inline/api` (créer inline avec `parent_page_id`)
- [x] **Règle permissions** — linked DB hérite du `workspace_id` de la source → mêmes ACLs
- [x] **Migration** — ajout colonnes `workspace_id`, `created_by` sur `collections`
- [x] **153 tests passent** (+10 nouveaux)
### v4.2.0 — Database: Templates & Dashboards
> **Objectif** : Templates réutilisables + dashboards combinant vues/widgets.
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 2](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
- [ ] **Table `collection_templates`** — avec récurrence (daily/weekly/monthly/yearly)
- [ ] **Table `collection_dashboards`** — layout widgets (grille configurable)
- [ ] **API templates** — CRUD + POST apply
- [ ] **API dashboards** — CRUD widgets (view_id, position, taille)
- [ ] **UI template picker** — dropdown New avec templates
- [ ] **UI dashboard view** — combinaison de vues/widgets sur une page
### v4.3.0 — Database Views complètes (10 types)
> **Objectif** : Tous les types de vues Notion avec layouts.
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 3](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
- [ ] **Chart** — barres, courbes, camemberts, donuts, scatter (Chart.js)
- [ ] **Form** — formulaire HTML → POST création page
- [ ] **Map** — Leaflet/OSM pour propriété Place
- [ ] **Feed** — vue chronologique type fil d'actualité
- [ ] **Timeline/Gantt** — barres horizontales avec dépendances
- [ ] **Layout options** — card size, preview, open in peek/side/full
- [ ] **View tabs** — navigation fluide entre vues (existant à enrichir)
### v4.4.0 — Tasks, Sub-items & Dependencies
> **Objectif** : Système complet de tâches Notion-style.
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 4](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
- [ ] **Flag `is_task`** sur collections — Turn into Tasks
- [ ] **Propriété ID** auto-générée (TASK-XXX)
- [ ] **Sub-items** — parent_id enrichi, modes d'affichage (nested/flattened/card)
- [ ] **Table `page_dependencies`** — bloque/bloqué par
- [ ] **API dependencies** — POST/GET/DELETE /db/{c}/pages/{p}/dependencies
- [ ] **Auto-shift dates** — overlap / maintain_time / never + skip weekends
- [ ] **UI dépendances** — graphe ou liste, toggle sub-items
- [ ] **Filtres sub-items** — parents only / all / sub-items only
### v4.5.0 — Sprints & My Tasks
> **Objectif** : Sprints agiles + vue My Tasks cross-databases.
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 5](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
- [ ] **Tables `sprints`, `sprint_pages`** — sprints agiles
- [ ] **API sprints** — CRUD + assignation pages, vélocité
- [ ] **Sprint board** — Current Sprint / Planning / Backlog
- [ ] **Burndown chart** — vélocité, points complétés vs restants
- [ ] **My Tasks** — agrégation cross-databases (toutes les task DBs)
- [ ] **My Tasks API** — /my-tasks?view=all|today|overdue|upcoming
- [ ] **My Tasks UI** — groupes par collection, status badges, filtres
### v4.6.0 — Content Blocks enrichis
> **Objectif** : parité d'édition avec Notion. > **Objectif** : parité d'édition avec Notion.
- [ ] **Callout blocks** — boîtes colorées (info, warning, tip, success) - [ ] **Callout blocks** — boîtes colorées (info, warning, tip, success)
@@ -211,53 +271,139 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
- [ ] **Toggle lists** — contenu expandable/collapsible (déjà partiel) - [ ] **Toggle lists** — contenu expandable/collapsible (déjà partiel)
- [ ] **Multi-colonnes** — layout flexible (2, 3 colonnes) - [ ] **Multi-colonnes** — layout flexible (2, 3 colonnes)
### v4.2.0 — Export ### v4.7.0 — Export
- [ ] **Export Markdown** — avec images et sous-pages (déjà partiel dans editor) - [ ] **Export Markdown** — avec images et sous-pages (déjà partiel dans editor)
- [ ] **Export PDF** — mise en page fidèle, table des matières - [ ] **Export PDF** — mise en page fidèle, table des matières
- [ ] **Export HTML** — site statique standalone - [ ] **Export HTML** — site statique standalone
### v4.3.0 — Collaboration ### v4.8.0 — Collaboration
- [ ] **Inline comments** — commentaires sur sélection de texte - [ ] **Inline comments** — commentaires sur sélection de texte
- [ ] **@mentions** — notifier un utilisateur → page/commentaire - [ ] **@mentions** — notifier un utilisateur → page/commentaire
- [ ] **Email notifications** — changements, mentions - [ ] **Email notifications** — changements, mentions
### v4.4.0 — Embeds & Rich Media ### v4.9.0 — FlowDeck Agent (Agent IA natif)
- [ ] **Embeds** — YouTube, Figma, Google Maps, Twitter, etc.
- [ ] **Image lightbox** — clic pour agrandir, navigation
- [ ] **Bookmark cards** — aperçu riche des liens (OG metadata)
### v4.5.0 — Kanban Adaptable **Objectif :** Un agent IA intégré à FlowDeck, capable de planifier, rechercher et **agir** directement sur les workspaces — collections, pages, propriétés, vues, issues Gitea. Inspiré de Notion Agent (2026).
- [ ] Colonnes configurables par utilisateur
- [ ] Cartes configurables (choisir propriétés affichées)
- [ ] Couverture de carte (image, icône, couleur)
- [ ] WIP limits par colonne
### v4.6.0 — Calendar & Meetings **Documentation :**
- [ ] Vue Calendrier drag & drop
- [ ] Récurrence d'événements
- [ ] Template Meeting Notes
### v4.7.0 — AI Assistant [`docs/Flowdeck_Agent_integration.md`](docs/Flowdeck_Agent_integration.md) (702 lignes) ·
- [ ] Intégration LLM pour écriture/résumé/traduction [`docs/Guide_Complet_Notion_Agent_2026.md`](docs/Guide_Complet_Notion_Agent_2026.md) (464 lignes)
- [ ] Slash AI commands (`/ai write`, `/ai summarize`)
- [ ] Auto-complétion
### v4.8.0 — Command Palette & Recherche #### Concept
Là où un assistant IA classique répond (`prompt → réponse`), FlowDeck Agent **réalise** :
`objectif → planification → collecte contexte → actions API → résultat`
| Notion AI | FlowDeck Agent |
|-----------|----------------|
| Répond, résume, réécrit | Planifie, recherche, **agit** |
| Bloc de texte | Crée collections, pages, propriétés, vues |
| Sans état | Historique conversation + snapshots rollback |
| Aucune action DB | Modifie le workspace via API FastAPI existantes |
#### Fonctionnalités clés
- [ ] **Interface conversationnelle** — panneau agent dans le sidebar (🤖 Agents) + popup chat
- [ ] **Boucle ReAct** — Raisonnement → Action → Observation → itération (max 12 tours)
- [ ] **Streaming SSE** — affichage temps réel du raisonnement et des actions
- [ ] **10+ outils actionnables** — search, read/write collections/pages, views, properties, Gitea sync
- [ ] **Permissions** — même ACL que l'utilisateur (`PermissionManager`), audit log + rollback
- [ ] **Contexte automatique** — workspace actif, pages mentionnées, fichiers uploadés
- [ ] **Custom agents** — instructions, modèle, outils, scope par agent
- [ ] **Déclencheurs** — planifiés, webhooks, événements workspace
- [ ] **Skills réutilisables** — templates d'instructions + outils (ex: "Rédiger rapport hebdo")
- [ ] **Multi-LLM** — GPT, Claude, Gemini, modèles locaux (Ollama)
#### Architecture (nouveaux composants)
```
app/
├── routers/agent.py — /api/agent/* (run, conversations, tools)
├── services/agent_engine.py — Orchestrateur ReAct + boucle tool calls
├── services/llm_client.py — Abstraction multi-fournisseur LLM
├── services/tool_registry.py — 10+ outils wrappant les API existantes
├── services/context_builder.py — Collecte contexte workspace
└── templates/
├── agent_panel.html — Panneau conversation Alpine.js
└── agent_message.html — Fragment message (streaming)
```
#### Nouvelles tables
| Table | Rôle |
|-------|------|
| `agents` | Config agent (nom, instructions, modèle, tools scope) |
| `agent_conversations` | Sessions (titre, agent, workspace, statut) |
| `agent_messages` | Messages (role, content, tool_calls, artifacts) |
| `agent_actions` | Journal d'audit (tool, args, result, undo_snapshot) |
| `agent_skills` | Skills réutilisables (nom, prompt template, outils) |
| `agent_triggers` | Déclencheurs (cron, webhook, event, agent cible) |
#### Cas d'usage FlowDeck
- **Créer un CRM complet** — agent crée collection, propriétés, vues, exemples
- **Résumer les notes de réunion** — lit les pages, synthétise, crée une page résumé
- **Générer des OKR** — crée la database OKR avec relations et vues
- **Analyser un repo Gitea** — lit les issues, crée un dashboard de suivi
- **Rédiger un rapport hebdomadaire** — agrège les pages modifiées, génère le rapport
#### Plan de migration (5 phases)
1. **Phase 1 — Core Agent** : AgentEngine + LLMClient + 3 outils (search, read, create) + SSE streaming
2. **Phase 2 — UI** : agent_panel.html, historique conversations, sélecteur de modèle
3. **Phase 3 — Outils avancés** : 7 outils supplémentaires (views, properties, Gitea, uploads)
4. **Phase 4 — Autonomie** : custom agents, skills, déclencheurs planifiés
5. **Phase 5 — Plateforme** : API publique agent → intégrations tierces, marketplace skills
#### Limitations (v1)
- Pas de modification concurrente (lock optimiste DB)
- Pas d'appels API externes non-FlowDeck (sandbox strict)
- Budget tokens max par conversation (500k tokens)
- Timeout 5 minutes par run
### v5.0.0 — Command Palette & Recherche
- [ ] **Command palette** — Ctrl+K / Ctrl+P recherche universelle - [ ] **Command palette** — Ctrl+K / Ctrl+P recherche universelle
- [ ] **Quick actions** — navigation, création, commandes - [ ] **Quick actions** — navigation, création, commandes
### v4.9.0 — Automations ### v5.1.0 — Automations
- [ ] **Database automations** — if-this-then-that - [ ] **Database automations** — if-this-then-that
- [ ] **Buttons** — cliquables déclenchant actions - [ ] **Buttons** — cliquables déclenchant actions
### v4.10.0 — Database Avancée ### v5.2.0 — Infrastructure & Polish
> **Objectif** : Qualité de code, design system, backup, CI/CD.
> **Items issus de l'ancien docs/ROADMAP.md (v3.0)**
**Design system**
- [ ] **Design tokens** — `design-tokens.css` (couleurs, espacements, typographie unifiés)
- [ ] **Composants réutilisables** — boutons, inputs, modales, dropdowns, toasts
**Sécurité & Utilisateur**
- [ ] **API Tokens** — générer/révoquer des clés API utilisateur
- [ ] **Sessions actives** — voir et révoquer les sessions
- [ ] **Onboarding wizard** — `/welcome` au premier lancement (créer compte → lier forges → premier projet)
**Infrastructure**
- [ ] **Migrations versionnées** — Alembic ou table `schema_version`
- [ ] **Backup automatique** — cron daily → fichier daté
- [ ] **Index manquants** — `users.email`, `forge_connections.user_id`
- [ ] **Linting** — ruff (Python), eslint (JS)
- [ ] **Tests parallèles** — pytest-xdist
- [ ] **Build Docker multi-stage** — optimiser taille d'image
**Forge integration**
- [ ] **DB: table `projects`** — type (builtin/gitea/github), forge_id, clone_url, default_branch, language
- [ ] **Cron: sync périodique des projets** — configurable (défaut: chaque heure)
- [ ] **GitHubAdapter** complet — API GitHub v3 → interface ForgeAdapter
**Tests (cibles)**
- [x] **143 tests** — cible 100+ atteinte ✅
- [ ] Tests d'intégration auth (OAuth mock)
- [ ] Tests des adapters forge (mock HTTP)
- [ ] Tests multi-user (permissions croisées)
### v5.3.0 — Database Avancée
- [ ] Inline databases dans n'importe quelle page - [ ] Inline databases dans n'importe quelle page
- [ ] Templates de database (Project tracker, CRM…) - [ ] Templates de database (Project tracker, CRM…)
- [ ] Validation des propriétés (required, unique, min/max) - [ ] Validation des propriétés (required, unique, min/max)
--- ---
## v5.0.0 — Pro (futur) ## v6.0.0 — Pro (futur)
- [ ] **PWA** — Progressive Web App, offline support - [ ] **PWA** — Progressive Web App, offline support
- [ ] **SSO/SAML** — enterprise authentication - [ ] **SSO/SAML** — enterprise authentication
@@ -278,9 +424,10 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
+ UI Notion + Tags + Admin + Sharing COMPLETED + UI Notion + Tags + Admin + Sharing COMPLETED
+ GitHub OAuth + Library + GitHub OAuth + Library
v4.0.2 ✅ v4.1.0 ⬜ v4.2.0 ⬜ v4.3.0 ⬜ v4.4–4.10 ⬜ v5.0.0 ⬜ v4.0.2 ✅ v4.1.0 ✅ v4.2–4.5 ⬜ v4.6.0 ⬜ v4.7.0 ⬜ v4.8–4.9 ⬜ v5.x–v6.0 ⬜
Quality Blocks Export Collab Notion Pro Quality Data Sources Templates + Content Export Collab + Pro + Agent
& Tests enrichis PDF/MD @mentions Parity (futur) & Tests & Linked DB Views/Tasks Blocks PDF/MD Agent IA (futur)
📚 NOTION_DATABASE_TASKS_GUIDE.md v2.0
``` ```
*Dernière mise à jour: 2026-07-18 — Roadmap restructuré, focus MVP, stratégie de branches Gitea* *Dernière mise à jour: 2026-07-21 — v4.1.0 Data Sources & Linked Databases complété ✅*
+1
View File
@@ -0,0 +1 @@
4.0.3
+54 -1
View File
@@ -192,7 +192,7 @@ def init_db():
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',
icon TEXT DEFAULT '📄', icon TEXT DEFAULT 'file',
position INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0,
parent_id INTEGER REFERENCES collection_pages(id), parent_id INTEGER REFERENCES collection_pages(id),
gitea_issue_id INTEGER, gitea_issue_id INTEGER,
@@ -462,6 +462,59 @@ def init_db():
""") """)
conn.commit() conn.commit()
# v4.1.0: Data Sources & Linked Databases
conn.execute("""
CREATE TABLE IF NOT EXISTS collection_data_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
source_collection_id INTEGER NOT NULL REFERENCES collections(id),
source_name TEXT DEFAULT '',
is_linked BOOLEAN NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(collection_id, source_collection_id)
)
""")
# Add workspace_id to collections if missing (v4.1.0 migration)
try:
conn.execute("ALTER TABLE collections ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)")
except sqlite3.OperationalError:
pass
# Add created_by to collections if missing
try:
conn.execute("ALTER TABLE collections ADD COLUMN created_by INTEGER REFERENCES users(id)")
except sqlite3.OperationalError:
pass
conn.commit()
# v4.2.0: Collection Templates (enhanced) + Dashboards
# Add description, is_recurring, recurrence_rule to page_templates
try:
conn.execute("ALTER TABLE page_templates ADD COLUMN description TEXT DEFAULT ''")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE page_templates ADD COLUMN is_recurring BOOLEAN NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE page_templates ADD COLUMN recurrence_rule TEXT DEFAULT ''")
except sqlite3.OperationalError:
pass
# Dashboard: combinaison de vues/widgets sur une page
conn.execute("""
CREATE TABLE IF NOT EXISTS collection_dashboards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Dashboard',
layout_json TEXT NOT NULL DEFAULT '{"columns":1,"widgets":[]}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
@contextmanager @contextmanager
def get_conn(): def get_conn():
+19 -5
View File
@@ -92,6 +92,20 @@ async def pwa_manifest():
# ═══════════ API aliases (v4.0.1) ═══════════ # ═══════════ API aliases (v4.0.1) ═══════════
@app.get("/api/csrf-token")
async def csrf_token_endpoint(request: Request):
"""Return a fresh CSRF token. Used by the frontend to auto-recover from 403."""
from fastapi.responses import JSONResponse
import secrets
token = secrets.token_hex(32)
response = JSONResponse({"csrf_token": token})
response.set_cookie(
"csrf_token", token,
httponly=False, samesite="lax", max_age=86400, path="/",
)
return response
@app.get("/api/pages") @app.get("/api/pages")
async def api_pages_alias(request: Request): async def api_pages_alias(request: Request):
"""Alias /api/pages → /board/api/pages for API path consistency.""" """Alias /api/pages → /board/api/pages for API path consistency."""
@@ -138,10 +152,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
@app.exception_handler(404) @app.exception_handler(404)
async def not_found_handler(request: Request, exc): async def not_found_handler(request: Request, exc):
"""Styled 404 page matching the FlowDeck dark theme.""" """Redirect 404 HTML pages to /workspaces. API routes still get JSON."""
from fastapi.responses import HTMLResponse # Preserve JSON 404 for all API-like paths (including /db/xxx/api)
# API routes should get JSON, not HTML if "/api" in request.url.path:
if request.url.path.startswith("/api/") or request.url.path.startswith("/board/api/"):
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
return JSONResponse({"detail": "Not found"}, status_code=404) return JSONResponse({"detail": "Not found"}, status_code=404)
return HTMLResponse(NOT_FOUND_HTML, status_code=404) from fastapi.responses import RedirectResponse
return RedirectResponse("/workspaces", status_code=302)
+1 -1
View File
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
""" """
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents"} EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token"}
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt # Webhook receiver, OAuth callback, and internal API are exempt
+40 -21
View File
@@ -10,6 +10,7 @@ from fastapi.responses import HTMLResponse, JSONResponse
from app.db import get_conn from app.db import get_conn
from app.services.gitea_client import gitea from app.services.gitea_client import gitea
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.routers.dashboard import _get_app_version
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board") router = APIRouter(tags=["board"], prefix="/board")
@@ -62,7 +63,7 @@ def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict:
"bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄", "bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄",
"design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒", "design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒",
} }
icon = "📄" icon = "file"
for lbl in labels: for lbl in labels:
for kw, emoji in icon_map.items(): for kw, emoji in icon_map.items():
if kw in lbl.get("name", "").lower(): if kw in lbl.get("name", "").lower():
@@ -141,27 +142,27 @@ def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, m
def _file_icon(name: str, content_format: str = "") -> str: def _file_icon(name: str, content_format: str = "") -> str:
"""Map file extension to emoji icon.""" """Map file extension to icon name (SVG-safe)."""
import re import re
# FlowDeck internal pages (no extension) # FlowDeck internal pages (no extension)
if content_format and content_format != 'file': if content_format and content_format != 'file':
return '📝' return 'edit'
n = name.lower() n = name.lower()
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): return '🖼️' if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): return 'image'
if n.endswith('.pdf'): return '📕' if n.endswith('.pdf'): return 'file'
if re.search(r'\.(md|markdown)$', n): return '📝' if re.search(r'\.(md|markdown)$', n): return 'edit'
if n.endswith('.py'): return '🐍' if n.endswith('.py'): return 'file'
if re.search(r'\.(js|jsx|ts|tsx)$', n): return '📜' if re.search(r'\.(js|jsx|ts|tsx)$', n): return 'file'
if re.search(r'\.(html?|xml)$', n): return '🌐' if re.search(r'\.(html?|xml)$', n): return 'file'
if n.endswith('.css'): return '🎨' if n.endswith('.css'): return 'file'
if n.endswith('.json'): return '📋' if n.endswith('.json'): return 'file'
if n.endswith('.sql'): return '🗃️' if n.endswith('.sql'): return 'file'
if re.search(r'\.(sh|bash|zsh)$', n): return '💻' if re.search(r'\.(sh|bash|zsh)$', n): return 'file'
if n.endswith('.ps1'): return '⚡' if n.endswith('.ps1'): return 'file'
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n): return '📜' if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n): return 'file'
if re.search(r'\.(txt|log)$', n): return '📄' if re.search(r'\.(txt|log)$', n): return 'file'
if re.search(r'\.(zip|tar|gz|rar|7z)$', n): return '📦' if re.search(r'\.(zip|tar|gz|rar|7z)$', n): return 'file'
return '📄' return 'file'
def _load_workspace_pages(ws_cookie: str) -> list: def _load_workspace_pages(ws_cookie: str) -> list:
@@ -216,6 +217,22 @@ def _load_children(parent_id: int) -> list:
return children return children
def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]:
"""Return list of local workspaces for a user."""
if not user:
return []
try:
from app.db import get_conn
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name",
(user["id"],)
).fetchall()
return [{"id": r["id"], "name": r["name"]} for r in rows]
except Exception:
return []
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws_name = user.get("login", "Bruno") if user else "Bruno" ws_name = user.get("login", "Bruno") if user else "Bruno"
@@ -280,7 +297,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
active = first active = first
recent.append({ recent.append({
"id": f"{owner}/{repo}/{view}", "id": f"{owner}/{repo}/{view}",
"name": label, "icon": "📁" if first else "·", "name": label, "icon": "folder" if first else "",
"url": f"/board/{owner}/{repo}?view={view}", "url": f"/board/{owner}/{repo}?view={view}",
"active": active, "indent": indent, "active": active, "indent": indent,
"depth": indent, "has_children": False, "children": [], "depth": indent, "has_children": False, "children": [],
@@ -380,7 +397,9 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
"auth_method": auth_method, "auth_method": auth_method,
"gitea_linked": gitea_linked, "gitea_linked": gitea_linked,
"github_linked": github_linked, "github_linked": github_linked,
"has_active_workspace": has_active_workspace} "has_active_workspace": has_active_workspace,
"app_version": _get_app_version(),
"local_workspaces": _local_workspaces_for_user(user)}
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""): def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
@@ -510,7 +529,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
"icon": "📄", "icon": "📄",
"url": f"/pages/{page['id']}", "url": f"/pages/{page['id']}",
"created_by": "You", "created_by": "You",
"source": page.get("workspace") or "🔒 Private", "source": page.get("workspace") or "Private",
"last_edited": page.get("updated_at", "now"), "last_edited": page.get("updated_at", "now"),
"last_visited": page.get("updated_at", "now"), "last_visited": page.get("updated_at", "now"),
}) })
+250
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import logging import logging
import sqlite3
from fastapi import APIRouter, Request, HTTPException, Query from fastapi import APIRouter, Request, HTTPException, Query
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@@ -760,6 +761,255 @@ async def check_dependencies(request: Request, collection_id: int, page_id: int)
} }
# ── v4.1.0: Data Sources & Linked Databases ──
@router.get("/{collection_id}/sources/api")
async def list_data_sources(request: Request, collection_id: int):
"""API: list all data sources for a collection."""
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
rows = conn.execute(
"SELECT * FROM collection_data_sources WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
return {"sources": [dict(r) for r in rows]}
@router.post("/{collection_id}/sources/api")
async def add_data_source(request: Request, collection_id: int):
"""API: add a data source to a collection."""
try:
body = await request.json()
except Exception:
body = {}
source_collection_id = body.get("source_collection_id")
if not source_collection_id:
raise HTTPException(status_code=400, detail="source_collection_id is required")
source_name = body.get("source_name", "").strip()
is_linked = body.get("is_linked", False)
with get_conn() as conn:
# Verify both collections exist
for cid in (collection_id, source_collection_id):
if not conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone():
raise HTTPException(status_code=404, detail=f"Collection {cid} not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_data_sources WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
try:
cur = conn.execute(
"""INSERT INTO collection_data_sources
(collection_id, source_collection_id, source_name, is_linked, position)
VALUES (?, ?, ?, ?, ?)""",
(collection_id, source_collection_id, source_name, int(is_linked), max_pos),
)
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(status_code=409, detail="This data source already exists in this collection")
return {
"id": cur.lastrowid,
"collection_id": collection_id,
"source_collection_id": source_collection_id,
"status": "added",
}
@router.delete("/{collection_id}/sources/{source_id}/api")
async def remove_data_source(request: Request, collection_id: int, source_id: int):
"""API: remove a data source from a collection."""
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM collection_data_sources WHERE id=? AND collection_id=?",
(source_id, collection_id),
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Data source not found")
conn.execute("DELETE FROM collection_data_sources WHERE id=?", (source_id,))
conn.commit()
return {"id": source_id, "status": "removed"}
@router.post("/{collection_id}/linked/api")
async def create_linked_database(request: Request, collection_id: int):
"""API: create a linked database view from a source collection.
A linked database copies the structure (views, filters, sorts) of a source
but shares the same pages — edits to pages propagate to the source.
"""
try:
body = await request.json()
except Exception:
body = {}
name = body.get("name", "").strip()
new_workspace_id = body.get("workspace_id")
with get_conn() as conn:
source = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not source:
raise HTTPException(status_code=404, detail="Source collection not found")
if not name:
name = f"{source['name']} (linked)"
# Create the linked collection (shallow copy of structure)
# Inherit workspace_id from source for permission inheritance
src_dict = dict(source)
source_workspace_id = src_dict.get("workspace_id")
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, is_locked, is_inline, parent_page_id, workspace_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
name,
src_dict["description"],
src_dict["icon"],
src_dict["schema_json"],
0, # linked DB is never locked
1, # linked DB starts as inline
src_dict.get("parent_page_id"),
source_workspace_id, # linked DB inherits source workspace permissions
),
)
linked_id = cur.lastrowid
# Copy views from source
views = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
for v in views:
conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)",
(linked_id, v["name"], v["view_type"], v["config_json"], v["position"]),
)
# Add the source as a data source with is_linked=1
conn.execute(
"""INSERT INTO collection_data_sources
(collection_id, source_collection_id, source_name, is_linked, position)
VALUES (?, ?, ?, 1, 0)""",
(linked_id, collection_id, source["name"]),
)
# Copy properties from source
props = conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
for p in props:
conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, number_format,
related_collection_id, reverse_name, relation_property_id,
target_property_id, rollup_function, formula_expression,
position, required, visible_in_views)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
linked_id, p["name"], p["prop_type"], p["options_json"],
p["number_format"], p["related_collection_id"], p["reverse_name"],
p["relation_property_id"], p["target_property_id"],
p["rollup_function"], p["formula_expression"],
p["position"], p["required"], p["visible_in_views"],
),
)
conn.commit()
return {
"linked_id": linked_id,
"name": name,
"source_collection_id": collection_id,
"status": "created",
}
@router.post("/{collection_id}/toggle-inline/api")
async def toggle_inline(request: Request, collection_id: int):
"""API: toggle a collection between full-page and inline mode."""
with get_conn() as conn:
coll = conn.execute(
"SELECT id, is_inline FROM collections WHERE id=?",
(collection_id,),
).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
new_inline = 0 if coll["is_inline"] else 1
conn.execute(
"UPDATE collections SET is_inline=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_inline, collection_id),
)
conn.commit()
return {
"collection_id": collection_id,
"is_inline": bool(new_inline),
"mode": "inline" if new_inline else "full-page",
}
@router.post("/inline/api")
async def create_inline_database(request: Request):
"""API: create an inline database within a parent page."""
try:
body = await request.json()
except Exception:
body = {}
name = body.get("name", "").strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
description = body.get("description", "")
icon = body.get("icon", "📋")
parent_page_id = body.get("parent_page_id")
workspace_id = body.get("workspace_id")
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, is_inline, parent_page_id)
VALUES (?, ?, ?, '[]', 1, ?)""",
(name, description, icon, parent_page_id),
)
collection_id = cur.lastrowid
# Create default view
conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json)
VALUES (?, ?, ?, ?)""",
(collection_id, "Default View", "table", json.dumps({
"visible_properties": ["Title"],
"sorts": [],
"filters": [],
})),
)
conn.commit()
return {
"id": collection_id,
"name": name,
"is_inline": True,
"parent_page_id": parent_page_id,
"status": "created",
}
# ── {collection_id} wildcards (LAST — catches everything else) ── # ── {collection_id} wildcards (LAST — catches everything else) ──
+54 -6
View File
@@ -13,6 +13,32 @@ from app.db import get_conn
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"]) router = APIRouter(tags=["dashboard"])
_VERSION = None
def _get_app_version() -> str:
"""Read version from VERSION file with caching."""
global _VERSION
if _VERSION is not None:
return _VERSION
try:
import os
version_path = os.path.join(os.path.dirname(__file__), "..", "..", "VERSION")
if os.path.exists(version_path):
with open(version_path) as f:
_VERSION = f.read().strip()
else:
# Docker fallback
version_path = "/app/VERSION"
if os.path.exists(version_path):
with open(version_path) as f:
_VERSION = f.read().strip()
else:
_VERSION = "0.0.0"
except Exception:
_VERSION = "0.0.0"
return _VERSION
def _get_user_or_redirect(request: Request): def _get_user_or_redirect(request: Request):
"""Return decoded user or a RedirectResponse to login page. """Return decoded user or a RedirectResponse to login page.
@@ -31,6 +57,21 @@ def _get_user_or_redirect(request: Request):
return user return user
def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]:
"""Return list of local workspaces for a user."""
if not user:
return []
try:
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name",
(user["id"],)
).fetchall()
return [{"id": r["id"], "name": r["name"]} for r in rows]
except Exception:
return []
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict: def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws = user.get("login", "Bruno") if user else "Bruno" ws = user.get("login", "Bruno") if user else "Bruno"
@@ -55,7 +96,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
recent_pages.append({ recent_pages.append({
"id": full_name, "id": full_name,
"name": repo.get("name", full_name), "name": repo.get("name", full_name),
"icon": "📁", "icon": "folder",
"url": f"/board/{full_name}", "url": f"/board/{full_name}",
"active": False, "active": False,
"indent": 0, "indent": 0,
@@ -173,8 +214,13 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
"gitea_linked": gitea_linked, "gitea_linked": gitea_linked,
"github_linked": github_linked, "github_linked": github_linked,
"has_active_workspace": has_active_workspace, "has_active_workspace": has_active_workspace,
"app_version": _get_app_version(),
} }
sidebar["local_workspaces"] = _local_workspaces_for_user(user)
return sidebar
@router.get("/trash", response_class=HTMLResponse) @router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
@@ -241,7 +287,7 @@ async def view_page_root(request: Request, page_id: int):
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row: if not row:
return HTMLResponse("<h2>Page not found</h2>", status_code=404) return RedirectResponse("/workspaces", status_code=302)
page = dict(row) page = dict(row)
ws = page.get("workspace", "") ws = page.get("workspace", "")
@@ -279,7 +325,9 @@ async def view_page_root(request: Request, page_id: int):
ws_id = p.replace("workspace_", "") ws_id = p.replace("workspace_", "")
break break
filename = fp_parts[-1] if fp_parts else page.get("title", "File") filename = fp_parts[-1] if fp_parts else page.get("title", "File")
file_url = f"/api/files/{ws_id}/{filename}" if ws_id else "" from urllib.parse import quote
safe_name = quote(filename, safe='')
file_url = f"/api/files/{ws_id}/{safe_name}" if ws_id else ""
page_data["file_url"] = file_url page_data["file_url"] = file_url
page_data["file_mime"] = mime_type page_data["file_mime"] = mime_type
page_data["file_size"] = file_size page_data["file_size"] = file_size
@@ -384,7 +432,7 @@ async def help_page(request: Request):
</div> </div>
<div class="help-card"> <div class="help-card">
<h3><span class="icon">📁</span>Workspaces</h3> <h3><span class="icon">{{ fd_icon("folder",16) }}</span>Workspaces</h3>
<p>Organize your work into separate workspaces. Each has its own pages and files.</p> <p>Organize your work into separate workspaces. Each has its own pages and files.</p>
<ul> <ul>
<li><span class="help-badge local">Local</span> Files stored on your server</li> <li><span class="help-badge local">Local</span> Files stored on your server</li>
@@ -1054,7 +1102,7 @@ def _nav_breadcrumb(conn, page_id: int) -> list:
"id": row["id"], "id": row["id"],
"label": title, "label": title,
"url": None, "url": None,
"icon": "\U0001F4C1" if is_folder else _file_icon(title, row["content_format"]), "icon": "folder" if is_folder else _file_icon(title, row["content_format"]),
"menu": True, "menu": True,
}) })
current = row["parent_id"] current = row["parent_id"]
@@ -1114,7 +1162,7 @@ async def nav_menu(request: Request, workspace_id: int = None, parent_id: int =
items.append({ items.append({
"id": r["id"], "id": r["id"],
"name": title, "name": title,
"icon": "\U0001F4C1" if is_folder else _file_icon(title, r["content_format"]), "icon": "folder" if is_folder else _file_icon(title, r["content_format"]),
"has_children": child_counts.get(r["id"], 0) > 0, "has_children": child_counts.get(r["id"], 0) > 0,
"url": f"/pages/{r['id']}", "url": f"/pages/{r['id']}",
}) })
+109
View File
@@ -296,6 +296,115 @@ async def apply_page_template(request: Request, collection_id: int, tid: int):
return {"id": cur.lastrowid, "status": "created"} return {"id": cur.lastrowid, "status": "created"}
@router.put("/collections/{collection_id}/templates/page/{tid}")
async def update_page_template(request: Request, collection_id: int, tid: int):
"""Update a page template — name, properties, content, recurrence."""
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
tmpl = conn.execute(
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
name = body.get("name", tmpl["name"])
tmpl_dict = dict(tmpl)
description = body.get("description", tmpl_dict.get("description", ""))
property_values_json = json.dumps(body.get("properties", json.loads(tmpl["property_values_json"])))
content_json = json.dumps(body.get("content", json.loads(tmpl_dict.get("content_json", "[]"))))
is_recurring = int(body.get("is_recurring", tmpl_dict.get("is_recurring", 0)))
recurrence_rule = body.get("recurrence_rule", tmpl_dict.get("recurrence_rule", ""))
conn.execute(
"""UPDATE page_templates SET name=?, description=?, property_values_json=?,
content_json=?, is_recurring=?, recurrence_rule=? WHERE id=?""",
(name, description, property_values_json, content_json, is_recurring, recurrence_rule, tid),
)
conn.commit()
return {"id": tid, "status": "updated"}
@router.delete("/collections/{collection_id}/templates/page/{tid}")
async def delete_page_template(request: Request, collection_id: int, tid: int):
"""Delete a page template."""
with get_conn() as conn:
tmpl = conn.execute(
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
conn.execute("DELETE FROM page_templates WHERE id=?", (tid,))
conn.commit()
return {"id": tid, "status": "deleted"}
# ── v4.2.0: Dashboards ──
@router.get("/collections/{collection_id}/dashboards")
async def list_dashboards(request: Request, collection_id: int):
"""List all dashboards for a collection."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM collection_dashboards WHERE collection_id=? ORDER BY name", (collection_id,)
).fetchall()
return {"dashboards": [dict(r) for r in rows]}
@router.post("/collections/{collection_id}/dashboards")
async def create_dashboard(request: Request, collection_id: int):
"""Create a new dashboard for a collection."""
body = await request.json() if request.headers.get("content-type") else {}
name = body.get("name", "Dashboard").strip()
layout = json.dumps(body.get("layout", {"columns": 1, "widgets": []}))
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(404, "Collection not found")
cur = conn.execute(
"INSERT INTO collection_dashboards (collection_id, name, layout_json) VALUES (?,?,?)",
(collection_id, name, layout),
)
conn.commit()
return {"id": cur.lastrowid, "name": name, "status": "created"}
@router.put("/collections/{collection_id}/dashboards/{did}")
async def update_dashboard(request: Request, collection_id: int, did: int):
"""Update a dashboard — name or layout (widgets grid)."""
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
dash = conn.execute(
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
).fetchone()
if not dash:
raise HTTPException(404, "Dashboard not found")
name = body.get("name", dash["name"])
layout = json.dumps(body.get("layout", json.loads(dash["layout_json"])))
conn.execute(
"UPDATE collection_dashboards SET name=?, layout_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(name, layout, did),
)
conn.commit()
return {"id": did, "status": "updated"}
@router.delete("/collections/{collection_id}/dashboards/{did}")
async def delete_dashboard(request: Request, collection_id: int, did: int):
"""Delete a dashboard."""
with get_conn() as conn:
dash = conn.execute(
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
).fetchone()
if not dash:
raise HTTPException(404, "Dashboard not found")
conn.execute("DELETE FROM collection_dashboards WHERE id=?", (did,))
conn.commit()
return {"id": did, "status": "deleted"}
# ── CSV Import/Export ── # ── CSV Import/Export ──
@router.post("/collections/{collection_id}/import/csv") @router.post("/collections/{collection_id}/import/csv")
+5 -5
View File
@@ -12,7 +12,7 @@
#} #}
{% set ns = namespace(items=[]) %} {% set ns = namespace(items=[]) %}
{% if not hide_home %} {% if not hide_home %}
{% set ns.items = ns.items + [{"label": "Home", "url": "/workspaces", "id": none, "icon": "🏠", "menu": false, "home": true}] %} {% set ns.items = ns.items + [{"label": "Home", "url": "/workspaces", "id": none, "icon": "home", "menu": false, "home": true}] %}
{% endif %} {% endif %}
{% if breadcrumb_items %} {% if breadcrumb_items %}
{% for item in breadcrumb_items %} {% for item in breadcrumb_items %}
@@ -51,7 +51,7 @@
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()" <div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
@mouseleave="dragCloseTimer()"> @mouseleave="dragCloseTimer()">
{% if page_icon %} {% if page_icon %}
<span class="header-page-icon">{{ page_icon }}</span> <span class="header-page-icon">{% if page_icon in ("folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap","paperclip","key","refresh","upload") %}{{ fd_icon(page_icon,18) }}{% else %}{{ page_icon }}{% endif %}</span>
{% endif %} {% endif %}
<template x-for="(crumb, di) in display" :key="di"> <template x-for="(crumb, di) in display" :key="di">
@@ -67,7 +67,7 @@
@mouseleave="dragCloseTimer()"> @mouseleave="dragCloseTimer()">
<template x-for="h in crumb.hidden" :key="h.id"> <template x-for="h in crumb.hidden" :key="h.id">
<div class="fd-nav-item" @click.stop="go(h.url); closeAll()"> <div class="fd-nav-item" @click.stop="go(h.url); closeAll()">
<span class="fd-nav-ico" x-text="h.icon || '📄'"></span> <span class="fd-nav-ico" :title="h.icon || 'file'"></span>
<span class="fd-nav-label" x-text="h.label"></span> <span class="fd-nav-label" x-text="h.label"></span>
</div> </div>
</template> </template>
@@ -110,7 +110,7 @@
<div class="fd-nav-item" <div class="fd-nav-item"
@mouseenter="openSub(item)" @mouseenter="openSub(item)"
@click.stop="go(item.url); closeAll()"> @click.stop="go(item.url); closeAll()">
<span class="fd-nav-ico" x-text="item.icon || '📄'"></span> <span class="fd-nav-ico" :title="item.icon || 'file'"></span>
<span class="fd-nav-label" x-text="item.name"></span> <span class="fd-nav-label" x-text="item.name"></span>
<span class="fd-nav-arrow" x-show="item.has_children">&rsaquo;</span> <span class="fd-nav-arrow" x-show="item.has_children">&rsaquo;</span>
<div class="fd-nav-menu fd-nav-submenu" x-show="subOpenId === item.id" x-cloak <div class="fd-nav-menu fd-nav-submenu" x-show="subOpenId === item.id" x-cloak
@@ -118,7 +118,7 @@
@mouseleave="dragCloseTimer()"> @mouseleave="dragCloseTimer()">
<template x-for="s in subItems" :key="s.id"> <template x-for="s in subItems" :key="s.id">
<div class="fd-nav-item" @click.stop="go(s.url); closeAll()"> <div class="fd-nav-item" @click.stop="go(s.url); closeAll()">
<span class="fd-nav-ico" x-text="s.icon || '📄'"></span> <span class="fd-nav-ico" :title="s.icon || 'file'"></span>
<span class="fd-nav-label" x-text="s.name"></span> <span class="fd-nav-label" x-text="s.name"></span>
<span class="fd-nav-arrow" x-show="s.has_children">&rsaquo;</span> <span class="fd-nav-arrow" x-show="s.has_children">&rsaquo;</span>
</div> </div>
+116
View File
@@ -0,0 +1,116 @@
{# FlowDeck — Outline SVG Icon Set (v4.0+)
Usage: {{ fd_icon('folder') }} {{ fd_icon('star', 16) }}
All icons use stroke="currentColor" for theme compatibility.
#}
{%- macro fd_icon(name, size=18) -%}
{%- set s = size|string -%}
{%- if name == 'folder' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
{%- elif name == 'file' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
{%- elif name == 'calendar' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
{%- elif name == 'clock' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{%- elif name == 'star' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
{%- elif name == 'bot' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/></svg>
{%- elif name == 'users' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
{%- elif name == 'globe' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
{%- elif name == 'lock' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
{%- elif name == 'book' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
{%- elif name == 'check-square' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
{%- elif name == 'trash' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
{%- elif name == 'help-circle' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
{%- elif name == 'settings' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
{%- elif name == 'refresh' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
{%- elif name == 'log-out' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
{%- elif name == 'message-square' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
{%- elif name == 'home' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
{%- elif name == 'search' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
{%- elif name == 'link' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
{%- elif name == 'plus' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{%- elif name == 'bell' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
{%- elif name == 'image' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
{%- elif name == 'download' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
{%- elif name == 'chevron-left' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
{%- elif name == 'chevron-right' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
{%- elif name == 'list' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
{%- elif name == 'bar-chart' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
{%- elif name == 'grid' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
{%- elif name == 'align-left' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/></svg>
{%- elif name == 'corner-down-right' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/></svg>
{%- elif name == 'copy' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
{%- elif name == 'chevron-down' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
{%- elif name == 'key' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
{%- elif name == 'inbox' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
{%- elif name == 'edit' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
{%- elif name == 'copy' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
{%- elif name == 'eye-off' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
{%- elif name == 'eye' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{%- elif name == 'share' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
{%- elif name == 'more-horizontal' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
{%- elif name == 'x' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{%- elif name == 'paperclip' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
{%- elif name == 'external-link' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
{%- elif name == 'sparkles' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/></svg>
{%- elif name == 'lightbulb' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>
{%- elif name == 'tag' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
{%- elif name == 'file-text' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
{%- elif name == 'save' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
{%- elif name == 'upload' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
{%- elif name == 'trending-up' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
{%- elif name == 'zap' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
{%- elif name == 'alert-triangle' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
{%- elif name == 'user' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
{%- endif -%}
{%- endmacro -%}
+2 -1
View File
@@ -1,3 +1,4 @@
{% from '_icons.html' import fd_icon %}
{% macro render_workspace_tree(pages, depth=0) %} {% macro render_workspace_tree(pages, depth=0) %}
{% for page in pages %} {% for page in pages %}
{% set safe_name = page.name | replace("'", "\\'") %} {% set safe_name = page.name | replace("'", "\\'") %}
@@ -25,7 +26,7 @@
{% else %} {% else %}
<span style="width:12px;flex-shrink:0;"></span> <span style="width:12px;flex-shrink:0;"></span>
{% endif %} {% endif %}
<span class="page-icon">{{ page.icon }}</span> <span class="page-icon">{% set valid_icons = ["folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap"] %}{% if page.icon in valid_icons %}{{ fd_icon(page.icon,14) }}{% elif page.is_folder %}{{ fd_icon("folder",14) }}{% else %}{{ fd_icon("file",14) }}{% endif %}</span>
{% if page.is_folder %} {% if page.is_folder %}
<span class="page-name tree-folder-link" <span class="page-name tree-folder-link"
@click.stop="navigateToFolder({{ page.db_id }})" @click.stop="navigateToFolder({{ page.db_id }})"
+3 -3
View File
@@ -1,12 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_icon %}👤{% endblock %} {% block page_icon %}{{ fd_icon("user",18) }}{% endblock %}
{% block page_title %}Accounts{% endblock %} {% block page_title %}Accounts{% endblock %}
{% block title_prefix %}Accounts{% endblock %} {% block title_prefix %}Accounts{% endblock %}
{% block content %} {% block content %}
<div class="page-title-area"> <div class="page-title-area">
<div class="page-title"> <div class="page-title">
<span class="page-icon-lg">👤</span> <span class="page-icon-lg">{{ fd_icon("user",24) }}</span>
<h1>Account Management</h1> <h1>Account Management</h1>
</div> </div>
</div> </div>
@@ -50,7 +50,7 @@
<div style="background:var(--bg-secondary); border-radius:8px; padding:20px; margin-bottom:24px;"> <div style="background:var(--bg-secondary); border-radius:8px; padding:20px; margin-bottom:24px;">
<h3 style="margin-bottom:12px;">Connected Accounts</h3> <h3 style="margin-bottom:12px;">Connected Accounts</h3>
<div style="display:flex; align-items:center; gap:12px; padding:12px; border:1px solid var(--border); border-radius:6px;"> <div style="display:flex; align-items:center; gap:12px; padding:12px; border:1px solid var(--border); border-radius:6px;">
<span style="font-size:20px;">🔗</span> <span style="font-size:20px;">{{ fd_icon("link",20) }}</span>
<div style="flex:1;"> <div style="flex:1;">
<div style="font-weight:600;">Gitea</div> <div style="font-weight:600;">Gitea</div>
<div style="font-size:12px; color:var(--text-dim);"> <div style="font-size:12px; color:var(--text-dim);">
+170 -55
View File
@@ -123,7 +123,7 @@
{% else %} {% else %}
<span class="tree-toggle-placeholder"></span> <span class="tree-toggle-placeholder"></span>
{% endif %} {% endif %}
<span class="page-icon">{{ page.icon }}</span> <span class="page-icon">{% if page.icon in ("folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap") %}{{ fd_icon(page.icon,14) }}{% elif page.icon and page.icon|length <= 2 %}{{ fd_icon("file",14) }}{% else %}{{ fd_icon("folder",14) }}{% endif %}</span>
<span class="page-name">{{ page.name }}</span> <span class="page-name">{{ page.name }}</span>
<span class="item-actions"> <span class="item-actions">
<button class="item-action-btn" @click.stop="newSubPage('{{ page.id }}')">+</button> <button class="item-action-btn" @click.stop="newSubPage('{{ page.id }}')">+</button>
@@ -140,6 +140,8 @@
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{% from '_icons.html' import fd_icon %}
<!-- ═══════════ SIDEBAR Notion ═══════════ --> <!-- ═══════════ SIDEBAR Notion ═══════════ -->
<aside class="sidebar" <aside class="sidebar"
:class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileSidebarOpen }" :class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileSidebarOpen }"
@@ -154,7 +156,7 @@
<span class="workspace-name">{{ user.full_name or user.login or workspaceName }}</span> <span class="workspace-name">{{ user.full_name or user.login or workspaceName }}</span>
{% if auth_method != 'local' %} {% if auth_method != 'local' %}
<span class="oauth-badge" title="Connected via {{ auth_method|title }}"> <span class="oauth-badge" title="Connected via {{ auth_method|title }}">
{% if auth_method == 'gitea' %}🦎 Gitea{% elif auth_method == 'github' %}🐙 GitHub{% else %}🔑 {{ auth_method|title }}{% endif %} {% if auth_method == 'gitea' %}Gitea{% elif auth_method == 'github' %}GitHub{% else %}{{ auth_method|title }}{% endif %}
</span> </span>
{% endif %} {% endif %}
</div> </div>
@@ -172,16 +174,31 @@
</div> </div>
</div> </div>
<div class="um-sep"></div> <div class="um-sep"></div>
<a href="/settings" class="um-item" @click="workspaceMenuOpen=false">⚙️ Settings</a> <a href="/settings" class="um-item" @click="workspaceMenuOpen=false">{{ fd_icon("settings", 16) }} Settings</a>
<a href="/workspaces" class="um-item" @click="workspaceMenuOpen=false">🔄 Switch workspace</a> <a href="/workspaces" class="um-item" @click="workspaceMenuOpen=false">{{ fd_icon("refresh", 16) }} Switch workspace</a>
{% if local_workspaces %}
<div class="um-sep"></div> <div class="um-sep"></div>
<a href="/auth/logout" class="um-item um-danger" @click="workspaceMenuOpen=false">🚪 Log out</a> <div class="um-section-label">Workspaces</div>
{% for ws in local_workspaces %}
<a href="/local-workspace?ws={{ ws.id }}" class="um-item" @click="workspaceMenuOpen=false"
style="padding-left:24px;font-size:13px;">
<span class="um-ws-dot" style="width:8px;height:8px;border-radius:50%;background:var(--accent);display:inline-block;margin-right:8px;flex-shrink:0;"></span>{{ ws.name }}
{% if active_ws_name == ws.name %}
<span style="margin-left:auto;color:var(--accent);">✓</span>
{% endif %}
</a>
{% endfor %}
{% endif %}
<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>
</div> </div>
</div> </div>
<!-- Navigation icons row --> <!-- Navigation icons row -->
<div class="sidebar-nav-row"> <div class="sidebar-nav-row">
<a href="/workspaces" class="nav-icon-btn home-btn" :class="{ active: currentView === 'home' }" title="Home"> <a href="{% if local_workspaces and local_workspaces|length > 0 %}/local-workspace?ws={{ local_workspaces[0].id }}{% else %}/workspaces{% endif %}" class="nav-icon-btn home-btn" :class="{ active: currentView === 'home' }" title="Home">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
Home Home
</a> </a>
@@ -191,7 +208,7 @@
<button class="nav-icon-btn" title="Inbox"> <button class="nav-icon-btn" title="Inbox">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/></svg>
</button> </button>
<button class="nav-icon-btn" @click="openQuickFind" title="Search"> <button class="nav-icon-btn" @click="openQuickFind" title="Search" style="margin-left:auto;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button> </button>
</div> </div>
@@ -203,15 +220,16 @@
<div class="sidebar-section-title"> <div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.workspace }" x-text="sectionsOpen.workspace ? '▼' : '▶'"></span> <span class="chevron" :class="{ open: sectionsOpen.workspace }" x-text="sectionsOpen.workspace ? '▼' : '▶'"></span>
{% if has_active_workspace %} {% if has_active_workspace %}
<span>{% if gitea_workspace %}📁 {{ active_ws_name }}{% elif workspace_key and '/' in workspace_key %}🔗{% else %}📁{% endif %} {{ active_ws_name if not gitea_workspace else '' }}</span> <span><span style="vertical-align:sub;margin-right:4px;">{{ fd_icon("folder",16) }}</span>{{ active_ws_name if active_ws_name else "" }}{% if gitea_workspace or (workspace_key and "/" in workspace_key) %} {{ fd_icon("link",12) }}{% endif %}</span>
{% else %} {% else %}
<span>📁 No workspace open</span> <span><span style="vertical-align:sub;margin-right:4px;">{{ fd_icon("folder",16) }}</span>No workspace open</span>
{% endif %} {% endif %}
</div> </div>
{% if has_active_workspace %} {% if has_active_workspace %}
<div class="sidebar-section-actions"> <div class="sidebar-section-actions">
<button class="section-action-btn" title="New Page" @click.stop="window.FlowDeck.createPage()">📄</button> <button class="section-action-btn" title="New Page" @click.stop="window.FlowDeck.createPage()">{{ fd_icon("file",16) }}</button>
<button class="section-action-btn" title="New Folder" @click.stop="window.FlowDeck.showCreateFolderModal()">📁</button> <button class="section-action-btn" title="New Folder" @click.stop="window.FlowDeck.showCreateFolderModal()">{{ fd_icon("folder",16) }}</button>
<a class="section-action-btn" href="/local-workspace" title="Open workspace page" style="text-decoration:none;">{{ fd_icon("external-link",16) }}</a>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -222,13 +240,13 @@
{% if workspace_pages %} {% if workspace_pages %}
{{ render_workspace_tree(workspace_pages) }} {{ render_workspace_tree(workspace_pages) }}
{% else %} {% else %}
<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li> <li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("file",16) }}</span><span class="page-name text-dim">No pages yet</span></li>
{% endif %} {% endif %}
{% else %} {% else %}
<li class="sidebar-item empty-hint"><span class="page-icon">📁</span><span class="page-name text-dim">Open a workspace to see your files</span></li> <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 %} {% 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.location='/workspaces'">
<span class="page-icon">⚙</span><span class="page-name text-dim">Manage Workspaces</span> <span class="page-icon page-icon-svg">{{ fd_icon("settings",16) }}</span><span class="page-name text-dim">Manage Workspaces</span>
</li> </li>
</ul> </ul>
</div> </div>
@@ -240,15 +258,15 @@
<div class="sidebar-section-header" @click="toggleSection('gitea')"> <div class="sidebar-section-header" @click="toggleSection('gitea')">
<div class="sidebar-section-title"> <div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.gitea }" x-text="sectionsOpen.gitea ? '▼' : '▶'"></span> <span class="chevron" :class="{ open: sectionsOpen.gitea }" x-text="sectionsOpen.gitea ? '▼' : '▶'"></span>
<span>🔗 Repository (Gitea)</span> <span>{{ fd_icon("link",14) }} Repository (Gitea)</span>
</div> </div>
<div class="sidebar-section-actions"> <div class="sidebar-section-actions">
<button class="section-action-btn" title="Refresh" @click.stop="if(window._gwData)window._gwData.refreshTree()">🔄</button> <button class="section-action-btn" title="Refresh" @click.stop="if(window._gwData)window._gwData.refreshTree()">{{ fd_icon("refresh",16) }}</button>
</div> </div>
</div> </div>
<div class="sidebar-section-items" x-show="sectionsOpen.gitea" x-transition> <div class="sidebar-section-items" x-show="sectionsOpen.gitea" x-transition>
<ul class="sidebar-items" data-section="gitea" id="sidebar-gitea-items"> <ul class="sidebar-items" data-section="gitea" id="sidebar-gitea-items">
<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Loading...</span></li> <li class="sidebar-item empty-hint"><span class="page-icon">{{ fd_icon("link",14) }}</span><span class="page-name text-dim">Loading...</span></li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -268,7 +286,7 @@
</div> </div>
</div> </div>
<div class="sidebar-section-items" x-show="sectionsOpen.meetings" x-transition> <div class="sidebar-section-items" x-show="sectionsOpen.meetings" x-transition>
<ul class="sidebar-items"><li class="sidebar-item empty-hint"><span class="page-icon">📅</span><span class="page-name text-dim">No meetings</span></li></ul> <ul class="sidebar-items"><li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("calendar",16) }}</span><span class="page-name text-dim">No meetings</span></li></ul>
</div> </div>
</div> </div>
@@ -280,7 +298,7 @@
<span>Recents</span> <span>Recents</span>
</div> </div>
<div class="sidebar-section-actions"> <div class="sidebar-section-actions">
<a href="/library?tab=Recents" class="library-link-btn" title="View in Library" @click.stop>📚</a> <a href="/library?tab=Recents" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
</div> </div>
</div> </div>
<div class="sidebar-section-items" x-show="sectionsOpen.recents" x-transition> <div class="sidebar-section-items" x-show="sectionsOpen.recents" x-transition>
@@ -289,7 +307,7 @@
{{ render_tree_item(page) }} {{ render_tree_item(page) }}
{% endfor %} {% endfor %}
{% if not recent_pages %} {% if not recent_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No recent pages</span></li> <li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("clock",16) }}</span><span class="page-name text-dim">No recent pages</span></li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
@@ -303,7 +321,7 @@
<span>Favorites</span> <span>Favorites</span>
</div> </div>
<div class="sidebar-section-actions"> <div class="sidebar-section-actions">
<a href="/library?tab=Favorites" class="library-link-btn" title="View in Library" @click.stop>📚</a> <a href="/library?tab=Favorites" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
</div> </div>
</div> </div>
<div class="sidebar-section-items" x-show="sectionsOpen.favorites" x-transition> <div class="sidebar-section-items" x-show="sectionsOpen.favorites" x-transition>
@@ -312,7 +330,7 @@
{{ render_tree_item(page) }} {{ render_tree_item(page) }}
{% endfor %} {% endfor %}
{% if not favorite_pages %} {% if not favorite_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon">⭐</span><span class="page-name text-dim">No favorites yet</span></li> <li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("star",16) }}</span><span class="page-name text-dim">No favorites yet</span></li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
@@ -324,7 +342,7 @@
<div class="sidebar-section-title"><span>Agents</span></div> <div class="sidebar-section-title"><span>Agents</span></div>
</div> </div>
<div class="sidebar-section-items" x-show="sectionsOpen.agents" x-transition> <div class="sidebar-section-items" x-show="sectionsOpen.agents" x-transition>
<ul class="sidebar-items"><li class="sidebar-item empty-hint"><span class="page-icon">🤖</span><span class="page-name text-dim">No agents configured</span></li></ul> <ul class="sidebar-items"><li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("bot",16) }}</span><span class="page-name text-dim">No agents configured</span></li></ul>
</div> </div>
</div> </div>
@@ -336,7 +354,7 @@
<span>Shared</span> <span>Shared</span>
</div> </div>
<div class="sidebar-section-actions"> <div class="sidebar-section-actions">
<a href="/library?tab=Shared" class="library-link-btn" title="View in Library" @click.stop>📚</a> <a href="/library?tab=Shared" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
</div> </div>
</div> </div>
<div class="sidebar-section-items" x-show="sectionsOpen.shared" x-transition> <div class="sidebar-section-items" x-show="sectionsOpen.shared" x-transition>
@@ -345,7 +363,7 @@
{{ render_tree_item(page) }} {{ render_tree_item(page) }}
{% endfor %} {% endfor %}
{% if not shared_pages %} {% if not shared_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon">👥</span><span class="page-name text-dim">No shared pages</span></li> <li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("users",16) }}</span><span class="page-name text-dim">No shared pages</span></li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
@@ -359,7 +377,7 @@
<span>Published</span> <span>Published</span>
</div> </div>
<div class="sidebar-section-actions"> <div class="sidebar-section-actions">
<a href="/library?tab=Published" class="library-link-btn" title="View in Library" @click.stop>📚</a> <a href="/library?tab=Published" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
</div> </div>
</div> </div>
<div class="sidebar-section-items" x-show="sectionsOpen.published" x-transition> <div class="sidebar-section-items" x-show="sectionsOpen.published" x-transition>
@@ -368,7 +386,7 @@
{{ render_tree_item(page) }} {{ render_tree_item(page) }}
{% endfor %} {% endfor %}
{% if not published_pages %} {% if not published_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon">🌐</span><span class="page-name text-dim">No published pages</span></li> <li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("globe",16) }}</span><span class="page-name text-dim">No published pages</span></li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
@@ -383,7 +401,7 @@
<span>Private</span> <span>Private</span>
</div> </div>
<div class="sidebar-section-actions"> <div class="sidebar-section-actions">
<a href="/library?tab=Private" class="library-link-btn" title="View in Library" @click.stop>📚</a> <a href="/library?tab=Private" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
</div> </div>
</div> </div>
<div class="sidebar-section-items" x-show="sectionsOpen.private" x-transition> <div class="sidebar-section-items" x-show="sectionsOpen.private" x-transition>
@@ -392,7 +410,7 @@
{{ render_tree_item(page) }} {{ render_tree_item(page) }}
{% endfor %} {% endfor %}
{% if not private_pages %} {% if not private_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li> <li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("file",16) }}</span><span class="page-name text-dim">No pages yet</span></li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
@@ -403,16 +421,16 @@
<!-- Bottom links (always visible, no section wrapper) --> <!-- Bottom links (always visible, no section wrapper) -->
<div class="sidebar-static-links" style="padding: 4px 12px;"> <div class="sidebar-static-links" style="padding: 4px 12px;">
<a href="/library" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;"> <a href="/library" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon">📚</span><span class="page-name" style="font-size:14px;">Library</span> <span class="page-icon page-icon-svg">{{ fd_icon("book",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">Library</span>
</a> </a>
<a href="/my-tasks" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;"> <a href="/my-tasks" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon">✅</span><span class="page-name" style="font-size:14px;">My Tasks</span> <span class="page-icon page-icon-svg">{{ fd_icon("check-square",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">My Tasks</span>
</a> </a>
<a href="/trash" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;"> <a href="/trash" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon">🗑️</span><span class="page-name" style="font-size:14px;">Trash</span> <span class="page-icon page-icon-svg">{{ fd_icon("trash",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">Trash</span>
</a> </a>
<a href="/help" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;"> <a href="/help" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon">❓</span><span class="page-name" style="font-size:14px;">Help</span> <span class="page-icon page-icon-svg">{{ fd_icon("help-circle",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">Help</span>
</a> </a>
</div> </div>
@@ -420,7 +438,7 @@
<div class="sidebar-footer"> <div class="sidebar-footer">
<div style="display:flex; gap:6px;"> <div style="display:flex; gap:6px;">
<button class="sidebar-footer-btn" style="flex:1; justify-content:center; background:var(--bg-hover); border-radius:20px;"> <button class="sidebar-footer-btn" style="flex:1; justify-content:center; background:var(--bg-hover); border-radius:20px;">
<span style="font-size:14px;">💬</span> New chat <span style="margin-right:4px;">{{ fd_icon("message-square",14) }}</span>New chat
<span class="shortcut">Ctrl+O</span> <span class="shortcut">Ctrl+O</span>
</button> </button>
{% if has_active_workspace %} {% if has_active_workspace %}
@@ -465,19 +483,19 @@
<span x-text="favoriteIds.has(contextMenu.pageId) ? 'Remove from Favorites' : 'Add to Favorites'"></span> <span x-text="favoriteIds.has(contextMenu.pageId) ? 'Remove from Favorites' : 'Add to Favorites'"></span>
</div> </div>
<div class="context-menu-item" @click="contextAction('recent')"> <div class="context-menu-item" @click="contextAction('recent')">
<span class="menu-icon">👁️</span> Remove from Recents <span class="menu-icon">{{ fd_icon("eye",14) }}</span> Remove from Recents
</div> </div>
</div> </div>
<div class="context-menu-section"> <div class="context-menu-section">
<div class="context-menu-item" @click="contextAction('copyLink')"> <div class="context-menu-item" @click="contextAction('copyLink')">
<span class="menu-icon">🔗</span> Copy link <span class="menu-icon">{{ fd_icon("link",14) }}</span> Copy link
</div> </div>
<div class="context-menu-item" @click="contextAction('duplicate')"> <div class="context-menu-item" @click="contextAction('duplicate')">
<span class="menu-icon">📋</span> Duplicate <span class="menu-icon">{{ fd_icon("copy",14) }}</span> Duplicate
<span class="menu-shortcut">▶</span> <span class="menu-shortcut">▶</span>
</div> </div>
<div class="context-menu-item" @click="contextAction('rename')"> <div class="context-menu-item" @click="contextAction('rename')">
<span class="menu-icon">✏️</span> Rename <span class="menu-icon">{{ fd_icon("edit",14) }}</span> Rename
<span class="menu-shortcut">Ctrl+Shift+R</span> <span class="menu-shortcut">Ctrl+Shift+R</span>
</div> </div>
<div class="context-menu-item" @click="contextAction('moveTo')"> <div class="context-menu-item" @click="contextAction('moveTo')">
@@ -485,16 +503,16 @@
<span class="menu-shortcut">Ctrl+Shift+P</span> <span class="menu-shortcut">Ctrl+Shift+P</span>
</div> </div>
<div class="context-menu-item" @click="contextAction('delete')"> <div class="context-menu-item" @click="contextAction('delete')">
<span class="menu-icon">🗑️</span> Move to Trash <span class="menu-icon">{{ fd_icon("trash",14) }}</span> Move to Trash
</div> </div>
</div> </div>
<div class="context-menu-section"> <div class="context-menu-section">
<div class="context-menu-item" @click="contextAction('openTab')"> <div class="context-menu-item" @click="contextAction('openTab')">
<span class="menu-icon">🔲</span> Open in new tab <span class="menu-icon">{{ fd_icon("external-link",14) }}</span> Open in new tab
<span class="menu-shortcut">Ctrl+Shift+O</span> <span class="menu-shortcut">Ctrl+Shift+O</span>
</div> </div>
<div class="context-menu-item" @click="contextAction('openPeek')"> <div class="context-menu-item" @click="contextAction('openPeek')">
<span class="menu-icon">📑</span> Open in side peek <span class="menu-icon">{{ fd_icon("file",14) }}</span> Open in side peek
<span class="menu-shortcut">Alt+Click</span> <span class="menu-shortcut">Alt+Click</span>
</div> </div>
</div> </div>
@@ -506,7 +524,7 @@
<span x-text="lastEdited"></span> <span x-text="lastEdited"></span>
</div> </div>
<div class="context-menu-item" @click="contextAction('learnDb')"> <div class="context-menu-item" @click="contextAction('learnDb')">
<span class="menu-icon">❓</span> Learn about databases <span class="menu-icon">{{ fd_icon("help-circle",14) }}</span> Learn about databases
</div> </div>
</div> </div>
</div> </div>
@@ -519,19 +537,19 @@
<template x-if="wsCtx.isFolder"> <template x-if="wsCtx.isFolder">
<div class="context-menu-section"> <div class="context-menu-section">
<div class="context-menu-item" onclick="window.FlowDeck.createPage()"> <div class="context-menu-item" onclick="window.FlowDeck.createPage()">
<span class="menu-icon">📄</span> New File <span class="menu-icon">{{ fd_icon("file",14) }}</span> New File
</div> </div>
<div class="context-menu-item" onclick="window.FlowDeck.showCreateFolderModal()"> <div class="context-menu-item" onclick="window.FlowDeck.showCreateFolderModal()">
<span class="menu-icon">📁</span> New Folder <span class="menu-icon">{{ fd_icon("folder",14) }}</span> New Folder
</div> </div>
</div> </div>
</template> </template>
<div class="context-menu-section"> <div class="context-menu-section">
<div class="context-menu-item" @click="wsCtxAction('rename')"> <div class="context-menu-item" @click="wsCtxAction('rename')">
<span class="menu-icon">✏️</span> Rename <span class="menu-icon">{{ fd_icon("edit",14) }}</span> Rename
</div> </div>
<div class="context-menu-item" @click="wsCtxAction('delete')"> <div class="context-menu-item" @click="wsCtxAction('delete')">
<span class="menu-icon">🗑</span> Delete <span class="menu-icon">{{ fd_icon("trash",14) }}</span> Delete
</div> </div>
</div> </div>
<div class="context-menu-section"> <div class="context-menu-section">
@@ -581,6 +599,48 @@
}); });
})(); })();
// ── Global SVG icon helper (used by JS-rendered elements) ─────
function getSvgIcon(name, size) {
size = size || 16;
var svgs = {
folder: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>',
file: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
star: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
trash: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
image: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>',
download: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
search: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
home: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
key: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>',
paperclip: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>',
'external-link': '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>',
save: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>',
upload: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>',
link: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>',
lock: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
globe: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
edit: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
clock: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
users: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
book: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
settings: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
tag: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>',
'bar-chart': '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>',
grid: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>',
'align-left': '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="17" y1="18" x2="3" y2="18"/></svg>',
calendar: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
bot: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16.01"/><line x1="16" y1="16" x2="16" y2="16.01"/></svg>',
refresh: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>',
inbox: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>',
'help-circle': '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
'check-square': '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>',
plus: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
zap: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
user: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'
};
return svgs[name] || '';
}
// ── Global FlowDeck namespace ──────────────────────────────── // ── Global FlowDeck namespace ────────────────────────────────
// All "New Page" and "New Folder" buttons across the app call // All "New Page" and "New Folder" buttons across the app call
// these functions for consistent behaviour. // these functions for consistent behaviour.
@@ -590,6 +650,47 @@
return m ? m[1] : ''; return m ? m[1] : '';
}, },
/** Refresh CSRF token from the server. Returns a promise resolving to the new token. */
refreshCsrfToken: async function() {
try {
var resp = await fetch('/api/csrf-token', { method: 'GET', credentials: 'same-origin' });
var data = await resp.json();
if (data.csrf_token) {
return data.csrf_token;
}
} catch (e) {
console.warn('[FlowDeck] CSRF refresh failed:', e);
}
return null;
},
/** Fetch wrapper that auto-refreshes CSRF token on 403 and retries once. */
csrfFetch: async function(url, options) {
options = options || {};
options.credentials = options.credentials || 'same-origin';
if (!options.headers) options.headers = {};
if (!options.headers['X-CSRF-Token']) {
options.headers['X-CSRF-Token'] = this.getCsrfToken();
}
var response = await fetch(url, options);
// Auto-refresh on CSRF failure and retry once
if (response.status === 403) {
var body = '';
try { body = await response.clone().text(); } catch(e) {}
if (body.indexOf('CSRF') !== -1 || body.indexOf('csrf') !== -1) {
var newToken = await this.refreshCsrfToken();
if (newToken) {
options.headers['X-CSRF-Token'] = newToken;
return fetch(url, options);
}
}
}
return response;
},
/** Detect the active workspace context from multiple sources. /** Detect the active workspace context from multiple sources.
* Returns { wsId: number, isGitea: boolean, workspaceKey: string } */ * Returns { wsId: number, isGitea: boolean, workspaceKey: string } */
_getContext: function() { _getContext: function() {
@@ -743,7 +844,7 @@
workspaceName: '{{ workspace_name }}', workspaceName: '{{ workspace_name }}',
workspaceInitial: '{{ workspace_initial }}', workspaceInitial: '{{ workspace_initial }}',
currentPage: '{{ current_page }}', currentPage: '{{ current_page }}',
pageIcon: '{% block page_icon %}📁{% endblock %}', pageIcon: '{% block page_icon %}folder{% endblock %}',
pageTitle: '{% block page_title %}FlowDeck{% endblock %}', pageTitle: '{% block page_title %}FlowDeck{% endblock %}',
lastEdited: '{{ last_edited }}', lastEdited: '{{ last_edited }}',
workspaceKey: '{{ workspace_key }}', workspaceKey: '{{ workspace_key }}',
@@ -844,10 +945,14 @@
const favList = document.querySelector('.sidebar-items[data-section="favorites"]'); const favList = document.querySelector('.sidebar-items[data-section="favorites"]');
if (!favList) return; if (!favList) return;
if (items.length === 0) { if (items.length === 0) {
favList.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">⭐</span><span class="page-name text-dim">No favorites yet</span></li>'; favList.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">'+getSvgIcon('star',16)+'</span><span class="page-name text-dim">No favorites yet</span></li>';
} else { } else {
favList.innerHTML = items.map(function(item) { favList.innerHTML = items.map(function(item) {
const icon = item.icon || '📄'; var iconName = item.icon || 'file';
// Map known icon names, anything else (including emojis) → file
var validNames = ['folder','file','star','link','trash','book','home','settings','lock','globe','image','edit','calendar','users','user','search','tag'];
if (validNames.indexOf(iconName) === -1) iconName = 'file';
var icon = '<span class="page-icon-svg">'+getSvgIcon(iconName,14)+'</span>';
const name = (item.title || 'Untitled'); const name = (item.title || 'Untitled');
const safeName = name.replace(/'/g, "\\'").replace(/"/g, '&quot;'); const safeName = name.replace(/'/g, "\\'").replace(/"/g, '&quot;');
const url = item.url || ('/pages/' + item.id); const url = item.url || ('/pages/' + item.id);
@@ -967,10 +1072,20 @@
}, },
showContextMenu(event, pageId, pageName) { showContextMenu(event, pageId, pageName) {
var x = event.clientX, y = event.clientY;
// Prevent menu from overflowing right edge
var viewW = window.innerWidth;
var viewH = window.innerHeight;
var menuW = 210; // max-width ~240, safe estimate
var menuH = 360; // approximate max height
if (x + menuW > viewW) x = viewW - menuW - 8;
if (y + menuH > viewH) y = viewH - menuH - 8;
if (x < 0) x = 8;
if (y < 0) y = 8;
this.contextMenu = { this.contextMenu = {
visible: true, visible: true,
x: event.clientX, x: x,
y: event.clientY, y: y,
pageId: pageId, pageId: pageId,
pageName: pageName pageName: pageName
}; };
@@ -1113,13 +1228,13 @@
} }
container.innerHTML = ''; container.innerHTML = '';
if (!this.giteaTree.length) { if (!this.giteaTree.length) {
container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No files yet</span></li>'; container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">'+getSvgIcon('file',16)+'</span><span class="page-name text-dim">No files yet</span></li>';
return; return;
} }
this.giteaTree.forEach(function(item) { this.giteaTree.forEach(function(item) {
var li = document.createElement('li'); var li = document.createElement('li');
li.className = 'sidebar-item'; li.className = 'sidebar-item';
var icon = item.type === 'dir' ? '📁' : '📄'; var icon = item.type === 'dir' ? '<span class="page-icon-svg">'+getSvgIcon('folder',14)+'</span>' : '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
li.innerHTML = '<span class="page-icon">' + icon + '</span><span class="page-name">' + item.name + '</span>'; li.innerHTML = '<span class="page-icon">' + icon + '</span><span class="page-name">' + item.name + '</span>';
container.appendChild(li); container.appendChild(li);
}); });
@@ -1128,7 +1243,7 @@
.catch(function(err) { .catch(function(err) {
console.error('loadGiteaTree failed:', err.message || err); console.error('loadGiteaTree failed:', err.message || err);
if (container) { if (container) {
container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">⚠️</span><span class="page-name text-dim">Could not load tree: ' + (err.message || 'Unknown error') + '</span></li>'; container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">{{ fd_icon("alert-triangle",14) }}</span><span class="page-name text-dim">Could not load tree: ' + (err.message || 'Unknown error') + '</span></li>';
} }
}); });
}, },
@@ -1487,7 +1602,7 @@
<div id="fd-global-folder-modal" class="flowdeck-modal-overlay" style="display:none" onclick="if(event.target===this)this.style.display='none'"> <div id="fd-global-folder-modal" class="flowdeck-modal-overlay" style="display:none" onclick="if(event.target===this)this.style.display='none'">
<div class="flowdeck-modal"> <div class="flowdeck-modal">
<div class="flowdeck-modal-header"> <div class="flowdeck-modal-header">
<h3>📁 New Folder</h3> <h3>{{ fd_icon("folder",16) }} New Folder</h3>
<button class="flowdeck-modal-close" onclick="document.getElementById('fd-global-folder-modal').style.display='none'">&times;</button> <button class="flowdeck-modal-close" onclick="document.getElementById('fd-global-folder-modal').style.display='none'">&times;</button>
</div> </div>
<div class="flowdeck-modal-body"> <div class="flowdeck-modal-body">
+10 -10
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_icon %}📁{% endblock %} {% block page_icon %}{{ fd_icon("folder",18) }}{% endblock %}
{% block page_title %}Kanban board new{% endblock %} {% block page_title %}Kanban board new{% endblock %}
{% block title_prefix %}Kanban board new{% endblock %} {% block title_prefix %}Kanban board new{% endblock %}
@@ -16,7 +16,7 @@
<!-- Page title --> <!-- Page title -->
<div class="page-title-area"> <div class="page-title-area">
<div class="page-title"> <div class="page-title">
<span class="page-icon-lg">📁</span> <span class="page-icon-lg">{{ fd_icon("folder",24) }}</span>
<h1>{{ repo }}</h1> <h1>{{ repo }}</h1>
</div> </div>
</div> </div>
@@ -41,31 +41,31 @@
hx-get="/board/{{ owner }}/{{ repo }}/view/kanban" hx-get="/board/{{ owner }}/{{ repo }}/view/kanban"
hx-target="#view-content" hx-swap="innerHTML" hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)"> @click="setActiveTab($el)">
<span class="tab-icon">📊</span> Kanban board <span class="tab-icon">{{ fd_icon("bar-chart",14) }}</span> Kanban board
</button> </button>
<button class="view-tab" data-view="detailed" <button class="view-tab" data-view="detailed"
hx-get="/board/{{ owner }}/{{ repo }}/view/detailed" hx-get="/board/{{ owner }}/{{ repo }}/view/detailed"
hx-target="#view-content" hx-swap="innerHTML" hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)"> @click="setActiveTab($el)">
<span class="tab-icon">📋</span> Detailed board <span class="tab-icon">{{ fd_icon("list",14) }}</span> Detailed board
</button> </button>
<button class="view-tab" data-view="table" <button class="view-tab" data-view="table"
hx-get="/board/{{ owner }}/{{ repo }}/view/table" hx-get="/board/{{ owner }}/{{ repo }}/view/table"
hx-target="#view-content" hx-swap="innerHTML" hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)"> @click="setActiveTab($el)">
<span class="tab-icon">☰</span> Table view <span class="tab-icon">{{ fd_icon("align-left",14) }}</span> Table view
</button> </button>
<button class="view-tab" data-view="status" <button class="view-tab" data-view="status"
hx-get="/board/{{ owner }}/{{ repo }}/view/status" hx-get="/board/{{ owner }}/{{ repo }}/view/status"
hx-target="#view-content" hx-swap="innerHTML" hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)"> @click="setActiveTab($el)">
<span class="tab-icon">📈</span> Status overview <span class="tab-icon">{{ fd_icon("trending-up",14) }}</span> Status overview
</button> </button>
<button class="view-tab" data-view="teamload" <button class="view-tab" data-view="teamload"
hx-get="/board/{{ owner }}/{{ repo }}/view/teamload" hx-get="/board/{{ owner }}/{{ repo }}/view/teamload"
hx-target="#view-content" hx-swap="innerHTML" hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)"> @click="setActiveTab($el)">
<span class="tab-icon">👥</span> Team Load <span class="tab-icon">{{ fd_icon("users",14) }}</span> Team Load
</button> </button>
</div> </div>
@@ -111,7 +111,7 @@
<span class="sort-direction" @click="toggleDir(i)"> <span class="sort-direction" @click="toggleDir(i)">
<span x-text="s.dir === 'asc' ? 'Ascending ↑' : 'Descending ↓'"></span> <span x-text="s.dir === 'asc' ? 'Ascending ↑' : 'Descending ↓'"></span>
</span> </span>
<button class="sort-delete" @click="removeSort(i)" title="Delete">🗑</button> <button class="sort-delete" @click="removeSort(i)" title="Delete">{{ fd_icon("trash",14) }}</button>
</div> </div>
</template> </template>
<button class="dropdown-option" @click="addSort()">+ Add sort</button> <button class="dropdown-option" @click="addSort()">+ Add sort</button>
@@ -119,8 +119,8 @@
</div> </div>
</div> </div>
</div> </div>
<button class="toolbar-btn" title="Properties">⚡</button> <button class="toolbar-btn" title="Properties">{{ fd_icon("zap",14) }}</button>
<button class="toolbar-btn" title="Search">🔍</button> <button class="toolbar-btn" title="Search">{{ fd_icon("search",14) }}</button>
<button class="toolbar-btn">⋯</button> <button class="toolbar-btn">⋯</button>
<button class="btn-new" @click="showNewIssue()">New ▾</button> <button class="btn-new" @click="showNewIssue()">New ▾</button>
</div> </div>
+1 -1
View File
@@ -36,7 +36,7 @@
<span class="card-tag" style="background: {{ card.tag_color }}20; color: {{ card.tag_color }};">{{ card.tag }}</span> <span class="card-tag" style="background: {{ card.tag_color }}20; color: {{ card.tag_color }};">{{ card.tag }}</span>
{% endif %} {% endif %}
{% if card.due_date %} {% if card.due_date %}
<div class="card-date"><span>📅</span> {{ card.due_date }}</div> <div class="card-date"><span>{{ fd_icon("calendar",14) }}</span> {{ card.due_date }}</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
+1 -1
View File
@@ -21,7 +21,7 @@
{% endif %} {% endif %}
{% if card.due_date %} {% if card.due_date %}
<div class="card-date"> <div class="card-date">
<span>📅</span> {{ card.due_date }} <span>{{ fd_icon("calendar",14) }}</span> {{ card.due_date }}
</div> </div>
{% endif %} {% endif %}
</div> </div>
+2 -2
View File
@@ -23,13 +23,13 @@
</div> </div>
<!-- Assignee --> <!-- Assignee -->
<div class="filter-pill" style="cursor:pointer;"> <div class="filter-pill" style="cursor:pointer;">
<span>👤</span> <span>{{ fd_icon("user",14) }}</span>
{% if issue.assignee %}{{ issue.assignee }}{% else %}Unassigned{% endif %} {% if issue.assignee %}{{ issue.assignee }}{% else %}Unassigned{% endif %}
<span style="font-size:10px;">▾</span> <span style="font-size:10px;">▾</span>
</div> </div>
<!-- Deadline --> <!-- Deadline -->
<div class="filter-pill" style="cursor:pointer;"> <div class="filter-pill" style="cursor:pointer;">
<span>📅</span> <span>{{ fd_icon("calendar",14) }}</span>
{% if issue.due_date %}{{ issue.due_date }}{% else %}No deadline{% endif %} {% if issue.due_date %}{{ issue.due_date }}{% else %}No deadline{% endif %}
</div> </div>
<!-- Labels --> <!-- Labels -->
+4 -4
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_icon %}🏠{% endblock %} {% block page_icon %}{{ fd_icon("home",18) }}{% endblock %}
{% block page_title %}Home{% endblock %} {% block page_title %}Home{% endblock %}
{% block title_prefix %}Home{% endblock %} {% block title_prefix %}Home{% endblock %}
@@ -42,7 +42,7 @@
border-radius: 6px; transition: background 0.15s; cursor: pointer; color: var(--text-primary); text-decoration: none;" border-radius: 6px; transition: background 0.15s; cursor: pointer; color: var(--text-primary); text-decoration: none;"
onmouseover="this.style.background='var(--bg-hover)'" onmouseover="this.style.background='var(--bg-hover)'"
onmouseout="this.style.background='transparent'"> onmouseout="this.style.background='transparent'">
<span style="font-size:18px;">📁</span> <span style="font-size:18px;">{{ fd_icon("folder",20) }}</span>
<div style="flex:1; min-width:0;"> <div style="flex:1; min-width:0;">
<div style="font-weight:500; font-size:14px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"> <div style="font-weight:500; font-size:14px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
{{ repo.name }} {{ repo.name }}
@@ -57,7 +57,7 @@
<span style="font-size:12px; color: var(--text-dim);">🟡 {{ repo.language }}</span> <span style="font-size:12px; color: var(--text-dim);">🟡 {{ repo.language }}</span>
{% endif %} {% endif %}
{% if repo.private %} {% if repo.private %}
<span style="font-size:12px; color: var(--text-dim);">🔒</span> <span style="font-size:12px; color: var(--text-dim);">{{ fd_icon("lock",14) }}</span>
{% endif %} {% endif %}
<span style="font-size:12px; color: var(--text-dim);">→</span> <span style="font-size:12px; color: var(--text-dim);">→</span>
</div> </div>
@@ -66,7 +66,7 @@
{% if not repos %} {% if not repos %}
<div style="text-align:center; padding:48px; color:var(--text-dim);"> <div style="text-align:center; padding:48px; color:var(--text-dim);">
<div style="font-size:48px; margin-bottom:16px;">📭</div> <div style="margin-bottom:16px;">{{ fd_icon("inbox",48) }}</div>
<h3 style="color:var(--text-secondary);">Aucun projet trouvé</h3> <h3 style="color:var(--text-secondary);">Aucun projet trouvé</h3>
<p style="margin-top:8px;"> <p style="margin-top:8px;">
{% if search %} {% if search %}
+1 -1
View File
@@ -39,7 +39,7 @@
{% endif %} {% endif %}
{% if card.due_date %} {% if card.due_date %}
<div class="card-date"> <div class="card-date">
<span>📅</span> <span>{{ fd_icon("calendar",14) }}</span>
<span style="font-size:12px;">{{ card.due_date }}</span> <span style="font-size:12px;">{{ card.due_date }}</span>
</div> </div>
{% endif %} {% endif %}
+19 -19
View File
@@ -1,12 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_title %}{{ workspace_name }}{% endblock %} {% block page_title %}{{ workspace_name }}{% endblock %}
{% block title_prefix %}{{ workspace_name }}{% endblock %} {% block title_prefix %}{{ workspace_name }}{% endblock %}
{% block page_icon %}🔗{% endblock %} {% block page_icon %}{{ fd_icon("link",18) }}{% endblock %}
{% block topbar %} {% block topbar %}
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %} {% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
{% set page_icon = "🔗" %} {% set page_icon = "link" %}
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">📄+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">📤</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">🔄</button><a href="/accounts/settings" class="topbar-btn" title="Settings">⚙</a>' %} {% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">' ~ fd_icon("file",14) ~ '+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">' ~ fd_icon("upload",14) ~ '</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">' ~ fd_icon("refresh",14) ~ '</button><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
{% include '_header.html' %} {% include '_header.html' %}
{% endblock %} {% endblock %}
@@ -164,7 +164,7 @@ document.addEventListener('alpine:init', () => {
if (!r.ok) { if (!r.ok) {
// If API fails (e.g. no Gitea token), set a message // If API fails (e.g. no Gitea token), set a message
var container = document.getElementById('sidebar-gitea-items'); var container = document.getElementById('sidebar-gitea-items');
if (container) container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Connect Gitea to browse files</span></li>'; if (container) container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">'+getSvgIcon('link',14)+'</span><span class="page-name text-dim">Connect Gitea to browse files</span></li>';
return; return;
} }
var d = await r.json(); var d = await r.json();
@@ -180,7 +180,7 @@ document.addEventListener('alpine:init', () => {
var html = ''; var html = '';
for (var i = 0; i < items.length; i++) { for (var i = 0; i < items.length; i++) {
var item = items[i]; var item = items[i];
var icon = item.type === 'folder' ? '📁' : '📄'; var icon = item.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
html += '<li class="sidebar-item" data-gitea-path="' + item.path + '" data-gitea-type="' + (item.type === 'folder' ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '') + '" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">'; html += '<li class="sidebar-item" data-gitea-path="' + item.path + '" data-gitea-type="' + (item.type === 'folder' ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '') + '" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
html += '<span class="page-icon">' + icon + '</span>'; html += '<span class="page-icon">' + icon + '</span>';
html += '<span class="page-name">' + item.name + '</span>'; html += '<span class="page-name">' + item.name + '</span>';
@@ -189,7 +189,7 @@ document.addEventListener('alpine:init', () => {
// Add Private Pages link // Add Private Pages link
html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>'; html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>';
html += '<li class="sidebar-item" id="gitea-private-link" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">'; html += '<li class="sidebar-item" id="gitea-private-link" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
html += '<span class="page-icon">🔒</span><span class="page-name">Private Pages</span></li>'; html += '<span class="page-icon">'+getSvgIcon('lock',14)+'</span><span class="page-name">Private Pages</span></li>';
container.innerHTML = html; container.innerHTML = html;
// Re-attach event listeners // Re-attach event listeners
this.setupSidebarClicks(); this.setupSidebarClicks();
@@ -270,7 +270,7 @@ document.addEventListener('alpine:init', () => {
ul.className = 'sidebar-items'; ul.className = 'sidebar-items';
ul.style.paddingLeft = '20px'; ul.style.paddingLeft = '20px';
children.forEach(function(child) { children.forEach(function(child) {
var icon = child.type === 'folder' ? '📁' : '📄'; var icon = child.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
var li = document.createElement('li'); var li = document.createElement('li');
li.className = 'sidebar-item'; li.className = 'sidebar-item';
li.setAttribute('data-gitea-path', child.path); li.setAttribute('data-gitea-path', child.path);
@@ -669,8 +669,8 @@ document.addEventListener('alpine:init', () => {
<strong x-text="filePath || ''"></strong> <strong x-text="filePath || ''"></strong>
<span x-text="formatSize(fileSize)"></span> <span x-text="formatSize(fileSize)"></span>
<span style="flex:1;"></span> <span style="flex:1;"></span>
<button class="btn" @click="openEditor()">✏️ Edit</button> <button class="btn" @click="openEditor()">{{ fd_icon("edit",14) }} Edit</button>
<button class="btn btn-danger" @click="deleteCurrentFile()">🗑 Delete</button> <button class="btn btn-danger" @click="deleteCurrentFile()">{{ fd_icon("trash",14) }} Delete</button>
</div> </div>
<div class="gw-content"> <div class="gw-content">
<!-- Skeleton loading --> <!-- Skeleton loading -->
@@ -710,7 +710,7 @@ document.addEventListener('alpine:init', () => {
<option :value="msg"> <option :value="msg">
</template> </template>
</datalist> </datalist>
<button class="btn-primary" @click="saveFile()">💾 Commit</button> <button class="btn-primary" @click="saveFile()">{{ fd_icon("save",14) }} Commit</button>
</div> </div>
</div> </div>
</template> </template>
@@ -734,9 +734,9 @@ document.addEventListener('alpine:init', () => {
<div x-show="!treeLoading"> <div x-show="!treeLoading">
<template x-if="treeItems.length === 0"> <template x-if="treeItems.length === 0">
<div class="empty-state"> <div class="empty-state">
<h2>📁 Empty</h2> <h2>{{ fd_icon("folder",16) }} Empty</h2>
<p>No files in this folder</p> <p>No files in this folder</p>
<button class="btn btn-primary" @click="showNewFile=!showNewFile">📄+ New file</button> <button class="btn btn-primary" @click="showNewFile=!showNewFile">{{ fd_icon("file",14) }}+ New file</button>
</div> </div>
</template> </template>
@@ -746,7 +746,7 @@ document.addEventListener('alpine:init', () => {
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)" @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
@contextmenu.prevent="openGwContext($event, item)" @contextmenu.prevent="openGwContext($event, item)"
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;"> style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
<span x-text="item.type==='folder' ? '📁' : '📄'" style="font-size:16px;"></span> <span x-html="item.type==='folder' ? getSvgIcon('folder',16) : getSvgIcon('file',16)" style="font-size:16px;"></span>
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span> <span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span> <span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
</div> </div>
@@ -759,8 +759,8 @@ document.addEventListener('alpine:init', () => {
:style="'left:' + gwCtx.x + 'px; top:' + gwCtx.y + 'px'" :style="'left:' + gwCtx.x + 'px; top:' + gwCtx.y + 'px'"
@click.outside="gwCtx.visible = false" @click.outside="gwCtx.visible = false"
style="display:none;position:fixed;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-popover);z-index:1100;min-width:160px;padding:4px;"> style="display:none;position:fixed;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-popover);z-index:1100;min-width:160px;padding:4px;">
<div class="ctx-item" @click="gwRename()">✏️ Rename</div> <div class="ctx-item" @click="gwRename()">{{ fd_icon("edit",14) }} Rename</div>
<div class="ctx-item ctx-danger" @click="gwDelete()">🗑 Delete</div> <div class="ctx-item ctx-danger" @click="gwDelete()">{{ fd_icon("trash",14) }} Delete</div>
</div> </div>
</div> </div>
@@ -769,7 +769,7 @@ document.addEventListener('alpine:init', () => {
<input type="text" x-model="newFilePath" placeholder="path/to/file.md" class="settings-input" style="margin-bottom:8px;"> <input type="text" x-model="newFilePath" placeholder="path/to/file.md" class="settings-input" style="margin-bottom:8px;">
<textarea x-model="newFileContent" placeholder="File content..." class="settings-input" style="height:120px;margin-bottom:8px;"></textarea> <textarea x-model="newFileContent" placeholder="File content..." class="settings-input" style="height:120px;margin-bottom:8px;"></textarea>
<input type="text" x-model="newFileMsg" placeholder="Commit message" class="settings-input" style="margin-bottom:8px;"> <input type="text" x-model="newFileMsg" placeholder="Commit message" class="settings-input" style="margin-bottom:8px;">
<button class="btn btn-primary" @click="createNewFile()">💾 Create file</button> <button class="btn btn-primary" @click="createNewFile()">{{ fd_icon("save",14) }} Create file</button>
</div> </div>
</div> </div>
</template> </template>
@@ -778,7 +778,7 @@ document.addEventListener('alpine:init', () => {
<template x-if="showPrivate && !editingPrivate"> <template x-if="showPrivate && !editingPrivate">
<div class="gw-content"> <div class="gw-content">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3>🔒 Private Pages</h3> <h3>{{ fd_icon("lock",14) }} Private Pages</h3>
<button class="btn btn-primary" onclick="window.FlowDeck.createPage()">+ New Page</button> <button class="btn btn-primary" onclick="window.FlowDeck.createPage()">+ New Page</button>
</div> </div>
<template x-if="privatePages.length === 0"> <template x-if="privatePages.length === 0">
@@ -793,7 +793,7 @@ document.addEventListener('alpine:init', () => {
<div style="font-weight:500;" x-text="pp.title"></div> <div style="font-weight:500;" x-text="pp.title"></div>
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div> <div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
</div> </div>
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">🗑</button> <button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">{{ fd_icon('trash',14) }}</button>
</div> </div>
</template> </template>
</div> </div>
@@ -805,7 +805,7 @@ document.addEventListener('alpine:init', () => {
<div class="gw-file-toolbar"> <div class="gw-file-toolbar">
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong> <strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
<span style="flex:1;"></span> <span style="flex:1;"></span>
<button class="btn" @click="savePrivate()">💾 Save</button> <button class="btn" @click="savePrivate()">{{ fd_icon("save",14) }} Save</button>
<button class="btn" @click="editingPrivate=null;editingPrivateTitle='';editingPrivateContent='';">Cancel</button> <button class="btn" @click="editingPrivate=null;editingPrivateTitle='';editingPrivateContent='';">Cancel</button>
</div> </div>
<div class="gw-content"> <div class="gw-content">
+9 -8
View File
@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
{% from '_icons.html' import fd_icon %}
<title>FlowDeck — All-in-one workspace</title> <title>FlowDeck — All-in-one workspace</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<style> <style>
@@ -142,7 +143,7 @@
<nav class="landing-nav"> <nav class="landing-nav">
<a href="/" class="landing-logo"> <a href="/" class="landing-logo">
<span>📚</span> FlowDeck <span>{{ fd_icon("book",20) }}</span> FlowDeck
</a> </a>
<div class="landing-nav-actions"> <div class="landing-nav-actions">
<a href="/auth/login?provider=local" class="btn btn-secondary">Log in</a> <a href="/auth/login?provider=local" class="btn btn-secondary">Log in</a>
@@ -158,39 +159,39 @@
All on your own server. All on your own server.
</p> </p>
<div class="hero-actions"> <div class="hero-actions">
<a href="/auth/register" class="btn btn-primary">🚀 Get started — it's free</a> <a href="/auth/register" class="btn btn-primary">Get started — it's free</a>
<a href="/auth/login?provider=local" class="btn btn-secondary">I already have an account</a> <a href="/auth/login?provider=local" class="btn btn-secondary">I already have an account</a>
</div> </div>
</section> </section>
<div class="features"> <div class="features">
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon">📝</div> <div class="feature-icon">{{ fd_icon("edit",24) }}</div>
<h3>Block Editor</h3> <h3>Block Editor</h3>
<p>Notion-style editor with slash commands, markdown shortcuts, headings, code blocks, to-dos, and more.</p> <p>Notion-style editor with slash commands, markdown shortcuts, headings, code blocks, to-dos, and more.</p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon">📊</div> <div class="feature-icon">{{ fd_icon("bar-chart",24) }}</div>
<h3>Kanban & Tables</h3> <h3>Kanban & Tables</h3>
<p>Visual project management with drag-drop boards, table views, and database collections.</p> <p>Visual project management with drag-drop boards, table views, and database collections.</p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon">🦎</div> <div class="feature-icon">{{ fd_icon("link",24) }}</div>
<h3>Gitea & GitHub</h3> <h3>Gitea & GitHub</h3>
<p>Browse repos, edit files, commit changes — all from your workspace sidebar.</p> <p>Browse repos, edit files, commit changes — all from your workspace sidebar.</p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon">🌐</div> <div class="feature-icon">{{ fd_icon("globe",24) }}</div>
<h3>Publish to Web</h3> <h3>Publish to Web</h3>
<p>One-click publish any page to a public URL. Share with anyone, no account needed.</p> <p>One-click publish any page to a public URL. Share with anyone, no account needed.</p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon">🔒</div> <div class="feature-icon">{{ fd_icon("lock",24) }}</div>
<h3>Self-Hosted</h3> <h3>Self-Hosted</h3>
<p>Your data stays on your server. No vendor lock-in, full control, Docker single-container deploy.</p> <p>Your data stays on your server. No vendor lock-in, full control, Docker single-container deploy.</p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon">⚡</div> <div class="feature-icon">{{ fd_icon("zap",24) }}</div>
<h3>Fast & Light</h3> <h3>Fast & Light</h3>
<p>Alpine.js + FastAPI + SQLite. No React bloat, no Node.js needed. Sub-100ms page loads.</p> <p>Alpine.js + FastAPI + SQLite. No React bloat, no Node.js needed. Sub-100ms page loads.</p>
</div> </div>
+37 -15
View File
@@ -1,10 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_icon %}📚{% endblock %} {% block page_icon %}{{ fd_icon("book",18) }}{% endblock %}
{% block page_title %}Library{% endblock %} {% block page_title %}Library{% endblock %}
{% block title_prefix %}Library{% endblock %} {% block title_prefix %}Library{% endblock %}
{% block topbar %} {% block topbar %}
{% set page_icon = "📚" %} {% set page_icon = "book" %}
{% set page_title = "Library" %} {% set page_title = "Library" %}
{% include '_header.html' %} {% include '_header.html' %}
{% endblock %} {% endblock %}
@@ -186,7 +186,7 @@
<div class="lib-header"> <div class="lib-header">
<h1>Library</h1> <h1>Library</h1>
<div class="lib-header-actions"> <div class="lib-header-actions">
<button class="lib-btn-primary" onclick="window.FlowDeck.createPage()"> <button class="lib-btn-primary" x-show="!noWorkspace" onclick="window.FlowDeck.createPage()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New page New page
</button> </button>
@@ -302,8 +302,14 @@
</div> </div>
<!-- ── Loading ── --> <!-- ── Loading ── -->
<div class="lib-loading" id="lib-loading"> <div class="lib-loading" id="lib-loading" x-show="loading">
<p>Loading…</p> <p x-show="!noWorkspace">Loading…</p>
<div x-show="noWorkspace" style="padding:40px;text-align:center;">
<div style="margin-bottom:16px;">{{ fd_icon("folder",32) }}</div>
<h3 style="font-size:18px;margin:0 0 8px;">No workspace open</h3>
<p style="color:var(--text-dim);font-size:14px;margin:0 0 20px;">Open a workspace to browse and manage your library items.</p>
<a href="/workspaces" class="btn btn-primary" style="display:inline-block;text-decoration:none;padding:8px 20px;">Go to Workspaces</a>
</div>
</div> </div>
<!-- ── Table ── --> <!-- ── Table ── -->
@@ -325,7 +331,7 @@
</div> </div>
<!-- "+ Add new" (root level) --> <!-- "+ Add new" (root level) -->
<div class="add-new-row" x-show="!loading && tab !== 'favorites' && tab !== 'shared' && tab !== 'published' && tab !== 'repository'" <div class="add-new-row" x-show="!loading && !noWorkspace && tab !== 'favorites' && tab !== 'shared' && tab !== 'published' && tab !== 'repository'"
onclick="window.FlowDeck.createPage()"> onclick="window.FlowDeck.createPage()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add new Add new
@@ -333,7 +339,7 @@
<!-- ── Empty state ── --> <!-- ── Empty state ── -->
<div class="lib-empty" id="lib-empty"> <div class="lib-empty" id="lib-empty">
<div class="empty-icon" x-text="emptyIcon"></div> <div class="empty-icon" x-html="getSvgIcon(emptyIcon,48)"></div>
<h3 x-text="emptyTitle"></h3> <h3 x-text="emptyTitle"></h3>
<p x-text="emptyText"></p> <p x-text="emptyText"></p>
</div> </div>
@@ -389,10 +395,10 @@
<input type="text" placeholder="Search pages..." x-model="moveSearchQuery" @input="doMoveSearch()"> <input type="text" placeholder="Search pages..." x-model="moveSearchQuery" @input="doMoveSearch()">
</div> </div>
<div class="move-modal-list"> <div class="move-modal-list">
<div class="move-modal-item" @click="confirmMove(0)">📄 <span>Root (no parent)</span></div> <div class="move-modal-item" @click="confirmMove(0)">{{ fd_icon("file",14) }} <span>Root (no parent)</span></div>
<template x-for="it in moveCandidates" :key="it.id"> <template x-for="it in moveCandidates" :key="it.id">
<div class="move-modal-item" @click="confirmMove(it.id)"> <div class="move-modal-item" @click="confirmMove(it.id)">
<span x-text="it.icon || '📄'"></span> <span x-text="it.title"></span> <span x-html="it.icon ? getSvgIcon(it.icon,14) : getSvgIcon('file',14)"></span> <span x-text="it.title"></span>
</div> </div>
</template> </template>
</div> </div>
@@ -408,7 +414,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
<div class="peek-title"> <div class="peek-title">
<span x-text="peekItem.icon || '📄'"></span> <span x-html="peekItem.icon ? getSvgIcon(peekItem.icon,14) : getSvgIcon('file',14)"></span>
<span x-text="peekItem.title"></span> <span x-text="peekItem.title"></span>
</div> </div>
<button @click="openItem(peekItem)" title="Open full page"> <button @click="openItem(peekItem)" title="Open full page">
@@ -443,7 +449,7 @@
<div x-show="peekLoading" class="peek-loading">Loading…</div> <div x-show="peekLoading" class="peek-loading">Loading…</div>
<div x-show="!peekLoading && peekContent" x-html="peekContent"></div> <div x-show="!peekLoading && peekContent" x-html="peekContent"></div>
<div x-show="!peekLoading && !peekContent" class="peek-empty"> <div x-show="!peekLoading && !peekContent" class="peek-empty">
<div style="font-size:48px;margin-bottom:8px;">📄</div> <div style="margin-bottom:8px;">{{ fd_icon("file",32) }}</div>
<div style="font-size:14px;color:var(--text-secondary);">No preview available</div> <div style="font-size:14px;color:var(--text-secondary);">No preview available</div>
</div> </div>
</div> </div>
@@ -501,6 +507,9 @@ function libraryPage() {
moveModalOpen: false, moveModalOpen: false,
moveIds: [], moveIds: [],
moveSearchQuery: '', moveSearchQuery: '',
// No workspace guard
noWorkspace: false,
moveCandidates: [], moveCandidates: [],
_moveAllCandidates: [], _moveAllCandidates: [],
@@ -579,7 +588,7 @@ function libraryPage() {
if (isFolder) { if (isFolder) {
html += '<span class="row-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/></svg></span>'; html += '<span class="row-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/></svg></span>';
} else { } else {
html += '<span class="row-icon">' + (item.icon || '\ud83d\udcc4') + '</span>'; html += '<span class="row-icon">' + self._renderIcon(item.icon, 'file') + '</span>';
} }
// Title or rename input // Title or rename input
if (isRenaming) { if (isRenaming) {
@@ -608,6 +617,13 @@ function libraryPage() {
_escHtml(s) { var d=document.createElement('div'); d.textContent = s||''; return d.innerHTML; }, _escHtml(s) { var d=document.createElement('div'); d.textContent = s||''; return d.innerHTML; },
_escAttr(s) { return String(s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }, _escAttr(s) { return String(s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); },
_renderIcon(icon, fallback) {
var name = icon || fallback || 'file';
// Only allow known icon names, otherwise use fallback
var valid = ['folder','file','star','link','trash','book','home','settings','lock','globe','image','edit','calendar','users','user','search','tag','bar-chart','grid','list','align-left','zap','paperclip','key','refresh','upload'];
if (valid.indexOf(name) === -1) name = fallback || 'file';
return getSvgIcon(name, 14);
},
_recomputeFlatItems() { _recomputeFlatItems() {
var result = []; var result = [];
@@ -773,13 +789,13 @@ function libraryPage() {
}, },
// ── Empty states ── // ── Empty states ──
emptyIcon: '📄', emptyIcon: 'file',
emptyTitle: 'No items', emptyTitle: 'No items',
emptyText: '', emptyText: '',
_recomputeEmpty() { _recomputeEmpty() {
var icons = {recents:'🕒',favorites:'⭐',shared:'👥',published:'🌐',private:'🔒',workspace:'📁',repository:'📦'}; var icons = {recents:'clock',favorites:'star',shared:'users',published:'globe',private:'lock',workspace:'folder',repository:'folder'};
this.emptyIcon = icons[this.tab] || '📄'; this.emptyIcon = icons[this.tab] || 'file';
var titles = {recents:'No recent pages',favorites:'No favorites',shared:'No shared pages', var titles = {recents:'No recent pages',favorites:'No favorites',shared:'No shared pages',
published:'No published pages',private:'No private pages',workspace:'No workspace pages', published:'No published pages',private:'No private pages',workspace:'No workspace pages',
repository:'Empty repository'}; repository:'Empty repository'};
@@ -804,6 +820,12 @@ function libraryPage() {
this.isGiteaActive = (giteaEl ? giteaEl.textContent : '0') === '1'; this.isGiteaActive = (giteaEl ? giteaEl.textContent : '0') === '1';
this.giteaOwner = (document.getElementById('lib-gitea-owner') || {}).textContent || ''; this.giteaOwner = (document.getElementById('lib-gitea-owner') || {}).textContent || '';
this.giteaRepo = (document.getElementById('lib-gitea-repo') || {}).textContent || ''; this.giteaRepo = (document.getElementById('lib-gitea-repo') || {}).textContent || '';
// Detect no workspace: no local wsId AND no Gitea active
if (!this.workspaceId && !this.isGiteaActive) {
this.noWorkspace = true;
this.loading = false; // don't show loading spinner
return;
}
console.log('[Library] workspaceId=' + this.workspaceId + ' isGitea=' + this.isGiteaActive); console.log('[Library] workspaceId=' + this.workspaceId + ' isGitea=' + this.isGiteaActive);
var params = new URLSearchParams(window.location.search); var params = new URLSearchParams(window.location.search);
var t = params.get('tab'); var t = params.get('tab');
+369 -181
View File
@@ -1,10 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_title %}{{ workspace_name }}{% endblock %} {% block page_title %}{{ workspace_name }}{% endblock %}
{% block title_prefix %}{{ workspace_name }}{% endblock %} {% block title_prefix %}{{ workspace_name }}{% endblock %}
{% block page_icon %}📁{% endblock %} {% block page_icon %}{{ fd_icon("folder",18) }}{% endblock %}
{% block topbar %} {% block topbar %}
{% set page_icon = "📁" %} {% set page_icon = "folder" %}
{% set page_title = workspace_name %} {% set page_title = workspace_name %}
{% include '_header.html' %} {% include '_header.html' %}
{% endblock %} {% endblock %}
@@ -17,12 +17,141 @@
@drop.prevent="onDrop($event)" @drop.prevent="onDrop($event)"
@keydown="onKeydown($event)"> @keydown="onKeydown($event)">
<style> <style>
.ws-layout{max-width:800px;margin:0 auto;padding:40px 24px;}/* deprecated by ws-split */ /* ── Library-style layout ── */
@media (max-width: 768px) { .ws-layout { padding: 20px 12px; } } .ws-container{max-width:1100px;margin:0 auto;padding:20px 24px;}
.ws-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;flex-wrap:wrap;gap:12px;} .ws-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;}
.ws-header h1{font-size:24px;} .ws-header h1{font-size:24px;font-weight:700;color:var(--text-primary);margin:0;}
.ws-toolbar{display:flex;gap:8px;}
/* ── Tabs row ── */
.ws-tabs-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;padding:4px 0;}
.ws-tabs{display:flex;gap:2px;align-items:center;overflow-x:auto;scrollbar-width:none;}
.ws-tabs::-webkit-scrollbar{display:none;}
.ws-tab{display:flex;align-items:center;gap:6px;padding:6px 12px;font-size:13px;font-weight:500;color:var(--text-secondary);background:transparent;border:none;border-radius:6px;cursor:pointer;transition:all 0.12s;white-space:nowrap;}
.ws-tab:hover{color:var(--text-primary);background:var(--bg-hover);}
.ws-tab.active{color:var(--text-primary);background:var(--bg-active);}
.ws-tab svg{width:14px;height:14px;flex-shrink:0;}
.ws-toolbar{display:flex;align-items:center;gap:2px;flex-shrink:0;}
.ws-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;background:transparent;border:none;color:var(--text-secondary);cursor:pointer;transition:background 0.1s,color 0.1s;}
.ws-icon-btn:hover,.ws-icon-btn.active{background:var(--bg-hover);color:var(--text-primary);}
.ws-icon-btn svg{width:16px;height:16px;}
.ws-btn-primary{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;font-size:13px;font-weight:500;color:#fff;background:var(--accent);border:none;border-radius:6px;cursor:pointer;transition:background 0.1s;}
.ws-btn-primary:hover{background:var(--accent-hover);}
.ws-btn-primary svg{width:14px;height:14px;}
/* ── Dropdowns ── */
.ws-dropdown{position:absolute;top:calc(100% + 4px);right:0;min-width:210px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow-popover);z-index:200;padding:6px;}
.ws-dropdown .dd-label{padding:6px 8px 2px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.3px;}
.ws-dropdown .dd-item{padding:6px 8px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:8px;transition:background 0.1s;}
.ws-dropdown .dd-item:hover{background:var(--bg-hover);}
.ws-dropdown .dd-item.active{color:var(--accent);}
.ws-dropdown .dd-item .check{width:14px;flex-shrink:0;color:var(--accent);}
.lib-toolbar-wrap{position:relative;}
/* ── Search bar ── */
.ws-search-bar{margin-bottom:10px;}
.ws-search-bar input{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);color:var(--text-primary);font-size:13px;outline:none;}
.ws-search-bar input:focus{border-color:var(--accent);}
/* ── Multi-select bar ── */
.ws-select-bar{display:flex;align-items:center;gap:4px;padding:6px 10px;background:var(--bg-secondary);border-radius:8px;margin-bottom:8px;font-size:13px;color:var(--text-secondary);min-height:38px;}
.ws-select-bar .sel-count{font-weight:500;color:var(--text-primary);margin-right:8px;white-space:nowrap;}
.ws-select-bar button{background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:6px 8px;border-radius:6px;font-size:13px;display:flex;align-items:center;gap:6px;transition:all 0.12s;}
.ws-select-bar button:hover{background:var(--bg-hover);color:var(--text-primary);}
.ws-select-bar button svg{width:14px;height:14px;}
/* ── Table (Notion-style rows) ── */
.ws-table{width:100%;}
.ws-table-header{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:0 4px;}
.ws-table-header .th{display:flex;align-items:center;gap:5px;padding:6px 8px;font-size:12px;font-weight:500;color:var(--text-dim);user-select:none;white-space:nowrap;}
.ws-table-header .th svg{width:13px;height:13px;flex-shrink:0;}
/* Column widths */
.ws-col-name{flex:1;min-width:0;}
.ws-col-type{width:80px;flex-shrink:0;}
.ws-col-date{width:110px;flex-shrink:0;justify-content:flex-end;}
/* ── Row ── */
.ws-row{display:flex;align-items:center;height:34px;transition:background 0.1s;position:relative;}
.ws-row:hover{background:var(--bg-hover);}
.ws-row.selected{background:rgba(35,131,226,0.08);}
.ws-row.drag-over{outline:2px solid var(--accent);outline-offset:-2px;background:rgba(35,131,226,0.12);}
.ws-row.dragging{opacity:0.4;}
.ws-row .row-cell{display:flex;align-items:center;padding:0 8px;height:100%;overflow:hidden;}
.ws-row .row-cell.ws-col-date{color:var(--text-secondary);font-size:13px;white-space:nowrap;}
/* ── Name cell ── */
.row-name{display:flex;align-items:center;gap:6px;min-width:0;flex:1;position:relative;}
.row-icon{font-size:15px;flex-shrink:0;width:20px;text-align:center;display:inline-flex;align-items:center;justify-content:center;}
.row-icon svg{width:16px;height:16px;color:var(--text-dim);}
.row-title{font-size:13px;font-weight:400;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;cursor:pointer;}
.row-title:hover{text-decoration:underline;}
.row-title input{width:100%;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;}
/* ── Hover-only elements ── */
.hover-only{opacity:0;transition:opacity 0.12s;flex-shrink:0;}
.ws-row:hover .hover-only,.ws-row.selected .hover-only{opacity:1;}
/* ── Drag handle ── */
.drag-handle{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;cursor:grab;flex-shrink:0;margin-right:2px;}
.drag-handle:active{cursor:grabbing;}
.drag-handle svg{width:14px;height:14px;color:var(--text-dim);opacity:0.75;}
/* ── Checkbox ── */
.ws-checkbox{width:15px;height:15px;border:1.5px solid var(--text-dim);border-radius:3px;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:border-color 0.1s;}
.ws-checkbox:hover{border-color:var(--text-primary);}
.ws-checkbox.checked{background:var(--accent);border-color:var(--accent);opacity:1!important;}
.ws-checkbox.checked svg{width:10px;height:10px;color:#fff;}
/* ── OPEN button ── */
.btn-open{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border:1px solid var(--border);border-radius:4px;font-size:11px;font-weight:500;color:var(--text-primary);background:var(--bg-tertiary);cursor:pointer;white-space:nowrap;transition:all 0.1s;flex-shrink:0;text-decoration:none;margin-left:auto;}
.btn-open:hover{background:var(--bg-active);}
.btn-open svg{width:12px;height:12px;}
/* ── Expand chevron ── */
.tree-chevron{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;background:none;border:none;color:var(--text-dim);cursor:pointer;padding:0;border-radius:3px;transition:transform 0.15s,background 0.1s;}
.tree-chevron:hover{color:var(--text-primary);background:var(--bg-active);}
.tree-chevron svg{width:11px;height:11px;transition:transform 0.15s;}
.tree-chevron.open svg{transform:rotate(90deg);}
/* Indent */
.row-indent{flex-shrink:0;}
/* ── "Add new" row ── */
.add-new-row{padding:8px 12px;color:var(--text-dim);font-size:13px;cursor:pointer;transition:color 0.15s;display:flex;align-items:center;gap:4px;}
.add-new-row:hover{color:var(--text-primary);}
.add-new-row svg{width:13px;height:13px;}
.add-new-under{padding:5px 8px;color:var(--text-dim);font-size:12px;cursor:pointer;transition:color 0.15s;display:flex;align-items:center;gap:4px;}
.add-new-under:hover{color:var(--text-primary);}
.add-new-under svg{width:12px;height:12px;}
/* ── Context menu ── */
.more-menu{position:fixed;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow-popover);z-index:600;padding:4px;}
.more-menu .menu-item{padding:6px 10px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:8px;transition:background 0.1s;}
.more-menu .menu-item:hover{background:var(--bg-hover);}
.more-menu .menu-item svg{width:14px;height:14px;flex-shrink:0;}
.more-menu .menu-item .shortcut{margin-left:auto;font-size:11px;color:var(--text-dim);}
.more-menu .menu-danger{color:var(--danger);}
.more-sep{height:1px;background:var(--border);margin:4px 8px;}
/* ── Keep existing functional styles ── */
.breadcrumb-row{display:flex;align-items:center;gap:4px;padding:6px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;margin-bottom:10px;flex-wrap:wrap;}
.breadcrumb-row .nav-arrow{width:26px;height:26px;padding:0;font-size:13px;}
.unified-breadcrumb{flex:1;display:flex;align-items:center;gap:2px;min-width:0;font-size:13px;overflow:hidden;}
.unified-breadcrumb .bc-icon{font-size:15px;flex-shrink:0;}
.unified-breadcrumb .bc-root{color:var(--text-secondary);text-decoration:none;white-space:nowrap;font-weight:500;flex-shrink:0;}
.unified-breadcrumb .bc-root:hover{color:var(--accent);}
.unified-breadcrumb .bc-sep{color:var(--text-tertiary);margin:0 2px;flex-shrink:0;}
.unified-breadcrumb a{color:var(--text-secondary);text-decoration:none;white-space:nowrap;}
.unified-breadcrumb a:hover{color:var(--accent);}
.unified-breadcrumb .bc-current{color:var(--text);font-weight:600;}
.bc-edit-trigger{background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:11px;padding:2px 4px;border-radius:3px;flex-shrink:0;opacity:.5;transition:opacity 150ms;}
.bc-edit-trigger:hover{opacity:1;color:var(--accent);background:var(--bg-hover);}
.bc-edit-wrap{display:flex;align-items:center;gap:4px;flex-shrink:0;}
.bc-edit-input{width:200px;background:var(--bg);border:1px solid var(--accent);border-radius:5px;color:var(--text);font-size:12px;padding:3px 8px;outline:none;font-family:'Fira Code',monospace;}
.bc-edit-go{background:var(--accent);color:#fff;border:none;padding:3px 8px;border-radius:4px;cursor:pointer;font-size:12px;}
.bc-edit-go:hover{opacity:.85;}
/* ── Preserved styles ── */
.ws-tree{list-style:none;margin:0;padding:0;} .ws-tree{list-style:none;margin:0;padding:0;}
.ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;} .ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;}
.ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);} .ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
@@ -34,21 +163,18 @@
.ws-tree-item .name.file:hover{color:var(--accent);} .ws-tree-item .name.file:hover{color:var(--accent);}
.ws-tree-item .actions{display:none;gap:2px;} .ws-tree-item .actions{display:none;gap:2px;}
.ws-tree-item:hover .actions{display:flex;} .ws-tree-item:hover .actions{display:flex;}
/* Chevron in main tree */
.ws-chevron{width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:8px;padding:0;border-radius:3px;transition:transform 150ms,color 100ms;} .ws-chevron{width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:8px;padding:0;border-radius:3px;transition:transform 150ms,color 100ms;}
.ws-chevron:hover{color:var(--text);background:var(--bg-hover);} .ws-chevron:hover{color:var(--text);background:var(--bg-hover);}
.ws-chevron.open{transform:rotate(90deg);} .ws-chevron.open{transform:rotate(90deg);}
.ws-act{background:none;border:none;color:var(--text-tertiary);cursor:pointer;padding:3px 6px;border-radius:4px;font-size:11px;} .ws-act{background:none;border:none;color:var(--text-tertiary);cursor:pointer;padding:3px 6px;border-radius:4px;font-size:11px;}
.ws-act:hover{background:var(--bg-hover);color:var(--text);} .ws-act:hover{background:var(--bg-hover);color:var(--text);}
.ws-act.danger:hover{color:var(--danger);} .ws-act.danger:hover{color:var(--danger);}
.empty-state{text-align:center;padding:80px 20px;color:var(--text-tertiary);} .empty-state{text-align:center;padding:80px 20px;color:var(--text-dim);}
.empty-state .empty-icon{font-size:64px;margin-bottom:16px;opacity:.5;} .empty-state .empty-icon{font-size:48px;margin-bottom:12px;opacity:0.5;}
.empty-state h2{font-size:18px;color:var(--text-secondary);margin-bottom:8px;} .empty-state h3{font-size:16px;color:var(--text-secondary);margin:0 0 4px;}
.empty-state p{font-size:13px;margin-bottom:24px;max-width:400px;margin-left:auto;margin-right:auto;} .empty-state p{font-size:13px;margin:0;}
.empty-actions{display:flex;gap:10px;justify-content:center;} .empty-actions{display:flex;gap:10px;justify-content:center;margin-top:16px;}
.btn{padding:10px 20px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;} .btn{padding:10px 20px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
.btn-primary{background:var(--accent);color:#fff;} .btn-primary{background:var(--accent);color:#fff;}
@@ -270,7 +396,6 @@
.view-menu-item.active{background:rgba(35,131,226,.12);color:var(--accent);font-weight:500;} .view-menu-item.active{background:rgba(35,131,226,.12);color:var(--accent);font-weight:500;}
/* Context menu */ /* Context menu */
.ctx-menu{position:fixed;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow-popover);min-width:200px;z-index:600;padding:4px 0;max-height:80vh;overflow-y:auto;}
.ctx-item{display:flex;align-items:center;gap:8px;padding:7px 14px;font-size:13px;color:var(--text);cursor:pointer;transition:background 100ms;} .ctx-item{display:flex;align-items:center;gap:8px;padding:7px 14px;font-size:13px;color:var(--text);cursor:pointer;transition:background 100ms;}
.ctx-item:hover{background:var(--bg-hover);} .ctx-item:hover{background:var(--bg-hover);}
.ctx-item.ctx-danger{color:var(--danger);} .ctx-item.ctx-danger{color:var(--danger);}
@@ -359,6 +484,7 @@ window._wsInitData = (function() {
currentFolder: {{ current_folder_id }}, currentFolder: {{ current_folder_id }},
searchQuery: '', searchQuery: '',
filterType: '', filterType: '',
filterOpen: false,
editingPath: false, editingPath: false,
folderStack: [], folderStack: [],
@@ -377,7 +503,9 @@ window._wsInitData = (function() {
sortBy: '', sortBy: '',
viewMode: localStorage.getItem('fd_default_view') || 'tree', viewMode: localStorage.getItem('fd_default_view') || 'tree',
viewMenuOpen: false, viewMenuOpen: false,
viewIcons: {tree:'🌳',list:'📋',details:'📊',title:'🏷️',content:'📝'}, sortOpen: false,
searchOpen: false,
viewIcons: {tree:'',list:'',details:'',title:'',content:''},
viewLabels: {tree:'Tree',list:'List',details:'Details',title:'Cards',content:'Content'}, viewLabels: {tree:'Tree',list:'List',details:'Details',title:'Cards',content:'Content'},
// Context menu // Context menu
@@ -677,7 +805,7 @@ window._wsInitData = (function() {
async _loadPreviewContent(node) { async _loadPreviewContent(node) {
if (node.is_folder) { if (node.is_folder) {
this.previewPanelContent = '<div style="text-align:center;padding:40px;color:var(--text-secondary)">' this.previewPanelContent = '<div style="text-align:center;padding:40px;color:var(--text-secondary)">'
+ '<span style="font-size:48px;display:block;margin-bottom:12px">📁</span>' + '<span class="empty-state-icon">'+getSvgIcon('folder',32)+'</span>'
+ '<div style="font-size:14px">' + (node.child_count||0) + ' item(s)</div>' + '<div style="font-size:14px">' + (node.child_count||0) + ' item(s)</div>'
+ '<a href="/local-workspace?folder=' + node.id + '" class="btn btn-secondary" style="display:inline-block;text-decoration:none;margin-top:16px">Open folder</a>' + '<a href="/local-workspace?folder=' + node.id + '" class="btn btn-secondary" style="display:inline-block;text-decoration:none;margin-top:16px">Open folder</a>'
+ '</div>'; + '</div>';
@@ -697,7 +825,7 @@ window._wsInitData = (function() {
// PDF preview // PDF preview
if (/\.pdf$/.test(name)) { if (/\.pdf$/.test(name)) {
this.previewPanelContent = '<div style="text-align:center;padding:24px;color:var(--text-secondary);">' this.previewPanelContent = '<div style="text-align:center;padding:24px;color:var(--text-secondary);">'
+ '<span style="font-size:48px;display:block;margin-bottom:8px;">📕</span>' + '<span style="font-size:48px;display:block;margin-bottom:8px;">'+getSvgIcon('file',32)+'</span>'
+ '<div style="font-size:14px;margin-bottom:4px;">' + this._escHtml(node.name) + '</div>' + '<div style="font-size:14px;margin-bottom:4px;">' + this._escHtml(node.name) + '</div>'
+ '<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:16px;">' + (node.size_display || '') + '</div>' + '<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:16px;">' + (node.size_display || '') + '</div>'
+ '<a href="/pages/' + node.id + '" class="btn btn-secondary" style="display:inline-block;text-decoration:none;">Open in viewer</a>' + '<a href="/pages/' + node.id + '" class="btn btn-secondary" style="display:inline-block;text-decoration:none;">Open in viewer</a>'
@@ -719,7 +847,7 @@ window._wsInitData = (function() {
} }
} catch(e) {} } catch(e) {}
preview = '<div style="text-align:center;padding:20px;color:var(--text-secondary)">' preview = '<div style="text-align:center;padding:20px;color:var(--text-secondary)">'
+ '<span style="font-size:48px;display:block;margin-bottom:8px">📄</span>' + '<span class="empty-state-icon">'+getSvgIcon('file',32)+'</span>'
+ (node.size_display || '') + ' — open to view</div>'; + (node.size_display || '') + ' — open to view</div>';
} else if (format === 'blocks') { } else if (format === 'blocks') {
try { try {
@@ -750,7 +878,7 @@ window._wsInitData = (function() {
// ── Context menu ── // ── Context menu ──
onContextMenu(ev) { onContextMenu(ev) {
var el = ev.target.closest('[data-ws-id]'); var el = ev.target.closest('[data-ws-id]');
var menu = document.querySelector('.ctx-menu'); var menu = document.querySelector('.more-menu');
if (!el || !menu) { if (menu) menu.style.display = 'none'; return; } if (!el || !menu) { if (menu) menu.style.display = 'none'; return; }
var id = parseInt(el.getAttribute('data-ws-id')); var id = parseInt(el.getAttribute('data-ws-id'));
if (!id) { menu.style.display = 'none'; return; } if (!id) { menu.style.display = 'none'; return; }
@@ -791,7 +919,7 @@ window._wsInitData = (function() {
c.ctxAvailTags = this.ctxAvailTags; c.ctxAvailTags = this.ctxAvailTags;
c.ctxNewTagColor = this.ctxNewTagColor; c.ctxNewTagColor = this.ctxNewTagColor;
// Also force Alpine update on the menu element // Also force Alpine update on the menu element
var menuEl = document.querySelector('.ctx-menu'); var menuEl = document.querySelector('.more-menu');
if (menuEl && menuEl._x_dataStack && menuEl._x_dataStack[0]) { if (menuEl && menuEl._x_dataStack && menuEl._x_dataStack[0]) {
var d = menuEl._x_dataStack[0]; var d = menuEl._x_dataStack[0];
d.ctxMenu = this.ctxMenu; d.ctxMenu = this.ctxMenu;
@@ -848,24 +976,24 @@ window._wsInitData = (function() {
ctxMenuOpenPage() { ctxMenuOpenPage() {
if (this.ctxMenu.node) this.openPage(this.ctxMenu.node.id); if (this.ctxMenu.node) this.openPage(this.ctxMenu.node.id);
document.querySelector('.ctx-menu').style.display = 'none'; document.querySelector('.more-menu').style.display = 'none';
}, },
ctxMenuOpenFolder() { ctxMenuOpenFolder() {
if (this.ctxMenu.node) this.navigateToFolder(this.ctxMenu.node.id); if (this.ctxMenu.node) this.navigateToFolder(this.ctxMenu.node.id);
document.querySelector('.ctx-menu').style.display = 'none'; document.querySelector('.more-menu').style.display = 'none';
}, },
ctxMenuRename() { ctxMenuRename() {
if (this.ctxMenu.node) { this.startRename(this.ctxMenu.node); document.querySelector('.ctx-menu').style.display = 'none'; } if (this.ctxMenu.node) { this.startRename(this.ctxMenu.node); document.querySelector('.more-menu').style.display = 'none'; }
}, },
ctxMenuDuplicate() { ctxMenuDuplicate() {
if (this.ctxMenu.node) { this.duplicateItem(this.ctxMenu.node); document.querySelector('.ctx-menu').style.display = 'none'; } if (this.ctxMenu.node) { this.duplicateItem(this.ctxMenu.node); document.querySelector('.more-menu').style.display = 'none'; }
}, },
ctxMenuDelete() { ctxMenuDelete() {
if (this.ctxMenu.node) { this.deleteWithUndo(this.ctxMenu.node); document.querySelector('.ctx-menu').style.display = 'none'; } if (this.ctxMenu.node) { this.deleteWithUndo(this.ctxMenu.node); document.querySelector('.more-menu').style.display = 'none'; }
}, },
ctxRemoveTag(tagId) { ctxRemoveTag(tagId) {
@@ -881,7 +1009,7 @@ window._wsInitData = (function() {
window._ctxMenuData.ctxAvailTags = this.ctxAvailTags; window._ctxMenuData.ctxAvailTags = this.ctxAvailTags;
window._ctxMenuData.ctxMenu = this.ctxMenu; window._ctxMenuData.ctxMenu = this.ctxMenu;
// Also force Alpine update // Also force Alpine update
var menuEl = document.querySelector('.ctx-menu'); var menuEl = document.querySelector('.more-menu');
if (menuEl && menuEl._x_dataStack && menuEl._x_dataStack[0]) { if (menuEl && menuEl._x_dataStack && menuEl._x_dataStack[0]) {
menuEl._x_dataStack[0].ctxAvailTags = [...this.ctxAvailTags]; menuEl._x_dataStack[0].ctxAvailTags = [...this.ctxAvailTags];
menuEl._x_dataStack[0].ctxMenu = this.ctxMenu; menuEl._x_dataStack[0].ctxMenu = this.ctxMenu;
@@ -914,13 +1042,13 @@ window._wsInitData = (function() {
window._ctxMenuData.ctxAvailTags = self.ctxAvailTags; window._ctxMenuData.ctxAvailTags = self.ctxAvailTags;
window._ctxMenuData.ctxMenu = self.ctxMenu; window._ctxMenuData.ctxMenu = self.ctxMenu;
// Also force Alpine update // Also force Alpine update
var menuEl = document.querySelector('.ctx-menu'); var menuEl = document.querySelector('.more-menu');
if (menuEl && menuEl._x_dataStack && menuEl._x_dataStack[0]) { if (menuEl && menuEl._x_dataStack && menuEl._x_dataStack[0]) {
menuEl._x_dataStack[0].ctxAvailTags = [...self.ctxAvailTags]; menuEl._x_dataStack[0].ctxAvailTags = [...self.ctxAvailTags];
menuEl._x_dataStack[0].ctxMenu = self.ctxMenu; menuEl._x_dataStack[0].ctxMenu = self.ctxMenu;
} }
// Close menu after successful add // Close menu after successful add
document.querySelector('.ctx-menu').style.display = 'none'; document.querySelector('.more-menu').style.display = 'none';
}); }, 200); }); }, 200);
} }
this.ctxTagAdding = false; this.ctxTagAdding = false;
@@ -988,12 +1116,13 @@ window._wsInitData = (function() {
html += '<div class="actions">'; html += '<div class="actions">';
if (c.is_folder) { if (c.is_folder) {
html += '<span style="display:flex;gap:2px">'; html += '<span style="display:flex;gap:2px">';
html += '<button class="ws-act" data-ws-action="new-file" data-ws-id="'+escId+'" data-ws-name="'+escName+'" title="New file">\ud83d\udcc4</button>'; html += '<button class="ws-act" data-ws-action="new-file" data-ws-id="'+escId+'" data-ws-name="'+escName+'" title="New file">'+getSvgIcon('file',14)+'</button>';
html += '<button class="ws-act" data-ws-action="new-folder" data-ws-id="'+escId+'" data-ws-name="'+escName+'" title="New folder">\ud83d\udcc1</button>'; html += '<button class="ws-act" data-ws-action="new-folder" data-ws-id="'+escId+'" data-ws-name="'+escName+'" title="New folder">'+getSvgIcon('folder',14)+'</button>';
html += '<a class="ws-act" href="/local-workspace" title="Open workspace page" style="text-decoration:none;display:inline-flex;align-items:center;">'+getSvgIcon('external-link',14)+'</a>';
html += '</span>'; html += '</span>';
} }
html += '<button class="ws-act" data-ws-action="rename" data-ws-id="'+escId+'" data-ws-name="'+escName+'" title="Rename">\u270f\ufe0f</button>'; html += '<button class="ws-act" data-ws-action="rename" data-ws-id="'+escId+'" data-ws-name="'+escName+'" title="Rename">'+getSvgIcon('edit',14)+'</button>';
html += '<button class="ws-act danger" data-ws-action="delete" data-ws-id="'+escId+'" data-ws-name="'+escName+'" title="Delete">\ud83d\uddd1</button>'; html += '<button class="ws-act danger" data-ws-action="delete" data-ws-id="'+escId+'" data-ws-name="'+escName+'" title="Delete">'+getSvgIcon('trash',14)+'</button>';
// Open button for files // Open button for files
if (!c.is_folder) { if (!c.is_folder) {
html += '<a href="/pages/'+escId+'" class="btn-open" style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border:1px solid var(--border);border-radius:4px;font-size:12px;color:var(--text-secondary);background:var(--bg-tertiary);cursor:pointer;text-decoration:none;margin-left:4px;opacity:0;"' html += '<a href="/pages/'+escId+'" class="btn-open" style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border:1px solid var(--border);border-radius:4px;font-size:12px;color:var(--text-secondary);background:var(--bg-tertiary);cursor:pointer;text-decoration:none;margin-left:4px;opacity:0;"'
@@ -1394,7 +1523,7 @@ window._wsInitData = (function() {
var html = '<img src="'+url+'" style="max-width:380px;max-height:180px;display:block;">'; var html = '<img src="'+url+'" style="max-width:380px;max-height:180px;display:block;">';
this.previewContent = html; this.previewCache[cacheKey] = html; this.previewContent = html; this.previewCache[cacheKey] = html;
} else if (isPdf) { } else if (isPdf) {
var html = '<div style="text-align:center;padding:16px;"><span style="font-size:48px;">📕</span><br><span style="color:var(--text-secondary);font-size:12px;">PDF Document</span><br><a href="/pages/'+node.id+'" style="color:var(--accent);font-size:11px;">Open viewer →</a></div>'; var html = '<div style="text-align:center;padding:16px;">'+getSvgIcon('file',48)+'<br><span style="color:var(--text-secondary);font-size:12px;">PDF Document</span><br><a href="/pages/'+node.id+'" style="color:var(--accent);font-size:11px;">Open viewer →</a></div>';
this.previewContent = html; this.previewCache[cacheKey] = html; this.previewContent = html; this.previewCache[cacheKey] = html;
} else { } else {
this.previewContent = 'Loading...'; this.previewContent = 'Loading...';
@@ -1475,25 +1604,25 @@ window._wsInitData = (function() {
// ── File type icon ── // ── File type icon ──
_fileIcon(name, isFolder, contentFormat) { _fileIcon(name, isFolder, contentFormat) {
if (isFolder) return '📁'; if (isFolder) return '<span class="page-icon-svg">'+getSvgIcon('folder',14)+'</span>';
// FlowDeck internal pages (no extension, blocks/markdown format) // FlowDeck internal pages (no extension, blocks/markdown format)
if (contentFormat && contentFormat !== 'file') return '📝'; if (contentFormat && contentFormat !== 'file') return '<span class="page-icon-svg">'+getSvgIcon('edit',14)+'</span>';
var n = (name || '').toLowerCase(); var n = (name || '').toLowerCase();
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return '🖼️'; if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('image',14)+'</span>';
if (/\.pdf$/.test(n)) return '📕'; if (/\.pdf$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.(md|markdown)$/.test(n)) return '📝'; if (/\.(md|markdown)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('edit',14)+'</span>';
if (/\.(py)$/.test(n)) return '🐍'; if (/\.(py)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.(js|jsx|ts|tsx)$/.test(n)) return '📜'; if (/\.(js|jsx|ts|tsx)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.(html?|xml)$/.test(n)) return '🌐'; if (/\.(html?|xml)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.css$/.test(n)) return '🎨'; if (/\.css$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.json$/.test(n)) return '📋'; if (/\.json$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.sql$/.test(n)) return '🗃️'; if (/\.sql$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.(sh|bash|zsh)$/.test(n)) return '💻'; if (/\.(sh|bash|zsh)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.ps1$/.test(n)) return '⚡'; if (/\.ps1$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$/.test(n)) return '📜'; if (/\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.(txt|log)$/.test(n)) return '📄'; if (/\.(txt|log)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.(zip|tar|gz|rar|7z)$/.test(n)) return '📦'; if (/\.(zip|tar|gz|rar|7z)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
return '📄'; return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
}, },
// ── Search / Filter ── // ── Search / Filter ──
@@ -1501,11 +1630,10 @@ window._wsInitData = (function() {
var q = (this.searchQuery || '').toLowerCase().trim(); var q = (this.searchQuery || '').toLowerCase().trim();
var ft = this.filterType || ''; var ft = this.filterType || '';
if (!q && !ft && !this.tagFilter) { this.displayTree = [...this.tree]; this.showAll = false; this._computeFilteredTableItems(); return; } 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) { if (this.tagFilter) {
// Filter tree by tag this.displayTree = this._filterTreeByTag(this.displayTree, this.tagFilter);
this.displayTree = this._filterTreeByTag([...this.tree], this.tagFilter);
} else {
this.displayTree = [...this.tree];
} }
this._computeFilteredTableItems(); this._computeFilteredTableItems();
}, },
@@ -2029,16 +2157,16 @@ window._wsInitData = (function() {
}, },
_fileTypeLabel(name, contentFormat) { _fileTypeLabel(name, contentFormat) {
if (contentFormat && contentFormat !== 'file') return '📝 Page'; if (contentFormat && contentFormat !== 'file') return 'Page';
var n = (name || '').toLowerCase(); var n = (name || '').toLowerCase();
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return '🖼️ Image'; if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return 'Image';
if (/\.pdf$/.test(n)) return '📕 PDF'; if (/\.pdf$/.test(n)) return 'PDF';
if (/\.(py|js|jsx|ts|tsx|rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r|sh|bash|ps1|sql)$/.test(n)) return '📜 Code'; if (/\.(py|js|jsx|ts|tsx|rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r|sh|bash|ps1|sql)$/.test(n)) return 'Code';
if (/\.(txt|md|markdown|log)$/.test(n)) return '📄 Text'; if (/\.(txt|md|markdown|log)$/.test(n)) return 'Text';
if (/\.(zip|tar|gz|rar|7z)$/.test(n)) return '📦 Archive'; if (/\.(zip|tar|gz|rar|7z)$/.test(n)) return 'Archive';
if (/\.(mp3|wav|ogg|flac|aac)$/.test(n)) return '🎵 Audio'; if (/\.(mp3|wav|ogg|flac|aac)$/.test(n)) return 'Audio';
if (/\.(mp4|webm|mov|avi)$/.test(n)) return '🎬 Video'; if (/\.(mp4|webm|mov|avi)$/.test(n)) return 'Video';
return '📄 File'; return 'File';
}, },
selectAllFiltered(ev) { selectAllFiltered(ev) {
@@ -2097,7 +2225,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<!-- Drop overlay --> <!-- Drop overlay -->
<div class="drop-overlay" :class="{ active: isDragging }"> <div class="drop-overlay" :class="{ active: isDragging }">
<div class="drop-hint"> <div class="drop-hint">
<span class="icon">📥</span> {{ fd_icon("download",16) }}
<div>Drop files here</div> <div>Drop files here</div>
<div style="font-size:12px;color:var(--text-tertiary);margin-top:6px;" x-show="dropTarget"> <div style="font-size:12px;color:var(--text-tertiary);margin-top:6px;" x-show="dropTarget">
Into: <strong x-text="dropTargetName"></strong> Into: <strong x-text="dropTargetName"></strong>
@@ -2124,14 +2252,15 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<button class="undo-dismiss" @click="undoVisible=false">×</button> <button class="undo-dismiss" @click="undoVisible=false">×</button>
</div> </div>
<!-- Breadcrumb row --> <!-- Breadcrumb + toolbar row (Library-style) -->
<div class="breadcrumb-row"> <div class="breadcrumb-row">
<button class="btn-icon nav-arrow" x-show="sidebarCollapsed" x-cloak @click="sidebarCollapsed = false" title="Show sidebar" style="margin-right:2px;">☰</button> <button class="btn-icon nav-arrow" x-show="sidebarCollapsed" x-cloak @click="sidebarCollapsed = false" title="Show sidebar">{{ fd_icon("chevron-right",14) }}</button>
<a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">🏠</a> <a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">{{ fd_icon("home",14) }}</a>
<button class="btn-icon nav-arrow" @click="goBack()" title="Back" :disabled="!canGoBack">←</button> <button class="btn-icon nav-arrow" @click="goBack()" title="Back" :disabled="!canGoBack">{{ fd_icon("chevron-left",14) }}</button>
<button class="btn-icon nav-arrow" @click="goForward()" title="Forward" :disabled="!canGoForward">→</button> <button class="btn-icon nav-arrow" @click="goForward()" title="Forward" :disabled="!canGoForward">{{ fd_icon("chevron-right",14) }}</button>
{% if breadcrumbs %}
<div class="unified-breadcrumb"> <div class="unified-breadcrumb">
<span class="bc-icon">📁</span> <span class="bc-icon">{{ fd_icon("folder",13) }}</span>
<a href="/local-workspace" @click.prevent="navigateToRoot()" class="bc-root">{{ workspace_name }}</a> <a href="/local-workspace" @click.prevent="navigateToRoot()" class="bc-root">{{ workspace_name }}</a>
<template x-for="(b, i) in folderStack" :key="'bc'+b.id"> <template x-for="(b, i) in folderStack" :key="'bc'+b.id">
<span style="display:contents;"> <span style="display:contents;">
@@ -2146,75 +2275,105 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
@keydown.enter="navigateToPath(), editingPath=false" @keydown.enter="navigateToPath(), editingPath=false"
@keydown.escape="editingPath=false" @keydown.escape="editingPath=false"
placeholder="/workspace/..."> placeholder="/workspace/...">
<button class="bc-edit-go" @click="navigateToPath(), editingPath=false">→</button> <button class="bc-edit-go" @click="navigateToPath(), editingPath=false">{{ fd_icon("corner-down-right",13) }}</button>
</div> </div>
<button class="bc-edit-trigger" x-show="!editingPath" @click="startPathEdit()" title="Edit path">✎</button> <button class="bc-edit-trigger" x-show="!editingPath" @click="startPathEdit()" title="Edit path">{{ fd_icon("edit",11) }}</button>
</div> </div>
</div> {% endif %}
<div class="ws-toolbar" style="margin-left:auto;">
<!-- Toolbar row --> <!-- View dropdown -->
<div class="toolbar-row"> <div class="lib-toolbar-wrap" style="position:relative;">
<div class="view-dropdown" @click.outside="viewMenuOpen=false" @keydown.escape="viewMenuOpen=false"> <button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="viewMenuOpen=!viewMenuOpen; sortOpen=false; searchOpen=false">
<button class="view-dropdown-btn" @click="viewMenuOpen=!viewMenuOpen" title="View"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
<span x-text="viewIcons[viewMode] || '🌳'"></span>
<span x-text="viewLabels[viewMode] || 'Tree'"></span>
<span class="dropdown-arrow">▾</span>
</button> </button>
<div class="view-dropdown-menu" x-show="viewMenuOpen" x-transition @click="viewMenuOpen=false"> <div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
<button class="view-menu-item" :class="{ active: viewMode==='tree' }" @click="viewMode='tree', previewItem=null">🌳 Tree</button> <div class="dd-label">View</div>
<button class="view-menu-item" :class="{ active: viewMode==='list' }" @click="viewMode='list', previewItem=null">📋 List</button> <div class="dd-item" :class="{active: viewMode==='tree'}" @click="viewMode='tree', previewItem=null, viewMenuOpen=false"><span class="check" x-text="viewMode==='tree'?'✓':''"></span>{{ fd_icon("folder",14) }} Tree</div>
<button class="view-menu-item" :class="{ active: viewMode==='details' }" @click="viewMode='details', previewItem=null">📊 Details</button> <div class="dd-item" :class="{active: viewMode==='list'}" @click="viewMode='list', previewItem=null, viewMenuOpen=false"><span class="check" x-text="viewMode==='list'?'✓':''"></span>{{ fd_icon("list",14) }} List</div>
<button class="view-menu-item" :class="{ active: viewMode==='title' }" @click="viewMode='title', previewItem=null">🏷️ Cards</button> <div class="dd-item" :class="{active: viewMode==='details'}" @click="viewMode='details', previewItem=null, viewMenuOpen=false"><span class="check" x-text="viewMode==='details'?'✓':''"></span>{{ fd_icon("bar-chart",14) }} Details</div>
<button class="view-menu-item" :class="{ active: viewMode==='content' }" @click="viewMode='content', previewItem=null">📝 Content</button> <div class="dd-item" :class="{active: viewMode==='title'}" @click="viewMode='title', previewItem=null, viewMenuOpen=false"><span class="check" x-text="viewMode==='title'?'✓':''"></span>{{ fd_icon("grid",14) }} Cards</div>
<div class="dd-item" :class="{active: viewMode==='content'}" @click="viewMode='content', previewItem=null, viewMenuOpen=false"><span class="check" x-text="viewMode==='content'?'✓':''"></span>{{ fd_icon("align-left",14) }} Content</div>
</div> </div>
</div> </div>
<div class="search-bar-inline"> <!-- Sort -->
<span class="search-icon">🔍</span> <div class="lib-toolbar-wrap" style="position:relative;">
<input class="search-input" type="text" placeholder="Filter..." <button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; viewMenuOpen=false">
x-model="searchQuery" @input="doFilter" autocomplete="off"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
<button class="search-clear" x-show="searchQuery" @click="searchQuery='', doFilter()" title="Clear">×</button> </button>
<div class="ws-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition style="right:0;">
<div class="dd-label">Sort by</div>
<div class="dd-item" :class="{active: sortBy==='name-asc'}" @click="sortBy='name-asc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='name-asc'?'✓':''"></span> Name A→Z</div>
<div class="dd-item" :class="{active: sortBy==='name-desc'}" @click="sortBy='name-desc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='name-desc'?'✓':''"></span> Name Z→A</div>
<div class="dd-item" :class="{active: sortBy==='type'}" @click="sortBy='type', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='type'?'✓':''"></span> Type</div>
<div class="dd-item" :class="{active: sortBy==='date-asc'}" @click="sortBy='date-asc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='date-asc'?'✓':''"></span> Oldest</div>
<div class="dd-item" :class="{active: sortBy==='date-desc'}" @click="sortBy='date-desc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='date-desc'?'✓':''"></span> Newest</div>
</div> </div>
<select class="sort-select" x-model="sortBy" @change="doSort()"> </div>
<option value="">Sort</option> <!-- Filter -->
<option value="name-asc">A→Z</option> <div class="lib-toolbar-wrap" style="position:relative;">
<option value="name-desc">Z→A</option> <button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="filterOpen=!filterOpen; viewMenuOpen=false; sortOpen=false">
<option value="type">Type</option> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
<option value="date-asc">Oldest</option> </button>
<option value="date-desc">Newest</option> <div class="ws-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition style="right:0;">
</select> <div class="dd-label">File type</div>
<button class="btn-icon" @click="expandAll()" title="Expand" x-show="viewMode==='tree'">⊞</button> <div class="dd-item" :class="{active: filterType==='folder'}" @click="filterByType('folder'), filterOpen=false"><span class="check" x-text="filterType==='folder'?'✓':''"></span>{{ fd_icon("folder",14) }} Folders</div>
<button class="btn-icon" @click="collapseAll()" title="Collapse" x-show="viewMode==='tree'">⊟</button> <div class="dd-item" :class="{active: filterType==='page'}" @click="filterByType('page'), filterOpen=false"><span class="check" x-text="filterType==='page'?'✓':''"></span>{{ fd_icon("file",14) }} Pages</div>
<button class="btn-icon btn-create" onclick="window.FlowDeck.createPage()" title="New file">📄</button> <div class="dd-item" :class="{active: filterType==='pdf'}" @click="filterByType('pdf'), filterOpen=false"><span class="check" x-text="filterType==='pdf'?'✓':''"></span>{{ fd_icon("file",14) }} PDF</div>
<button class="btn-icon btn-create" onclick="window.FlowDeck.showCreateFolderModal()" title="New folder">📁</button> <div class="dd-item" :class="{active: filterType==='image'}" @click="filterByType('image'), filterOpen=false"><span class="check" x-text="filterType==='image'?'✓':''"></span>{{ fd_icon("image",14) }} Images</div>
<div class="dd-item" :class="{active: filterType==='code'}" @click="filterByType('code'), filterOpen=false"><span class="check" x-text="filterType==='code'?'✓':''"></span>{{ fd_icon("file",14) }} Code</div>
<div class="dd-item" :class="{active: filterType==='text'}" @click="filterByType('text'), filterOpen=false"><span class="check" x-text="filterType==='text'?'✓':''"></span>{{ fd_icon("file",14) }} Text</div>
<div class="more-sep" x-show="filterType"></div>
<div class="dd-item" x-show="filterType" @click="filterType='', doFilter(), filterOpen=false" style="color:var(--text-dim);">{{ fd_icon("x",14) }} Clear filter</div>
</div>
</div>
<!-- Search toggle -->
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; if(searchOpen) $nextTick(()=>$refs.searchInput?.focus())">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button>
<!-- Expand/Collapse (tree only) -->
<button class="ws-icon-btn" @click="expandAll()" title="Expand all" x-show="viewMode==='tree'"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg></button>
<button class="ws-icon-btn" @click="collapseAll()" title="Collapse all" x-show="viewMode==='tree'"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 15 12 9 18 15"/></svg></button>
<!-- Create buttons -->
<button class="ws-icon-btn" style="background:var(--bg-hover); border-radius:8px;" onclick="window.FlowDeck.createPage()" title="New page">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
</button>
</div>
</div>
<!-- Search bar (togglable) -->
<div class="ws-search-bar" x-show="searchOpen" x-transition>
<input x-ref="searchInput" type="text" placeholder="Filter files and folders..." x-model="searchQuery" @input="doFilter" autocomplete="off">
</div> </div>
<!-- Bulk actions bar --> <!-- Bulk actions bar -->
<div class="bulk-bar" x-show="selectedIdsCount > 0" x-transition> <div class="ws-select-bar" x-show="selectedIdsCount > 0" x-transition>
<span x-text="selectedIdsCount + ' selected'"></span> <span class="sel-count" x-text="selectedIdsCount + ' selected'"></span>
<button class="btn btn-danger" @click="bulkDelete()">🗑 Delete</button> <button @click="clearSelection()" title="Clear"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<button class="btn btn-secondary" @click="clearSelection()">✕ Clear</button> <button @click="bulkDelete()" style="margin-left:auto;color:var(--danger);" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
Delete
</button>
</div>
<!-- Filter chips (visual indicator when filter is active — shown in all views) -->
<div class="filter-row" x-show="filterType" style="padding:4px 0;margin-bottom:6px;">
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-html="getSvgIcon('folder',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-html="getSvgIcon('image',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-html="getSvgIcon('file',14)"></button>
<span class="filter-clear" @click="filterType='', doFilter()">clear filter</span>
</div> </div>
<!-- Tree --> <!-- Tree -->
<div x-show="viewMode==='tree'" style="display:contents;"> <div x-show="viewMode==='tree'" style="display:contents;">
<!-- Compact filter row -->
<div class="filter-row" x-show="tree.length>0 || searchQuery || filterType">
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders">📁</button>
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages">📝</button>
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF">📕</button>
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images">🖼️</button>
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code">📜</button>
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text">📄</button>
<span x-show="filterType || searchQuery" class="filter-clear" @click="filterType='', searchQuery='', doFilter()">✕ clear</span>
</div>
<ul class="ws-tree" x-show="viewMode==='tree' && (displayTree.length>0 || (currentFolder > 0))" @click="handleTreeAction($event)"> <ul class="ws-tree" x-show="viewMode==='tree' && (displayTree.length>0 || (currentFolder > 0))" @click="handleTreeAction($event)">
<!-- Parent folder ".." --> <!-- Parent folder ".." -->
<li x-show="currentFolder > 0" style="list-style:none;"> <li x-show="currentFolder > 0" style="list-style:none;">
<div class="ws-tree-item" @click="goToParent()" style="padding-left:4px;"> <div class="ws-tree-item" @click="goToParent()" style="padding-left:4px;">
<span style="width:16px;flex-shrink:0;"></span> <span style="width:16px;flex-shrink:0;"></span>
<span class="icon">📂</span> <span class="icon">{{ fd_icon("folder",16) }}</span>
<span class="name folder" style="font-weight:600;">..</span> <span class="name folder" style="font-weight:600;">..</span>
</div> </div>
</li> </li>
@@ -2247,7 +2406,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<span style="width:16px;flex-shrink:0;"></span> <span style="width:16px;flex-shrink:0;"></span>
</template> </template>
<span class="icon" x-text="_fileIcon(node.name, node.is_folder, node.content_format)"></span> <span class="icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<template x-if="node.is_folder"> <template x-if="node.is_folder">
<span class="name folder" @click.stop="navigateToFolder(node.id)" <span class="name folder" @click.stop="navigateToFolder(node.id)"
@@ -2275,12 +2434,13 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<div class="actions"> <div class="actions">
<template x-if="node.is_folder"> <template x-if="node.is_folder">
<span style="display:flex;gap:2px"> <span style="display:flex;gap:2px">
<button class="ws-act" @click.stop="window.FlowDeck.createPage()" title="New file">📄</button> <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">📁</button> <button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal()" 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> </span>
</template> </template>
<button class="ws-act" @click.stop="startRename(node)" title="Rename">✏️</button> <button class="ws-act" @click.stop="startRename(node)" title="Rename">{{ fd_icon("edit",14) }}</button>
<button class="ws-act danger" @click.stop="deleteWithUndo(node)" title="Delete">🗑</button> <button class="ws-act danger" @click.stop="deleteWithUndo(node)" title="Delete">{{ fd_icon("trash",14) }}</button>
</div> </div>
</div> </div>
<!-- Children --> <!-- Children -->
@@ -2320,7 +2480,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<span style="font-size:10px;opacity:.7" x-text="'('+t.count+')'"></span> <span style="font-size:10px;opacity:.7" x-text="'('+t.count+')'"></span>
</button> </button>
</template> </template>
<button x-show="tagFilter" class="tag-chip rm-btn" @click="tagFilter='', doFilter()" style="margin-left:4px;">✕ clear</button> <button x-show="tagFilter" class="tag-chip rm-btn" @click="tagFilter='', doFilter()" style="margin-left:4px;">clear</button>
</div> </div>
<!-- Filter table (shown when search/filter/tag is active) --> <!-- Filter table (shown when search/filter/tag is active) -->
@@ -2341,13 +2501,13 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<tr :class="{ selected: !!selectedIds[node.id] }"> <tr :class="{ selected: !!selectedIds[node.id] }">
<td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td> <td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td>
<td> <td>
<span class="table-icon" x-text="_fileIcon(node.name, node.is_folder, node.content_format)"></span> <span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<span class="col-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span> <span class="col-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
</td> </td>
<td class="col-path" x-text="node._path || '—'"></td> <td class="col-path" x-text="node._path || '—'"></td>
<td class="col-size" x-text="node.size_display || '—'"></td> <td class="col-size" x-text="node.size_display || '—'"></td>
<td class="col-date" x-text="_formatDate(node.updated_at)"></td> <td class="col-date" x-text="_formatDate(node.updated_at)"></td>
<td class="col-type" x-text="node.is_folder ? '📁 Folder' : _fileTypeLabel(node.name, node.content_format)"></td> <td class="col-type" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
<td class="col-author" x-text="node.author || '—'"></td> <td class="col-author" x-text="node.author || '—'"></td>
<td class="col-tags"> <td class="col-tags">
<span style="display:flex;align-items:center;gap:2px;flex-wrap:wrap;"> <span style="display:flex;align-items:center;gap:2px;flex-wrap:wrap;">
@@ -2369,8 +2529,8 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
</span> </span>
</td> </td>
<td> <td>
<button class="ws-act" @click.stop="startRename(node)" title="Rename">✏️</button> <button class="ws-act" @click.stop="startRename(node)" title="Rename">{{ fd_icon("edit",14) }}</button>
<button class="ws-act danger" @click.stop="deleteWithUndo(node)" title="Delete">🗑</button> <button class="ws-act danger" @click.stop="deleteWithUndo(node)" title="Delete">{{ fd_icon("trash",14) }}</button>
</td> </td>
</tr> </tr>
</template> </template>
@@ -2387,11 +2547,19 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<th style="width:100px">Modified</th> <th style="width:100px">Modified</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
<!-- Parent folder ".." -->
<tr x-show="currentFolder > 0" @click="goToParent()" style="cursor:pointer" class="parent-folder-row">
<td>{{ fd_icon("folder",16) }}</td>
<td class="col-name"><span style="font-weight:600;color:var(--text);">..</span></td>
<td style="font-size:12px;color:var(--text-tertiary)">Folder</td>
<td style="font-size:12px;color:var(--text-secondary)"></td>
<td style="font-size:12px;color:var(--text-tertiary)"></td>
</tr>
<template x-for="node in displayTree" :key="'l'+node.id"> <template x-for="node in displayTree" :key="'l'+node.id">
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer"> <tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer">
<td><span x-text="_fileIcon(node.name, node.is_folder, node.content_format)"></span></td> <td><span x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span></td>
<td class="col-name" x-text="node.name"></td> <td class="col-name" x-text="node.name"></td>
<td style="font-size:12px;color:var(--text-tertiary)" x-text="node.is_folder ? '📁 Folder' : _fileTypeLabel(node.name, node.content_format)"></td> <td style="font-size:12px;color:var(--text-tertiary)" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
<td style="font-size:12px;color:var(--text-secondary)" x-text="node.is_folder ? (node.child_count||0)+' items' : (node.size_display||'—')"></td> <td style="font-size:12px;color:var(--text-secondary)" x-text="node.is_folder ? (node.child_count||0)+' items' : (node.size_display||'—')"></td>
<td style="font-size:12px;color:var(--text-tertiary)" x-text="_formatDate(node.updated_at)"></td> <td style="font-size:12px;color:var(--text-tertiary)" x-text="_formatDate(node.updated_at)"></td>
</tr> </tr>
@@ -2412,15 +2580,26 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<th>Tags</th> <th>Tags</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
<!-- Parent folder ".." -->
<tr x-show="currentFolder > 0" @click="goToParent()" style="cursor:pointer" class="parent-folder-row">
<td>{{ fd_icon("folder",16) }}</td>
<td class="col-name"><span style="font-weight:600;color:var(--text);">..</span></td>
<td class="col-path"></td>
<td class="col-size"></td>
<td class="col-date"></td>
<td class="col-type">Folder</td>
<td class="col-author"></td>
<td class="col-tags"></td>
</tr>
<template x-for="node in displayTree" :key="'d'+node.id"> <template x-for="node in displayTree" :key="'d'+node.id">
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer" <tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer"
:class="{ 'selected-row': previewItem && previewItem.id === node.id }"> :class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<td><span class="table-icon" x-text="_fileIcon(node.name, node.is_folder, node.content_format)"></span></td> <td><span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span></td>
<td class="col-name" x-text="node.name"></td> <td class="col-name" x-text="node.name"></td>
<td class="col-path" x-text="node._path || '—'"></td> <td class="col-path" x-text="node._path || '—'"></td>
<td class="col-size" x-text="node.size_display || '—'"></td> <td class="col-size" x-text="node.size_display || '—'"></td>
<td class="col-date" x-text="_formatDate(node.updated_at)"></td> <td class="col-date" x-text="_formatDate(node.updated_at)"></td>
<td class="col-type" x-text="node.is_folder ? '📁 Folder' : _fileTypeLabel(node.name, node.content_format)"></td> <td class="col-type" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
<td class="col-author" x-text="node.author || '—'"></td> <td class="col-author" x-text="node.author || '—'"></td>
<td class="col-tags"> <td class="col-tags">
<span style="display:flex;align-items:center;gap:2px;flex-wrap:wrap;"> <span style="display:flex;align-items:center;gap:2px;flex-wrap:wrap;">
@@ -2436,10 +2615,16 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<!-- Title view — card grid --> <!-- Title view — card grid -->
<div class="title-grid" x-show="viewMode==='title' && !searchQuery && !filterType && !tagFilter && displayTree.length>0"> <div class="title-grid" x-show="viewMode==='title' && !searchQuery && !filterType && !tagFilter && displayTree.length>0">
<!-- Parent folder ".." -->
<div class="title-card parent-folder-card" x-show="currentFolder > 0" @click="goToParent()" style="cursor:pointer">
<div class="title-card-icon">{{ fd_icon("folder",24) }}</div>
<div class="title-card-name" style="font-weight:600;">..</div>
<div class="title-card-meta"><span>Parent folder</span></div>
</div>
<template x-for="node in displayTree" :key="'g'+node.id"> <template x-for="node in displayTree" :key="'g'+node.id">
<div class="title-card" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" <div class="title-card" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
:class="{ 'selected-card': previewItem && previewItem.id === node.id }"> :class="{ 'selected-card': previewItem && previewItem.id === node.id }">
<div class="title-card-icon" x-text="node.is_folder ? '📁' : _fileIcon(node.name, node.is_folder, node.content_format)"></div> <div class="title-card-icon" x-html="node.is_folder ? getSvgIcon('folder',24) : _fileIcon(node.name, node.is_folder, node.content_format)"></div>
<div class="title-card-name" x-text="node.name"></div> <div class="title-card-name" x-text="node.name"></div>
<div class="title-card-meta"> <div class="title-card-meta">
<span x-text="_formatDate(node.updated_at)"></span> <span x-text="_formatDate(node.updated_at)"></span>
@@ -2451,11 +2636,19 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<!-- Content view — list with inline preview --> <!-- Content view — list with inline preview -->
<div class="content-list" x-show="viewMode==='content' && !searchQuery && !filterType && !tagFilter && displayTree.length>0"> <div class="content-list" x-show="viewMode==='content' && !searchQuery && !filterType && !tagFilter && displayTree.length>0">
<!-- Parent folder ".." -->
<div class="content-item parent-folder-item" x-show="currentFolder > 0" @click="goToParent()" style="cursor:pointer">
<div class="content-item-header">
{{ fd_icon("folder",16) }}
<span class="col-name" style="font-weight:600;">..</span>
</div>
<div class="content-item-preview">Parent folder</div>
</div>
<template x-for="node in displayTree" :key="'c'+node.id"> <template x-for="node in displayTree" :key="'c'+node.id">
<div class="content-item" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" <div class="content-item" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
:class="{ 'selected-row': previewItem && previewItem.id === node.id }"> :class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<div class="content-item-header"> <div class="content-item-header">
<span x-text="_fileIcon(node.name, node.is_folder, node.content_format)"></span> <span x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<span class="col-name" x-text="node.name"></span> <span class="col-name" x-text="node.name"></span>
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="_formatDate(node.updated_at)"></span> <span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="_formatDate(node.updated_at)"></span>
</div> </div>
@@ -2477,24 +2670,24 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<!-- Empty --> <!-- Empty -->
<div class="empty-state" x-show="tree.length===0&&loaded"> <div class="empty-state" x-show="tree.length===0&&loaded">
<div class="empty-icon">📁</div> <div class="empty-icon">{{ fd_icon("folder",48) }}</div>
<h2 x-text="currentFolder > 0 ? 'Empty folder' : 'Welcome to your Workspace'"></h2> <h2 x-text="currentFolder > 0 ? 'Empty folder' : 'Welcome to your Workspace'"></h2>
<p x-text="currentFolder > 0 ? 'Create a new file or folder to get started' : 'Drag & drop files from your computer, or create new ones.'"></p> <p x-text="currentFolder > 0 ? 'Create a new file or folder to get started' : 'Drag & drop files from your computer, or create new ones.'"></p>
<div class="empty-actions"> <div class="empty-actions">
<button class="btn-icon" onclick="window.FlowDeck.createPage()" title="New file">📄</button> <button class="btn-icon" onclick="window.FlowDeck.createPage()" title="New file">{{ fd_icon("file",16) }}</button>
<button class="btn-icon" onclick="window.FlowDeck.showCreateFolderModal()" title="New folder">📁</button> <button class="btn-icon" onclick="window.FlowDeck.showCreateFolderModal()" title="New folder">{{ fd_icon("folder",16) }}</button>
</div> </div>
</div> </div>
<!-- Offline banner --> <!-- Offline banner -->
<div class="offline-banner" x-show="!online" x-transition> <div class="offline-banner" x-show="!online" x-transition>
<span>⚠️ You are offline. Changes will sync when connection is restored.</span> <span>{{ fd_icon('alert-triangle',14) }} You are offline. Changes will sync when connection is restored.</span>
</div> </div>
<!-- Create Modal --> <!-- Create Modal -->
<div class="modal-overlay" x-show="showCreate" @click.outside="showCreate=false" @keydown.escape="showCreate=false" style="display:none"> <div class="modal-overlay" x-show="showCreate" @click.outside="showCreate=false" @keydown.escape="showCreate=false" style="display:none">
<div class="modal-box"> <div class="modal-box">
<h3><span x-text="createType==='folder'?'📁':'📄'"></span> <span x-text="createType==='folder'?'New Folder':'New File'"></span></h3> <h3><span x-show="createType==='folder'">{{ fd_icon('folder',16) }}</span><span x-show="createType!='folder'">{{ fd_icon('file',16) }}</span> <span x-text="createType==='folder'?'New Folder':'New File'"></span></h3>
<p class="modal-sub"> <p class="modal-sub">
In <strong>{{ workspace_name }}</strong> <span x-show="folderStack.length>1">/ <span x-text="(folderStack[folderStack.length-1]||{}).name||''"></span></span> In <strong>{{ workspace_name }}</strong> <span x-show="folderStack.length>1">/ <span x-text="(folderStack[folderStack.length-1]||{}).name||''"></span></span>
<span x-show="parentFolder"> / <span x-text="parentFolder?.name"></span></span> <span x-show="parentFolder"> / <span x-text="parentFolder?.name"></span></span>
@@ -2510,7 +2703,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<!-- Rename Modal --> <!-- Rename Modal -->
<div class="modal-overlay" x-show="showRename" @click.outside="showRename=false" @keydown.escape="showRename=false" style="display:none"> <div class="modal-overlay" x-show="showRename" @click.outside="showRename=false" @keydown.escape="showRename=false" style="display:none">
<div class="modal-box"> <div class="modal-box">
<h3>✏️ Rename</h3> <h3>{{ fd_icon("edit",14) }} Rename</h3>
<p class="modal-sub">Rename <strong x-text="target?.name"></strong></p> <p class="modal-sub">Rename <strong x-text="target?.name"></strong></p>
<input class="modal-input" x-model="newName" @keydown.enter="doRename" x-ref="rinp"> <input class="modal-input" x-model="newName" @keydown.enter="doRename" x-ref="rinp">
<div class="modal-actions"> <div class="modal-actions">
@@ -2523,7 +2716,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<!-- Delete Modal --> <!-- Delete Modal -->
<div class="modal-overlay" x-show="showDelete" @click.outside="showDelete=false" @keydown.escape="showDelete=false" style="display:none"> <div class="modal-overlay" x-show="showDelete" @click.outside="showDelete=false" @keydown.escape="showDelete=false" style="display:none">
<div class="modal-box"> <div class="modal-box">
<h3>🗑 Delete</h3> <h3>{{ fd_icon("trash",16) }} Delete</h3>
<p class="modal-sub">Delete <strong x-text="target?.name"></strong>?</p> <p class="modal-sub">Delete <strong x-text="target?.name"></strong>?</p>
<div class="delete-confirm">This cannot be undone. Children will also be deleted.</div> <div class="delete-confirm">This cannot be undone. Children will also be deleted.</div>
<div class="modal-actions"> <div class="modal-actions">
@@ -2535,45 +2728,42 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
</div><!-- /ws-main --> </div><!-- /ws-main -->
<!-- Context menu --> <!-- Context menu (Library-style) -->
<div class="ctx-menu" x-data="_ctxMenuData" style="display:none" <div class="more-menu" x-data="_ctxMenuData" style="display:none"
@click.outside="document.querySelector('.ctx-menu').style.display='none'" @click.outside="document.querySelector('.more-menu').style.display='none'"
@keydown.escape="document.querySelector('.ctx-menu').style.display='none'" @keydown.escape="document.querySelector('.more-menu').style.display='none'"
x-transition> x-transition>
<div class="ctx-item" @click="ctxMenuOpenPage()">📄 Open</div> <div class="menu-item" @click="ctxMenuOpenPage()">{{ fd_icon("file",14) }} Open</div>
<div class="ctx-item" x-show="ctxMenu.node && ctxMenu.node.is_folder" @click="ctxMenuOpenFolder()">📂 Open folder</div> <div class="menu-item" x-show="ctxMenu.node && ctxMenu.node.is_folder" @click="ctxMenuOpenFolder()">{{ fd_icon("folder",14) }} Open folder</div>
<div class="ctx-item" @click="ctxMenuRename()">✏️ Rename</div> <div class="menu-item" @click="ctxMenuRename()">{{ fd_icon("edit",14) }} Rename</div>
<div class="ctx-item" @click="ctxMenuDuplicate()">📋 Duplicate</div> <div class="menu-item" @click="ctxMenuDuplicate()">{{ fd_icon("copy",14) }} Duplicate</div>
<div class="ctx-sep"></div> <div class="more-sep"></div>
<div class="ctx-section">🏷️ Tags</div> <!-- Current tags -->
<!-- Current tags with remove button -->
<template x-for="t in (ctxMenu.tags||[])" :key="'ctx-tag'+t.id"> <template x-for="t in (ctxMenu.tags||[])" :key="'ctx-tag'+t.id">
<div class="ctx-item ctx-tag-item"> <div class="menu-item" style="justify-content:space-between;">
<span class="ctx-tag-color" :style="{background: t.color}"></span> <span style="display:flex;align-items:center;gap:8px;"><span class="ctx-tag-color" :style="{background: t.color}"></span><span style="font-size:13px;" x-text="t.name"></span></span>
<span style="flex:1;font-size:13px;" x-text="t.name"></span>
<button class="tag-chip rm-btn" @click.stop="ctxRemoveTag(t.id)" title="Remove">×</button> <button class="tag-chip rm-btn" @click.stop="ctxRemoveTag(t.id)" title="Remove">×</button>
</div> </div>
</template> </template>
<div x-show="(ctxMenu.tags||[]).length === 0" style="padding:2px 14px;font-size:11px;color:var(--text-dim);">No tags</div> <div x-show="(ctxMenu.tags||[]).length === 0" style="padding:4px 10px;font-size:11px;color:var(--text-dim);">No tags</div>
<div class="ctx-sep"></div> <div class="more-sep"></div>
<!-- Add existing tag → sub-menu --> <!-- Add existing tag -->
<div class="ctx-item ctx-has-sub" @click.stop="ctxTagExistingOpen=!ctxTagExistingOpen" x-show="ctxAvailTags.length > 0"> <div class="menu-item" @click.stop="ctxTagExistingOpen=!ctxTagExistingOpen" x-show="ctxAvailTags.length > 0">
<span style="flex:1;">📋 Add existing tag</span> <span style="flex:1;">Add existing tag</span>
<span x-text="ctxTagExistingOpen ? '▾' : '▸'"></span> <span x-text="ctxTagExistingOpen ? '▾' : '▸'" style="font-size:11px;"></span>
</div> </div>
<div x-show="ctxTagExistingOpen && ctxAvailTags.length > 0" style="padding:0 0 4px;background:var(--bg-tertiary);border-radius:6px;margin:2px 6px;"> <div x-show="ctxTagExistingOpen && ctxAvailTags.length > 0" style="padding:0 0 4px;background:var(--bg-tertiary);border-radius:6px;margin:2px 6px;">
<template x-for="t in ctxAvailTags" :key="'ctx-avail'+t.id"> <template x-for="t in ctxAvailTags" :key="'ctx-avail'+t.id">
<div class="ctx-item ctx-add-tag-item" @click.stop="ctxAddExistingTag(t), ctxTagExistingOpen=false"> <div class="menu-item" @click.stop="ctxAddExistingTag(t), ctxTagExistingOpen=false" style="justify-content:space-between;">
<span class="ctx-tag-color" :style="{background: t.color}"></span> <span style="display:flex;align-items:center;gap:8px;"><span class="ctx-tag-color" :style="{background: t.color}"></span><span style="font-size:13px;" x-text="t.name"></span></span>
<span style="flex:1;font-size:13px;" x-text="t.name"></span>
<span style="font-size:10px;color:var(--text-dim);" x-text="t.count||''"></span> <span style="font-size:10px;color:var(--text-dim);" x-text="t.count||''"></span>
</div> </div>
</template> </template>
</div> </div>
<!-- Create new tag --> <!-- Create new tag -->
<div class="ctx-item ctx-has-sub" @click.stop="ctxTagAdding=!ctxTagAdding"> <div class="menu-item" @click.stop="ctxTagAdding=!ctxTagAdding">
<span style="flex:1;">➕ New tag</span> <span style="flex:1;">{{ fd_icon("plus",14) }} New tag</span>
<span x-text="ctxTagAdding ? '▾' : '▸'"></span> <span x-text="ctxTagAdding ? '▾' : '▸'" style="font-size:11px;"></span>
</div> </div>
<div x-show="ctxTagAdding" style="padding:6px 10px;background:var(--bg-tertiary);border-radius:6px;margin:2px 6px;"> <div x-show="ctxTagAdding" style="padding:6px 10px;background:var(--bg-tertiary);border-radius:6px;margin:2px 6px;">
<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:4px;margin-bottom:6px;"> <div style="display:grid;grid-template-columns:repeat(7,1fr);gap:4px;margin-bottom:6px;">
@@ -2586,14 +2776,12 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<input class="tag-input" x-ref="ctxTagInput" <input class="tag-input" x-ref="ctxTagInput"
@keydown.enter="ctxAddNewTag($event.target.value), $event.target.value=''" @keydown.enter="ctxAddNewTag($event.target.value), $event.target.value=''"
@keydown.escape="ctxTagAdding=false" @keydown.escape="ctxTagAdding=false"
@click.stop @click.stop placeholder="Tag name..." style="flex:1;">
placeholder="Tag name..."
style="flex:1;">
<button class="btn btn-primary" @click.stop="ctxAddNewTag($refs.ctxTagInput.value), $refs.ctxTagInput.value=''" style="padding:2px 10px;font-size:12px;">Add</button> <button class="btn btn-primary" @click.stop="ctxAddNewTag($refs.ctxTagInput.value), $refs.ctxTagInput.value=''" style="padding:2px 10px;font-size:12px;">Add</button>
</div> </div>
</div> </div>
<div class="ctx-sep"></div> <div class="more-sep"></div>
<div class="ctx-item ctx-danger" @click="ctxMenuDelete()">🗑 Delete</div> <div class="menu-item menu-danger" @click="ctxMenuDelete()">{{ fd_icon("trash",14) }} Delete</div>
</div> </div>
<!-- Preview tooltip --> <!-- Preview tooltip -->
@@ -2608,12 +2796,12 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<template x-if="previewItem"> <template x-if="previewItem">
<div class="preview-panel" x-show="previewItem" x-transition:enter="preview-enter" x-transition:leave="preview-leave"> <div class="preview-panel" x-show="previewItem" x-transition:enter="preview-enter" x-transition:leave="preview-leave">
<div class="preview-panel-header"> <div class="preview-panel-header">
<span x-text="_fileIcon(previewItem.name, previewItem.is_folder, previewItem.content_format)"></span> <span x-html="_fileIcon(previewItem.name, previewItem.is_folder, previewItem.content_format)"></span>
<span class="preview-panel-title" x-text="previewItem.name"></span> <span class="preview-panel-title" x-text="previewItem.name"></span>
<button class="preview-close" @click="previewItem=null" title="Close preview">×</button> <button class="preview-close" @click="previewItem=null" title="Close preview">×</button>
</div> </div>
<div class="preview-panel-meta"> <div class="preview-panel-meta">
<span x-text="previewItem.is_folder ? '📁 Folder' : _fileTypeLabel(previewItem.name, previewItem.content_format)"></span> <span x-text="previewItem.is_folder ? 'Folder' : _fileTypeLabel(previewItem.name, previewItem.content_format)"></span>
<span x-text="previewItem.size_display || '—'"></span> <span x-text="previewItem.size_display || '—'"></span>
<span x-text="_formatDate(previewItem.updated_at)"></span> <span x-text="_formatDate(previewItem.updated_at)"></span>
</div> </div>
@@ -2624,9 +2812,9 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
</div> </div>
<div class="preview-panel-body" x-html="previewPanelContent || '<div style=text-align:center;padding:40px;color:var(--text-tertiary)>Loading preview...</div>'"></div> <div class="preview-panel-body" x-html="previewPanelContent || '<div style=text-align:center;padding:40px;color:var(--text-tertiary)>Loading preview...</div>'"></div>
<div class="preview-panel-actions"> <div class="preview-panel-actions">
<button class="btn btn-secondary" @click="openPage(previewItem.id)">📄 Open</button> <button class="btn btn-secondary" @click="openPage(previewItem.id)">{{ fd_icon("file",14) }} Open</button>
<button class="btn btn-secondary" @click="startRename(previewItem)">✏️ Rename</button> <button class="btn btn-secondary" @click="startRename(previewItem)">{{ fd_icon("edit",14) }} Rename</button>
<button class="btn btn-danger" @click="deleteWithUndo(previewItem)">🗑 Delete</button> <button class="btn btn-danger" @click="deleteWithUndo(previewItem)">{{ fd_icon("trash",14) }} Delete</button>
</div> </div>
</div> </div>
</template> </template>
+1 -1
View File
@@ -15,7 +15,7 @@
<form hx-post="/notes/{{ owner }}/{{ repo }}" hx-target=".notes-editor" hx-swap="outerHTML"> <form hx-post="/notes/{{ owner }}/{{ repo }}" hx-target=".notes-editor" hx-swap="outerHTML">
<textarea name="content" class="notes-textarea" placeholder="Notes markdown pour ce projet...">{{ content }}</textarea> <textarea name="content" class="notes-textarea" placeholder="Notes markdown pour ce projet...">{{ content }}</textarea>
<div class="notes-actions"> <div class="notes-actions">
<button type="submit" class="btn btn-primary">💾 Sauvegarder</button> <button type="submit" class="btn btn-primary">{{ fd_icon("save",14) }} Sauvegarder</button>
</div> </div>
</form> </form>
+35 -35
View File
@@ -1,8 +1,8 @@
{% extends "base.html" %} {% block page_icon %}{% if page.content_format == 'file' %}📎{% else %}📄{% endif %}{% endblock %} {% block {% extends "base.html" %} {% block page_icon %}{% if page.content_format == 'file' %}{{ fd_icon("paperclip",14) }}{% else %}{{ fd_icon("file",14) }}{% endif %}{% endblock %} {% block
page_title %}{{ page.title }}{% endblock %} {% block topbar %} page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "📄" if page.content_format != 'file' else "📎" %} {% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
{% set page_title = page.title %} {% set page_title = page.title %}
{% 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 share-btn" @click="window.E && window.E.toggleShareOpen()">🔒 Share ▾</button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">🔗</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-text="(window.E && window.E.favorited) ? \'⭐\' : \'☆\'"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %} {% 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 share-btn" @click="window.E && window.E.toggleShareOpen()">' ~ fd_icon("lock",14) ~ ' Share ▾</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' %} {% include '_header.html' %}
{% endblock %} {% block content %} {% endblock %} {% block content %}
<div class="page-editor-wrapper" x-data="editorState()"> <div class="page-editor-wrapper" x-data="editorState()">
@@ -148,7 +148,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
@click="accessMenuOpen = !accessMenuOpen" @click="accessMenuOpen = !accessMenuOpen"
> >
<span <span
x-text="generalAccess === 'invited' ? '🔒 Only people invited' : '🌐 Anyone with the link'" x-text="generalAccess === 'invited' ? 'Only people invited' : 'Anyone with the link'"
></span> ></span>
<span class="chevron-down">▾</span> <span class="chevron-down">▾</span>
</button> </button>
@@ -163,7 +163,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
:class="{ active: generalAccess === 'invited' }" :class="{ active: generalAccess === 'invited' }"
@click="generalAccess='invited'; accessMenuOpen=false" @click="generalAccess='invited'; accessMenuOpen=false"
> >
<span>🔒 Only people invited</span> <span>{{ fd_icon('lock',14) }} Only people invited</span>
<span class="sd-perm-desc" <span class="sd-perm-desc"
>People need to be invited</span >People need to be invited</span
> >
@@ -173,7 +173,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
:class="{ active: generalAccess === 'anyone' }" :class="{ active: generalAccess === 'anyone' }"
@click="generalAccess='anyone'; accessMenuOpen=false" @click="generalAccess='anyone'; accessMenuOpen=false"
> >
<span>🌐 Anyone with the link</span> <span>{{ fd_icon('globe',14) }} Anyone with the link</span>
<span class="sd-perm-desc" <span class="sd-perm-desc"
>Can view the page</span >Can view the page</span
> >
@@ -213,7 +213,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
>? Learn about sharing</a >? Learn about sharing</a
> >
<button class="sd-footer-action" @click="copyPageLink"> <button class="sd-footer-action" @click="copyPageLink">
🔗 Copy link {{ fd_icon("link",14) }} Copy link
</button> </button>
</div> </div>
</div> </div>
@@ -229,7 +229,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
<div class="sd-publish-preview"> <div class="sd-publish-preview">
<div class="sd-preview-bar"></div> <div class="sd-preview-bar"></div>
<div class="sd-preview-title"> <div class="sd-preview-title">
🌐 {{ page.title }} {{ fd_icon('globe',14) }} {{ page.title }}
</div> </div>
</div> </div>
<h3 class="sd-publish-heading">Publish to web</h3> <h3 class="sd-publish-heading">Publish to web</h3>
@@ -280,26 +280,26 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
<!-- Settings list --> <!-- Settings list -->
<div class="sd-settings"> <div class="sd-settings">
<div class="sd-setting-row"> <div class="sd-setting-row">
<span>🔗 Link expires</span> <span>{{ fd_icon("link",14) }} Link expires</span>
<span style="color: var(--text-dim)" <span style="color: var(--text-dim)"
>Never ▾</span >Never ▾</span
> >
</div> </div>
<div class="sd-setting-row"> <div class="sd-setting-row">
<span>🔍 Search engine indexing</span> <span>{{ fd_icon('search',14) }} Search engine indexing</span>
<span style="color: var(--text-dim)" <span style="color: var(--text-dim)"
>Off ▾</span >Off ▾</span
> >
</div> </div>
<div class="sd-setting-row"> <div class="sd-setting-row">
<span>📄 Allow duplicate as template</span> <span>{{ fd_icon("file",14) }} Allow duplicate as template</span>
<label class="toggle-switch sm"> <label class="toggle-switch sm">
<input type="checkbox" checked /> <input type="checkbox" checked />
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
</div> </div>
<div class="sd-setting-row"> <div class="sd-setting-row">
<span>✏️ Allow editing</span> <span>{{ fd_icon("edit",14) }} Allow editing</span>
<label class="toggle-switch sm"> <label class="toggle-switch sm">
<input <input
type="checkbox" type="checkbox"
@@ -309,7 +309,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
</label> </label>
</div> </div>
<div class="sd-setting-row"> <div class="sd-setting-row">
<span>💬 Allow comments</span> <span>{{ fd_icon("message-square",14) }} Allow comments</span>
<label class="toggle-switch sm"> <label class="toggle-switch sm">
<input <input
type="checkbox" type="checkbox"
@@ -354,12 +354,12 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
> >
<div class="more-menu-item" @click="exportPage">↗ Export</div> <div class="more-menu-item" @click="exportPage">↗ Export</div>
<div class="more-menu-item" @click="duplicatePage"> <div class="more-menu-item" @click="duplicatePage">
📋 Duplicate {{ fd_icon("copy",14) }} Duplicate
</div> </div>
<div class="more-menu-item" @click="movePage">↗ Move to</div> <div class="more-menu-item" @click="movePage">↗ Move to</div>
<div class="more-menu-sep"></div> <div class="more-menu-sep"></div>
<div class="more-menu-item danger" @click="deletePage"> <div class="more-menu-item danger" @click="deletePage">
🗑️ Move to Trash {{ fd_icon("trash",14) }} Move to Trash
</div> </div>
</div> </div>
@@ -376,7 +376,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
<div class="move-ws-item" <div class="move-ws-item"
@click="doMove(ws.id)" @click="doMove(ws.id)"
style="display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;"> style="display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;">
<span>📁</span> <span>{{ fd_icon("folder",14) }}</span>
<span x-text="ws.name" style="flex:1;"></span> <span x-text="ws.name" style="flex:1;"></span>
<span style="font-size:11px;color:var(--text-tertiary);" x-text="ws.page_count + ' pages'"></span> <span style="font-size:11px;color:var(--text-tertiary);" x-text="ws.page_count + ' pages'"></span>
</div> </div>
@@ -390,7 +390,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
<!-- ═══════════ Page Content ═══════════ --> <!-- ═══════════ Page Content ═══════════ -->
<div class="page-cover-area"> <div class="page-cover-area">
<div class="page-title-block"> <div class="page-title-block">
<span class="page-icon-emoji">{% if page.content_format == 'file' %}📎{% else %}📄{% endif %}</span> <span class="page-icon-emoji">{% if page.content_format == 'file' %}{{ fd_icon("paperclip",14) }}{% else %}{{ fd_icon("file",14) }}{% endif %}</span>
<div <div
class="page-title-input" class="page-title-input"
contenteditable="true" contenteditable="true"
@@ -426,7 +426,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
<button @click="fmtApply('italic')"><em>I</em></button> <button @click="fmtApply('italic')"><em>I</em></button>
<button @click="fmtApply('underline')"><u>U</u></button> <button @click="fmtApply('underline')"><u>U</u></button>
<button @click="fmtApply('strikeThrough')"><s>S</s></button> <button @click="fmtApply('strikeThrough')"><s>S</s></button>
<button @click="fmtLink()">🔗</button> <button @click="fmtLink()">{{ fd_icon("link",14) }}</button>
</div> </div>
<!-- ═══════════ Copy Link Toast ═══════════ --> <!-- ═══════════ Copy Link Toast ═══════════ -->
@@ -447,31 +447,31 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
class="gs-pill" class="gs-pill"
@click="replaceBlock(0, 'paragraph');blocks[0].content='Ask AI to write...';render()" @click="replaceBlock(0, 'paragraph');blocks[0].content='Ask AI to write...';render()"
> >
<span class="gs-icon">✨</span> Ask AI <span class="gs-icon">{{ fd_icon("sparkles",14) }}</span> Ask AI
</button> </button>
<button <button
class="gs-pill" class="gs-pill"
@click="replaceBlock(0, 'heading_1');blocks[0].content='AI Meeting Note';render()" @click="replaceBlock(0, 'heading_1');blocks[0].content='AI Meeting Note';render()"
> >
<span class="gs-icon">📝</span> AI meeting note <span class="gs-icon">{{ fd_icon('edit',14) }}</span> AI meeting note
</button> </button>
<button <button
class="gs-pill" class="gs-pill"
@click="replaceBlock(0, 'paragraph');blocks[0].content='Database';render()" @click="replaceBlock(0, 'paragraph');blocks[0].content='Database';render()"
> >
<span class="gs-icon">🗄️</span> Database <span class="gs-icon">{{ fd_icon("folder",14) }}</span> Database
</button> </button>
<button <button
class="gs-pill" class="gs-pill"
@click="replaceBlock(0, 'paragraph');blocks[0].content='Form';render()" @click="replaceBlock(0, 'paragraph');blocks[0].content='Form';render()"
> >
<span class="gs-icon">📋</span> Form <span class="gs-icon">{{ fd_icon("copy",14) }}</span> Form
</button> </button>
<button <button
class="gs-pill" class="gs-pill"
@click="replaceBlock(0, 'paragraph');blocks[0].content='Templates';render()" @click="replaceBlock(0, 'paragraph');blocks[0].content='Templates';render()"
> >
<span class="gs-icon">📑</span> Templates <span class="gs-icon">{{ fd_icon("file",14) }}</span> Templates
</button> </button>
<div style="position: relative"> <div style="position: relative">
<button class="gs-pill" @click="gsMoreOpen = !gsMoreOpen"> <button class="gs-pill" @click="gsMoreOpen = !gsMoreOpen">
@@ -484,26 +484,26 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
x-transition x-transition
> >
<div class="gs-more-item" @click="gsMoreOpen=false"> <div class="gs-more-item" @click="gsMoreOpen=false">
📊 Table {{ fd_icon("bar-chart",14) }} Table
</div> </div>
<div class="gs-more-item" @click="gsMoreOpen=false"> <div class="gs-more-item" @click="gsMoreOpen=false">
📋 Board {{ fd_icon("copy",14) }} Board
</div> </div>
<div class="gs-more-item" @click="gsMoreOpen=false"> <div class="gs-more-item" @click="gsMoreOpen=false">
📝 List {{ fd_icon('list',14) }} List
</div> </div>
<div class="gs-more-item" @click="gsMoreOpen=false"> <div class="gs-more-item" @click="gsMoreOpen=false">
📅 Timeline {{ fd_icon("calendar",14) }} Timeline
</div> </div>
<div class="gs-more-item" @click="gsMoreOpen=false"> <div class="gs-more-item" @click="gsMoreOpen=false">
🗓️ Calendar {{ fd_icon("calendar",14) }} Calendar
</div> </div>
<div class="gs-more-item" @click="gsMoreOpen=false"> <div class="gs-more-item" @click="gsMoreOpen=false">
🖼️ Gallery {{ fd_icon("image",14) }} Gallery
</div> </div>
<div class="gs-more-sep"></div> <div class="gs-more-sep"></div>
<div class="gs-more-item" @click="gsMoreOpen=false"> <div class="gs-more-item" @click="gsMoreOpen=false">
📥 Import {{ fd_icon("download",14) }} Import
</div> </div>
</div> </div>
</div> </div>
@@ -528,8 +528,8 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'}, {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.'}, {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.'},
{id:'to_do',name:'To-do',icon:'☐'},{id:'toggle',name:'Toggle',icon:'▶'},{id:'quote',name:'Quote',icon:'❝'}, {id:'to_do',name:'To-do',icon:'☐'},{id:'toggle',name:'Toggle',icon:'▶'},{id:'quote',name:'Quote',icon:'❝'},
{id:'callout',name:'Callout',icon:'💡'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'}, {id:'callout',name:'Callout',icon:'lightbulb'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'},
]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼'}]}]; ]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'image'}]}];
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);} let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;} function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;}
@@ -575,10 +575,10 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
if(b.type==='divider')return `<div class="block-wrapper"><hr class="block-divider"></div>`; if(b.type==='divider')return `<div class="block-wrapper"><hr class="block-divider"></div>`;
if(b.type==='image')return `<div class="block-wrapper"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'"></div></div>`; if(b.type==='image')return `<div class="block-wrapper"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'"></div></div>`;
if(b.type==='embed'){ if(b.type==='embed'){
if(b.embed_type==='pdf')return `<div class="block-wrapper"><div class="block-content block-embed" style="padding:0;"><iframe src="${b.src||''}" style="width:100%;height:80vh;border:none;border-radius:8px;"></iframe></div></div>`; if(b.embed_type==='pdf')return `<div class="block-wrapper"><div class="block-content block-embed" style="padding:0;"><iframe src="${b.src||''}" style="width:100%;height:80vh;border:none;border-radius:8px;" onerror="this.style.display='none';this.nextElementSibling.style.display='block';"></iframe><div style="display:none;text-align:center;padding:32px;color:var(--text-secondary);"><svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><p style="margin-top:8px;">Failed to load PDF</p><a href="${b.src||''}" target="_blank" style="color:var(--accent);">Open in new tab →</a></div></div></div>`;
if(b.embed_type==='download'){ if(b.embed_type==='download'){
var sz=b.file_size||0;var szStr=sz<1024?sz+' B':sz<1048576?(sz/1024).toFixed(1)+' KB':(sz/1048576).toFixed(1)+' MB'; var sz=b.file_size||0;var szStr=sz<1024?sz+' B':sz<1048576?(sz/1024).toFixed(1)+' KB':(sz/1048576).toFixed(1)+' MB';
return `<div class="block-wrapper"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;"><div style="font-size:48px;margin-bottom:12px;">📎</div><div style="font-size:16px;font-weight:500;color:var(--text-primary);margin-bottom:4px;">${esc(b.file_name||'File')}</div><div style="font-size:12px;color:var(--text-tertiary);margin-bottom:16px;">${szStr} · ${esc(b.file_mime||'')}</div><a href="${b.src||''}" download style="display:inline-block;padding:8px 20px;background:var(--accent);color:#fff;text-decoration:none;border-radius:8px;font-size:13px;font-weight:500;">⬇ Download</a></div></div>`; return `<div class="block-wrapper"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;"><div style="font-size:48px;margin-bottom:12px;">{{ fd_icon("paperclip",14) }}</div><div style="font-size:16px;font-weight:500;color:var(--text-primary);margin-bottom:4px;">${esc(b.file_name||'File')}</div><div style="font-size:12px;color:var(--text-tertiary);margin-bottom:16px;">${szStr} · ${esc(b.file_mime||'')}</div><a href="${b.src||''}" download style="display:inline-block;padding:8px 20px;background:var(--accent);color:#fff;text-decoration:none;border-radius:8px;font-size:13px;font-weight:500;">⬇ Download</a></div></div>`;
} }
} }
const ph=b.type==='heading_1'?'Heading 1':b.type==='heading_2'?'Heading 2':b.type==='heading_3'?'Heading 3':b.type==='heading_4'?'Heading 4':b.type==='bulleted_list'?'List':b.type==='numbered_list'?'List':b.type==='to_do'?'To-do':b.type==='toggle'?'Toggle list':b.type==='quote'?'Empty quote':b.type==='code'?'Type code...':b.type==='callout'?'Type something...':'Press \'/\' for commands...'; const ph=b.type==='heading_1'?'Heading 1':b.type==='heading_2'?'Heading 2':b.type==='heading_3'?'Heading 3':b.type==='heading_4'?'Heading 4':b.type==='bulleted_list'?'List':b.type==='numbered_list'?'List':b.type==='to_do'?'To-do':b.type==='toggle'?'Toggle list':b.type==='quote'?'Empty quote':b.type==='code'?'Type code...':b.type==='callout'?'Type something...':'Press \'/\' for commands...';
@@ -586,7 +586,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
if(b.type==='to_do')inner=`<input type="checkbox" class="todo-checkbox" ${b.checked?'checked':''} onchange="E._doToggle('${b.id}',this.checked)"><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`; if(b.type==='to_do')inner=`<input type="checkbox" class="todo-checkbox" ${b.checked?'checked':''} onchange="E._doToggle('${b.id}',this.checked)"><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
else if(b.type==='toggle')inner=`<button class="toggle-chevron ${b.expanded?'open':''}" onclick="E._doToggleExpand('${b.id}')">▶</button><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`; else if(b.type==='toggle')inner=`<button class="toggle-chevron ${b.expanded?'open':''}" onclick="E._doToggleExpand('${b.id}')">▶</button><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
else if(b.type==='code')inner=`<div class="code-lang-label">${b.language||'Plain Text'}</div><pre><code data-bid="${b.id}" contenteditable="true" spellcheck="false">${esc(b.content)}</code></pre>`; else if(b.type==='code')inner=`<div class="code-lang-label">${b.language||'Plain Text'}</div><pre><code data-bid="${b.id}" contenteditable="true" spellcheck="false">${esc(b.content)}</code></pre>`;
else if(b.type==='callout')inner=`<span class="callout-icon">${b.icon||'💡'}</span><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`; else if(b.type==='callout')inner=`<span class="callout-icon">${b.icon||'lightbulb'}</span><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
else inner=`<div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`; else inner=`<div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
const tag=b.type.startsWith('heading_')?'h'+b.type.slice(-1):b.type==='quote'?'blockquote':'div'; const tag=b.type.startsWith('heading_')?'h'+b.type.slice(-1):b.type==='quote'?'blockquote':'div';
const cls=tag==='div'?`block-content ${b.type==='bulleted_list'?'block-bullet':b.type==='numbered_list'?'block-numbered':b.type==='to_do'?'block-todo':b.type==='toggle'?'block-toggle':b.type==='code'?'block-code':b.type==='callout'?'block-callout':''}`:`block-content block-${tag}`; const cls=tag==='div'?`block-content ${b.type==='bulleted_list'?'block-bullet':b.type==='numbered_list'?'block-numbered':b.type==='to_do'?'block-todo':b.type==='toggle'?'block-toggle':b.type==='code'?'block-code':b.type==='callout'?'block-callout':''}`:`block-content block-${tag}`;
+2 -2
View File
@@ -130,9 +130,9 @@
<header class="pub-header"> <header class="pub-header">
<a href="/" class="pub-brand"> <a href="/" class="pub-brand">
<span>📚</span> FlowDeck <span>{{ fd_icon("book",20) }}</span> FlowDeck
</a> </a>
<span class="pub-badge">🌐 Published</span> <span class="pub-badge">{{ fd_icon("globe",14) }} Published</span>
</header> </header>
<main class="pub-container"> <main class="pub-container">
+28 -28
View File
@@ -1,10 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_title %}Settings{% endblock %} {% block page_title %}Settings{% endblock %}
{% block title_prefix %}Settings{% endblock %} {% block title_prefix %}Settings{% endblock %}
{% block page_icon %}⚙️{% endblock %} {% block page_icon %}{{ fd_icon("settings",18) }}{% endblock %}
{% block topbar %} {% block topbar %}
{% set page_icon = "⚙" %} {% set page_icon = "settings" %}
{% set page_title = "Settings" %} {% set page_title = "Settings" %}
{% include '_header.html' %} {% include '_header.html' %}
{% endblock %} {% endblock %}
@@ -81,19 +81,19 @@
<!-- Left navigation --> <!-- Left navigation -->
<div class="settings-nav"> <div class="settings-nav">
<div class="settings-nav-header">Account</div> <div class="settings-nav-header">Account</div>
<div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'">👤 My account</div> <div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'"><span class="nav-icon-inline">{{ fd_icon("user",14) }}</span> My account</div>
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'">🔔 Notifications</div> <div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
<div class="settings-nav-header">Workspace</div> <div class="settings-nav-header">Workspace</div>
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">📋 General</div> <div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">🏷️ Tags</div> <div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
<div class="settings-nav-header">Features</div> <div class="settings-nav-header">Features</div>
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">🧩 Integrations</div> <div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
<!-- Admin nav: only visible to admins --> <!-- Admin nav: only visible to admins -->
<template x-if="userIsAdmin"> <template x-if="userIsAdmin">
<div> <div>
<div class="settings-nav-header">Admin</div> <div class="settings-nav-header">Admin</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">👥 Users &amp; Roles</div> <div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users &amp; Roles</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">📋 Audit Log</div> <div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
</div> </div>
</template> </template>
</div> </div>
@@ -113,11 +113,11 @@
<span x-show="!avatarUrl">{{ user.get('full_name', user.get('login',''))[:1].upper() }}</span> <span x-show="!avatarUrl">{{ user.get('full_name', user.get('login',''))[:1].upper() }}</span>
</div> </div>
<div> <div>
<label class="avatar-upload-label"> <label class="avatar-upload-label" style="margin-bottom:4px;">
Upload photo Upload photo
<input type="file" class="avatar-upload-input" accept="image/*" @change="uploadAvatar($event)"> <input type="file" class="avatar-upload-input" accept="image/*" @change="uploadAvatar($event)">
</label> </label>
<div style="font-size:11px;color:var(--text-dim);margin-top:4px;">JPG, PNG or GIF — max 2MB</div> <div style="font-size:11px;color:var(--text-dim);margin-top:8px;">JPG, PNG or GIF — max 2MB</div>
</div> </div>
</div> </div>
<div style="margin-top:8px;"> <div style="margin-top:8px;">
@@ -151,7 +151,7 @@
</div> </div>
<div style="margin-top:12px;"> <div style="margin-top:12px;">
<button class="btn btn-primary" @click="saveAccount()" :disabled="accountSaving" style="width:auto;padding:8px 20px;font-size:13px;"> <button class="btn btn-primary" @click="saveAccount()" :disabled="accountSaving" style="width:auto;padding:8px 20px;font-size:13px;">
<span x-show="!accountSaving">💾 Save changes</span> <span x-show="!accountSaving">{{ fd_icon("save",14) }} Save changes</span>
<span x-show="accountSaving">Saving…</span> <span x-show="accountSaving">Saving…</span>
</button> </button>
<span x-show="accountMsg" x-text="accountMsg" style="margin-left:12px;font-size:13px;color:var(--toast-success-bg);"></span> <span x-show="accountMsg" x-text="accountMsg" style="margin-left:12px;font-size:13px;color:var(--toast-success-bg);"></span>
@@ -165,12 +165,12 @@
<div class="setting-label">New password</div> <div class="setting-label">New password</div>
<div class="pw-wrapper" style="max-width:300px;"> <div class="pw-wrapper" style="max-width:300px;">
<input :type="pwVisible ? 'text' : 'password'" class="settings-input" x-model="accountForm.password" placeholder="Leave blank to keep current" style="padding-right:36px;"> <input :type="pwVisible ? 'text' : 'password'" class="settings-input" x-model="accountForm.password" placeholder="Leave blank to keep current" style="padding-right:36px;">
<button type="button" class="pw-toggle" @click="pwVisible=!pwVisible" x-text="pwVisible ? '🙈' : '👁'" style="top:50%;"></button> <button type="button" class="pw-toggle" @click="pwVisible=!pwVisible" x-text="pwVisible ? '{{ fd_icon('eye-off',14) }}' : '{{ fd_icon('eye',14) }}'" style="top:50%;"></button>
</div> </div>
</div> </div>
<div style="margin-top:12px;"> <div style="margin-top:12px;">
<button class="btn btn-primary" @click="saveAccount()" :disabled="accountSaving || !accountForm.password" style="width:auto;padding:8px 20px;font-size:13px;"> <button class="btn btn-primary" @click="saveAccount()" :disabled="accountSaving || !accountForm.password" style="width:auto;padding:8px 20px;font-size:13px;">
<span x-show="!accountSaving">🔒 Update password</span> <span x-show="!accountSaving">{{ fd_icon("lock",14) }} Update password</span>
<span x-show="accountSaving">Saving…</span> <span x-show="accountSaving">Saving…</span>
</button> </button>
</div> </div>
@@ -186,8 +186,8 @@
</div> </div>
<div class="setting-control"> <div class="setting-control">
<select x-model="theme" @change="applyTheme()" style="background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;padding:6px 10px;outline:none;"> <select x-model="theme" @change="applyTheme()" style="background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;padding:6px 10px;outline:none;">
<option value="dark">🌙 Dark</option> <option value="dark">Dark</option>
<option value="light">☀️ Light</option> <option value="light">Light</option>
</select> </select>
</div> </div>
</div> </div>
@@ -198,11 +198,11 @@
</div> </div>
<div class="setting-control"> <div class="setting-control">
<select x-model="defaultView" @change="saveDefaultView()" style="background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;padding:6px 10px;outline:none;"> <select x-model="defaultView" @change="saveDefaultView()" style="background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;padding:6px 10px;outline:none;">
<option value="tree">🌳 Tree</option> <option value="tree">{{ fd_icon("folder",14) }} Tree</option>
<option value="list">📋 List</option> <option value="list">{{ fd_icon("list",14) }} List</option>
<option value="details">📊 Details</option> <option value="details">{{ fd_icon("bar-chart",14) }} Details</option>
<option value="title">🏷️ Title</option> <option value="title">{{ fd_icon("grid",14) }} Title</option>
<option value="content">📝 Content</option> <option value="content">{{ fd_icon("align-left",14) }} Content</option>
</select> </select>
</div> </div>
</div> </div>
@@ -281,7 +281,7 @@
<p style="color:var(--text-dim);margin:0 0 16px;"> <p style="color:var(--text-dim);margin:0 0 16px;">
Are you sure you want to delete the tag "<strong x-text="deletingTag?.name"></strong>"? Are you sure you want to delete the tag "<strong x-text="deletingTag?.name"></strong>"?
<span x-show="deletingTag?.count > 0" style="color:var(--danger);display:block;margin-top:4px;"> <span x-show="deletingTag?.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
⚠️ This tag is used on <span x-text="deletingTag?.count"></span> item(s). {{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag?.count"></span> item(s).
</span> </span>
</p> </p>
<div style="display:flex;gap:8px;justify-content:flex-end;"> <div style="display:flex;gap:8px;justify-content:flex-end;">
@@ -317,7 +317,7 @@
<!-- Gitea --> <!-- Gitea -->
<div class="setting-row"> <div class="setting-row">
<div> <div>
<div class="setting-label">🦎 Gitea <span x-show="authMethod==='gitea'" style="font-size:10px;background:var(--accent);color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">Primary</span></div> <div class="setting-label">Gitea <span x-show="authMethod==='gitea'" style="font-size:10px;background:var(--accent);color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">Primary</span></div>
<div class="setting-desc"> <div class="setting-desc">
<span x-show="giteaLinked">Connected to git.dracodev.net</span> <span x-show="giteaLinked">Connected to git.dracodev.net</span>
<span x-show="!giteaLinked">Connect your Gitea account to access projects</span> <span x-show="!giteaLinked">Connect your Gitea account to access projects</span>
@@ -326,13 +326,13 @@
<div class="setting-control"> <div class="setting-control">
<span x-show="giteaLinked" style="font-size:12px;color:var(--toast-success-bg);">✅ Active</span> <span x-show="giteaLinked" style="font-size:12px;color:var(--toast-success-bg);">✅ Active</span>
<button x-show="giteaLinked && authMethod!=='gitea'" class="btn-sm" style="color:var(--danger);" @click="disconnectGitea()">Disconnect</button> <button x-show="giteaLinked && authMethod!=='gitea'" class="btn-sm" style="color:var(--danger);" @click="disconnectGitea()">Disconnect</button>
<a x-show="!giteaLinked" class="btn-sm" href="/auth/login?provider=gitea&mode=link" style="text-decoration:none;color:var(--accent);">🔗 Connect</a> <a x-show="!giteaLinked" class="btn-sm" href="/auth/login?provider=gitea&mode=link" style="text-decoration:none;color:var(--accent);">{{ fd_icon("link",14) }} Connect</a>
</div> </div>
</div> </div>
<!-- GitHub --> <!-- GitHub -->
<div class="setting-row"> <div class="setting-row">
<div> <div>
<div class="setting-label">🐙 GitHub <span x-show="authMethod==='github'" style="font-size:10px;background:var(--accent);color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">Primary</span></div> <div class="setting-label">GitHub <span x-show="authMethod==='github'" style="font-size:10px;background:var(--accent);color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">Primary</span></div>
<div class="setting-desc"> <div class="setting-desc">
<span x-show="githubLinked">Connected to github.com</span> <span x-show="githubLinked">Connected to github.com</span>
<span x-show="!githubLinked">Connect your GitHub account to access repositories</span> <span x-show="!githubLinked">Connect your GitHub account to access repositories</span>
@@ -341,7 +341,7 @@
<div class="setting-control"> <div class="setting-control">
<span x-show="githubLinked" style="font-size:12px;color:var(--toast-success-bg);">✅ Active</span> <span x-show="githubLinked" style="font-size:12px;color:var(--toast-success-bg);">✅ Active</span>
<button x-show="githubLinked && authMethod!=='github'" class="btn-sm" style="color:var(--danger);" @click="disconnectGithub()">Disconnect</button> <button x-show="githubLinked && authMethod!=='github'" class="btn-sm" style="color:var(--danger);" @click="disconnectGithub()">Disconnect</button>
<a x-show="!githubLinked" class="btn-sm" href="/auth/login?provider=github&mode=link" style="text-decoration:none;color:var(--accent);">🔗 Connect</a> <a x-show="!githubLinked" class="btn-sm" href="/auth/login?provider=github&mode=link" style="text-decoration:none;color:var(--accent);">{{ fd_icon("link",14) }} Connect</a>
</div> </div>
</div> </div>
</div> </div>
@@ -428,8 +428,8 @@
</td> </td>
<td> <td>
<div style="display:flex;gap:4px;"> <div style="display:flex;gap:4px;">
<button class="btn-sm" @click="editingUser=u; editUserForm={login:u.login,name:u.full_name,email:u.email,is_admin:u.is_admin,is_active:u.is_active}" title="Edit">✏️</button> <button class="btn-sm" @click="editingUser=u; editUserForm={login:u.login,name:u.full_name,email:u.email,is_admin:u.is_admin,is_active:u.is_active}" title="Edit">{{ fd_icon("edit",14) }}</button>
<button class="btn-sm btn-sm-danger" @click="adminDeleteUser(u.id)" title="Delete" x-show="adminUsers.length > 1">🗑</button> <button class="btn-sm btn-sm-danger" @click="adminDeleteUser(u.id)" title="Delete" x-show="adminUsers.length > 1">{{ fd_icon("trash",14) }}</button>
</div> </div>
</td> </td>
</tr> </tr>
+6 -5
View File
@@ -1,3 +1,4 @@
{% from '_icons.html' import fd_icon %}
<!-- Table View — Notion style --> <!-- Table View — Notion style -->
<div id="view-content" x-data="tableView()"> <div id="view-content" x-data="tableView()">
<div class="table-container"> <div class="table-container">
@@ -9,23 +10,23 @@
<span x-show="sortField === 'name'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span> <span x-show="sortField === 'name'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
</th> </th>
<th @click="sortBy('status')"> <th @click="sortBy('status')">
<span class="th-icon">⚙</span> Status <span class="th-icon">{{ fd_icon("settings",14) }}</span> Status
<span x-show="sortField === 'status'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span> <span x-show="sortField === 'status'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
</th> </th>
<th @click="sortBy('assignee')"> <th @click="sortBy('assignee')">
<span class="th-icon">👤</span> Assign <span class="th-icon">{{ fd_icon("user",14) }}</span> Assign
<span x-show="sortField === 'assignee'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span> <span x-show="sortField === 'assignee'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
</th> </th>
<th @click="sortBy('deadline')"> <th @click="sortBy('deadline')">
<span class="th-icon">📅</span> Deadline <span class="th-icon">{{ fd_icon("calendar",14) }}</span> Deadline
<span x-show="sortField === 'deadline'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span> <span x-show="sortField === 'deadline'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
</th> </th>
<th @click="sortBy('team')"> <th @click="sortBy('team')">
<span class="th-icon">👥</span> Team <span class="th-icon">{{ fd_icon("users",14) }}</span> Team
<span x-show="sortField === 'team'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span> <span x-show="sortField === 'team'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
</th> </th>
<th @click="sortBy('keywords')"> <th @click="sortBy('keywords')">
<span class="th-icon">🏷</span> AI keywords <span class="th-icon">{{ fd_icon("tag",14) }}</span> AI keywords
<span x-show="sortField === 'keywords'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span> <span x-show="sortField === 'keywords'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
</th> </th>
</tr> </tr>
+2 -1
View File
@@ -1,3 +1,4 @@
{% from '_icons.html' import fd_icon %}
<!-- Team Load — Notion-style stacked bar chart --> <!-- Team Load — Notion-style stacked bar chart -->
<div id="view-content" x-data="teamLoad()" class="team-load"> <div id="view-content" x-data="teamLoad()" class="team-load">
{% for member in team_data %} {% for member in team_data %}
@@ -29,7 +30,7 @@
{% if not team_data %} {% if not team_data %}
<div style="text-align:center; padding:48px; width:100%; color:var(--text-dim);"> <div style="text-align:center; padding:48px; width:100%; color:var(--text-dim);">
<div style="font-size:48px; margin-bottom:16px;">👥</div> <div style="margin-bottom:16px;">{{ fd_icon("users",48) }}</div>
<h3>No team data</h3> <h3>No team data</h3>
<p style="margin-top:8px;">Assign issues to team members to see their workload.</p> <p style="margin-top:8px;">Assign issues to team members to see their workload.</p>
</div> </div>
+7 -7
View File
@@ -1,12 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_icon %}🗑️{% endblock %} {% block page_icon %}{{ fd_icon("trash",18) }}{% endblock %}
{% block page_title %}Trash{% endblock %} {% block page_title %}Trash{% endblock %}
{% block title_prefix %}Trash{% endblock %} {% block title_prefix %}Trash{% endblock %}
{% block content %} {% block content %}
<div class="page-title-area"> <div class="page-title-area">
<div class="page-title"> <div class="page-title">
<span class="page-icon-lg">🗑️</span> <span class="page-icon-lg">{{ fd_icon("trash",24) }}</span>
<h1>Trash</h1> <h1>Trash</h1>
</div> </div>
</div> </div>
@@ -14,7 +14,7 @@
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;"> <div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
<!-- Search --> <!-- Search -->
<div style="position:relative; margin-bottom:12px;"> <div style="position:relative; margin-bottom:12px;">
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">🔍</span> <span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
<input type="text" placeholder="Search pages in Trash" x-model="search" <input type="text" placeholder="Search pages in Trash" x-model="search"
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;"> style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
</div> </div>
@@ -22,10 +22,10 @@
<!-- Filters --> <!-- Filters -->
<div style="display:flex; gap:8px; margin-bottom:16px;"> <div style="display:flex; gap:8px; margin-bottom:16px;">
<button class="trash-filter active"> <button class="trash-filter active">
<span style="color:var(--accent);">👤</span> Last edited by ▾ <span style="color:var(--accent);">{{ fd_icon("user",14) }}</span> Last edited by ▾
</button> </button>
<button class="trash-filter"> <button class="trash-filter">
<span>📁</span> In ▾ <span>{{ fd_icon("folder",14) }}</span> In ▾
</button> </button>
</div> </div>
@@ -42,12 +42,12 @@
↩️ ↩️
</button> </button>
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)"> <button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
🗑️ {{ fd_icon("trash",14) }}
</button> </button>
</div> </div>
</template> </template>
<div x-show="filteredItems.length === 0" class="lib-empty"> <div x-show="filteredItems.length === 0" class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">🗑️</div> <div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",14) }}</div>
<h3>Trash is empty</h3> <h3>Trash is empty</h3>
<p>Deleted pages will appear here for 30 days.</p> <p>Deleted pages will appear here for 30 days.</p>
</div> </div>
+10 -10
View File
@@ -1,12 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_title %}Workspace{% endblock %} {% block page_title %}Workspace{% endblock %}
{% block title_prefix %}Workspace{% endblock %} {% block title_prefix %}Workspace{% endblock %}
{% block page_icon %}🏠{% endblock %} {% block page_icon %}{{ fd_icon("home",18) }}{% endblock %}
{% block topbar %} {% block topbar %}
{% set page_icon = "🏠" %} {% set page_icon = "home" %}
{% set page_title = "Workspace — Projects" %} {% set page_title = "Workspace — Projects" %}
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">🔑</a><a href="/accounts/settings" class="topbar-btn" title="Settings">⚙</a>' %} {% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
{% include '_header.html' %} {% include '_header.html' %}
{% endblock %} {% endblock %}
@@ -51,7 +51,7 @@
<div class="workspace-layout" x-data="workspacePage()"> <div class="workspace-layout" x-data="workspacePage()">
<div class="workspace-header"> <div class="workspace-header">
<h1>🏠 Workspace</h1> <h1>{{ fd_icon("home",20) }} Workspace</h1>
<p>All your projects in one place — built-in, Gitea, and GitHub</p> <p>All your projects in one place — built-in, Gitea, and GitHub</p>
</div> </div>
@@ -62,7 +62,7 @@
<template x-for="p in builtinProjects" :key="p.id"> <template x-for="p in builtinProjects" :key="p.id">
<div class="project-card" @click="openProject(p)"> <div class="project-card" @click="openProject(p)">
<div class="project-card-top"> <div class="project-card-top">
<span class="project-card-icon" x-text="p.icon || '📁'"></span> <span class="project-card-icon" x-html="getSvgIcon(p.icon || 'folder',20)"></span>
<span class="forge-badge builtin">Built-in</span> <span class="forge-badge builtin">Built-in</span>
</div> </div>
<div class="project-card-name" x-text="p.name"></div> <div class="project-card-name" x-text="p.name"></div>
@@ -80,12 +80,12 @@
<!-- Gitea Projects --> <!-- Gitea Projects -->
<div class="forge-section" x-show="giteaProjects.length"> <div class="forge-section" x-show="giteaProjects.length">
<h2><span class="forge-badge gitea">🔗 Gitea</span> Repositories</h2> <h2><span class="forge-badge gitea">{{ fd_icon("link",14) }} Gitea</span> Repositories</h2>
<div class="project-grid"> <div class="project-grid">
<template x-for="p in giteaProjects" :key="p.id"> <template x-for="p in giteaProjects" :key="p.id">
<div class="project-card" @click="openProject(p)"> <div class="project-card" @click="openProject(p)">
<div class="project-card-top"> <div class="project-card-top">
<span class="project-card-icon">📂</span> <span class="project-card-icon">{{ fd_icon("folder",14) }}</span>
<span class="forge-badge gitea">Gitea</span> <span class="forge-badge gitea">Gitea</span>
</div> </div>
<div class="project-card-name" x-text="p.name"></div> <div class="project-card-name" x-text="p.name"></div>
@@ -101,12 +101,12 @@
<!-- GitHub Projects --> <!-- GitHub Projects -->
<div class="forge-section" x-show="githubProjects.length"> <div class="forge-section" x-show="githubProjects.length">
<h2><span class="forge-badge github">🐙 GitHub</span> Repositories</h2> <h2><span class="forge-badge github">{{ fd_icon("link",14) }} GitHub</span> Repositories</h2>
<div class="project-grid"> <div class="project-grid">
<template x-for="p in githubProjects" :key="p.id"> <template x-for="p in githubProjects" :key="p.id">
<div class="project-card" @click="openProject(p)"> <div class="project-card" @click="openProject(p)">
<div class="project-card-top"> <div class="project-card-top">
<span class="project-card-icon">📂</span> <span class="project-card-icon">{{ fd_icon("folder",14) }}</span>
<span class="forge-badge github">GitHub</span> <span class="forge-badge github">GitHub</span>
</div> </div>
<div class="project-card-name" x-text="p.name"></div> <div class="project-card-name" x-text="p.name"></div>
@@ -179,7 +179,7 @@ function workspacePage() {
}); });
if (r.ok) { if (r.ok) {
const p = await r.json(); const p = await r.json();
this.builtinProjects.unshift({...p, icon: '📁', pageCount: 0}); this.builtinProjects.unshift({...p, icon: 'folder', pageCount: 0});
this.newProjectName = ''; this.newProjectName = '';
this.showCreateModal = false; this.showCreateModal = false;
} }
+15 -15
View File
@@ -1,12 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_title %}Workspaces{% endblock %} {% block page_title %}Workspaces{% endblock %}
{% block title_prefix %}Workspaces{% endblock %} {% block title_prefix %}Workspaces{% endblock %}
{% block page_icon %}🏠{% endblock %} {% block page_icon %}{{ fd_icon("home",18) }}{% endblock %}
{% block topbar %} {% block topbar %}
{% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %} {% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %}
{% set page_icon = "🏠" %} {% set page_icon = "home" %}
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">🔑</a><a href="/accounts/settings" class="topbar-btn" title="Settings">⚙</a>' %} {% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
{% include '_header.html' %} {% include '_header.html' %}
{% endblock %} {% endblock %}
@@ -51,39 +51,39 @@
<div class="ws-page" x-data="workspacesPage()"> <div class="ws-page" x-data="workspacesPage()">
<div class="ws-header"> <div class="ws-header">
<h1>🏠 Workspaces</h1> <h1>{{ fd_icon("home",20) }} Workspaces</h1>
</div> </div>
<!-- ── Local Workspaces ── --> <!-- ── Local Workspaces ── -->
<div class="ws-section"> <div class="ws-section">
<div class="ws-section-title">📁 My Workspaces <span class="badge" x-text="workspaces.length"></span></div> <div class="ws-section-title">{{ fd_icon("folder",16) }} My Workspaces <span class="badge" x-text="workspaces.length"></span></div>
<div class="ws-grid"> <div class="ws-grid">
<template x-for="ws in workspaces" :key="'local-'+ws.id"> <template x-for="ws in workspaces" :key="'local-'+ws.id">
<div class="ws-card" :class="{active:ws.id===activeId}" @click="selectLocal(ws)"> <div class="ws-card" :class="{active:ws.id===activeId}" @click="selectLocal(ws)">
<div class="ws-card-name" x-text="ws.name"></div> <div class="ws-card-name" x-text="ws.name"></div>
<div class="ws-card-count" x-text="ws.page_count+' page(s)'"></div> <div class="ws-card-count" x-text="ws.page_count+' page(s)'"></div>
<div class="ws-card-actions" @click.stop> <div class="ws-card-actions" @click.stop>
<button class="btn-sm" @click="renameWs(ws)">✏️</button> <button class="btn-sm" @click="renameWs(ws)">{{ fd_icon("edit",14) }}</button>
<button class="btn-sm" style="color:var(--danger);border-color:rgba(255,80,80,.3)" @click="deleteWs(ws)">🗑</button> <button class="btn-sm" style="color:var(--danger);border-color:rgba(255,80,80,.3)" @click="deleteWs(ws)">{{ fd_icon("trash",14) }}</button>
</div> </div>
</div> </div>
</template> </template>
<div class="ws-card btn-new" @click="showCreate=true"> <div class="ws-card btn-new" @click="showCreate=true" style="display:flex;align-items:center;justify-content:center;height:auto;min-height:140px;">
<span style="font-size:32px">+</span> <span style="font-size:48px;line-height:1;">+</span>
</div> </div>
</div> </div>
</div> </div>
<!-- ── Gitea Projects ── --> <!-- ── Gitea Projects ── -->
<div class="ws-section"> <div class="ws-section">
<div class="ws-section-title">🔗 Gitea Projects <span class="badge" x-text="giteaTotal"></span></div> <div class="ws-section-title">{{ fd_icon("link",16) }} Gitea Projects <span class="badge" x-text="giteaTotal"></span></div>
<!-- Not linked --> <!-- Not linked -->
<div class="gitea-empty" x-show="giteaStatus==='not_linked'"> <div class="gitea-empty" x-show="giteaStatus==='not_linked'">
<div>Connect your Gitea account to see your projects.</div> <div>Connect your Gitea account to see your projects.</div>
<a class="gitea-connect" href="/auth/login?provider=gitea&mode=link">🔗 Connect Gitea</a> <a class="gitea-connect" href="/auth/login?provider=gitea&mode=link">{{ fd_icon("link",14) }} Connect Gitea</a>
<span style="margin:0 8px;color:var(--text-dim);">or</span> <span style="margin:0 8px;color:var(--text-dim);">or</span>
<a class="gitea-connect" href="/auth/login?provider=gitea">📝 Register with Gitea</a> <a class="gitea-connect" href="/auth/login?provider=gitea">{{ fd_icon("edit",14) }} Register with Gitea</a>
</div> </div>
<!-- Loading skeletons --> <!-- Loading skeletons -->
@@ -99,7 +99,7 @@
<!-- Error --> <!-- Error -->
<div class="gitea-empty" x-show="giteaStatus==='error'"> <div class="gitea-empty" x-show="giteaStatus==='error'">
<div>⚠ Could not load Gitea projects.</div> <div>{{ fd_icon("alert-triangle",14) }} Could not load Gitea projects.</div>
<button class="gitea-connect" @click="loadGitea()">Retry</button> <button class="gitea-connect" @click="loadGitea()">Retry</button>
</div> </div>
@@ -119,7 +119,7 @@
</template> </template>
</div> </div>
<!-- Search --> <!-- Search -->
<input type="text" class="search-input" x-model="giteaSearch" placeholder="🔍 Filter projects…" style="width:200px;padding:6px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none;"> <input type="text" class="search-input" x-model="giteaSearch" placeholder="Filter projects…" style="width:200px;padding:6px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none;">
</div> </div>
<!-- Project cards (filtered) --> <!-- Project cards (filtered) -->
@@ -139,7 +139,7 @@
<span x-text="proj.name"></span> <span x-text="proj.name"></span>
</div> </div>
<div class="ws-card-count"> <div class="ws-card-count">
<span x-show="proj.private" style="margin-right:6px">🔒</span> <span x-show="proj.private" style="margin-right:6px">{{ fd_icon("lock",14) }}</span>
<span x-text="proj.description || proj.language || ''" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px;"></span> <span x-text="proj.description || proj.language || ''" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px;"></span>
</div> </div>
</div> </div>
+702
View File
@@ -0,0 +1,702 @@
# FlowDeck Agent — Intégration & Fonctionnement
> **Version:** 1.0 · **Date:** 2026-07-20 · **Auteur:** Hermes-Deepin
> **Cible:** Agent IA natif pour FlowDeck, inspiré de Notion Agent (2026)
> **Prérequis:** FlowDeck v4.0.x · FastAPI · SQLite · Gitea/GitHub
---
## Table des matières
1. [Vision & Objectifs](#1-vision--objectifs)
2. [Concept : de l'IA qui répond à l'IA qui réalise](#2-concept)
3. [Positionnement dans l'architecture FlowDeck](#3-positionnement)
4. [Placement dans l'interface (sidebar 🤖 Agents)](#4-placement-interface)
5. [Boucle d'exécution de l'agent](#5-boucle-dexécution)
6. [Sources de contexte FlowDeck](#6-sources-de-contexte)
7. [Modèle de données (nouvelles tables)](#7-modèle-de-données)
8. [Routes & API Agent](#8-routes--api)
9. [Le service AgentEngine](#9-service-agentengine)
10. [Système d'outils (Tools)](#10-système-doutils)
11. [Permissions & sécurité](#11-permissions--sécurité)
12. [Custom Agents & déclencheurs](#12-custom-agents)
13. [Skills réutilisables](#13-skills)
14. [Cas d'usage FlowDeck](#14-cas-dusage)
15. [Plan de migration](#15-plan-de-migration)
16. [Limitations](#16-limitations)
---
## 1. Vision & Objectifs
FlowDeck Agent transforme FlowDeck d'un clone Notion **passif** (où l'utilisateur crée manuellement collections, pages, propriétés) en un espace de travail **agentique** où une IA peut :
- Comprendre un objectif exprimé en langage naturel
- Lire le contexte du workspace (collections, pages, propriétés, issues Gitea)
- Planifier une suite d'actions
- **Exécuter** ces actions via l'API interne (créer collections, pages, propriétés, vues, sync Gitea)
- Retourner un résultat consolidé
L'agent réutilise l'infrastructure existante : **il n'appelle pas directement la DB**, il passe par les mêmes routers FastAPI (`/db/*`, `/board/api/*`) que l'utilisateur, garantissant cohérence, validation et respect du `PermissionManager`.
Cette fonctionnalité concrétise la ligne `v5.0.0 — AI Assistants` de la roadmap, en s'inspirant directement du modèle Notion Agent.
---
## 2. Concept : de l'IA qui répond à l'IA qui réalise {#2-concept}
Assistant classique :
Utilisateur → Prompt → Réponse texte
FlowDeck Agent :
Utilisateur → Objectif → Planification → Collecte contexte → Actions API → Résultat
| Notion AI | FlowDeck Agent |
|-----------|----------------|
| Répond, résume, réécrit | Planifie, recherche, **agit** |
| Bloc de texte | Crée collections, pages, propriétés, vues |
| Sans état | Historique de conversation + snapshots |
| Aucune action DB | Modifie le workspace via routers FastAPI |
---
## 3. Positionnement dans l'architecture FlowDeck {#3-positionnement}
L'agent s'insère comme un **nouveau service** et un **nouveau router**, sans modifier le cœur existant.
```
┌──────────────────────────────────────────────────────────────────┐
│ CLIENT (Browser) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ agent_panel.html — Panneau conversation (Alpine.js) │ │
│ │ agent_message.html — Fragment message (HTMX streaming) │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────┬───────────────────────────────────────┘
│ HTTP (SSE stream / JSON)
┌──────────────────────────▼───────────────────────────────────────┐
│ FASTAPI (Python 3.11+) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ROUTERS │ │
│ │ ├─ agent.py ← NOUVEAU /api/agent/* │ │
│ │ └─ (existants: board.py, api.py, collections.py, ...) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SERVICES │ │
│ │ ├─ AgentEngine ← NOUVEAU (orchestrateur) │ │
│ │ ├─ ToolRegistry ← NOUVEAU (outils actionnables) │ │
│ │ ├─ LLMClient ← NOUVEAU (GPT/Claude/Gemini/local) │ │
│ │ ├─ ContextBuilder ← NOUVEAU (collecte workspace) │ │
│ │ ├─ SearchEngine (réutilisé — FTS5) │ │
│ │ ├─ PermissionManager (réutilisé — ACL) │ │
│ │ └─ GiteaClient (réutilisé — API Gitea) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ DATA LAYER — SQLite │ │
│ │ ├─ agents ← NOUVEAU │ │
│ │ ├─ agent_conversations ← NOUVEAU │ │
│ │ ├─ agent_messages ← NOUVEAU │ │
│ │ ├─ agent_actions ← NOUVEAU (journal audit) │ │
│ │ ├─ agent_skills ← NOUVEAU │ │
│ │ └─ (tables existantes: collections, pages, ...) │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────┬───────────────────────────────────────┘
│ HTTPS
┌──────────────────┴──────────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────────────┐
│ GITEA API REST │ │ PROVIDER LLM │
│ (contexte issues)│ │ OpenAI / Anthropic / │
│ │ │ Gemini / Ollama local │
└──────────────────┘ └──────────────────────────┘
```
**Principe clé — Tool-calling en boucle fermée :**
Le `LLMClient` ne touche jamais la base. Il émet des *intentions d'outil* (function calls). L'`AgentEngine` les traduit en appels HTTP internes vers les routers existants, exécutés avec la session et les permissions de l'utilisateur.
---
## 4. Placement dans l'interface (sidebar 🤖 Agents) {#4-placement-interface}
Le sidebar FlowDeck contient déjà une entrée **🤖 Agents** (voir ARCHITECTURE §5.2). Elle devient le point d'entrée de l'agent.
```
┌────────────┬─────────────────────────────────────────────────┐
│ SIDEBAR │ CONTENU PRINCIPAL │
│ │ │
│ 🕒 Recents │ ┌─ Page / Collection courante ──────────────┐ │
│ ⭐ Favoris │ │ │ │
│ 🤖 Agents ◄┼── clic ouvre le panneau agent (droite) │ │
│ 👥 Shared │ └────────────────────────────────────────────┘ │
│ │ ┌────────────┐ │
│ │ │ 🤖 Agent │ │
│ │ ├────────────┤ │
│ │ │ Historique │ │
│ │ │ Réponses │ │
│ │ │ ───────── │ │
│ │ │ @Sources │ │
│ │ │ 📎 Fichier │ │
│ │ │ [Modèle ▾] │ │
│ │ │ [Saisie... ]│ │
│ │ └────────────┘ │
└────────────┴─────────────────────────────────────────────────┘
```
Deux modes d'accès, comme Notion Agent :
- **Icône flottante** (coin inférieur droit) — accès global rapide
- **Section 🤖 Agents** du sidebar — liste des agents (personnel + custom)
L'affichage de la section 🤖 Agents est déjà prévu dans les 3 scénarios de compte (A/B/C) — voir ARCHITECTURE §6.5.4 : **toujours visible**.
---
## 5. Boucle d'exécution de l'agent {#5-boucle-dexécution}
Adaptation du modèle Notion (Compréhension → Contexte → Raisonnement → Action) à l'architecture FlowDeck :
```
Utilisateur formule un objectif
│
▼
┌─────────────────────────────────────────────┐
│ 1. COMPRÉHENSION (AgentEngine) │
│ Parse objectif + instructions custom │
└──────────────────┬──────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ 2. COLLECTE CONTEXTE (ContextBuilder) │
│ - Page/collection courante (cookie ws) │
│ - @mentions explicites │
│ - SearchEngine FTS5 (pages pertinentes) │
│ - Issues Gitea liées (GiteaClient) │
│ - Fichiers joints │
│ ⚠ filtré par PermissionManager │
└──────────────────┬──────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ 3. RAISONNEMENT (LLMClient + ToolRegistry) │
│ LLM reçoit: objectif + contexte + schéma │
│ des outils disponibles (function calling) │
│ → émet un plan / une intention d'outil │
└──────────────────┬──────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ 4. ACTION (AgentEngine → routers FastAPI) │
│ Exécute l'appel outil → route interne │
│ POST /db → create_collection │
│ POST /db/{id}/properties → add_property │
│ POST /db/{id}/pages → create_page │
│ Journalise dans agent_actions │
└──────────────────┬──────────────────────────┘
▼
Résultat outil réinjecté dans le LLM
│
┌────────┴────────┐
│ Objectif atteint?│
└────┬────────┬────┘
non │ │ oui
│ ▼
(retour étape 3) ┌─────────────────────────────┐
│ 5. RÉSULTAT consolidé + lien │
│ vers les objets créés │
└─────────────────────────────┘
```
Cette boucle **ReAct** (Reason + Act) se répète jusqu'à `max_iterations` (défaut : 12) ou atteinte de l'objectif.
---
## 6. Sources de contexte FlowDeck {#6-sources-de-contexte}
Correspondance avec les sources Notion Agent, adaptées aux entités FlowDeck :
| Source Notion | Équivalent FlowDeck | Mécanisme |
|---------------|---------------------|-----------|
| Pages, sous-pages | `pages`, `collection_pages` (parent_id) | `SearchEngine` FTS5 + lecture directe |
| Bases de données | `collections` + `collection_properties` | Lecture schéma `schema_json` |
| Wikis / docs internes | Blocs éditeur (`content_json`) | Parsing blocs |
| Jira | **Issues Gitea/GitHub** | `GiteaClient` (issues, labels, milestones) |
| Slack / Drive | Fichiers uploadés (`/api/files/`) | Lecture `files` property |
| Fichiers joints | Upload local-workspace | PDF/MD parsing |
| `@Projet Alpha` | `@collection` / `@page` / `@repo` | Résolution de mention |
**Résolution des mentions** — L'utilisateur peut cibler :
@collection:Roadmap → force le contexte sur une collection
@page:123 → une page précise
@repo:bruno/flowdeck → issues d'un repo Gitea
@ws → workspace courant complet
Le `ContextBuilder` respecte strictement le `PermissionManager` : l'agent ne voit **que** ce que l'utilisateur peut voir (même token, mêmes ACL — voir ARCHITECTURE §6.2).
---
## 7. Modèle de données (nouvelles tables) {#7-modèle-de-données}
```sql
-- Définition d'un agent (personnel ou custom)
CREATE TABLE agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER REFERENCES workspaces(id),
name TEXT NOT NULL DEFAULT 'FlowDeck Agent',
icon TEXT DEFAULT '🤖',
agent_type TEXT NOT NULL DEFAULT 'personal',
-- 'personal' | 'custom'
description TEXT DEFAULT '',
-- Instructions personnalisées (ton, format, règles métier)
system_instructions TEXT DEFAULT '',
-- Modèle IA par défaut
model TEXT DEFAULT 'claude-opus-4-8',
-- gpt-* | claude-* | gemini-* | ollama:*
-- Périmètre custom agent : collections/repos autorisés
scope_json TEXT NOT NULL DEFAULT '{}',
-- {collections:[3,7], repos:["bruno/flowdeck"], tools:["create_page"]}
-- Déclencheurs (custom agents)
trigger_json TEXT NOT NULL DEFAULT '{}',
-- {type:"schedule", cron:"0 9 * * 1"} | {type:"webhook", event:"issue.opened"}
is_active BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id),
UNIQUE(workspace_id, name)
);
-- Conversations (fils de discussion, comme Notion history)
CREATE TABLE agent_conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
title TEXT DEFAULT 'New conversation',
-- Contexte de départ (page/collection ouverte à l'invocation)
context_json TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Messages d'une conversation
CREATE TABLE agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
-- 'user' | 'assistant' | 'tool' | 'system'
content TEXT NOT NULL DEFAULT '',
-- Pour role='tool' : nom de l'outil + payload + résultat
tool_calls_json TEXT DEFAULT '[]',
model TEXT,
tokens_used INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Journal d'audit : CHAQUE action modifiant le workspace
CREATE TABLE agent_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
tool_name TEXT NOT NULL,
-- create_collection | create_page | add_property | ...
target_type TEXT,
-- collection | page | property | view | issue
target_id TEXT,
payload_json TEXT NOT NULL DEFAULT '{}',
result_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'success',
-- success | error | reverted
-- Snapshot avant modification → permet le rollback
undo_snapshot_json TEXT DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
executed_by INTEGER REFERENCES users(id)
);
-- Skills réutilisables (mini-prompts)
CREATE TABLE agent_skills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER REFERENCES workspaces(id),
name TEXT NOT NULL,
-- "Génération de PRD", "Analyse SWOT", "Préparation sprint"
description TEXT DEFAULT '',
prompt_template TEXT NOT NULL,
-- Outils que la skill peut invoquer
allowed_tools_json TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id),
UNIQUE(workspace_id, name)
);
CREATE INDEX idx_agent_conv_user ON agent_conversations(user_id, updated_at);
CREATE INDEX idx_agent_msg_conv ON agent_messages(conversation_id, created_at);
CREATE INDEX idx_agent_action_conv ON agent_actions(conversation_id);
Intégration au diagramme de relations existant
users ──1:N──► agent_conversations ──1:N──► agent_messages
│ │
│ └──1:N──► agent_actions ──► (collections/pages/...)
│
workspaces ──1:N──► agents ──1:N──► agent_conversations
│
└──1:N──► agent_skills
```
## 8. Routes & API Agent {#8-routes--api}
Nouveau router agent.py, préfixe /api/agent.
# Agents (définitions)
```
GET /api/agent → Liste agents du workspace
POST /api/agent → Créer custom agent
GET /api/agent/{agent_id} → Détail agent
PUT /api/agent/{agent_id} → Update (instructions, scope, model)
DELETE /api/agent/{agent_id} → Supprimer custom agent
```
# Conversations
```
GET /api/agent/conversations → Historique des conversations
POST /api/agent/conversations → Nouvelle conversation
GET /api/agent/conversations/{id} → Messages d'une conversation
DELETE /api/agent/conversations/{id} → Supprimer conversation
```
# Exécution (cœur)
```
POST /api/agent/conversations/{id}/run → Envoyer un objectif (SSE stream)
body: {message, model?, mentions?, files?, skill_id?}
→ stream: reasoning → tool_call → tool_result → ... → final
```
# Actions & audit
```
GET /api/agent/conversations/{id}/actions → Journal des actions
POST /api/agent/actions/{action_id}/undo → Annuler une action (rollback)
```
# Skills
```
GET /api/agent/skills → Liste skills
POST /api/agent/skills → Créer skill
POST /api/agent/skills/{id}/apply → Appliquer une skill
```
# Custom agents — déclencheurs
```
POST /api/agent/{agent_id}/trigger → Déclenchement manuel
```
# (déclenchement auto via scheduler interne + webhooks.py)
Streaming — La route /run renvoie un flux Server-Sent Events (SSE) pour afficher en temps réel le raisonnement et chaque action, à la manière de l'interface conversationnelle Notion. Le frontend (Alpine.js) consomme le flux et met à jour agent_panel.html.
-------------------------
## 9. Le service AgentEngine {#9-service-agentengine}
Orchestrateur central. Pseudo-implémentation :
```
class AgentEngine:
"""Orchestrateur ReAct pour FlowDeck Agent."""
def __init__(self, db, user, request):
self.db = db
self.user = user
self.request = request # pour réutiliser session/cookies
self.llm = LLMClient()
self.tools = ToolRegistry(db, user, request)
self.ctx = ContextBuilder(db, user)
self.perms = PermissionManager(db, user)
async def run(self, conversation_id, objective, *, model=None,
mentions=None, files=None, skill_id=None):
agent = self._load_agent(conversation_id)
model = model or agent.model
# 1. COMPRÉHENSION + instructions custom + skill
system = self._build_system_prompt(agent, skill_id)
# 2. COLLECTE CONTEXTE (respecte permissions)
context = await self.ctx.build(
workspace=self._active_workspace(),
mentions=mentions,
files=files,
)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": f"{objective}\n\n# Contexte\n{context}"},
]
# 3-4. BOUCLE RAISONNEMENT ↔ ACTION
for _ in range(MAX_ITERATIONS): # défaut 12
response = await self.llm.complete(
model=model,
messages=messages,
tools=self.tools.schema(scope=agent.scope), # function calling
stream=True,
)
yield {"type": "reasoning", "content": response.text}
if not response.tool_calls:
yield {"type": "final", "content": response.text}
break
for call in response.tool_calls:
# Vérif permission AVANT exécution
self.perms.assert_can(call.tool, call.args)
result = await self.tools.execute(call.tool, call.args)
# Journal d'audit + snapshot rollback
self._log_action(conversation_id, call, result)
yield {"type": "action",
"tool": call.tool,
"target": result.get("target"),
"status": result["status"]}
messages.append({"role": "tool",
"name": call.tool,
"content": result})
```
Points clés :
- stream=True → SSE vers le frontend
- Chaque tool_call passe par PermissionManager.assert_can() avant exécution
- Chaque action modifiant la DB écrit un agent_actions avec undo_snapshot_json → rollback possible
- La boucle réutilise le même token Gitea que l'utilisateur (via user_tokens)
-------------------------
10. Système d'outils (Tools) {#10-système-doutils}
Les outils sont des wrappers autour des routers FastAPI existants — l'agent ne réinvente rien.
| Outil | Route interne appelée | Ce que ça fait |
|-------|----------------------|----------------|
| `search_workspace` | `SearchEngine` (FTS5) | Recherche full-text pages/collections |
| `read_collection` | `GET /db/{id}` | Lit schéma + pages d'une collection |
| `read_page` | `GET /db/{c}/pages/{p}` | Lit une page + propriétés + blocs |
| `create_collection` | `POST /db` | Crée une database |
| `add_property` | `POST /db/{id}/properties` | Ajoute une propriété typée |
| `create_view` | `POST /db/{id}/views` | Crée une vue (table/board/calendar…) |
| `create_page` | `POST /db/{id}/pages` | Crée une page + valeurs propriétés |
| `update_page` | `PUT /db/{c}/pages/{p}` | Modifie propriétés/contenu |
| `write_blocks` | `POST /board/api/pages/{id}/blocks` | Écrit du contenu (éditeur de blocs) |
| `add_relation` | `POST /db/{id}/properties` (type relation) | Lie deux collections |
| `create_sub_item` | `POST /db/{c}/pages/{p}/sub-items` | Crée un sub-item |
| `add_dependency` | `POST /db/{c}/pages/{p}/dependencies` | Ajoute blocks/blocked_by |
| `sync_gitea` | `POST /board/api/sync/{o}/{r}` | Synchronise issues ↔ collection |
| `read_gitea_issues` | `GiteaClient` | Lit issues/labels/milestones |
| `create_gitea_issue` | `POST /api/issues/{o}/{r}` | Crée une issue Gitea |
| `apply_template` | `POST /db/{id}/templates/{t}/apply` | Applique un template |
Schéma d'un outil (function calling) exposé au LLM :
```
{
"name": "create_page",
"description": "Crée une page dans une collection FlowDeck avec ses valeurs de propriétés.",
"parameters": {
"type": "object",
"properties": {
"collection_id": {"type": "integer"},
"title": {"type": "string"},
"property_values": {
"type": "object",
"description": "Map property_id → valeur (respecte le type)"
}
},
"required": ["collection_id", "title"]
}
}
```
Filtrage par scope — Pour un custom agent, ToolRegistry.schema(scope=...) ne renvoie que les outils autorisés dans agents.scope_json.tools, et limite les cibles aux collections/repos de scope_json.
-------------------------
## 11. Permissions & sécurité {#11-permissions--sécurité}
L'agent agit avec les permissions de l'utilisateur, jamais au-delà (principe Notion Agent : « mêmes permissions que l'utilisateur »).
```
┌───────────────────────────────────────────────────────────┐
│ GARDE-FOUS AGENT │
│ │
│ 1. Identité → session utilisateur + user_tokens │
│ 2. ACL → PermissionManager.assert_can() par outil│
│ 3. Scope custom → scope_json limite collections/repos │
│ 4. Database lock → collection is_locked ⇒ outils write KO │
│ 5. Audit → agent_actions journalise TOUT │
│ 6. Rollback → undo_snapshot_json par action │
│ 7. Rate limiting → RateLimiter (middleware existant) │
│ 8. Confirmation → actions destructives ⇒ approbation UI │
└───────────────────────────────────────────────────────────┘
```
Mode approbation — Configurable par agent :
- auto : l'agent exécute directement (création de contenu)
- confirm : actions destructives (delete_page, sync massif) demandent une confirmation dans le panneau avant exécution
Cohérent avec le modèle de rôles workspace (owner/admin/editor/commenter/viewer, ARCHITECTURE §6.2) : un agent lancé par un viewer ne peut que lire.
-------------------------
## 12. Custom Agents & déclencheurs {#12-custom-agents}
Comme Notion (Custom Agents 2026), FlowDeck distingue :
| Type | Portée | Exemple |
|------|--------|---------|
| **Personal Agent** | Généraliste, tout le workspace visible | Assistant quotidien |
| **Custom Agent** | Spécialisé, scope restreint, déclencheurs | Agent Reporting, Agent Support |
Déclencheurs (agents.trigger_json) :
```
type: "manual" → bouton dans l'UI
type: "schedule" → cron interne (ex: "0 9 * * 1" = lundi 9h)
type: "webhook" → branché sur webhooks.py (issue.opened, issue.closed…)
```
Exemples de custom agents FlowDeck :
- Agent Reporting — chaque lundi, scanne toutes les collections, produit une page « Rapport hebdo » avec KPI
- Agent Gitea Triage — à issue.opened, classe l'issue dans la bonne collection et remplit ses propriétés
- Agent Sprint — prépare une collection sprint depuis les issues du milestone courant
Le scheduler s'appuie sur le lifespan FastAPI (main.py) et les webhooks réutilisent le router webhooks.py existant.
-------------------------
## 13. Skills réutilisables {#13-skills}
Une skill est un mini-prompt paramétrable + liste d'outils autorisés (table agent_skills).
```
Skill "Préparation de sprint"
├─ prompt_template:
│ "Analyse les issues ouvertes du repo {repo}, crée une collection
│ sprint '{sprint_name}', ajoute les propriétés Status/Priority/Assignee,
│ importe chaque issue comme page, groupe en vue Board."
├─ allowed_tools: [read_gitea_issues, create_collection,
│ add_property, create_page, create_view]
└─ Invocation: POST /api/agent/skills/{id}/apply
```
Autres skills livrées par défaut : Analyse SWOT, Génération de compte-rendu, Génération de PRD, Synthèse GitHub/Gitea.
-------------------------
## 14. Cas d'usage FlowDeck {#14-cas-dusage}
Gestion de produit
> « Crée une roadmap Q3 à partir des issues du repo bruno/flowdeck »
```
L'agent : read_gitea_issues → create_collection("Roadmap Q3") → add_property(Status, Priority, DueDate) → create_page × N → create_view(timeline).
Développement logiciel
```
> « Analyse les bugs des 6 derniers mois et crée un plan d'amélioration »
```
sync_gitea → search_workspace → analyse LLM → create_collection("Plan qualité") → create_page (causes, actions) → add_dependency.
Direction
```
> « Génère les OKR du prochain trimestre depuis nos objectifs actuels »
```
read_collection(Objectifs) → raisonnement → create_collection("OKR Q4") → create_page × N.
Multi-étapes (comme Notion)
```
> « Résume les notes de réunion du trimestre et crée les tâches de suivi »
```
search_workspace(meetings) → synthèse → write_blocks (page résumé) → create_page × N (tâches) dans la collection Tasks.
```
-------------------------
## 15. Plan de migration {#15-plan-de-migration}
Phase 1 — Fondations DB & Service
1. Créer tables agents, agent_conversations, agent_messages,
agent_actions, agent_skills
2. Implémenter LLMClient (abstraction multi-provider)
3. Implémenter ContextBuilder (réutilise SearchEngine)
Phase 2 — Tool-calling
1. ToolRegistry : wrappers read-only d'abord (search, read_*)
2. Ajouter outils write (create_collection, create_page, ...)
3. Brancher PermissionManager.assert_can() sur chaque outil
4. Journal agent_actions + snapshots rollback
Phase 3 — Interface
1. agent_panel.html (Alpine.js) branché sur section 🤖 Agents
2. Streaming SSE (/run)
3. Icône flottante coin inférieur droit
4. Affichage temps réel reasoning + actions
Phase 4 — Personnalisation
1. Instructions custom par agent
2. Skills (table + /apply)
3. Sélecteur de modèle (GPT/Claude/Gemini/Ollama)
Phase 5 — Custom Agents
1. Scope + trigger_json
2. Scheduler (lifespan FastAPI)
3. Déclencheurs webhook (via webhooks.py)
4. Mode approbation (auto/confirm)
-------------------------
## 16. Limitations {#16-limitations}
Alignées sur les limites connues de Notion Agent, adaptées à FlowDeck. L'agent ne peut pas :
- Modifier les paramètres du workspace (/workspace/{id}/settings)
- Gérer les membres / rôles (workspace_members)
- Gérer l'authentification ou les intégrations OAuth (connexion/déconnexion Gitea/GitHub)
- Publier ou partager publiquement une page sans confirmation utilisateur (/api/pages/{id}/publish, /share)
- Modifier une collection verrouillée (is_locked = 1)
- Agir au-delà des permissions de l'utilisateur qui l'invoque
- Supprimer définitivement (hard delete) sans mode confirm
-------------------------
Résumé
FlowDeck Agent porte le modèle Notion Agent dans FlowDeck en réutilisant l'infrastructure existante :
- Un service AgentEngine orchestrant une boucle ReAct (raisonnement ↔ action)
- Des outils qui sont de simples wrappers des routers FastAPI existants (/db/*, /board/api/*, Gitea)
- Un contexte collecté via SearchEngine + GiteaClient, filtré par PermissionManager
- Un audit complet (agent_actions) avec rollback
- Custom Agents, skills et déclencheurs pour l'automatisation
Le passage de « l'IA qui répond » à « l'IA qui réalise le travail » se fait sans réécrire FlowDeck : l'agent agit comme un utilisateur privilégié mais contraint, à travers les mêmes portes que l'interface humaine.
J'ai créé le document `Flowdeck_Agent_integration.md`. Points saillants de l'intégration proposée :
- **Réutilisation maximale** : l'agent n'accède jamais directement à SQLite — il appelle les routers FastAPI existants (`/db/*`, `/board/api/*`, `GiteaClient`), ce qui garantit validation, cohérence et respect du `PermissionManager`.
- **Boucle ReAct** : Compréhension → Contexte → Raisonnement → Action, calquée sur le fonctionnement de Notion Agent mais mappée sur vos entités (`collections`, `collection_pages`, issues Gitea).
- **5 nouvelles tables** seulement (`agents`, `agent_conversations`, `agent_messages`, `agent_actions`, `agent_skills`), plus un router `agent.py` et 4 services.
- **Sécurité** : audit complet avec `undo_snapshot_json` pour rollback, respect des rôles workspace, mode `confirm` pour les actions destructives.
- **Custom Agents + Skills + déclencheurs** (cron via `lifespan`, webhooks via votre `webhooks.py` existant).
+464
View File
@@ -0,0 +1,464 @@
# Guide complet — Notion Agent (2026)
## Introduction
Notion Agent est l'évolution la plus avancée de Notion AI. Contrairement à un assistant conversationnel classique qui répond uniquement à des questions, Notion Agent est conçu pour **agir directement dans votre espace de travail**.
L'objectif est de transformer l'IA d'un simple outil de rédaction en un véritable collaborateur numérique capable d'exécuter des tâches complètes à travers les pages, bases de données et outils connectés.
---
# 1. Qu'est-ce que Notion Agent ?
Notion Agent est un agent IA intégré nativement dans Notion.
Il possède :
- Un accès au contexte de votre espace de travail
- La capacité de lire des pages et bases de données
- La capacité de créer et modifier du contenu
- La possibilité d'effectuer des tâches multi‑étapes
- L'accès à certaines sources externes et outils connectés
Conceptuellement :
Assistant IA traditionnel :
Utilisateur → Prompt → Réponse
Notion Agent :
Utilisateur → Objectif → Planification → Recherche → Actions → Résultat
L'agent agit davantage comme un employé numérique que comme un chatbot.
---
# 2. Où apparaît-il dans l'interface ?
## Emplacement principal
Notion affiche l'agent sous la forme d'une icône circulaire représentant un visage.
Position habituelle :
- Coin inférieur droit de l'écran
Ou :
- Section AI dans la barre latérale gauche
---
## Interface de conversation
Lorsque l'utilisateur ouvre l'agent :
┌──────────────────────────┐
│ Conversation Agent │
├──────────────────────────┤
│ Historique │
│ │
│ Réponses │
│ │
├──────────────────────────┤
│ @Sources │
│ 📎 Fichier │
│ Modèle IA │
│ Zone de saisie │
└──────────────────────────┘
L'interface ressemble à ChatGPT mais avec davantage de contrôles contextuels.
---
# 3. Comment fonctionne l'agent ?
## Étape 1 : Compréhension
L'utilisateur formule un objectif.
Exemples :
- Construis un CRM.
- Résume les notes de réunion du trimestre.
- Crée les OKR du prochain trimestre.
L'agent interprète l'intention.
---
## Étape 2 : Collecte du contexte
Il récupère automatiquement :
- La page actuelle
- Les blocs sélectionnés
- Les bases de données liées
- Les documents référencés
- Les permissions utilisateur
Il fonctionne avec les mêmes permissions que l'utilisateur.
---
## Étape 3 : Raisonnement
L'agent décompose la demande.
Exemple :
Créer un CRM
→ déterminer les propriétés
→ créer la base
→ créer les vues
→ créer les relations
→ générer des exemples
---
## Étape 4 : Action
L'agent modifie directement Notion.
Contrairement à un chatbot traditionnel, il peut :
- créer
- modifier
- organiser
- restructurer
le contenu.
---
# 4. Sources de connaissances
## Sources Notion
L'agent peut consulter :
- Pages
- Sous-pages
- Bases de données
- Wikis
- Documents internes
---
## Sources connectées
Selon la configuration du workspace :
- Jira
- Slack
- Google Drive
- GitHub
- Figma
- Outils connectés compatibles
---
## Fichiers
L'utilisateur peut joindre :
- PDF
- Documents
- Présentations
- Fichiers importés
---
## Mention manuelle
L'utilisateur peut préciser :
@Projet Alpha
ou
@Équipe Marketing
pour forcer l'utilisation d'un contexte particulier.
---
# 5. Ce que Notion Agent peut faire
## Création de contenu
- Pages
- Sous-pages
- Documentation
- SOP
- Guides
- Rapports
- Comptes-rendus
---
## Modification de contenu
- Réécriture
- Résumé
- Traduction
- Simplification
- Restructuration
---
## Bases de données
L'agent peut :
- créer une base
- ajouter des propriétés
- ajouter des relations
- créer des vues
- remplir les données
---
## Recherche documentaire
Il peut analyser :
- des centaines de pages
- plusieurs bases de données
- des documents attachés
pour produire un résultat consolidé.
---
## Analyse
Exemples :
- Identifier les tendances
- Comparer des projets
- Détecter les risques
- Produire des synthèses exécutives
---
## Travail multi‑étapes
Exemple :
« Analyse les incidents des 6 derniers mois et crée un plan d'amélioration. »
L'agent :
1. collecte les incidents
2. les analyse
3. identifie les causes
4. produit un rapport
5. crée les tâches
---
# 6. Fonctionnalités avancées
## Changement de modèle IA
Le sélecteur de modèle permet selon la disponibilité :
- GPT
- Claude
- Gemini
- autres modèles partenaires
Certains modèles peuvent n'utiliser que le Web alors que d'autres exploitent le contexte Notion.
---
## Historique
L'agent conserve l'historique des conversations.
Permet :
- reprendre un travail
- relancer une tâche
- réutiliser un contexte
---
## Instructions personnalisées
L'utilisateur peut définir :
- ton
- style
- format
- règles métier
Exemple :
« Toujours écrire les procédures en format SOP. »
---
## Skills
Notion introduit des compétences réutilisables.
Une skill agit comme un mini‑prompt réutilisable.
Exemples :
- Génération de compte-rendu
- Analyse SWOT
- Préparation de sprint
---
# 7. Ce que l'agent ne peut pas faire
À ce jour, certaines limitations existent.
Il ne peut généralement pas :
- modifier les permissions des pages
- partager des pages
- gérer la facturation
- modifier les paramètres du workspace
- créer certaines propriétés avancées complexes
- créer des rappels système
---
# 8. Différence entre Notion AI et Notion Agent
## Notion AI (ancienne approche)
- Répond
- Résume
- Réécrit
## Notion Agent
- Planifie
- Recherche
- Agit
- Modifie le workspace
- Réalise des tâches complètes
---
# 9. Custom Agents (2026)
Notion a également lancé les Custom Agents.
Différence :
Notion Agent :
- Agent personnel généraliste
Custom Agent :
- Agent spécialisé
Exemples :
- Agent RH
- Agent Support
- Agent Produit
- Agent Marketing
- Agent GitHub
- Agent Reporting
Ces agents peuvent fonctionner automatiquement selon des déclencheurs et horaires.
---
# 10. Cas d'usage réels
## Gestion de produit
- Générer des PRD
- Créer des roadmaps
- Analyser Jira
## Développement logiciel
- Synthèse GitHub
- Documentation technique
- Suivi des bugs
## Marketing
- Rapports de campagne
- Veille concurrentielle
- Création de contenu
## Direction
- OKR
- KPI
- Rapports exécutifs
---
# 11. Architecture conceptuelle
Utilisateur
↓
Notion Agent
↓
Contexte Workspace
↓
Sources Connectées
↓
Moteur IA
↓
Planification
↓
Actions Notion
↓
Résultat
---
# 12. Vision produit
Notion cherche à transformer son produit en système d'exploitation du travail.
L'évolution observée :
2018 → Notes
2020 → Bases de données
2023 → Notion AI
2025 → Notion Agent
2026 → Custom Agents
Direction probable :
- équipes d'agents spécialisés
- automatisation continue
- orchestration inter‑outils
- assistants autonomes de projet
---
# Résumé
Notion Agent est un agent IA intégré profondément dans Notion.
Ses caractéristiques majeures :
- Compréhension du contexte
- Accès aux connaissances du workspace
- Raisonnement multi‑étapes
- Création et modification de contenu
- Gestion des bases de données
- Analyse documentaire
- Personnalisation via instructions et skills
- Intégration avec outils externes
- Possibilité d'évoluer vers des agents spécialisés
Il représente le passage de « l'IA qui répond » à « l'IA qui réalise le travail » directement dans l'environnement Notion.
+406
View File
@@ -0,0 +1,406 @@
# 📚 Guide complet : Databases & Database Views dans Notion
> Guide de référence de A à Z sur le fonctionnement des bases de données (Databases) et des vues de bases de données (Database Views) dans Notion.
---
## Table des matières
1. [Introduction : Qu'est-ce qu'une Database ?](#1-introduction)
2. [Concepts fondamentaux](#2-concepts-fondamentaux)
3. [Créer une database](#3-créer-une-database)
4. [Databases pleine page vs. en ligne (inline)](#4-full-page-vs-inline)
5. [Les propriétés (Database Properties)](#5-les-propriétés)
6. [Relations & Rollups](#6-relations--rollups)
7. [Formules (Formulas)](#7-formules)
8. [Templates de database](#8-templates-de-database)
9. [Sous-éléments, dépendances & tâches/sprints](#9-tâches-sous-items-sprints)
10. [Data sources & databases liées](#10-data-sources)
11. [Les Database Views (vues)](#11-les-database-views)
12. [Filtres, tris et regroupements](#12-filtres-tris-regroupements)
13. [Tableau récapitulatif des vues](#13-récapitulatif-des-vues)
14. [Cas d'usage concrets](#14-cas-dusage)
15. [Limites à connaître](#15-limites)
---
## 1. Introduction
Les **Databases** (bases de données) sont l'une des fonctionnalités **fondamentales** de Notion. Ce sont des **collections de pages Notion** qui vous permettent d'organiser l'information de multiples façons.
Ce qui rend les databases Notion uniques :
- **Chaque élément est sa propre page** : chaque ligne/élément est une page Notion complète que vous pouvez ouvrir, formater et remplir de contenu (texte, images, sous-pages, etc.).
- **Propriétés personnalisables** : ajoutez du contexte (dates, statuts, personnes, liens…) à chaque élément.
- **Visualisation multiple** : les mêmes données peuvent être affichées en table, calendrier, tableau kanban, chart, etc.
- **Sources multiples** : une seule database peut regrouper plusieurs jeux de données (ex. un CRM tout-en-un : contacts, entreprises, deals).
---
## 2. Concepts fondamentaux
| Concept | Description |
|---------|-------------|
| **Database** | Le conteneur global qui regroupe une ou plusieurs sources de données et une ou plusieurs vues. |
| **Data source** | Un ensemble de pages. Chaque database contient **au moins une** data source. |
| **Page / Item** | Chaque ligne = une page Notion complète, ouvrable et éditable. |
| **Property (propriété)** | Champ de métadonnées attaché à chaque page (date, statut, personne…). |
| **View (vue)** | Une manière d'afficher les données (table, board, calendrier…). |
---
## 3. Créer une database
**Méthode 1 — Nouvelle page**
1. Créez une nouvelle page → sous **Get started with**, sélectionnez **Table** (ou `•••` → **Database**).
**Méthode 2 — Commande slash**
- Dans une page existante, tapez `/database`.
**Options à la création :**
- **New empty database** : partir de zéro.
- **Link to existing data source** : réutiliser des données existantes du workspace.
- **Build with AI** : donner un prompt à Notion AI pour générer la database.
- **Suggested template** : choisir un modèle proposé.
**Premières étapes de configuration :**
1. **Créer une page** → bouton **New page**.
2. **Ajouter une propriété** → **Add property** (ex. Status, Due date).
3. **Ajouter une vue** → par défaut en **Table view**, mais autant de vues que souhaité.
4. **Éditer la vue** → nom, layout, propriétés visibles, filtres, tris, groupes.
---
## 4. Full-page vs. Inline
### Databases pleine page (Full-page)
- Apparaissent comme n'importe quelle page dans la sidebar.
- **Verrouillage** possible : `•••` → **Lock database** (empêche la modification des propriétés/options).
- Transformer en inline : glisser dans une autre page → `⋮⋮` → **Turn into inline**.
### Databases en ligne (Inline)
- Contrôles et menus **masqués** jusqu'au survol.
- Étendre en pleine page : cliquer sur **⤡** en haut.
- Transformer en pleine page : `⋮⋮` puis glisser vers la sidebar.
- Actions via `⋮⋮` : **Delete, Duplicate, Move, Copy link**.
### Dupliquer une database
- **Inline** : survol → `⋮⋮` → **Duplicate**.
- **Full-page** : `•••` → **Duplicate**.
- Choix : **Duplicate with content** (avec les pages) ou **without content** (structure seule).
---
## 5. Les propriétés
Les propriétés ajoutent du contexte et permettent de **filtrer, trier et rechercher**.
### Types de propriétés disponibles
| Propriété | Usage |
|-----------|-------|
| **Text** | Texte formaté (notes, résumés, descriptions). |
| **Number** | Nombres, avec format devise ou barre de progression. |
| **Select** | Une option parmi une liste de tags. |
| **Status** | Progression : To-do, In Progress, Complete. |
| **Multi-Select** | Plusieurs tags/catégories. |
| **Date** | Date ou plage de dates (heure optionnelle). |
| **Formula** | Calculs basés sur d'autres propriétés. |
| **Relation** | Connexion entre databases. |
| **Rollup** | Agrégation d'infos issues d'une relation. |
| **Person** | Assigner une personne/groupe du workspace. |
| **File** | Upload de fichiers et images. |
| **Checkbox** | Vrai/faux (tâches légères). |
| **URL** | Lien web. |
| **Email** | Adresse email (ouvre le client mail). |
| **Phone** | Numéro (déclenche l'appel). |
| **Created time / by** | Auto-généré (non éditable). |
| **Last edited time / by** | Auto-mis à jour (non éditable). |
| **Button** | Automatiser des actions en un clic. |
| **ID** | Identifiant numérique unique, immuable. |
| **Place** | Localisation (services de localisation, nom, adresse). |
> 💡 **Notion AI** peut auto-remplir certains types de propriétés.
### Gérer les propriétés
- **Créer** : **Add property** ou `+` à droite → nom + type.
- **Éditer** : menu ⚙️ en haut → **Edit properties**.
- **Masquer/afficher** : **Property visibility** → 👁️.
- **Valeurs** : la façon d'éditer varie par type (date picker, tags, upload de fichier…).
> ⚠️ **Limite** : max **500 propriétés** par database.
---
## 6. Relations & Rollups
### Relations
Une **relation** connecte les éléments de deux databases (ou d'une database à elle-même).
**Exemple** : une database *Customers* + une database *Items* → savoir quel client a acheté quel article.
**Créer une relation :**
1. Ajouter une propriété → type **Relation**.
2. Chercher la database cible.
3. **Add relation**.
**Relations bidirectionnelles (2-way) :**
- Par défaut, les relations sont **1-way**.
- Activer **Show on [database]** pour synchroniser dans les deux sens.
**Options :**
- **Limiter** le nombre de pages liées (1 page ou illimité).
- **Property visibility** : Always show / Hide when empty / Always hide.
- **Personnaliser les propriétés affichées** dans la relation.
- **Relation vers elle-même** : ex. tâches liées entre elles.
### Rollups
Un **rollup** agrège les données issues d'une relation (compter, additionner, moyenne…).
> ⚠️ On **ne peut pas faire un rollup d'un rollup** (risque de boucles).
---
## 7. Formules
Une propriété **Formula** exécute des calculs basés sur d'autres propriétés, via le langage de formules Notion.
### Où utiliser les formules
- Comme **propriété de database**.
- Dans une **action de bouton**.
- Dans une **action de database button**.
- Dans une **action d'automatisation**.
### Exemples concrets
Due date = date de début + 2 semaines
dateAdd(Start Date, 2, "week")
Marquer "Overdue" en rouge/gras si dépassé et non terminé
if(and(now() > Due Date, Status != "Done"), style("Overdue", "red", "b"), "")
Nombre de tâches liées
Tasks.length()
### Éléments du langage
- **Properties** : `prop("Nom")`
- **Opérateurs math** : `+ - * % ^`
- **Booléens** : `true`, `false`
- **Comparaison** : `== > >= < <= !=`
- **Logiques** : `and`, `or`, `not`
- **Ternaire** : `X ? Y : Z` (équivalent à `if(X, Y, Z)`)
### Fonctions courantes
`if()`, `ifs()`, `empty()`, `length()`, `substring()`, `contains()`, `replace()`, `lower()`, `upper()`, `style()`, `format()`, `now()`, `today()`, `dateAdd()`, `dateBetween()`, `.first()`, `.every()`, `.filter()`, `.map()`, `round()`, `min()`, `max()`, `sum()`, `mean()`…
> 💡 **Notion AI** peut créer/éditer vos formules à partir d'une description.
---
## 8. Templates de database
Les **templates** permettent de répliquer une structure de page en un clic (ex. notes de réunion, rapport de bug, spec design).
**Créer un template :**
1. Flèche déroulante à côté de **New** → **+ New template**.
2. Nommer le template (= nom affiché).
3. Prédéfinir propriétés (ex. Priority = P1) et contenu (texte, images, embeds, sous-pages).
**Utiliser un template :** menu déroulant du bouton **New** → choisir le template.
**Templates récurrents :** répétition quotidienne, hebdomadaire, mensuelle ou annuelle.
**Nesting (imbrication) :**
- Pas de nesting dans un template récurrent **quotidien**.
- Max **3 niveaux** d'imbrication.
> ⚠️ Les templates sont **propres à chaque database**. Évitez de remplir les propriétés relation dans un template (sauf si vous voulez lier toutes les pages à la même cible).
---
## 9. Tâches, sous-items & sprints
### Sub-items (sous-éléments)
Découpent une tâche en plus petites parties, visibles dans toutes les vues.
- **Activer** : ⚙️ → **More settings** → **Sub-items** → **Turn on**.
- **Affichage** : *Nested in toggle*, *Flattened list*, *Card property*…
- **Filtres** : Parents only / Parents and sub-items / Sub-items only.
### Dependencies (dépendances)
Connectent les tâches de façon linéaire (A bloque B).
- Options de décalage automatique des dates :
- **Shift only when dates overlap**
- **Shift & maintain time between items**
- **Do not automatically shift**
- Option pour éviter les week-ends.
### Task database
Une database avec les propriétés **status, assignee, due date**.
- **Activer** : ⚙️ → **More settings** → **Turn into Tasks**.
- Les tâches assignées apparaissent dans **My tasks** (widget dans Home).
- Propriété **ID** : identifiant unique, URL directe (`notion.so/TASK-123`).
### Sprints
Nécessitent une **Task database**.
- **Activer** : ⚙️ → **More settings** → **Sprints** → **Turn on sprints**.
- **Sprint board** : Current Sprint / Sprint planning / Backlog.
- **Sprints database** : vue générale + Timeline.
- **Réglages** : durée (1 à 8 semaines), jour de début, gestion des tâches incomplètes, complétion automatique.
---
## 10. Data sources
Chaque database contient **au moins une data source**. Une database peut en contenir **plusieurs**.
**Cas d'usage multi-sources :**
- **CRM** : contacts + entreprises + deals + activités.
- **Gestion de projet** : membres + projets + tâches + ressources.
- **Recrutement** : candidats + postes + départements + entretiens.
**Ajouter/gérer :** icône slider → **Manage data sources**.
- **Sources** : celles originaires de cette database (Move / Move to Trash / Add).
- **Linked** : celles venant d'ailleurs (X pour retirer).
**Databases liées (linked) :**
- Les vues/filtres/tris/groupes créés sur une database liée **n'affectent pas** l'originale.
- En revanche, l'édition du **titre, des propriétés ou des pages** est **répercutée** sur la source d'origine.
- Le niveau d'accès respecte celui de la database originale.
> ⚠️ Pour partager une page contenant une data source liée, le destinataire doit avoir accès à la **database originale**.
---
## 11. Les Database Views
Une **vue** est une manière d'afficher/organiser les données. Chaque vue peut avoir ses propres filtres, tris, groupes et propriétés visibles.
### Types de vues disponibles
| Vue | Description | Idéal pour |
|-----|-------------|-----------|
| **Table** | Format tableur classique (lignes/colonnes). | Données structurées, aperçu complet. |
| **Board (Kanban)** | Colonnes de cartes regroupées par propriété (ex. Status). | Workflows, gestion de tâches. |
| **Timeline** | Chronologie horizontale (type Gantt). | Planification, dépendances. |
| **Calendar** | Vue calendrier basée sur une propriété Date. | Événements, deadlines. |
| **List** | Liste minimaliste et épurée. | Notes, docs, lecture simple. |
| **Gallery** | Cartes visuelles avec aperçu d'image/contenu. | Moodboards, portfolios. |
| **Chart** | Graphiques (barres, courbes, camemberts…). | Analyse, reporting, dashboards. |
| **Form** | Formulaire de saisie qui crée de nouvelles entrées. | Collecte de données, sondages. |
| **Map** | Localisation des éléments (propriété Place). | Lieux, adresses. |
| **Feed** | Flux de contenu de type actualité. | Mises à jour, annonces. |
### Layouts (mises en page)
Chaque vue possède des **options de layout** personnalisables : taille des cartes, aperçu d'image, propriétés affichées, ouverture des pages, etc.
### Dashboards
Combinez plusieurs vues liées + charts sur une même page pour un tableau de bord centralisé.
### Ajouter/éditer une vue
1. Cliquer sur **+** à côté des vues existantes.
2. Choisir le type de layout.
3. Éditer : nom, layout, visibilité des propriétés, filtres, tris, groupes.
> 💡 Vous pouvez ajouter une **vue liée** d'une autre data source dans n'importe quelle database.
---
## 12. Filtres, tris & regroupements
### Filtres (Filters)
Affichent uniquement les éléments correspondant à des critères.
- Ex. : Status = "In Progress", Due Date = cette semaine.
- Filtres multiples avec logique **AND / OR**.
- Groupes de filtres avancés (imbriqués).
### Tris (Sorts)
Ordonnent les éléments selon une ou plusieurs propriétés (ascendant/descendant).
### Regroupements (Groups)
Regroupent visuellement les éléments par propriété (ex. par Status dans un board, par assigné, par date).
- **Group** (regroupement principal) et **Sub-group** (sous-regroupement, notamment en board).
> ⚠️ Sur une **database liée**, filtres/tris/groupes sont locaux et n'affectent pas l'originale.
---
## 13. Récapitulatif des vues
📊 Table → tableur (colonnes/lignes)
📋 Board → kanban (colonnes de cartes)
📅 Calendar → calendrier (par date)
⏱️ Timeline → chronologie / Gantt
📝 List → liste épurée
🖼️ Gallery → cartes visuelles
📈 Chart → graphiques
🗺️ Map → carte géographique
📰 Feed → flux d'actualité
🗒️ Form → formulaire de saisie
---
## 14. Cas d'usage
- **Gestion de projet** : Task database + sous-items + dépendances + sprints, vues Board/Timeline/Calendar.
- **CRM** : database multi-sources (contacts, entreprises, deals), vues filtrées par étape.
- **Notes de réunion** : templates récurrents + relation vers clients/projets.
- **Suivi OKR** : relation entre database d'OKR et database de tâches, rollups pour la progression.
- **Portfolio / moodboard** : vue Gallery avec propriété File.
- **Collecte de données** : vue Form partagée en externe.
- **Reporting** : Dashboard combinant charts et vues liées.
---
## 15. Limites à connaître
- **500 propriétés max** par database.
- **Pas de rollup d'un rollup** (évite les boucles).
- **Au-delà de 1 000 éléments** : les nouvelles pages peuvent apparaître au milieu de la collection (indexation).
- **Export CSV** : les relations s'exportent en URL texte brut ; **impossible de réimporter** pour rétablir les relations.
- **Duplication d'une database avec relation** : la relation dupliquée devient **1-way** (convertible à nouveau en 2-way).
- **Templates** : non partagés entre databases ; nesting limité à 3 niveaux ; pas de nesting dans un template récurrent quotidien.
- **Databases liées** : nécessitent l'accès à la database originale pour le partage.
---
## ✅ En résumé
Une **Database Notion** = une collection de pages, décrites par des **propriétés**, reliées entre elles par des **relations/rollups**, enrichies par des **formules**, structurées par des **templates**, et affichées à travers de multiples **vues** (table, board, calendrier, timeline, gallery, chart, form, map, feed, list) — chacune personnalisable par des **filtres, tris et regroupements**. Le tout peut regrouper plusieurs **data sources** pour créer des outils tout-en-un (CRM, gestion de projet, recrutement…).
*Guide basé sur la documentation officielle du Notion Help Center.*
*Database*
https://www.notion.com/help/category/databases
https://www.notion.com/help/intro-to-databases
https://www.notion.com/help/database-properties
https://www.notion.com/help/relations-and-rollups
https://www.notion.com/help/formulas
https://www.notion.com/help/formula-syntax
https://www.notion.com/help/database-templates
https://www.notion.com/help/tasks-and-dependencies
https://www.notion.com/help/sprints
https://www.notion.com/help/data-sources-and-linked-databases
*Database view*
https://www.notion.com/help/category/database-views
https://www.notion.com/help/category/database-views/all
https://www.notion.com/help/views-filters-and-sorts
https://www.notion.com/help/tables
https://www.notion.com/help/boards
https://www.notion.com/help/timelines
https://www.notion.com/help/calendars
https://www.notion.com/help/lists
https://www.notion.com/help/dashboards
https://www.notion.com/help/galleries
https://www.notion.com/help/charts
https://www.notion.com/help/forms
https://www.notion.com/help/maps
https://www.notion.com/help/feeds
https://www.notion.com/help/layouts
File diff suppressed because it is too large Load Diff
-210
View File
@@ -1,210 +0,0 @@
# ROADMAP — FlowDeck v3.0 : Multi-User, Multi-Forge, Standalone
> **Début**: 2026-07-10 | **Cible**: v3.0 | **Auteur**: Bruno + Hermes-Deepin
> **Objectif**: Transformer FlowDeck d'un outil personnel lié à Gitea en une plateforme collaborative multi-utilisateur, multi-forge (Gitea/GitHub), fonctionnant avec ou sans forge Git.
---
## Vue d'ensemble
```
┌─────────────────────────────────────────────────────────────┐
│ FLOWDECK v3.0 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌────────────────────────┐ │
│ │ Comptes │ │ Forges │ │ Workspaces & Projets │ │
│ │ locaux │ │ Gitea │ │ (built-in, Gitea, │ │
│ │ + OAuth │ │ GitHub │ │ GitHub) │ │
│ └──────────┘ └──────────┘ └────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Navigation: Topbar avec arborescence projet │ │
│ │ [Workspace] [Projet ▾] [Meetings] [Shared] │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## Phase 1 — Authentification Multi-Mode
### 1.1 Comptes locaux avec mot de passe
- [x] **DB**: Ajouter `password_hash` (hash) à la table `users`
- [x] **DB**: Ajouter `is_active`, `is_admin`, `last_login`, `login_attempts`, `locked_until`
- [x] **API**: `POST /auth/register` — création de compte local (email + password)
- [x] **API**: `POST /auth/login` — login local (email + password → session)
- [x] **Page**: `/login` — formulaire login/register
- [x] **Sécurité**: Rate limiting (5 tentatives → lock 15 min)
- [x] **Sécurité**: CSRF sur toutes les routes auth
- [x] **Session**: Détachée de Gitea — session valide sans OAuth
### 1.2 Page de configuration du compte
- [x] **Page**: `/accounts/settings` — config du profil
- [x] **Section**: Profil (nom, email, avatar, mot de passe)
- [x] **Section**: Connexions forges (Gitea, GitHub) — lier/délier
- [x] **Section**: Préférences (langue, thème, notifs)
- [ ] **Section**: Tokens API (générer/révoquer des clés API)
- [ ] **Section**: Sessions actives (voir et révoquer)
### 1.3 OAuth multi-forge
- [x] **Refactor**: `GiteaOAuth` → `OAuthProvider` (classe abstraite)
- [ ] **Implement**: `GitHubOAuthProvider` (OAuth2 GitHub)
- [x] **Implement**: `GiteaOAuthProvider` (existant, refactoré)
- [x] **DB**: Table `user_oauth_tokens` (user_id, provider, access_token, refresh_token, expires_at)
- [x] **API**: `GET /auth/{provider}/login` — redirige vers OAuth
- [x] **API**: `GET /auth/{provider}/callback` — callback OAuth
- [x] **Page**: `/accounts/settings` → boutons "Connect GitHub" / "Connect Gitea"
- [x] **Fallback**: L'utilisateur peut utiliser FlowDeck SANS lier aucune forge
---
## Phase 2 — Abstraction Multi-Forge
### 2.1 Adapter pattern pour les forges
- [x] **Interface**: `ForgeAdapter` (classe abstraite)
- `list_repos(token) → List[Repo]`
- `get_repo(token, owner, repo) → RepoDetail`
- `list_issues(token, owner, repo) → List[Issue]`
- `get_file_tree(token, owner, repo, path) → TreeNode`
- `get_file_content(token, owner, repo, path) → str`
- `create_webhook(token, owner, repo, url) → Webhook`
- [x] **Implement**: `GiteaAdapter` (API Gitea existante → interface unifiée)
- [ ] **Implement**: `GitHubAdapter` (API GitHub v3 → interface unifiée)
- [x] **DB**: Table `forge_connections` (user_id, provider, token_id, instance_url)
- [x] **Config**: Support GitHub Enterprise (custom URL) et Gitea self-hosted
### 2.2 Synchronisation des projets
- [x] **Service**: `ProjectSyncService` — sync projets depuis les forges liées
- [ ] **DB**: Table `projects` avec type (`builtin`, `gitea`, `github`)
- [ ] **DB**: `projects.forge_id` — référence externe (repo ID)
- [ ] **DB**: `projects.clone_url`, `projects.default_branch`, `projects.language`
- [ ] **Cron**: Sync périodique des projets (configurable, défaut: chaque heure)
- [x] **Page**: `/workspace` — liste TOUS les projets (built-in + Gitea + GitHub)
---
## Phase 3 — Workspace & Navigation
### 3.1 Nouvelle page Workspace
- [x] **Page**: `/workspace` — dashboard central
- Section "My Projects" (built-in)
- Section "Gitea Projects" (si connecté)
- Section "GitHub Projects" (si connecté)
- Bouton "Create Project" (built-in)
- Bouton "Import from Gitea/GitHub"
- [x] **Filtres**: Par forge, par statut, recherche
- [x] **Carte projet**: Nom, description, forge badge, dernière activité, nombre de pages
### 3.2 Barre de navigation projet
- [x] **Topbar**: Section entre "Meetings" et la sidebar
- Nom du projet actif (dropdown pour switcher)
- Badge forge (icône Gitea/GitHub/built-in)
- [x] **Arborescence**: Sous le projet dans la sidebar gauche
- Dossiers et fichiers du repo Git (lecture seule si forge externe)
- Pages FlowDeck liées au projet
- Bouton "+" pour créer nouvelle page dans le projet
- [x] **Service**: `FileTreeService` — construit l'arborescence depuis l'API forge
- [x] **Cache**: Arborescence en cache (TTL 5 min) pour éviter les appels API à chaque page
### 3.3 Navigation projet
- [x] **Sidebar**: Les projets apparaissent sous une section dédiée
- [x] **Breadcrumb**: Workspace > Projet > Dossier > Page
- [x] **Contexte projet**: Toute nouvelle page créée depuis un projet y est liée
- [ ] **Quick switch**: Ctrl+K → chercher et switcher de projet
---
## Phase 4 — Fonctionnement sans forge
### 4.1 Mode standalone
- [x] **Config**: `FLOWDECK_STANDALONE=true` — démarre sans aucune forge
- [x] **UI**: Pas de sections Gitea/GitHub si non configuré
- [x] **Workspace**: Uniquement projets built-in
- [x] **Auth**: Login local uniquement (pas de boutons OAuth)
- [x] **Pages**: Création/édition 100% locale, pas de synchro externe
### 4.2 Mode hybride
- [x] Un utilisateur peut avoir Gitea lié, un autre GitHub, un troisième rien
- [x] Chaque utilisateur voit SES projets de forge dans SA workspace
- [x] Les projets built-in sont partagés selon les permissions workspace
---
## Phase 5 — Permissions & Collaboratif
### 5.1 Rôles workspace
- [x] **DB**: Table `workspace_members` avec rôles: owner, admin, editor, viewer
- [x] **API**: `POST /workspace/{id}/members` — inviter un utilisateur
- [x] **API**: `DELETE /workspace/{id}/members/{user_id}` — retirer
- [x] **API**: `PUT /workspace/{id}/members/{user_id}` — changer rôle
- [x] **Middleware**: `PermissionMiddleware` — vérifie les droits avant chaque action
### 5.2 Partage de pages
- [x] Étendre le système Share existant pour supporter les permissions workspace
- [x] Une page dans un workspace est visible par tous les membres
- [x] "Anyone with the link" crée un lien public indépendant du workspace
---
## Phase 6 — UI/UX Polishing
### 6.1 Design system
- [ ] Unifier les couleurs, espacements, typographie dans un fichier `design-tokens.css`
- [ ] Composants réutilisables: boutons, inputs, modales, dropdowns, toasts
- [ ] Responsive: adapter la sidebar et topbar pour écrans < 1024px
### 6.2 Onboarding
- [ ] Page `/welcome` au premier lancement
- [ ] Wizard: créer compte → lier forges (optionnel) → créer premier projet
- [ ] Templates de projets (vide, kanban, wiki, documentation)
---
## Phase 7 — Infrastructure
### 7.1 Base de données
- [ ] Migrations versionnées (Alembic ou script maison avec table `schema_version`)
- [ ] Backup automatique (cron daily → fichier daté)
- [ ] Index manquants (users.email, projects.forge_id, forge_connections.user_id)
### 7.2 Tests
- [ ] Tests d'intégration auth (login local, OAuth mock)
- [ ] Tests des adapters forge (mock HTTP responses)
- [ ] Tests multi-user (2 utilisateurs, permissions croisées)
- [ ] Tests standalone (sans forge configurée)
- [ ] Cible: 100+ tests
### 7.3 CI/CD
- [ ] Linting (ruff, eslint)
- [ ] Tests parallèles (pytest-xdist)
- [ ] Build Docker multi-stage (optimiser la taille d'image)
---
## Dépendances et points critiques
| Dépendance | Impact | Bloque |
|-----------|--------|--------|
| **Auth locale** | Fondation — tout le reste en dépend | Phases 2-7 |
| **Abstraction forge** | Permet GitHub + standalone | Phases 3-4 |
| **Permission system** | Nécessaire pour le multi-user réel | Phase 5 |
| **Migration DB** | Les données existantes doivent survivre | Phase 1 |
| **Session refactor** | Actuellement couplée à Gitea OAuth | Phase 1 |
---
## Chronologie estimée
| Phase | Effort | Priorité |
|-------|--------|----------|
| Phase 1 — Auth | 🔴🔴🔴 | Critique |
| Phase 2 — Multi-forge | 🔴🔴 | Haute |
| Phase 3 — Workspace/Nav | 🔴🔴 | Haute |
| Phase 4 — Standalone | 🔴 | Moyenne |
| Phase 5 — Permissions | 🔴🔴 | Haute |
| Phase 6 — UI/UX | 🔴 | Moyenne |
| Phase 7 — Infra | 🔴 | Continue |
---
*Dernière mise à jour: 2026-07-13 — phases 1-5 majoritairement complétées*
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+21 -2
View File
@@ -278,9 +278,12 @@ input, select, textarea { font-family: inherit; color: inherit; }
padding: 7px 14px; font-size: 13px; color: var(--text); padding: 7px 14px; font-size: 13px; color: var(--text);
text-decoration: none; cursor: pointer; transition: background 100ms; text-decoration: none; cursor: pointer; transition: background 100ms;
} }
.um-item svg { flex-shrink: 0; }
.um-item:hover { background: var(--bg-hover); } .um-item:hover { background: var(--bg-hover); }
.um-danger { color: var(--danger); } .um-danger { color: var(--danger); }
.um-danger:hover { background: rgba(255,80,80,.1); } .um-danger:hover { background: rgba(255,80,80,.1); }
.um-version { font-size: 11px; color: var(--text-dim); padding: 4px 14px 2px; }
.um-section-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .5px; padding: 4px 14px 2px; }
.sidebar-actions { .sidebar-actions {
display: flex; display: flex;
@@ -510,6 +513,21 @@ input, select, textarea { font-family: inherit; color: inherit; }
cursor: grab; cursor: grab;
} }
/* Outline SVG icons (from _icons.html / getSvgIcon) */
.page-icon-svg {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
flex-shrink: 0;
}
.page-icon-svg svg {
vertical-align: middle;
}
.nav-icon-inline { margin-right: 6px; vertical-align: middle; display: inline-flex; align-items: center; }
.nav-icon-inline svg { vertical-align: middle; }
.empty-state-icon { display: inline-flex; align-items: center; justify-content: center; margin-bottom: 12px; }
.sidebar-item:active .page-icon { .sidebar-item:active .page-icon {
cursor: grabbing; cursor: grabbing;
} }
@@ -666,7 +684,8 @@ input, select, textarea { font-family: inherit; color: inherit; }
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
border-radius: var(--radius-md); border-radius: var(--radius-md);
box-shadow: 0 8px 24px rgba(0,0,0,0.4); box-shadow: 0 8px 24px rgba(0,0,0,0.4);
min-width: 200px; width: max-content;
max-width: 240px;
padding: var(--space-xs) 0; padding: var(--space-xs) 0;
z-index: 1000; z-index: 1000;
display: none; display: none;
@@ -2763,7 +2782,7 @@ button.breadcrumb-current { max-width: 260px; }
position: absolute; top: calc(100% + 4px); right: 0; position: absolute; top: calc(100% + 4px); right: 0;
background: var(--bg-modal); border: 1px solid var(--border); background: var(--bg-modal); border: 1px solid var(--border);
border-radius: var(--radius-md); box-shadow: var(--shadow-popover); border-radius: var(--radius-md); box-shadow: var(--shadow-popover);
min-width: 200px; z-index: 100; padding: 4px 0; min-width: unset; width: max-content; z-index: 100; padding: 4px 0;
} }
.more-menu-item { .more-menu-item {
padding: 8px 14px; font-size: 13px; color: var(--text-primary); padding: 8px 14px; font-size: 13px; color: var(--text-primary);
+376 -4
View File
@@ -1950,11 +1950,10 @@ def test_create_page_defaults_to_untitled(client):
def test_styled_404_page(client): def test_styled_404_page(client):
"""Unknown routes return a styled 404 HTML page.""" """Unknown routes redirect to /workspaces."""
resp = client.get("/this-does-not-exist-xyz", follow_redirects=False) resp = client.get("/this-does-not-exist-xyz", follow_redirects=False)
assert resp.status_code == 404 assert resp.status_code == 302
assert "404" in resp.text assert resp.headers.get("location") == "/workspaces"
assert "FlowDeck" in resp.text
def test_api_404_returns_json(client): def test_api_404_returns_json(client):
@@ -2003,3 +2002,376 @@ def test_session_expired_redirect(client):
location = resp.headers.get("location", "") location = resp.headers.get("location", "")
assert "login" in location assert "login" in location
assert "expired=1" in location assert "expired=1" in location
# ── v4.1.0: Data Sources & Linked Databases ──
def test_data_sources_list_empty(client):
"""List data sources for a collection — empty by default."""
# Create a collection first
resp = client.post("/db/api", json={"name": "Sources Test DB"})
coll_id = resp.json()["id"]
resp = client.get(f"/db/{coll_id}/sources/api")
assert resp.status_code == 200
assert resp.json()["sources"] == []
def test_data_sources_add_and_remove(client):
"""Add and remove a data source from a collection."""
# Create two collections
r1 = client.post("/db/api", json={"name": "Source A"})
r2 = client.post("/db/api", json={"name": "Source B"})
coll_a = r1.json()["id"]
coll_b = r2.json()["id"]
# Add B as a data source of A
resp = client.post(f"/db/{coll_a}/sources/api", json={
"source_collection_id": coll_b,
"source_name": "Linked B",
})
assert resp.status_code == 200
assert resp.json()["status"] == "added"
source_id = resp.json()["id"]
# List — should have 1 source
resp = client.get(f"/db/{coll_a}/sources/api")
assert resp.status_code == 200
assert len(resp.json()["sources"]) == 1
assert resp.json()["sources"][0]["source_name"] == "Linked B"
# Remove the source
resp = client.delete(f"/db/{coll_a}/sources/{source_id}/api")
assert resp.status_code == 200
assert resp.json()["status"] == "removed"
# List — should be empty again
resp = client.get(f"/db/{coll_a}/sources/api")
assert len(resp.json()["sources"]) == 0
def test_data_sources_duplicate_rejected(client):
"""Adding the same data source twice returns 409."""
r1 = client.post("/db/api", json={"name": "Dup Source A"})
r2 = client.post("/db/api", json={"name": "Dup Source B"})
coll_a = r1.json()["id"]
coll_b = r2.json()["id"]
# First add — OK
resp = client.post(f"/db/{coll_a}/sources/api", json={
"source_collection_id": coll_b,
})
assert resp.status_code == 200
# Second add — conflict
resp = client.post(f"/db/{coll_a}/sources/api", json={
"source_collection_id": coll_b,
})
assert resp.status_code == 409
def test_data_sources_not_found(client):
"""Non-existent collection returns 404."""
resp = client.get("/db/99999/sources/api")
assert resp.status_code == 404
def test_create_linked_database(client):
"""Create a linked database from a source collection."""
# Create source collection
resp = client.post("/db/api", json={
"name": "CRM Contacts",
"description": "Customer contacts",
"icon": "👥",
})
source_id = resp.json()["id"]
# Add a property to the source
client.post(f"/db/{source_id}/properties/api", json={
"name": "Email",
"prop_type": "email",
})
# Create linked DB
resp = client.post(f"/db/{source_id}/linked/api", json={})
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "created"
assert data["source_collection_id"] == source_id
linked_id = data["linked_id"]
assert linked_id != source_id
# Linked DB should have a data source pointing to source
resp = client.get(f"/db/{linked_id}/sources/api")
sources = resp.json()["sources"]
assert len(sources) == 1
assert sources[0]["source_collection_id"] == source_id
assert sources[0]["is_linked"] == 1
# Linked DB should have copied the source's properties
resp = client.get(f"/db/{linked_id}/properties/api")
props = resp.json()["properties"]
assert any(p["name"] == "Email" for p in props)
# Linked DB should have copied the source's views
resp = client.get(f"/db/{linked_id}/views/api")
views = resp.json()["views"]
assert len(views) >= 1
def test_create_linked_database_custom_name(client):
"""Create a linked database with a custom name."""
resp = client.post("/db/api", json={"name": "Task DB"})
source_id = resp.json()["id"]
resp = client.post(f"/db/{source_id}/linked/api", json={
"name": "My Linked Tasks",
})
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "My Linked Tasks"
assert data["status"] == "created"
def test_toggle_inline(client):
"""Toggle a collection between full-page and inline modes."""
resp = client.post("/db/api", json={"name": "Toggle Test"})
coll_id = resp.json()["id"]
# Start as full-page (is_inline=0 by default)
resp = client.get(f"/db/{coll_id}/api")
assert resp.json()["collection"]["is_inline"] == 0
# Toggle to inline
resp = client.post(f"/db/{coll_id}/toggle-inline/api")
assert resp.status_code == 200
assert resp.json()["is_inline"] is True
assert resp.json()["mode"] == "inline"
# Verify in DB
resp = client.get(f"/db/{coll_id}/api")
assert resp.json()["collection"]["is_inline"] == 1
# Toggle back to full-page
resp = client.post(f"/db/{coll_id}/toggle-inline/api")
assert resp.status_code == 200
assert resp.json()["is_inline"] is False
assert resp.json()["mode"] == "full-page"
def test_create_inline_database(client):
"""Create an inline database within a parent page."""
# Create a page first (dummy)
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"INSERT INTO pages (workspace, title) VALUES ('test', 'Parent Page')"
)
conn.commit()
page_id = conn.execute("SELECT id FROM pages ORDER BY id DESC LIMIT 1").fetchone()["id"]
resp = client.post("/db/inline/api", json={
"name": "Inline Comments DB",
"description": "Inline comments database",
"parent_page_id": page_id,
})
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "created"
assert data["is_inline"] is True
assert data["parent_page_id"] == page_id
# Verify the collection exists with inline flag
resp = client.get(f"/db/{data['id']}/api")
assert resp.json()["collection"]["is_inline"] == 1
def test_linked_db_inherits_workspace(client):
"""Linked database should inherit the source's workspace_id."""
# Create workspace
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('Test WS', 1)"
)
conn.commit()
ws_id = conn.execute("SELECT id FROM workspaces ORDER BY id DESC LIMIT 1").fetchone()["id"]
# Create collection with workspace_id
conn.execute(
"INSERT INTO collections (name, workspace_id) VALUES (?, ?)",
("WS Collection", ws_id),
)
conn.commit()
coll_id = conn.execute(
"SELECT id FROM collections WHERE name='WS Collection'"
).fetchone()["id"]
# Create linked DB
resp = client.post(f"/db/{coll_id}/linked/api", json={"name": "Linked WS DB"})
assert resp.status_code == 200
linked_id = resp.json()["linked_id"]
# Verify linked DB has same workspace_id
with get_conn() as conn:
linked = conn.execute(
"SELECT workspace_id FROM collections WHERE id=?", (linked_id,)
).fetchone()
assert linked["workspace_id"] == ws_id
def test_remove_data_source_not_found(client):
"""Deleting a non-existent data source returns 404."""
resp = client.post("/db/api", json={"name": "Remove Test"})
coll_id = resp.json()["id"]
resp = client.delete(f"/db/{coll_id}/sources/99999/api")
assert resp.status_code == 404
# ── v4.2.0: Templates & Dashboards ──
def test_page_template_update(client):
"""Update a page template with recurrence settings."""
resp = client.post("/db/api", json={"name": "Template Update DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/templates/page", json={
"name": "Weekly Report",
"properties": {"Status": "Todo", "Priority": "P1"},
})
assert resp.status_code == 200
tid = resp.json()["id"]
resp = client.put(f"/workspace/collections/{coll_id}/templates/page/{tid}", json={
"name": "Weekly Report v2",
"description": "Updated weekly report template",
"is_recurring": True,
"recurrence_rule": "weekly",
"properties": {"Status": "In Progress", "Priority": "P2"},
})
assert resp.status_code == 200
assert resp.json()["status"] == "updated"
resp = client.get(f"/workspace/collections/{coll_id}/templates/page")
tmpls = resp.json()["templates"]
assert len(tmpls) == 1
assert tmpls[0]["name"] == "Weekly Report v2"
assert tmpls[0]["is_recurring"] == 1
assert tmpls[0]["recurrence_rule"] == "weekly"
def test_page_template_delete(client):
"""Delete a page template."""
resp = client.post("/db/api", json={"name": "Template Delete DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/templates/page", json={
"name": "To Delete",
})
tid = resp.json()["id"]
resp = client.delete(f"/workspace/collections/{coll_id}/templates/page/{tid}")
assert resp.status_code == 200
assert resp.json()["status"] == "deleted"
resp = client.get(f"/workspace/collections/{coll_id}/templates/page")
assert len(resp.json()["templates"]) == 0
def test_page_template_apply_with_content(client):
"""Apply a page template that has content_json."""
resp = client.post("/db/api", json={"name": "Content Template DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/templates/page", json={
"name": "Meeting Notes",
"properties": {"Status": "Todo"},
"content": [{"type": "heading", "text": "Meeting Notes"}],
})
tid = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/templates/page/{tid}/apply", json={
"title": "Sprint Review 2026-07-21",
})
assert resp.status_code == 200
page_id = resp.json()["id"]
resp = client.get(f"/db/pages/{page_id}/api")
assert resp.status_code == 200
assert resp.json()["title"] == "Sprint Review 2026-07-21"
def test_dashboard_crud(client):
"""Full CRUD lifecycle for dashboards."""
resp = client.post("/db/api", json={"name": "Dashboard CRUD DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/dashboards", json={
"name": "Project Dashboard",
"layout": {
"columns": 2,
"widgets": [
{"view_id": 1, "x": 0, "y": 0, "width": 1, "height": 1},
{"view_id": 2, "x": 1, "y": 0, "width": 1, "height": 1},
],
},
})
assert resp.status_code == 200
assert resp.json()["status"] == "created"
did = resp.json()["id"]
resp = client.get(f"/workspace/collections/{coll_id}/dashboards")
dashboards = resp.json()["dashboards"]
assert len(dashboards) == 1
assert dashboards[0]["name"] == "Project Dashboard"
resp = client.put(f"/workspace/collections/{coll_id}/dashboards/{did}", json={
"name": "Project Dashboard v2",
"layout": {"columns": 3, "widgets": []},
})
assert resp.status_code == 200
assert resp.json()["status"] == "updated"
resp = client.get(f"/workspace/collections/{coll_id}/dashboards")
assert resp.json()["dashboards"][0]["name"] == "Project Dashboard v2"
resp = client.delete(f"/workspace/collections/{coll_id}/dashboards/{did}")
assert resp.status_code == 200
assert resp.json()["status"] == "deleted"
resp = client.get(f"/workspace/collections/{coll_id}/dashboards")
assert len(resp.json()["dashboards"]) == 0
def test_template_recurrence_defaults(client):
"""New templates default to non-recurring with empty recurrence_rule."""
resp = client.post("/db/api", json={"name": "Recur Defaults DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/templates/page", json={
"name": "Default Template",
})
resp = client.get(f"/workspace/collections/{coll_id}/templates/page")
tmpl = resp.json()["templates"][0]
assert tmpl["is_recurring"] == 0
assert tmpl["recurrence_rule"] == ""
def test_dashboard_create_default_layout(client):
"""Dashboard creation with no layout parameter gets default grid."""
resp = client.post("/db/api", json={"name": "Default Layout DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/dashboards", json={
"name": "Auto Layout",
})
assert resp.status_code == 200
resp = client.get(f"/workspace/collections/{coll_id}/dashboards")
dash = resp.json()["dashboards"][0]
layout = json.loads(dash["layout_json"])
assert layout["columns"] == 1
assert layout["widgets"] == []