Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10cbd7f52e | ||
|
|
ec96619341 | ||
|
|
d7c1f4c03e | ||
|
|
3fcfa8fdbb | ||
|
|
ce2bae1f1b | ||
|
|
85b4d624b8 | ||
|
|
ef8d4e6bca | ||
|
|
27352c84e5 | ||
|
|
bf329a4ba0 | ||
|
|
b505cfc156 | ||
|
|
3079d53cab | ||
|
|
d3bada54f0 | ||
|
|
eac6e5aed3 | ||
|
|
55853db625 | ||
|
|
fd1a4319b4 | ||
|
|
b818bc908f | ||
|
|
8a94c3b676 | ||
|
|
53d31572e6 | ||
|
|
3fcc12ad8c | ||
|
|
b88a972531 | ||
|
|
3d10400f8f | ||
|
|
9e45ea8870 | ||
|
|
d94dd3da99 | ||
|
|
aa434e9fe9 | ||
|
|
45911438f0 | ||
|
|
5ac8dd10b2 | ||
|
|
eca4067c82 | ||
|
|
fff502a700 | ||
|
|
fbd4b836f7 | ||
|
|
b835dd6b5e | ||
|
|
5372f4f22f | ||
|
|
39b485622d | ||
|
|
aba771400d | ||
|
|
2ca27d3398 | ||
|
|
9422753e8b | ||
|
|
3a94b06af4 | ||
|
|
b0d222b81a | ||
|
|
0ea447ee6f | ||
|
|
63773cb904 | ||
|
|
ec3ad6b1de |
+208
-57
@@ -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
|
||||
> **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) │ │
|
||||
│ │ ├─ 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_fragment.html — Cartes Kanban │ │
|
||||
│ │ ├─ table_view.html — Vue table │ │
|
||||
│ │ ├─ calendar_view.html — Vue calendrier (v1.6) │ │
|
||||
│ │ ├─ timeline_view.html — Vue Gantt (v1.6) │ │
|
||||
│ │ ├─ gallery_view.html — Vue galerie (v1.6) │ │
|
||||
│ │ ├─ 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 │ │
|
||||
│ │ ├─ login.html — Login/Register (local + OAuth) │ │
|
||||
│ │ ├─ public_page.html — Page publiée (/p/slug) │ │
|
||||
│ │ └─ (legacy: dashboard, detailed_board, card_detail, etc.) │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ JavaScript (CDN, pas de build step) │ │
|
||||
│ │ ├─ HTMX 1.9 — AJAX sans JS │ │
|
||||
│ │ JavaScript (servi localement, pas de CDN externe) │ │
|
||||
│ │ ├─ Alpine.js 3.14 — Interactivité légère │ │
|
||||
│ │ ├─ HTMX 1.9 — AJAX sans JS (legacy) │ │
|
||||
│ │ ├─ 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 │ │
|
||||
│ │ ├─ 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/api/... APIs board │ │
|
||||
│ │ │ — /board/api/pages CRUD pages │ │
|
||||
│ │ │ — /board/api/trash Trash │ │
|
||||
│ │ ├─ api.py — /api/... CRUD + sync │ │
|
||||
│ │ ├─ notes.py — /notes/{o}/{r} Notes │ │
|
||||
│ │ ├─ auth.py — /auth/... OAuth2 │ │
|
||||
│ │ ├─ webhooks.py — /webhooks/... Gitea hooks │ │
|
||||
│ │ ├─ my_tasks.py — /my-tasks (v1.9) │ │
|
||||
│ │ ├─ auth.py — /auth/... OAuth2 + local │ │
|
||||
│ │ ├─ local_workspace.py— /api/local-workspace CRUD + upload │ │
|
||||
│ │ ├─ my_tasks.py — /my-tasks Dashboard │ │
|
||||
│ │ ├─ library.py — /api/library/* API bibliothèque│ │
|
||||
│ │ ├─ pages.py — /pages/... Pages CRUD │ │
|
||||
│ │ ├─ 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) │
|
||||
│ │ │ ├─ ai_keywords │ │ │
|
||||
│ │ │ ├─ pages │ │ │
|
||||
│ │ │ ├─ page_shares (v4.0) │ │ │
|
||||
│ │ │ ├─ recents (v4.0) │ │ │
|
||||
│ │ │ ├─ tags (v4.0) │ │ │
|
||||
│ │ │ ├─ page_tags (v4.0) │ │ │
|
||||
│ │ │ ├─ gitea_private_pages (v4.0) │ │ │
|
||||
│ │ │ ├─ users │ │ │
|
||||
│ │ │ ├─ user_tokens │ │ │
|
||||
│ │ │ ├─ workspaces (v2.0) │ │ │
|
||||
@@ -685,42 +698,146 @@ POST /favorites → Ajouter 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
|
||||
|
||||
```
|
||||
```ascii
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ TOPBAR (44px, sticky) │
|
||||
│ ┌──────────┬──────────────────────────────────┬────────────┐ │
|
||||
│ │ Logo │ Breadcrumbs: WS > Collection │ Share... │ │
|
||||
│ │ FlowDeck │ (ou WS > Collection > Page) │ Settings │ │
|
||||
│ └──────────┴──────────────────────────────────┴────────────┘ │
|
||||
│ TOPBAR (unified-header) │
|
||||
│ ┌──────┬───────────────────────────────────────┬───────────┐ │
|
||||
│ │ «» │ Breadcrumbs: 🏠 Workspaces │ Share ⚙ │ │
|
||||
│ └──────┴───────────────────────────────────────┴───────────┘ │
|
||||
├────────────┬─────────────────────────────────────────────────┤
|
||||
│ SIDEBAR │ CONTENU PRINCIPAL │
|
||||
│ (240px) │ │
|
||||
│ │ ┌─────────────────────────────────────────────┐│
|
||||
│ Workspace │ │ DATABASE VIEW ││
|
||||
│ header │ │ ┌──────┬──────┬──────┬──────┬──────────┐ ││
|
||||
│ │ │ │Title │Statut│Priori│Assign│Due Date │ ││
|
||||
│ 🔍 Search │ │ ├──────┼──────┼──────┼──────┼──────────┤ ││
|
||||
│ │ │ │Tâche1│Todo │ P1 │Bruno │15 juil │ ││
|
||||
│ ▼ Recents │ │ │Tâche2│Doing │ P2 │ — │20 juil │ ││
|
||||
│ · Proj A │ │ │Tâche3│Done │ P3 │Marie │10 juil │ ││
|
||||
│ · Proj B │ │ └──────┴──────┴──────┴──────┴──────────┘ ││
|
||||
│ │ │ [+ New] [Filter] [Sort] [Search] [...] ││
|
||||
│ ▼ Private │ └─────────────────────────────────────────────┘│
|
||||
│ · Notes │ │
|
||||
│ │ ┌─ Barre d'outils ────────────────────────────┐│
|
||||
│ ▼ Shared │ │ [Table▼] [Kanban] [Calendar] [Gallery] ... ││
|
||||
│ · ... │ │ [Group▼] [Filter▼] [Sort▼] [Properties▼] ││
|
||||
│ │ ┌─ Help Page ────────────────────────────────┐│
|
||||
│ ┌────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ││
|
||||
│ │ Header │ │ │ │ 🚀 Getting │ │ 📝 Pages │ (...) ││
|
||||
│ │ [D] │ │ │ │ Started │ │ & Editor │ ││
|
||||
│ │ Draco │ │ │ └──────────────┘ └──────────────┘ ││
|
||||
│ └────────┘ │ └─────────────────────────────────────────────┘│
|
||||
│ │ │
|
||||
│ 🏠 💬 📨 🔍│ ┌─ Library ─────────────────────────────────┐│
|
||||
│ │ │ [Recents|Favorites|Shared|Published|...] ││
|
||||
│ 📁 Worksp. │ │ ┌──────┬──────────┬──────────┬───────────┐ ││
|
||||
│ 📄 New Pg │ │ │Name │Created by│Source │Last edited│ ││
|
||||
│ 📁 New Fl │ │ ├──────┼──────────┼──────────┼───────────┤ ││
|
||||
│ 📄 page 1 │ │ │doc A │Draco │Local │10 min ago │ ││
|
||||
│ 📄 page 2 │ │ │doc B │bruno │Gitea │2 hours ago│ ││
|
||||
│ │ │ └──────┴──────────┴──────────┴───────────┘ ││
|
||||
│ 📅 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 — Tasks & Dependencies](https://www.notion.com/help/tasks-and-dependencies)
|
||||
- [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 — 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
@@ -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] **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
|
||||
- [ ] **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] **Tests de non-régression** — +10 tests (landing, register, 404, validation, duplicate, expiry)
|
||||
- [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.
|
||||
|
||||
- [ ] **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)
|
||||
- [ ] **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 PDF** — mise en page fidèle, table des matières
|
||||
- [ ] **Export HTML** — site statique standalone
|
||||
|
||||
### v4.3.0 — Collaboration
|
||||
### v4.8.0 — Collaboration
|
||||
- [ ] **Inline comments** — commentaires sur sélection de texte
|
||||
- [ ] **@mentions** — notifier un utilisateur → page/commentaire
|
||||
- [ ] **Email notifications** — changements, mentions
|
||||
|
||||
### v4.4.0 — Embeds & Rich Media
|
||||
- [ ] **Embeds** — YouTube, Figma, Google Maps, Twitter, etc.
|
||||
- [ ] **Image lightbox** — clic pour agrandir, navigation
|
||||
- [ ] **Bookmark cards** — aperçu riche des liens (OG metadata)
|
||||
### v4.9.0 — FlowDeck Agent (Agent IA natif)
|
||||
|
||||
### v4.5.0 — Kanban Adaptable
|
||||
- [ ] Colonnes configurables par utilisateur
|
||||
- [ ] Cartes configurables (choisir propriétés affichées)
|
||||
- [ ] Couverture de carte (image, icône, couleur)
|
||||
- [ ] WIP limits par colonne
|
||||
**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).
|
||||
|
||||
### v4.6.0 — Calendar & Meetings
|
||||
- [ ] Vue Calendrier drag & drop
|
||||
- [ ] Récurrence d'événements
|
||||
- [ ] Template Meeting Notes
|
||||
**Documentation :**
|
||||
|
||||
### v4.7.0 — AI Assistant
|
||||
- [ ] Intégration LLM pour écriture/résumé/traduction
|
||||
- [ ] Slash AI commands (`/ai write`, `/ai summarize`)
|
||||
- [ ] Auto-complétion
|
||||
[`docs/Flowdeck_Agent_integration.md`](docs/Flowdeck_Agent_integration.md) (702 lignes) ·
|
||||
[`docs/Guide_Complet_Notion_Agent_2026.md`](docs/Guide_Complet_Notion_Agent_2026.md) (464 lignes)
|
||||
|
||||
### 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
|
||||
- [ ] **Quick actions** — navigation, création, commandes
|
||||
|
||||
### v4.9.0 — Automations
|
||||
### v5.1.0 — Automations
|
||||
- [ ] **Database automations** — if-this-then-that
|
||||
- [ ] **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
|
||||
- [ ] Templates de database (Project tracker, CRM…)
|
||||
- [ ] 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
|
||||
- [ ] **SSO/SAML** — enterprise authentication
|
||||
@@ -278,9 +424,10 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
|
||||
+ UI Notion + Tags + Admin + Sharing COMPLETED
|
||||
+ GitHub OAuth + Library
|
||||
|
||||
v4.0.2 ✅ v4.1.0 ⬜ v4.2.0 ⬜ v4.3.0 ⬜ v4.4–4.10 ⬜ v5.0.0 ⬜
|
||||
Quality Blocks Export Collab Notion Pro
|
||||
& Tests enrichis PDF/MD @mentions Parity (futur)
|
||||
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 Data Sources Templates + Content Export Collab + Pro + Agent
|
||||
& 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é ✅*
|
||||
|
||||
@@ -192,7 +192,7 @@ def init_db():
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT DEFAULT '📄',
|
||||
icon TEXT DEFAULT 'file',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
parent_id INTEGER REFERENCES collection_pages(id),
|
||||
gitea_issue_id INTEGER,
|
||||
@@ -462,6 +462,59 @@ def init_db():
|
||||
""")
|
||||
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
|
||||
def get_conn():
|
||||
|
||||
+19
-5
@@ -92,6 +92,20 @@ async def pwa_manifest():
|
||||
# ═══════════ 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")
|
||||
async def api_pages_alias(request: Request):
|
||||
"""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)
|
||||
async def not_found_handler(request: Request, exc):
|
||||
"""Styled 404 page matching the FlowDeck dark theme."""
|
||||
from fastapi.responses import HTMLResponse
|
||||
# API routes should get JSON, not HTML
|
||||
if request.url.path.startswith("/api/") or request.url.path.startswith("/board/api/"):
|
||||
"""Redirect 404 HTML pages to /workspaces. API routes still get JSON."""
|
||||
# Preserve JSON 404 for all API-like paths (including /db/xxx/api)
|
||||
if "/api" in request.url.path:
|
||||
from fastapi.responses import JSONResponse
|
||||
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)
|
||||
|
||||
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
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):
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
|
||||
+40
-21
@@ -10,6 +10,7 @@ from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import gitea
|
||||
from app.auth.session import SessionManager
|
||||
from app.routers.dashboard import _get_app_version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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": "📄",
|
||||
"design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒",
|
||||
}
|
||||
icon = "📄"
|
||||
icon = "file"
|
||||
for lbl in labels:
|
||||
for kw, emoji in icon_map.items():
|
||||
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:
|
||||
"""Map file extension to emoji icon."""
|
||||
"""Map file extension to icon name (SVG-safe)."""
|
||||
import re
|
||||
# FlowDeck internal pages (no extension)
|
||||
if content_format and content_format != 'file':
|
||||
return '📝'
|
||||
return 'edit'
|
||||
n = name.lower()
|
||||
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): return '🖼️'
|
||||
if n.endswith('.pdf'): return '📕'
|
||||
if re.search(r'\.(md|markdown)$', n): return '📝'
|
||||
if n.endswith('.py'): return '🐍'
|
||||
if re.search(r'\.(js|jsx|ts|tsx)$', n): return '📜'
|
||||
if re.search(r'\.(html?|xml)$', n): return '🌐'
|
||||
if n.endswith('.css'): return '🎨'
|
||||
if n.endswith('.json'): return '📋'
|
||||
if n.endswith('.sql'): return '🗃️'
|
||||
if re.search(r'\.(sh|bash|zsh)$', n): return '💻'
|
||||
if n.endswith('.ps1'): return '⚡'
|
||||
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n): return '📜'
|
||||
if re.search(r'\.(txt|log)$', n): return '📄'
|
||||
if re.search(r'\.(zip|tar|gz|rar|7z)$', n): return '📦'
|
||||
return '📄'
|
||||
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): return 'image'
|
||||
if n.endswith('.pdf'): return 'file'
|
||||
if re.search(r'\.(md|markdown)$', n): return 'edit'
|
||||
if n.endswith('.py'): return 'file'
|
||||
if re.search(r'\.(js|jsx|ts|tsx)$', n): return 'file'
|
||||
if re.search(r'\.(html?|xml)$', n): return 'file'
|
||||
if n.endswith('.css'): return 'file'
|
||||
if n.endswith('.json'): return 'file'
|
||||
if n.endswith('.sql'): return 'file'
|
||||
if re.search(r'\.(sh|bash|zsh)$', n): return 'file'
|
||||
if n.endswith('.ps1'): return 'file'
|
||||
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 'file'
|
||||
if re.search(r'\.(zip|tar|gz|rar|7z)$', n): return 'file'
|
||||
return 'file'
|
||||
|
||||
|
||||
def _load_workspace_pages(ws_cookie: str) -> list:
|
||||
@@ -216,6 +217,22 @@ def _load_children(parent_id: int) -> list:
|
||||
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:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
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
|
||||
recent.append({
|
||||
"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}",
|
||||
"active": active, "indent": indent,
|
||||
"depth": indent, "has_children": False, "children": [],
|
||||
@@ -380,7 +397,9 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
"auth_method": auth_method,
|
||||
"gitea_linked": gitea_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 = ""):
|
||||
@@ -510,7 +529,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{page['id']}",
|
||||
"created_by": "You",
|
||||
"source": page.get("workspace") or "🔒 Private",
|
||||
"source": page.get("workspace") or "Private",
|
||||
"last_edited": page.get("updated_at", "now"),
|
||||
"last_visited": page.get("updated_at", "now"),
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Query
|
||||
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) ──
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,32 @@ from app.db import get_conn
|
||||
logger = logging.getLogger(__name__)
|
||||
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):
|
||||
"""Return decoded user or a RedirectResponse to login page.
|
||||
@@ -31,6 +57,21 @@ def _get_user_or_redirect(request: Request):
|
||||
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:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
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({
|
||||
"id": full_name,
|
||||
"name": repo.get("name", full_name),
|
||||
"icon": "📁",
|
||||
"icon": "folder",
|
||||
"url": f"/board/{full_name}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
@@ -173,8 +214,13 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
"gitea_linked": gitea_linked,
|
||||
"github_linked": github_linked,
|
||||
"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)
|
||||
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:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
page = dict(row)
|
||||
|
||||
ws = page.get("workspace", "")
|
||||
@@ -279,7 +325,9 @@ async def view_page_root(request: Request, page_id: int):
|
||||
ws_id = p.replace("workspace_", "")
|
||||
break
|
||||
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_mime"] = mime_type
|
||||
page_data["file_size"] = file_size
|
||||
@@ -384,7 +432,7 @@ async def help_page(request: Request):
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<ul>
|
||||
<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"],
|
||||
"label": title,
|
||||
"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,
|
||||
})
|
||||
current = row["parent_id"]
|
||||
@@ -1114,7 +1162,7 @@ async def nav_menu(request: Request, workspace_id: int = None, parent_id: int =
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"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,
|
||||
"url": f"/pages/{r['id']}",
|
||||
})
|
||||
|
||||
@@ -296,6 +296,115 @@ async def apply_page_template(request: Request, collection_id: int, tid: int):
|
||||
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 ──
|
||||
|
||||
@router.post("/collections/{collection_id}/import/csv")
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#}
|
||||
{% set ns = namespace(items=[]) %}
|
||||
{% 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 %}
|
||||
{% if breadcrumb_items %}
|
||||
{% for item in breadcrumb_items %}
|
||||
@@ -51,7 +51,7 @@
|
||||
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
{% 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 %}
|
||||
|
||||
<template x-for="(crumb, di) in display" :key="di">
|
||||
@@ -67,7 +67,7 @@
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<template x-for="h in crumb.hidden" :key="h.id">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -110,7 +110,7 @@
|
||||
<div class="fd-nav-item"
|
||||
@mouseenter="openSub(item)"
|
||||
@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-arrow" x-show="item.has_children">›</span>
|
||||
<div class="fd-nav-menu fd-nav-submenu" x-show="subOpenId === item.id" x-cloak
|
||||
@@ -118,7 +118,7 @@
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<template x-for="s in subItems" :key="s.id">
|
||||
<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-arrow" x-show="s.has_children">›</span>
|
||||
</div>
|
||||
|
||||
@@ -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 -%}
|
||||
@@ -1,3 +1,4 @@
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
{% macro render_workspace_tree(pages, depth=0) %}
|
||||
{% for page in pages %}
|
||||
{% set safe_name = page.name | replace("'", "\\'") %}
|
||||
@@ -25,7 +26,7 @@
|
||||
{% else %}
|
||||
<span style="width:12px;flex-shrink:0;"></span>
|
||||
{% 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 %}
|
||||
<span class="page-name tree-folder-link"
|
||||
@click.stop="navigateToFolder({{ page.db_id }})"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}👤{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("user",18) }}{% endblock %}
|
||||
{% block page_title %}Accounts{% endblock %}
|
||||
{% block title_prefix %}Accounts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">👤</span>
|
||||
<span class="page-icon-lg">{{ fd_icon("user",24) }}</span>
|
||||
<h1>Account Management</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,7 +50,7 @@
|
||||
<div style="background:var(--bg-secondary); border-radius:8px; padding:20px; margin-bottom:24px;">
|
||||
<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;">
|
||||
<span style="font-size:20px;">🔗</span>
|
||||
<span style="font-size:20px;">{{ fd_icon("link",20) }}</span>
|
||||
<div style="flex:1;">
|
||||
<div style="font-weight:600;">Gitea</div>
|
||||
<div style="font-size:12px; color:var(--text-dim);">
|
||||
|
||||
+170
-55
@@ -123,7 +123,7 @@
|
||||
{% else %}
|
||||
<span class="tree-toggle-placeholder"></span>
|
||||
{% 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="item-actions">
|
||||
<button class="item-action-btn" @click.stop="newSubPage('{{ page.id }}')">+</button>
|
||||
@@ -140,6 +140,8 @@
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
|
||||
<!-- ═══════════ SIDEBAR Notion ═══════════ -->
|
||||
<aside class="sidebar"
|
||||
:class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileSidebarOpen }"
|
||||
@@ -154,7 +156,7 @@
|
||||
<span class="workspace-name">{{ user.full_name or user.login or workspaceName }}</span>
|
||||
{% if auth_method != 'local' %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -172,16 +174,31 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="um-sep"></div>
|
||||
<a href="/settings" class="um-item" @click="workspaceMenuOpen=false">⚙️ Settings</a>
|
||||
<a href="/workspaces" class="um-item" @click="workspaceMenuOpen=false">🔄 Switch workspace</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">{{ fd_icon("refresh", 16) }} Switch workspace</a>
|
||||
{% if local_workspaces %}
|
||||
<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>
|
||||
|
||||
<!-- Navigation icons 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>
|
||||
Home
|
||||
</a>
|
||||
@@ -191,7 +208,7 @@
|
||||
<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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -203,15 +220,16 @@
|
||||
<div class="sidebar-section-title">
|
||||
<span class="chevron" :class="{ open: sectionsOpen.workspace }" x-text="sectionsOpen.workspace ? '▼' : '▶'"></span>
|
||||
{% 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 %}
|
||||
<span>📁 No workspace open</span>
|
||||
<span><span style="vertical-align:sub;margin-right:4px;">{{ fd_icon("folder",16) }}</span>No workspace open</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if has_active_workspace %}
|
||||
<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 Folder" @click.stop="window.FlowDeck.showCreateFolderModal()">📁</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()">{{ 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -222,13 +240,13 @@
|
||||
{% if workspace_pages %}
|
||||
{{ render_workspace_tree(workspace_pages) }}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -240,15 +258,15 @@
|
||||
<div class="sidebar-section-header" @click="toggleSection('gitea')">
|
||||
<div class="sidebar-section-title">
|
||||
<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 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 class="sidebar-section-items" x-show="sectionsOpen.gitea" x-transition>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,7 +286,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -280,7 +298,7 @@
|
||||
<span>Recents</span>
|
||||
</div>
|
||||
<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 class="sidebar-section-items" x-show="sectionsOpen.recents" x-transition>
|
||||
@@ -289,7 +307,7 @@
|
||||
{{ render_tree_item(page) }}
|
||||
{% endfor %}
|
||||
{% 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 %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -303,7 +321,7 @@
|
||||
<span>Favorites</span>
|
||||
</div>
|
||||
<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 class="sidebar-section-items" x-show="sectionsOpen.favorites" x-transition>
|
||||
@@ -312,7 +330,7 @@
|
||||
{{ render_tree_item(page) }}
|
||||
{% endfor %}
|
||||
{% 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 %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -324,7 +342,7 @@
|
||||
<div class="sidebar-section-title"><span>Agents</span></div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -336,7 +354,7 @@
|
||||
<span>Shared</span>
|
||||
</div>
|
||||
<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 class="sidebar-section-items" x-show="sectionsOpen.shared" x-transition>
|
||||
@@ -345,7 +363,7 @@
|
||||
{{ render_tree_item(page) }}
|
||||
{% endfor %}
|
||||
{% 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 %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -359,7 +377,7 @@
|
||||
<span>Published</span>
|
||||
</div>
|
||||
<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 class="sidebar-section-items" x-show="sectionsOpen.published" x-transition>
|
||||
@@ -368,7 +386,7 @@
|
||||
{{ render_tree_item(page) }}
|
||||
{% endfor %}
|
||||
{% 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 %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -383,7 +401,7 @@
|
||||
<span>Private</span>
|
||||
</div>
|
||||
<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 class="sidebar-section-items" x-show="sectionsOpen.private" x-transition>
|
||||
@@ -392,7 +410,7 @@
|
||||
{{ render_tree_item(page) }}
|
||||
{% endfor %}
|
||||
{% 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 %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -403,16 +421,16 @@
|
||||
<!-- Bottom links (always visible, no section wrapper) -->
|
||||
<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;">
|
||||
<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 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 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 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>
|
||||
</div>
|
||||
|
||||
@@ -420,7 +438,7 @@
|
||||
<div class="sidebar-footer">
|
||||
<div style="display:flex; gap:6px;">
|
||||
<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>
|
||||
</button>
|
||||
{% if has_active_workspace %}
|
||||
@@ -465,19 +483,19 @@
|
||||
<span x-text="favoriteIds.has(contextMenu.pageId) ? 'Remove from Favorites' : 'Add to Favorites'"></span>
|
||||
</div>
|
||||
<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 class="context-menu-section">
|
||||
<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 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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="context-menu-item" @click="contextAction('moveTo')">
|
||||
@@ -485,16 +503,16 @@
|
||||
<span class="menu-shortcut">Ctrl+Shift+P</span>
|
||||
</div>
|
||||
<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 class="context-menu-section">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -506,7 +524,7 @@
|
||||
<span x-text="lastEdited"></span>
|
||||
</div>
|
||||
<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>
|
||||
@@ -519,19 +537,19 @@
|
||||
<template x-if="wsCtx.isFolder">
|
||||
<div class="context-menu-section">
|
||||
<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 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>
|
||||
</template>
|
||||
<div class="context-menu-section">
|
||||
<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 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 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 ────────────────────────────────
|
||||
// All "New Page" and "New Folder" buttons across the app call
|
||||
// these functions for consistent behaviour.
|
||||
@@ -590,6 +650,47 @@
|
||||
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.
|
||||
* Returns { wsId: number, isGitea: boolean, workspaceKey: string } */
|
||||
_getContext: function() {
|
||||
@@ -743,7 +844,7 @@
|
||||
workspaceName: '{{ workspace_name }}',
|
||||
workspaceInitial: '{{ workspace_initial }}',
|
||||
currentPage: '{{ current_page }}',
|
||||
pageIcon: '{% block page_icon %}📁{% endblock %}',
|
||||
pageIcon: '{% block page_icon %}folder{% endblock %}',
|
||||
pageTitle: '{% block page_title %}FlowDeck{% endblock %}',
|
||||
lastEdited: '{{ last_edited }}',
|
||||
workspaceKey: '{{ workspace_key }}',
|
||||
@@ -844,10 +945,14 @@
|
||||
const favList = document.querySelector('.sidebar-items[data-section="favorites"]');
|
||||
if (!favList) return;
|
||||
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 {
|
||||
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 safeName = name.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
const url = item.url || ('/pages/' + item.id);
|
||||
@@ -967,10 +1072,20 @@
|
||||
},
|
||||
|
||||
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 = {
|
||||
visible: true,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
x: x,
|
||||
y: y,
|
||||
pageId: pageId,
|
||||
pageName: pageName
|
||||
};
|
||||
@@ -1113,13 +1228,13 @@
|
||||
}
|
||||
container.innerHTML = '';
|
||||
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;
|
||||
}
|
||||
this.giteaTree.forEach(function(item) {
|
||||
var li = document.createElement('li');
|
||||
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>';
|
||||
container.appendChild(li);
|
||||
});
|
||||
@@ -1128,7 +1243,7 @@
|
||||
.catch(function(err) {
|
||||
console.error('loadGiteaTree failed:', err.message || err);
|
||||
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 class="flowdeck-modal">
|
||||
<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'">×</button>
|
||||
</div>
|
||||
<div class="flowdeck-modal-body">
|
||||
|
||||
+10
-10
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}📁{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("folder",18) }}{% endblock %}
|
||||
{% block page_title %}Kanban board new{% endblock %}
|
||||
{% block title_prefix %}Kanban board new{% endblock %}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<!-- Page title -->
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">📁</span>
|
||||
<span class="page-icon-lg">{{ fd_icon("folder",24) }}</span>
|
||||
<h1>{{ repo }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,31 +41,31 @@
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/kanban"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">📊</span> Kanban board
|
||||
<span class="tab-icon">{{ fd_icon("bar-chart",14) }}</span> Kanban board
|
||||
</button>
|
||||
<button class="view-tab" data-view="detailed"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/detailed"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">📋</span> Detailed board
|
||||
<span class="tab-icon">{{ fd_icon("list",14) }}</span> Detailed board
|
||||
</button>
|
||||
<button class="view-tab" data-view="table"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/table"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">☰</span> Table view
|
||||
<span class="tab-icon">{{ fd_icon("align-left",14) }}</span> Table view
|
||||
</button>
|
||||
<button class="view-tab" data-view="status"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/status"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">📈</span> Status overview
|
||||
<span class="tab-icon">{{ fd_icon("trending-up",14) }}</span> Status overview
|
||||
</button>
|
||||
<button class="view-tab" data-view="teamload"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/teamload"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">👥</span> Team Load
|
||||
<span class="tab-icon">{{ fd_icon("users",14) }}</span> Team Load
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
<span class="sort-direction" @click="toggleDir(i)">
|
||||
<span x-text="s.dir === 'asc' ? 'Ascending ↑' : 'Descending ↓'"></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>
|
||||
</template>
|
||||
<button class="dropdown-option" @click="addSort()">+ Add sort</button>
|
||||
@@ -119,8 +119,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="toolbar-btn" title="Properties">⚡</button>
|
||||
<button class="toolbar-btn" title="Search">🔍</button>
|
||||
<button class="toolbar-btn" title="Properties">{{ fd_icon("zap",14) }}</button>
|
||||
<button class="toolbar-btn" title="Search">{{ fd_icon("search",14) }}</button>
|
||||
<button class="toolbar-btn">⋯</button>
|
||||
<button class="btn-new" @click="showNewIssue()">New ▾</button>
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<span class="card-tag" style="background: {{ card.tag_color }}20; color: {{ card.tag_color }};">{{ card.tag }}</span>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{% endif %}
|
||||
{% if card.due_date %}
|
||||
<div class="card-date">
|
||||
<span>📅</span> {{ card.due_date }}
|
||||
<span>{{ fd_icon("calendar",14) }}</span> {{ card.due_date }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -23,13 +23,13 @@
|
||||
</div>
|
||||
<!-- Assignee -->
|
||||
<div class="filter-pill" style="cursor:pointer;">
|
||||
<span>👤</span>
|
||||
<span>{{ fd_icon("user",14) }}</span>
|
||||
{% if issue.assignee %}{{ issue.assignee }}{% else %}Unassigned{% endif %}
|
||||
<span style="font-size:10px;">▾</span>
|
||||
</div>
|
||||
<!-- Deadline -->
|
||||
<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 %}
|
||||
</div>
|
||||
<!-- Labels -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}🏠{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("home",18) }}{% endblock %}
|
||||
{% block page_title %}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;"
|
||||
onmouseover="this.style.background='var(--bg-hover)'"
|
||||
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="font-weight:500; font-size:14px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||
{{ repo.name }}
|
||||
@@ -57,7 +57,7 @@
|
||||
<span style="font-size:12px; color: var(--text-dim);">🟡 {{ repo.language }}</span>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
<span style="font-size:12px; color: var(--text-dim);">→</span>
|
||||
</div>
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
{% if not repos %}
|
||||
<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>
|
||||
<p style="margin-top:8px;">
|
||||
{% if search %}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
{% endif %}
|
||||
{% if card.due_date %}
|
||||
<div class="card-date">
|
||||
<span>📅</span>
|
||||
<span>{{ fd_icon("calendar",14) }}</span>
|
||||
<span style="font-size:12px;">{{ card.due_date }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}{{ workspace_name }}{% endblock %}
|
||||
{% block title_prefix %}{{ workspace_name }}{% endblock %}
|
||||
{% block page_icon %}🔗{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("link",18) }}{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
|
||||
{% set page_icon = "🔗" %}
|
||||
{% 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 page_icon = "link" %}
|
||||
{% 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' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -164,7 +164,7 @@ document.addEventListener('alpine:init', () => {
|
||||
if (!r.ok) {
|
||||
// If API fails (e.g. no Gitea token), set a message
|
||||
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;
|
||||
}
|
||||
var d = await r.json();
|
||||
@@ -180,7 +180,7 @@ document.addEventListener('alpine:init', () => {
|
||||
var html = '';
|
||||
for (var i = 0; i < items.length; 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 += '<span class="page-icon">' + icon + '</span>';
|
||||
html += '<span class="page-name">' + item.name + '</span>';
|
||||
@@ -189,7 +189,7 @@ document.addEventListener('alpine:init', () => {
|
||||
// Add Private Pages link
|
||||
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 += '<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;
|
||||
// Re-attach event listeners
|
||||
this.setupSidebarClicks();
|
||||
@@ -270,7 +270,7 @@ document.addEventListener('alpine:init', () => {
|
||||
ul.className = 'sidebar-items';
|
||||
ul.style.paddingLeft = '20px';
|
||||
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');
|
||||
li.className = 'sidebar-item';
|
||||
li.setAttribute('data-gitea-path', child.path);
|
||||
@@ -669,8 +669,8 @@ document.addEventListener('alpine:init', () => {
|
||||
<strong x-text="filePath || ''"></strong>
|
||||
<span x-text="formatSize(fileSize)"></span>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" @click="openEditor()">✏️ Edit</button>
|
||||
<button class="btn btn-danger" @click="deleteCurrentFile()">🗑 Delete</button>
|
||||
<button class="btn" @click="openEditor()">{{ fd_icon("edit",14) }} Edit</button>
|
||||
<button class="btn btn-danger" @click="deleteCurrentFile()">{{ fd_icon("trash",14) }} Delete</button>
|
||||
</div>
|
||||
<div class="gw-content">
|
||||
<!-- Skeleton loading -->
|
||||
@@ -710,7 +710,7 @@ document.addEventListener('alpine:init', () => {
|
||||
<option :value="msg">
|
||||
</template>
|
||||
</datalist>
|
||||
<button class="btn-primary" @click="saveFile()">💾 Commit</button>
|
||||
<button class="btn-primary" @click="saveFile()">{{ fd_icon("save",14) }} Commit</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -734,9 +734,9 @@ document.addEventListener('alpine:init', () => {
|
||||
<div x-show="!treeLoading">
|
||||
<template x-if="treeItems.length === 0">
|
||||
<div class="empty-state">
|
||||
<h2>📁 Empty</h2>
|
||||
<h2>{{ fd_icon("folder",16) }} Empty</h2>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -746,7 +746,7 @@ document.addEventListener('alpine:init', () => {
|
||||
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
|
||||
@contextmenu.prevent="openGwContext($event, item)"
|
||||
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.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
|
||||
</div>
|
||||
@@ -759,8 +759,8 @@ document.addEventListener('alpine:init', () => {
|
||||
:style="'left:' + gwCtx.x + 'px; top:' + gwCtx.y + 'px'"
|
||||
@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;">
|
||||
<div class="ctx-item" @click="gwRename()">✏️ Rename</div>
|
||||
<div class="ctx-item ctx-danger" @click="gwDelete()">🗑 Delete</div>
|
||||
<div class="ctx-item" @click="gwRename()">{{ fd_icon("edit",14) }} Rename</div>
|
||||
<div class="ctx-item ctx-danger" @click="gwDelete()">{{ fd_icon("trash",14) }} Delete</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;">
|
||||
<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;">
|
||||
<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>
|
||||
</template>
|
||||
@@ -778,7 +778,7 @@ document.addEventListener('alpine:init', () => {
|
||||
<template x-if="showPrivate && !editingPrivate">
|
||||
<div class="gw-content">
|
||||
<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>
|
||||
</div>
|
||||
<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-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></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>
|
||||
</template>
|
||||
</div>
|
||||
@@ -805,7 +805,7 @@ document.addEventListener('alpine:init', () => {
|
||||
<div class="gw-file-toolbar">
|
||||
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
|
||||
<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>
|
||||
</div>
|
||||
<div class="gw-content">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
<title>FlowDeck — All-in-one workspace</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<style>
|
||||
@@ -142,7 +143,7 @@
|
||||
|
||||
<nav class="landing-nav">
|
||||
<a href="/" class="landing-logo">
|
||||
<span>📚</span> FlowDeck
|
||||
<span>{{ fd_icon("book",20) }}</span> FlowDeck
|
||||
</a>
|
||||
<div class="landing-nav-actions">
|
||||
<a href="/auth/login?provider=local" class="btn btn-secondary">Log in</a>
|
||||
@@ -158,39 +159,39 @@
|
||||
All on your own server.
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📝</div>
|
||||
<div class="feature-icon">{{ fd_icon("edit",24) }}</div>
|
||||
<h3>Block Editor</h3>
|
||||
<p>Notion-style editor with slash commands, markdown shortcuts, headings, code blocks, to-dos, and more.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📊</div>
|
||||
<div class="feature-icon">{{ fd_icon("bar-chart",24) }}</div>
|
||||
<h3>Kanban & Tables</h3>
|
||||
<p>Visual project management with drag-drop boards, table views, and database collections.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🦎</div>
|
||||
<div class="feature-icon">{{ fd_icon("link",24) }}</div>
|
||||
<h3>Gitea & GitHub</h3>
|
||||
<p>Browse repos, edit files, commit changes — all from your workspace sidebar.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🌐</div>
|
||||
<div class="feature-icon">{{ fd_icon("globe",24) }}</div>
|
||||
<h3>Publish to Web</h3>
|
||||
<p>One-click publish any page to a public URL. Share with anyone, no account needed.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔒</div>
|
||||
<div class="feature-icon">{{ fd_icon("lock",24) }}</div>
|
||||
<h3>Self-Hosted</h3>
|
||||
<p>Your data stays on your server. No vendor lock-in, full control, Docker single-container deploy.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">⚡</div>
|
||||
<div class="feature-icon">{{ fd_icon("zap",24) }}</div>
|
||||
<h3>Fast & Light</h3>
|
||||
<p>Alpine.js + FastAPI + SQLite. No React bloat, no Node.js needed. Sub-100ms page loads.</p>
|
||||
</div>
|
||||
|
||||
+37
-15
@@ -1,10 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}📚{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("book",18) }}{% endblock %}
|
||||
{% block page_title %}Library{% endblock %}
|
||||
{% block title_prefix %}Library{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set page_icon = "📚" %}
|
||||
{% set page_icon = "book" %}
|
||||
{% set page_title = "Library" %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
@@ -186,7 +186,7 @@
|
||||
<div class="lib-header">
|
||||
<h1>Library</h1>
|
||||
<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>
|
||||
New page
|
||||
</button>
|
||||
@@ -302,8 +302,14 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Loading ── -->
|
||||
<div class="lib-loading" id="lib-loading">
|
||||
<p>Loading…</p>
|
||||
<div class="lib-loading" id="lib-loading" x-show="loading">
|
||||
<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>
|
||||
|
||||
<!-- ── Table ── -->
|
||||
@@ -325,7 +331,7 @@
|
||||
</div>
|
||||
|
||||
<!-- "+ 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()">
|
||||
<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
|
||||
@@ -333,7 +339,7 @@
|
||||
|
||||
<!-- ── Empty state ── -->
|
||||
<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>
|
||||
<p x-text="emptyText"></p>
|
||||
</div>
|
||||
@@ -389,10 +395,10 @@
|
||||
<input type="text" placeholder="Search pages..." x-model="moveSearchQuery" @input="doMoveSearch()">
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
</template>
|
||||
</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>
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
<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 && peekContent" x-html="peekContent"></div>
|
||||
<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>
|
||||
</div>
|
||||
@@ -501,6 +507,9 @@ function libraryPage() {
|
||||
moveModalOpen: false,
|
||||
moveIds: [],
|
||||
moveSearchQuery: '',
|
||||
|
||||
// No workspace guard
|
||||
noWorkspace: false,
|
||||
moveCandidates: [],
|
||||
_moveAllCandidates: [],
|
||||
|
||||
@@ -579,7 +588,7 @@ function libraryPage() {
|
||||
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>';
|
||||
} 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
|
||||
if (isRenaming) {
|
||||
@@ -608,6 +617,13 @@ function libraryPage() {
|
||||
|
||||
_escHtml(s) { var d=document.createElement('div'); d.textContent = s||''; return d.innerHTML; },
|
||||
_escAttr(s) { return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); },
|
||||
_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() {
|
||||
var result = [];
|
||||
@@ -773,13 +789,13 @@ function libraryPage() {
|
||||
},
|
||||
|
||||
// ── Empty states ──
|
||||
emptyIcon: '📄',
|
||||
emptyIcon: 'file',
|
||||
emptyTitle: 'No items',
|
||||
emptyText: '',
|
||||
|
||||
_recomputeEmpty() {
|
||||
var icons = {recents:'🕒',favorites:'⭐',shared:'👥',published:'🌐',private:'🔒',workspace:'📁',repository:'📦'};
|
||||
this.emptyIcon = icons[this.tab] || '📄';
|
||||
var icons = {recents:'clock',favorites:'star',shared:'users',published:'globe',private:'lock',workspace:'folder',repository:'folder'};
|
||||
this.emptyIcon = icons[this.tab] || 'file';
|
||||
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',
|
||||
repository:'Empty repository'};
|
||||
@@ -804,6 +820,12 @@ function libraryPage() {
|
||||
this.isGiteaActive = (giteaEl ? giteaEl.textContent : '0') === '1';
|
||||
this.giteaOwner = (document.getElementById('lib-gitea-owner') || {}).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);
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var t = params.get('tab');
|
||||
|
||||
+369
-181
@@ -1,10 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}{{ workspace_name }}{% endblock %}
|
||||
{% block title_prefix %}{{ workspace_name }}{% endblock %}
|
||||
{% block page_icon %}📁{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("folder",18) }}{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set page_icon = "📁" %}
|
||||
{% set page_icon = "folder" %}
|
||||
{% set page_title = workspace_name %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
@@ -17,12 +17,141 @@
|
||||
@drop.prevent="onDrop($event)"
|
||||
@keydown="onKeydown($event)">
|
||||
<style>
|
||||
.ws-layout{max-width:800px;margin:0 auto;padding:40px 24px;}/* deprecated by ws-split */
|
||||
@media (max-width: 768px) { .ws-layout { padding: 20px 12px; } }
|
||||
.ws-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;flex-wrap:wrap;gap:12px;}
|
||||
.ws-header h1{font-size:24px;}
|
||||
.ws-toolbar{display:flex;gap:8px;}
|
||||
/* ── Library-style layout ── */
|
||||
.ws-container{max-width:1100px;margin:0 auto;padding:20px 24px;}
|
||||
.ws-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;}
|
||||
.ws-header h1{font-size:24px;font-weight:700;color:var(--text-primary);margin:0;}
|
||||
|
||||
/* ── 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-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);}
|
||||
@@ -34,21 +163,18 @@
|
||||
.ws-tree-item .name.file:hover{color:var(--accent);}
|
||||
.ws-tree-item .actions{display:none;gap:2px;}
|
||||
.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:hover{color:var(--text);background:var(--bg-hover);}
|
||||
.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:hover{background:var(--bg-hover);color:var(--text);}
|
||||
.ws-act.danger:hover{color:var(--danger);}
|
||||
|
||||
.empty-state{text-align:center;padding:80px 20px;color:var(--text-tertiary);}
|
||||
.empty-state .empty-icon{font-size:64px;margin-bottom:16px;opacity:.5;}
|
||||
.empty-state h2{font-size:18px;color:var(--text-secondary);margin-bottom:8px;}
|
||||
.empty-state p{font-size:13px;margin-bottom:24px;max-width:400px;margin-left:auto;margin-right:auto;}
|
||||
.empty-actions{display:flex;gap:10px;justify-content:center;}
|
||||
.empty-state{text-align:center;padding:80px 20px;color:var(--text-dim);}
|
||||
.empty-state .empty-icon{font-size:48px;margin-bottom:12px;opacity:0.5;}
|
||||
.empty-state h3{font-size:16px;color:var(--text-secondary);margin:0 0 4px;}
|
||||
.empty-state p{font-size:13px;margin:0;}
|
||||
.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-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;}
|
||||
|
||||
/* 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:hover{background:var(--bg-hover);}
|
||||
.ctx-item.ctx-danger{color:var(--danger);}
|
||||
@@ -359,6 +484,7 @@ window._wsInitData = (function() {
|
||||
currentFolder: {{ current_folder_id }},
|
||||
searchQuery: '',
|
||||
filterType: '',
|
||||
filterOpen: false,
|
||||
editingPath: false,
|
||||
folderStack: [],
|
||||
|
||||
@@ -377,7 +503,9 @@ window._wsInitData = (function() {
|
||||
sortBy: '',
|
||||
viewMode: localStorage.getItem('fd_default_view') || 'tree',
|
||||
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'},
|
||||
|
||||
// Context menu
|
||||
@@ -677,7 +805,7 @@ window._wsInitData = (function() {
|
||||
async _loadPreviewContent(node) {
|
||||
if (node.is_folder) {
|
||||
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>'
|
||||
+ '<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>';
|
||||
@@ -697,7 +825,7 @@ window._wsInitData = (function() {
|
||||
// PDF preview
|
||||
if (/\.pdf$/.test(name)) {
|
||||
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: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>'
|
||||
@@ -719,7 +847,7 @@ window._wsInitData = (function() {
|
||||
}
|
||||
} catch(e) {}
|
||||
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>';
|
||||
} else if (format === 'blocks') {
|
||||
try {
|
||||
@@ -750,7 +878,7 @@ window._wsInitData = (function() {
|
||||
// ── Context menu ──
|
||||
onContextMenu(ev) {
|
||||
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; }
|
||||
var id = parseInt(el.getAttribute('data-ws-id'));
|
||||
if (!id) { menu.style.display = 'none'; return; }
|
||||
@@ -791,7 +919,7 @@ window._wsInitData = (function() {
|
||||
c.ctxAvailTags = this.ctxAvailTags;
|
||||
c.ctxNewTagColor = this.ctxNewTagColor;
|
||||
// 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]) {
|
||||
var d = menuEl._x_dataStack[0];
|
||||
d.ctxMenu = this.ctxMenu;
|
||||
@@ -848,24 +976,24 @@ window._wsInitData = (function() {
|
||||
|
||||
ctxMenuOpenPage() {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
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) {
|
||||
@@ -881,7 +1009,7 @@ window._wsInitData = (function() {
|
||||
window._ctxMenuData.ctxAvailTags = this.ctxAvailTags;
|
||||
window._ctxMenuData.ctxMenu = this.ctxMenu;
|
||||
// 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]) {
|
||||
menuEl._x_dataStack[0].ctxAvailTags = [...this.ctxAvailTags];
|
||||
menuEl._x_dataStack[0].ctxMenu = this.ctxMenu;
|
||||
@@ -914,13 +1042,13 @@ window._wsInitData = (function() {
|
||||
window._ctxMenuData.ctxAvailTags = self.ctxAvailTags;
|
||||
window._ctxMenuData.ctxMenu = self.ctxMenu;
|
||||
// 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]) {
|
||||
menuEl._x_dataStack[0].ctxAvailTags = [...self.ctxAvailTags];
|
||||
menuEl._x_dataStack[0].ctxMenu = self.ctxMenu;
|
||||
}
|
||||
// Close menu after successful add
|
||||
document.querySelector('.ctx-menu').style.display = 'none';
|
||||
document.querySelector('.more-menu').style.display = 'none';
|
||||
}); }, 200);
|
||||
}
|
||||
this.ctxTagAdding = false;
|
||||
@@ -988,12 +1116,13 @@ window._wsInitData = (function() {
|
||||
html += '<div class="actions">';
|
||||
if (c.is_folder) {
|
||||
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-folder" data-ws-id="'+escId+'" data-ws-name="'+escName+'" title="New folder">\ud83d\udcc1</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">'+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 += '<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 danger" data-ws-action="delete" data-ws-id="'+escId+'" data-ws-name="'+escName+'" title="Delete">\ud83d\uddd1</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">'+getSvgIcon('trash',14)+'</button>';
|
||||
// Open button for files
|
||||
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;"'
|
||||
@@ -1394,7 +1523,7 @@ window._wsInitData = (function() {
|
||||
var html = '<img src="'+url+'" style="max-width:380px;max-height:180px;display:block;">';
|
||||
this.previewContent = html; this.previewCache[cacheKey] = html;
|
||||
} 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;
|
||||
} else {
|
||||
this.previewContent = 'Loading...';
|
||||
@@ -1475,25 +1604,25 @@ window._wsInitData = (function() {
|
||||
|
||||
// ── File type icon ──
|
||||
_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)
|
||||
if (contentFormat && contentFormat !== 'file') return '📝';
|
||||
if (contentFormat && contentFormat !== 'file') return '<span class="page-icon-svg">'+getSvgIcon('edit',14)+'</span>';
|
||||
var n = (name || '').toLowerCase();
|
||||
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return '🖼️';
|
||||
if (/\.pdf$/.test(n)) return '📕';
|
||||
if (/\.(md|markdown)$/.test(n)) return '📝';
|
||||
if (/\.(py)$/.test(n)) return '🐍';
|
||||
if (/\.(js|jsx|ts|tsx)$/.test(n)) return '📜';
|
||||
if (/\.(html?|xml)$/.test(n)) return '🌐';
|
||||
if (/\.css$/.test(n)) return '🎨';
|
||||
if (/\.json$/.test(n)) return '📋';
|
||||
if (/\.sql$/.test(n)) return '🗃️';
|
||||
if (/\.(sh|bash|zsh)$/.test(n)) return '💻';
|
||||
if (/\.ps1$/.test(n)) return '⚡';
|
||||
if (/\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$/.test(n)) return '📜';
|
||||
if (/\.(txt|log)$/.test(n)) return '📄';
|
||||
if (/\.(zip|tar|gz|rar|7z)$/.test(n)) return '📦';
|
||||
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 '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||||
if (/\.(md|markdown)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('edit',14)+'</span>';
|
||||
if (/\.(py)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||||
if (/\.(js|jsx|ts|tsx)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||||
if (/\.(html?|xml)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||||
if (/\.css$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||||
if (/\.json$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||||
if (/\.sql$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||||
if (/\.(sh|bash|zsh)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||||
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 '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||||
if (/\.(txt|log)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||||
if (/\.(zip|tar|gz|rar|7z)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||||
return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||||
},
|
||||
|
||||
// ── Search / Filter ──
|
||||
@@ -1501,11 +1630,10 @@ window._wsInitData = (function() {
|
||||
var q = (this.searchQuery || '').toLowerCase().trim();
|
||||
var ft = this.filterType || '';
|
||||
if (!q && !ft && !this.tagFilter) { this.displayTree = [...this.tree]; this.showAll = false; this._computeFilteredTableItems(); return; }
|
||||
// Apply all filters (search + type + tag) to tree view
|
||||
this.displayTree = this._filterTree([...this.tree], q, ft);
|
||||
if (this.tagFilter) {
|
||||
// Filter tree by tag
|
||||
this.displayTree = this._filterTreeByTag([...this.tree], this.tagFilter);
|
||||
} else {
|
||||
this.displayTree = [...this.tree];
|
||||
this.displayTree = this._filterTreeByTag(this.displayTree, this.tagFilter);
|
||||
}
|
||||
this._computeFilteredTableItems();
|
||||
},
|
||||
@@ -2029,16 +2157,16 @@ window._wsInitData = (function() {
|
||||
},
|
||||
|
||||
_fileTypeLabel(name, contentFormat) {
|
||||
if (contentFormat && contentFormat !== 'file') return '📝 Page';
|
||||
if (contentFormat && contentFormat !== 'file') return 'Page';
|
||||
var n = (name || '').toLowerCase();
|
||||
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return '🖼️ Image';
|
||||
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 (/\.(txt|md|markdown|log)$/.test(n)) return '📄 Text';
|
||||
if (/\.(zip|tar|gz|rar|7z)$/.test(n)) return '📦 Archive';
|
||||
if (/\.(mp3|wav|ogg|flac|aac)$/.test(n)) return '🎵 Audio';
|
||||
if (/\.(mp4|webm|mov|avi)$/.test(n)) return '🎬 Video';
|
||||
return '📄 File';
|
||||
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return 'Image';
|
||||
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 (/\.(txt|md|markdown|log)$/.test(n)) return 'Text';
|
||||
if (/\.(zip|tar|gz|rar|7z)$/.test(n)) return 'Archive';
|
||||
if (/\.(mp3|wav|ogg|flac|aac)$/.test(n)) return 'Audio';
|
||||
if (/\.(mp4|webm|mov|avi)$/.test(n)) return 'Video';
|
||||
return 'File';
|
||||
},
|
||||
|
||||
selectAllFiltered(ev) {
|
||||
@@ -2097,7 +2225,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
<!-- Drop overlay -->
|
||||
<div class="drop-overlay" :class="{ active: isDragging }">
|
||||
<div class="drop-hint">
|
||||
<span class="icon">📥</span>
|
||||
{{ fd_icon("download",16) }}
|
||||
<div>Drop files here</div>
|
||||
<div style="font-size:12px;color:var(--text-tertiary);margin-top:6px;" x-show="dropTarget">
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb row -->
|
||||
<!-- Breadcrumb + toolbar row (Library-style) -->
|
||||
<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>
|
||||
<a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">🏠</a>
|
||||
<button class="btn-icon nav-arrow" @click="goBack()" title="Back" :disabled="!canGoBack">←</button>
|
||||
<button class="btn-icon nav-arrow" @click="goForward()" title="Forward" :disabled="!canGoForward">→</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">{{ fd_icon("home",14) }}</a>
|
||||
<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">{{ fd_icon("chevron-right",14) }}</button>
|
||||
{% if breadcrumbs %}
|
||||
<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>
|
||||
<template x-for="(b, i) in folderStack" :key="'bc'+b.id">
|
||||
<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.escape="editingPath=false"
|
||||
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>
|
||||
<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>
|
||||
|
||||
<!-- Toolbar row -->
|
||||
<div class="toolbar-row">
|
||||
<div class="view-dropdown" @click.outside="viewMenuOpen=false" @keydown.escape="viewMenuOpen=false">
|
||||
<button class="view-dropdown-btn" @click="viewMenuOpen=!viewMenuOpen" title="View">
|
||||
<span x-text="viewIcons[viewMode] || '🌳'"></span>
|
||||
<span x-text="viewLabels[viewMode] || 'Tree'"></span>
|
||||
<span class="dropdown-arrow">▾</span>
|
||||
{% endif %}
|
||||
<div class="ws-toolbar" style="margin-left:auto;">
|
||||
<!-- View dropdown -->
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="viewMenuOpen=!viewMenuOpen; sortOpen=false; searchOpen=false">
|
||||
<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>
|
||||
</button>
|
||||
<div class="view-dropdown-menu" x-show="viewMenuOpen" x-transition @click="viewMenuOpen=false">
|
||||
<button class="view-menu-item" :class="{ active: viewMode==='tree' }" @click="viewMode='tree', previewItem=null">🌳 Tree</button>
|
||||
<button class="view-menu-item" :class="{ active: viewMode==='list' }" @click="viewMode='list', previewItem=null">📋 List</button>
|
||||
<button class="view-menu-item" :class="{ active: viewMode==='details' }" @click="viewMode='details', previewItem=null">📊 Details</button>
|
||||
<button class="view-menu-item" :class="{ active: viewMode==='title' }" @click="viewMode='title', previewItem=null">🏷️ Cards</button>
|
||||
<button class="view-menu-item" :class="{ active: viewMode==='content' }" @click="viewMode='content', previewItem=null">📝 Content</button>
|
||||
<div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
|
||||
<div class="dd-label">View</div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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 class="search-bar-inline">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input class="search-input" type="text" placeholder="Filter..."
|
||||
x-model="searchQuery" @input="doFilter" autocomplete="off">
|
||||
<button class="search-clear" x-show="searchQuery" @click="searchQuery='', doFilter()" title="Clear">×</button>
|
||||
<!-- Sort -->
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; viewMenuOpen=false">
|
||||
<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>
|
||||
<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>
|
||||
<select class="sort-select" x-model="sortBy" @change="doSort()">
|
||||
<option value="">Sort</option>
|
||||
<option value="name-asc">A→Z</option>
|
||||
<option value="name-desc">Z→A</option>
|
||||
<option value="type">Type</option>
|
||||
<option value="date-asc">Oldest</option>
|
||||
<option value="date-desc">Newest</option>
|
||||
</select>
|
||||
<button class="btn-icon" @click="expandAll()" title="Expand" x-show="viewMode==='tree'">⊞</button>
|
||||
<button class="btn-icon" @click="collapseAll()" title="Collapse" x-show="viewMode==='tree'">⊟</button>
|
||||
<button class="btn-icon btn-create" onclick="window.FlowDeck.createPage()" title="New file">📄</button>
|
||||
<button class="btn-icon btn-create" onclick="window.FlowDeck.showCreateFolderModal()" title="New folder">📁</button>
|
||||
</div>
|
||||
<!-- Filter -->
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="filterOpen=!filterOpen; viewMenuOpen=false; sortOpen=false">
|
||||
<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>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition style="right:0;">
|
||||
<div class="dd-label">File type</div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Bulk actions bar -->
|
||||
<div class="bulk-bar" x-show="selectedIdsCount > 0" x-transition>
|
||||
<span x-text="selectedIdsCount + ' selected'"></span>
|
||||
<button class="btn btn-danger" @click="bulkDelete()">🗑 Delete</button>
|
||||
<button class="btn btn-secondary" @click="clearSelection()">✕ Clear</button>
|
||||
<div class="ws-select-bar" x-show="selectedIdsCount > 0" x-transition>
|
||||
<span class="sel-count" x-text="selectedIdsCount + ' selected'"></span>
|
||||
<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 @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>
|
||||
|
||||
<!-- Tree -->
|
||||
<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)">
|
||||
<!-- Parent folder ".." -->
|
||||
<li x-show="currentFolder > 0" style="list-style:none;">
|
||||
<div class="ws-tree-item" @click="goToParent()" style="padding-left:4px;">
|
||||
<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>
|
||||
</div>
|
||||
</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>
|
||||
</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">
|
||||
<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">
|
||||
<template x-if="node.is_folder">
|
||||
<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.showCreateFolderModal()" title="New folder">📁</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">{{ fd_icon("folder",14) }}</button>
|
||||
<a class="ws-act" href="/local-workspace" title="Open workspace page" style="text-decoration:none;display:inline-flex;align-items:center;">{{ fd_icon("external-link",14) }}</a>
|
||||
</span>
|
||||
</template>
|
||||
<button class="ws-act" @click.stop="startRename(node)" title="Rename">✏️</button>
|
||||
<button class="ws-act danger" @click.stop="deleteWithUndo(node)" title="Delete">🗑</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">{{ fd_icon("trash",14) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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>
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<!-- 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] }">
|
||||
<td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></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>
|
||||
</td>
|
||||
<td class="col-path" x-text="node._path || '—'"></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-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-tags">
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<button class="ws-act" @click.stop="startRename(node)" title="Rename">✏️</button>
|
||||
<button class="ws-act danger" @click.stop="deleteWithUndo(node)" title="Delete">🗑</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">{{ fd_icon("trash",14) }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -2387,11 +2547,19 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
<th style="width:100px">Modified</th>
|
||||
</tr></thead>
|
||||
<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">
|
||||
<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 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-tertiary)" x-text="_formatDate(node.updated_at)"></td>
|
||||
</tr>
|
||||
@@ -2412,15 +2580,26 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
<th>Tags</th>
|
||||
</tr></thead>
|
||||
<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">
|
||||
<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 }">
|
||||
<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-path" x-text="node._path || '—'"></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-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-tags">
|
||||
<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 -->
|
||||
<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">
|
||||
<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 }">
|
||||
<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-meta">
|
||||
<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 -->
|
||||
<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">
|
||||
<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 }">
|
||||
<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 style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="_formatDate(node.updated_at)"></span>
|
||||
</div>
|
||||
@@ -2477,24 +2670,24 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
|
||||
<!-- Empty -->
|
||||
<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>
|
||||
<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">
|
||||
<button class="btn-icon" onclick="window.FlowDeck.createPage()" title="New file">📄</button>
|
||||
<button class="btn-icon" onclick="window.FlowDeck.showCreateFolderModal()" title="New folder">📁</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">{{ fd_icon("folder",16) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Offline banner -->
|
||||
<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>
|
||||
|
||||
<!-- Create Modal -->
|
||||
<div class="modal-overlay" x-show="showCreate" @click.outside="showCreate=false" @keydown.escape="showCreate=false" style="display:none">
|
||||
<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">
|
||||
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>
|
||||
@@ -2510,7 +2703,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
<!-- Rename Modal -->
|
||||
<div class="modal-overlay" x-show="showRename" @click.outside="showRename=false" @keydown.escape="showRename=false" style="display:none">
|
||||
<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>
|
||||
<input class="modal-input" x-model="newName" @keydown.enter="doRename" x-ref="rinp">
|
||||
<div class="modal-actions">
|
||||
@@ -2523,7 +2716,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
<!-- Delete Modal -->
|
||||
<div class="modal-overlay" x-show="showDelete" @click.outside="showDelete=false" @keydown.escape="showDelete=false" style="display:none">
|
||||
<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>
|
||||
<div class="delete-confirm">This cannot be undone. Children will also be deleted.</div>
|
||||
<div class="modal-actions">
|
||||
@@ -2535,45 +2728,42 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
|
||||
</div><!-- /ws-main -->
|
||||
|
||||
<!-- Context menu -->
|
||||
<div class="ctx-menu" x-data="_ctxMenuData" style="display:none"
|
||||
@click.outside="document.querySelector('.ctx-menu').style.display='none'"
|
||||
@keydown.escape="document.querySelector('.ctx-menu').style.display='none'"
|
||||
<!-- Context menu (Library-style) -->
|
||||
<div class="more-menu" x-data="_ctxMenuData" style="display:none"
|
||||
@click.outside="document.querySelector('.more-menu').style.display='none'"
|
||||
@keydown.escape="document.querySelector('.more-menu').style.display='none'"
|
||||
x-transition>
|
||||
<div class="ctx-item" @click="ctxMenuOpenPage()">📄 Open</div>
|
||||
<div class="ctx-item" x-show="ctxMenu.node && ctxMenu.node.is_folder" @click="ctxMenuOpenFolder()">📂 Open folder</div>
|
||||
<div class="ctx-item" @click="ctxMenuRename()">✏️ Rename</div>
|
||||
<div class="ctx-item" @click="ctxMenuDuplicate()">📋 Duplicate</div>
|
||||
<div class="ctx-sep"></div>
|
||||
<div class="ctx-section">🏷️ Tags</div>
|
||||
<!-- Current tags with remove button -->
|
||||
<div class="menu-item" @click="ctxMenuOpenPage()">{{ fd_icon("file",14) }} Open</div>
|
||||
<div class="menu-item" x-show="ctxMenu.node && ctxMenu.node.is_folder" @click="ctxMenuOpenFolder()">{{ fd_icon("folder",14) }} Open folder</div>
|
||||
<div class="menu-item" @click="ctxMenuRename()">{{ fd_icon("edit",14) }} Rename</div>
|
||||
<div class="menu-item" @click="ctxMenuDuplicate()">{{ fd_icon("copy",14) }} Duplicate</div>
|
||||
<div class="more-sep"></div>
|
||||
<!-- Current tags -->
|
||||
<template x-for="t in (ctxMenu.tags||[])" :key="'ctx-tag'+t.id">
|
||||
<div class="ctx-item ctx-tag-item">
|
||||
<span class="ctx-tag-color" :style="{background: t.color}"></span>
|
||||
<span style="flex:1;font-size:13px;" x-text="t.name"></span>
|
||||
<div class="menu-item" style="justify-content:space-between;">
|
||||
<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>
|
||||
<button class="tag-chip rm-btn" @click.stop="ctxRemoveTag(t.id)" title="Remove">×</button>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="(ctxMenu.tags||[]).length === 0" style="padding:2px 14px;font-size:11px;color:var(--text-dim);">No tags</div>
|
||||
<div class="ctx-sep"></div>
|
||||
<!-- Add existing tag → sub-menu -->
|
||||
<div class="ctx-item ctx-has-sub" @click.stop="ctxTagExistingOpen=!ctxTagExistingOpen" x-show="ctxAvailTags.length > 0">
|
||||
<span style="flex:1;">📋 Add existing tag</span>
|
||||
<span x-text="ctxTagExistingOpen ? '▾' : '▸'"></span>
|
||||
<div x-show="(ctxMenu.tags||[]).length === 0" style="padding:4px 10px;font-size:11px;color:var(--text-dim);">No tags</div>
|
||||
<div class="more-sep"></div>
|
||||
<!-- Add existing tag -->
|
||||
<div class="menu-item" @click.stop="ctxTagExistingOpen=!ctxTagExistingOpen" x-show="ctxAvailTags.length > 0">
|
||||
<span style="flex:1;">Add existing tag</span>
|
||||
<span x-text="ctxTagExistingOpen ? '▾' : '▸'" style="font-size:11px;"></span>
|
||||
</div>
|
||||
<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">
|
||||
<div class="ctx-item ctx-add-tag-item" @click.stop="ctxAddExistingTag(t), ctxTagExistingOpen=false">
|
||||
<span class="ctx-tag-color" :style="{background: t.color}"></span>
|
||||
<span style="flex:1;font-size:13px;" x-text="t.name"></span>
|
||||
<div class="menu-item" @click.stop="ctxAddExistingTag(t), ctxTagExistingOpen=false" style="justify-content:space-between;">
|
||||
<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="font-size:10px;color:var(--text-dim);" x-text="t.count||''"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Create new tag -->
|
||||
<div class="ctx-item ctx-has-sub" @click.stop="ctxTagAdding=!ctxTagAdding">
|
||||
<span style="flex:1;">➕ New tag</span>
|
||||
<span x-text="ctxTagAdding ? '▾' : '▸'"></span>
|
||||
<div class="menu-item" @click.stop="ctxTagAdding=!ctxTagAdding">
|
||||
<span style="flex:1;">{{ fd_icon("plus",14) }} New tag</span>
|
||||
<span x-text="ctxTagAdding ? '▾' : '▸'" style="font-size:11px;"></span>
|
||||
</div>
|
||||
<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;">
|
||||
@@ -2586,14 +2776,12 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
<input class="tag-input" x-ref="ctxTagInput"
|
||||
@keydown.enter="ctxAddNewTag($event.target.value), $event.target.value=''"
|
||||
@keydown.escape="ctxTagAdding=false"
|
||||
@click.stop
|
||||
placeholder="Tag name..."
|
||||
style="flex:1;">
|
||||
@click.stop 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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctx-sep"></div>
|
||||
<div class="ctx-item ctx-danger" @click="ctxMenuDelete()">🗑 Delete</div>
|
||||
<div class="more-sep"></div>
|
||||
<div class="menu-item menu-danger" @click="ctxMenuDelete()">{{ fd_icon("trash",14) }} Delete</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview tooltip -->
|
||||
@@ -2608,12 +2796,12 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
<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-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>
|
||||
<button class="preview-close" @click="previewItem=null" title="Close preview">×</button>
|
||||
</div>
|
||||
<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="_formatDate(previewItem.updated_at)"></span>
|
||||
</div>
|
||||
@@ -2624,9 +2812,9 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
</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">
|
||||
<button class="btn btn-secondary" @click="openPage(previewItem.id)">📄 Open</button>
|
||||
<button class="btn btn-secondary" @click="startRename(previewItem)">✏️ Rename</button>
|
||||
<button class="btn btn-danger" @click="deleteWithUndo(previewItem)">🗑 Delete</button>
|
||||
<button class="btn btn-secondary" @click="openPage(previewItem.id)">{{ fd_icon("file",14) }} Open</button>
|
||||
<button class="btn btn-secondary" @click="startRename(previewItem)">{{ fd_icon("edit",14) }} Rename</button>
|
||||
<button class="btn btn-danger" @click="deleteWithUndo(previewItem)">{{ fd_icon("trash",14) }} Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
{% 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 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' %}
|
||||
{% endblock %} {% block content %}
|
||||
<div class="page-editor-wrapper" x-data="editorState()">
|
||||
@@ -148,7 +148,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
@click="accessMenuOpen = !accessMenuOpen"
|
||||
>
|
||||
<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 class="chevron-down">▾</span>
|
||||
</button>
|
||||
@@ -163,7 +163,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
:class="{ active: generalAccess === 'invited' }"
|
||||
@click="generalAccess='invited'; accessMenuOpen=false"
|
||||
>
|
||||
<span>🔒 Only people invited</span>
|
||||
<span>{{ fd_icon('lock',14) }} Only people invited</span>
|
||||
<span class="sd-perm-desc"
|
||||
>People need to be invited</span
|
||||
>
|
||||
@@ -173,7 +173,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
:class="{ active: generalAccess === 'anyone' }"
|
||||
@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"
|
||||
>Can view the page</span
|
||||
>
|
||||
@@ -213,7 +213,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
>? Learn about sharing</a
|
||||
>
|
||||
<button class="sd-footer-action" @click="copyPageLink">
|
||||
🔗 Copy link
|
||||
{{ fd_icon("link",14) }} Copy link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,7 +229,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
<div class="sd-publish-preview">
|
||||
<div class="sd-preview-bar"></div>
|
||||
<div class="sd-preview-title">
|
||||
🌐 {{ page.title }}
|
||||
{{ fd_icon('globe',14) }} {{ page.title }}
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="sd-publish-heading">Publish to web</h3>
|
||||
@@ -280,26 +280,26 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
<!-- Settings list -->
|
||||
<div class="sd-settings">
|
||||
<div class="sd-setting-row">
|
||||
<span>🔗 Link expires</span>
|
||||
<span>{{ fd_icon("link",14) }} Link expires</span>
|
||||
<span style="color: var(--text-dim)"
|
||||
>Never ▾</span
|
||||
>
|
||||
</div>
|
||||
<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)"
|
||||
>Off ▾</span
|
||||
>
|
||||
</div>
|
||||
<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">
|
||||
<input type="checkbox" checked />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>✏️ Allow editing</span>
|
||||
<span>{{ fd_icon("edit",14) }} Allow editing</span>
|
||||
<label class="toggle-switch sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -309,7 +309,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>💬 Allow comments</span>
|
||||
<span>{{ fd_icon("message-square",14) }} Allow comments</span>
|
||||
<label class="toggle-switch sm">
|
||||
<input
|
||||
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="duplicatePage">
|
||||
📋 Duplicate
|
||||
{{ fd_icon("copy",14) }} Duplicate
|
||||
</div>
|
||||
<div class="more-menu-item" @click="movePage">↗ Move to</div>
|
||||
<div class="more-menu-sep"></div>
|
||||
<div class="more-menu-item danger" @click="deletePage">
|
||||
🗑️ Move to Trash
|
||||
{{ fd_icon("trash",14) }} Move to Trash
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -376,7 +376,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
<div class="move-ws-item"
|
||||
@click="doMove(ws.id)"
|
||||
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 style="font-size:11px;color:var(--text-tertiary);" x-text="ws.page_count + ' pages'"></span>
|
||||
</div>
|
||||
@@ -390,7 +390,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
<!-- ═══════════ Page Content ═══════════ -->
|
||||
<div class="page-cover-area">
|
||||
<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
|
||||
class="page-title-input"
|
||||
contenteditable="true"
|
||||
@@ -426,7 +426,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
<button @click="fmtApply('italic')"><em>I</em></button>
|
||||
<button @click="fmtApply('underline')"><u>U</u></button>
|
||||
<button @click="fmtApply('strikeThrough')"><s>S</s></button>
|
||||
<button @click="fmtLink()">🔗</button>
|
||||
<button @click="fmtLink()">{{ fd_icon("link",14) }}</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Copy Link Toast ═══════════ -->
|
||||
@@ -447,31 +447,31 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
class="gs-pill"
|
||||
@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
|
||||
class="gs-pill"
|
||||
@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
|
||||
class="gs-pill"
|
||||
@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
|
||||
class="gs-pill"
|
||||
@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
|
||||
class="gs-pill"
|
||||
@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>
|
||||
<div style="position: relative">
|
||||
<button class="gs-pill" @click="gsMoreOpen = !gsMoreOpen">
|
||||
@@ -484,26 +484,26 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
x-transition
|
||||
>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
📊 Table
|
||||
{{ fd_icon("bar-chart",14) }} Table
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
📋 Board
|
||||
{{ fd_icon("copy",14) }} Board
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
📝 List
|
||||
{{ fd_icon('list',14) }} List
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
📅 Timeline
|
||||
{{ fd_icon("calendar",14) }} Timeline
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
🗓️ Calendar
|
||||
{{ fd_icon("calendar",14) }} Calendar
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
🖼️ Gallery
|
||||
{{ fd_icon("image",14) }} Gallery
|
||||
</div>
|
||||
<div class="gs-more-sep"></div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
📥 Import
|
||||
{{ fd_icon("download",14) }} Import
|
||||
</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:'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:'callout',name:'Callout',icon:'💡'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'},
|
||||
]},{name:'MEDIA',items:[{id:'image',name:'Image',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:'image'}]}];
|
||||
|
||||
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;}
|
||||
@@ -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==='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.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'){
|
||||
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...';
|
||||
@@ -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>`;
|
||||
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==='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>`;
|
||||
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}`;
|
||||
|
||||
@@ -130,9 +130,9 @@
|
||||
|
||||
<header class="pub-header">
|
||||
<a href="/" class="pub-brand">
|
||||
<span>📚</span> FlowDeck
|
||||
<span>{{ fd_icon("book",20) }}</span> FlowDeck
|
||||
</a>
|
||||
<span class="pub-badge">🌐 Published</span>
|
||||
<span class="pub-badge">{{ fd_icon("globe",14) }} Published</span>
|
||||
</header>
|
||||
|
||||
<main class="pub-container">
|
||||
|
||||
+28
-28
@@ -1,10 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}Settings{% endblock %}
|
||||
{% block title_prefix %}Settings{% endblock %}
|
||||
{% block page_icon %}⚙️{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("settings",18) }}{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set page_icon = "⚙" %}
|
||||
{% set page_icon = "settings" %}
|
||||
{% set page_title = "Settings" %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
@@ -81,19 +81,19 @@
|
||||
<!-- Left navigation -->
|
||||
<div class="settings-nav">
|
||||
<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==='notifications' }" @click="activeSection='notifications'">🔔 Notifications</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'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</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==='tags' }" @click="activeSection='tags'">🏷️ Tags</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'">{{ fd_icon("tag",14) }} Tags</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 -->
|
||||
<template x-if="userIsAdmin">
|
||||
<div>
|
||||
<div class="settings-nav-header">Admin</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">👥 Users & 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-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users & Roles</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>
|
||||
</template>
|
||||
</div>
|
||||
@@ -113,11 +113,11 @@
|
||||
<span x-show="!avatarUrl">{{ user.get('full_name', user.get('login',''))[:1].upper() }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="avatar-upload-label">
|
||||
<label class="avatar-upload-label" style="margin-bottom:4px;">
|
||||
Upload photo
|
||||
<input type="file" class="avatar-upload-input" accept="image/*" @change="uploadAvatar($event)">
|
||||
</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 style="margin-top:8px;">
|
||||
@@ -151,7 +151,7 @@
|
||||
</div>
|
||||
<div style="margin-top:12px;">
|
||||
<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>
|
||||
</button>
|
||||
<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="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;">
|
||||
<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 style="margin-top:12px;">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -186,8 +186,8 @@
|
||||
</div>
|
||||
<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;">
|
||||
<option value="dark">🌙 Dark</option>
|
||||
<option value="light">☀️ Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,11 +198,11 @@
|
||||
</div>
|
||||
<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;">
|
||||
<option value="tree">🌳 Tree</option>
|
||||
<option value="list">📋 List</option>
|
||||
<option value="details">📊 Details</option>
|
||||
<option value="title">🏷️ Title</option>
|
||||
<option value="content">📝 Content</option>
|
||||
<option value="tree">{{ fd_icon("folder",14) }} Tree</option>
|
||||
<option value="list">{{ fd_icon("list",14) }} List</option>
|
||||
<option value="details">{{ fd_icon("bar-chart",14) }} Details</option>
|
||||
<option value="title">{{ fd_icon("grid",14) }} Title</option>
|
||||
<option value="content">{{ fd_icon("align-left",14) }} Content</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,7 +281,7 @@
|
||||
<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>"?
|
||||
<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>
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
@@ -317,7 +317,7 @@
|
||||
<!-- Gitea -->
|
||||
<div class="setting-row">
|
||||
<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">
|
||||
<span x-show="giteaLinked">Connected to git.dracodev.net</span>
|
||||
<span x-show="!giteaLinked">Connect your Gitea account to access projects</span>
|
||||
@@ -326,13 +326,13 @@
|
||||
<div class="setting-control">
|
||||
<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>
|
||||
<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>
|
||||
<!-- GitHub -->
|
||||
<div class="setting-row">
|
||||
<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">
|
||||
<span x-show="githubLinked">Connected to github.com</span>
|
||||
<span x-show="!githubLinked">Connect your GitHub account to access repositories</span>
|
||||
@@ -341,7 +341,7 @@
|
||||
<div class="setting-control">
|
||||
<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>
|
||||
<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>
|
||||
@@ -428,8 +428,8 @@
|
||||
</td>
|
||||
<td>
|
||||
<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 btn-sm-danger" @click="adminDeleteUser(u.id)" title="Delete" x-show="adminUsers.length > 1">🗑</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">{{ fd_icon("trash",14) }}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
<!-- Table View — Notion style -->
|
||||
<div id="view-content" x-data="tableView()">
|
||||
<div class="table-container">
|
||||
@@ -9,23 +10,23 @@
|
||||
<span x-show="sortField === 'name'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||
</th>
|
||||
<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>
|
||||
</th>
|
||||
<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>
|
||||
</th>
|
||||
<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>
|
||||
</th>
|
||||
<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>
|
||||
</th>
|
||||
<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>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
<!-- Team Load — Notion-style stacked bar chart -->
|
||||
<div id="view-content" x-data="teamLoad()" class="team-load">
|
||||
{% for member in team_data %}
|
||||
@@ -29,7 +30,7 @@
|
||||
|
||||
{% if not team_data %}
|
||||
<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>
|
||||
<p style="margin-top:8px;">Assign issues to team members to see their workload.</p>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}🗑️{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("trash",18) }}{% endblock %}
|
||||
{% block page_title %}Trash{% endblock %}
|
||||
{% block title_prefix %}Trash{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">🗑️</span>
|
||||
<span class="page-icon-lg">{{ fd_icon("trash",24) }}</span>
|
||||
<h1>Trash</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -14,7 +14,7 @@
|
||||
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
|
||||
<!-- Search -->
|
||||
<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"
|
||||
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>
|
||||
@@ -22,10 +22,10 @@
|
||||
<!-- Filters -->
|
||||
<div style="display:flex; gap:8px; margin-bottom:16px;">
|
||||
<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 class="trash-filter">
|
||||
<span>📁</span> In ▾
|
||||
<span>{{ fd_icon("folder",14) }}</span> In ▾
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -42,12 +42,12 @@
|
||||
↩️
|
||||
</button>
|
||||
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
|
||||
🗑️
|
||||
{{ fd_icon("trash",14) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<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>
|
||||
<p>Deleted pages will appear here for 30 days.</p>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}Workspace{% endblock %}
|
||||
{% block title_prefix %}Workspace{% endblock %}
|
||||
{% block page_icon %}🏠{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("home",18) }}{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set page_icon = "🏠" %}
|
||||
{% set page_icon = "home" %}
|
||||
{% 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' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<div class="workspace-layout" x-data="workspacePage()">
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<template x-for="p in builtinProjects" :key="p.id">
|
||||
<div class="project-card" @click="openProject(p)">
|
||||
<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>
|
||||
</div>
|
||||
<div class="project-card-name" x-text="p.name"></div>
|
||||
@@ -80,12 +80,12 @@
|
||||
|
||||
<!-- Gitea Projects -->
|
||||
<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">
|
||||
<template x-for="p in giteaProjects" :key="p.id">
|
||||
<div class="project-card" @click="openProject(p)">
|
||||
<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>
|
||||
</div>
|
||||
<div class="project-card-name" x-text="p.name"></div>
|
||||
@@ -101,12 +101,12 @@
|
||||
|
||||
<!-- GitHub Projects -->
|
||||
<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">
|
||||
<template x-for="p in githubProjects" :key="p.id">
|
||||
<div class="project-card" @click="openProject(p)">
|
||||
<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>
|
||||
</div>
|
||||
<div class="project-card-name" x-text="p.name"></div>
|
||||
@@ -179,7 +179,7 @@ function workspacePage() {
|
||||
});
|
||||
if (r.ok) {
|
||||
const p = await r.json();
|
||||
this.builtinProjects.unshift({...p, icon: '📁', pageCount: 0});
|
||||
this.builtinProjects.unshift({...p, icon: 'folder', pageCount: 0});
|
||||
this.newProjectName = '';
|
||||
this.showCreateModal = false;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}Workspaces{% endblock %}
|
||||
{% block title_prefix %}Workspaces{% endblock %}
|
||||
{% block page_icon %}🏠{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("home",18) }}{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %}
|
||||
{% set page_icon = "🏠" %}
|
||||
{% 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 page_icon = "home" %}
|
||||
{% 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' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -51,39 +51,39 @@
|
||||
<div class="ws-page" x-data="workspacesPage()">
|
||||
|
||||
<div class="ws-header">
|
||||
<h1>🏠 Workspaces</h1>
|
||||
<h1>{{ fd_icon("home",20) }} Workspaces</h1>
|
||||
</div>
|
||||
|
||||
<!-- ── Local Workspaces ── -->
|
||||
<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">
|
||||
<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-name" x-text="ws.name"></div>
|
||||
<div class="ws-card-count" x-text="ws.page_count+' page(s)'"></div>
|
||||
<div class="ws-card-actions" @click.stop>
|
||||
<button class="btn-sm" @click="renameWs(ws)">✏️</button>
|
||||
<button class="btn-sm" style="color:var(--danger);border-color:rgba(255,80,80,.3)" @click="deleteWs(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)">{{ fd_icon("trash",14) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="ws-card btn-new" @click="showCreate=true">
|
||||
<span style="font-size:32px">+</span>
|
||||
<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:48px;line-height:1;">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Gitea Projects ── -->
|
||||
<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 -->
|
||||
<div class="gitea-empty" x-show="giteaStatus==='not_linked'">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Loading skeletons -->
|
||||
@@ -99,7 +99,7 @@
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
</template>
|
||||
</div>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Project cards (filtered) -->
|
||||
@@ -139,7 +139,7 @@
|
||||
<span x-text="proj.name"></span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
+671
-721
File diff suppressed because it is too large
Load Diff
-210
@@ -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
@@ -278,9 +278,12 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
||||
padding: 7px 14px; font-size: 13px; color: var(--text);
|
||||
text-decoration: none; cursor: pointer; transition: background 100ms;
|
||||
}
|
||||
.um-item svg { flex-shrink: 0; }
|
||||
.um-item:hover { background: var(--bg-hover); }
|
||||
.um-danger { color: var(--danger); }
|
||||
.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 {
|
||||
display: flex;
|
||||
@@ -510,6 +513,21 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
||||
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 {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -666,7 +684,8 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
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;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
@@ -2763,7 +2782,7 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
position: absolute; top: calc(100% + 4px); right: 0;
|
||||
background: var(--bg-modal); border: 1px solid var(--border);
|
||||
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 {
|
||||
padding: 8px 14px; font-size: 13px; color: var(--text-primary);
|
||||
|
||||
+376
-4
@@ -1950,11 +1950,10 @@ def test_create_page_defaults_to_untitled(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)
|
||||
assert resp.status_code == 404
|
||||
assert "404" in resp.text
|
||||
assert "FlowDeck" in resp.text
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers.get("location") == "/workspaces"
|
||||
|
||||
|
||||
def test_api_404_returns_json(client):
|
||||
@@ -2003,3 +2002,376 @@ def test_session_expired_redirect(client):
|
||||
location = resp.headers.get("location", "")
|
||||
assert "login" 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"] == []
|
||||
|
||||
Reference in New Issue
Block a user