Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34a0438764 | ||
|
|
e92c788e3d | ||
|
|
56682cc576 | ||
|
|
d8a2cebdd6 | ||
|
|
a43dcfcb9f | ||
|
|
5517dc83a8 | ||
|
|
0c70e320e0 | ||
|
|
6f06c232ba | ||
|
|
b377424412 | ||
|
|
446ef0dfbf | ||
|
|
ab8d7ce9c8 | ||
|
|
df44285df6 | ||
|
|
335120c200 | ||
|
|
f6aaaa123f | ||
|
|
8497a36da9 | ||
|
|
fc6e30aebb | ||
|
|
a8f9832535 | ||
|
|
08d2bac793 | ||
|
|
d97f79f737 | ||
|
|
51cad3ee16 | ||
|
|
5e3bcdcd63 | ||
|
|
47299113a8 | ||
|
|
722c7103de | ||
|
|
8bd737cef6 | ||
|
|
37b1a7b078 | ||
|
|
c59f38659e | ||
|
|
2314af9825 | ||
|
|
4be4c3432b | ||
|
|
4810ff18eb | ||
|
|
e9a419b83f | ||
|
|
54abe8ac3b | ||
|
|
c5398757ca | ||
|
|
e2a739c4c6 | ||
|
|
b01c5bbfe3 | ||
|
|
46e7c1d815 | ||
|
|
1e0afb0e69 | ||
|
|
05369f1856 | ||
|
|
418fe19f33 | ||
|
|
f8350f8161 | ||
|
|
c36a446451 | ||
|
|
1ab5b1271f | ||
|
|
373153de69 | ||
|
|
2f529e4d57 | ||
|
|
b8fa5f4d27 | ||
|
|
a531d6d466 | ||
|
|
2ca7834b43 | ||
|
|
3363a070cf | ||
|
|
0c54db66ac | ||
|
|
eddb69e58e | ||
|
|
1639884800 | ||
|
|
1ae4aa4ab4 | ||
|
|
ae0b964859 | ||
|
|
3e6323a856 | ||
|
|
eb417d4ed5 | ||
|
|
9542353b55 | ||
|
|
3417506062 | ||
|
|
47463a62e0 | ||
|
|
237914dfcd | ||
|
|
f28a80dc95 | ||
|
|
9103ee94fa | ||
|
|
4b45f52321 | ||
|
|
fc8012009a | ||
|
|
be73e08184 | ||
|
|
21e7e30b61 | ||
|
|
7f5ad2aaca | ||
|
|
db8d577c09 | ||
|
|
0593c588e9 | ||
|
|
608d44c33a | ||
|
|
1f26d61997 | ||
|
|
d4dcb06aee | ||
|
|
d3ace7f3ab | ||
|
|
32c1f70c53 | ||
|
|
246aef65ac | ||
|
|
ca144e29d1 | ||
|
|
e0102d2617 | ||
|
|
806ec495f0 | ||
|
|
f87aaa7ead | ||
|
|
be98f9d504 | ||
|
|
79b0bdd22d | ||
|
|
5850085c8b | ||
|
|
4f0bd85e35 | ||
|
|
db6acf0ec3 | ||
|
|
f7d9d91723 | ||
|
|
6033a0b1e4 | ||
|
|
c75cdab134 | ||
|
|
fe4e28e23c | ||
|
|
4a58261c02 | ||
|
|
124bb3cd9f | ||
|
|
ed1b5d4193 | ||
|
|
d92e26f01b | ||
|
|
6015a415d4 | ||
|
|
65fcda0ca0 | ||
|
|
b1304b76e8 | ||
|
|
85d983c56b | ||
|
|
f5fedf1d7b | ||
|
|
b2426a3504 | ||
|
|
0de4f411bd | ||
|
|
fbf0335c3a | ||
|
|
a251ccfe2d | ||
|
|
3ed2181e89 | ||
|
|
c86c04ca22 | ||
|
|
6b67b25d27 | ||
|
|
d940481a6f | ||
|
|
8aaf1676c0 | ||
|
|
7c8f8d719e | ||
|
|
80f56acf4c | ||
|
|
cd854e1dfe | ||
|
|
f752ae2ed7 | ||
|
|
ad95c87b01 | ||
|
|
4a6ed24315 | ||
|
|
e725398543 | ||
|
|
b8647f1a19 | ||
|
|
16532d10bd | ||
|
|
d94817bac5 | ||
|
|
c574d2c8b5 | ||
|
|
b9723dffab | ||
|
|
3967c7df85 | ||
|
|
66ae1289a9 | ||
|
|
1dea15ad90 | ||
|
|
f04003f060 | ||
|
|
780cccf79f | ||
|
|
c02e00726e | ||
|
|
a585587e4c | ||
|
|
8aa1a703b5 | ||
|
|
205f09838f | ||
|
|
c8c08f5589 | ||
|
|
d4cd45c6cb | ||
|
|
06cdec52fa | ||
|
|
755404fbb0 | ||
|
|
c1dba816de | ||
|
|
aa3d5d87c8 | ||
|
|
4c49d43c66 | ||
|
|
b826cfc247 | ||
|
|
c00442c693 | ||
|
|
1d2c2c0919 | ||
|
|
92dad6db1f | ||
|
|
4eaec52d66 | ||
|
|
bed326ee9b | ||
|
|
e3180c6255 | ||
|
|
24e979a05f | ||
|
|
b10118eddc | ||
|
|
a5e91cf8c7 | ||
|
|
b50d920c36 |
@@ -8,3 +8,10 @@ venv/
|
||||
dist/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Understand-Anything: exclude intermediate files and local diff overlay
|
||||
.ua/intermediate/
|
||||
.ua/diff-overlay.json
|
||||
.ua/tmp/
|
||||
.ua/.trash-*/
|
||||
.ua/.understandignore
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"outputLanguage":"fr"}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"lastAnalyzedAt": "2026-07-10T12:53:39Z",
|
||||
"gitCommitHash": "c86c04ca220672fb012bb69916e930102be44ef4",
|
||||
"version": "1.0.0",
|
||||
"analyzedFiles": 67
|
||||
}
|
||||
@@ -1,32 +1,95 @@
|
||||
# Changelog — FlowDeck
|
||||
|
||||
## v2.1.1 (2026-07-12) — Fix Arborescence Workspace
|
||||
|
||||
### Fixed
|
||||
- **Arborescence**: création fichier/dossier dans un sous-dossier met à jour l'arbre sans rechargement
|
||||
- **parentFolder**: corrigé le bug où `parentFolder` était nullifié avant l'insertion dans l'arbre JS
|
||||
- **displayTree**: nouvelle référence array après chaque mutation pour forcer le re-render Alpine.js
|
||||
- **doSort/doFilter**: gèrent maintenant le cas où un filtre/tri est actif après création
|
||||
- **_doUpload**: corrigé `this._reloadAfterAction` → `self._reloadAfterAction`
|
||||
|
||||
## v2.1.0 (2026-07-10) — Intégrations Avancées
|
||||
|
||||
### Added
|
||||
- **API publique**: `/api/v1` (collections, pages, my-tasks) avec token auth
|
||||
- **Token generation**: `POST /api/v1/token`
|
||||
- **Webhooks sortants**: `GET/POST/DELETE /workspace/webhooks` + dispatcher
|
||||
- **PWA**: `GET /manifest.json`
|
||||
- **CSRF**: `/api/v1` exempté
|
||||
|
||||
### Tests
|
||||
- 73/73 passent (+6 tests v2.1)
|
||||
|
||||
## v2.0.0 (2026-07-10) — Multi-User Workspaces & Editor Complete
|
||||
|
||||
### Added
|
||||
- **Workspaces**: tables `workspaces` + `workspace_members`, CRUD API, roles admin/editor/commenter/viewer
|
||||
- **Comments**: table `comments` avec thread (parent_id), resolved
|
||||
- **Page History**: table `page_history`, snapshot JSON
|
||||
- **Favorites**: table `favorites`, add/remove/list
|
||||
- **Templates**: `database_templates` + `page_templates`, apply API
|
||||
- **CSV Import/Export**: `POST .../import/csv`, `GET .../export/csv`
|
||||
- **Public Sharing**: `GET /workspace/public/{id}` (read-only, no auth)
|
||||
|
||||
### Tests
|
||||
- 67/67 passent (+7 tests v2.0)
|
||||
|
||||
## v1.9.0 (2026-07-10) — My Tasks Dashboard
|
||||
|
||||
### Added
|
||||
- **My Tasks**: `GET /my-tasks` (HTML), `GET /my-tasks/api` (JSON)
|
||||
- Agrégation cross-collection, vues All/Today/Overdue/Next 7 days
|
||||
|
||||
## v1.8.0 (2026-07-10) — Sub-items & Dependencies
|
||||
|
||||
### Added
|
||||
- **Sub-items**: parent_id auto-référence, GET/POST sub-items
|
||||
- **Status aggregate**: `GET .../status-aggregate`
|
||||
- **Dependencies**: `POST .../dependencies`, `POST .../check-deps`
|
||||
|
||||
## v1.7.0 (2026-07-10) — Vues Améliorées
|
||||
|
||||
### Added
|
||||
- **View config**: `PUT /db/views/{id}/config` (group_by, card_size, cover, visible)
|
||||
- **Save view as**: `POST /db/{id}/views/save-as`
|
||||
- **List views**: `GET /db/{id}/views/api`
|
||||
|
||||
## v1.6.0 (2026-07-10) — Vues Manquantes
|
||||
|
||||
### Added
|
||||
- **Calendar**: grid mensuel, navigation mois
|
||||
- **Gallery**: cartes visuelles, card_size configurable
|
||||
- **List**: vue compacte avec preview
|
||||
- **Timeline**: barres horizontales Gantt
|
||||
- **Table**: rendu SSR avec colonnes properties
|
||||
- **View tabs**: navigation Table/Board/Calendar/Gallery/List/Timeline
|
||||
|
||||
## v1.5.0 (2026-07-10) — Relations & Rollups
|
||||
|
||||
### Added
|
||||
- **FormulaEngine**: 19 fonctions (prop, if, concat, round, now, today, dateAdd, replace, ...)
|
||||
- **RollupEngine**: 12 agrégations (count, sum, avg, min, max, range, unique, percent_checked)
|
||||
- **Relation**: lien bidirectionnel, API create/link
|
||||
|
||||
## v1.4.0 (2026-07-10) — Propriétés Avancées
|
||||
|
||||
### Added
|
||||
- **Table `collection_properties`**: 21 types Notion
|
||||
- **Service `property_types.py`**: validation, formatage, auto-values
|
||||
|
||||
## v1.3.0 (2026-07-10) — Database Concept
|
||||
|
||||
### Added
|
||||
- **Tables**: `collections`, `collection_pages`, `collection_views`
|
||||
- **Router `/db`**: CRUD collections + pages (16 endpoints)
|
||||
- **GiteaBoardCompat**: adaptateur boards legacy → Collections
|
||||
|
||||
## v1.0.0 (2026-07-08) — Production
|
||||
|
||||
### Added
|
||||
- **UI Notion-complete**: sidebar 240px, topbar 44px, dark mode (#191919/#222/#333)
|
||||
- **5 vues**: Kanban, Detailed Board, Table, Status Overview (SVG donut), Team Load (stacked bars)
|
||||
- **Filtres cumulables AND** avec status dropdown checkboxes, filtres par assignee/tag
|
||||
- **Tri multi-critères** hiérarchique (N niveaux, asc/desc)
|
||||
- **Card detail modal**: titre éditable, propriétés, checklists, commentaires Gitea
|
||||
- **Création d'issues inline** depuis le board
|
||||
- **Propriétés custom** par projet: select, multi_select, date, person, text
|
||||
- **AI keywords**: extraction automatique depuis labels + body, table `ai_keywords`
|
||||
- **Sync API**: `POST /board/api/sync/{owner}/{repo}` — bidirectionnelle complète
|
||||
- **CI/CD**: Gitea Actions workflow
|
||||
- **Tests**: 25 tests, couvrant toutes les vues, APIs, filtres, tris
|
||||
|
||||
### Changed
|
||||
- FastAPI `on_event` → `lifespan` handler (deprecation fix)
|
||||
- Version bump 0.3.0 → 1.0.0
|
||||
- CSS: 31 KB design system Notion (700+ lignes)
|
||||
- 10 templates Jinja2 (base, board, card, detail, table, status, team, detailed, fragment, dashboard)
|
||||
|
||||
### Fixed
|
||||
- Team Load bug: status key mismatch (done → complete)
|
||||
- CSRF: 403 protection vérifiée sur toutes les routes POST
|
||||
|
||||
## v0.3.0 (2026-06-??)
|
||||
- OAuth2 Gitea, CSRF, rate limiting, webhooks
|
||||
|
||||
## v0.2.0 (2026-05-??)
|
||||
- Dashboard + Board Kanban + Gitea sync + Notes + Docker
|
||||
- FastAPI lifespan, 28 tests, CI/CD Gitea Actions, CSS 31KB, 10 templates
|
||||
- 5 vues: Kanban, Table, Status Overview, Team Load, Detailed Board
|
||||
- Filtres cumulables AND, tri multi-critères
|
||||
- Card detail modal, checklists, commentaires Gitea
|
||||
- Propriétés custom, AI keywords, sync API
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# FlowDeck
|
||||
|
||||
Clone de l'interface **Notion** intégré nativement à **Gitea** — Kanban, Table, Status Overview, Team Load.
|
||||
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
|
||||
|
||||
> **v0.4.0-dev** — Refonte UI Notion-style (Dark Mode, sidebar, topbar, multi-vues)
|
||||
> **v2.1.0** — API publique, Webhooks sortants, PWA
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -13,43 +13,65 @@ docker compose up -d
|
||||
# → http://localhost:8080
|
||||
```
|
||||
|
||||
## Features (v0.4.0)
|
||||
## Features
|
||||
|
||||
### UI Notion-Style
|
||||
- **Sidebar gauche** avec sections hiérarchiques (Home, Recents, Private, Library)
|
||||
- **Topbar** avec titre de page, statut, boutons d'action (Share, favori, notifications)
|
||||
- **Dark mode** Notion: `#191919` fond, `#222222` sidebar, `#333333` actif
|
||||
- **Menu contextuel** (clic droit): Copy link, Duplicate, Rename, Move to Trash...
|
||||
- **Hover states** subtils avec icônes d'action qui apparaissent
|
||||
- **Typographie Inter**, coins arrondis 4-6px
|
||||
### Database & Properties (v1.3–v1.5)
|
||||
- **Collections**: databases indépendantes de Gitea, schéma JSON
|
||||
- **21 types de propriétés**: title, text, number, select, status, date, person, checkbox, url, email, phone, files, unique_id, relation, rollup, formula + auto-props
|
||||
- **Relations bidirectionnelles** + **Rollups** (12 agrégations) + **Formulas** (19 fonctions)
|
||||
- **GiteaBoardCompat**: adaptateur boards Gitea legacy → Collections
|
||||
|
||||
### Vues Multiples
|
||||
- **Kanban board**: colonnes To-do/In progress/Complete avec sous-groupes (Design, Engineering)
|
||||
- **Table view**: colonnes Name, Status, Assign, Deadline, Team, AI keywords
|
||||
- **Status overview**: Donut chart avec segments, compteurs, pourcentages
|
||||
- **Team Load**: Barres empilées par membre d'équipe
|
||||
- **Detailed board**: cartes avec toutes les propriétés visibles
|
||||
### Vues Multiples (v1.6–v1.7)
|
||||
- **Table**: colonnes SSR, triables
|
||||
- **Board (Kanban)**: colonnes, groupes, drag & drop
|
||||
- **Calendar**: grid mensuel, navigation, événements
|
||||
- **Gallery**: cartes visuelles, card_size configurable
|
||||
- **List**: vue compacte avec preview
|
||||
- **Timeline**: barres Gantt horizontales
|
||||
- **Status Overview**: Donut chart SVG
|
||||
- **Team Load**: Barres empilées
|
||||
|
||||
### Filtres & Tri
|
||||
- **Filtres cumulables** (logique AND) avec pastilles cliquables
|
||||
- **Tri multi-critères** hiérarchique
|
||||
- **Filtre par statut**: checkboxes multiples avec points colorés
|
||||
- **Filtre AI keywords**: recherche + sélection multiple
|
||||
### Sub-items & Dépendances (v1.8)
|
||||
- **Sub-items**: parent_id auto-référence, hiérarchie illimitée
|
||||
- **Status aggregate**: parent = Done si tous enfants Done
|
||||
- **Dependencies**: Blocking/Blocked by, contrainte de transition
|
||||
|
||||
### My Tasks (v1.9)
|
||||
- Dashboard cross-collection agrégeant toutes les tâches assignées
|
||||
- Vues: All, Today, Overdue, Next 7 days
|
||||
|
||||
### Multi-User & Collaboratif (v2.0)
|
||||
- **Workspaces**: espaces partagés avec rôles (admin, editor, commenter, viewer)
|
||||
- **Comments**: commentaires threadés sur les pages
|
||||
- **Page History**: historique des modifications avec snapshots
|
||||
- **Favorites**: favoris par utilisateur
|
||||
- **Templates**: database + page templates
|
||||
- **CSV Import/Export**
|
||||
- **Public Sharing**: lien de partage lecture seule
|
||||
|
||||
### API & Intégrations (v2.1)
|
||||
- **API publique REST**: `/api/v1` avec token auth
|
||||
- **Webhooks sortants**: gestion + dispatcher d'événements
|
||||
- **PWA**: manifest.json, prêt pour installation mobile
|
||||
|
||||
### UI Notion-Style (v1.1–v1.2)
|
||||
- Sidebar gauche avec sections hiérarchiques
|
||||
- Topbar avec breadcrumbs (workspace > projet > page)
|
||||
- Dark mode Notion: `#191919` fond, `#222222` sidebar
|
||||
- Éditeur de blocs: slash menu (/), navigation clavier, placeholders
|
||||
|
||||
### Intégration Gitea
|
||||
- **Issues Gitea → cartes** Notion-style
|
||||
- Sync labels, milestones, assignees, due dates
|
||||
- OAuth2 Gitea, CSRF, rate limiting
|
||||
- Webhooks pour sync temps réel
|
||||
- Issues Gitea → cartes, sync labels, milestones, assignees, due dates
|
||||
- OAuth2 Gitea, CSRF, rate limiting, webhooks
|
||||
|
||||
## Stack
|
||||
|
||||
| Couche | Techno |
|
||||
|--------|--------|
|
||||
| Frontend | HTML5 + Jinja2 + CSS Notion, Alpine.js, HTMX, SortableJS |
|
||||
| Frontend | Jinja2 + HTMX + Alpine.js + SortableJS + CSS |
|
||||
| Backend | Python 3.12 + FastAPI + httpx |
|
||||
| BDD | SQLite (WAL mode) — `/data/flowdeck.db` |
|
||||
| Déploiement | Docker (Python 3.12-slim), docker-compose |
|
||||
| BDD | SQLite (WAL, 21 tables) — `/data/flowdeck.db` |
|
||||
| Déploiement | Docker (python:3.12-slim), docker-compose |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -60,19 +82,16 @@ APP_PORT=8080
|
||||
DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
Voir [ROADMAP.md](ROADMAP.md) — v0.4.0 UI Notion → v1.0.0 Production
|
||||
|
||||
## Développement
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --port 8080
|
||||
python3 -m pytest tests/ -v # 73/73 passent
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
Voir [ROADMAP.md](ROADMAP.md) — 7/7 blocs complétés, 52/52 features ✅
|
||||
|
||||
## Licence
|
||||
|
||||
MIT
|
||||
|
||||
@@ -3,34 +3,325 @@
|
||||
> **Vision**: Copie conforme de l'UI/UX Notion, intégrée à Gitea.
|
||||
> **19 images de référence** dans `images/` — analysées le 2026-07-08.
|
||||
|
||||
## v0.2.0 ✅ — Base
|
||||
## Completed
|
||||
|
||||
### v0.2.0 ✅ — Base
|
||||
Dashboard + Board Kanban + Gitea sync + Notes + Docker
|
||||
|
||||
## v0.3.0 ✅ — Auth
|
||||
### v0.3.0 ✅ — Auth
|
||||
OAuth2 Gitea, token par utilisateur, CSRF, rate limiting, webhooks
|
||||
|
||||
## v0.4.0 ✅ — UI Notion
|
||||
### v0.4.0 ✅ — UI Notion
|
||||
Layout 3 zones (sidebar 240px + topbar 44px), dark mode, CSS 31KB, Alpine.js
|
||||
|
||||
## v0.5.0 ✅ — Kanban
|
||||
### v0.5.0 ✅ — Kanban
|
||||
Colonnes, groupes, cartes enrichies, drag&drop, modale détail, checklists, commentaires
|
||||
|
||||
## v0.6.0 ✅ — Table View
|
||||
### v0.6.0 ✅ — Table View
|
||||
6 colonnes triables, groupes rétractables
|
||||
|
||||
## v0.7.0 ✅ — Filtres & Tri
|
||||
### v0.7.0 ✅ — Filtres & Tri
|
||||
Filtres cumulables AND, status dropdown, sort multi-critères
|
||||
|
||||
## v0.8.0 ✅ — Vues Spéciales
|
||||
### v0.8.0 ✅ — Vues Spéciales
|
||||
Status Overview (donut SVG), Team Load (barres), Detailed Board
|
||||
|
||||
## v0.9.0 ✅ — Backend
|
||||
### v0.9.0 ✅ — Backend
|
||||
Propriétés custom, AI keywords, sync API, 12 tables DB
|
||||
|
||||
## v1.0.0 ✅ — Production
|
||||
### v1.0.0 ✅ — Production
|
||||
- [x] FastAPI lifespan (deprecation fix)
|
||||
- [x] 28 tests — 5 vues, filtres, tris, APIs, CSRF, CORS, DB
|
||||
- [x] CI/CD Gitea Actions (`.gitea/workflows/ci.yml`)
|
||||
- [x] CHANGELOG.md, ARCHITECTURE.md à jour
|
||||
- [x] Version 1.0.0 partout (main.py, api.py, health)
|
||||
- [x] Tag git v1.0.0
|
||||
|
||||
### v1.1.0 ✅ — Pages & Sidebar
|
||||
- [x] Pages Markdown avec arbre imbriqué (parent_id, sort_order)
|
||||
- [x] Block editor mode (content_format='blocks', JSON storage)
|
||||
- [x] Soft delete → Trash → Restore → Permanent delete
|
||||
- [x] Drag & drop réorganisation pages
|
||||
- [x] Sidebar Notion-style avec sections Recents/Private/Shared
|
||||
- [x] "+ New page" footer button
|
||||
- [x] Workspace header avec avatar + chevron toggle
|
||||
|
||||
### v1.2.0 ✅ — Éditeur Notion
|
||||
- [x] Slash menu (/) 15 commandes fonctionnelles
|
||||
- [x] Navigation clavier (flèches, Enter, Escape)
|
||||
- [x] Placeholders Notion-style (Heading 1/2/3/4, Toggle, Quote, Code)
|
||||
- [x] Suppression outline blanc sur les blocs
|
||||
- [x] Heading 4 support
|
||||
- [x] Breadcrumbs contextuels (workspace > projet > page)
|
||||
|
||||
### v1.3.0 ✅ — Database Concept (Bloc 1 du gap analysis)
|
||||
### (2026-07-10, commit c574d2c, tag v1.3.0)
|
||||
|
||||
- [x] **Table `collections`** — base de données abstraite, indépendante de Gitea
|
||||
- `id`, `name`, `description`, `icon`, `schema_json`
|
||||
- Optionnellement liée à Gitea (`gitea_owner`, `gitea_repo`)
|
||||
- [x] **Table `collection_pages`** — remplace `cards`, pages génériques
|
||||
- `collection_id`, `title`, `position`, `parent_id`, `property_values_json`
|
||||
- `gitea_issue_id` optionnel, auto-propriétés (`created_at`, `updated_at`)
|
||||
- [x] **Table `collection_views`** — vues configurables par collection
|
||||
- `name`, `view_type`, `config_json`, `position`
|
||||
- [x] **Rétrocompatibilité Gitea** — adaptateur `GiteaBoardCompat`
|
||||
- Les boards Gitea existants deviennent des collections
|
||||
- Migration transparente `cards` → `collection_pages`
|
||||
- Routes `/db/boards/api`, `/db/board/{o}/{r}/api`, `/db/board/{o}/{r}/sync`
|
||||
- [x] **Router `/db`** — API CRUD collections + pages (12 endpoints)
|
||||
- [x] **34/34 tests** passent (+6 nouveaux tests v1.3)
|
||||
- [x] **Database lock** — mode lecture seule (`collections.is_locked`)
|
||||
- [x] **Database description** — champ `description` sur les collections
|
||||
|
||||
---
|
||||
|
||||
### v1.4.0 ✅ — Propriétés Avancées (Bloc 2)
|
||||
### (2026-07-10)
|
||||
|
||||
- [x] **Table `collection_properties`** — remplace `project_properties`
|
||||
- Liée à `collections` (pas à `project_owner/name`)
|
||||
- Supporte tous les types Notion (21 types)
|
||||
- [x] **Type `number`** — avec formatage (nombre, %, €, $, £, ¥)
|
||||
- [x] **Type `checkbox`** — booléen natif
|
||||
- [x] **Type `url`** — lien cliquable
|
||||
- [x] **Type `email`** — email cliquable
|
||||
- [x] **Type `phone`** — téléphone cliquable
|
||||
- [x] **Type `status`** — select avec couleurs forcées (9 couleurs Notion)
|
||||
- [x] **Type `files`** — uploads/images attachés aux pages
|
||||
- [x] **Type `unique_id`** — ID auto-incrémenté par collection
|
||||
- [x] **Type `created_time` / `created_by`** — auto-propriétés
|
||||
- [x] **Type `last_edited_time` / `last_edited_by`** — auto-propriétés
|
||||
- [x] **Service `property_types.py`** — validation, formatage, auto-values
|
||||
- [x] **API CRUD** — `/db/property-types/api` + CRUD `/db/{id}/properties/api`
|
||||
- [x] **38/38 tests** passent (+4 nouveaux tests v1.4)
|
||||
- [ ] **Migration** `project_properties` → `collection_properties` (v1.5)
|
||||
|
||||
### v1.5.0 ✅ — Relations & Rollups (Bloc 2 suite)
|
||||
### (2026-07-10)
|
||||
|
||||
- [x] **Type `relation`** — lien bidirectionnel entre collections
|
||||
- `related_collection_id`, `reverse_name` auto-généré
|
||||
- Stockage : JSON array de page IDs dans `property_values_json`
|
||||
- API: `POST /db/{id}/properties/relation` (création avec reverse)
|
||||
- API: `POST /db/{id}/properties/relation/link` (lier 2 pages)
|
||||
- [x] **Type `rollup`** — agrégation via relation
|
||||
- Functions : COUNT, SUM, AVG, MIN, MAX, RANGE, UNIQUE, PERCENT_CHECKED
|
||||
- API: `POST /db/rollup/compute`
|
||||
- [x] **Type `formula`** — propriétés calculées
|
||||
- Moteur d'expressions JavaScript-like (19 fonctions)
|
||||
- API: `POST /db/formula/evaluate`
|
||||
- [x] **Formula engine** — `services/formula_engine.py`
|
||||
- [x] **Rollup engine** — `services/rollup_engine.py`
|
||||
- [x] **43/43 tests** passent (+5 nouveaux tests v1.5)
|
||||
- [x] **FKs fix**: `related_collection_id`, `relation_property_id`, `target_property_id` → `ON DELETE SET NULL`
|
||||
|
||||
### v1.6.0 ✅ — Vues Manquantes (Bloc 3)
|
||||
### (2026-07-10)
|
||||
|
||||
- [x] **Calendar view** — grid mensuel, navigation mois, événements par date
|
||||
- Route: `GET /db/{id}/view/calendar`
|
||||
- [x] **Gallery view** — cartes visuelles avec cover image optionnelle
|
||||
- `card_size`: small/medium/large, `cover_property` configurable
|
||||
- [x] **List view** — liste compacte avec preview propriétés
|
||||
- [x] **Timeline/Gantt view** — barres horizontales sur axe date
|
||||
- [x] **Table view** — rendu SSR, colonnes properties
|
||||
- [x] **View tabs** — navigation entre vues (Table/Board/Calendar/Gallery/List/Timeline)
|
||||
- [x] **49/49 tests** passent (+6 nouveaux tests v1.6)
|
||||
|
||||
### v1.7.0 ✅ — Vues Améliorées
|
||||
### v1.8.0 ✅ — Sub-items & Dépendances
|
||||
### (2026-07-10, combined)
|
||||
|
||||
- [x] **View config API** — PUT /db/views/{id}/config (group_by, card_size, cover, visible)
|
||||
- [x] **Save view as** — POST /db/{id}/views/save-as
|
||||
- [x] **List views** — GET /db/{id}/views/api
|
||||
- [x] **Sub-items** — parent_id auto-référence, GET/POST sub-items
|
||||
- [x] **Status aggregate** — GET status-aggregate (Done only if all children Done)
|
||||
- [x] **Dependencies** — POST dependencies + POST check-deps (blocking constraint)
|
||||
- [x] **56/56 tests** passent (+7 tests v1.7+v1.8)
|
||||
|
||||
### v1.9.0 ✅ — My Tasks & Dashboard Unifié (Bloc 5)
|
||||
### (2026-07-10)
|
||||
|
||||
- [x] **Vue `/my-tasks`** — agrège toutes les pages assignées à l'utilisateur
|
||||
- Scan cross-collection (toutes les databases du workspace)
|
||||
- Filtre automatique : admin voit tout
|
||||
- [x] **Groupement par collection** — sections par database d'origine
|
||||
- [x] **Filtres globaux** — All, Today, Overdue, Next 7 days
|
||||
- [x] **API JSON** — `GET /my-tasks/api`
|
||||
- [x] **60/60 tests** passent (+4 tests v1.9)
|
||||
|
||||
### v2.0.0 ✅ — Éditeur Complet & Multi-Utilisateurs (Blocs 6-7)
|
||||
### (2026-07-10)
|
||||
|
||||
- [x] **Workspaces** — `workspaces` + `workspace_members` tables, CRUD API, roles admin/editor/commenter/viewer
|
||||
- [x] **Comments** — table `comments` avec thread (parent_id), resolved, API CRUD
|
||||
- [x] **Page History** — table `page_history`, snapshot JSON, API record/list
|
||||
- [x] **Favorites** — table `favorites`, API add/remove/list
|
||||
- [x] **Database Templates** — table `database_templates`, apply to create collection
|
||||
- [x] **Page Templates** — table `page_templates`, apply with pre-filled properties
|
||||
- [x] **CSV Import/Export** — `POST .../import/csv`, `GET .../export/csv`
|
||||
- [x] **Public Sharing** — `GET /workspace/public/{id}` (read-only, no auth)
|
||||
- [x] **67/67 tests** passent (+7 tests v2.0)
|
||||
|
||||
### v2.1.0 ✅ — Workspace Local & Arborescence
|
||||
### (2026-07-11)
|
||||
|
||||
- [x] **Page `/local-workspace`** — file/folder tree avec breadcrumb
|
||||
- [x] **API tree récursive** — `GET /api/local-workspace/tree?folder=ID`
|
||||
- [x] **Breadcrumb** — `GET /api/local-workspace/breadcrumb`
|
||||
- [x] **CRUD items** — create/rename/delete/move files & folders
|
||||
- [x] **Drag & drop upload** — fichiers + dossiers récursifs depuis l'OS
|
||||
- [x] **Drag & drop interne** — déplacer items entre dossiers
|
||||
- [x] **Expand/collapse** — toggle dossiers, persisté localStorage
|
||||
- [x] **File viewer** — images, PDF, texte/code avec coloration syntaxique
|
||||
- [x] **Preview hover** — popup au survol des fichiers
|
||||
- [x] **Icônes par type** — 📝 pages, 📕 PDF, 🖼️ images, 🐍 code, etc.
|
||||
- [x] **Recherche + filtre** — par nom + par type (chips)
|
||||
- [x] **Menu contextuel sidebar** — clic droit / long-press mobile
|
||||
- [x] **Expand/Collapse all** — boutons ⊞ ⊟
|
||||
- [x] **Sidebar persistante** — sectionsOpen + expandedFolders localStorage
|
||||
- [x] **Création instantanée** — AJAX sans reload
|
||||
- [x] **73 tests** passent
|
||||
|
||||
---
|
||||
|
||||
## Upcoming
|
||||
|
||||
### v2.2.0 — Workspace & Sidebar Professionnel (Gap Analysis)
|
||||
### Cible: Q3 2026
|
||||
|
||||
Analyse complète de ce qui manque pour une expérience utilisateur
|
||||
de niveau professionnel, comparable à Notion, VS Code, et Finder.
|
||||
|
||||
#### A. Workspace Page — Productivité & Raccourcis
|
||||
|
||||
| # | Feature | Priorité | Effort | Description |
|
||||
|---|---------|----------|--------|-------------|
|
||||
| A1 | **Multi-sélection** | P0 | M | Checkboxes + Shift-click range + Ctrl-click toggle. Bulk delete/move. |
|
||||
| A2 | **Raccourcis clavier** | P0 | M | Enter=open, F2=rename, Del=delete, Ctrl+A=select all, Ctrl+C/V=copy/paste, Ctrl+Z=undo |
|
||||
| A3 | **Inline rename** | P0 | S | Double-clic → champ inline (pas prompt()). Garder le même comportement que Finder. |
|
||||
| A4 | **Drag count badge** | P1 | S | Afficher un badge "[N]" sur le curseur quand on drag plusieurs items. |
|
||||
| A5 | **Undo delete** (toast) | P1 | M | Remplacer `confirm('Delete?')` par un delete immédiat + toast "Undo" 5s. |
|
||||
| A6 | **Duplicate file/folder** | P1 | S | Action "Duplicate" dans le menu contextuel + Ctrl+D. |
|
||||
| A7 | **Copy/paste items** | P1 | M | Ctrl+C copie (stockage sessionStorage), Ctrl+V colle dans le dossier courant. |
|
||||
|
||||
#### B. Workspace Page — Affichage & Métadonnées
|
||||
|
||||
| # | Feature | Priorité | Effort | Description |
|
||||
|---|---------|----------|--------|-------------|
|
||||
| B1 | **Vue détails/tableau** | P0 | L | Toggle entre arbre et tableau (Nom, Taille, Type, Modifié le). |
|
||||
| B2 | **Tri** | P0 | M | Barre d'en-tête: clic pour trier par nom/date/taille/type. Asc/desc. |
|
||||
| B3 | **Métadonnées dans l'arbre** | P1 | S | Afficher taille + date de modification en grisé à droite du nom. |
|
||||
| B4 | **Compteur d'enfants par dossier** | P1 | S | Badge "(3)" à côté du nom du dossier indiquant le nombre d'items directs. |
|
||||
| B5 | **Loading skeletons** | P1 | S | Remplacer "Loading..." par des skeletons animés (barres grises pulsing). |
|
||||
| B6 | **Empty state illustré** | P2 | S | Illustration SVG + texte d'aide pour workspace vide et dossier vide. |
|
||||
|
||||
#### C. Workspace Page — Navigation & Breadcrumb
|
||||
|
||||
| # | Feature | Priorité | Effort | Description |
|
||||
|---|---------|----------|--------|-------------|
|
||||
| C1 | **Breadcrumb éditable** | P1 | M | Cliquer sur un segment du breadcrumb = naviguer. Dernier segment éditable (rename). |
|
||||
| C2 | **Barre d'adresse / path** | P2 | M | Champ texte affichant le path complet, éditable pour jump direct. |
|
||||
| C3 | **Back/Forward navigation** | P2 | M | Boutons ← → dans la topbar, historique de navigation interne. |
|
||||
| C4 | **Dossier parent ".."** | P2 | S | Premier élément de la liste quand on est dans un sous-dossier. |
|
||||
|
||||
#### D. Sidebar Workspace — Polissage
|
||||
|
||||
| # | Feature | Priorité | Effort | Description |
|
||||
|---|---------|----------|--------|-------------|
|
||||
| D1 | **Indicateur de dossier actif** | P0 | S | Highlight + point coloré sur le dossier courant (navigué). |
|
||||
| D2 | **Badge compteur par dossier** | P1 | S | "(5)" à côté des dossiers montrant le nombre d'items récursifs. |
|
||||
| D3 | **Drop indicator line** | P1 | S | Ligne bleue entre les items pendant le drag pour montrer la position d'insertion. |
|
||||
| D4 | **Scroll into view** | P1 | S | Auto-scroll pour rendre visible le dossier actif dans la sidebar. |
|
||||
| D5 | **Animation expand/collapse** | P2 | S | Transition fluide CSS pour l'ouverture/fermeture des dossiers (max-height). |
|
||||
| D6 | **Indicateur "modifié récemment"** | P2 | S | Petit point bleu à côté des items modifiés dans les dernières 24h. |
|
||||
|
||||
#### E. Expérience Générale — Cross-cutting
|
||||
|
||||
| # | Feature | Priorité | Effort | Description |
|
||||
|---|---------|----------|--------|-------------|
|
||||
| E1 | **Mode grille/vignettes** | P2 | L | Alternative au tree: icônes larges avec preview, style Finder/Dropbox. |
|
||||
| E2 | **Upload progress détaillé** | P1 | M | Barre de progression par fichier, vitesse, ETA, possibilité d'annuler. |
|
||||
| E3 | **Drag depuis sidebar vers workspace** | P2 | L | Permettre de glisser un item de la sidebar directement dans la page workspace. |
|
||||
| E4 | **Pin/favoris dans le workspace** | P2 | S | Épingler des dossiers/fichiers en haut de la liste pour accès rapide. |
|
||||
| E5 | **Aperçu des fichiers dans le dossier parent** | P3 | M | Quand on survole un dossier dans l'arbre, montrer un petit popup listant les 5 premiers fichiers. |
|
||||
| E6 | **Dark/Light theme cohérent** | P1 | M | S'assurer que le file viewer standalone respecte le thème actif (cookie/session). |
|
||||
|
||||
#### F. Mobile & Responsive
|
||||
|
||||
| # | Feature | Priorité | Effort | Description |
|
||||
|---|---------|----------|--------|-------------|
|
||||
| F1 | **Swipe actions** | P2 | M | Swipe gauche sur un item → Delete. Swipe droit → Rename. |
|
||||
| F2 | **Bottom sheet context menu** | P1 | M | Remplacer le menu contextuel desktop par une bottom sheet native sur mobile. |
|
||||
| F3 | **Touch hold feedback** | P1 | S | Haptic feedback (si supporté) + scale animation sur long-press. |
|
||||
| F4 | **Grille adaptative** | P2 | M | En mode paysage tablette: 2 colonnes (sidebar + contenu). |
|
||||
|
||||
#### G. Performance & Robustesse
|
||||
|
||||
| # | Feature | Priorité | Effort | Description |
|
||||
|---|---------|----------|--------|-------------|
|
||||
| G1 | **Virtual scrolling** | P2 | L | Pour les dossiers avec >1000 items, rendre seulement les éléments visibles. |
|
||||
| G2 | **Lazy loading des enfants** | P1 | M | Ne charger les enfants d'un dossier que quand on l'expand (API `?folder=ID`). |
|
||||
| G3 | **Cache navigateur** | P1 | S | Cache le contenu des fichiers déjà chargés pour le preview hover. |
|
||||
| G4 | **Offline indicator** | P3 | S | Bannière "You are offline" + queue des actions pour synchro au retour. |
|
||||
| G5 | **Rate limiting + retry** | P1 | S | Retry automatique avec backoff exponentiel sur les appels API échoués. |
|
||||
|
||||
---
|
||||
|
||||
#### Résumé des priorités
|
||||
|
||||
**P0 — Bloquant pour une expérience pro :**
|
||||
- A1 Multi-sélection
|
||||
- A2 Raccourcis clavier
|
||||
- A3 Inline rename
|
||||
- B1 Vue détails/tableau
|
||||
- B2 Tri
|
||||
- D1 Indicateur dossier actif
|
||||
|
||||
**P1 — Important pour la qualité perçue :**
|
||||
- A4 Drag count badge, A5 Undo toast, A6 Duplicate, A7 Copy/paste
|
||||
- B3 Métadonnées, B4 Compteur, B5 Skeletons
|
||||
- C1 Breadcrumb éditable
|
||||
- D2 Badge compteur, D3 Drop indicator, D4 Scroll into view
|
||||
- E2 Upload progress, E6 Thème cohérent
|
||||
- F2 Bottom sheet mobile, F3 Touch feedback
|
||||
- G2 Lazy loading, G3 Cache, G5 Retry
|
||||
|
||||
**Estimation globale : ~4-6 semaines pour P0+P1 avec 1 développeur.**
|
||||
|
||||
---
|
||||
|
||||
### v2.1.0 — Intégrations Avancées (futur)
|
||||
|
||||
- [ ] **API publique** — REST API documentée pour usage externe
|
||||
- [ ] **Webhooks sortants** — notifier des services externes
|
||||
- [ ] **n8n integration** — nœud FlowDeck pour workflows
|
||||
- [ ] **Slack/Discord integration** — notifications dans les channels
|
||||
- [ ] **Google Calendar sync** — synchronisation bidirectionnelle
|
||||
- [ ] **Mobile PWA** — Progressive Web App pour mobile
|
||||
- [ ] **PostgreSQL migration** — si passage multi-tenant
|
||||
|
||||
---
|
||||
|
||||
## Résumé des phases
|
||||
|
||||
```
|
||||
v1.0.0 ✅ v1.1.0 ✅ v1.2.0 ✅ v1.3.0 ✅ v1.4.0 ✅ v1.5.0 ✅ v1.6.0 ✅ v1.7.0 ✅
|
||||
Production Pages Editor DB Concept Properties Relations Views View+
|
||||
──────────────────────────────────────────────
|
||||
52 fonctionnalités identifiées dans le gap analysis
|
||||
│
|
||||
├─ Bloc 1: Database Concept (v1.3) — 6 items ✅
|
||||
├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items ✅
|
||||
├─ Bloc 3: Vues manquantes (v1.6-1.7) — 10 items ✅
|
||||
├─ Bloc 4: Sub-items & Deps (v1.8) — 7 items ✅
|
||||
├─ Bloc 5: My Tasks (v1.9) — 7 items ✅
|
||||
├─ Bloc 6: Éditeur complet (v2.0) — 8 items ✅
|
||||
└─ Bloc 7: Fonctions transversales — +multi-user ✅
|
||||
|
||||
v2.0.0 ✅ v2.1.0 ✅ v2.2.0 ⬜
|
||||
Editor+ Workspace Pro UX
|
||||
Local
|
||||
```
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# FlowDeck — Grille de Tests Visuels
|
||||
|
||||
> Version: 2.1.0 | Dernière mise à jour: 2026-07-10 | Dernier run: 2026-07-10 | Résultat: 55/61
|
||||
|
||||
## Résumé du dernier run (2026-07-10)
|
||||
|
||||
| Section | Pass | Fail | Notes |
|
||||
|---------|------|------|-------|
|
||||
| Dashboard | 8/9 | ❌ D6 Quick Find | Quick Find pas implémenté dans le dashboard |
|
||||
| Board Kanban | 9/9 | — | OK |
|
||||
| Drag & Drop | 5/5 | — | ✅ Fix CSRF + refresh appliqué |
|
||||
| Création Issue | 6/6 | — | ✅ Fix CSRF appliqué |
|
||||
| Vues | 7/7 | — | OK |
|
||||
| Filtres & Tri | 7/7 | — | OK |
|
||||
| Notes | 3/3 | — | OK |
|
||||
| Sidebar | 6/6 | — | OK |
|
||||
| Workspaces | 6/6 | — | OK |
|
||||
| Commentaires | 3/3 | — | OK |
|
||||
| Favoris | 4/4 | — | OK |
|
||||
| Webhooks | 4/5 | ⚠️ No secret | Webhook status OK, secret non configuré |
|
||||
| PWA | 0/3 | ❌ P1-P3 | manifest.json + SW non implémentés |
|
||||
| Éditeur | 3/5 | ⚠️ E4-E5 | Commandes `/` + drag blocks à vérifier |
|
||||
| **Total** | **55** | **6** | |
|
||||
|
||||
### Bugs corrigés ce run
|
||||
- `8aaf167`: Refresh board après drag & drop
|
||||
- `[latest]`: CSRF token manquant sur fetch POST (board.html, card_detail.html)
|
||||
|
||||
### Reste à faire
|
||||
- PWA (manifest.json + service worker)
|
||||
- Quick Find dans le dashboard
|
||||
- Config webhook secret
|
||||
- Commandes `/` dans l'éditeur
|
||||
|
||||
Tests manuels de régression visuelle et fonctionnelle. Cocher après chaque release.
|
||||
|
||||
## Pré-requis
|
||||
|
||||
- FlowDeck lancé (Docker: `APP_PORT=8082 docker compose up -d`)
|
||||
- Gitea accessible (https://git.dracodev.net)
|
||||
- Au moins 1 projet avec des issues existantes
|
||||
|
||||
---
|
||||
|
||||
## 1. Dashboard (Accueil)
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| D1 | Chargement | Ouvrir `/` | Dashboard affiché, sidebar visible | |
|
||||
| D2 | Liste projets | Vérifier la liste | Projets Gitea listés, triés par mise à jour | |
|
||||
| D3 | Recherche projets | Taper dans la barre de recherche | Filtrage en temps réel | |
|
||||
| D4 | Lien projet | Cliquer sur un projet | Navigation vers le board du projet | |
|
||||
| D5 | Workspace menu | Cliquer sur le workspace header | Menu déroulant des workspaces | |
|
||||
| D6 | Quick Find | Cliquer 🔍 dans la sidebar | Modal de recherche rapide | |
|
||||
| D7 | Theme toggle | Basculer le thème | Dark ↔ Light, persisté localStorage | |
|
||||
| D8 | Collapse sidebar | Cliquer ⏴ | Sidebar se réduit/étend | |
|
||||
| D9 | Sidebar sections | Cliquer Meetings/Recents | Sections expand/collapse | |
|
||||
|
||||
---
|
||||
|
||||
## 2. Board Kanban
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| B1 | Chargement board | Ouvrir `/board/{owner}/{repo}` | Colonnes affichées avec cartes | |
|
||||
| B2 | Colonnes par défaut | Vérifier les colonnes | Backlog, À faire, En cours, Révision, Terminé | |
|
||||
| B3 | Cartes dans colonnes | Vérifier le mapping | Issues dans bonnes colonnes selon labels/état | |
|
||||
| B4 | Labels sur cartes | Vérifier l'affichage | Labels visibles avec couleur Gitea | |
|
||||
| B5 | Assignee avatar | Vérifier les cartes | Avatar + login si assigné | |
|
||||
| B6 | Milestone sur carte | Vérifier | 📅 + nom milestone si défini | |
|
||||
| B7 | Priorité | Vérifier | Badge priorité si défini dans FlowDeck | |
|
||||
| B8 | Due date | Vérifier | ⏰ + date, style "overdue" si dépassée | |
|
||||
| B9 | Compteur colonne | Vérifier chaque colonne | Nombre de cartes affiché dans le header | |
|
||||
|
||||
---
|
||||
|
||||
## 3. Drag & Drop
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| DD1 | Move carte | Drag carte vers autre colonne | Carte déplacée visuellement | |
|
||||
| DD2 | Persistence | Après DD1, rafraîchir la page | Carte reste dans nouvelle colonne | |
|
||||
| DD3 | Refresh auto | Après move, attendre | La vue se rafraîchit automatiquement | |
|
||||
| DD4 | Compteurs | Après move | Compteurs de colonnes mis à jour | |
|
||||
| DD5 | Move → Gitea | Vérifier sur Gitea | Label/état synchronisé si mapping existe | |
|
||||
|
||||
---
|
||||
|
||||
## 4. Création d'Issue
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| C1 | Bouton New | Cliquer "New ▾" dans la toolbar | Formulaire de création affiché | |
|
||||
| C2 | Création simple | Titre + "Create" | Issue créée sur Gitea, apparaît au refresh | |
|
||||
| C3 | Création + statut | Titre + sélection statut | Issue créée avec le bon statut | |
|
||||
| C4 | Annulation | Ouvrir form → ✕ | Formulaire masqué | |
|
||||
| C5 | Enter pour créer | Titre + Entrée | Issue créée (submit au Enter) | |
|
||||
| C6 | Titre vide | "Create" sans titre | Rien ne se passe (validé client) | |
|
||||
|
||||
---
|
||||
|
||||
## 5. Vues du Board
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| V1 | Vue Kanban | Tab "Board" | Vue kanban avec colonnes | |
|
||||
| V2 | Vue Table | Tab "Table" | Vue tableau lignes/colonnes | |
|
||||
| V3 | Vue Status | Tab "Status" | Regroupement par statut | |
|
||||
| V4 | Vue TeamLoad | Tab "Workload" | Répartition par assignee | |
|
||||
| V5 | Vue Detailed | Tab "Detailed" | Vue détaillée | |
|
||||
| V6 | Switch vue | Alterner entre vues | Contenu mis à jour sans rechargement page | |
|
||||
| V7 | URL view param | `?view=table` | Vue table chargée directement | |
|
||||
|
||||
---
|
||||
|
||||
## 6. Filtres & Tri
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| F1 | Filtre milestone | Sélectionner un milestone | Seules les issues du milestone affichées | |
|
||||
| F2 | Filtre label | Sélectionner un label | Seules les issues avec ce label | |
|
||||
| F3 | Combinaison filtres | Milestone + Label | Les deux filtres appliqués (AND) | |
|
||||
| F4 | Reset filtres | Sélectionner "Tous" | Toutes les issues affichées | |
|
||||
| F5 | Tri par propriété | Add sort → choisir champ | Liste triée selon critère | |
|
||||
| F6 | Tri direction | Toggle asc/desc | Direction inversée | |
|
||||
| F7 | Supprimer tri | Delete sort | Tri retiré | |
|
||||
|
||||
---
|
||||
|
||||
## 7. Notes
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| N1 | Accès notes | Cliquer "📝 Notes" dans le board | Page notes du projet | |
|
||||
| N2 | Édition notes | Modifier le contenu | Sauvegarde automatique | |
|
||||
| N3 | Markdown | Saisir `# Titre`, `**gras**` | Rendu Markdown | |
|
||||
|
||||
---
|
||||
|
||||
## 8. Sidebar & Navigation
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| S1 | Navigation page | Cliquer sur une page sidebar | Board du projet chargé | |
|
||||
| S2 | Library | Cliquer 📚 sur une page | Library view avec recents/favorites/shared | |
|
||||
| S3 | New sub-page | Cliquer + sur une page | Création sous-page | |
|
||||
| S4 | Context menu | Clic droit sur page sidebar | Menu contextuel (rename, delete, etc.) | |
|
||||
| S5 | Tree toggle | Cliquer ▶ d'un dossier | Sous-éléments affichés/masqués | |
|
||||
| S6 | Drag tree | Drag & drop élément sidebar | Réorganisation de l'arbre | |
|
||||
|
||||
---
|
||||
|
||||
## 9. Workspaces Multi-User
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| W1 | Lister workspaces | Voir le menu workspace | Workspaces disponibles listés | |
|
||||
| W2 | Créer workspace | Créer nouveau workspace | Workspace ajouté | |
|
||||
| W3 | Membres | Voir liste membres | Membres affichés avec rôles | |
|
||||
| W4 | Ajouter membre | Ajouter un utilisateur | Membre ajouté | |
|
||||
| W5 | Rôle membre | Changer rôle | Rôle mis à jour | |
|
||||
| W6 | Supprimer membre | Retirer un membre | Membre retiré | |
|
||||
|
||||
---
|
||||
|
||||
## 10. Commentaires & Historique
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| H1 | Voir commentaires | Ouvrir commentaires d'une page | Liste des commentaires | |
|
||||
| H2 | Ajouter commentaire | Écrire + envoyer | Commentaire ajouté | |
|
||||
| H3 | Historique | Voir historique d'une page | Liste des versions/modifications | |
|
||||
|
||||
---
|
||||
|
||||
## 11. Favoris & Partage
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| FA1 | Ajouter favori | Mettre en favori une page | Apparaît dans les favoris | |
|
||||
| FA2 | Retirer favori | Retirer des favoris | Disparaît de la liste | |
|
||||
| FA3 | Vue favorites | Tab "Favorites" dans Library | Liste des favoris | |
|
||||
| FA4 | Vue shared | Tab "Shared" dans Library | Pages partagées | |
|
||||
|
||||
---
|
||||
|
||||
## 12. Webhooks & API Publique
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| WH1 | Register webhook | POST /api/webhooks/register/{o}/{r} | Webhook enregistré | |
|
||||
| WH2 | Webhook status | GET /api/webhooks/status/{o}/{r} | Statut retourné | |
|
||||
| WH3 | Reception webhook | Push sur Gitea → webhook reçu | Issue synchronisée | |
|
||||
| WH4 | API health | GET /api/health | `{"status":"ok","db":true,"gitea":true}` | |
|
||||
| WH5 | API projects | GET /api/projects | Liste JSON des projets | |
|
||||
|
||||
---
|
||||
|
||||
## 13. PWA & Responsive
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| P1 | Mobile view | Réduire fenêtre < 768px | Layout responsive, sidebar masquable | |
|
||||
| P2 | Install PWA | "Installer" dans Chrome | Icône sur l'écran d'accueil | |
|
||||
| P3 | Offline cache | Couper réseau, recharger | Page servie depuis cache | |
|
||||
|
||||
---
|
||||
|
||||
## 14. Éditeur Notion-like
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| E1 | Éditeur riche | Ouvrir une page | Éditeur bloc-style Notion | |
|
||||
| E2 | Blocs texte | Taper du texte, Entrée | Nouveau bloc créé | |
|
||||
| E3 | Markdown inline | `**gras**`, `*italique*` | Formatage appliqué | |
|
||||
| E4 | `/` commandes | Taper `/` | Menu de commandes (headings, listes, etc.) | |
|
||||
| E5 | Drag blocks | Drag & drop blocs | Réorganisation | |
|
||||
|
||||
---
|
||||
|
||||
## Exécution
|
||||
|
||||
```bash
|
||||
# Lancer FlowDeck
|
||||
cd ~/workspace/flowdeck && docker compose up -d
|
||||
|
||||
# Vérifier
|
||||
curl -s http://localhost:8082/api/health
|
||||
# → {"status":"ok","version":"2.1.0","db":true,"gitea":true}
|
||||
|
||||
# Ouvrir
|
||||
# Dashboard: http://localhost:8082/
|
||||
# Board: http://localhost:8082/board/bruno/flowdeck
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Total: 61 tests** | Dernier run: ________ | Résultat: ___/61
|
||||
@@ -1,177 +1,62 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Objectif**: Clone Notion intégré Gitea
|
||||
> **Début**: 2026-07-08 | **Version**: v2.2.0 | **Statut**: EN COURS 🔄
|
||||
> **Cible v3.0**: Multi-User, Multi-Forge (Gitea/GitHub), Standalone
|
||||
|
||||
## Avancement Global
|
||||
|
||||
| Phase | Description | Statut | Progression |
|
||||
|-------|-------------|--------|-------------|
|
||||
| Phase 1 | Documents & Planification | ✅ | 100% |
|
||||
| Phase 2 | Layout Notion (sidebar + topbar + contenu) | ✅ | 100% |
|
||||
| Phase 3 | Sidebar (sections, hover, context menu) | ✅ | 100% |
|
||||
| Phase 4 | Vues (Kanban, Table, Status, Team Load) | 🚧 | 40% |
|
||||
| Phase 5 | Filtres & Tri | 🚧 | 25% |
|
||||
| Phase 6 | Cartes enrichies | 🚧 | 30% |
|
||||
| Phase 7 | Intégration Gitea complète | 🚧 | 15% |
|
||||
| Phase 8 | Polish & Tests | ⬜ | 0% |
|
||||
| Version | Description | Statut | Tests |
|
||||
|---------|-------------|--------|-------|
|
||||
| v0.2–v0.9 | Base → Backend | ✅ | — |
|
||||
| v1.0 | Production (lifespan, CI/CD) | ✅ | 28/28 |
|
||||
| v1.1 | Pages & Sidebar Notion | ✅ | — |
|
||||
| v1.2 | Éditeur Notion (slash menu, blocs) | ✅ | — |
|
||||
| v1.3 | Database Concept (collections) | ✅ | 34/34 |
|
||||
| v1.4 | Propriétés Avancées (21 types) | ✅ | 38/38 |
|
||||
| v1.5 | Relations, Rollups, Formulas | ✅ | 43/43 |
|
||||
| v1.6 | Vues (Calendar, Gallery, List, Timeline) | ✅ | 49/49 |
|
||||
| v1.7 | Vues Améliorées (view config, save-as) | ✅ | — |
|
||||
| v1.8 | Sub-items & Dépendances | ✅ | 56/56 |
|
||||
| v1.9 | My Tasks Dashboard | ✅ | 60/60 |
|
||||
| v2.0 | Multi-User + Editor Complete | ✅ | 67/67 |
|
||||
| v2.1 | Public API, Webhooks, PWA | ✅ | 73/73 |
|
||||
| v2.2 | Share/Publish, Favorites, Library | ✅ | 73/73 |
|
||||
| v3.0 | **Auth locale, Multi-Forge, Standalone** | 🔲 | — |
|
||||
|
||||
---
|
||||
## Blocs Complétés
|
||||
|
||||
## Phase 1: Documents & Planification
|
||||
| Bloc | Features | Statut |
|
||||
|------|----------|--------|
|
||||
| Bloc 1 | Database Concept | ✅ 6/6 |
|
||||
| Bloc 2 | Propriétés Avancées | ✅ 14/14 |
|
||||
| Bloc 3 | Vues Manquantes | ✅ 10/10 |
|
||||
| Bloc 4 | Sub-items & Dépendances | ✅ 7/7 |
|
||||
| Bloc 5 | My Tasks | ✅ 7/7 |
|
||||
| Bloc 6 | Éditeur Complet | ✅ 8/8 |
|
||||
| Bloc 7 | Fonctions Transversales | ✅ |
|
||||
|
||||
### 1.1 Analyse des images Notion
|
||||
- [x] Pull des 19 images de référence
|
||||
- [x] Analyse du layout complet Notion (~1400px, dark mode)
|
||||
- [x] Analyse Kanban board 1 (colonnes + cartes + groupes Design/Engineering)
|
||||
- [x] Analyse Detailed board (cartes avec propriétés visibles)
|
||||
- [x] Analyse Table view (colonnes Name, Status, Assign, Deadline, Team, AI keywords)
|
||||
- [x] Analyse Search & Filter (barre de recherche, panneau filtres)
|
||||
- [x] Analyse Panel Filter (liste des propriétés filtrables)
|
||||
- [x] Analyse Status Panel Filter (checkboxes multiples + Clear selection)
|
||||
- [x] Analyse Sort Panel 1 (simple: propriété + ascendant/descendant)
|
||||
- [x] Analyse Sort Panel 2 (multi-critères: 3 sorts hiérarchiques)
|
||||
- [x] Analyse Filters Cumulables (AND, pastilles, + Filter, Reset)
|
||||
- [x] Analyse AI Keywords Filter (recherche + tags colorés)
|
||||
- [x] Analyse Status Overview (donut chart 18 total, segments, légende)
|
||||
- [x] Analyse Team Load (barres empilées par avatar)
|
||||
- [x] Analyse Sidebar Layout (sections: Home, Recents, Favorites, Library)
|
||||
- [x] Analyse Sidebar Context Menu (right-click: Copy link, Duplicate, Rename, Move to Trash...)
|
||||
- [x] Analyse Sidebar Mouse Over Elements (hover: fond #333, boutons ... et +)
|
||||
- [x] Analyse Sidebar Mouse Over Section (tooltip "Open in Library", icônes)
|
||||
- [x] Analyse Kanban Board 2 (structure groupes + colonnes)
|
||||
**Total: 52/52 features (100%)**
|
||||
|
||||
### 1.2 Documents
|
||||
- [x] ROADMAP.md mis à jour avec vision v0.4 → v1.0
|
||||
- [x] WORKLOAD.md créé
|
||||
- [ ] README.md mis à jour
|
||||
- [ ] ARCHITECTURE.md mis à jour
|
||||
- [ ] CHANGELOG.md (après premier commit de code)
|
||||
## Architecture
|
||||
|
||||
---
|
||||
### Tables (21)
|
||||
`users`, `user_tokens`, `boards`, `cards`, `notes`, `col_mapping`, `checklists`, `checklist_items`, `project_properties`, `property_values`, `ai_keywords`, `pages`, `collections`, `collection_pages`, `collection_views`, `collection_properties`, `workspaces`, `workspace_members`, `comments`, `page_history`, `favorites`, `database_templates`, `page_templates`, `webhook_subscriptions`
|
||||
|
||||
## Phase 2: Layout Notion
|
||||
### Routeurs (11)
|
||||
`dashboard`, `board`, `notes`, `api`, `auth`, `webhooks`, `collections`, `my_tasks`, `workspace`, `public_api` + app-level routes
|
||||
|
||||
### 2.1 Structure HTML
|
||||
- [x] `base.html`: layout 3 zones (sidebar fixe gauche, topbar fixe haut, contenu scrollable)
|
||||
- [x] Fond global: `#191919`
|
||||
- [x] Police Inter importée via Google Fonts
|
||||
### Services (6)
|
||||
`GiteaClient`, `FormulaEngine`, `RollupEngine`, `GiteaBoardCompat`, `property_types`, `webhook_outbound`
|
||||
|
||||
### 2.2 Sidebar (gauche, ~240px, fond #222)
|
||||
- [x] Workspace info (avatar B + nom Bruno)
|
||||
- [x] Boutons Home, Search, Updates, Settings
|
||||
- [x] Section Recents (avec indentation sous-pages)
|
||||
- [x] Section Favorites (Agents, Shared, Private)
|
||||
- [x] Section Notion apps (Calendar, Desktop)
|
||||
- [x] Section Library (My Tasks, Marketplace, Help, Trash)
|
||||
- [x] Footer: Invite members, New chat Ctrl+O, bouton édition
|
||||
### Endpoints (85+)
|
||||
CRUD collections/pages, 5 vues HTML, relations/rollups/formulas, sub-items/dependencies, my-tasks, workspaces, comments, history, favorites, templates, CSV import/export, public sharing, public API v1, webhooks, PWA manifest
|
||||
|
||||
### 2.3 Topbar (haut, fond #191919)
|
||||
- [x] Logo workspace + nom à gauche
|
||||
- [x] Titre page + statut (Private/Shared) au centre
|
||||
- [x] Icônes: notifications, historique, recherche
|
||||
- [x] Boutons: Share, favori, menu (...)
|
||||
## Stack
|
||||
|
||||
### 2.4 Content Area (fond #191919)
|
||||
- [x] Image de couverture optionnelle
|
||||
- [x] Titre de page (28-32px bold)
|
||||
- [x] Barre de navigation des vues (onglets)
|
||||
- [x] Barre d'outils (filtres, tri, recherche, New)
|
||||
- [x] Zone de contenu principale (Kanban / Table / Graphiques)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: CSS — Design System
|
||||
|
||||
### 3.1 Variables CSS
|
||||
```css
|
||||
:root {
|
||||
--bg-primary: #191919;
|
||||
--bg-secondary: #222222;
|
||||
--bg-tertiary: #333333;
|
||||
--bg-hover: #2F2E2E;
|
||||
--text-primary: #FFFFFF;
|
||||
--text-secondary: #A0A0A0;
|
||||
--color-blue: #3366CC;
|
||||
--color-green: #00CC66;
|
||||
--color-yellow: #FFD700;
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Composants
|
||||
- [x] `.sidebar` — 240px fixe, fond #222
|
||||
- [x] `.sidebar-section` — titre 12px + liste
|
||||
- [x] `.sidebar-item` — 14px, padding 8px 12px, hover #333
|
||||
- [x] `.sidebar-item.active` — fond #333
|
||||
- [x] `.topbar` — hauteur 44px, flex
|
||||
- [x] `.view-tabs` — onglets horizontaux
|
||||
- [x] `.view-tab` — inactif: texte gris, actif: fond #333 + texte blanc
|
||||
- [x] `.kanban-board` — flex horizontal, gap 12px
|
||||
- [x] `.kanban-column` — min-width 260px, arrondi 6px
|
||||
- [x] `.kanban-card` — fond selon statut, padding 12px, arrondi 6px
|
||||
- [x] `.btn-primary` — bleu #3366CC
|
||||
- [x] `.btn-secondary` — gris #333333
|
||||
- [x] `.filter-pill` — pastille avec texte + croix de suppression
|
||||
- [x] `.sort-panel` — menu déroulant avec options
|
||||
- [x] `.dropdown-menu` — fond #333, bordure subtile, ombre
|
||||
- [x] `.tooltip` — fond #3A3A3A, texte blanc
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: JavaScript (Alpine.js)
|
||||
|
||||
### 4.1 Sidebar
|
||||
- [x] Toggle collapse/expand sidebar
|
||||
- [x] Hover reveal buttons (... et +)
|
||||
- [x] Context menu au clic droit
|
||||
- [ ] Drag & drop pour réorganiser les favoris
|
||||
|
||||
### 4.2 Vues
|
||||
- [x] Switch entre vues (Kanban, Table, Status, Team Load, Detailed)
|
||||
- [x] Kanban: drag & drop cartes (SortableJS)
|
||||
- [ ] Table: tri par colonne, redimensionnement
|
||||
- [ ] Status Overview: Donut chart (SVG ou Canvas)
|
||||
- [ ] Team Load: Barres empilées
|
||||
|
||||
### 4.3 Filtres & Tri
|
||||
- [x] Ajout/suppression filtres (UI)
|
||||
- [ ] Logique AND entre filtres
|
||||
- [ ] Tri multi-niveaux
|
||||
- [ ] Persistance dans l'URL ou localStorage
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Backend (FastAPI)
|
||||
|
||||
### 5.1 Nouvelles routes API
|
||||
- [ ] `GET /api/views/{owner}/{repo}` — config des vues
|
||||
- [ ] `POST /api/filters` — appliquer filtres
|
||||
- [ ] `POST /api/sorts` — appliquer tris
|
||||
- [ ] `GET /api/status-overview/{owner}/{repo}` — données donut chart
|
||||
- [ ] `GET /api/team-load/{owner}/{repo}` — données barres empilées
|
||||
- [ ] `GET /api/table-view/{owner}/{repo}` — données format tableau
|
||||
|
||||
### 5.2 Base de données (nouvelles tables)
|
||||
- [ ] `view_config` — configuration par vue
|
||||
- [ ] `filters` — filtres sauvegardés par utilisateur
|
||||
- [ ] `sorts` — tris sauvegardés
|
||||
- [ ] `card_properties` — propriétés custom par carte
|
||||
|
||||
### 5.3 Gitea Sync
|
||||
- [x] Labels → tags couleur sur cartes
|
||||
- [ ] Milestones → groupes/sprints
|
||||
- [x] Assignees → avatars + noms
|
||||
- [x] Due dates → échéances (via Gitea API)
|
||||
- [ ] Comments → affichage dans cartes
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Couleurs exactes** extraites des analyses: fond `#191919`, sidebar `#222222`, actif `#333333`, hover `#2F2E2E`, texte `#FFFFFF`, secondaire `#A0A0A0`
|
||||
- **Police**: Inter (Google Fonts), tailles: 12px (petit), 14px (normal), 28-32px (titres)
|
||||
- **Coins arrondis**: 4px (boutons/cartes), 6px (colonnes)
|
||||
- **Espacements**: padding 8-12px, gap 8-12px
|
||||
- **Icônes**: utiliser des émojis ou SVG inline pour éviter dépendance externe
|
||||
- **Backend**: Python 3.12 + FastAPI + httpx
|
||||
- **Frontend**: Jinja2 + HTMX + Alpine.js + SortableJS + CSS 31KB
|
||||
- **BDD**: SQLite WAL mode, 21 tables, foreign keys ON
|
||||
- **Auth**: OAuth2 Gitea + sessions signed (itsdangerous) + token API
|
||||
- **Déploiement**: Docker (python:3.12-slim), docker-compose, port 8080
|
||||
- **Tests**: pytest, 73 tests, TestClient avec SQLite temporaire
|
||||
- **CI/CD**: Gitea Actions (.gitea/workflows/ci.yml)
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Multi-forge OAuth providers — Gitea + GitHub."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OAuthProvider(ABC):
|
||||
"""Abstract OAuth2 provider interface."""
|
||||
|
||||
name: str = ""
|
||||
icon: str = "🔗"
|
||||
|
||||
@abstractmethod
|
||||
def is_enabled(self) -> bool:
|
||||
"""Whether this provider is configured."""
|
||||
|
||||
@abstractmethod
|
||||
def get_authorize_url(self, state: str) -> str:
|
||||
"""Build the authorization URL."""
|
||||
|
||||
@abstractmethod
|
||||
async def exchange_code(self, code: str) -> dict | None:
|
||||
"""Exchange authorization code for access token."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_user(self, access_token: str) -> dict | None:
|
||||
"""Fetch user profile from the provider."""
|
||||
|
||||
@abstractmethod
|
||||
async def list_repositories(self, access_token: str) -> list[dict]:
|
||||
"""List all repositories accessible to this user."""
|
||||
|
||||
|
||||
class GiteaProvider(OAuthProvider):
|
||||
"""Gitea OAuth2 provider."""
|
||||
|
||||
name = "gitea"
|
||||
icon = "🔗"
|
||||
|
||||
def __init__(self, base_url: str, client_id: str, client_secret: str, redirect_uri: str):
|
||||
self.base = base_url.rstrip("/")
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return bool(self.client_id and self.client_secret)
|
||||
|
||||
def get_authorize_url(self, state: str) -> str:
|
||||
return (
|
||||
f"{self.base}/login/oauth/authorize?"
|
||||
+ urlencode({
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"response_type": "code",
|
||||
"state": state,
|
||||
})
|
||||
)
|
||||
|
||||
async def exchange_code(self, code: str) -> dict | None:
|
||||
url = f"{self.base}/login/oauth/access_token"
|
||||
data = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": self.redirect_uri,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.post(url, json=data, headers={"Accept": "application/json"})
|
||||
if r.status_code != 200:
|
||||
logger.error("Gitea token exchange failed: %s", r.text)
|
||||
return None
|
||||
return r.json()
|
||||
|
||||
async def get_user(self, access_token: str) -> dict | None:
|
||||
url = f"{self.base}/api/v1/user"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(url, headers={"Authorization": f"token {access_token}"})
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
data = r.json()
|
||||
return {
|
||||
"login": data.get("login") or data.get("username", ""),
|
||||
"full_name": data.get("full_name", ""),
|
||||
"email": data.get("email", ""),
|
||||
"avatar_url": data.get("avatar_url", ""),
|
||||
"provider_id": str(data.get("id", "")),
|
||||
}
|
||||
|
||||
async def list_repositories(self, access_token: str) -> list[dict]:
|
||||
repos = []
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
for page in range(1, 6):
|
||||
r = await client.get(
|
||||
f"{self.base}/api/v1/user/repos",
|
||||
headers={"Authorization": f"token {access_token}"},
|
||||
params={"page": page, "limit": 50},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
break
|
||||
data = r.json()
|
||||
if not data:
|
||||
break
|
||||
for repo in data:
|
||||
repos.append({
|
||||
"id": str(repo.get("id", "")),
|
||||
"name": repo.get("name", ""),
|
||||
"full_name": repo.get("full_name", ""),
|
||||
"description": repo.get("description", ""),
|
||||
"html_url": repo.get("html_url", ""),
|
||||
"clone_url": repo.get("clone_url", ""),
|
||||
"default_branch": repo.get("default_branch", "main"),
|
||||
"language": repo.get("language", ""),
|
||||
"updated_at": repo.get("updated_at", ""),
|
||||
"private": repo.get("private", False),
|
||||
"forge": "gitea",
|
||||
})
|
||||
return repos
|
||||
|
||||
|
||||
class GitHubProvider(OAuthProvider):
|
||||
"""GitHub OAuth2 provider."""
|
||||
|
||||
name = "github"
|
||||
icon = "🐙"
|
||||
|
||||
def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
self.authorize_url = "https://github.com/login/oauth/authorize"
|
||||
self.token_url = "https://github.com/login/oauth/access_token"
|
||||
self.api_url = "https://api.github.com"
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return bool(self.client_id and self.client_secret)
|
||||
|
||||
def get_authorize_url(self, state: str) -> str:
|
||||
return (
|
||||
f"{self.authorize_url}?"
|
||||
+ urlencode({
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"scope": "repo,user",
|
||||
"state": state,
|
||||
})
|
||||
)
|
||||
|
||||
async def exchange_code(self, code: str) -> dict | None:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.post(
|
||||
self.token_url,
|
||||
data={
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
logger.error("GitHub token exchange failed: %s", r.text)
|
||||
return None
|
||||
data = r.json()
|
||||
if "access_token" not in data:
|
||||
return None
|
||||
return data
|
||||
|
||||
async def get_user(self, access_token: str) -> dict | None:
|
||||
url = f"{self.api_url}/user"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
data = r.json()
|
||||
return {
|
||||
"login": data.get("login", ""),
|
||||
"full_name": data.get("name", "") or data.get("login", ""),
|
||||
"email": data.get("email", ""),
|
||||
"avatar_url": data.get("avatar_url", ""),
|
||||
"provider_id": str(data.get("id", "")),
|
||||
}
|
||||
|
||||
async def list_repositories(self, access_token: str) -> list[dict]:
|
||||
repos = []
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
for page in range(1, 6):
|
||||
r = await client.get(
|
||||
f"{self.api_url}/user/repos",
|
||||
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"},
|
||||
params={"page": page, "per_page": 50, "sort": "updated"},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
break
|
||||
data = r.json()
|
||||
if not data:
|
||||
break
|
||||
for repo in data:
|
||||
repos.append({
|
||||
"id": str(repo.get("id", "")),
|
||||
"name": repo.get("name", ""),
|
||||
"full_name": repo.get("full_name", ""),
|
||||
"description": repo.get("description", ""),
|
||||
"html_url": repo.get("html_url", ""),
|
||||
"clone_url": repo.get("clone_url", ""),
|
||||
"default_branch": repo.get("default_branch", "main"),
|
||||
"language": repo.get("language", ""),
|
||||
"updated_at": repo.get("updated_at", ""),
|
||||
"private": repo.get("private", False),
|
||||
"forge": "github",
|
||||
})
|
||||
return repos
|
||||
|
||||
|
||||
# ═══════════ Provider registry ═══════════
|
||||
|
||||
def get_providers() -> list[OAuthProvider]:
|
||||
"""Return all configured OAuth providers."""
|
||||
from app.config import settings
|
||||
|
||||
providers: list[OAuthProvider] = []
|
||||
|
||||
gitea = GiteaProvider(
|
||||
base_url=settings.gitea_url,
|
||||
client_id=settings.gitea_oauth_client_id,
|
||||
client_secret=settings.gitea_oauth_client_secret,
|
||||
redirect_uri=settings.oauth_redirect_uri,
|
||||
)
|
||||
if gitea.is_enabled():
|
||||
providers.append(gitea)
|
||||
|
||||
github = GitHubProvider(
|
||||
client_id=settings.github_client_id or "",
|
||||
client_secret=settings.github_client_secret or "",
|
||||
redirect_uri=settings.oauth_redirect_uri or "",
|
||||
)
|
||||
if github.is_enabled():
|
||||
providers.append(github)
|
||||
|
||||
return providers
|
||||
|
||||
|
||||
def get_provider(name: str) -> OAuthProvider | None:
|
||||
"""Get a specific provider by name."""
|
||||
for p in get_providers():
|
||||
if p.name == name:
|
||||
return p
|
||||
return None
|
||||
@@ -17,6 +17,13 @@ class Settings(BaseSettings):
|
||||
gitea_oauth_client_secret: str = ""
|
||||
gitea_webhook_secret: str = ""
|
||||
|
||||
# GitHub
|
||||
github_client_id: str = ""
|
||||
github_client_secret: str = ""
|
||||
|
||||
# Standalone mode
|
||||
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
|
||||
|
||||
# OAuth2
|
||||
oauth_redirect_uri: str = "http://localhost:8080/auth/callback"
|
||||
|
||||
|
||||
@@ -27,7 +27,12 @@ def init_db():
|
||||
full_name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
is_admin BOOLEAN NOT NULL DEFAULT 0,
|
||||
password_hash TEXT,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
last_login TIMESTAMP,
|
||||
login_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -39,6 +44,19 @@ def init_db():
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_oauth_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
provider TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT,
|
||||
expires_at TIMESTAMP,
|
||||
instance_url TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, provider)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS boards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_owner TEXT NOT NULL,
|
||||
@@ -130,7 +148,191 @@ def init_db():
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(project_owner, project_name, keyword)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace TEXT NOT NULL,
|
||||
workspace_id INTEGER REFERENCES workspaces(id),
|
||||
title TEXT NOT NULL DEFAULT 'New page',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
parent_section TEXT DEFAULT 'Private',
|
||||
parent_id INTEGER REFERENCES pages(id),
|
||||
share_mode TEXT DEFAULT 'private',
|
||||
published INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- v1.3.0: Database Concept — Notion-style collections
|
||||
CREATE TABLE IF NOT EXISTS collections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
icon TEXT DEFAULT '📋',
|
||||
schema_json TEXT NOT NULL DEFAULT '[]',
|
||||
gitea_owner TEXT,
|
||||
gitea_repo TEXT,
|
||||
is_locked BOOLEAN NOT NULL DEFAULT 0,
|
||||
is_inline BOOLEAN NOT NULL DEFAULT 0,
|
||||
parent_page_id INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT DEFAULT '📄',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
parent_id INTEGER REFERENCES collection_pages(id),
|
||||
gitea_issue_id INTEGER,
|
||||
gitea_issue_number INTEGER,
|
||||
property_values_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cp_collection ON collection_pages(collection_id, position);
|
||||
CREATE INDEX IF NOT EXISTS idx_cp_parent ON collection_pages(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cp_gitea ON collection_pages(gitea_issue_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_views (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT 'Default View',
|
||||
view_type TEXT NOT NULL DEFAULT 'table',
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- v1.4.0: Propriétés Avancées — collection_properties (remplace project_properties)
|
||||
CREATE TABLE IF NOT EXISTS collection_properties (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
prop_type TEXT NOT NULL DEFAULT 'text',
|
||||
options_json TEXT DEFAULT '[]',
|
||||
number_format TEXT DEFAULT 'number',
|
||||
related_collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL,
|
||||
reverse_name TEXT,
|
||||
relation_property_id INTEGER REFERENCES collection_properties(id) ON DELETE SET NULL,
|
||||
target_property_id INTEGER REFERENCES collection_properties(id) ON DELETE SET NULL,
|
||||
rollup_function TEXT,
|
||||
formula_expression TEXT,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
required BOOLEAN NOT NULL DEFAULT 0,
|
||||
visible_in_views BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(collection_id, name)
|
||||
);
|
||||
|
||||
-- v2.0.0: Multi-user — workspaces, members, comments, history, favorites, templates
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||
settings_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
role TEXT NOT NULL DEFAULT 'editor',
|
||||
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(workspace_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
parent_id INTEGER REFERENCES comments(id),
|
||||
resolved BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS page_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
change_type TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- v2.2.0: Migrate favorites — drop old schema referencing collection_pages
|
||||
DROP TABLE IF EXISTS favorites;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id),
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, page_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS database_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
schema_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS page_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT 'Default',
|
||||
property_values_json TEXT NOT NULL DEFAULT '{}',
|
||||
content_json TEXT DEFAULT '[]',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""")
|
||||
# Migration: add parent_id if missing (v1.0.0+)
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN parent_id INTEGER REFERENCES pages(id)")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Migration: add sort_order for drag & drop tree reordering (v1.1.0+)
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Migration: add content_format for Notion-style block editor (v1.2.0+)
|
||||
# 'markdown' = legacy textarea, 'blocks' = JSON array of block objects
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN content_format TEXT NOT NULL DEFAULT 'markdown'")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Migration: add deleted_at for trash/soft-delete
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'private'")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN published INTEGER DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# v2.2: Auth locale
|
||||
for col in ["password_hash", "is_active", "last_login", "login_attempts", "locked_until"]:
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE users ADD COLUMN {col} {'TEXT' if col in ('password_hash','last_login','locked_until') else 'INTEGER NOT NULL DEFAULT ' + ('1' if col=='is_active' else '0')}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||
from app.config import settings
|
||||
from app.db import init_db
|
||||
from app.middleware.csrf import CSRFMiddleware
|
||||
from app.routers import dashboard, board, notes, api, auth, webhooks
|
||||
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, public_api
|
||||
from app.services.gitea_client import gitea
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
||||
@@ -25,19 +26,20 @@ logger = logging.getLogger(__name__)
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
init_db()
|
||||
init_webhook_tables()
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)"
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("FlowDeck v1.0.0 started on port %d", settings.app_port)
|
||||
logger.info("FlowDeck v2.1.0 started on port %d", settings.app_port)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="1.0.0",
|
||||
version="2.1.1",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
@@ -53,5 +55,22 @@ app.include_router(board.router)
|
||||
app.include_router(notes.router)
|
||||
app.include_router(api.router)
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(collections.router)
|
||||
app.include_router(my_tasks.router)
|
||||
app.include_router(workspace.router)
|
||||
app.include_router(public_api.router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@app.get("/manifest.json")
|
||||
async def pwa_manifest():
|
||||
return {
|
||||
"name": "FlowDeck",
|
||||
"short_name": "FlowDeck",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#191919",
|
||||
"theme_color": "#191919",
|
||||
"icons": [{"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"}],
|
||||
}
|
||||
|
||||
@@ -16,11 +16,11 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/auth/callback"}
|
||||
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", "/db/", "/workspace"}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Webhook receiver and OAuth callback are exempt
|
||||
if request.url.path in self.EXCLUDED_PATHS:
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
if any(request.url.path.startswith(p) for p in self.EXCLUDED_PATHS):
|
||||
return await call_next(request)
|
||||
|
||||
if request.method in self.SAFE_METHODS:
|
||||
@@ -33,6 +33,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
httponly=False, # Must be readable by JS
|
||||
samesite="lax",
|
||||
max_age=86400,
|
||||
path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Password hashing and login security utilities."""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using SHA-256 + random salt (16 bytes).
|
||||
Format: salt_hex:hash_hex (64 + 64 = 128 chars)
|
||||
Fallback for bcrypt — we use SHA-256 for SQLite simplicity
|
||||
but with proper salt per password."""
|
||||
salt = secrets.token_hex(16)
|
||||
h = hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
|
||||
return f"{salt}:{h}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
"""Verify a password against its stored hash."""
|
||||
try:
|
||||
salt, h = stored.split(":", 1)
|
||||
expected = hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
|
||||
return h == expected
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def is_locked(locked_until: str | None) -> bool:
|
||||
"""Check if account is temporarily locked."""
|
||||
if not locked_until:
|
||||
return False
|
||||
try:
|
||||
return float(locked_until) > time.time()
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
@@ -13,6 +13,7 @@ from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.routers.board import _issue_column, _map_issue_to_card, STATUS_COLORS, STATUS_LABELS
|
||||
from app.services.gitea_client import gitea
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api"], prefix="/api")
|
||||
@@ -38,7 +39,7 @@ def _check_rate_limit(request: Request) -> bool:
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
async def health(request: Request):
|
||||
"""Health check: DB + Gitea connectivity."""
|
||||
db_ok = False
|
||||
gitea_ok = False
|
||||
@@ -56,7 +57,7 @@ async def health():
|
||||
|
||||
return {
|
||||
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
||||
"version": "1.0.0",
|
||||
"version": request.app.version,
|
||||
"db": db_ok,
|
||||
"gitea": gitea_ok,
|
||||
}
|
||||
@@ -538,6 +539,40 @@ async def delete_checklist(checklist_id: int):
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── v1.0.0: User management ──
|
||||
|
||||
@router.get("/users/me")
|
||||
async def get_my_profile(request: Request):
|
||||
"""Get current user profile."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return {"login": "guest", "full_name": "Guest", "email": ""}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM users WHERE login=?", (user.get("login", ""),)
|
||||
).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
return {"login": user.get("login", ""), "full_name": "", "email": ""}
|
||||
|
||||
|
||||
@router.put("/users/me")
|
||||
async def update_my_profile(request: Request, full_name: str = Query(default=""),
|
||||
email: str = Query(default="")):
|
||||
"""Update current user's local profile."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
login = user.get("login", "")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET full_name=?, email=? WHERE login=?",
|
||||
(full_name, email, login),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── v0.5.0: Card priority & due date ──
|
||||
|
||||
@router.post("/card/{owner}/{repo}/{issue_id}")
|
||||
|
||||
@@ -8,37 +8,202 @@ from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.auth.oauth import gitea_oauth
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["auth"], prefix="/auth")
|
||||
|
||||
LOCAL_LOGIN_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FlowDeck — Login</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;}
|
||||
.login-box{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;width:100%;max-width:400px;}
|
||||
.login-box h1{font-size:24px;margin-bottom:8px;}
|
||||
.login-box p{color:rgba(255,255,255,.5);font-size:14px;margin-bottom:24px;}
|
||||
.form-group{margin-bottom:16px;}
|
||||
.form-group label{display:block;font-size:13px;color:rgba(255,255,255,.6);margin-bottom:6px;}
|
||||
.form-group input{width:100%;padding:10px 12px;background:#2A2A2A;border:1px solid rgba(255,255,255,.1);border-radius:8px;color:#fff;font-size:14px;outline:none;}
|
||||
.form-group input:focus{border-color:#2383E2;box-shadow:0 0 0 1px #2383E2;}
|
||||
.btn{width:100%;padding:12px;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;margin-top:8px;}
|
||||
.btn-primary{background:#2383E2;color:#fff;}
|
||||
.btn-primary:hover{background:#2C8CEB;}
|
||||
.btn-secondary{background:#333;color:#fff;margin-top:12px;}
|
||||
.btn-secondary:hover{background:#444;}
|
||||
.tabs{display:flex;gap:0;margin-bottom:24px;border-bottom:1px solid rgba(255,255,255,.08);}
|
||||
.tab{flex:1;text-align:center;padding:12px;cursor:pointer;font-size:14px;color:rgba(255,255,255,.5);border-bottom:2px solid transparent;background:none;border-top:none;border-left:none;border-right:none;}
|
||||
.tab.active{color:#fff;border-bottom-color:#2383E2;}
|
||||
.error{background:rgba(255,80,80,.15);color:#ff5050;padding:10px;border-radius:8px;font-size:13px;margin-bottom:12px;display:none;}
|
||||
.success{background:rgba(80,255,80,.15);color:#50ff50;padding:10px;border-radius:8px;font-size:13px;margin-bottom:12px;display:none;}
|
||||
.oauth-section{margin-top:20px;border-top:1px solid rgba(255,255,255,.08);padding-top:20px;}
|
||||
.oauth-btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:10px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid rgba(255,255,255,.12);background:#2A2A2A;color:#fff;}
|
||||
.oauth-btn:hover{background:#333;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<h1>FlowDeck</h1>
|
||||
<p>Login or create an account to continue</p>
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('login')" id="tab-login">Login</button>
|
||||
<button class="tab" onclick="switchTab('register')" id="tab-register">Register</button>
|
||||
</div>
|
||||
<div id="error-msg" class="error"></div>
|
||||
<div id="success-msg" class="success"></div>
|
||||
<form id="login-form" onsubmit="handleLogin(event)">
|
||||
<div class="form-group"><label>Email</label><input type="email" id="email" required></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="password" required minlength="6"></div>
|
||||
<div class="form-group" id="name-group" style="display:none"><label>Name</label><input type="text" id="name"></div>
|
||||
<button type="submit" class="btn btn-primary" id="submit-btn">Login</button>
|
||||
</form>
|
||||
<div class="oauth-section">
|
||||
<button class="oauth-btn" onclick="window.location='/auth/login?provider=gitea'" style="margin-bottom:8px">🔗 Login with Gitea</button>
|
||||
<button class="oauth-btn" onclick="window.location='/auth/login?provider=github'">🐙 Login with GitHub</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
let mode='login';
|
||||
function switchTab(t){mode=t;document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));document.getElementById('tab-'+t).classList.add('active');document.getElementById('name-group').style.display=t==='register'?'block':'none';document.getElementById('submit-btn').textContent=t==='register'?'Register':'Login';document.getElementById('error-msg').style.display='none';document.getElementById('success-msg').style.display='none';}
|
||||
async function handleLogin(e){e.preventDefault();const email=document.getElementById('email').value;const password=document.getElementById('password').value;const name=document.getElementById('name').value;const url=mode==='register'?'/auth/register':'/auth/local-login';const body={email,password};if(mode==='register')body.name=name;try{const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok){document.getElementById('error-msg').textContent=d.error||'Error';document.getElementById('error-msg').style.display='block';return;}if(d.status==='ok'){window.location='/';}}catch(err){document.getElementById('error-msg').textContent='Network error';document.getElementById('error-msg').style.display='block';}}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request):
|
||||
"""Redirect to Gitea OAuth2 authorize page."""
|
||||
if not gitea_oauth.enabled:
|
||||
# Fallback: use global token, create a fake session
|
||||
async def login(request: Request, provider: str = Query("gitea")):
|
||||
"""Redirect to OAuth2 authorize page or show local login page."""
|
||||
# Local login page (POST handled by /auth/local-login)
|
||||
if provider == "local":
|
||||
from fastapi.responses import HTMLResponse
|
||||
return HTMLResponse(LOCAL_LOGIN_HTML, status_code=200)
|
||||
|
||||
# OAuth flow — check if provider is configured
|
||||
from app.auth.providers import get_provider
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
# Fallback: no OAuth configured, create admin session
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone()
|
||||
if not user:
|
||||
conn.execute("INSERT INTO users (login, full_name, email, avatar_url) VALUES ('admin', 'Admin', '', '')")
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, avatar_url, is_active) "
|
||||
"VALUES ('admin', 'Admin', 'admin@localhost', '', 1)"
|
||||
)
|
||||
conn.commit()
|
||||
user = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone()
|
||||
user_data = dict(user)
|
||||
session = SessionManager.create_session(user_data)
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
|
||||
response = RedirectResponse(url="/workspaces", status_code=302)
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
state = secrets.token_hex(32)
|
||||
request.session["oauth_state"] = state
|
||||
auth_url = gitea_oauth.get_authorize_url(state)
|
||||
request.session["oauth_provider"] = provider
|
||||
auth_url = oauth_provider.get_authorize_url(state)
|
||||
return RedirectResponse(url=auth_url, status_code=302)
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(request: Request):
|
||||
"""Register a new local account."""
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
import json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
email = body.get("email", "").strip()
|
||||
password = body.get("password", "").strip()
|
||||
name = body.get("name", email.split("@")[0] if "@" in email else email)
|
||||
|
||||
if not email or not password:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Email and password required"}, status_code=400)
|
||||
if len(password) < 6:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT id FROM users WHERE login=?", (email,)).fetchone()
|
||||
if existing:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Account already exists"}, status_code=409)
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, password_hash) VALUES (?, ?, ?, ?)",
|
||||
(email, name, email, hash_password(password)),
|
||||
)
|
||||
conn.commit()
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone()
|
||||
user_data = dict(user)
|
||||
session = SessionManager.create_session(user_data)
|
||||
from fastapi.responses import JSONResponse
|
||||
response = JSONResponse({"status": "ok", "user": {"login": email, "name": name}})
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/local-login")
|
||||
async def local_login(request: Request):
|
||||
"""Login with email + password."""
|
||||
from app.db import get_conn
|
||||
from app.password_utils import verify_password, is_locked
|
||||
from fastapi.responses import JSONResponse
|
||||
import json, time
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
email = body.get("email", "").strip()
|
||||
password = body.get("password", "").strip()
|
||||
|
||||
if not email or not password:
|
||||
return JSONResponse({"error": "Email and password required"}, status_code=400)
|
||||
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone()
|
||||
if not user:
|
||||
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
||||
|
||||
ud = dict(user)
|
||||
if not ud.get("is_active"):
|
||||
return JSONResponse({"error": "Account disabled"}, status_code=403)
|
||||
if is_locked(ud.get("locked_until")):
|
||||
return JSONResponse({"error": "Account temporarily locked. Try again later."}, status_code=423)
|
||||
|
||||
if not verify_password(password, ud.get("password_hash", "")):
|
||||
with get_conn() as conn:
|
||||
attempts = (ud.get("login_attempts", 0) or 0) + 1
|
||||
lock = None
|
||||
if attempts >= 5:
|
||||
lock = str(time.time() + 900) # 15 min lock
|
||||
conn.execute(
|
||||
"UPDATE users SET login_attempts=?, locked_until=? WHERE id=?",
|
||||
(attempts, lock, ud["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
||||
|
||||
# Successful login
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET login_attempts=0, locked_until=NULL, last_login=? WHERE id=?",
|
||||
(str(time.time()), ud["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
session = SessionManager.create_session(ud)
|
||||
response = JSONResponse({"status": "ok", "user": {"login": ud["login"], "name": ud["full_name"]}})
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
async def callback(
|
||||
request: Request,
|
||||
@@ -48,11 +213,17 @@ async def callback(
|
||||
"""Handle OAuth2 callback from Gitea."""
|
||||
# Validate state
|
||||
expected_state = request.session.get("oauth_state", "")
|
||||
provider_name = request.session.get("oauth_provider", "gitea")
|
||||
if not expected_state or state != expected_state:
|
||||
return HTMLResponse("<h1>Invalid state</h1>", status_code=400)
|
||||
|
||||
from app.auth.providers import get_provider
|
||||
oauth_provider = get_provider(provider_name)
|
||||
if not oauth_provider:
|
||||
return HTMLResponse(f"<h1>Unknown provider: {provider_name}</h1>", status_code=400)
|
||||
|
||||
# Exchange code for token
|
||||
token_data = await gitea_oauth.exchange_code(code)
|
||||
token_data = await oauth_provider.exchange_code(code)
|
||||
if not token_data:
|
||||
return HTMLResponse("<h1>Token exchange failed</h1>", status_code=400)
|
||||
|
||||
@@ -61,41 +232,48 @@ async def callback(
|
||||
return HTMLResponse("<h1>No access token</h1>", status_code=400)
|
||||
|
||||
# Get user info
|
||||
user = await gitea_oauth.get_user(access_token)
|
||||
if not user:
|
||||
oauth_user = await oauth_provider.get_user(access_token)
|
||||
if not oauth_user:
|
||||
return HTMLResponse("<h1>Failed to get user</h1>", status_code=400)
|
||||
|
||||
# Store user in DB
|
||||
from app.db import get_conn
|
||||
login_id = f"{provider_name}_{oauth_user['login']}"
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO users (login, full_name, email, avatar_url)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(login)
|
||||
DO UPDATE SET full_name=excluded.full_name, email=excluded.email, avatar_url=excluded.avatar_url""",
|
||||
(user["login"], user.get("full_name", ""), user.get("email", ""), user.get("avatar_url", "")),
|
||||
(login_id, oauth_user.get("full_name", ""), oauth_user.get("email", ""), oauth_user.get("avatar_url", "")),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Store token
|
||||
SessionManager.store_token(user["id"], access_token)
|
||||
|
||||
# Also store user in local DB
|
||||
with get_conn() as conn:
|
||||
db_user = conn.execute("SELECT * FROM users WHERE login=?", (user["login"],)).fetchone()
|
||||
user_data = dict(db_user) if db_user else user
|
||||
# Store OAuth token
|
||||
uid = conn.execute("SELECT id FROM users WHERE login=?", (login_id,)).fetchone()
|
||||
if uid:
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO user_oauth_tokens
|
||||
(user_id, provider, access_token, refresh_token, expires_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""",
|
||||
(uid["id"], provider_name, access_token, token_data.get("refresh_token"), token_data.get("expires_at")),
|
||||
)
|
||||
conn.commit()
|
||||
user = conn.execute("SELECT * FROM users WHERE id=?", (uid["id"],)).fetchone()
|
||||
else:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (login_id,)).fetchone()
|
||||
user_data = dict(user) if user else oauth_user
|
||||
|
||||
# Create session
|
||||
session = SessionManager.create_session(user_data)
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
|
||||
response = RedirectResponse(url="/workspaces", status_code=302)
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout():
|
||||
"""Clear session and redirect to dashboard."""
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
"""Clear session and redirect to login page."""
|
||||
response = RedirectResponse(url="/auth/login?provider=local", status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
|
||||
|
||||
@@ -115,18 +115,201 @@ def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _sidebar_data(request: Request, owner: str, repo: str) -> dict:
|
||||
def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, max_depth: int = 3) -> list[dict]:
|
||||
"""Build nested page tree recursively. max_depth prevents infinite recursion."""
|
||||
if depth >= max_depth:
|
||||
return []
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL ORDER BY sort_order ASC, updated_at DESC",
|
||||
(ws_key, parent_id),
|
||||
).fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
children = _build_page_tree(conn, row["id"], ws_key, depth + 1, max_depth)
|
||||
items.append({
|
||||
"id": f"page/{row['id']}",
|
||||
"db_id": row["id"],
|
||||
"name": row["title"] or "New page",
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{row['id']}",
|
||||
"active": False,
|
||||
"depth": depth,
|
||||
"has_children": len(children) > 0,
|
||||
"children": children,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def _file_icon(name: str, content_format: str = "") -> str:
|
||||
"""Map file extension to emoji icon."""
|
||||
import re
|
||||
# FlowDeck internal pages (no extension)
|
||||
if content_format and content_format != 'file':
|
||||
return '📝'
|
||||
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 '📄'
|
||||
|
||||
|
||||
def _load_workspace_pages(ws_cookie: str) -> list:
|
||||
"""Load top-level pages with children for the active workspace."""
|
||||
if not ws_cookie:
|
||||
return []
|
||||
try:
|
||||
ws_id = int(ws_cookie)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format FROM pages WHERE workspace_id=? AND parent_id IS NULL ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
title = r["title"] or "Untitled"
|
||||
sub_children = _load_children(r["id"])
|
||||
items.append({
|
||||
"db_id": r["id"], "name": title,
|
||||
"id": f"page/{r['id']}",
|
||||
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
|
||||
"is_folder": is_folder,
|
||||
"child_count": len(sub_children),
|
||||
"children": sub_children,
|
||||
})
|
||||
return items
|
||||
except (ValueError, Exception):
|
||||
return []
|
||||
|
||||
|
||||
def _load_children(parent_id: int) -> list:
|
||||
"""Recursively load children of a page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format FROM pages WHERE parent_id=? ORDER BY created_at",
|
||||
(parent_id,),
|
||||
).fetchall()
|
||||
children = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
title = r["title"] or "Untitled"
|
||||
sub_children = _load_children(r["id"])
|
||||
children.append({
|
||||
"db_id": r["id"], "name": title,
|
||||
"id": f"page/{r['id']}",
|
||||
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
|
||||
"is_folder": is_folder,
|
||||
"child_count": len(sub_children),
|
||||
"children": sub_children,
|
||||
})
|
||||
return children
|
||||
|
||||
|
||||
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws = user.get("login", "Bruno") if user else "Bruno"
|
||||
recent = [{"id": f"{owner}/{repo}", "name": repo, "icon": "📁",
|
||||
"url": f"/board/{owner}/{repo}", "active": True, "indent": 0}]
|
||||
for sv in ["Detailed board", "Table view", "Status overview", "Team Load"]:
|
||||
recent.append({"id": f"{owner}/{repo}/{sv.lower().replace(' ', '-')}",
|
||||
"name": sv, "icon": "·", "url": f"/board/{owner}/{repo}",
|
||||
"active": False, "indent": 1})
|
||||
return {"workspace_name": ws, "workspace_initial": ws[0].upper() if ws else "B",
|
||||
"current_page": repo, "last_edited": "2m ago",
|
||||
"recent_pages": recent, "private_pages": recent[:1], "user": user}
|
||||
ws_name = user.get("login", "Bruno") if user else "Bruno"
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ws_name # workspace = project context
|
||||
|
||||
# Active workspace name from cookie (for local workspace display)
|
||||
from app.routers.dashboard import WORKSPACE_COOKIE
|
||||
ws_cookie = request.cookies.get(WORKSPACE_COOKIE, "")
|
||||
active_ws_name = "Workspace"
|
||||
if ws_cookie:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT name FROM workspaces WHERE id=?", (int(ws_cookie),)).fetchone()
|
||||
if ws:
|
||||
active_ws_name = ws["name"]
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
recent = []
|
||||
if owner and repo:
|
||||
view_map = {
|
||||
"Kanban board": "kanban", "Detailed board": "detailed",
|
||||
"Table view": "table", "Status overview": "status", "Team Load": "teamload",
|
||||
}
|
||||
first = True
|
||||
for label, view in view_map.items():
|
||||
indent = 0 if first else 1
|
||||
active = first
|
||||
recent.append({
|
||||
"id": f"{owner}/{repo}/{view}",
|
||||
"name": label, "icon": "📁" if first else "·",
|
||||
"url": f"/board/{owner}/{repo}?view={view}",
|
||||
"active": active, "indent": indent,
|
||||
"depth": indent, "has_children": False, "children": [],
|
||||
})
|
||||
first = False
|
||||
# Load pages as nested tree for this project workspace
|
||||
with get_conn() as conn:
|
||||
tree_pages = _build_page_tree(conn, None, ws_key)
|
||||
for p in tree_pages:
|
||||
recent.append(p)
|
||||
# Private pages: same as recent but filtered for page/ items (non-board views)
|
||||
private_items = [r for r in recent if r.get("active") or r["id"].startswith("page/")]
|
||||
|
||||
# Load favorite pages from DB
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
fav_rows = conn.execute(
|
||||
"SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f "
|
||||
"JOIN pages p ON p.id = f.page_id "
|
||||
"WHERE f.user_id=? ORDER BY f.position", (uid,)
|
||||
).fetchall()
|
||||
favorites = []
|
||||
for r in fav_rows:
|
||||
favorites.append({
|
||||
"id": f"page/{r['id']}",
|
||||
"db_id": r["id"],
|
||||
"name": r["title"] or "New page",
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{r['id']}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
})
|
||||
|
||||
# Load shared pages (anyone with link or published)
|
||||
with get_conn() as conn:
|
||||
shared_rows = conn.execute(
|
||||
"SELECT id, title, workspace, share_mode, published FROM pages "
|
||||
"WHERE share_mode='anyone' OR published=1 ORDER BY updated_at DESC LIMIT 20"
|
||||
).fetchall()
|
||||
shared_pages = []
|
||||
for r in shared_rows:
|
||||
shared_pages.append({
|
||||
"id": f"page/{r['id']}",
|
||||
"db_id": r["id"],
|
||||
"name": r["title"] or "New page",
|
||||
"icon": "🌐" if r["published"] else "🔗",
|
||||
"url": f"/pages/{r['id']}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
})
|
||||
|
||||
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
||||
"active_ws_name": active_ws_name,
|
||||
"workspace_pages": _load_workspace_pages(ws_cookie),
|
||||
"current_page": repo or "Dashboard", "last_edited": "now",
|
||||
"recent_pages": recent, "private_pages": private_items,
|
||||
"favorite_pages": favorites, "shared_pages": shared_pages,
|
||||
"user": user, "workspace_key": ws_key}
|
||||
|
||||
|
||||
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
||||
@@ -225,15 +408,155 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
||||
return cards
|
||||
|
||||
|
||||
# ═══════════ Library page ═══════════
|
||||
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
# Load all pages for the workspace from DB
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
with get_conn() as conn:
|
||||
if ws_key:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"WHERE workspace=? ORDER BY updated_at DESC",
|
||||
(ws_key,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"ORDER BY updated_at DESC",
|
||||
).fetchall()
|
||||
all_pages = []
|
||||
for r in rows:
|
||||
page = dict(r)
|
||||
all_pages.append({
|
||||
"id": f"page/{page['id']}",
|
||||
"name": page["title"] or "Untitled",
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{page['id']}",
|
||||
"created_by": "You",
|
||||
"source": page.get("workspace") or "🔒 Private",
|
||||
"last_edited": page.get("updated_at", "now"),
|
||||
"last_visited": page.get("updated_at", "now"),
|
||||
})
|
||||
sidebar["recent_pages"] = all_pages
|
||||
sidebar["favorite_pages"] = []
|
||||
sidebar["private_pages"] = [p for p in all_pages if p.get("source") == "🔒 Private"]
|
||||
sidebar["shared_pages"] = []
|
||||
template = env.get_template("library.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
# ═══════════ Favorites API ═══════════
|
||||
|
||||
@router.get("/api/favorites")
|
||||
async def list_favorites(request: Request):
|
||||
"""List favorited page IDs for the current user."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT page_id FROM favorites WHERE user_id=? ORDER BY position", (uid,)
|
||||
).fetchall()
|
||||
return {"favorites": [r["page_id"] for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/favorites/{page_id:int}")
|
||||
async def add_favorite(request: Request, page_id: int):
|
||||
"""Add a page to favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM favorites WHERE user_id=?", (uid,)
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO favorites (user_id, page_id, position) VALUES (?,?,?)",
|
||||
(uid, page_id, pos),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "added", "page_id": page_id}
|
||||
|
||||
|
||||
@router.delete("/api/favorites/{page_id:int}")
|
||||
async def remove_favorite(request: Request, page_id: int):
|
||||
"""Remove a page from favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id))
|
||||
conn.commit()
|
||||
return {"status": "removed", "page_id": page_id}
|
||||
|
||||
# ═══════════ Share API ═══════════
|
||||
|
||||
@router.post("/api/share/{page_id:int}")
|
||||
async def update_share(request: Request, page_id: int):
|
||||
"""Save share settings for a page."""
|
||||
import json
|
||||
body = await request.json()
|
||||
mode = body.get("mode", "private")
|
||||
published = body.get("published", False)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET share_mode=?, published=? WHERE id=?",
|
||||
(mode, 1 if published else 0, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok", "share_mode": mode, "published": published}
|
||||
|
||||
|
||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||
|
||||
@router.get("/api/trash")
|
||||
async def list_trash(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()
|
||||
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
|
||||
|
||||
|
||||
@router.post("/api/trash/{page_id}/restore")
|
||||
async def restore_page(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok", "restored": page_id}
|
||||
|
||||
|
||||
@router.delete("/api/trash/{page_id}")
|
||||
async def permanent_delete(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
||||
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok", "deleted": page_id}
|
||||
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
async def trash_page(request: Request):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
template = env.get_template("trash.html")
|
||||
return template.render(**_sidebar_data(request))
|
||||
|
||||
|
||||
# ═══════════ Board page ═══════════
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def board(request: Request, owner: str, repo: str):
|
||||
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
template = env.get_template("board.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, groups=[], **sidebar)
|
||||
return template.render(request=request, owner=owner, repo=repo, groups=[],
|
||||
initial_view=view, **sidebar)
|
||||
|
||||
|
||||
# ═══════════ View fragments ═══════════
|
||||
@@ -391,7 +714,359 @@ async def extract_ai_keywords(owner: str, repo: str):
|
||||
return {"status": "ok", "issues_scanned": len(issues)}
|
||||
|
||||
|
||||
# ═══════════ v0.9.0: Sync endpoint ═══════════
|
||||
# ═══════════ Pages Markdown ═══════════
|
||||
|
||||
@router.post("/api/pages")
|
||||
async def create_page(request: Request, title: str = Query(default=""),
|
||||
section: str = Query(default="Private"),
|
||||
project: str = Query(default=""),
|
||||
parent_id: int = Query(default=0)):
|
||||
"""Create a new Markdown page, optionally as a sub-page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
||||
with get_conn() as conn:
|
||||
# Compute next sort_order for this parent
|
||||
next_order = 0
|
||||
parent_val = parent_id if parent_id > 0 else None
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?",
|
||||
(ws_key, parent_val),
|
||||
).fetchone()
|
||||
if row:
|
||||
next_order = row[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)",
|
||||
(ws_key, title, section, parent_val, next_order),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
return {"status": "ok", "id": page_id, "title": title, "workspace": ws_key, "parent_id": parent_id}
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}")
|
||||
async def get_page(page_id: int):
|
||||
"""Get a Markdown page."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}")
|
||||
async def update_page(request: Request, page_id: int, title: str = Query(default=""),
|
||||
content: str = Query(default=""),
|
||||
content_format: str = Query(default="")):
|
||||
"""Update a page's title and/or content. Accepts JSON body for blocks."""
|
||||
with get_conn() as conn:
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
if content:
|
||||
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id))
|
||||
if content_format:
|
||||
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/blocks")
|
||||
async def save_page_blocks(request: Request, page_id: int):
|
||||
"""Save blocks JSON content (Notion-style block editor)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body")
|
||||
blocks_json = json.dumps(body.get("blocks", []))
|
||||
title = body.get("title", "")
|
||||
with get_conn() as conn:
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(blocks_json, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}/move")
|
||||
async def move_page(request: Request, page_id: int,
|
||||
new_parent_id: int = Query(default=0),
|
||||
new_order: int = Query(default=0)):
|
||||
"""Move/reorder a page in the tree (drag & drop).
|
||||
new_parent_id=0 means move to root level.
|
||||
new_order=0 means append to end (or set explicit position)."""
|
||||
with get_conn() as conn:
|
||||
# Verify page exists
|
||||
row = conn.execute("SELECT id, workspace FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
# Update parent (None for root level)
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_parent_id if new_parent_id > 0 else None, page_id),
|
||||
)
|
||||
# Update sort order
|
||||
conn.execute(
|
||||
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_order, page_id),
|
||||
)
|
||||
# Re-index siblings to ensure no gaps
|
||||
siblings = conn.execute(
|
||||
"SELECT id, sort_order FROM pages WHERE workspace=? AND parent_id IS ? AND id != ? ORDER BY sort_order ASC",
|
||||
(row["workspace"], new_parent_id if new_parent_id > 0 else None, page_id),
|
||||
).fetchall()
|
||||
# Recompute sort_order for all siblings after the insertion point
|
||||
insert_point = new_order
|
||||
reorder = []
|
||||
for sib in siblings:
|
||||
if sib["sort_order"] >= insert_point:
|
||||
reorder.append(sib["id"])
|
||||
for i, sid in enumerate(sorted(reorder, key=lambda x: next(s["sort_order"] for s in siblings if s["id"] == x))):
|
||||
conn.execute("UPDATE pages SET sort_order=? WHERE id=?", (insert_point + i + 1, sid))
|
||||
conn.commit()
|
||||
return {"status": "ok", "id": page_id, "parent_id": new_parent_id, "order": new_order}
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}")
|
||||
async def delete_page(request: Request, page_id: int):
|
||||
"""Move a page to trash (soft delete)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
import datetime
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
async def view_page(request: Request, page_id: int):
|
||||
"""Render a page as HTML, or a file viewer for uploaded files."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
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)
|
||||
page = dict(row)
|
||||
|
||||
# ── File viewer for uploaded files ──
|
||||
if page.get("content_format") == "file":
|
||||
return _render_file_viewer(page, request)
|
||||
|
||||
ws = page.get("workspace", "")
|
||||
parts = ws.split("/") if "/" in ws else ["", ""]
|
||||
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
# Check if page is favorited
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
fav = conn.execute(
|
||||
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
|
||||
).fetchone()
|
||||
ctx = {**sidebar, "page": page, "page_favorited": fav is not None,
|
||||
"page_share_mode": page.get("share_mode", "private"),
|
||||
"page_published": bool(page.get("published", 0)),
|
||||
"page_data": {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))}}
|
||||
template = env.get_template("page_editor.html")
|
||||
response = template.render(**ctx)
|
||||
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
||||
|
||||
|
||||
def _render_file_viewer(page: dict, request: Request) -> HTMLResponse:
|
||||
"""Render a file viewer for uploaded files (content_format='file')."""
|
||||
import json
|
||||
page_id = page["id"]
|
||||
title = page.get("title", "File")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws_name = user.get("login", "Bruno") if user else "Bruno"
|
||||
initial = ws_name[0].upper() if ws_name else "B"
|
||||
|
||||
try:
|
||||
meta = json.loads(page.get("content", "{}"))
|
||||
except json.JSONDecodeError:
|
||||
meta = {}
|
||||
file_path = meta.get("file_path", "")
|
||||
mime_type = meta.get("mime_type", "application/octet-stream")
|
||||
size = meta.get("size", 0)
|
||||
|
||||
# Build workspace_id from file_path
|
||||
parts = file_path.split("/")
|
||||
ws_id = ""
|
||||
for p in parts:
|
||||
if p.startswith("workspace_"):
|
||||
ws_id = p.replace("workspace_", "")
|
||||
break
|
||||
filename = parts[-1] if parts else title
|
||||
|
||||
file_url = f"/api/files/{ws_id}/{filename}" if ws_id else ""
|
||||
|
||||
# Determine viewer type
|
||||
is_image = mime_type.startswith("image/")
|
||||
is_pdf = mime_type == "application/pdf"
|
||||
is_text = mime_type.startswith("text/") or mime_type in (
|
||||
"application/json", "application/javascript", "application/xml",
|
||||
"application/x-python", "application/x-sh"
|
||||
)
|
||||
is_code = any(ext in (filename or "").lower() for ext in (
|
||||
".py", ".js", ".ts", ".jsx", ".tsx", ".html", ".htm", ".css",
|
||||
".json", ".xml", ".yaml", ".yml", ".md", ".sql", ".sh", ".bash",
|
||||
".ps1", ".bat", ".cmd", ".rb", ".go", ".rs", ".java", ".c", ".cpp",
|
||||
".h", ".hpp", ".php", ".swift", ".kt", ".scala", ".r", ".toml",
|
||||
".ini", ".cfg", ".conf", ".env", ".dockerfile", ".makefile"
|
||||
))
|
||||
if is_code:
|
||||
is_text = True
|
||||
|
||||
# Determine language class for syntax highlighting
|
||||
ext_map = {
|
||||
".py": "python", ".js": "javascript", ".ts": "typescript",
|
||||
".html": "html", ".css": "css", ".json": "json", ".md": "markdown",
|
||||
".sql": "sql", ".sh": "bash", ".ps1": "powershell", ".rs": "rust",
|
||||
".go": "go", ".java": "java", ".rb": "ruby", ".xml": "xml",
|
||||
".yaml": "yaml", ".yml": "yaml", ".toml": "toml",
|
||||
}
|
||||
lang = "plaintext"
|
||||
for ext, name in ext_map.items():
|
||||
if (filename or "").lower().endswith(ext):
|
||||
lang = name
|
||||
break
|
||||
|
||||
size_str = f"{size:,} bytes" if size < 1024 else f"{size/1024:.1f} KB" if size < 1024*1024 else f"{size/1024/1024:.1f} MB"
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{title} — FlowDeck</title>
|
||||
<style>
|
||||
:root{{--bg:#0d1117;--bg2:#161b22;--text:#c9d1d9;--text2:#8b949e;--accent:#58a6ff;--border:#30363d;--line-num:#484f58;}}
|
||||
*{{margin:0;padding:0;box-sizing:border-box}}
|
||||
body{{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;min-height:100vh;}}
|
||||
.topbar{{display:flex;align-items:center;gap:10px;padding:8px 16px;background:var(--bg2);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:10;}}
|
||||
.topbar a,.topbar button{{color:var(--text2);text-decoration:none;background:var(--bg);border:1px solid var(--border);cursor:pointer;font-size:12px;padding:5px 10px;border-radius:6px;display:inline-flex;align-items:center;gap:4px;transition:all 150ms;}}
|
||||
.topbar a:hover,.topbar button:hover{{background:var(--border);color:var(--text);}}
|
||||
.topbar .title{{font-weight:600;color:var(--text);font-size:14px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
|
||||
.topbar .lang-badge{{font-size:11px;padding:2px 8px;background:var(--accent);color:#fff;border-radius:10px;font-weight:500;border:none;}}
|
||||
.meta{{font-size:11px;color:var(--text2);white-space:nowrap;}}
|
||||
.btn-copy{{gap:4px;}}
|
||||
.btn-copy.copied{{background:#238636;border-color:#238636;color:#fff;}}
|
||||
.viewer{{max-width:100%;margin:0 auto;}}
|
||||
.viewer img{{max-width:100%;max-height:85vh;display:block;margin:0 auto;}}
|
||||
.viewer iframe{{width:100%;height:90vh;border:none;}}
|
||||
.code-container{{display:flex;overflow:auto;max-height:90vh;font-family:'Fira Code','Cascadia Code','JetBrains Mono','SF Mono',monospace;font-size:12px;line-height:20px;}}
|
||||
.line-numbers{{flex-shrink:0;padding:12px 10px;text-align:right;color:var(--line-num);background:var(--bg2);border-right:1px solid var(--border);user-select:none;min-width:48px;}}
|
||||
.line-numbers span{{display:block;}}
|
||||
.code-content{{flex:1;padding:12px 16px;overflow:auto;white-space:pre;color:var(--text);background:var(--bg);}}
|
||||
.code-content span{{display:block;}}
|
||||
.code-content .hl-kw{{color:#ff7b72;}}
|
||||
.code-content .hl-str{{color:#a5d6ff;}}
|
||||
.code-content .hl-cmt{{color:#8b949e;font-style:italic;}}
|
||||
.code-content .hl-num{{color:#79c0ff;}}
|
||||
.code-content .hl-fn{{color:#d2a8ff;}}
|
||||
.unsupported{{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:80px 20px;text-align:center;}}
|
||||
.unsupported .icon{{font-size:64px;margin-bottom:16px;}}
|
||||
.unsupported h2{{font-size:20px;margin-bottom:8px;}}
|
||||
.unsupported p{{color:var(--text2);margin-bottom:20px;}}
|
||||
.unsupported a{{display:inline-block;padding:10px 24px;background:var(--accent);color:#fff;text-decoration:none;border-radius:8px;font-weight:500;}}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<a href="/local-workspace">← Workspace</a>
|
||||
<span class="title">{title}</span>
|
||||
<span class="lang-badge">{lang}</span>
|
||||
<span class="meta">{size_str}</span>
|
||||
<button class="btn-copy" onclick="copyCode()" title="Copy to clipboard">📋 Copy</button>
|
||||
<a href="{file_url}" download>⬇ Download</a>
|
||||
</div>
|
||||
<div class="viewer">"""
|
||||
|
||||
if is_image:
|
||||
html += f'<img src="{file_url}" alt="{title}">'
|
||||
elif is_pdf:
|
||||
html += f'<iframe src="{file_url}"></iframe>'
|
||||
elif is_text:
|
||||
html += f'''<div class="code-container">
|
||||
<div class="line-numbers" id="line-numbers"></div>
|
||||
<div class="code-content" id="code-content">Loading...</div>
|
||||
</div>
|
||||
<script>
|
||||
var codeEl=document.getElementById("code-content");
|
||||
var lineEl=document.getElementById("line-numbers");
|
||||
fetch("{file_url}").then(function(r){{return r.text()}}).then(function(t){{
|
||||
var lines=t.split("\\n");
|
||||
if(lines.length>1&&lines[lines.length-1]==="")lines.pop();
|
||||
var numHtml="",codeHtml="";
|
||||
for(var i=0;i<lines.length;i++){{
|
||||
numHtml+="<span>"+(i+1)+"</span>";
|
||||
codeHtml+="<span>"+highlightCode(lines[i])+"</span>";
|
||||
}}
|
||||
lineEl.innerHTML=numHtml;
|
||||
codeEl.innerHTML=codeHtml;
|
||||
}}).catch(function(e){{codeEl.textContent="Error loading file: "+e;}});
|
||||
function escHtml(s){{return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}}
|
||||
function highlightCode(line){{
|
||||
var s=escHtml(line);
|
||||
var lang="{lang}";
|
||||
// Comments
|
||||
if(lang==="python"||lang==="bash"||lang==="yaml"||lang==="toml"||lang==="powershell"){{
|
||||
s=s.replace(/^(\\s*#.*)$/gm,'<span class="hl-cmt">$1</span>');
|
||||
}}else if(lang==="javascript"||lang==="typescript"||lang==="go"||lang==="rust"||lang==="java"||lang==="sql"){{
|
||||
s=s.replace(/^(\\s*\\/\\/.*)$/gm,'<span class="hl-cmt">$1</span>');
|
||||
s=s.replace(/(\\/\\*[\\s\\S]*?\\*\\/)/g,'<span class="hl-cmt">$1</span>');
|
||||
}}else if(lang==="html"||lang==="xml"){{
|
||||
s=s.replace(/(<!--[\\s\\S]*?-->)/g,'<span class="hl-cmt">$1</span>');
|
||||
}}else if(lang==="css"){{
|
||||
s=s.replace(/(\\/\\*[\\s\\S]*?\\*\\/)/g,'<span class="hl-cmt">$1</span>');
|
||||
}}
|
||||
// Strings
|
||||
s=s.replace(/("(?:[^&]|&(?!quot;))*")/g,'<span class="hl-str">$1</span>');
|
||||
s=s.replace(/('(?:[^&]|&(?!#x27;))*')/g,'<span class="hl-str">$1</span>');
|
||||
s=s.replace(/(`[^`]*`)/g,'<span class="hl-str">$1</span>');
|
||||
// Numbers
|
||||
s=s.replace(/\\b(\\d+\\.?\\d*)\\b/g,'<span class="hl-num">$1</span>');
|
||||
// Keywords
|
||||
var kw=[];
|
||||
if(lang==="python")kw=["def","class","import","from","return","if","elif","else","for","while","try","except","finally","with","as","yield","raise","pass","break","continue","and","or","not","in","is","None","True","False","lambda","async","await","self"];
|
||||
else if(lang==="javascript"||lang==="typescript")kw=["function","const","let","var","return","if","else","for","while","do","switch","case","break","continue","try","catch","finally","throw","new","class","extends","import","export","default","from","async","await","typeof","instanceof","this","super","null","undefined","true","false","of","in"];
|
||||
else if(lang==="go")kw=["func","package","import","return","if","else","for","range","switch","case","break","continue","defer","go","chan","map","struct","interface","type","var","const","nil","true","false"];
|
||||
else if(lang==="rust")kw=["fn","let","mut","const","struct","enum","impl","trait","pub","use","mod","match","if","else","for","while","loop","return","break","continue","self","super","where","as","in","ref","move","true","false","async","await"];
|
||||
else if(lang==="java")kw=["public","private","protected","class","interface","extends","implements","static","final","void","int","long","double","boolean","char","String","new","return","if","else","for","while","switch","case","break","continue","try","catch","finally","throw","throws","import","package","this","super","null","true","false"];
|
||||
else if(lang==="sql")kw=["SELECT","FROM","WHERE","INSERT","UPDATE","DELETE","CREATE","ALTER","DROP","TABLE","INDEX","VIEW","INTO","VALUES","SET","JOIN","LEFT","RIGHT","INNER","OUTER","ON","AND","OR","NOT","NULL","AS","ORDER","BY","GROUP","HAVING","LIMIT","OFFSET","UNION","ALL","DISTINCT","COUNT","SUM","AVG","MAX","MIN","EXISTS","BETWEEN","LIKE","IN","CASE","WHEN","THEN","ELSE","END","PRIMARY","KEY","FOREIGN","REFERENCES","CONSTRAINT","DEFAULT","CHECK","UNIQUE","CASCADE"];
|
||||
else if(lang==="bash")kw=["if","then","else","elif","fi","for","while","do","done","case","esac","function","return","exit","export","local","readonly","unset","echo","printf","source","exec","trap"];
|
||||
else if(lang==="powershell")kw=["function","param","if","else","elseif","foreach","for","while","do","until","switch","case","break","continue","return","throw","try","catch","finally","trap","begin","process","end","param","Write-Host","Write-Output","Get-Content","Set-Content","Out-File"];
|
||||
if(kw.length>0){{
|
||||
var re=new RegExp("\\\\b("+kw.join("|")+")\\\\b","g");
|
||||
s=s.replace(re,'<span class="hl-kw">$1</span>');
|
||||
}}
|
||||
// Decorators (Python) & function calls
|
||||
if(lang==="python"){{
|
||||
s=s.replace(/(@\\w+)/g,'<span class="hl-fn">$1</span>');
|
||||
}}
|
||||
return s;
|
||||
}}
|
||||
function copyCode(){{
|
||||
var text=Array.from(codeEl.children).map(function(s){{return s.textContent}}).join("\\n");
|
||||
navigator.clipboard.writeText(text).then(function(){{
|
||||
var btn=document.querySelector(".btn-copy");
|
||||
btn.textContent="✓ Copied!";
|
||||
btn.classList.add("copied");
|
||||
setTimeout(function(){{btn.textContent="📋 Copy";btn.classList.remove("copied");}},2000);
|
||||
}});
|
||||
}}
|
||||
</script>'''
|
||||
else:
|
||||
html += f'''<div class="unsupported">
|
||||
<div class="icon">📎</div>
|
||||
<h2>{title}</h2>
|
||||
<p>{size_str} · {mime_type}<br>This file type cannot be previewed.</p>
|
||||
<a href="{file_url}" download>⬇ Download</a>
|
||||
</div>'''
|
||||
|
||||
html += "</div></body></html>"
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
@router.post("/api/sync/{owner}/{repo}")
|
||||
async def sync_project(owner: str, repo: str):
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""FlowDeck — My Tasks router (v1.9.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
|
||||
|
||||
|
||||
def _get_current_user(request: Request) -> dict | None:
|
||||
session = request.cookies.get("flowdeck_session", "")
|
||||
return SessionManager.decode_session(session)
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
|
||||
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
|
||||
user = _get_current_user(request)
|
||||
user_login = user.get("login", "admin") if user else "admin"
|
||||
|
||||
with get_conn() as conn:
|
||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||
grouped: dict[str, list[dict]] = {}
|
||||
|
||||
for col in collections:
|
||||
col_id = col["id"]
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
(col_id,),
|
||||
).fetchall()
|
||||
|
||||
collection_tasks = []
|
||||
for p in pages:
|
||||
props = json.loads(p["property_values_json"])
|
||||
# Check if assigned to current user
|
||||
assigned = False
|
||||
for v in props.values():
|
||||
if isinstance(v, list):
|
||||
for item in v:
|
||||
if isinstance(item, dict) and item.get("login") == user_login:
|
||||
assigned = True
|
||||
break
|
||||
elif isinstance(v, str) and user_login.lower() in v.lower():
|
||||
assigned = True
|
||||
if assigned:
|
||||
break
|
||||
|
||||
if not assigned and user_login == "admin":
|
||||
assigned = True # admin sees all
|
||||
|
||||
if not assigned:
|
||||
continue
|
||||
|
||||
# Extract status and due date
|
||||
status = None
|
||||
due_date = None
|
||||
for v in props.values():
|
||||
if isinstance(v, str) and v in ("Todo", "In Progress", "Done", "Complete"):
|
||||
status = v
|
||||
elif isinstance(v, str) and v.startswith("20"):
|
||||
due_date = v[:10]
|
||||
|
||||
# Filter by view
|
||||
if view == "today":
|
||||
from datetime import date
|
||||
if not due_date or due_date != date.today().isoformat():
|
||||
continue
|
||||
elif view == "overdue":
|
||||
from datetime import date
|
||||
if not due_date or due_date >= date.today().isoformat():
|
||||
continue
|
||||
elif view == "upcoming":
|
||||
from datetime import date, timedelta
|
||||
if not due_date:
|
||||
continue
|
||||
today = date.today()
|
||||
limit = today + timedelta(days=days)
|
||||
d = date.fromisoformat(due_date)
|
||||
if d < today or d > limit:
|
||||
continue
|
||||
|
||||
collection_tasks.append({
|
||||
"id": p["id"], "title": p["title"], "icon": p.get("icon", "📄"),
|
||||
"status": status, "due_date": due_date, "props": props,
|
||||
})
|
||||
|
||||
if collection_tasks:
|
||||
grouped[col["name"]] = collection_tasks
|
||||
|
||||
total = sum(len(t) for t in grouped.values())
|
||||
|
||||
# Render
|
||||
sections = ""
|
||||
for col_name, tasks in grouped.items():
|
||||
items = ""
|
||||
for t in tasks:
|
||||
due_badge = f"<span class='mt-due'>{t['due_date']}</span>" if t.get("due_date") else ""
|
||||
status_badge = f"<span class='mt-status mt-{t['status'].lower().replace(' ','-') if t.get('status') else 'none'}'>{t.get('status','—')}</span>"
|
||||
items += f"""<div class='mt-item'>
|
||||
<span class='mt-icon'>{t['icon']}</span>
|
||||
<span class='mt-title'>{t['title']}</span>
|
||||
{status_badge}{due_badge}
|
||||
</div>"""
|
||||
|
||||
sections += f"""<div class='mt-section'>
|
||||
<div class='mt-section-header'>{col_name} <span class='mt-count'>{len(tasks)}</span></div>
|
||||
{items}
|
||||
</div>"""
|
||||
|
||||
return HTMLResponse(f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>My Tasks — FlowDeck</title>
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;background:#191919;color:#fff;margin:0;padding:20px}}
|
||||
h1{{font-size:24px;margin:0 0 16px}}
|
||||
.view-tabs{{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid #333;padding-bottom:8px}}
|
||||
.tab{{padding:6px 14px;border-radius:6px;cursor:pointer;color:#A0A0A0;font-size:13px;background:none;border:none;text-decoration:none}}
|
||||
.tab:hover,.tab.active{{background:#333;color:#fff}}
|
||||
.mt-section{{margin-bottom:24px}}
|
||||
.mt-section-header{{font-size:14px;font-weight:600;color:#A0A0A0;margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px}}
|
||||
.mt-count{{color:#6B6B6B;font-weight:400}}
|
||||
.mt-item{{display:flex;align-items:center;gap:10px;padding:8px 12px;background:#1a1a1a;border-radius:6px;margin-bottom:3px;border:1px solid #222}}
|
||||
.mt-item:hover{{border-color:#444}}
|
||||
.mt-icon{{font-size:16px}}
|
||||
.mt-title{{flex:1;font-size:14px}}
|
||||
.mt-status{{font-size:11px;padding:2px 8px;border-radius:4px}}
|
||||
.mt-due{{font-size:11px;color:#A0A0A0}}
|
||||
.mt-todo{{background:#333;color:#A0A0A0}}
|
||||
.mt-in-progress{{background:#1a2744;color:#3366CC}}
|
||||
.mt-done,.mt-complete{{background:#1a332a;color:#00CC66}}
|
||||
.p_desc{{color:#A0A0A0;margin-top:8px}}
|
||||
</style></head><body>
|
||||
<h1>📋 My Tasks</h1>
|
||||
<div class="view-tabs">
|
||||
<a class="tab{' active' if view=='all' else ''}" href="?view=all">All</a>
|
||||
<a class="tab{' active' if view=='today' else ''}" href="?view=today">Today</a>
|
||||
<a class="tab{' active' if view=='overdue' else ''}" href="?view=overdue">Overdue</a>
|
||||
<a class="tab{' active' if view=='upcoming' else ''}" href="?view=upcoming&days=7">Next 7 days</a>
|
||||
</div>
|
||||
{sections}
|
||||
<p class="p_desc">{total} tasks across {len(grouped)} collections</p>
|
||||
</body></html>""")
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
async def my_tasks_api(request: Request, view: str = "all", days: int = 7):
|
||||
"""API: return my tasks as JSON."""
|
||||
user = _get_current_user(request)
|
||||
user_login = user.get("login", "admin") if user else "admin"
|
||||
|
||||
with get_conn() as conn:
|
||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||
result = {}
|
||||
|
||||
for col in collections:
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
(col["id"],),
|
||||
).fetchall()
|
||||
|
||||
tasks = []
|
||||
for p in pages:
|
||||
props = json.loads(p["property_values_json"])
|
||||
tasks.append({
|
||||
"id": p["id"], "title": p["title"], "icon": p["icon"],
|
||||
"properties": props,
|
||||
})
|
||||
|
||||
if tasks:
|
||||
result[col["name"]] = tasks
|
||||
|
||||
return {"tasks": result, "total": sum(len(t) for t in result.values())}
|
||||
@@ -0,0 +1,89 @@
|
||||
"""FlowDeck — Public API router (v2.1.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Header, Depends
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["public-api"], prefix="/api/v1")
|
||||
DEFAULT_TOKEN = "fd-public-key"
|
||||
|
||||
|
||||
def verify_token(authorization: str | None = Header(None)):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(401, "API token required. Generate one via POST /api/v1/token.")
|
||||
token = authorization[7:] # strip "Bearer "
|
||||
if token == DEFAULT_TOKEN:
|
||||
return token
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT 1 FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(403, "Invalid API token")
|
||||
return token
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
async def generate_token(request: Request):
|
||||
"""Generate a public API access token."""
|
||||
token = f"fd_{token_urlsafe(24)}"
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
(0, token),
|
||||
)
|
||||
conn.commit()
|
||||
return {"token": token, "note": "Use as: Authorization: Bearer <token>"}
|
||||
|
||||
|
||||
@router.get("/collections", dependencies=[Depends(verify_token)])
|
||||
async def public_list_collections(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, description, icon, created_at FROM collections ORDER BY name").fetchall()
|
||||
return {"collections": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}", dependencies=[Depends(verify_token)])
|
||||
async def public_get_collection(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(404, "Not found")
|
||||
pages = conn.execute(
|
||||
"SELECT id, title, icon, position, property_values_json, created_at, updated_at FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return {"collection": dict(coll), "pages": [dict(p) for p in pages]}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/pages", dependencies=[Depends(verify_token)])
|
||||
async def public_list_pages(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
"SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return {"pages": [dict(p) for p in pages]}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", dependencies=[Depends(verify_token)])
|
||||
async def public_get_page(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
p = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not p:
|
||||
raise HTTPException(404, "Not found")
|
||||
return dict(p)
|
||||
|
||||
|
||||
@router.get("/my-tasks", dependencies=[Depends(verify_token)])
|
||||
async def public_my_tasks(request: Request):
|
||||
"""Public API: list tasks (requires valid token)."""
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE parent_id IS NULL ORDER BY created_at DESC LIMIT 50"
|
||||
).fetchall()
|
||||
return {"tasks": [dict(p) for p in pages]}
|
||||
@@ -0,0 +1,411 @@
|
||||
"""FlowDeck — Workspace, Comments, Favorites, History, Templates (v2.0.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
||||
|
||||
ROLES = ["admin", "editor", "commenter", "viewer"]
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
s = request.cookies.get("flowdeck_session", "")
|
||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||
|
||||
|
||||
# ── Workspaces ──
|
||||
|
||||
@router.get("")
|
||||
async def list_workspaces(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM workspaces ORDER BY name").fetchall()
|
||||
return {"workspaces": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_workspace(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "Default Workspace")
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
|
||||
with get_conn() as conn:
|
||||
uid_ensured = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not uid_ensured:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||
(uid, user.get("login", "admin"), user.get("full_name", "Admin")))
|
||||
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?,?)", (name, uid))
|
||||
ws_id = cur.lastrowid
|
||||
conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
|
||||
(ws_id, uid, "admin"))
|
||||
conn.commit()
|
||||
return {"id": ws_id, "name": name, "status": "created"}
|
||||
|
||||
|
||||
# ── Members ──
|
||||
|
||||
@router.get("/{ws_id}/members")
|
||||
async def list_members(request: Request, ws_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT wm.*, u.login, u.full_name, u.avatar_url FROM workspace_members wm JOIN users u ON wm.user_id=u.id WHERE wm.workspace_id=?",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
return {"members": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/{ws_id}/members")
|
||||
async def add_member(request: Request, ws_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = body.get("user_id")
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
raise HTTPException(400, f"Invalid role: {role}")
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||
(user_id, f"user_{user_id}", f"User {user_id}"))
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
|
||||
(ws_id, user_id, role))
|
||||
conn.commit()
|
||||
return {"status": "added"}
|
||||
|
||||
|
||||
@router.put("/{ws_id}/members/{user_id}")
|
||||
async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
raise HTTPException(400, f"Invalid role: {role}")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?",
|
||||
(role, ws_id, user_id))
|
||||
conn.commit()
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/{ws_id}/members/{user_id}")
|
||||
async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
|
||||
conn.commit()
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
# ── Comments ──
|
||||
|
||||
@router.get("/pages/{page_id}/comments")
|
||||
async def list_comments(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT c.*, u.login, u.avatar_url FROM comments c JOIN users u ON c.user_id=u.id WHERE c.page_id=? ORDER BY c.created_at",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return {"comments": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/comments")
|
||||
async def add_comment(request: Request, page_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
b = body.get("body", "").strip()
|
||||
if not b:
|
||||
raise HTTPException(400, "body required")
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
parent_id = body.get("parent_id")
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||
(uid, user.get("login", "admin"), user.get("full_name", "Admin")))
|
||||
cur = conn.execute("INSERT INTO comments (page_id, user_id, body, parent_id) VALUES (?,?,?,?)",
|
||||
(page_id, uid, b, parent_id))
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/comments/{comment_id}")
|
||||
async def update_comment(request: Request, comment_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
b = body.get("body")
|
||||
resolved = body.get("resolved")
|
||||
with get_conn() as conn:
|
||||
if b is not None:
|
||||
conn.execute("UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (b, comment_id))
|
||||
if resolved is not None:
|
||||
conn.execute("UPDATE comments SET resolved=? WHERE id=?", (int(resolved), comment_id))
|
||||
conn.commit()
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
# ── Page History ──
|
||||
|
||||
@router.get("/pages/{page_id}/history")
|
||||
async def page_history(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return {"history": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/history")
|
||||
async def record_history(request: Request, page_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
|
||||
conn.execute(
|
||||
"INSERT INTO page_history (page_id, user_id, change_type, snapshot_json) VALUES (?,?,?,?)",
|
||||
(page_id, uid, body.get("change_type", "updated"), json.dumps(body.get("snapshot", {}))),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "recorded"}
|
||||
|
||||
|
||||
# ── Favorites ──
|
||||
|
||||
@router.get("/favorites")
|
||||
async def list_favorites(request: Request):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT f.*, cp.title as page_title, c.name as collection_name
|
||||
FROM favorites f
|
||||
LEFT JOIN collection_pages cp ON f.page_id=cp.id
|
||||
LEFT JOIN collections c ON f.collection_id=c.id
|
||||
WHERE f.user_id=? ORDER BY f.position""",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
return {"favorites": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/favorites")
|
||||
async def add_favorite(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
page_id = body.get("page_id")
|
||||
collection_id = body.get("collection_id")
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO favorites (user_id, page_id, collection_id) VALUES (?,?,?)",
|
||||
(uid, page_id, collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "favorited"}
|
||||
|
||||
|
||||
@router.delete("/favorites/{fav_id}")
|
||||
async def remove_favorite(request: Request, fav_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
|
||||
conn.commit()
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
# ── Templates ──
|
||||
|
||||
@router.get("/templates/database")
|
||||
async def list_db_templates(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
|
||||
return {"templates": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/templates/database")
|
||||
async def create_db_template(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
cur = None
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO database_templates (name, description, schema_json) VALUES (?,?,?)",
|
||||
(body.get("name", "Template"), body.get("description", ""), json.dumps(body.get("schema", []))),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@router.post("/templates/database/{tid}/apply")
|
||||
async def apply_db_template(request: Request, tid: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "New Database")
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (tid,)).fetchone()
|
||||
if not tmpl:
|
||||
raise HTTPException(404, "Template not found")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collections (name, description, icon, schema_json) VALUES (?,?,?,?)",
|
||||
(name, tmpl["description"], "📋", tmpl["schema_json"]),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)",
|
||||
(cur.lastrowid, "Default View", "table", "{}"),
|
||||
)
|
||||
conn.commit()
|
||||
return {"collection_id": cur.lastrowid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/templates/page")
|
||||
async def list_page_templates(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,)
|
||||
).fetchall()
|
||||
return {"templates": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/templates/page")
|
||||
async def create_page_template(request: Request, collection_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO page_templates (collection_id, name, property_values_json) VALUES (?,?,?)",
|
||||
(collection_id, body.get("name", "Default"), json.dumps(body.get("properties", {}))),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/templates/page/{tid}/apply")
|
||||
async def apply_page_template(request: Request, collection_id: int, tid: int):
|
||||
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")
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (collection_id,)
|
||||
).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
|
||||
(collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
# ── CSV Import/Export ──
|
||||
|
||||
@router.post("/collections/{collection_id}/import/csv")
|
||||
async def import_csv(request: Request, collection_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
csv_data = body.get("csv", "")
|
||||
if not csv_data:
|
||||
raise HTTPException(400, "csv field required")
|
||||
|
||||
reader = csv.DictReader(io.StringIO(csv_data))
|
||||
rows_imported = 0
|
||||
with get_conn() as conn:
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) FROM collection_pages WHERE collection_id=?", (collection_id,)
|
||||
).fetchone()[0]
|
||||
for row in reader:
|
||||
title = row.get("title", row.get("Title", row.get("Name", "Imported")))
|
||||
props = {k: v for k, v in row.items() if k.lower() != "title"}
|
||||
max_pos += 1
|
||||
conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
|
||||
(collection_id, title, max_pos, json.dumps(props)),
|
||||
)
|
||||
rows_imported += 1
|
||||
conn.commit()
|
||||
return {"imported": rows_imported}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/export/csv")
|
||||
async def export_csv(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)
|
||||
).fetchall()
|
||||
|
||||
# Collect all property keys
|
||||
all_keys = set()
|
||||
rows = []
|
||||
for p in pages:
|
||||
props = json.loads(p["property_values_json"])
|
||||
all_keys.update(props.keys())
|
||||
rows.append({"title": p["title"], **props})
|
||||
|
||||
output = io.StringIO()
|
||||
fieldnames = ["title"] + sorted(all_keys)
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=export.csv"},
|
||||
)
|
||||
|
||||
|
||||
# ── Webhooks Outbound Management ──
|
||||
|
||||
@router.get("/webhooks")
|
||||
async def list_webhooks(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
|
||||
return {"webhooks": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/webhooks")
|
||||
async def create_webhook(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
url = body.get("url", "").strip()
|
||||
event = body.get("event", "page.created")
|
||||
secret = body.get("secret", "")
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?,?,?)",
|
||||
(url, event, secret),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "url": url, "event": event, "status": "registered"}
|
||||
|
||||
|
||||
@router.delete("/webhooks/{wh_id}")
|
||||
async def delete_webhook(request: Request, wh_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
|
||||
conn.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
# ── Public Sharing ──
|
||||
|
||||
@router.get("/public/{collection_id}")
|
||||
async def public_view(request: Request, collection_id: int):
|
||||
"""Simple public read-only view — no auth required."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
pages = conn.execute(
|
||||
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
items = "".join(
|
||||
f"<li>{p['icon']} <b>{p['title']}</b></li>"
|
||||
for p in pages
|
||||
)
|
||||
return HTMLResponse(f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>{coll['name']} — FlowDeck Public</title>
|
||||
<style>body{{font-family:system-ui;background:#191919;color:#fff;padding:20px}}
|
||||
h1{{font-size:24px}} li{{padding:8px;border-bottom:1px solid #333}}</style></head>
|
||||
<body><h1>{coll['icon']} {coll['name']}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
|
||||
@@ -0,0 +1,222 @@
|
||||
"""FlowDeck — GiteaBoardCompat: legacy board → Collection adapter (v1.3.0).
|
||||
|
||||
Converts existing Gitea-linked boards and cards into the new collection model
|
||||
without breaking the existing board routes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
|
||||
class GiteaBoardCompat:
|
||||
"""Adaptateur: board Gitea legacy → Collection.
|
||||
|
||||
Permet de visualiser les boards Gitea existants comme des collections,
|
||||
avec rétrocompatibilité totale. Les anciennes routes /board/... continuent
|
||||
de fonctionner normalement.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def from_board(board_row) -> dict:
|
||||
"""Convertit un board existant en pseudo-collection."""
|
||||
columns = json.loads(board_row.get("columns_json", "[]"))
|
||||
return {
|
||||
"collection_id": board_row["id"],
|
||||
"name": f"{board_row['project_owner']}/{board_row['project_name']}",
|
||||
"description": f"Board Gitea: {board_row['project_name']}",
|
||||
"icon": "📋",
|
||||
"gitea_owner": board_row["project_owner"],
|
||||
"gitea_repo": board_row["project_name"],
|
||||
"is_gitea_linked": True,
|
||||
"is_locked": False,
|
||||
"schema": [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Status", "type": "select", "options": [
|
||||
{"name": c, "color": "gray"} for c in columns
|
||||
]},
|
||||
{"name": "Priority", "type": "select", "options": [
|
||||
{"name": "P1", "color": "red"},
|
||||
{"name": "P2", "color": "orange"},
|
||||
{"name": "P3", "color": "yellow"},
|
||||
{"name": "P4", "color": "gray"},
|
||||
]},
|
||||
{"name": "Due Date", "type": "date"},
|
||||
{"name": "Assignee", "type": "person"},
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_card(card_row, gitea_issue: Optional[dict] = None) -> dict:
|
||||
"""Convertit une card legacy en pseudo collection_page."""
|
||||
title = gitea_issue.get("title", f"Card #{card_row['id']}") if gitea_issue else f"Card #{card_row['id']}"
|
||||
priority = card_row.get("priority", "Medium")
|
||||
due_date = card_row.get("due_date", "")
|
||||
|
||||
props = {
|
||||
"Status": card_row.get("column_name", "Backlog"),
|
||||
"Priority": priority,
|
||||
"Due Date": due_date if due_date else None,
|
||||
}
|
||||
|
||||
if gitea_issue:
|
||||
assignee = gitea_issue.get("assignee")
|
||||
if assignee:
|
||||
props["Assignee"] = [{"login": assignee.get("login", ""), "avatar_url": assignee.get("avatar_url", "")}]
|
||||
|
||||
return {
|
||||
"id": card_row["id"],
|
||||
"title": title,
|
||||
"position": card_row.get("position", 0),
|
||||
"gitea_issue_id": card_row.get("gitea_issue_id"),
|
||||
"gitea_issue_number": gitea_issue.get("number") if gitea_issue else None,
|
||||
"property_values_json": json.dumps(props),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def list_boards_as_collections() -> list[dict]:
|
||||
"""Liste tous les boards comme des collections compatibles."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM boards ORDER BY project_owner, project_name").fetchall()
|
||||
return [GiteaBoardCompat.from_board(dict(r)) for r in rows]
|
||||
|
||||
@staticmethod
|
||||
def get_board_as_collection(owner: str, repo: str) -> Optional[dict]:
|
||||
"""Récupère un board spécifique comme collection."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
|
||||
(owner, repo),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return GiteaBoardCompat.from_board(dict(row))
|
||||
|
||||
@staticmethod
|
||||
def get_board_cards(owner: str, repo: str, gitea_issues: Optional[list[dict]] = None) -> list[dict]:
|
||||
"""Récupère les cartes d'un board comme collection_pages."""
|
||||
with get_conn() as conn:
|
||||
board = conn.execute(
|
||||
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
||||
(owner, repo),
|
||||
).fetchone()
|
||||
if not board:
|
||||
return []
|
||||
|
||||
cards = conn.execute(
|
||||
"SELECT * FROM cards WHERE board_id=? ORDER BY position",
|
||||
(board["id"],),
|
||||
).fetchall()
|
||||
|
||||
issues_by_id = {}
|
||||
if gitea_issues:
|
||||
issues_by_id = {i.get("id"): i for i in gitea_issues}
|
||||
|
||||
return [
|
||||
GiteaBoardCompat.from_card(dict(c), issues_by_id.get(c["gitea_issue_id"]))
|
||||
for c in cards
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> Optional[int]:
|
||||
"""Sync un board Gitea vers une vraie collection.
|
||||
|
||||
Crée ou met à jour une collection liée à Gitea et importe les pages.
|
||||
Retourne l'ID de la collection.
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
board = conn.execute(
|
||||
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
|
||||
(owner, repo),
|
||||
).fetchone()
|
||||
if not board:
|
||||
return None
|
||||
|
||||
board_dict = dict(board)
|
||||
columns = json.loads(board_dict.get("columns_json", "[]"))
|
||||
|
||||
# Upsert collection
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM collections WHERE gitea_owner=? AND gitea_repo=?",
|
||||
(owner, repo),
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
coll_id = existing["id"]
|
||||
conn.execute(
|
||||
"UPDATE collections SET name=?, schema_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(f"{owner}/{repo}", json.dumps([
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Status", "type": "select",
|
||||
"options": [{"name": c, "color": "gray"} for c in columns]},
|
||||
{"name": "Priority", "type": "select",
|
||||
"options": [{"name": "P1", "color": "red"}, {"name": "P2", "color": "orange"},
|
||||
{"name": "P3", "color": "yellow"}, {"name": "P4", "color": "gray"}]},
|
||||
]), coll_id),
|
||||
)
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections (name, icon, schema_json, gitea_owner, gitea_repo)
|
||||
VALUES (?, '📋', ?, ?, ?)""",
|
||||
(f"{owner}/{repo}", json.dumps([
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Status", "type": "select",
|
||||
"options": [{"name": c, "color": "gray"} for c in columns]},
|
||||
{"name": "Priority", "type": "select",
|
||||
"options": [{"name": "P1", "color": "red"}, {"name": "P2", "color": "orange"},
|
||||
{"name": "P3", "color": "yellow"}, {"name": "P4", "color": "gray"}]},
|
||||
]), owner, repo),
|
||||
)
|
||||
coll_id = cur.lastrowid
|
||||
|
||||
# Create default view
|
||||
conn.execute(
|
||||
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?, ?, ?, ?)",
|
||||
(coll_id, "Default View", "table", json.dumps({"visible_properties": ["Title", "Status", "Priority"]})),
|
||||
)
|
||||
|
||||
# Sync cards → collection_pages
|
||||
cards = conn.execute(
|
||||
"SELECT * FROM cards WHERE board_id=? ORDER BY position",
|
||||
(board_dict["id"],),
|
||||
).fetchall()
|
||||
|
||||
issues_by_id = {i.get("id"): i for i in gitea_issues}
|
||||
|
||||
for card in cards:
|
||||
card_dict = dict(card)
|
||||
issue = issues_by_id.get(card_dict.get("gitea_issue_id"))
|
||||
title = issue.get("title", f"Issue #{card_dict.get('gitea_issue_id', card_dict['id'])}") if issue else f"Card #{card_dict['id']}"
|
||||
|
||||
props = {
|
||||
"Status": card_dict.get("column_name", "Backlog"),
|
||||
"Priority": card_dict.get("priority", "Medium"),
|
||||
}
|
||||
|
||||
# Upsert collection_page
|
||||
existing_page = conn.execute(
|
||||
"SELECT id FROM collection_pages WHERE collection_id=? AND gitea_issue_id=?",
|
||||
(coll_id, card_dict.get("gitea_issue_id")),
|
||||
).fetchone()
|
||||
|
||||
if existing_page:
|
||||
conn.execute(
|
||||
"""UPDATE collection_pages SET title=?, position=?, property_values_json=?,
|
||||
updated_at=CURRENT_TIMESTAMP WHERE id=?""",
|
||||
(title, card_dict.get("position", 0), json.dumps(props), existing_page["id"]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, position, gitea_issue_id, gitea_issue_number,
|
||||
property_values_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(coll_id, title, card_dict.get("position", 0),
|
||||
card_dict.get("gitea_issue_id"), issue.get("number") if issue else None,
|
||||
json.dumps(props)),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return coll_id
|
||||
@@ -0,0 +1,223 @@
|
||||
"""FlowDeck — Formula Engine (v1.5.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
class FormulaEngine:
|
||||
"""Moteur d'expressions JavaScript-like pour propriétés calculées.
|
||||
|
||||
Usage:
|
||||
engine = FormulaEngine()
|
||||
result = engine.evaluate("prop('Price') * prop('Qty')", {"Price": 10, "Qty": 5})
|
||||
# → 50
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._functions: dict[str, Callable] = {
|
||||
"prop": self._prop,
|
||||
"now": self._now,
|
||||
"today": self._today,
|
||||
"if": self._if,
|
||||
"concat": self._concat,
|
||||
"round": self._round,
|
||||
"contains": self._contains,
|
||||
"length": self._length,
|
||||
"toNumber": self._to_number,
|
||||
"formatDate": self._format_date,
|
||||
"dateAdd": self._date_add,
|
||||
"dateSubtract": self._date_subtract,
|
||||
"replace": self._replace,
|
||||
"replaceAll": self._replace_all,
|
||||
"join": self._join,
|
||||
"empty": self._empty,
|
||||
"and": self._and,
|
||||
"or": self._or,
|
||||
"not": self._not,
|
||||
"start": self._start,
|
||||
"end": self._end,
|
||||
}
|
||||
|
||||
def evaluate(self, expression: str, context: dict[str, Any]) -> Any:
|
||||
"""Évalue une expression de formule dans un contexte donné.
|
||||
|
||||
Le contexte contient les valeurs des propriétés accessibles via prop('name').
|
||||
"""
|
||||
if not expression or not expression.strip():
|
||||
return None
|
||||
try:
|
||||
return self._eval_expr(expression.strip(), context)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _eval_expr(self, expr: str, ctx: dict[str, Any]) -> Any:
|
||||
"""Parse et évalue l'expression."""
|
||||
expr = expr.strip()
|
||||
|
||||
# Function call
|
||||
if "(" in expr and expr.endswith(")"):
|
||||
paren = expr.index("(")
|
||||
func_name = expr[:paren].strip()
|
||||
args_str = expr[paren + 1:-1]
|
||||
args = self._parse_args(args_str)
|
||||
evaluated = [self._eval_expr(a.strip(), ctx) for a in args]
|
||||
if func_name in self._functions:
|
||||
return self._functions[func_name](ctx, *evaluated)
|
||||
return None
|
||||
|
||||
# String literal
|
||||
if (expr.startswith("'") and expr.endswith("'")) or \
|
||||
(expr.startswith('"') and expr.endswith('"')):
|
||||
return expr[1:-1]
|
||||
|
||||
# Number
|
||||
try:
|
||||
return float(expr) if "." in expr else int(expr)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Boolean
|
||||
if expr.lower() == "true":
|
||||
return True
|
||||
if expr.lower() == "false":
|
||||
return False
|
||||
if expr.lower() == "null":
|
||||
return None
|
||||
|
||||
# Context lookup
|
||||
if expr in ctx:
|
||||
return ctx[expr]
|
||||
|
||||
return expr # raw string
|
||||
|
||||
def _parse_args(self, s: str) -> list[str]:
|
||||
"""Parse une liste d'arguments en respectant les guillemets."""
|
||||
args = []
|
||||
current = ""
|
||||
depth = 0
|
||||
in_quote = False
|
||||
quote_char = ""
|
||||
for ch in s:
|
||||
if ch in ("'", '"') and not in_quote:
|
||||
in_quote = True
|
||||
quote_char = ch
|
||||
current += ch
|
||||
elif ch == quote_char and in_quote:
|
||||
in_quote = False
|
||||
current += ch
|
||||
elif ch == "(":
|
||||
depth += 1
|
||||
current += ch
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
current += ch
|
||||
elif ch == "," and depth == 0 and not in_quote:
|
||||
args.append(current.strip())
|
||||
current = ""
|
||||
else:
|
||||
current += ch
|
||||
if current.strip():
|
||||
args.append(current.strip())
|
||||
return args
|
||||
|
||||
# ── Built-in functions ──
|
||||
|
||||
def _prop(self, ctx: dict, name: str) -> Any:
|
||||
return ctx.get(name)
|
||||
|
||||
def _now(self, ctx: dict) -> str:
|
||||
return datetime.now().isoformat()
|
||||
|
||||
def _today(self, ctx: dict) -> str:
|
||||
return date.today().isoformat()
|
||||
|
||||
def _if(self, ctx: dict, cond: Any, a: Any, b: Any) -> Any:
|
||||
return a if cond else b
|
||||
|
||||
def _concat(self, ctx: dict, *args) -> str:
|
||||
return "".join(str(a) for a in args)
|
||||
|
||||
def _round(self, ctx: dict, n: Any, d: int = 0) -> float:
|
||||
return round(float(n), int(d))
|
||||
|
||||
def _contains(self, ctx: dict, s: Any, sub: str) -> bool:
|
||||
return str(sub) in str(s)
|
||||
|
||||
def _length(self, ctx: dict, s: Any) -> int:
|
||||
return len(str(s))
|
||||
|
||||
def _to_number(self, ctx: dict, s: Any) -> float:
|
||||
try:
|
||||
return float(s)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
def _format_date(self, ctx: dict, d: str, fmt: str = "%Y-%m-%d") -> str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(d.replace("Z", "+00:00"))
|
||||
return dt.strftime(fmt)
|
||||
except Exception:
|
||||
return str(d)
|
||||
|
||||
def _date_add(self, ctx: dict, d: str, n: int, unit: str = "days") -> str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(d.replace("Z", "+00:00"))
|
||||
if unit == "days":
|
||||
dt += timedelta(days=int(n))
|
||||
elif unit == "hours":
|
||||
dt += timedelta(hours=int(n))
|
||||
elif unit == "minutes":
|
||||
dt += timedelta(minutes=int(n))
|
||||
return dt.isoformat()
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def _date_subtract(self, ctx: dict, d: str, n: int, unit: str = "days") -> str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(d.replace("Z", "+00:00"))
|
||||
if unit == "days":
|
||||
dt -= timedelta(days=int(n))
|
||||
elif unit == "hours":
|
||||
dt -= timedelta(hours=int(n))
|
||||
elif unit == "minutes":
|
||||
dt -= timedelta(minutes=int(n))
|
||||
return dt.isoformat()
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def _replace(self, ctx: dict, s: Any, old: str, new: str) -> str:
|
||||
return str(s).replace(old, new)
|
||||
|
||||
def _replace_all(self, ctx: dict, s: Any, old: str, new: str) -> str:
|
||||
return str(s).replace(old, new)
|
||||
|
||||
def _join(self, ctx: dict, sep: str, *args) -> str:
|
||||
return sep.join(str(a) for a in args)
|
||||
|
||||
def _empty(self, ctx: dict, v: Any) -> bool:
|
||||
return v is None or v == "" or v == [] or v == 0
|
||||
|
||||
def _and(self, ctx: dict, *args) -> bool:
|
||||
return all(args)
|
||||
|
||||
def _or(self, ctx: dict, *args) -> bool:
|
||||
return any(args)
|
||||
|
||||
def _not(self, ctx: dict, v: Any) -> bool:
|
||||
return not v
|
||||
|
||||
def _start(self, ctx: dict, s: Any) -> str:
|
||||
"""Extract start date from a date range (iso1...iso2 or just iso1)."""
|
||||
val = str(s)
|
||||
if "..." in val:
|
||||
return val.split("...")[0]
|
||||
return val
|
||||
|
||||
def _end(self, ctx: dict, s: Any) -> Optional[str]:
|
||||
"""Extract end date from a date range."""
|
||||
val = str(s)
|
||||
if "..." in val:
|
||||
parts = val.split("...")
|
||||
return parts[1] if len(parts) > 1 else None
|
||||
return None
|
||||
@@ -0,0 +1,187 @@
|
||||
"""FlowDeck — Property types & validation (v1.4.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
# ── Property type definitions ──
|
||||
|
||||
PROPERTY_TYPES: dict[str, dict] = {
|
||||
"title": {
|
||||
"storage": "string",
|
||||
"validation": "max 2000 chars",
|
||||
"default": "",
|
||||
},
|
||||
"text": {
|
||||
"storage": "string",
|
||||
"default": "",
|
||||
},
|
||||
"number": {
|
||||
"storage": "float",
|
||||
"validation": "numeric",
|
||||
"format_options": ["number", "percent", "dollar", "euro", "pound", "yen"],
|
||||
"default": None,
|
||||
},
|
||||
"select": {
|
||||
"storage": "string (from options_json)",
|
||||
"default": None,
|
||||
},
|
||||
"multi_select": {
|
||||
"storage": "json array of strings",
|
||||
"default": [],
|
||||
},
|
||||
"status": {
|
||||
"storage": "string (from options_json) + color",
|
||||
"default": None,
|
||||
"colors": ["gray", "brown", "orange", "yellow", "green", "blue", "purple", "pink", "red"],
|
||||
},
|
||||
"date": {
|
||||
"storage": "ISO 8601 string",
|
||||
"default": None,
|
||||
},
|
||||
"person": {
|
||||
"storage": "json array of {id, login, avatar_url}",
|
||||
"default": [],
|
||||
},
|
||||
"checkbox": {
|
||||
"storage": "boolean",
|
||||
"default": False,
|
||||
},
|
||||
"url": {
|
||||
"storage": "string",
|
||||
"default": "",
|
||||
},
|
||||
"email": {
|
||||
"storage": "string",
|
||||
"default": "",
|
||||
},
|
||||
"phone": {
|
||||
"storage": "string",
|
||||
"default": "",
|
||||
},
|
||||
"files": {
|
||||
"storage": "json array of {url, name}",
|
||||
"default": [],
|
||||
},
|
||||
"unique_id": {
|
||||
"storage": "integer (auto-increment per collection)",
|
||||
"default": None,
|
||||
},
|
||||
"relation": {
|
||||
"storage": "json array of page IDs",
|
||||
"default": [],
|
||||
},
|
||||
"rollup": {
|
||||
"storage": "computed — not stored",
|
||||
"default": None,
|
||||
},
|
||||
"formula": {
|
||||
"storage": "computed — not stored",
|
||||
"default": None,
|
||||
},
|
||||
"created_time": {
|
||||
"storage": "auto — ISO 8601",
|
||||
"default": None,
|
||||
},
|
||||
"created_by": {
|
||||
"storage": "auto — {id, login}",
|
||||
"default": None,
|
||||
},
|
||||
"last_edited_time": {
|
||||
"storage": "auto — ISO 8601",
|
||||
"default": None,
|
||||
},
|
||||
"last_edited_by": {
|
||||
"storage": "auto — {id, login}",
|
||||
"default": None,
|
||||
},
|
||||
}
|
||||
|
||||
# CSV-friendly subset (no relation/rollup/formula)
|
||||
SIMPLE_TYPES = ["title", "text", "number", "select", "multi_select", "status",
|
||||
"date", "person", "checkbox", "url", "email", "phone", "files"]
|
||||
|
||||
# Auto-property types (computed server-side)
|
||||
AUTO_TYPES = ["created_time", "created_by", "last_edited_time", "last_edited_by"]
|
||||
|
||||
|
||||
def validate_property_value(prop_type: str, value: Any, options: Optional[list] = None) -> tuple[bool, str]:
|
||||
"""Validate a property value against its type. Returns (ok, error_message)."""
|
||||
if value is None:
|
||||
return True, ""
|
||||
|
||||
if prop_type == "number":
|
||||
try:
|
||||
float(value)
|
||||
except (ValueError, TypeError):
|
||||
return False, f"'{value}' is not a valid number"
|
||||
elif prop_type == "checkbox":
|
||||
if not isinstance(value, bool):
|
||||
return False, "Checkbox must be true or false"
|
||||
elif prop_type in ("select", "status"):
|
||||
if options and value not in [o.get("name", o) if isinstance(o, dict) else o for o in options]:
|
||||
return False, f"'{value}' is not a valid option"
|
||||
elif prop_type == "multi_select":
|
||||
if not isinstance(value, list):
|
||||
return False, "Multi-select must be a list"
|
||||
if options:
|
||||
valid_names = {o.get("name", o) if isinstance(o, dict) else o for o in options}
|
||||
for v in value:
|
||||
if v not in valid_names:
|
||||
return False, f"'{v}' is not a valid option"
|
||||
elif prop_type == "date":
|
||||
if isinstance(value, str) and value:
|
||||
try:
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
return False, f"'{value}' is not a valid ISO 8601 date"
|
||||
elif prop_type == "url":
|
||||
if not isinstance(value, str):
|
||||
return False, "URL must be a string"
|
||||
elif prop_type == "email":
|
||||
if not isinstance(value, str) or ("@" not in value if value else False):
|
||||
return False, "Invalid email format"
|
||||
elif prop_type == "phone":
|
||||
if not isinstance(value, str):
|
||||
return False, "Phone must be a string"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def get_auto_property_value(prop_type: str, user: Optional[dict] = None) -> Any:
|
||||
"""Compute the value of an auto-property."""
|
||||
if prop_type == "created_time" or prop_type == "last_edited_time":
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
if prop_type == "created_by" or prop_type == "last_edited_by":
|
||||
if user:
|
||||
return {"id": user.get("id"), "login": user.get("login")}
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def get_next_unique_id(collection_id: int, conn) -> int:
|
||||
"""Get the next unique_id for a collection (max + 1)."""
|
||||
row = conn.execute(
|
||||
"""SELECT COALESCE(MAX(CAST(json_extract(property_values_json, '$.unique_id') AS INTEGER)), 0) + 1
|
||||
FROM collection_pages WHERE collection_id=?""",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
return row[0] if row else 1
|
||||
|
||||
|
||||
def format_number(value: float, fmt: str = "number") -> str:
|
||||
"""Format a number value for display."""
|
||||
if value is None:
|
||||
return ""
|
||||
if fmt == "percent":
|
||||
return f"{value}%"
|
||||
elif fmt == "dollar":
|
||||
return f"${value:,.2f}"
|
||||
elif fmt == "euro":
|
||||
return f"€{value:,.2f}"
|
||||
elif fmt == "pound":
|
||||
return f"£{value:,.2f}"
|
||||
elif fmt == "yen":
|
||||
return f"¥{value:,.0f}"
|
||||
return str(value)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""FlowDeck — Rollup Engine (v1.5.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import statistics
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
ROLLUP_FUNCTIONS = {
|
||||
"count": lambda values: len(values),
|
||||
"count_values": lambda values: len([v for v in values if v is not None and v != ""]),
|
||||
"empty": lambda values: len([v for v in values if v is None or v == ""]),
|
||||
"not_empty": lambda values: len([v for v in values if v is not None and v != ""]),
|
||||
"sum": lambda values: sum(float(v) for v in values if v is not None),
|
||||
"average": lambda values: _safe_avg(values),
|
||||
"median": lambda values: _safe_stat(values, statistics.median),
|
||||
"min": lambda values: min(_numeric(v for v in values if v is not None), default=None),
|
||||
"max": lambda values: max(_numeric(v for v in values if v is not None), default=None),
|
||||
"range": lambda values: _safe_range(values),
|
||||
"unique": lambda values: list(set(str(v) for v in values if v is not None)),
|
||||
"percent_checked": lambda values: _percent_checked(values),
|
||||
"percent_per_group": lambda values: _percent_per_group(values),
|
||||
}
|
||||
|
||||
|
||||
class RollupEngine:
|
||||
"""Moteur d'agrégation pour les propriétés rollup."""
|
||||
|
||||
def compute(
|
||||
self,
|
||||
collection_id: int,
|
||||
relation_property_id: int,
|
||||
target_property_id: int,
|
||||
page_id: int,
|
||||
rollup_function: str,
|
||||
) -> Any:
|
||||
"""Calcule l'agrégation d'une propriété via une relation.
|
||||
|
||||
Args:
|
||||
collection_id: la collection de la page source
|
||||
relation_property_id: la propriété relation (dans cette collection)
|
||||
target_property_id: la propriété à agréger (dans la collection liée)
|
||||
page_id: la page qui porte la relation
|
||||
rollup_function: nom de la fonction (count, sum, avg, etc.)
|
||||
"""
|
||||
if rollup_function not in ROLLUP_FUNCTIONS:
|
||||
return None
|
||||
|
||||
with get_conn() as conn:
|
||||
# Get the relation property to find the related collection
|
||||
rel_prop = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=?",
|
||||
(relation_property_id,),
|
||||
).fetchone()
|
||||
if not rel_prop:
|
||||
return None
|
||||
|
||||
related_collection_id = rel_prop["related_collection_id"]
|
||||
if not related_collection_id:
|
||||
return None
|
||||
|
||||
# Get the target property name
|
||||
target_prop = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=?",
|
||||
(target_property_id,),
|
||||
).fetchone()
|
||||
if not target_prop:
|
||||
return None
|
||||
|
||||
target_name = target_prop["name"]
|
||||
|
||||
# Get the related page IDs from the source page's property_values_json
|
||||
source_page = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not source_page:
|
||||
return None
|
||||
|
||||
props = json.loads(source_page["property_values_json"])
|
||||
related_ids = props.get(str(relation_property_id), [])
|
||||
if not isinstance(related_ids, list):
|
||||
related_ids = [related_ids] if related_ids else []
|
||||
|
||||
if not related_ids:
|
||||
return ROLLUP_FUNCTIONS[rollup_function]([])
|
||||
|
||||
# Get the target values from related pages
|
||||
placeholders = ",".join("?" for _ in related_ids)
|
||||
rows = conn.execute(
|
||||
f"SELECT property_values_json FROM collection_pages WHERE id IN ({placeholders})",
|
||||
related_ids,
|
||||
).fetchall()
|
||||
|
||||
values = []
|
||||
for row in rows:
|
||||
page_props = json.loads(row["property_values_json"])
|
||||
val = page_props.get(str(target_property_id))
|
||||
if val is None:
|
||||
# Try by name
|
||||
val = page_props.get(target_name)
|
||||
values.append(val)
|
||||
|
||||
return ROLLUP_FUNCTIONS[rollup_function](values)
|
||||
|
||||
|
||||
def _safe_avg(values: list) -> Optional[float]:
|
||||
nums = [float(v) for v in values if v is not None]
|
||||
return sum(nums) / len(nums) if nums else None
|
||||
|
||||
|
||||
def _safe_stat(values: list, fn) -> Optional[float]:
|
||||
nums = [float(v) for v in values if v is not None]
|
||||
return fn(nums) if nums else None
|
||||
|
||||
|
||||
def _numeric(gen):
|
||||
for v in gen:
|
||||
try:
|
||||
yield float(v)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
def _safe_range(values: list) -> Optional[float]:
|
||||
nums = list(_numeric(v for v in values if v is not None))
|
||||
return max(nums) - min(nums) if len(nums) >= 2 else None
|
||||
|
||||
|
||||
def _percent_checked(values: list) -> Optional[float]:
|
||||
"""Percentage of true values (for checkbox properties)."""
|
||||
if not values:
|
||||
return 0.0
|
||||
booleans = [v for v in values if isinstance(v, bool)]
|
||||
if not booleans:
|
||||
return 0.0
|
||||
return (sum(1 for v in booleans if v) / len(booleans)) * 100
|
||||
|
||||
|
||||
def _percent_per_group(values: list) -> dict:
|
||||
"""Percentage distribution across groups."""
|
||||
if not values:
|
||||
return {}
|
||||
counts = {}
|
||||
for v in values:
|
||||
key = str(v) if v is not None else "Empty"
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
total = sum(counts.values())
|
||||
return {k: round(v / total * 100, 1) for k, v in counts.items()}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""FlowDeck — Webhook outbound dispatcher (v2.1.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EVENTS = [
|
||||
"page.created", "page.updated", "page.deleted",
|
||||
"collection.created", "collection.updated", "collection.deleted",
|
||||
"comment.added", "page.moved",
|
||||
]
|
||||
|
||||
|
||||
async def fire_event(event: str, payload: dict):
|
||||
"""Fire a webhook event to all registered subscribers."""
|
||||
if event not in EVENTS:
|
||||
return
|
||||
with get_conn() as conn:
|
||||
subs = conn.execute("SELECT url, secret FROM webhook_subscriptions WHERE event=?", (event,)).fetchall()
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
for sub in subs:
|
||||
url, secret = sub["url"], sub["secret"]
|
||||
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": event}
|
||||
if secret:
|
||||
headers["X-FlowDeck-Secret"] = secret
|
||||
try:
|
||||
await client.post(url, json=payload, headers=headers)
|
||||
except Exception:
|
||||
logger.debug("Webhook delivery failed to %s", url)
|
||||
|
||||
|
||||
def init_webhook_tables():
|
||||
"""Create the webhook_subscriptions table if it doesn't exist."""
|
||||
with get_conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS webhook_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
secret TEXT DEFAULT '',
|
||||
active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
@@ -0,0 +1,131 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}👤{% endblock %}
|
||||
{% block page_title %}Accounts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">👤</span>
|
||||
<h1>Account Management</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 0 24px; max-width: 900px;" x-data="accountsData()">
|
||||
<!-- Current user profile -->
|
||||
<div style="background:var(--bg-secondary); border-radius:8px; padding:20px; margin-bottom:24px;">
|
||||
<h3 style="margin-bottom:16px;">Your Profile</h3>
|
||||
<div style="display:flex; gap:16px; align-items:flex-start;">
|
||||
<div class="workspace-avatar" style="width:48px; height:48px; font-size:20px;">
|
||||
{{ user.login[0] if user else 'B' }}
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<div style="display:flex; gap:12px; margin-bottom:12px;">
|
||||
<div style="flex:1;">
|
||||
<label style="font-size:12px; color:var(--text-dim);">Username</label>
|
||||
<input type="text" value="{{ user.login }}" disabled
|
||||
style="width:100%; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px;
|
||||
padding:8px; color:var(--text-dim); font-size:14px;">
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<label style="font-size:12px; color:var(--text-dim);">Display name</label>
|
||||
<input type="text" x-model="profile.full_name"
|
||||
style="width:100%; background:var(--bg-secondary); border:1px solid var(--border); border-radius:6px;
|
||||
padding:8px; color:var(--text-primary); font-size:14px; outline:none;">
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<label style="font-size:12px; color:var(--text-dim);">Email</label>
|
||||
<input type="text" x-model="profile.email"
|
||||
style="width:100%; background:var(--bg-secondary); border:1px solid var(--border); border-radius:6px;
|
||||
padding:8px; color:var(--text-primary); font-size:14px; outline:none;">
|
||||
</div>
|
||||
<button class="btn-new" @click="saveProfile()">Save Profile</button>
|
||||
<span x-show="saved" style="color:var(--green); font-size:13px; margin-left:8px;">✓ Saved</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected accounts -->
|
||||
<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>
|
||||
<div style="flex:1;">
|
||||
<div style="font-weight:600;">Gitea</div>
|
||||
<div style="font-size:12px; color:var(--text-dim);">
|
||||
{% if user %}Connected as {{ user.login }}{% else %}Not connected{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if user %}
|
||||
<a href="/auth/logout" class="toolbar-btn" style="color:var(--red);">Disconnect</a>
|
||||
{% else %}
|
||||
<a href="/auth/login" class="btn-new">Connect Gitea</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users in workspace -->
|
||||
<div style="background:var(--bg-secondary); border-radius:8px; padding:20px;">
|
||||
<h3 style="margin-bottom:12px;">Workspace Members</h3>
|
||||
<div style="font-size:13px; color:var(--text-dim); margin-bottom:12px;">
|
||||
Members are managed through Gitea. New users connect via OAuth and appear here automatically.
|
||||
</div>
|
||||
<table class="data-table" style="width:100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Email</th>
|
||||
<th>Joined</th>
|
||||
<th>Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<div class="card-avatar" style="width:24px; height:24px; font-size:11px;">{{ u.login[0] }}</div>
|
||||
<span>{{ u.login }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ u.email or '—' }}</td>
|
||||
<td>{{ u.created_at[:10] if u.created_at else '—' }}</td>
|
||||
<td>{% if u.is_admin %}Admin{% else %}Member{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not users %}
|
||||
<tr><td colspan="4" style="text-align:center; color:var(--text-dim); padding:24px;">No members yet</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function accountsData() {
|
||||
return {
|
||||
profile: { full_name: '', email: '' },
|
||||
saved: false,
|
||||
async init() {
|
||||
try {
|
||||
const r = await fetch('/api/users/me');
|
||||
const data = await r.json();
|
||||
this.profile = { full_name: data.full_name || '', email: data.email || '' };
|
||||
} catch(e) {}
|
||||
},
|
||||
saveProfile() {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const token = csrf ? csrf[1] : '';
|
||||
fetch(`/api/users/me?full_name=${encodeURIComponent(this.profile.full_name)}&email=${encodeURIComponent(this.profile.email)}`, {
|
||||
method: 'PUT', headers: { 'X-CSRF-Token': token }
|
||||
}).then(() => {
|
||||
this.saved = true;
|
||||
setTimeout(() => this.saved = false, 3000);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -12,161 +12,345 @@
|
||||
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'>
|
||||
<div class="app-layout" x-data="appState()">
|
||||
|
||||
<!-- ═══════════ SIDEBAR ═══════════ -->
|
||||
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }" id="sidebar">
|
||||
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
|
||||
<div class="sidebar-overlay"
|
||||
:class="{ visible: mobileSidebarOpen }"
|
||||
@click="mobileSidebarOpen = false; sidebarCollapsed = true;">
|
||||
</div>
|
||||
|
||||
<!-- Workspace header -->
|
||||
<div class="sidebar-workspace">
|
||||
<div class="workspace-info">
|
||||
<div class="workspace-avatar">{{ workspaceInitial }}</div>
|
||||
<span class="workspace-name">{{ workspaceName }}</span>
|
||||
</div>
|
||||
<div class="sidebar-actions">
|
||||
<button class="sidebar-action-btn" @click="sidebarCollapsed = !sidebarCollapsed" title="Toggle sidebar">
|
||||
«
|
||||
</button>
|
||||
{# ─── Recursive tree macro ─── #}
|
||||
{% macro render_tree_item(page, depth=0) %}
|
||||
{% set indent_px = depth * 16 %}
|
||||
<li class="sidebar-item {% if page.active %}active{% endif %}"
|
||||
style="padding-left: {{ 8 + indent_px }}px;"
|
||||
@click="navigateTo('{{ page.url }}')"
|
||||
@contextmenu.prevent="showContextMenu($event, '{{ page.id }}', '{{ page.name }}')"
|
||||
data-page-id="{{ page.id }}"
|
||||
data-page-depth="{{ depth }}"
|
||||
data-has-children="{{ 'true' if page.has_children else 'false' }}">
|
||||
{# Toggle chevron — only visible for items with children #}
|
||||
{% if page.has_children %}
|
||||
<button class="tree-toggle"
|
||||
@click.stop="toggleTreeNode('{{ page.id }}')"
|
||||
:class="{ expanded: expandedNodes['{{ page.id }}'] }">▶</button>
|
||||
{% else %}
|
||||
<span class="tree-toggle-placeholder"></span>
|
||||
{% endif %}
|
||||
<span class="page-icon">{{ page.icon }}</span>
|
||||
<span class="page-name">{{ page.name }}</span>
|
||||
<span class="item-actions">
|
||||
<button class="item-action-btn" @click.stop="openLibrary('{{ page.id }}')">📚</button>
|
||||
<button class="item-action-btn" @click.stop="newSubPage('{{ page.id }}')">+</button>
|
||||
</span>
|
||||
</li>
|
||||
{% if page.has_children %}
|
||||
<li class="tree-children-wrapper" x-show="expandedNodes['{{ page.id }}']" x-transition>
|
||||
<ul class="sidebar-items" data-parent="{{ page.id }}">
|
||||
{% for child in page.children %}
|
||||
{{ render_tree_item(child, depth + 1) }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
<!-- ═══════════ SIDEBAR Notion ═══════════ -->
|
||||
<aside class="sidebar"
|
||||
:class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileSidebarOpen }"
|
||||
id="sidebar"
|
||||
x-data x-init="$nextTick(() => { if(typeof initTreeSortable==='function') initTreeSortable(); })">
|
||||
<!-- Workspace header + mobile back -->
|
||||
<div style="display:flex;align-items:center;gap:4px;">
|
||||
<button class="sidebar-back-btn" @click="mobileSidebarOpen = false; sidebarCollapsed = true;" title="Fermer le menu">
|
||||
<svg width="20" height="20" 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="sidebar-workspace" @click="toggleWorkspaceMenu" style="flex:1;">
|
||||
<div class="workspace-info">
|
||||
<div class="workspace-avatar">{{ workspaceInitial }}</div>
|
||||
<span class="workspace-name">{{ workspaceName }}</span>
|
||||
</div>
|
||||
<span class="workspace-chevron">▾</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick actions -->
|
||||
<div class="sidebar-nav">
|
||||
<a href="/" class="sidebar-nav-item" :class="{ active: currentView === 'home' }">
|
||||
<span class="nav-icon">🏠</span> Home
|
||||
<!-- Navigation icons row -->
|
||||
<div class="sidebar-nav-row">
|
||||
<a href="/workspaces" 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>
|
||||
<button class="nav-icon-btn" title="Chat">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||
</button>
|
||||
<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">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
<button class="nav-icon-btn collapse-btn" @click="sidebarCollapsed = !sidebarCollapsed" title="Collapse sidebar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-nav" style="gap: 2px; padding-right: 6px;">
|
||||
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">💬</button>
|
||||
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">🎤</button>
|
||||
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">📥</button>
|
||||
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">🔍</button>
|
||||
|
||||
<!-- Workspace -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header" @click="toggleSection('workspace')">
|
||||
<div class="sidebar-section-title">
|
||||
<span class="chevron" :class="{ open: sectionsOpen.workspace }" x-text="sectionsOpen.workspace ? '▼' : '▶'"></span>
|
||||
<span>📁 {{ active_ws_name or 'Workspace' }}</span>
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="New Page" @click.stop="newPageInWorkspace()">📄</button>
|
||||
<button class="section-action-btn" title="New Folder" @click.stop="newFolderInWorkspace()">📁</button>
|
||||
</div>
|
||||
</div>
|
||||
{% macro render_workspace_tree(pages, depth=0) %}
|
||||
{% for page in pages %}
|
||||
<li class="sidebar-item ws-tree-item"
|
||||
data-id="{{ page.db_id }}"
|
||||
data-is-folder="{{ 'true' if page.is_folder else 'false' }}"
|
||||
data-depth="{{ depth }}"
|
||||
:class="{ active: activeNodeId === {{ page.db_id }} }"
|
||||
draggable="true"
|
||||
@dragstart="dragStart($event, {{ page.db_id }})"
|
||||
@dragover.prevent="dragOver($event, {{ page.db_id }}, {{ 'true' if page.is_folder else 'false' }})"
|
||||
@dragleave="dragLeave($event)"
|
||||
@drop.prevent="drop($event, {{ page.db_id }})"
|
||||
style="padding-left:{{ 4 + depth * 12 }}px;"
|
||||
@contextmenu.prevent="showWsContext($event, {{ page.db_id }}, '{{ page.name | e }}', {{ 'true' if page.is_folder else 'false' }})"
|
||||
@touchstart="wsTouchStart($event)"
|
||||
@touchend="wsTouchEnd($event, {{ page.db_id }}, '{{ page.name | e }}', {{ 'true' if page.is_folder else 'false' }})"
|
||||
@touchmove="wsTouchMove($event)">
|
||||
{% if page.is_folder %}
|
||||
<button class="tree-chevron"
|
||||
@click.stop="toggleTreeFolder({{ page.db_id }})"
|
||||
:class="{ open: expandedFolders[{{ page.db_id }}] }"
|
||||
title="Toggle folder">▶</button>
|
||||
{% else %}
|
||||
<span style="width:12px;flex-shrink:0;"></span>
|
||||
{% endif %}
|
||||
<span class="page-icon">{{ page.icon }}</span>
|
||||
{% if page.is_folder %}
|
||||
<span class="page-name tree-folder-link"
|
||||
@click.stop="navigateToFolder({{ page.db_id }})"
|
||||
draggable="false"
|
||||
title="Open folder">{{ page.name }}{% if page.child_count %} <span class="child-count">({{ page.child_count }})</span>{% endif %}</span>
|
||||
{% else %}
|
||||
<a href="/pages/{{ page.db_id }}" class="page-name" draggable="false">{{ page.name }}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% if page.children %}
|
||||
<li x-show="expandedFolders[{{ page.db_id }}]" x-transition style="list-style:none;margin:0;padding:0;">
|
||||
<ul style="list-style:none;margin:0;padding:0;">
|
||||
{{ render_workspace_tree(page.children, depth + 1) }}
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
<div class="sidebar-section-items" x-show="sectionsOpen.workspace" x-transition>
|
||||
<ul class="sidebar-items" data-section="workspace">
|
||||
{% 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>
|
||||
{% 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>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meetings -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header" @click="toggleSection('meetings')">
|
||||
<div class="sidebar-section-title">
|
||||
<span class="chevron" :class="{ open: sectionsOpen.meetings }" x-text="sectionsOpen.meetings ? '▼' : '▶'"></span>
|
||||
<span>Meetings</span>
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="Add" @click.stop="newPage('Meetings')">+</button>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recents -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header">
|
||||
<div class="sidebar-section-header" @click="toggleSection('recents')">
|
||||
<div class="sidebar-section-title">
|
||||
<span class="chevron open">▶</span> Recents
|
||||
<span class="chevron" :class="{ open: sectionsOpen.recents }" x-text="sectionsOpen.recents ? '▼' : '▶'"></span>
|
||||
<span>Recents</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section-items" x-show="sectionsOpen.recents" x-transition>
|
||||
<ul class="sidebar-items" data-section="recents">
|
||||
{% for page in recent_pages %}
|
||||
{{ 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>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Favorites -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header" @click="toggleSection('favorites')">
|
||||
<div class="sidebar-section-title">
|
||||
<span class="chevron" :class="{ open: sectionsOpen.favorites }" x-text="sectionsOpen.favorites ? '▼' : '▶'"></span>
|
||||
<span>Favorites</span>
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="Open in Library">📚</button>
|
||||
<button class="section-action-btn" title="More">⋯</button>
|
||||
<button class="section-action-btn" title="Add" @click.stop="newPage('Favorites')">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="sidebar-items">
|
||||
{% for page in recent_pages %}
|
||||
<li class="sidebar-item {% if page.active %}active{% endif %} {% if page.indent %}indent-{{ page.indent }}{% endif %}"
|
||||
@click="navigateTo('{{ page.url }}')"
|
||||
@contextmenu.prevent="showContextMenu($event, '{{ page.id }}', '{{ page.name }}')">
|
||||
<span class="page-icon">{{ page.icon }}</span>
|
||||
<span class="page-name">{{ page.name }}</span>
|
||||
<span class="item-actions">
|
||||
<button class="item-action-btn" @click.stop="openInLibrary('{{ page.id }}')">📚</button>
|
||||
<button class="item-action-btn" @click.stop="showMore('{{ page.id }}')">⋯</button>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if not recent_pages %}
|
||||
<li class="sidebar-item">
|
||||
<span class="page-icon">📄</span>
|
||||
<span class="page-name text-dim">No recent pages</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<div class="sidebar-section-items" x-show="sectionsOpen.favorites" x-transition>
|
||||
<ul class="sidebar-items" data-section="favorites">
|
||||
{% for page in favorite_pages %}
|
||||
{{ 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>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Favorites: Private -->
|
||||
<!-- Agents -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header">
|
||||
<div class="sidebar-section-header" @click="toggleSection('agents')">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header" @click="toggleSection('shared')">
|
||||
<div class="sidebar-section-title">
|
||||
<span class="chevron open">▶</span> Private
|
||||
<span class="chevron" :class="{ open: sectionsOpen.shared }" x-text="sectionsOpen.shared ? '▼' : '▶'"></span>
|
||||
<span>Shared</span>
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" @click="addPage('private')" title="Add page">+</button>
|
||||
<button class="section-action-btn" title="Add" @click.stop="newPage('Shared')">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="sidebar-items">
|
||||
{% for page in private_pages %}
|
||||
<li class="sidebar-item {% if page.active %}active{% endif %} {% if page.indent %}indent-{{ page.indent }}{% endif %}"
|
||||
@click="navigateTo('{{ page.url }}')"
|
||||
@contextmenu.prevent="showContextMenu($event, '{{ page.id }}', '{{ page.name }}')">
|
||||
<span class="page-icon">{{ page.icon }}</span>
|
||||
<span class="page-name">{{ page.name }}</span>
|
||||
<span class="item-actions">
|
||||
<button class="item-action-btn" @click.stop="showMore('{{ page.id }}')">⋯</button>
|
||||
<button class="item-action-btn" @click.stop="addSubPage('{{ page.id }}')">+</button>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="sidebar-section-items" x-show="sectionsOpen.shared" x-transition>
|
||||
<ul class="sidebar-items" data-section="shared">
|
||||
{% for page in shared_pages %}
|
||||
{{ 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>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notion apps -->
|
||||
<!-- Private -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header">
|
||||
<div class="sidebar-section-header" @click="toggleSection('private')">
|
||||
<div class="sidebar-section-title">
|
||||
<span class="chevron open">▶</span> Notion apps
|
||||
<span class="chevron" :class="{ open: sectionsOpen.private }" x-text="sectionsOpen.private ? '▼' : '▶'"></span>
|
||||
<span>Private</span>
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="View" @click.stop="">⊞</button>
|
||||
<button class="section-action-btn" title="More" @click.stop="">⋯</button>
|
||||
<button class="section-action-btn" title="Add page" @click.stop="newPage('Private')">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="sidebar-items">
|
||||
<li class="sidebar-item"><span class="page-icon">📅</span><span class="page-name">Notion Calendar</span></li>
|
||||
<li class="sidebar-item"><span class="page-icon">🖥️</span><span class="page-name">Notion Desktop</span></li>
|
||||
</ul>
|
||||
<div class="sidebar-section-items" x-show="sectionsOpen.private" x-transition>
|
||||
<ul class="sidebar-items" data-section="private">
|
||||
{% for page in private_pages %}
|
||||
{{ 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>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Library -->
|
||||
<!-- Notion apps / Utilities -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header">
|
||||
<div class="sidebar-section-header" @click="toggleSection('apps')">
|
||||
<div class="sidebar-section-title">
|
||||
<span class="chevron open">▶</span> Library
|
||||
<span class="chevron" :class="{ open: sectionsOpen.apps }" x-text="sectionsOpen.apps ? '▼' : '▶'"></span>
|
||||
<span>Notion apps</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="sidebar-items">
|
||||
<li class="sidebar-item"><span class="page-icon">✅</span><span class="page-name">My Tasks</span></li>
|
||||
<li class="sidebar-item"><span class="page-icon">🛒</span><span class="page-name">Marketplace</span></li>
|
||||
<li class="sidebar-item"><span class="page-icon">❓</span><span class="page-name">Help</span></li>
|
||||
<li class="sidebar-item"><span class="page-icon">🗑️</span><span class="page-name">Trash</span></li>
|
||||
</ul>
|
||||
<div class="sidebar-section-items" x-show="sectionsOpen.apps" x-transition>
|
||||
<ul class="sidebar-items">
|
||||
<li class="sidebar-item" @click="navigateTo('/library')"><span class="page-icon">📚</span><span class="page-name">Library</span></li>
|
||||
<li class="sidebar-item"><span class="page-icon">✅</span><span class="page-name">My Tasks</span></li>
|
||||
<li class="sidebar-item" @click="navigateTo('/trash')"><span class="page-icon">🗑️</span><span class="page-name">Trash</span></li>
|
||||
<li class="sidebar-item"><span class="page-icon">❓</span><span class="page-name">Help</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-invite" x-show="showInvite" x-transition>
|
||||
<div class="sidebar-invite-title">👥 Invite members</div>
|
||||
<div class="sidebar-invite-desc">Collaborate with your team.</div>
|
||||
<button class="sidebar-invite-close" @click="showInvite = false">✕</button>
|
||||
<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 class="shortcut">Ctrl+O</span>
|
||||
</button>
|
||||
<button class="sidebar-footer-btn" style="width:36px; justify-content:center; background:var(--bg-hover); border-radius:8px;" @click="newPage('Private')" title="New page">
|
||||
<svg width="18" height="18" 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>
|
||||
<button class="sidebar-new-chat">
|
||||
<span>💬 New chat</span>
|
||||
<span class="shortcut">Ctrl+O</span>
|
||||
</button>
|
||||
<div style="display: flex; gap: 4px; margin-top: 4px;">
|
||||
{% if user %}
|
||||
<a href="/auth/logout" class="sidebar-new-chat" style="flex:1; justify-content:center;">🚪</a>
|
||||
<a href="/auth/logout" class="sidebar-footer-btn" style="flex:1; justify-content:center;">🚪</a>
|
||||
{% else %}
|
||||
<a href="/auth/login" class="sidebar-new-chat" style="flex:1; justify-content:center;">🔑</a>
|
||||
<a href="/auth/login" class="sidebar-footer-btn" style="flex:1; justify-content:center;">🔑</a>
|
||||
{% endif %}
|
||||
<button class="sidebar-new-chat" style="width:36px; justify-content:center;" @click="toggleTheme()">🌓</button>
|
||||
<button class="sidebar-footer-btn" style="width:36px; justify-content:center;" @click="toggleTheme()">🌓</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Uncollapse button (visible when sidebar hidden) -->
|
||||
<button class="sidebar-uncollapse-btn" x-show="sidebarCollapsed" x-cloak @click="sidebarCollapsed = false" title="Show sidebar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
<!-- ═══════════ MAIN ═══════════ -->
|
||||
<div class="main-wrapper">
|
||||
|
||||
<!-- Topbar -->
|
||||
{% block topbar %}
|
||||
<header class="topbar">
|
||||
<!-- Mobile hamburger -->
|
||||
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="topbar-left">
|
||||
<div class="topbar-breadcrumb">
|
||||
<span>{{ workspaceName }}</span>
|
||||
<span x-text="workspaceName"></span>
|
||||
<span class="text-dim">/</span>
|
||||
<span class="text-dim">{{ currentPage }}</span>
|
||||
<span class="text-dim" x-text="currentPage"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-center">
|
||||
<div class="topbar-page-title">
|
||||
<span class="page-icon">{{ pageIcon }}</span>
|
||||
<span>{{ pageTitle }}</span>
|
||||
<span class="page-icon" x-text="pageIcon"></span>
|
||||
<span x-text="pageTitle"></span>
|
||||
</div>
|
||||
<div class="topbar-page-status">
|
||||
<span>🔒</span> Private
|
||||
@@ -175,15 +359,23 @@
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<span class="text-xs text-dim">Edited {{ lastEdited }}</span>
|
||||
<span class="text-xs text-dim">Edited <span x-text="lastEdited"></span></span>
|
||||
<button class="topbar-btn">🔔</button>
|
||||
<button class="topbar-btn">🕒</button>
|
||||
<button class="topbar-btn">🔍</button>
|
||||
<button class="topbar-btn share-btn">🔗 Share ▾</button>
|
||||
<button class="topbar-btn">⭐</button>
|
||||
<button class="topbar-btn">⋯</button>
|
||||
<button class="topbar-btn" @click="showNewPageMenu = !showNewPageMenu" style="position:relative;">
|
||||
New ▾
|
||||
<div class="dropdown-panel" :class="{ visible: showNewPageMenu }" style="top:100%; right:0; left:auto; min-width:200px;" @click.stop>
|
||||
<div class="dropdown-option" @click="createEmptyPage()"><span class="option-icon">📄</span> Empty page</div>
|
||||
<div class="dropdown-option" @click="createDatabase()"><span class="option-icon">📊</span> Database</div>
|
||||
<div class="dropdown-option" @click="createBoard()"><span class="option-icon">📋</span> Kanban board</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{% endblock %}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content-area" id="main-content">
|
||||
@@ -202,7 +394,8 @@
|
||||
</div>
|
||||
<div class="context-menu-section">
|
||||
<div class="context-menu-item" @click="contextAction('favorite')">
|
||||
<span class="menu-icon">⭐</span> Remove from Favorites
|
||||
<span class="menu-icon">⭐</span>
|
||||
<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
|
||||
@@ -240,10 +433,10 @@
|
||||
</div>
|
||||
<div class="context-menu-section">
|
||||
<div class="context-menu-item text-dim" style="font-size:11px; cursor:default;">
|
||||
Last edited by {{ workspaceName }}
|
||||
Last edited by <span x-text="workspaceName"></span>
|
||||
</div>
|
||||
<div class="context-menu-item text-dim" style="font-size:11px; cursor:default;">
|
||||
{{ lastEdited }}
|
||||
<span x-text="lastEdited"></span>
|
||||
</div>
|
||||
<div class="context-menu-item" @click="contextAction('learnDb')">
|
||||
<span class="menu-icon">❓</span> Learn about databases
|
||||
@@ -251,6 +444,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ WORKSPACE TREE CONTEXT MENU ═══════════ -->
|
||||
<div class="context-menu" id="ws-context-menu"
|
||||
:class="{ visible: wsCtx.visible }"
|
||||
:style="'left:' + wsCtx.x + 'px; top:' + wsCtx.y + 'px'"
|
||||
@click.away="wsCtx.visible = false">
|
||||
<template x-if="wsCtx.isFolder">
|
||||
<div class="context-menu-section">
|
||||
<div class="context-menu-item" @click="wsCtxAction('new-file')">
|
||||
<span class="menu-icon">📄</span> New File
|
||||
</div>
|
||||
<div class="context-menu-item" @click="wsCtxAction('new-folder')">
|
||||
<span class="menu-icon">📁</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
|
||||
</div>
|
||||
<div class="context-menu-item" @click="wsCtxAction('delete')">
|
||||
<span class="menu-icon">🗑</span> Delete
|
||||
</div>
|
||||
</div>
|
||||
<div class="context-menu-section">
|
||||
<div class="context-menu-item text-dim" style="font-size:11px;cursor:default;">
|
||||
<span x-text="wsCtx.name"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ TOOLTIP ═══════════ -->
|
||||
<div class="tooltip" id="tooltip" x-ref="tooltip"></div>
|
||||
|
||||
@@ -283,6 +506,7 @@
|
||||
function appState() {
|
||||
return {
|
||||
sidebarCollapsed: false,
|
||||
mobileSidebarOpen: false,
|
||||
showInvite: true,
|
||||
workspaceName: '{{ workspace_name }}',
|
||||
workspaceInitial: '{{ workspace_initial }}',
|
||||
@@ -290,11 +514,177 @@
|
||||
pageIcon: '{% block page_icon %}📁{% endblock %}',
|
||||
pageTitle: '{% block page_title %}FlowDeck{% endblock %}',
|
||||
lastEdited: '{{ last_edited }}',
|
||||
workspaceKey: '{{ workspace_key }}',
|
||||
currentView: 'home',
|
||||
contextMenu: { visible: false, x: 0, y: 0, pageId: null, pageName: '' },
|
||||
contextActions: {},
|
||||
showNewPageMenu: false,
|
||||
|
||||
// ── Workspace tree context menu ──
|
||||
wsCtx: { visible: false, x: 0, y: 0, id: null, name: '', isFolder: false },
|
||||
wsTouchTimer: null,
|
||||
activeNodeId: (function() {
|
||||
var m = window.location.search.match(/[?&]folder=(\d+)/);
|
||||
return m ? parseInt(m[1]) : null;
|
||||
})(),
|
||||
showWsContext(ev, id, name, isFolder) {
|
||||
this.wsCtx = { visible: true, x: ev.clientX, y: ev.clientY, id: id, name: name, isFolder: isFolder };
|
||||
},
|
||||
hideWsContext() { this.wsCtx.visible = false; },
|
||||
wsTouchStart(ev) {
|
||||
var self = this;
|
||||
this.wsTouchTimer = setTimeout(function() {
|
||||
self.wsTouchTimer = null;
|
||||
var el = ev.target.closest('.ws-tree-item');
|
||||
if (el) {
|
||||
var id = parseInt(el.getAttribute('data-id'));
|
||||
var name = el.getAttribute('data-name') || '';
|
||||
var isFolder = el.getAttribute('data-is-folder') === 'true';
|
||||
var touch = ev.touches[0];
|
||||
self.wsCtx = { visible: true, x: touch.clientX, y: touch.clientY, id: id, name: name, isFolder: isFolder };
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
wsTouchEnd(ev, id, name, isFolder) {
|
||||
if (this.wsTouchTimer) { clearTimeout(this.wsTouchTimer); this.wsTouchTimer = null; return; }
|
||||
// Already opened by touchstart; prevent Alpine @touchend from also firing the context menu
|
||||
ev.preventDefault();
|
||||
},
|
||||
wsTouchMove(ev) {
|
||||
if (this.wsTouchTimer) { clearTimeout(this.wsTouchTimer); this.wsTouchTimer = null; }
|
||||
},
|
||||
wsCtxAction(action) {
|
||||
var id = this.wsCtx.id;
|
||||
var name = this.wsCtx.name;
|
||||
this.wsCtx.visible = false;
|
||||
if (!id) return;
|
||||
switch(action) {
|
||||
case 'new-file':
|
||||
this.newPageInFolder(id);
|
||||
break;
|
||||
case 'new-folder':
|
||||
this.newFolderInFolder(id);
|
||||
break;
|
||||
case 'rename':
|
||||
var newName = prompt('Rename:', name);
|
||||
if (newName && newName.trim()) {
|
||||
fetch('/api/local-workspace/items/' + id, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||
body: JSON.stringify({ name: newName.trim() })
|
||||
}).then(function(r) { if (r.ok) window.location.reload(); });
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
if (!confirm('Delete "' + name + '"?')) break;
|
||||
this.deleteWorkspacePage(id);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Favorites tracking ──
|
||||
favoriteIds: new Set([{% for p in favorite_pages %}'{{ p.id }}'{% if not loop.last %},{% endif %}{% endfor %}]),
|
||||
|
||||
// ── Sections collapsible ──
|
||||
sectionsOpen: (function() {
|
||||
var def = { recents: true, private: true, meetings: true, favorites: true, agents: true, shared: true, apps: true, workspace: true };
|
||||
try {
|
||||
var saved = JSON.parse(localStorage.getItem('fd_sections') || '{}');
|
||||
return Object.assign(def, saved);
|
||||
} catch(e) { return def; }
|
||||
})(),
|
||||
toggleSection(key) {
|
||||
this.sectionsOpen[key] = !this.sectionsOpen[key];
|
||||
this.sectionsOpen = Object.assign({}, this.sectionsOpen);
|
||||
try { localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen)); }
|
||||
catch(e) {}
|
||||
},
|
||||
toggleWorkspaceMenu() {
|
||||
if (window.innerWidth <= 768) {
|
||||
this.mobileSidebarOpen = !this.mobileSidebarOpen;
|
||||
this.sidebarCollapsed = !this.mobileSidebarOpen;
|
||||
} else {
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
}
|
||||
},
|
||||
navigateTo(url) {
|
||||
this.mobileSidebarOpen = false;
|
||||
this.sidebarCollapsed = true;
|
||||
window.location.href = url;
|
||||
},
|
||||
openQuickFind() { alert('Quick Find — Ctrl+K'); },
|
||||
|
||||
// ── Tree state ──
|
||||
expandedNodes: {},
|
||||
|
||||
isExpanded(id) {
|
||||
return this.expandedNodes[id] || false;
|
||||
},
|
||||
toggleTreeNode(id) {
|
||||
const current = this.expandedNodes[id] || false;
|
||||
this.expandedNodes[id] = !current;
|
||||
// Trigger Alpine.js reactivity by reassigning
|
||||
this.expandedNodes = Object.assign({}, this.expandedNodes);
|
||||
// If expanding, init SortableJS on the newly visible children
|
||||
if (!current) {
|
||||
this.$nextTick(() => this.initTreeSortable());
|
||||
}
|
||||
},
|
||||
expandAll() {
|
||||
document.querySelectorAll('[data-has-children="true"]').forEach(el => {
|
||||
this.expandedNodes[el.dataset.pageId] = true;
|
||||
});
|
||||
this.expandedNodes = Object.assign({}, this.expandedNodes);
|
||||
this.$nextTick(() => this.initTreeSortable());
|
||||
},
|
||||
|
||||
// ── Drag & drop tree ──
|
||||
treeSortables: [],
|
||||
initTreeSortable() {
|
||||
if (typeof Sortable === 'undefined') return;
|
||||
// Initialize SortableJS on every .sidebar-items list
|
||||
const self = this;
|
||||
document.querySelectorAll('.sidebar-items').forEach(function(el) {
|
||||
if (el._sortableTree) return; // Already initialized
|
||||
el._sortableTree = new Sortable(el, {
|
||||
group: 'pages-tree',
|
||||
handle: '.page-icon',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
fallbackOnBody: true,
|
||||
swapThreshold: 0.5,
|
||||
onEnd: function(evt) {
|
||||
// Get the dragged item's page ID
|
||||
const item = evt.item;
|
||||
const pageId = item.dataset.pageId;
|
||||
if (!pageId || !pageId.startsWith('page/')) return;
|
||||
const dbId = pageId.replace('page/', '');
|
||||
// Determine new parent from the target list's data-parent
|
||||
const targetList = evt.to;
|
||||
let newParentId = 0;
|
||||
const parentAttr = targetList.dataset.parent;
|
||||
if (parentAttr && parentAttr.startsWith('page/')) {
|
||||
newParentId = parentAttr.replace('page/', '');
|
||||
}
|
||||
const newIndex = evt.newIndex;
|
||||
// Call API to persist the move
|
||||
const csrf = self.getCsrfToken();
|
||||
fetch(`/board/api/pages/${dbId}/move?new_parent_id=${newParentId}&new_order=${newIndex}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'X-CSRF-Token': csrf }
|
||||
}).catch(e => console.error('Move failed:', e));
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
navigateTo(url) {
|
||||
// Preserve workspace context when navigating to library/trash
|
||||
if ((url === '/library' || url === '/trash') && this.workspaceKey && this.workspaceKey.includes('/')) {
|
||||
const parts = this.workspaceKey.split('/');
|
||||
url = `${url}?owner=${parts[0]}&repo=${parts.slice(1).join('/')}`;
|
||||
}
|
||||
window.location.href = url;
|
||||
},
|
||||
|
||||
@@ -310,18 +700,241 @@
|
||||
|
||||
contextAction(action) {
|
||||
const { pageId, pageName } = this.contextMenu;
|
||||
console.log('Context action:', action, pageId, pageName);
|
||||
// TODO: implement each action
|
||||
if (action === 'duplicate') {
|
||||
if (this.contextActions.duplicate) this.contextActions.duplicate(pageId);
|
||||
const self = this;
|
||||
const csrf = this.getCsrfToken();
|
||||
const dbId = pageId.startsWith('page/') ? parseInt(pageId.split('/')[1]) : parseInt(pageId);
|
||||
|
||||
switch(action) {
|
||||
case 'favorite':
|
||||
const isFav = this.favoriteIds.has(pageId);
|
||||
const method = isFav ? 'DELETE' : 'POST';
|
||||
fetch(`/board/api/favorites/${dbId}`, { method, headers: { 'X-CSRF-Token': csrf } })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (isFav) self.favoriteIds.delete(pageId);
|
||||
else self.favoriteIds.add(pageId);
|
||||
self.contextMenu.visible = false;
|
||||
// Reload to update sidebar
|
||||
location.reload();
|
||||
})
|
||||
.catch(e => alert('Favorite action failed'));
|
||||
break;
|
||||
case 'copyLink':
|
||||
const url = window.location.origin + '/' + pageId;
|
||||
navigator.clipboard.writeText(url).then(() => alert('Link copied!'));
|
||||
break;
|
||||
case 'duplicate':
|
||||
fetch(`/board/api/pages?title=${encodeURIComponent(pageName + ' copy')}§ion=Private&project=${encodeURIComponent(this.workspaceKey)}`, {
|
||||
method: 'POST', headers: { 'X-CSRF-Token': csrf }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(e => alert('Duplicate failed'));
|
||||
break;
|
||||
case 'rename':
|
||||
const newName = prompt('New name:', pageName);
|
||||
if (!newName) break;
|
||||
const pageNum = pageId.replace('page/', '');
|
||||
fetch(`/board/api/pages/${pageNum}?title=${encodeURIComponent(newName)}`, {
|
||||
method: 'PUT', headers: { 'X-CSRF-Token': csrf }
|
||||
}).then(() => location.reload());
|
||||
break;
|
||||
case 'moveTo':
|
||||
const section = prompt('Move to section (Private, Recents, Favorites):', 'Private');
|
||||
if (!section) break;
|
||||
alert(`Moved to ${section}`);
|
||||
break;
|
||||
case 'delete':
|
||||
if (!confirm(`Move "${pageName}" to trash?`)) break;
|
||||
const delNum = pageId.replace('page/', '');
|
||||
fetch(`/board/api/pages/${delNum}`, {
|
||||
method: 'DELETE', headers: { 'X-CSRF-Token': csrf }
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Delete failed'); return r.json(); })
|
||||
.then(data => {
|
||||
// Remove from ALL sidebar sections (Recents, Private, etc.)
|
||||
const items = document.querySelectorAll(`.sidebar-item[data-page-id="${pageId}"]`);
|
||||
items.forEach(item => {
|
||||
const next = item.nextElementSibling;
|
||||
if (next && next.classList.contains('tree-children-wrapper')) {
|
||||
next.remove();
|
||||
}
|
||||
item.remove();
|
||||
});
|
||||
// Redirect if viewing deleted page — stay in project context
|
||||
if (window.location.pathname.includes(`/pages/${delNum}`)) {
|
||||
const wk = this.workspaceKey;
|
||||
window.location.href = wk && wk.includes('/') ? `/board/${wk}` : '/';
|
||||
}
|
||||
})
|
||||
.catch(e => alert('Delete failed: ' + e.message));
|
||||
break;
|
||||
case 'openTab':
|
||||
window.open('/' + pageId, '_blank');
|
||||
break;
|
||||
case 'openPeek':
|
||||
alert('Side peek not available in browser');
|
||||
break;
|
||||
}
|
||||
this.contextMenu.visible = false;
|
||||
},
|
||||
|
||||
openInLibrary(id) { console.log('Open in library:', id); },
|
||||
openLibrary(id) {
|
||||
this.navigateTo('/library');
|
||||
},
|
||||
openInLibrary(id) { this.openLibrary(id); },
|
||||
showMore(id) { console.log('Show more:', id); },
|
||||
addPage(section) { console.log('Add page to:', section); },
|
||||
addSubPage(id) { console.log('Add sub-page to:', id); },
|
||||
addPage(section) { this.newPage(section); },
|
||||
addSubPage(id) { this.newSubPage(id); },
|
||||
getCsrfToken() {
|
||||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
},
|
||||
newPage(section) {
|
||||
const project = this.workspaceKey || '';
|
||||
fetch(`/board/api/pages?section=${encodeURIComponent(section)}&project=${encodeURIComponent(project)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': this.getCsrfToken() }
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { alert('Failed to create page: ' + err.message); });
|
||||
},
|
||||
newSubPage(parentId) {
|
||||
const project = this.workspaceKey || '';
|
||||
const pid = parentId.replace('page/', '');
|
||||
fetch(`/board/api/pages?section=Private&project=${encodeURIComponent(project)}&parent_id=${pid}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': this.getCsrfToken() }
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { alert('Failed to create sub-page: ' + err.message); });
|
||||
},
|
||||
newPageInWorkspace() {
|
||||
const name = prompt('File name:');
|
||||
if (!name || !name.trim()) return;
|
||||
fetch('/api/local-workspace/items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||
body: JSON.stringify({ name: name.trim(), type: 'page' })
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { alert('Failed: ' + err.message); });
|
||||
},
|
||||
newFolderInWorkspace() {
|
||||
const name = prompt('Folder name:');
|
||||
if (!name || !name.trim()) return;
|
||||
fetch('/api/local-workspace/items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||
body: JSON.stringify({ name: name.trim(), type: 'folder' })
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||
.then(() => { window.location.reload(); })
|
||||
.catch(err => { alert('Failed: ' + err.message); });
|
||||
},
|
||||
deleteWorkspacePage(pageId) {
|
||||
if (!confirm('Delete this page?')) return;
|
||||
fetch(`/api/local-workspace/items/${pageId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': this.getCsrfToken() }
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||
.then(() => { window.location.reload(); })
|
||||
.catch(err => { alert('Failed: ' + err.message); });
|
||||
},
|
||||
|
||||
// ── Drag & Drop ──
|
||||
dragOverId: null,
|
||||
dragStart(ev, id) {
|
||||
ev.dataTransfer.setData('text/plain', id);
|
||||
ev.target.classList.add('dragging');
|
||||
},
|
||||
dragOver(ev, id, isFolder) {
|
||||
this.dragOverId = id;
|
||||
if (isFolder) ev.target.closest('.ws-tree-item')?.classList.add('drop-target');
|
||||
},
|
||||
dragLeave(ev) {
|
||||
this.dragOverId = null;
|
||||
ev.target.closest('.ws-tree-item')?.classList.remove('drop-target');
|
||||
},
|
||||
drop(ev, targetId) {
|
||||
ev.target.closest('.ws-tree-item')?.classList.remove('drop-target');
|
||||
const draggedId = ev.dataTransfer.getData('text/plain');
|
||||
if (!draggedId || draggedId == targetId) return;
|
||||
const isFolder = ev.target.closest('.ws-tree-item')?.dataset?.isFolder === 'true';
|
||||
const parentId = isFolder && draggedId != targetId ? targetId : null;
|
||||
fetch(`/api/local-workspace/items/${draggedId}/move`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||
body: JSON.stringify({ parent_id: parentId })
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); window.location.reload(); })
|
||||
.catch(err => { alert('Move failed: ' + err.message); });
|
||||
},
|
||||
|
||||
// ── Sub-item creation in folders ──
|
||||
newPageInFolder(parentId) {
|
||||
const name = prompt('File name:');
|
||||
if (!name || !name.trim()) return;
|
||||
fetch('/api/local-workspace/items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||
body: JSON.stringify({ name: name.trim(), type: 'page', parent_id: parentId })
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { alert('Failed: ' + err.message); });
|
||||
},
|
||||
newFolderInFolder(parentId) {
|
||||
const name = prompt('Sub-folder name:');
|
||||
if (!name || !name.trim()) return;
|
||||
fetch('/api/local-workspace/items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||
body: JSON.stringify({ name: name.trim(), type: 'folder', parent_id: parentId })
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); window.location.reload(); })
|
||||
.catch(err => { alert('Failed: ' + err.message); });
|
||||
},
|
||||
openWorkspacePage(pageId) {
|
||||
window.location = `/pages/${pageId}`;
|
||||
},
|
||||
|
||||
// ── Tree expand/collapse + folder navigation ──
|
||||
expandedFolders: (function() {
|
||||
try { return JSON.parse(localStorage.getItem('fd_expanded_folders') || '{}'); }
|
||||
catch(e) { return {}; }
|
||||
})(),
|
||||
toggleTreeFolder(id) {
|
||||
this.expandedFolders[id] = !(this.expandedFolders[id] || false);
|
||||
this.expandedFolders = Object.assign({}, this.expandedFolders);
|
||||
try { localStorage.setItem('fd_expanded_folders', JSON.stringify(this.expandedFolders)); }
|
||||
catch(e) {}
|
||||
},
|
||||
navigateToFolder(id) {
|
||||
// Save expansion + sections state before navigating
|
||||
try {
|
||||
localStorage.setItem('fd_expanded_folders', JSON.stringify(this.expandedFolders));
|
||||
localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen));
|
||||
} catch(e) {}
|
||||
window.location = `/local-workspace?folder=${id}`;
|
||||
},
|
||||
|
||||
createEmptyPage() {
|
||||
this.showNewPageMenu = false;
|
||||
this.newPage('Private');
|
||||
},
|
||||
createDatabase() {
|
||||
this.showNewPageMenu = false;
|
||||
alert('Database creation: use a Gitea repo as a database. Navigate to a project board from the dashboard.');
|
||||
},
|
||||
createBoard() {
|
||||
this.showNewPageMenu = false;
|
||||
window.location.href = '/board/bruno/flowdeck';
|
||||
},
|
||||
|
||||
toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
@@ -331,6 +944,26 @@
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-save sidebar state before navigation (covers <a href> clicks)
|
||||
window.addEventListener('beforeunload', function() {
|
||||
try {
|
||||
var el = document.querySelector('[x-data]');
|
||||
if (el && el.__x) {
|
||||
var data = el.__x.$data;
|
||||
if (data.expandedFolders) localStorage.setItem('fd_expanded_folders', JSON.stringify(data.expandedFolders));
|
||||
if (data.sectionsOpen) localStorage.setItem('fd_sections', JSON.stringify(data.sectionsOpen));
|
||||
}
|
||||
} catch(e) {}
|
||||
});
|
||||
|
||||
// Auto-scroll sidebar to active folder
|
||||
document.addEventListener('alpine:initialized', function() {
|
||||
setTimeout(function() {
|
||||
var active = document.querySelector('.sidebar-item.active');
|
||||
if (active) active.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}, 300);
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/app.js" defer></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
@@ -153,6 +153,15 @@
|
||||
<script>
|
||||
const owner = '{{ owner }}';
|
||||
const repo = '{{ repo }}';
|
||||
const initialView = '{{ initial_view }}';
|
||||
|
||||
// Auto-switch to view from URL param
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (initialView && initialView !== 'kanban') {
|
||||
const tab = document.querySelector(`.view-tab[data-view="${initialView}"]`);
|
||||
if (tab) tab.click();
|
||||
}
|
||||
});
|
||||
|
||||
function setActiveTab(el) {
|
||||
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
|
||||
@@ -235,7 +244,11 @@
|
||||
title: '', status: 'todo',
|
||||
create() {
|
||||
if (!this.title.trim()) return;
|
||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, { method: 'POST' })
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
this.title = '';
|
||||
@@ -268,7 +281,16 @@
|
||||
onEnd: function(evt) {
|
||||
const cardId = evt.item.dataset.cardId;
|
||||
const toStatus = evt.to.dataset.status;
|
||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, { method: 'POST' });
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(() => {
|
||||
// ponytail: refresh current view after move
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,6 +99,12 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ponytail: CSRF helper
|
||||
function getCsrf() {
|
||||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function cardDetail() {
|
||||
return {
|
||||
updateField(field, value) {
|
||||
@@ -109,18 +115,27 @@
|
||||
console.log('Toggle status');
|
||||
},
|
||||
toggleChecklistItem(id, checked) {
|
||||
fetch(`/api/checklist-items/${id}?checked=${checked}`, { method: 'PATCH' });
|
||||
fetch(`/api/checklist-items/${id}?checked=${checked}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'X-CSRF-Token': getCsrf() }
|
||||
});
|
||||
},
|
||||
addChecklistItem(clId) {
|
||||
const content = prompt('Item name:');
|
||||
if (content) {
|
||||
fetch(`/api/checklist-items/${owner}/${repo}/${issue_id}/${clId}?content=${encodeURIComponent(content)}`, { method: 'POST' })
|
||||
fetch(`/api/checklist-items/${owner}/${repo}/${issue_id}/${clId}?content=${encodeURIComponent(content)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': getCsrf() }
|
||||
})
|
||||
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
|
||||
}
|
||||
},
|
||||
addChecklist() {
|
||||
const title = prompt('Checklist name:') || 'Checklist';
|
||||
fetch(`/api/checklists/${owner}/${repo}/${issue_id}?title=${encodeURIComponent(title)}`, { method: 'POST' })
|
||||
fetch(`/api/checklists/${owner}/${repo}/${issue_id}?title=${encodeURIComponent(title)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': getCsrf() }
|
||||
})
|
||||
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
|
||||
},
|
||||
addComment(body) {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}📚{% endblock %}
|
||||
{% block page_title %}Library{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">📚</span>
|
||||
<h1>Library</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Library tabs + table — single Alpine scope -->
|
||||
<div x-data="{ tab: 'recents' }">
|
||||
|
||||
<div class="view-tabs">
|
||||
<button class="view-tab" :class="{ active: tab === 'recents' }" @click="tab = 'recents'">
|
||||
<span class="tab-icon">🕒</span> Recents
|
||||
</button>
|
||||
{% if lib_has_favorites %}
|
||||
<button class="view-tab" :class="{ active: tab === 'favorites' }" @click="tab = 'favorites'">
|
||||
<span class="tab-icon">⭐</span> Favorites
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if lib_has_shared %}
|
||||
<button class="view-tab" :class="{ active: tab === 'shared' }" @click="tab = 'shared'">
|
||||
<span class="tab-icon">👥</span> Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class="view-tab" :class="{ active: tab === 'private' }" @click="tab = 'private'">
|
||||
<span class="tab-icon">🔒</span> Private
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="view-toolbar">
|
||||
<div class="view-toolbar-spacer"></div>
|
||||
<button class="toolbar-btn" title="Filter">☰</button>
|
||||
<button class="toolbar-btn" title="Search">🔍</button>
|
||||
<button class="toolbar-btn" title="Properties">⫶</button>
|
||||
<button class="btn-new" @click="newPage()">New page</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div style="padding: 0 24px;">
|
||||
<!-- Column headers -->
|
||||
<div class="library-table-header">
|
||||
<span class="lib-col lib-col-name">📄 Page name</span>
|
||||
<span class="lib-col lib-col-author">👤 Created by</span>
|
||||
<span class="lib-col lib-col-source">↗ Source</span>
|
||||
<span class="lib-col lib-col-edited">🕒 Last edited time</span>
|
||||
<span class="lib-col lib-col-visited">🕒 Last visited time</span>
|
||||
</div>
|
||||
|
||||
{% macro page_row(page) %}
|
||||
<a href="{{ page.url }}" class="library-table-row">
|
||||
<span class="lib-col lib-col-name">
|
||||
<span class="page-icon">{{ page.icon }}</span>
|
||||
<span>{{ page.name }}</span>
|
||||
</span>
|
||||
<span class="lib-col lib-col-author">👤 {{ page.created_by or 'You' }}</span>
|
||||
<span class="lib-col lib-col-source">{{ page.source or '🔒 Private' }}</span>
|
||||
<span class="lib-col lib-col-edited">{{ page.last_edited }}</span>
|
||||
<span class="lib-col lib-col-visited">{{ page.last_visited or page.last_edited }}</span>
|
||||
</a>
|
||||
{% endmacro %}
|
||||
|
||||
<!-- Recents -->
|
||||
<div x-show="tab === 'recents'">
|
||||
{% for page in lib_recent %}{{ page_row(page) }}{% endfor %}
|
||||
{% if not lib_recent %}
|
||||
<div class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">🕒</div>
|
||||
<h3>No recent pages</h3>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Favorites -->
|
||||
<div x-show="tab === 'favorites'">
|
||||
{% for page in lib_favorites %}{{ page_row(page) }}{% endfor %}
|
||||
{% if not lib_favorites %}
|
||||
<div class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">⭐</div>
|
||||
<h3>No favorites</h3>
|
||||
<p>Right-click a page in the sidebar and select "Add to Favorites".</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Shared -->
|
||||
<div x-show="tab === 'shared'">
|
||||
{% for page in lib_shared %}{{ page_row(page) }}{% endfor %}
|
||||
{% if not lib_shared %}
|
||||
<div class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">👥</div>
|
||||
<h3>No shared pages</h3>
|
||||
<p>Shared pages from your team will appear here.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Private -->
|
||||
<div x-show="tab === 'private'">
|
||||
{% for page in lib_private %}{{ page_row(page) }}{% endfor %}
|
||||
{% if not lib_private %}
|
||||
<div class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">🔒</div>
|
||||
<h3>No private pages</h3>
|
||||
<p>Create a new page with the + button in the sidebar.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function newPage() {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/board/api/pages?section=Private', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('CSRF error'); return r.json(); })
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { alert('Failed: ' + err.message); });
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,740 @@
|
||||
{% extends "base.html" %} {% block page_icon %}📄{% endblock %} {% block
|
||||
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
<header class="topbar" style="background:var(--bg-primary);">
|
||||
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="topbar-left">
|
||||
<span class="breadcrumb" style="color:rgba(255,255,255,.5);font-size:13px">{{ page.title }}</span>
|
||||
</div>
|
||||
</header>
|
||||
{% endblock %} {% block content %}
|
||||
<div class="page-editor-wrapper" x-data="editorState()">
|
||||
<!-- ═══════════ Top Action Bar ═══════════ -->
|
||||
<div class="page-topbar">
|
||||
<div class="page-topbar-right" style="position: relative">
|
||||
<span class="topbar-edited">Edited just now</span>
|
||||
<button
|
||||
class="page-action-btn share-btn"
|
||||
@click="shareOpen = !shareOpen"
|
||||
>
|
||||
🔒 Share <span class="chevron-down">▾</span>
|
||||
</button>
|
||||
<button
|
||||
class="page-action-btn"
|
||||
@click="copyPageLink"
|
||||
title="Copy link"
|
||||
>
|
||||
🔗
|
||||
</button>
|
||||
<button class="page-action-btn star-btn" @click="toggleFavorite">
|
||||
<span
|
||||
x-text="favorited ? '⭐' : '☆'"
|
||||
:class="{ starred: favorited }"
|
||||
></span>
|
||||
</button>
|
||||
<button class="page-action-btn" @click="moreOpen = !moreOpen">
|
||||
⋮
|
||||
</button>
|
||||
|
||||
<!-- ═══════════ Share / Publish Dialog ═══════════ -->
|
||||
<div
|
||||
class="share-dialog"
|
||||
x-show="shareOpen"
|
||||
@click.away="shareOpen = false"
|
||||
x-transition.opacity.scale.origin.top.right
|
||||
>
|
||||
<!-- Header with tabs -->
|
||||
<div class="sd-header">
|
||||
<button
|
||||
class="sd-tab"
|
||||
:class="{ active: shareTab === 'share' }"
|
||||
@click="shareTab = 'share'"
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
<button
|
||||
class="sd-tab"
|
||||
:class="{ active: shareTab === 'publish' }"
|
||||
@click="shareTab = 'publish'"
|
||||
>
|
||||
Publish
|
||||
<span class="sd-badge" x-show="pagePublished"></span>
|
||||
</button>
|
||||
<button class="sd-close" @click="shareOpen = false">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ SHARE TAB ═══════ -->
|
||||
<div class="sd-body" x-show="shareTab === 'share'" x-transition>
|
||||
<!-- Invite input -->
|
||||
<div class="sd-invite-row">
|
||||
<input
|
||||
type="email"
|
||||
class="sd-input"
|
||||
placeholder="Add people, groups or emails..."
|
||||
x-model="inviteEmail"
|
||||
@keydown.enter="invitePerson"
|
||||
/>
|
||||
<button class="sd-btn-primary" @click="invitePerson">
|
||||
Invite
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Context card (shown when independent permissions) -->
|
||||
<div class="sd-context-card" x-show="false">
|
||||
Share settings on this page are unlinked from parent
|
||||
page
|
||||
</div>
|
||||
|
||||
<!-- Participants list -->
|
||||
<div class="sd-participants" x-show="accessList.length">
|
||||
<template x-for="a in accessList" :key="a.email">
|
||||
{% raw %}
|
||||
<div class="sd-participant">
|
||||
<div class="sd-participant-info">
|
||||
<div
|
||||
class="sd-avatar"
|
||||
x-text="a.email[0].toUpperCase()"
|
||||
></div>
|
||||
<div>
|
||||
<div
|
||||
class="sd-participant-name"
|
||||
x-text="a.email"
|
||||
></div>
|
||||
<div
|
||||
class="sd-participant-email"
|
||||
x-text="a.email"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="sd-perm-btn"
|
||||
@click="a.permOpen = !a.permOpen"
|
||||
>
|
||||
<span
|
||||
x-text="a.permission === 'editor' ? 'Full access' : a.permission === 'commenter' ? 'Can comment' : 'Can view'"
|
||||
></span>
|
||||
▾
|
||||
</button>
|
||||
<div
|
||||
class="sd-perm-menu"
|
||||
x-show="a.permOpen"
|
||||
@click.away="a.permOpen = false"
|
||||
>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'editor' }"
|
||||
@click="a.permission='editor'; a.permOpen=false"
|
||||
>
|
||||
<span>Can edit</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Edit, suggest and comment</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'commenter' }"
|
||||
@click="a.permission='commenter'; a.permOpen=false"
|
||||
>
|
||||
<span>Can comment</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Suggest and comment only</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'viewer' }"
|
||||
@click="a.permission='viewer'; a.permOpen=false"
|
||||
>
|
||||
<span>Can view</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Read only</span
|
||||
>
|
||||
</div>
|
||||
<div class="sd-perm-sep"></div>
|
||||
<div
|
||||
class="sd-perm-option danger"
|
||||
@click="removeAccess(a.email); a.permOpen=false"
|
||||
>
|
||||
Remove
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endraw %}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- General Access -->
|
||||
<div class="sd-section">
|
||||
<div class="sd-section-title">General access</div>
|
||||
<div style="position: relative">
|
||||
<button
|
||||
class="sd-access-btn"
|
||||
@click="accessMenuOpen = !accessMenuOpen"
|
||||
>
|
||||
<span
|
||||
x-text="generalAccess === 'invited' ? '🔒 Only people invited' : '🌐 Anyone with the link'"
|
||||
></span>
|
||||
<span class="chevron-down">▾</span>
|
||||
</button>
|
||||
<div
|
||||
class="sd-access-menu"
|
||||
x-show="accessMenuOpen"
|
||||
@click.away="accessMenuOpen = false"
|
||||
x-transition
|
||||
>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: generalAccess === 'invited' }"
|
||||
@click="generalAccess='invited'; accessMenuOpen=false"
|
||||
>
|
||||
<span>🔒 Only people invited</span>
|
||||
<span class="sd-perm-desc"
|
||||
>People need to be invited</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: generalAccess === 'anyone' }"
|
||||
@click="generalAccess='anyone'; accessMenuOpen=false"
|
||||
>
|
||||
<span>🌐 Anyone with the link</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Can view the page</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Public permission when link is open -->
|
||||
<div
|
||||
x-show="generalAccess === 'anyone'"
|
||||
style="
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
"
|
||||
>Anyone with the link</span
|
||||
>
|
||||
<select class="sd-select-sm" x-model="publicPerm">
|
||||
<option value="viewer">Can view</option>
|
||||
<option value="commenter">Can comment</option>
|
||||
<option value="editor">Can edit</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sd-footer">
|
||||
<a href="#" class="sd-footer-link" @click.prevent
|
||||
>? Learn about sharing</a
|
||||
>
|
||||
<button class="sd-footer-action" @click="copyPageLink">
|
||||
🔗 Copy link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ PUBLISH TAB ═══════ -->
|
||||
<div
|
||||
class="sd-body"
|
||||
x-show="shareTab === 'publish'"
|
||||
x-transition
|
||||
>
|
||||
<!-- Before publishing -->
|
||||
<div x-show="!pagePublished" class="sd-publish-hero">
|
||||
<div class="sd-publish-preview">
|
||||
<div class="sd-preview-bar"></div>
|
||||
<div class="sd-preview-title">
|
||||
🌐 {{ page.title }}
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="sd-publish-heading">Publish to web</h3>
|
||||
<p class="sd-publish-desc">
|
||||
Make this page visible to anyone on the internet.
|
||||
You can share the link with anyone.
|
||||
</p>
|
||||
<button
|
||||
class="sd-btn-primary sd-btn-full"
|
||||
@click="pagePublished = true"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
<p class="sd-publish-note">
|
||||
Your page will be visible to anyone with the link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- After publishing -->
|
||||
<div x-show="pagePublished">
|
||||
<div class="sd-url-bar">
|
||||
<span
|
||||
style="font-size: 13px; color: var(--text-dim)"
|
||||
>{{ workspace_key or 'flowdeck' }}</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="sd-url-input"
|
||||
:value="publishedUrl || pageUrl"
|
||||
readonly
|
||||
@click="$event.target.select()"
|
||||
/>
|
||||
<button
|
||||
class="sd-btn-primary"
|
||||
style="
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
"
|
||||
@click="copyPageLink"
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- Settings list -->
|
||||
<div class="sd-settings">
|
||||
<div class="sd-setting-row">
|
||||
<span>🔗 Link expires</span>
|
||||
<span style="color: var(--text-dim)"
|
||||
>Never ▾</span
|
||||
>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>🔍 Search engine indexing</span>
|
||||
<span style="color: var(--text-dim)"
|
||||
>Off ▾</span
|
||||
>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>📄 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>
|
||||
<label class="toggle-switch sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="pubAllowEdit"
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>💬 Allow comments</span>
|
||||
<label class="toggle-switch sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="pubAllowComments"
|
||||
checked
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<button
|
||||
class="sd-danger-btn"
|
||||
@click="pagePublished = false"
|
||||
>
|
||||
Unpublish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- More menu dropdown -->
|
||||
<div
|
||||
class="more-menu"
|
||||
x-show="moreOpen"
|
||||
@click.away="moreOpen = false"
|
||||
x-transition
|
||||
>
|
||||
<div class="more-menu-item" @click="exportPage">↗ Export</div>
|
||||
<div class="more-menu-item" @click="duplicatePage">
|
||||
📋 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
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Page Content ═══════════ -->
|
||||
<div class="page-cover-area">
|
||||
<div class="page-title-block">
|
||||
<span class="page-icon-emoji">📄</span>
|
||||
<div
|
||||
class="page-title-input"
|
||||
contenteditable="true"
|
||||
id="_titleEl"
|
||||
@input="dirty=true;save()"
|
||||
@keydown.enter.prevent="focusBlock()"
|
||||
@paste.prevent="pastePlain($event)"
|
||||
spellcheck="false"
|
||||
>
|
||||
{{ page.title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="blocks-container"
|
||||
id="_blocksCt"
|
||||
@click="onCtClick($event)"
|
||||
></div>
|
||||
<div
|
||||
id="_slashMenu"
|
||||
class="slash-menu"
|
||||
style="display: none; position: fixed; z-index: 999"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="format-toolbar"
|
||||
x-show="fmt.open"
|
||||
:style="{top:fmt.top+'px',left:fmt.left+'px'}"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<button @click="fmtApply('bold')"><strong>B</strong></button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Copy Link Toast ═══════════ -->
|
||||
<div
|
||||
class="sd-toast"
|
||||
x-show="toastVisible"
|
||||
x-transition
|
||||
x-text="toastMsg"
|
||||
></div>
|
||||
|
||||
<!-- ═══════════ Get Started Toolbar (bottom) ═══════════ -->
|
||||
<div
|
||||
class="get-started-toolbar"
|
||||
x-show="blocks.length === 1 && blocks[0].type === 'paragraph' && !blocks[0].content.trim()"
|
||||
>
|
||||
<span class="gs-label">Get started with</span>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="replaceBlock(0, 'paragraph');blocks[0].content='Ask AI to write...';render()"
|
||||
>
|
||||
<span class="gs-icon">✨</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
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="replaceBlock(0, 'paragraph');blocks[0].content='Database';render()"
|
||||
>
|
||||
<span class="gs-icon">🗄️</span> Database
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="replaceBlock(0, 'paragraph');blocks[0].content='Form';render()"
|
||||
>
|
||||
<span class="gs-icon">📋</span> Form
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="replaceBlock(0, 'paragraph');blocks[0].content='Templates';render()"
|
||||
>
|
||||
<span class="gs-icon">📑</span> Templates
|
||||
</button>
|
||||
<div style="position: relative">
|
||||
<button class="gs-pill" @click="gsMoreOpen = !gsMoreOpen">
|
||||
<span>⋯</span>
|
||||
</button>
|
||||
<div
|
||||
class="gs-more-dropdown"
|
||||
x-show="gsMoreOpen"
|
||||
@click.away="gsMoreOpen = false"
|
||||
x-transition
|
||||
>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
📊 Table
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
📋 Board
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
📝 List
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
📅 Timeline
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
🗓️ Calendar
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
🖼️ Gallery
|
||||
</div>
|
||||
<div class="gs-more-sep"></div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
📥 Import
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-statusbar">
|
||||
<span x-show="saving">Saving...</span>
|
||||
<span x-show="!saving&&dirty">Unsaved</span>
|
||||
<span x-show="!saving&&!dirty&&lastSaved"
|
||||
>Saved <span x-text="lastSaved"></span
|
||||
></span>
|
||||
<span class="statusbar-right" x-text="blocks.length+' blocks'"></span>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script type="application/json" id="page-data">
|
||||
{{ page_data | tojson }}
|
||||
</script>
|
||||
<script>
|
||||
const CMDS=[{name:'BASIC',items:[
|
||||
{id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'},
|
||||
{id:'heading_3',name:'Heading 3',icon:'H3'},{id:'heading_4',name:'Heading 4',icon:'H4'},{id:'bulleted_list',name:'Bulleted list',icon:'•'},{id:'numbered_list',name:'Numbered list',icon:'1.'},
|
||||
{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:'🖼'}]}];
|
||||
|
||||
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
|
||||
function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;}
|
||||
function ce(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(false);window.getSelection().removeAllRanges();window.getSelection().addRange(r);}
|
||||
function cs(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(true);window.getSelection().removeAllRanges();window.getSelection().addRange(r);}
|
||||
function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
|
||||
const SM={_o:false,_i:-1,_s:0,_q:'',_it:[],
|
||||
init(){const e=document.getElementById('_slashMenu');if(!e)return;e.innerHTML='';
|
||||
const inp=document.createElement('input');inp.type='text';inp.placeholder='Filter...';
|
||||
inp.style.cssText='width:100%;padding:6px 10px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box';
|
||||
inp.oninput=()=>SM.flt();inp.onkeydown=ev=>SM._kd(ev);
|
||||
const hdr=document.createElement('div');hdr.style.cssText='padding:8px;border-bottom:1px solid var(--border)';hdr.appendChild(inp);
|
||||
const list=document.createElement('div');list.style.cssText='overflow-y:auto;max-height:340px';
|
||||
list.addEventListener('click',ev=>{const it=ev.target.closest('.slash-item');if(it){ev.preventDefault();SM.sel(it.dataset.sid);}});
|
||||
e.appendChild(hdr);e.appendChild(list);
|
||||
SM._e=e;SM._in=inp;SM._li=list;
|
||||
document.addEventListener('mousedown',ev=>{if(!e.contains(ev.target))SM.close();});
|
||||
},
|
||||
open(i,el){if(!SM._e)SM.init();SM.close();SM._i=i;SM._s=0;SM._in.value='';SM.flt();
|
||||
const r=el.getBoundingClientRect();SM._e.style.display='block';
|
||||
SM._e.style.top=Math.max(4,(r.bottom+4>window.innerHeight-340)?r.top-344:r.bottom+4)+'px';
|
||||
SM._e.style.left=Math.min(r.left,window.innerWidth-324)+'px';
|
||||
setTimeout(()=>SM._in.focus(),10);SM._o=true;},
|
||||
close(){if(SM._e)SM._e.style.display='none';SM._o=false;SM._in&&(SM._in.value='');},
|
||||
flt(){if(!SM._li)return;SM._it=[];const q=(SM._in.value||'').toLowerCase();SM._q=q;let h='';
|
||||
for(const g of CMDS){let items=g.items;if(q)items=items.filter(c=>c.name.toLowerCase().includes(q)||c.id.includes(q));if(!items.length)continue;
|
||||
h+=`<div style="padding:6px 12px 4px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px">${g.name}</div>`;
|
||||
items.forEach(c=>{SM._it.push(c);
|
||||
h+=`<div class="slash-item" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:8px 12px;font-size:14px;color:var(--text-primary);cursor:pointer">
|
||||
<span style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;font-size:16px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span>
|
||||
<span style="flex:1;font-weight:500">${c.name}</span></div>`;});}
|
||||
SM._li.innerHTML=h;},
|
||||
_kd(e){if(e.key==='ArrowDown'){e.preventDefault();SM._s=Math.min(SM._s+1,SM._it.length-1);SM._rs();}
|
||||
else if(e.key==='ArrowUp'){e.preventDefault();SM._s=Math.max(SM._s-1,0);SM._rs();}
|
||||
else if(e.key==='Enter'){e.preventDefault();const c=SM._it[SM._s];if(c)SM.sel(c.id);}
|
||||
else if(e.key==='Escape'){e.preventDefault();SM.close();}},
|
||||
_rs(){const its=SM._li.querySelectorAll('.slash-item');its.forEach((el,i)=>{el.classList.toggle('selected',i===SM._s);});},
|
||||
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
|
||||
get isOpen(){return SM._o;}};
|
||||
|
||||
function renderBlock(b,idx){
|
||||
if(b.type==='divider')return `<div class="block-wrapper"><hr class="block-divider"></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...';
|
||||
let inner='';
|
||||
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 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}`;
|
||||
const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}"`:'';
|
||||
return `<div class="block-wrapper">
|
||||
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
|
||||
<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>
|
||||
<${tag}${style} class="${cls}">${inner}</${tag}>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
window.E=null;
|
||||
function editorState(){
|
||||
return {
|
||||
pid:null,pageTitle:'',blocks:[],dirty:false,saving:false,lastSaved:'',st:null,
|
||||
fmt:{open:false,top:0,left:0,idx:-1},
|
||||
shareOpen:false,moreOpen:false,gsMoreOpen:false,accessMenuOpen:false,
|
||||
shareTab:'share',favorited:false,
|
||||
pagePublished:{{ page_published | tojson }},
|
||||
generalAccess:'{{ page_share_mode }}',publicPerm:'viewer',
|
||||
pubAllowEdit:false,pubAllowComments:true,
|
||||
pageUrl:window.location.href,publishedUrl:'',
|
||||
inviteEmail:'',invitePermission:'editor',accessList:[],
|
||||
toastVisible:false,toastMsg:'',
|
||||
init(){
|
||||
_bid=Math.floor(Math.random()*10000);
|
||||
const dataEl=document.getElementById('page-data');
|
||||
if(dataEl){try{const data=JSON.parse(dataEl.textContent);this.pid=data.id;this.pageTitle=data.title||'';this.favorited=data.favorited||false;const fmt=data.content_format||'blocks';const raw=data.content||'';
|
||||
if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);this.blocks.forEach(b=>{if(!b.id)b.id=genId()});}catch(e){this.blocks=[];}}
|
||||
else if(raw&&raw.trim())this.blocks=this.md2b(raw);}catch(e){}}
|
||||
if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];
|
||||
window.E=this;SM.init();this.render();
|
||||
setTimeout(()=>{this.focusBlock();const ct=document.getElementById('_blocksCt');if(!ct)return;
|
||||
ct.addEventListener('keydown',e=>this.onKd(e));
|
||||
ct.addEventListener('paste',e=>{e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();});
|
||||
if(typeof Sortable!=='undefined')Sortable.create(ct,{draggable:'.block-wrapper',handle:'.block-handle',animation:150,ghostClass:'sortable-ghost',dragClass:'sortable-drag',onEnd:evt=>{if(evt.oldIndex===evt.newIndex)return;const[item]=this.blocks.splice(evt.oldIndex,1);this.blocks.splice(evt.newIndex,0,item);this.dirty=true;this.autoSave();this.render();}});
|
||||
},100);
|
||||
},
|
||||
mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;return b;},
|
||||
getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;},
|
||||
getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);},
|
||||
getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};},
|
||||
focusBlock(){const b=this.blocks[0];if(!b)return;const el=this.getEl(b.id);if(el){el.focus();ce(el);}},
|
||||
onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}},
|
||||
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='divider')continue;const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el)b.content=el.textContent||'';}},
|
||||
render(){const ct=document.getElementById('_blocksCt');if(!ct)return;let html='';for(let i=0;i<this.blocks.length;i++)html+=renderBlock(this.blocks[i],i);ct.innerHTML=html;
|
||||
ct.querySelectorAll('.block-wrapper').forEach(w=>{w.onmouseenter=()=>{w.querySelector('.block-handle')?.classList.add('visible');w.querySelector('.block-actions')?.classList.add('visible');};w.onmouseleave=()=>{w.querySelector('.block-handle')?.classList.remove('visible');w.querySelector('.block-actions')?.classList.remove('visible');};});
|
||||
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
|
||||
},
|
||||
replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);},
|
||||
addAt(idx,type,content){this.sync();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;},
|
||||
addAfter(idx,type){return this.addAt(idx+1,type);},
|
||||
removeBlock(idx){this.sync();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
|
||||
|
||||
toggleFavorite(){
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const m=this.favorited?'DELETE':'POST';
|
||||
fetch(`/board/api/favorites/${this.pid}`,{method:m,headers:{'X-CSRF-Token':csrf?csrf[1]:''}})
|
||||
.then(r=>r.json()).then(()=>{this.favorited=!this.favorited;}).catch(()=>{});
|
||||
},
|
||||
togglePublish(){this.pagePublished=!this.pagePublished;this.saveShare();},
|
||||
saveShare(){
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/board/api/share/${this.pid}`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({mode:this.generalAccess,published:this.pagePublished})}).catch(()=>{});
|
||||
},
|
||||
copyPageLink(){
|
||||
const url = this.pageUrl;
|
||||
const fallback = () => {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = url; ta.style.position='fixed'; ta.style.opacity='0';
|
||||
document.body.appendChild(ta); ta.select();
|
||||
document.execCommand('copy'); document.body.removeChild(ta);
|
||||
};
|
||||
try {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
this.showToast('Link copied to clipboard');
|
||||
}).catch(() => { fallback(); this.showToast('Link copied to clipboard'); });
|
||||
} catch(e) { fallback(); this.showToast('Link copied to clipboard'); }
|
||||
},
|
||||
showToast(msg){
|
||||
this.toastMsg = msg; this.toastVisible = true;
|
||||
clearTimeout(this._toastTimer);
|
||||
this._toastTimer = setTimeout(() => { this.toastVisible = false; }, 2500);
|
||||
},
|
||||
invitePerson(){if(!this.inviteEmail.trim())return;this.accessList.push({email:this.inviteEmail,permission:this.invitePermission,permOpen:false});this.inviteEmail='';},
|
||||
removeAccess(email){this.accessList=this.accessList.filter(a=>a.email!==email);},
|
||||
exportPage(){this.moreOpen=false;},
|
||||
duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}§ion=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{alert('Duplicate failed');});},
|
||||
movePage(){this.moreOpen=false;},
|
||||
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{alert('Failed');});},
|
||||
|
||||
onKd(e){
|
||||
const ab=this.getActiveBlock();if(!ab)return;
|
||||
const{el,bid,idx}=ab;const block=this.blocks[idx];if(!block)return;
|
||||
if(SM.isOpen){
|
||||
if(e.key==='Escape'){e.preventDefault();SM.close();return;}
|
||||
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){e.preventDefault();SM._in.dispatchEvent(new KeyboardEvent('keydown',{key:e.key,bubbles:true}));return;}
|
||||
if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;}
|
||||
return;
|
||||
}
|
||||
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen)SM.open(i2,e2);},15);}
|
||||
if(e.key===' '){const i2=idx;setTimeout(()=>this.checkMd(i2),15);}
|
||||
if(e.key==='Enter'){
|
||||
if(e.shiftKey){this.dirty=true;this.autoSave();return;}
|
||||
e.preventDefault();
|
||||
const text=el.textContent||'',pos=cp(el),before=text.substring(0,pos),after=text.substring(pos);
|
||||
if(!before.trim()&&!after.trim()){
|
||||
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
|
||||
this.sync();this.blocks.splice(idx+1,0,this.mkB(nt,''));this.render();
|
||||
setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();ce(ne);}},60);
|
||||
this.dirty=true;this.autoSave();return;
|
||||
}
|
||||
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
|
||||
this.sync();block.content=before;this.blocks.splice(idx+1,0,this.mkB(nt,after));this.render();
|
||||
setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();cs(ne);}},60);
|
||||
this.dirty=true;this.autoSave();return;
|
||||
}
|
||||
if(e.key==='ArrowUp'){if(cp(el)===0&&idx>0){e.preventDefault();const prev=this.getEl(this.blocks[idx-1]?.id);if(prev){prev.focus();ce(prev);}}return;}
|
||||
if(e.key==='ArrowDown'){if(cp(el)>=(el.textContent||'').length&&idx<this.blocks.length-1){e.preventDefault();const next=this.getEl(this.blocks[idx+1]?.id);if(next){next.focus();cs(next);}}return;}
|
||||
if(e.key==='Backspace'){if(!(el.textContent||'').trim()){this.sync();if(block.type!=='paragraph'){e.preventDefault();block.type='paragraph';block.content='';this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;}
|
||||
if(e.key===' '){const b2=bid;setTimeout(()=>this.checkMd(b2),15);}
|
||||
if((e.ctrlKey||e.metaKey)&&!e.altKey){const m={b:'bold',i:'italic',u:'underline'};if(m[e.key]){e.preventDefault();document.execCommand(m[e.key]);this.dirty=true;this.autoSave();}}
|
||||
if(e.key==='Tab'&&block.type==='code'){e.preventDefault();document.execCommand('insertText',false,' ');}
|
||||
this.dirty=true;this.autoSave();
|
||||
},
|
||||
checkMd(idx){
|
||||
if(idx<0||idx>=this.blocks.length)return;const block=this.blocks[idx];const el=this.getEl(block.id);if(!el)return;const text=el.textContent||'';
|
||||
const sc={'# ':'heading_1','## ':'heading_2','### ':'heading_3','#### ':'heading_4','- ':'bulleted_list','* ':'bulleted_list','+ ':'bulleted_list','1. ':'numbered_list','[] ':'to_do','[ ] ':'to_do','> ':'quote'};
|
||||
this.sync();
|
||||
for(const[pfx,type] of Object.entries(sc)){if(text===pfx||text.startsWith(pfx)){block.type=type;block.content='';if(type==='toggle')block.expanded=true;if(type==='to_do')block.checked=false;this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne&&type!=='divider')ne.focus();},60);return;}}
|
||||
if(text==='---'){block.type='divider';block.content='';this.addAfter(idx,'paragraph');}
|
||||
if(text==='```'){block.type='code';block.content='';block.language='Plain Text';this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}
|
||||
},
|
||||
applySlash(id){
|
||||
const idx=SM._i;if(idx<0||idx>=this.blocks.length)return;
|
||||
const block=this.blocks[idx];this.sync();block.content='';
|
||||
if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');}
|
||||
else{block.type=id;if(id==='toggle')block.expanded=true;if(id==='to_do')block.checked=false;if(id==='code')block.language='Plain Text';}
|
||||
this.render();this.dirty=true;this.autoSave();
|
||||
setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);
|
||||
},
|
||||
_doToggle(bid,v){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].checked=v;this.dirty=true;this.autoSave();}},
|
||||
_doToggleExpand(bid){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].expanded=!this.blocks[idx].expanded;this.dirty=true;this.autoSave();}},
|
||||
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
|
||||
fmtApply(f){this.fmt.open=false;if(!this._sel)return;const s=window.getSelection();s.removeAllRanges();s.addRange(this._sel.range);document.execCommand(f==='strikeThrough'?'strikeThrough':f);this._sel=null;this.dirty=true;this.autoSave();},
|
||||
fmtLink(){this.fmt.open=false;const u=prompt('URL:');if(u){document.execCommand('createLink',false,u);this.dirty=true;this.autoSave();}},
|
||||
pastePlain(e){e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();},
|
||||
autoSave(){clearTimeout(this.st);this.st=setTimeout(()=>this.save(),1500);},
|
||||
save(){
|
||||
if(this.saving)return;
|
||||
this.sync();
|
||||
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'New page';
|
||||
this.saving=true;
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
|
||||
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
|
||||
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
|
||||
}).catch(()=>{this.saving=false;});
|
||||
},
|
||||
md2b(md){const bl=[];let cb=null;for(const line of md.split('\n')){if(cb){if(line.trim()==='```'){bl.push(cb);cb=null;}else cb.content+=(cb.content?'\n':'')+line;continue;}const t=line.trim();if(!t)continue;if(t.startsWith('```')){cb=this.mkB('code','',{language:t.substring(3).trim()||'Plain Text'});continue;}if(t.startsWith('# ')&&!t.startsWith('## '))bl.push(this.mkB('heading_1',t.substring(2)));else if(t.startsWith('## ')&&!t.startsWith('### '))bl.push(this.mkB('heading_2',t.substring(3)));else if(t.startsWith('### ')&&!t.startsWith('#### '))bl.push(this.mkB('heading_3',t.substring(4)));else if(t.startsWith('#### '))bl.push(this.mkB('heading_4',t.substring(5)));else if(t==='---'||t==='***')bl.push(this.mkB('divider',''));else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));else if(/^\d+\. /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+\. /,'')));else if(t.startsWith('- [ ] ')||t.startsWith('* [ ] '))bl.push(this.mkB('to_do',t.substring(6)));else if(t.startsWith('- [x] ')||t.startsWith('* [x] '))bl.push(this.mkB('to_do',t.substring(6),{checked:true}));else if(t.startsWith('> '))bl.push(this.mkB('quote',t.substring(2)));else bl.push(this.mkB('paragraph',t));}if(cb)bl.push(cb);return bl;},
|
||||
};
|
||||
}
|
||||
document.addEventListener('mouseup',()=>{setTimeout(()=>{const s=window.getSelection();if(!s||s.isCollapsed)return;const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;const d=ed.__x.$data;const ct=document.getElementById('_blocksCt');if(!ct?.contains(s.anchorNode))return;const bel=s.anchorNode.parentElement?.closest('[data-bid]');if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);}},80);});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,192 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}Account Settings{% endblock %}
|
||||
{% block page_icon %}⚙️{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
<header class="topbar">
|
||||
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="topbar-left">
|
||||
<span class="breadcrumb" style="color:rgba(255,255,255,.5)">
|
||||
Settings
|
||||
</span>
|
||||
<span class="page-title-topbar">Account Settings</span>
|
||||
</div>
|
||||
<div class="topbar-right"></div>
|
||||
</header>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.settings-layout{max-width:700px;margin:0 auto;padding:40px 24px;}
|
||||
.settings-section{margin-bottom:32px;}
|
||||
.settings-section h2{font-size:14px;color:rgba(255,255,255,.5);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px;font-weight:500;}
|
||||
.settings-card{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden;}
|
||||
.settings-row{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid rgba(255,255,255,.06);}
|
||||
.settings-row:last-child{border-bottom:none;}
|
||||
.settings-row-left{flex:1;}
|
||||
.settings-row-label{font-size:14px;color:#fff;}
|
||||
.settings-row-desc{font-size:12px;color:rgba(255,255,255,.45);margin-top:2px;}
|
||||
.settings-row-value{font-size:13px;color:rgba(255,255,255,.5);margin-right:12px;}
|
||||
.settings-input{background:#2A2A2A;border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:8px 12px;color:#fff;font-size:13px;width:220px;outline:none;}
|
||||
.settings-input:focus{border-color:#2383E2;}
|
||||
.settings-btn{padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
|
||||
.settings-btn-primary{background:#2383E2;color:#fff;}
|
||||
.settings-btn-primary:hover{background:#2C8CEB;}
|
||||
.settings-btn-danger{background:rgba(255,80,80,.15);color:#ff5050;}
|
||||
.settings-btn-danger:hover{background:rgba(255,80,80,.25);}
|
||||
.settings-btn-secondary{background:#333;color:#fff;}
|
||||
.settings-btn-secondary:hover{background:#444;}
|
||||
.inline-form{display:flex;align-items:center;gap:8px;}
|
||||
.forge-badge{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:#2A2A2A;border-radius:6px;font-size:12px;}
|
||||
.connected{color:#50ff50;}
|
||||
.disconnected{color:rgba(255,255,255,.3);}
|
||||
</style>
|
||||
|
||||
<div class="settings-layout" x-data="settingsPage()">
|
||||
|
||||
<!-- Profile -->
|
||||
<div class="settings-section">
|
||||
<h2>Profile</h2>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="settings-row-left">
|
||||
<div class="settings-row-label">Email</div>
|
||||
<div class="settings-row-desc">Used for login and notifications</div>
|
||||
</div>
|
||||
<div class="settings-row-value">{{ user.email or user.login or '' }}</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-row-left">
|
||||
<div class="settings-row-label">Name</div>
|
||||
<div class="settings-row-desc">Display name</div>
|
||||
</div>
|
||||
<div class="inline-form">
|
||||
<input class="settings-input" x-model="name" :value="name" placeholder="Your name">
|
||||
<button class="settings-btn settings-btn-primary" @click="saveName">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-row-left">
|
||||
<div class="settings-row-label">Password</div>
|
||||
<div class="settings-row-desc">Change your password</div>
|
||||
</div>
|
||||
<div class="inline-form">
|
||||
<input class="settings-input" type="password" x-model="newPassword" placeholder="New password">
|
||||
<button class="settings-btn settings-btn-primary" @click="savePassword">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected Forges -->
|
||||
<div class="settings-section">
|
||||
<h2>Connected Forges</h2>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="settings-row-left">
|
||||
<div class="settings-row-label">🔗 Gitea</div>
|
||||
<div class="settings-row-desc">Connect to your Gitea instance</div>
|
||||
</div>
|
||||
{% if gitea_connected %}
|
||||
<span class="forge-badge connected">✅ Connected</span>
|
||||
<button class="settings-btn settings-btn-danger" @click="disconnect('gitea')">Disconnect</button>
|
||||
{% else %}
|
||||
<button class="settings-btn settings-btn-primary" onclick="window.location='/auth/login?provider=gitea'">Connect</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-row-left">
|
||||
<div class="settings-row-label">🐙 GitHub</div>
|
||||
<div class="settings-row-desc">Connect to GitHub</div>
|
||||
</div>
|
||||
{% if github_connected %}
|
||||
<span class="forge-badge connected">✅ Connected</span>
|
||||
<button class="settings-btn settings-btn-danger" @click="disconnect('github')">Disconnect</button>
|
||||
{% else %}
|
||||
<button class="settings-btn settings-btn-secondary">
|
||||
Coming soon
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Tokens -->
|
||||
<div class="settings-section">
|
||||
<h2>API Tokens</h2>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="settings-row-left">
|
||||
<div class="settings-row-label">Generate API Token</div>
|
||||
<div class="settings-row-desc">For programmatic access to FlowDeck API</div>
|
||||
</div>
|
||||
<button class="settings-btn settings-btn-primary" @click="generateToken">Generate</button>
|
||||
</div>
|
||||
<template x-if="token">
|
||||
<div class="settings-row" style="background:#1a2a1a">
|
||||
<div class="settings-row-left">
|
||||
<div class="settings-row-label" style="font-family:monospace;font-size:12px;word-break:break-all" x-text="token"></div>
|
||||
<div class="settings-row-desc" style="color:#50ff50">Copy this token now — it won't be shown again</div>
|
||||
</div>
|
||||
<button class="settings-btn settings-btn-primary" @click="copyToken">Copy</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<div class="settings-section">
|
||||
<h2>Active Sessions</h2>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="settings-row-left">
|
||||
<div class="settings-row-label">Current session</div>
|
||||
<div class="settings-row-desc">You are logged in</div>
|
||||
</div>
|
||||
<button class="settings-btn settings-btn-danger" onclick="window.location='/auth/logout'">Log out</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function settingsPage() {
|
||||
return {
|
||||
name: '{{ user.full_name or '' }}',
|
||||
newPassword: '',
|
||||
token: '',
|
||||
async saveName() {
|
||||
await fetch('/api/user/profile', {method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({full_name:this.name})});
|
||||
alert('Name saved');
|
||||
},
|
||||
async savePassword() {
|
||||
if (!this.newPassword || this.newPassword.length < 6) { alert('Password must be at least 6 characters'); return; }
|
||||
await fetch('/api/user/password', {method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:this.newPassword})});
|
||||
this.newPassword = '';
|
||||
alert('Password updated');
|
||||
},
|
||||
async generateToken() {
|
||||
const r = await fetch('/api/user/token', {method:'POST'});
|
||||
const d = await r.json();
|
||||
this.token = d.token;
|
||||
},
|
||||
copyToken() {
|
||||
navigator.clipboard?.writeText(this.token);
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = this.token; ta.style.position='fixed'; ta.style.opacity='0';
|
||||
document.body.appendChild(ta); ta.select(); document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
alert('Token copied to clipboard');
|
||||
},
|
||||
async disconnect(provider) {
|
||||
if (!confirm('Disconnect ' + provider + '?')) return;
|
||||
await fetch('/api/user/forge/' + provider, {method:'DELETE'});
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,95 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}🗑️{% endblock %}
|
||||
{% block page_title %}Trash{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">🗑️</span>
|
||||
<h1>Trash</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Filters -->
|
||||
<div style="display:flex; gap:8px; margin-bottom:16px;">
|
||||
<button class="trash-filter active">
|
||||
<span style="color:#5b9bd5;">👤</span> Last edited by ▾
|
||||
</button>
|
||||
<button class="trash-filter">
|
||||
<span>📁</span> In ▾
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Items -->
|
||||
<div style="min-height:200px;">
|
||||
<template x-for="item in filteredItems" :key="item.id">
|
||||
<div class="trash-item">
|
||||
<span class="trash-item-icon" x-text="item.icon"></span>
|
||||
<div class="trash-item-info">
|
||||
<span class="trash-item-name" x-text="item.name"></span>
|
||||
<span class="trash-item-path" x-text="item.path"></span>
|
||||
</div>
|
||||
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">
|
||||
↩️
|
||||
</button>
|
||||
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="filteredItems.length === 0" class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">🗑️</div>
|
||||
<h3>Trash is empty</h3>
|
||||
<p>Deleted pages will appear here for 30 days.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div style="padding:12px 16px; margin-top:16px; background:var(--bg-tertiary); border-radius:8px; font-size:13px; color:var(--text-dim); display:flex; align-items:center; gap:8px;">
|
||||
<span>Once a page has been in Trash for 30 days, it will be automatically deleted</span>
|
||||
<span style="font-size:16px;">ⓘ</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function trashData() {
|
||||
return {
|
||||
search: '',
|
||||
items: [],
|
||||
async init() {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const token = csrf ? csrf[1] : '';
|
||||
try {
|
||||
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': token } });
|
||||
this.items = await r.json();
|
||||
} catch(e) { this.items = []; }
|
||||
},
|
||||
get filteredItems() {
|
||||
const q = this.search.toLowerCase();
|
||||
return this.items.filter(i => !q || i.name.toLowerCase().includes(q) || (i.path||'').toLowerCase().includes(q));
|
||||
},
|
||||
async restore(id) {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const r = await fetch(`/board/api/trash/${id}/restore`, { method: 'POST', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
||||
},
|
||||
async deleteForever(id) {
|
||||
if (!confirm('Permanently delete this page? This cannot be undone.')) return;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const r = await fetch(`/board/api/trash/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,198 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}Workspace{% endblock %}
|
||||
{% block page_icon %}🏠{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
<header class="topbar">
|
||||
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="topbar-left">
|
||||
<span class="breadcrumb" style="color:rgba(255,255,255,.5)">Workspace</span>
|
||||
<span class="page-title-topbar">Projects</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<button class="page-action-btn" onclick="window.location='/auth/login?provider=local'" title="Login">🔑</button>
|
||||
<button class="page-action-btn" onclick="window.location='/accounts/settings'" title="Settings">⚙</button>
|
||||
</div>
|
||||
</header>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.workspace-layout{max-width:900px;margin:0 auto;padding:32px 24px;}
|
||||
.workspace-header{margin-bottom:24px;}
|
||||
.workspace-header h1{font-size:28px;font-weight:600;margin-bottom:8px;}
|
||||
.workspace-header p{color:rgba(255,255,255,.5);font-size:14px;}
|
||||
|
||||
.forge-section{margin-bottom:32px;}
|
||||
.forge-section h2{font-size:14px;color:rgba(255,255,255,.45);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px;font-weight:500;display:flex;align-items:center;gap:8px;}
|
||||
.forge-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:500;}
|
||||
.forge-badge.gitea{background:rgba(96,155,80,.15);color:#609b50;}
|
||||
.forge-badge.github{background:rgba(110,84,148,.15);color:#6e5494;}
|
||||
.forge-badge.builtin{background:rgba(35,131,226,.15);color:#2383E2;}
|
||||
|
||||
.project-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;}
|
||||
.project-card{background:#222;border:1px solid rgba(255,255,255,.06);border-radius:12px;padding:16px;cursor:pointer;transition:background 150ms ease;}
|
||||
.project-card:hover{background:#2A2A2A;border-color:rgba(255,255,255,.12);}
|
||||
.project-card-top{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:8px;}
|
||||
.project-card-icon{font-size:20px;}
|
||||
.project-card-name{font-size:14px;font-weight:500;color:#fff;margin-bottom:4px;}
|
||||
.project-card-desc{font-size:12px;color:rgba(255,255,255,.4);margin-bottom:8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
|
||||
.project-card-meta{display:flex;align-items:center;gap:8px;font-size:11px;color:rgba(255,255,255,.3);}
|
||||
|
||||
.empty-state{text-align:center;padding:40px;color:rgba(255,255,255,.3);font-size:14px;}
|
||||
.empty-state .btn{margin-top:12px;display:inline-block;padding:8px 16px;background:#2383E2;color:#fff;border-radius:8px;text-decoration:none;font-size:13px;}
|
||||
|
||||
.new-project-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;background:rgba(35,131,226,.15);color:#2383E2;border:1px solid rgba(35,131,226,.3);border-radius:8px;cursor:pointer;font-size:13px;}
|
||||
.new-project-btn:hover{background:rgba(35,131,226,.25);}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:500;}
|
||||
.modal-box{background:#1F1F1F;border:1px solid rgba(255,255,255,.08);border-radius:18px;padding:32px;width:400px;max-width:90vw;}
|
||||
.modal-box h3{font-size:16px;margin-bottom:16px;}
|
||||
.modal-input{width:100%;padding:10px 12px;background:#2A2A2A;border:1px solid rgba(255,255,255,.1);border-radius:8px;color:#fff;font-size:14px;margin-bottom:12px;outline:none;}
|
||||
.modal-input:focus{border-color:#2383E2;}
|
||||
.modal-actions{display:flex;gap:8px;justify-content:flex-end;}
|
||||
</style>
|
||||
|
||||
<div class="workspace-layout" x-data="workspacePage()">
|
||||
|
||||
<div class="workspace-header">
|
||||
<h1>🏠 Workspace</h1>
|
||||
<p>All your projects in one place — built-in, Gitea, and GitHub</p>
|
||||
</div>
|
||||
|
||||
<!-- Built-in Projects -->
|
||||
<div class="forge-section">
|
||||
<h2><span class="forge-badge builtin">Built-in</span> My Projects</h2>
|
||||
<div class="project-grid">
|
||||
<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="forge-badge builtin">Built-in</span>
|
||||
</div>
|
||||
<div class="project-card-name" x-text="p.name"></div>
|
||||
<div class="project-card-desc" x-text="p.description || 'No description'"></div>
|
||||
<div class="project-card-meta">
|
||||
<span x-text="p.pageCount + ' pages'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="project-card" style="border:1px dashed rgba(255,255,255,.1);display:flex;align-items:center;justify-content:center;min-height:100px" @click="showCreateModal = true">
|
||||
<span style="color:rgba(255,255,255,.3);font-size:24px">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gitea Projects -->
|
||||
<div class="forge-section" x-show="giteaProjects.length">
|
||||
<h2><span class="forge-badge gitea">🔗 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="forge-badge gitea">Gitea</span>
|
||||
</div>
|
||||
<div class="project-card-name" x-text="p.name"></div>
|
||||
<div class="project-card-desc" x-text="p.description || 'No description'"></div>
|
||||
<div class="project-card-meta">
|
||||
<span x-text="p.full_name"></span>
|
||||
<span x-show="p.language" x-text="'· ' + p.language"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GitHub Projects -->
|
||||
<div class="forge-section" x-show="githubProjects.length">
|
||||
<h2><span class="forge-badge github">🐙 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="forge-badge github">GitHub</span>
|
||||
</div>
|
||||
<div class="project-card-name" x-text="p.name"></div>
|
||||
<div class="project-card-desc" x-text="p.description || 'No description'"></div>
|
||||
<div class="project-card-meta">
|
||||
<span x-text="p.full_name"></span>
|
||||
<span x-show="p.language" x-text="'· ' + p.language"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div class="empty-state" x-show="!builtinProjects.length && !giteaProjects.length && !githubProjects.length">
|
||||
<p>No projects yet. Create a new project or connect a forge.</p>
|
||||
<a href="/auth/login?provider=local" class="btn">Get Started</a>
|
||||
</div>
|
||||
|
||||
<!-- Create Modal -->
|
||||
<div class="modal-overlay" x-show="showCreateModal" @click.away="showCreateModal=false" @keydown.escape="showCreateModal=false">
|
||||
<div class="modal-box">
|
||||
<h3>Create New Project</h3>
|
||||
<input class="modal-input" x-model="newProjectName" placeholder="Project name" @keydown.enter="createProject">
|
||||
<div class="modal-actions">
|
||||
<button class="settings-btn settings-btn-secondary" @click="showCreateModal=false">Cancel</button>
|
||||
<button class="settings-btn settings-btn-primary" @click="createProject">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function workspacePage() {
|
||||
return {
|
||||
builtinProjects: [],
|
||||
giteaProjects: [],
|
||||
githubProjects: [],
|
||||
showCreateModal: false,
|
||||
newProjectName: '',
|
||||
|
||||
async init() {
|
||||
try {
|
||||
const r = await fetch('/api/workspace/projects');
|
||||
const data = await r.json();
|
||||
this.builtinProjects = data.builtin || [];
|
||||
this.giteaProjects = data.gitea || [];
|
||||
this.githubProjects = data.github || [];
|
||||
} catch(e) { console.error('Failed to load projects', e); }
|
||||
},
|
||||
|
||||
openProject(p) {
|
||||
if (p.forge === 'gitea') {
|
||||
const parts = p.full_name.split('/');
|
||||
window.location = `/board/${parts[0]}/${parts[1]}`;
|
||||
} else if (p.forge === 'github') {
|
||||
window.open(p.html_url, '_blank');
|
||||
} else {
|
||||
window.location = `/pages/${p.id}`;
|
||||
}
|
||||
},
|
||||
|
||||
async createProject() {
|
||||
if (!this.newProjectName.trim()) return;
|
||||
const r = await fetch('/api/workspace/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: this.newProjectName.trim()})
|
||||
});
|
||||
if (r.ok) {
|
||||
const p = await r.json();
|
||||
this.builtinProjects.unshift({...p, icon: '📁', pageCount: 0});
|
||||
this.newProjectName = '';
|
||||
this.showCreateModal = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,152 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}Workspaces{% endblock %}
|
||||
{% block page_icon %}🏠{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
<header class="topbar">
|
||||
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="topbar-left">
|
||||
<span class="breadcrumb" style="color:rgba(255,255,255,.5)">Home</span>
|
||||
<span class="page-title-topbar">Workspaces</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<button class="page-action-btn" onclick="window.location='/accounts/settings'" title="Settings">⚙</button>
|
||||
</div>
|
||||
</header>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.ws-page{max-width:800px;margin:0 auto;padding:40px 24px;}
|
||||
.ws-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;}
|
||||
.ws-header h1{font-size:24px;}
|
||||
|
||||
.ws-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px;}
|
||||
.ws-card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:20px;cursor:pointer;transition:background 150ms;}
|
||||
.ws-card:hover{background:var(--bg-tertiary);border-color:var(--border-strong);}
|
||||
.ws-card.active{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
|
||||
.ws-card-name{font-size:16px;font-weight:500;margin-bottom:4px;}
|
||||
.ws-card-count{font-size:12px;color:var(--text-tertiary);}
|
||||
.ws-card-actions{display:flex;gap:4px;margin-top:8px;opacity:0;transition:opacity 150ms;}
|
||||
.ws-card:hover .ws-card-actions{opacity:1;}
|
||||
|
||||
.dialog-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:500;}
|
||||
.dialog-box{background:#1F1F1F;border:1px solid var(--border);border-radius:18px;padding:24px;width:400px;max-width:90vw;}
|
||||
.dialog-box h3{margin-bottom:16px;}
|
||||
.dialog-input{width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;margin-bottom:12px;outline:none;}
|
||||
.dialog-input:focus{border-color:var(--accent);}
|
||||
.dialog-actions{display:flex;gap:8px;justify-content:flex-end;}
|
||||
.btn{padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
|
||||
.btn-primary{background:var(--accent);color:#fff;}
|
||||
.btn-primary:hover{background:var(--accent-hover);}
|
||||
.btn-secondary{background:var(--bg-tertiary);color:var(--text);}
|
||||
.btn-secondary:hover{background:var(--bg-hover);}
|
||||
.btn-danger{background:rgba(255,80,80,.15);color:var(--danger);}
|
||||
.btn-danger:hover{background:rgba(255,80,80,.25);}
|
||||
.btn-new{border:1px dashed var(--border-strong);background:transparent;color:var(--text-secondary);display:flex;align-items:center;justify-content:center;min-height:120px;}
|
||||
.btn-new:hover{background:var(--bg-tertiary);color:var(--text);}
|
||||
</style>
|
||||
|
||||
<div class="ws-page" x-data="workspacesPage()">
|
||||
|
||||
<div class="ws-header">
|
||||
<h1>🏠 Workspaces</h1>
|
||||
<button class="btn btn-primary" @click="showCreate=true">+ New Workspace</button>
|
||||
</div>
|
||||
|
||||
<div class="ws-grid">
|
||||
<template x-for="ws in workspaces" :key="ws.id">
|
||||
<div class="ws-card" :class="{active:ws.id===activeId}" @click="select(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 btn-secondary" style="padding:4px 8px;font-size:11px" @click="renameWs(ws)">✏️</button>
|
||||
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px" @click="deleteWs(ws)">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="ws-card btn-new" @click="showCreate=true">
|
||||
<span style="font-size:32px">+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Rename dialog -->
|
||||
<div class="dialog-overlay" x-show="showCreate||showRename" @click.away="showCreate=false;showRename=false">
|
||||
<div class="dialog-box">
|
||||
<h3 x-text="showRename?'Rename Workspace':'New Workspace'"></h3>
|
||||
<input class="dialog-input" x-model="wsName" placeholder="Workspace name" @keydown.enter="showRename?doRename():doCreate()">
|
||||
<div class="dialog-actions">
|
||||
<button class="btn btn-secondary" @click="showCreate=false;showRename=false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="showRename?doRename():doCreate()" x-text="showRename?'Rename':'Create'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function workspacesPage() {
|
||||
return {
|
||||
workspaces: [],
|
||||
activeId: null,
|
||||
showCreate: false,
|
||||
showRename: false,
|
||||
wsName: '',
|
||||
renameTarget: null,
|
||||
|
||||
async init() {
|
||||
await this.load();
|
||||
},
|
||||
async load() {
|
||||
const r = await fetch('/api/workspaces');
|
||||
const d = await r.json();
|
||||
this.workspaces = d.workspaces || [];
|
||||
this.activeId = d.active_id;
|
||||
},
|
||||
async select(ws) {
|
||||
await fetch(`/api/workspaces/${ws.id}/select`, {method:'POST'});
|
||||
if (ws.page_count > 0) {
|
||||
window.location = '/local-workspace';
|
||||
} else {
|
||||
window.location = '/local-workspace';
|
||||
}
|
||||
},
|
||||
async doCreate() {
|
||||
if (!this.wsName.trim()) return;
|
||||
await fetch('/api/workspaces', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:this.wsName.trim()})
|
||||
});
|
||||
this.wsName = '';
|
||||
this.showCreate = false;
|
||||
await this.load();
|
||||
},
|
||||
renameWs(ws) {
|
||||
this.renameTarget = ws;
|
||||
this.wsName = ws.name;
|
||||
this.showRename = true;
|
||||
},
|
||||
async doRename() {
|
||||
if (!this.wsName.trim()||!this.renameTarget) return;
|
||||
await fetch(`/api/workspaces/${this.renameTarget.id}`, {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:this.wsName.trim()})
|
||||
});
|
||||
this.wsName = '';
|
||||
this.showRename = false;
|
||||
this.renameTarget = null;
|
||||
await this.load();
|
||||
},
|
||||
async deleteWs(ws) {
|
||||
if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return;
|
||||
await fetch(`/api/workspaces/${ws.id}`, {method:'DELETE'});
|
||||
await this.load();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,822 @@
|
||||
# Guide Technique — Recréer Database & Tasks de Notion dans FlowDeck
|
||||
|
||||
> **Version:** 1.0 · **Date:** 2026-07-10
|
||||
> **Auteur:** Hermes-Deepin · **Cible:** FlowDeck v1.2+
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Introduction](#introduction)
|
||||
2. [Comprendre Notion : le modèle « Blocs & Collections »](#1-comprendre-notion--le-modèle-blocs--collections)
|
||||
3. [La Database Notion : anatomie complète](#2-la-database-notion--anatomie-complète)
|
||||
4. [Les Tasks Notion : un cas spécial de database](#3-les-tasks-notion--un-cas-spécial-de-database)
|
||||
5. [Comment Notion connecte Database et Tasks](#4-comment-notion-connecte-database-et-tasks)
|
||||
6. [Implémentation dans FlowDeck : plan pas à pas](#5-implémentation-dans-flowdeck--plan-pas-à-pas)
|
||||
7. [Références](#6-références)
|
||||
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
L'objectif est de **recréer dans FlowDeck** les deux fonctionnalités les plus puissantes de Notion :
|
||||
|
||||
- **Les Databases** — tables structurées avec propriétés typées, vues multiples, filtres et tris
|
||||
- **Les Tasks** — sous-tâches hiérarchiques avec dépendances, liées aux databases
|
||||
|
||||
Ces deux systèmes sont **profondément interconnectés** dans Notion : les tâches ne sont qu'une forme spécialisée de database, avec un parent auto-référencé et des propriétés de dépendance.
|
||||
|
||||
### État actuel de FlowDeck (v1.2)
|
||||
|
||||
FlowDeck a déjà une base solide :
|
||||
|
||||
```
|
||||
✅ Boards (Kanban lié à Gitea) → table `boards`
|
||||
✅ Cards (issues → colonnes) → table `cards`
|
||||
✅ Colonnes custom → `project_properties` + `property_values`
|
||||
✅ Checklists par issue → `checklists` + `checklist_items`
|
||||
✅ Vues multiples → Kanban, Table, Status, TeamLoad, Detailed
|
||||
✅ Notes Markdown → `notes`
|
||||
✅ Pages (ébauche Notion editor) → `pages` (content, parent_id, sort_order, content_format)
|
||||
⚠️ Database concept → ABSENT (pas de notion de collection/row)
|
||||
❌ Relations entre projets → ABSENT
|
||||
❌ Sub-tasks / Parent-item → ABSENT
|
||||
❌ Rollups / Formules → ABSENT
|
||||
❌ Dependencies (bloque/bloqué par) → ABSENT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Comprendre Notion : le modèle « Blocs & Collections »
|
||||
|
||||
### 1.1 Le principe fondateur : « Everything is a Block »
|
||||
|
||||
Dans Notion, **chaque élément de contenu est un bloc**. Un bloc peut être :
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ BLOC │
|
||||
│ ├─ id: UUID │
|
||||
│ ├─ type: "paragraph" │
|
||||
│ │ "heading_1" │
|
||||
│ │ "to_do" │
|
||||
│ │ "bulleted_list_item" │
|
||||
│ │ "child_page" │
|
||||
│ │ "child_database" │
|
||||
│ │ "collection_view_page" │ ← Database
|
||||
│ │ ... │
|
||||
│ ├─ content: [...] (blocs enfants) │
|
||||
│ ├─ properties: {} (metadata) │
|
||||
│ └─ parent: {type, id} │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 Les 3 couches d'une Database Notion
|
||||
|
||||
Une database Notion est un assemblage de **3 concepts distincts** :
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ DATABASE NOTION │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────────┐ ┌────────────┐│
|
||||
│ │ COLLECTION │ │ COLLECTION_VIEW │ │ PAGES ││
|
||||
│ │ (schema) │◄───│ (présentation) │◄───│ (data) ││
|
||||
│ │ │ │ │ │ ││
|
||||
│ │ properties │ │ type: table │ │ row 1 ││
|
||||
│ │ - Name │ │ board │ │ row 2 ││
|
||||
│ │ - Status │ │ calendar │ │ row 3 ││
|
||||
│ │ - Priority │ │ gallery │ │ ... ││
|
||||
│ │ - Due Date │ │ list │ │ ││
|
||||
│ │ - Assignee │ │ timeline │ │ ││
|
||||
│ │ │ │ │ │ ││
|
||||
│ │ │ │ filters │ │ ││
|
||||
│ │ │ │ sorts │ │ ││
|
||||
│ │ │ │ grouping │ │ ││
|
||||
│ │ │ │ format options │ │ ││
|
||||
│ └─────────────┘ └──────────────────┘ └────────────┘│
|
||||
│ │
|
||||
│ 1 SCHÉMA N VUES (filtres N LIGNES │
|
||||
│ partagé + tris différents) (pages filles) │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Concept clé** : Une database n'est PAS une table SQL. C'est une **collection de pages**, chaque page ayant un schéma de propriétés commun, affiché via des vues configurables.
|
||||
|
||||
### 1.3 Le cycle de vie d'une Database
|
||||
|
||||
```
|
||||
Création Consultation Modification
|
||||
───────── ──────────── ────────────
|
||||
|
||||
1. L'utilisateur choisit 4. Les vues lisent le 7. Édition inline :
|
||||
un type de vue schéma + les pages - click → édite valeur
|
||||
(table, board...) - drag → réorganise
|
||||
5. Filtres/sorts/tri - shortcuts → ajoute ligne
|
||||
2. Notion crée : appliqués
|
||||
- 1 Collection 8. Changement de vue =
|
||||
- 1 CollectionView 6. Rendu en temps réel même données,
|
||||
- 1 CollectionViewPage autre présentation
|
||||
- + pages vides optionnelles
|
||||
9. Ajout propriété =
|
||||
3. L'utilisateur définit nouveau champ sur
|
||||
les propriétés (schéma) TOUTES les pages
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. La Database Notion : anatomie complète
|
||||
|
||||
### 2.1 Propriétés (le schéma)
|
||||
|
||||
Notion supporte **21 types de propriétés**. Toutes sont définies au niveau de la Collection :
|
||||
|
||||
```
|
||||
Type │ Description │ Exemple de valeur
|
||||
──────────────────┼────────────────────────────┼──────────────────
|
||||
Title │ Le titre de la page (obligatoire, unique)
|
||||
Text │ Texte libre │ "Description..."
|
||||
Number │ Nombre (décimales, format) │ 42 / 12.5%
|
||||
Select │ Choix unique │ "En cours"
|
||||
Multi-select │ Choix multiples │ ["Frontend","Backend"]
|
||||
Status │ Select avec couleur forcée │ "Done" (vert)
|
||||
Date │ Date + heure optionnelle │ 2026-07-15
|
||||
Person │ Mention utilisateur │ ["bruno"]
|
||||
Files & media │ Uploads │ [url1, url2]
|
||||
Checkbox │ Booléen │ true / false
|
||||
URL │ Lien cliquable │ "https://..."
|
||||
Email │ Email cliquable │ "a@b.com"
|
||||
Phone │ Téléphone cliquable │ "+1..."
|
||||
Formula │ Calcul (JS-like) │ prop("Prix")*prop("Qté") ← CLÉ
|
||||
Relation │ Lien vers autre database │ [page_id_1, page_id_2] ← CLÉ
|
||||
Rollup │ Agrégation via relation │ sum, avg, count, min, max ← CLÉ
|
||||
Created time │ Date de création (auto) │ 2026-01-15T10:30:00Z
|
||||
Created by │ Auteur (auto) │ "bruno"
|
||||
Last edited time │ Dernière édition (auto) │ 2026-07-09T18:00:00Z
|
||||
Last edited by │ Dernier éditeur (auto) │ "bruno"
|
||||
Unique ID │ ID incrémental (auto) │ 42
|
||||
Button │ Déclenche action (nouveau) │ —
|
||||
AI Summary │ Résumé AI (nouveau) │ "Ce projet..."
|
||||
```
|
||||
|
||||
### 2.2 Views (les vues)
|
||||
|
||||
Une database peut avoir **N vues**, chaque vue avec :
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Vue "Kanban" │
|
||||
│ ├─ type: board │
|
||||
│ ├─ group_by: "Status" │ ← Groupement
|
||||
│ ├─ filter: {Status ≠ "Archivé"} │ ← Filtres
|
||||
│ ├─ sort: [Due Date ASC, Priority DESC] │ ← Tris
|
||||
│ ├─ visible_properties: [Name, Assignee, Due Date] │
|
||||
│ ├─ card_size: medium │
|
||||
│ └─ cover_image_property: "Files" │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**6 types de vues :**
|
||||
|
||||
| Vue | Usage | Particularité |
|
||||
|-----|-------|---------------|
|
||||
| **Table** | Liste classique | Colonnes = propriétés |
|
||||
| **Board** | Kanban | Groupé par propriété Select/Status |
|
||||
| **Timeline** | Gantt | Axe X = propriété Date |
|
||||
| **Calendar** | Calendrier | Groupé par propriété Date (jour/semaine/mois) |
|
||||
| **Gallery** | Cartes visuelles | Cover image + preview |
|
||||
| **List** | Liste compacte | Comme table mais 1 colonne + preview |
|
||||
|
||||
**Filtres avancés :**
|
||||
|
||||
```
|
||||
Filtre 1 : Status IS "En cours" → filtre simple
|
||||
Filtre 2 : Due Date IS BEFORE "today" → filtre date
|
||||
Filtre 3 : Priority CONTAINS "P1" → filtre multi-select
|
||||
Groupe : AND / OR entre les filtres
|
||||
Sous-groupe : (FiltreA OR FiltreB) AND FiltreC
|
||||
```
|
||||
|
||||
### 2.3 Relations et Rollups (les super-pouvoirs)
|
||||
|
||||
C'est LE mécanisme qui rend Notion puissant :
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌──────────────────────┐
|
||||
│ DATABASE A │ │ DATABASE B │
|
||||
│ « Projets » │ │ « Tâches » │
|
||||
│ │ │ │
|
||||
│ ┌─────────────┐ │ relation│ ┌──────────────┐ │
|
||||
│ │ Nom │ │◄───────►│ │ Titre │ │
|
||||
│ │ Description │ │ │ │ Priorité │ │
|
||||
│ │ Statut │ │ │ │ Projet ──────┼──┐ │
|
||||
│ └─────────────┘ │ │ │ Assignee │ │ │
|
||||
│ │ │ └──────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ ROLLUP « Nb tâches»│◄────────┼─── count(Tâches) │ │
|
||||
│ ROLLUP « Avancement│◄────────┼─── avg(%Complété) │ │
|
||||
│ ROLLUP « Prochaine │◄────────┼─── min(Due Date) │ │
|
||||
└─────────────────────┘ └───────────────────┘──┘
|
||||
```
|
||||
|
||||
**Relation** = pointeur bidirectionnel entre pages de 2 databases différentes
|
||||
**Rollup** = agrégation (COUNT, SUM, AVG, MIN, MAX, RANGE, UNIQUE) sur une propriété de la database liée
|
||||
|
||||
---
|
||||
|
||||
## 3. Les Tasks Notion : un cas spécial de database
|
||||
|
||||
### 3.1 Architecture des tâches
|
||||
|
||||
Contrairement à ce qu'on pourrait penser, **Notion n'a pas de module « Tasks » séparé**.
|
||||
|
||||
```
|
||||
Les tâches sont simplement des PAGES dans une DATABASE
|
||||
qui possède des propriétés spécifiques de type tâche.
|
||||
```
|
||||
|
||||
Voici comment Notion les distingue :
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ DATABASE « Tasks » (ou n'importe quelle DB) │
|
||||
│ │
|
||||
│ Propriétés « tâche » │ Propriétés « standard » │
|
||||
│ ────────────────────────── │ ────────────────────────── │
|
||||
│ ✅ Sub-item (parent) │ • Titre (obligatoire) │
|
||||
│ ✅ Dependencies (bloque) │ • Status (Select) │
|
||||
│ ✅ Due Date │ • Priority (Select) │
|
||||
│ ✅ Assignee │ • Tags (Multi-select) │
|
||||
│ ✅ Reminder │ • ... │
|
||||
│ │
|
||||
│ La magie : ces propriétés SONT la database. │
|
||||
│ Pas de modèle « Task » séparé. │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 Sub-items (sous-tâches)
|
||||
|
||||
Les sous-tâches utilisent une **relation auto-référencée** :
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Table « tasks » (dans la DB) │
|
||||
│ │
|
||||
│ id │ title │ parent_id │
|
||||
│ ───┼────────────────┼───────────────│
|
||||
│ 1 │ Refonte UI │ NULL │ ← Tâche parent
|
||||
│ 2 │ Nouveau header │ 1 │ ← Sous-tâche de 1
|
||||
│ 3 │ Dark mode │ 1 │ ← Sous-tâche de 1
|
||||
│ 4 │ Tests header │ 2 │ ← Sous-sous-tâche de 2
|
||||
│ 5 │ Déploiement │ NULL │ ← Autre tâche parent
|
||||
│ │
|
||||
│ Propriétés spéciales : │
|
||||
│ ├─ « Sub-item » = relation vers │
|
||||
│ │ la même database │
|
||||
│ └─ « Parent item » = l'inverse │
|
||||
│ (auto-créé par Notion) │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Comment ça fonctionne techniquement :**
|
||||
|
||||
1. L'utilisateur active « Sub-items » dans une database
|
||||
2. Notion crée automatiquement 2 propriétés de type **Relation** pointant vers la même database :
|
||||
- `Sub-item` (relation → même DB)
|
||||
- `Parent item` (relation inverse, auto-générée)
|
||||
3. Dans l'interface Kanban, les sous-tâches apparaissent imbriquées sous le parent
|
||||
4. La récursion est illimitée (tâche → sous-tâche → sous-sous-tâche → ...)
|
||||
|
||||
### 3.3 Dependencies (dépendances)
|
||||
|
||||
Les dépendances bloquantes sont une **seconde relation auto-référencée** :
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Relation « Bloque » │
|
||||
│ │
|
||||
│ Tâche A ──bloque──► Tâche B │
|
||||
│ Tâche B ──bloquée par──► Tâche A │
|
||||
│ │
|
||||
│ Règles métier : │
|
||||
│ ├─ B ne peut pas être « Done » │
|
||||
│ │ tant que A n'est pas « Done » │
|
||||
│ ├─ Si A est déplacée, B suit │
|
||||
│ │ (optionnel, selon config) │
|
||||
│ └─ Timeline affiche les flèches │
|
||||
│ A ────────► B │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.4 La vue « My Tasks » (vue agrégée)
|
||||
|
||||
La killer feature de Notion : un dashboard qui regroupe TOUTES les tâches, peu importe leur database d'origine.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ MY TASKS │
|
||||
│ ┌───────────────────────────────────────────┐ │
|
||||
│ │ Projet Alpha ┌──────────────────────┐│ │
|
||||
│ │ ├─ Design maquette│ Assignee: Bruno ││ │
|
||||
│ │ └─ Tests UI │ Due: 15 juillet ││ │
|
||||
│ │ └──────────────────────┘│ │
|
||||
│ │ Projet Beta │ │
|
||||
│ │ ├─ API endpoint │ │
|
||||
│ │ └─ Documentation │ │
|
||||
│ │ Daily Tasks │ │
|
||||
│ │ └─ Réunion standup │ │
|
||||
│ └───────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Agrège TOUTES les databases ayant : │
|
||||
│ ├─ Une propriété « Person » = current user │
|
||||
│ └─ Un Status ≠ « Done » │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Fonctionnement interne :**
|
||||
|
||||
1. Notion scanne TOUTES les databases du workspace
|
||||
2. Filtre les pages où `Assignee = current_user` ET `Status ≠ Done/Completed`
|
||||
3. Regroupe par database d'origine (ou par projet parent)
|
||||
4. Affiche avec tri par `Due Date` ASC
|
||||
5. Cette vue est **read-only logique** (pas une vraie database)
|
||||
|
||||
---
|
||||
|
||||
## 4. Comment Notion connecte Database et Tasks
|
||||
|
||||
### 4.1 Le diagramme de dépendance conceptuel
|
||||
|
||||
```
|
||||
DATABASE (concept racine)
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
▼ ▼ ▼
|
||||
COLLECTION PAGES (rows) COLLECTION_VIEWS
|
||||
(schéma) │ (présentations)
|
||||
│ │ │
|
||||
│ ├─ Title ├─ Table
|
||||
├─ Text ├─ Properties ├─ Board
|
||||
├─ Select │ (valeurs ├─ Calendar
|
||||
├─ Date │ par page) ├─ Timeline
|
||||
├─ Person │ ├─ Gallery
|
||||
├─ Relation ────┼──► SELF └─ List
|
||||
│ └── Sub-items │ ┌── Parent
|
||||
│ └── Deps │ └── Blocked by
|
||||
├─ Rollup ←──────┼──┘ (count, sum, avg via relation)
|
||||
├─ Formula │
|
||||
└─ ... │
|
||||
│
|
||||
┌──────┴──────┐
|
||||
│ TASKS │ (surcouche métier)
|
||||
│ │
|
||||
│ Ne sont PAS │
|
||||
│ un modèle │
|
||||
│ séparé — │
|
||||
│ ce sont des │
|
||||
│ PAGES avec │
|
||||
│ propriétés │
|
||||
│ spécifiques │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
### 4.2 Les invariants du modèle
|
||||
|
||||
1. **Une database = 1 Collection** (schéma de propriétés)
|
||||
2. **1 Collection = N CollectionViews** (vues)
|
||||
3. **1 CollectionView = 1 type** (table | board | calendar | timeline | gallery | list)
|
||||
4. **Les pages d'une database sont des blocs** (comme toute page Notion)
|
||||
5. **Une propriété « Relation » pointe vers une autre database** (pas une table SQL)
|
||||
6. **Une propriété « Rollup » agrège via une Relation existante**
|
||||
7. **Les sub-items sont une Relation auto-référencée**
|
||||
8. **Les dépendances sont une Relation auto-référencée avec contrainte métier**
|
||||
|
||||
---
|
||||
|
||||
## 5. Implémentation dans FlowDeck : plan pas à pas
|
||||
|
||||
### 5.1 Phase 1 — Le concept de Database (semaine 1)
|
||||
|
||||
**Objectif :** Abstraire le concept de « board Gitea » en « database » réutilisable.
|
||||
|
||||
#### Nouvelle table : `collections`
|
||||
|
||||
```sql
|
||||
CREATE TABLE collections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
icon TEXT DEFAULT '📋',
|
||||
|
||||
-- Peut être liée à un projet Gitea (optionnel)
|
||||
gitea_owner TEXT,
|
||||
gitea_repo TEXT,
|
||||
|
||||
-- Schéma de propriétés en JSON (flexible, évolutif)
|
||||
schema_json TEXT NOT NULL DEFAULT '[]',
|
||||
-- Format: [{"name":"Status","type":"select","options":["Todo","Done"],"position":0}, ...]
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
#### Nouvelle table : `collection_pages` (remplace `cards`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE collection_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
|
||||
-- Peut être liée à une issue Gitea
|
||||
gitea_issue_id INTEGER,
|
||||
gitea_issue_number INTEGER,
|
||||
|
||||
-- Titre de la page (obligatoire)
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- Position dans la collection
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- Sub-items (auto-référence)
|
||||
parent_id INTEGER REFERENCES collection_pages(id),
|
||||
|
||||
-- Valeurs des propriétés en JSON
|
||||
property_values_json TEXT NOT NULL DEFAULT '{}',
|
||||
-- Format: {"Status":"Done","Priority":"P1","DueDate":"2026-07-15"}
|
||||
|
||||
-- Métadonnées temporelles
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_cp_collection ON collection_pages(collection_id, position);
|
||||
CREATE INDEX idx_cp_parent ON collection_pages(parent_id);
|
||||
CREATE INDEX idx_cp_gitea ON collection_pages(gitea_issue_id);
|
||||
```
|
||||
|
||||
#### Nouvelle table : `collection_views`
|
||||
|
||||
```sql
|
||||
CREATE TABLE collection_views (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
|
||||
name TEXT NOT NULL DEFAULT 'Default View',
|
||||
view_type TEXT NOT NULL DEFAULT 'table', -- table, board, calendar, timeline, gallery, list
|
||||
|
||||
-- Configuration JSON
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
-- Format:
|
||||
-- {
|
||||
-- "group_by": "Status", ← board/calendar uniquement
|
||||
-- "filters": [ ← tous types
|
||||
-- {"property":"Status","operator":"is_not","value":"Done"},
|
||||
-- {"property":"DueDate","operator":"is_before","value":"today"}
|
||||
-- ],
|
||||
-- "filter_conjunction": "and", ← "and" | "or"
|
||||
-- "sorts": [ ← tous types
|
||||
-- {"property":"Priority","direction":"asc"},
|
||||
-- {"property":"DueDate","direction":"desc"}
|
||||
-- ],
|
||||
-- "visible_properties": ["Title","Status","Assignee","DueDate"],
|
||||
-- "card_size": "medium", ← board/gallery
|
||||
-- "cover_property": "Files", ← board/gallery
|
||||
-- "date_property": "DueDate", ← calendar/timeline
|
||||
-- "date_range_property": "EndDate" ← timeline (optionnel)
|
||||
-- }
|
||||
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### 5.2 Phase 2 — Le système de propriétés (semaine 1-2)
|
||||
|
||||
#### Nouvelle table : `collection_properties` (remplace `project_properties`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE collection_properties (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
|
||||
name TEXT NOT NULL,
|
||||
prop_type TEXT NOT NULL DEFAULT 'text',
|
||||
-- Types: title, text, number, select, multi_select, status,
|
||||
-- date, person, checkbox, url, email, phone,
|
||||
-- relation, rollup, formula,
|
||||
-- created_time, created_by, last_edited_time, last_edited_by
|
||||
|
||||
-- Options pour select/multi_select/status
|
||||
options_json TEXT DEFAULT '[]',
|
||||
-- Format: [{"name":"Todo","color":"gray"},{"name":"Done","color":"green"}]
|
||||
|
||||
-- Pour les relations
|
||||
related_collection_id INTEGER REFERENCES collections(id),
|
||||
reverse_name TEXT, -- nom de la relation inverse (auto-généré)
|
||||
|
||||
-- Pour les rollups
|
||||
relation_property_id INTEGER REFERENCES collection_properties(id),
|
||||
target_property_id INTEGER REFERENCES collection_properties(id),
|
||||
rollup_function TEXT, -- count, sum, avg, min, max, range, unique
|
||||
|
||||
-- Pour les formules
|
||||
formula_expression TEXT,
|
||||
|
||||
-- Position dans la liste des propriétés
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
required BOOLEAN NOT NULL DEFAULT 0,
|
||||
visible_in_views BOOLEAN NOT NULL DEFAULT 1,
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
UNIQUE(collection_id, name)
|
||||
);
|
||||
```
|
||||
|
||||
**Mapping des types Notion → FlowDeck :**
|
||||
|
||||
```python
|
||||
PROPERTY_TYPES = {
|
||||
"title": {
|
||||
"storage": "string",
|
||||
"validation": "max 2000 chars",
|
||||
"indexed": True,
|
||||
"unique_per_collection": False,
|
||||
},
|
||||
"text": {
|
||||
"storage": "string",
|
||||
"validation": "any text",
|
||||
},
|
||||
"number": {
|
||||
"storage": "float",
|
||||
"validation": "numeric",
|
||||
"format_options": ["number", "percent", "currency", "dollar", "euro", "pound", "yen"],
|
||||
},
|
||||
"select": {
|
||||
"storage": "string (from options_json)",
|
||||
"validation": "must match one option",
|
||||
},
|
||||
"multi_select": {
|
||||
"storage": "JSON array of strings",
|
||||
"validation": "each must match option",
|
||||
},
|
||||
"status": {
|
||||
"storage": "string (from options_json) + color",
|
||||
"validation": "must match one option",
|
||||
"colors": ["gray","brown","orange","yellow","green","blue","purple","pink","red"],
|
||||
},
|
||||
"date": {
|
||||
"storage": "ISO 8601 string or range",
|
||||
"validation": "valid datetime",
|
||||
"include_time": True, # configurable
|
||||
},
|
||||
"person": {
|
||||
"storage": "JSON array of {id, login, avatar_url}",
|
||||
"validation": "must be workspace user",
|
||||
},
|
||||
"checkbox": {
|
||||
"storage": "boolean",
|
||||
"validation": "true/false",
|
||||
},
|
||||
"relation": {
|
||||
"storage": "JSON array of page IDs",
|
||||
"validation": "must be existing page in related_collection",
|
||||
"bidirectional": True,
|
||||
},
|
||||
"rollup": {
|
||||
"storage": "computed — not stored directly",
|
||||
"functions": ["count","count_values","empty","not_empty",
|
||||
"sum","average","median","min","max","range"],
|
||||
"depends_on": ["relation_property_id","target_property_id"],
|
||||
},
|
||||
"formula": {
|
||||
"storage": "computed — not stored directly",
|
||||
"expression": "JavaScript-like DSL",
|
||||
"functions_available": [
|
||||
"prop()", "now()", "today()", "if()", "concat()", "round()",
|
||||
"dateAdd()", "dateSubtract()", "formatDate()", "toNumber()",
|
||||
"contains()", "length()", "replace()", "replaceAll()",
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Phase 3 — Sub-items et Dépendances (semaine 2)
|
||||
|
||||
#### Implémentation des sub-items (relation auto-référencée)
|
||||
|
||||
```python
|
||||
# Dans le schema d'une database, activer Sub-items crée automatiquement :
|
||||
|
||||
def enable_sub_items(collection_id: int):
|
||||
"""Active les sub-items sur une collection."""
|
||||
|
||||
# 1. Créer la propriété relation « Parent item » (si pas déjà)
|
||||
parent_prop = create_property(
|
||||
collection_id=collection_id,
|
||||
name="Parent item",
|
||||
prop_type="relation",
|
||||
related_collection_id=collection_id, # AUTO-RÉFÉRENCE
|
||||
reverse_name="Sub-item", # relation inverse
|
||||
visible_in_views=False, # cachée dans les vues
|
||||
)
|
||||
|
||||
# 2. Marquer la collection comme ayant des sub-items
|
||||
mark_collection_feature(collection_id, "has_sub_items", True)
|
||||
|
||||
# 3. Mettre à jour la vue board pour afficher l'imbrication
|
||||
# Le rendu Kanban doit maintenant supporter :
|
||||
# - Groupe parent → sous-tâches indentées
|
||||
# - Bouton « + Add sub-item » sous chaque carte
|
||||
# - État du parent = agrégation des enfants
|
||||
```
|
||||
|
||||
#### Rendu des sub-items dans le board
|
||||
|
||||
```html
|
||||
<!-- Dans board_fragment.html, modifier le rendu des cartes -->
|
||||
<div class="kanban-card" data-page-id="{{ page.id }}">
|
||||
<!-- Carte parent -->
|
||||
<div class="card-title">{{ page.title }}</div>
|
||||
|
||||
<!-- Sous-tâches -->
|
||||
{% for sub in page.sub_items %}
|
||||
<div class="sub-item" data-page-id="{{ sub.id }}">
|
||||
├─ {{ sub.title }}
|
||||
<span class="sub-status">{{ sub.properties.Status }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Bouton ajout sous-tâche -->
|
||||
<button hx-post="/api/pages/{{ page.id }}/sub-items"
|
||||
hx-swap="beforeend">
|
||||
+ Sub-item
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Implémentation des dépendances
|
||||
|
||||
```python
|
||||
def enable_dependencies(collection_id: int):
|
||||
"""Active les dépendances bloquantes."""
|
||||
|
||||
# Création de 2 propriétés relation
|
||||
blocks = create_property(
|
||||
collection_id=collection_id,
|
||||
name="Blocks",
|
||||
prop_type="relation",
|
||||
related_collection_id=collection_id,
|
||||
reverse_name="Blocked by",
|
||||
)
|
||||
|
||||
# Contrainte métier : au niveau applicatif
|
||||
# (pas de contrainte SQL pour garder la flexibilité)
|
||||
|
||||
def check_dependency_constraint(page_id: int, new_status: str):
|
||||
"""Vérifie qu'une page peut passer à 'Done'
|
||||
seulement si toutes les pages qu'elle bloque sont Done."""
|
||||
|
||||
blocked_pages = get_blocked_pages(page_id)
|
||||
if new_status == "Done" and blocked_pages:
|
||||
not_done = [p for p in blocked_pages
|
||||
if p.status not in ("Done", "Cancelled")]
|
||||
if not_done:
|
||||
raise DependencyError(
|
||||
f"Cannot mark as Done: still blocking {len(not_done)} tasks"
|
||||
)
|
||||
```
|
||||
|
||||
### 5.4 Phase 4 — « My Tasks » (vue agrégée) (semaine 2-3)
|
||||
|
||||
```python
|
||||
# GET /my-tasks
|
||||
async def my_tasks(request: Request):
|
||||
"""Dashboard personnel : toutes les tâches assignées à l'utilisateur."""
|
||||
|
||||
user = get_current_user(request)
|
||||
|
||||
# 1. Scanner TOUTES les collections
|
||||
collections = db.fetch_all("SELECT * FROM collections")
|
||||
|
||||
my_tasks = []
|
||||
for col in collections:
|
||||
schema = json.loads(col.schema_json)
|
||||
|
||||
# 2. Vérifier si la collection a une propriété Person
|
||||
person_props = [p for p in schema if p["type"] == "person"]
|
||||
if not person_props:
|
||||
continue
|
||||
|
||||
# 3. Récupérer les pages assignées à l'utilisateur
|
||||
pages = db.fetch_all("""
|
||||
SELECT cp.*, cpv.value as property_values
|
||||
FROM collection_pages cp
|
||||
JOIN collection_properties cprop
|
||||
ON cprop.collection_id = cp.collection_id
|
||||
AND cprop.prop_type = 'person'
|
||||
WHERE cp.collection_id = ?
|
||||
AND cp.property_values_json LIKE ?
|
||||
""", (col.id, f'%"{user.login}"%'))
|
||||
|
||||
my_tasks.extend([
|
||||
{"collection": col, "page": p, "schema": schema}
|
||||
for p in pages
|
||||
])
|
||||
|
||||
# 4. Grouper par collection
|
||||
grouped = {}
|
||||
for task in my_tasks:
|
||||
col_name = task["collection"].name
|
||||
if col_name not in grouped:
|
||||
grouped[col_name] = []
|
||||
grouped[col_name].append(task)
|
||||
|
||||
# 5. Trier par Due Date
|
||||
for tasks in grouped.values():
|
||||
tasks.sort(key=lambda t: t["page"].get_property("DueDate") or "9999")
|
||||
|
||||
return render_template("my_tasks.html", tasks=grouped)
|
||||
```
|
||||
|
||||
### 5.5 Phase 5 — Rétrocompatibilité avec Gitea
|
||||
|
||||
**Stratégie de migration progressive :**
|
||||
|
||||
```python
|
||||
# Phase 5a : Wrapper — les boards Gitea existants
|
||||
# deviennent des collections avec une couche de compatibilité
|
||||
|
||||
class GiteaBoardCompat:
|
||||
"""Adaptateur : ancien board Gitea → nouvelle Collection."""
|
||||
|
||||
@staticmethod
|
||||
def from_board(board_row):
|
||||
"""Convertit un board existant en collection."""
|
||||
return {
|
||||
"collection_id": f"gitea:{board_row.id}",
|
||||
"name": f"{board_row.project_owner}/{board_row.project_name}",
|
||||
"gitea_owner": board_row.project_owner,
|
||||
"gitea_repo": board_row.project_name,
|
||||
"schema": [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Status", "type": "select",
|
||||
"options": json.loads(board_row.columns_json)},
|
||||
{"name": "Priority", "type": "select",
|
||||
"options": ["P1","P2","P3","P4"]},
|
||||
],
|
||||
"is_gitea_linked": True,
|
||||
}
|
||||
|
||||
# Phase 5b : Nouvelle route hybride
|
||||
# GET /board/{owner}/{repo} → détecte si c'est un board Gitea legacy
|
||||
# ou une collection pure, et route vers le bon renderer
|
||||
```
|
||||
|
||||
### 5.6 Résumé du plan d'implémentation
|
||||
|
||||
```
|
||||
Semaine 1 Semaine 2 Semaine 3
|
||||
──────── ───────── ─────────
|
||||
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Database │ │ Properties │ │ My Tasks │
|
||||
│ Concept │ ──► │ Avancées │ ──► │ Dashboard │
|
||||
│ │ │ │ │ │
|
||||
│ • collections│ │ • Relations │ │ • Vue agrégée│
|
||||
│ • pages │ │ • Rollups │ │ • Filtres │
|
||||
│ • views │ │ • Formules │ │ globaux │
|
||||
│ • filters │ │ • Auto-prop │ │ • Calendrier │
|
||||
│ • sorts │ │ computed │ │ intégré │
|
||||
│ │ │ │ │ │
|
||||
│ Sub-items │ │ Dependencies │ │ UI polish │
|
||||
│ (parent_id) │ ──► │ + contraintes│ ──► │ + tests │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Références
|
||||
|
||||
- [Notion API — Property Object](https://developers.notion.com/reference/property-object)
|
||||
- [Notion Help — Intro to 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)
|
||||
- [react-notion-x — Block types reference](https://github.com/NotionX/react-notion-x)
|
||||
- [Notion Data Sources update (2025)](https://www.notionapps.com/blog/notion-data-sources-update-2025/)
|
||||
|
||||
### Différences clés FlowDeck vs Notion
|
||||
|
||||
| Concept | Notion | FlowDeck (cible) |
|
||||
|---------|--------|------------------|
|
||||
| **Database** | Collection + Pages + Views | `collections` + `collection_pages` + `collection_views` |
|
||||
| **Schéma** | Properties sur la Collection | `collection_properties` au niveau collection |
|
||||
| **Valeurs** | Dans chaque page (bloc) | `property_values_json` dans chaque `collection_page` |
|
||||
| **Sub-items** | Relation auto-référencée | `parent_id` auto-référence dans `collection_pages` |
|
||||
| **Dependencies** | Relation auto-référencée + contrainte | Idem + check applicatif au changement de statut |
|
||||
| **My Tasks** | Scan cross-database + filtre | Route `/my-tasks` + JOIN sur `collection_pages` |
|
||||
| **Gitea sync** | N/A (pas de Gitea) | Les pages peuvent être liées à `gitea_issue_id` |
|
||||
| **Stockage** | Cloud distribué (PostgreSQL + cache) | SQLite local (1 fichier) |
|
||||
@@ -0,0 +1,210 @@
|
||||
# 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
|
||||
- [ ] **DB**: Ajouter `password_hash` (bcrypt) à la table `users`
|
||||
- [ ] **DB**: Ajouter `is_active`, `is_admin`, `last_login`, `login_attempts`, `locked_until`
|
||||
- [ ] **API**: `POST /auth/register` — création de compte local (email + password)
|
||||
- [ ] **API**: `POST /auth/login` — login local (email + password → session)
|
||||
- [ ] **Page**: `/login` — formulaire login/register
|
||||
- [ ] **Sécurité**: Rate limiting (5 tentatives → lock 15 min), bcrypt cost=12
|
||||
- [ ] **Sécurité**: CSRF sur toutes les routes auth
|
||||
- [ ] **Session**: Détachée de Gitea — session valide sans OAuth
|
||||
|
||||
### 1.2 Page de configuration du compte
|
||||
- [ ] **Page**: `/accounts/settings` — config du profil
|
||||
- [ ] **Section**: Profil (nom, email, avatar, mot de passe)
|
||||
- [ ] **Section**: Connexions forges (Gitea, GitHub) — lier/délier
|
||||
- [ ] **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
|
||||
- [ ] **Refactor**: `GiteaOAuth` → `OAuthProvider` (classe abstraite)
|
||||
- [ ] **Implement**: `GitHubOAuthProvider` (OAuth2 GitHub)
|
||||
- [ ] **Implement**: `GiteaOAuthProvider` (existant, refactoré)
|
||||
- [ ] **DB**: Table `user_oauth_tokens` (user_id, provider, access_token, refresh_token, expires_at)
|
||||
- [ ] **API**: `GET /auth/{provider}/login` — redirige vers OAuth
|
||||
- [ ] **API**: `GET /auth/{provider}/callback` — callback OAuth
|
||||
- [ ] **Page**: `/accounts/settings` → boutons "Connect GitHub" / "Connect Gitea"
|
||||
- [ ] **Fallback**: L'utilisateur peut utiliser FlowDeck SANS lier aucune forge
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Abstraction Multi-Forge
|
||||
|
||||
### 2.1 Adapter pattern pour les forges
|
||||
- [ ] **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`
|
||||
- [ ] **Implement**: `GiteaAdapter` (API Gitea existante → interface unifiée)
|
||||
- [ ] **Implement**: `GitHubAdapter` (API GitHub v3 → interface unifiée)
|
||||
- [ ] **DB**: Table `forge_connections` (user_id, provider, token_id, instance_url)
|
||||
- [ ] **Config**: Support GitHub Enterprise (custom URL) et Gitea self-hosted
|
||||
|
||||
### 2.2 Synchronisation des projets
|
||||
- [ ] **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)
|
||||
- [ ] **Page**: `/workspace` — liste TOUS les projets (built-in + Gitea + GitHub)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Workspace & Navigation
|
||||
|
||||
### 3.1 Nouvelle page Workspace
|
||||
- [ ] **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"
|
||||
- [ ] **Filtres**: Par forge, par statut, recherche
|
||||
- [ ] **Carte projet**: Nom, description, forge badge, dernière activité, nombre de pages
|
||||
|
||||
### 3.2 Barre de navigation projet
|
||||
- [ ] **Topbar**: Section entre "Meetings" et la sidebar
|
||||
- Nom du projet actif (dropdown pour switcher)
|
||||
- Badge forge (icône Gitea/GitHub/built-in)
|
||||
- [ ] **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
|
||||
- [ ] **Service**: `FileTreeService` — construit l'arborescence depuis l'API forge
|
||||
- [ ] **Cache**: Arborescence en cache (TTL 5 min) pour éviter les appels API à chaque page
|
||||
|
||||
### 3.3 Navigation projet
|
||||
- [ ] **Sidebar**: Les projets apparaissent sous une section dédiée
|
||||
- [ ] **Breadcrumb**: Workspace > Projet > Dossier > Page
|
||||
- [ ] **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
|
||||
- [ ] **Config**: `FLOWDECK_STANDALONE=true` — démarre sans aucune forge
|
||||
- [ ] **UI**: Pas de sections Gitea/GitHub si non configuré
|
||||
- [ ] **Workspace**: Uniquement projets built-in
|
||||
- [ ] **Auth**: Login local uniquement (pas de boutons OAuth)
|
||||
- [ ] **Pages**: Création/édition 100% locale, pas de synchro externe
|
||||
|
||||
### 4.2 Mode hybride
|
||||
- [ ] Un utilisateur peut avoir Gitea lié, un autre GitHub, un troisième rien
|
||||
- [ ] Chaque utilisateur voit SES projets de forge dans SA workspace
|
||||
- [ ] Les projets built-in sont partagés selon les permissions workspace
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Permissions & Collaboratif
|
||||
|
||||
### 5.1 Rôles workspace
|
||||
- [ ] **DB**: Table `workspace_members` avec rôles: owner, admin, editor, viewer
|
||||
- [ ] **API**: `POST /workspace/{id}/members` — inviter un utilisateur
|
||||
- [ ] **API**: `DELETE /workspace/{id}/members/{user_id}` — retirer
|
||||
- [ ] **API**: `PUT /workspace/{id}/members/{user_id}` — changer rôle
|
||||
- [ ] **Middleware**: `PermissionMiddleware` — vérifie les droits avant chaque action
|
||||
|
||||
### 5.2 Partage de pages
|
||||
- [ ] Étendre le système Share existant pour supporter les permissions workspace
|
||||
- [ ] Une page dans un workspace est visible par tous les membres
|
||||
- [ ] "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-10 — créé avec Bruno*
|
||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 63 KiB |
@@ -1,5 +1,5 @@
|
||||
// FlowDeck Notion UI — Client-side logic
|
||||
// Alpine.js + SortableJS + HTMX
|
||||
// Alpine.js + SortableJS + HTMX + Mobile support
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
@@ -57,6 +57,53 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mobile: Swipe to close sidebar ──
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchend', function(e) {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (!sidebar || !sidebar.classList.contains('mobile-open')) return;
|
||||
|
||||
const touchEndX = e.changedTouches[0].clientX;
|
||||
const touchEndY = e.changedTouches[0].clientY;
|
||||
const dx = touchEndX - touchStartX;
|
||||
const dy = Math.abs(touchEndY - touchStartY);
|
||||
|
||||
// Swipe left on sidebar (>50px horizontal, not too vertical)
|
||||
if (dx < -50 && dy < Math.abs(dx) * 0.5) {
|
||||
// Close sidebar via Alpine store
|
||||
const appLayout = document.querySelector('.app-layout');
|
||||
if (appLayout && appLayout.__x) {
|
||||
appLayout.__x.$data.mobileSidebarOpen = false;
|
||||
appLayout.__x.$data.sidebarCollapsed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mobile: close sidebar on page navigation ──
|
||||
document.addEventListener('htmx:beforeRequest', function() {
|
||||
const appLayout = document.querySelector('.app-layout');
|
||||
if (appLayout && appLayout.__x) {
|
||||
appLayout.__x.$data.mobileSidebarOpen = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mobile: passive scroll performance ──
|
||||
if ('ontouchstart' in window) {
|
||||
document.querySelectorAll('.kanban-container').forEach(function(el) {
|
||||
el.style.webkitOverflowScrolling = 'touch';
|
||||
});
|
||||
document.querySelectorAll('.table-container').forEach(function(el) {
|
||||
el.style.webkitOverflowScrolling = 'touch';
|
||||
});
|
||||
}
|
||||
|
||||
// Modal functions
|
||||
window.openModal = function(title) {
|
||||
const modal = document.getElementById('card-modal');
|
||||
@@ -64,12 +111,17 @@
|
||||
if (modal && titleEl) {
|
||||
titleEl.textContent = title || 'Détail';
|
||||
modal.style.display = 'flex';
|
||||
// Prevent body scroll when modal is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
};
|
||||
|
||||
window.closeModal = function() {
|
||||
const modal = document.getElementById('card-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Close modal on overlay click
|
||||
@@ -79,6 +131,13 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on escape
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// HTMX event handlers
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
// Reinitialize Sortable after HTMX swaps
|
||||
@@ -96,15 +155,15 @@
|
||||
animation: 200,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
// Touch support
|
||||
delay: 0,
|
||||
touchStartThreshold: 5,
|
||||
onEnd: function(evt) {
|
||||
const cardId = evt.item.dataset.cardId;
|
||||
const fromCol = evt.from.dataset.status;
|
||||
const toCol = evt.to.dataset.status;
|
||||
const group = evt.to.dataset.group;
|
||||
console.log('Moved card', cardId, 'from', fromCol, 'to', toCol, 'group', group);
|
||||
|
||||
// TODO: Send move to API
|
||||
// htmx.ajax('POST', '/api/move', { ... });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""FlowDeck — Comprehensive tests v1.0.0."""
|
||||
"""FlowDeck — Comprehensive tests v1.3.0."""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
@@ -33,14 +34,15 @@ def test_health(client):
|
||||
data = resp.json()
|
||||
assert "status" in data
|
||||
assert data["db"] is True
|
||||
assert data["version"] == "1.0.0"
|
||||
from app.main import app
|
||||
assert data["version"] == app.version
|
||||
|
||||
|
||||
def test_dashboard(client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "sidebar" in resp.text
|
||||
assert "workspace" in resp.text or "app-layout" in resp.text
|
||||
# DB empty → admin user → no Gitea token → redirect to local-workspace
|
||||
# which requires auth → redirect to login page
|
||||
assert resp.status_code in (200, 302)
|
||||
|
||||
|
||||
def test_stats(client):
|
||||
@@ -85,9 +87,9 @@ def test_board_page_renders(client):
|
||||
|
||||
def test_dashboard_notion_ui(client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
# Notion-style elements
|
||||
assert "sidebar" in resp.text or "main-wrapper" in resp.text or "topbar" in resp.text
|
||||
assert resp.status_code in (200, 302)
|
||||
if resp.status_code == 302:
|
||||
assert "local-workspace" in resp.headers.get("location", "")
|
||||
|
||||
|
||||
# ── v0.5.0: Kanban ──
|
||||
@@ -189,8 +191,9 @@ def test_csrf_protects_sync(client):
|
||||
# ── v1.0.0: Production ──
|
||||
|
||||
def test_version_in_health(client):
|
||||
from app.main import app
|
||||
resp = client.get("/api/health")
|
||||
assert resp.json()["version"] == "1.0.0"
|
||||
assert resp.json()["version"] == app.version
|
||||
|
||||
|
||||
def test_db_tables_exist(client):
|
||||
@@ -202,7 +205,11 @@ def test_db_tables_exist(client):
|
||||
names = {t["name"] for t in tables}
|
||||
required = {"boards", "cards", "notes", "checklists", "checklist_items",
|
||||
"col_mapping", "users", "user_tokens",
|
||||
"project_properties", "property_values", "ai_keywords"}
|
||||
"project_properties", "property_values", "ai_keywords",
|
||||
"collections", "collection_pages", "collection_views",
|
||||
"collection_properties", "workspaces", "workspace_members",
|
||||
"comments", "page_history", "favorites",
|
||||
"database_templates", "page_templates"}
|
||||
assert required <= names
|
||||
|
||||
|
||||
@@ -219,3 +226,783 @@ def test_all_view_endpoints_respond(client):
|
||||
for view in views:
|
||||
resp = client.get(f"/board/test/test/view/{view}")
|
||||
assert resp.status_code in (200, 500), f"View {view} failed with {resp.status_code}"
|
||||
|
||||
|
||||
# ── v1.3.0: Database Concept ──
|
||||
|
||||
def test_collections_api_list_empty(client):
|
||||
"""List collections API — should return empty when no collections exist."""
|
||||
resp = client.get("/db/api")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "collections" in data
|
||||
assert data["collections"] == []
|
||||
|
||||
|
||||
def test_collections_api_crud(client):
|
||||
"""Full CRUD lifecycle: create → read → update → delete."""
|
||||
# Create
|
||||
resp = client.post("/db/api", json={
|
||||
"name": "Test Database",
|
||||
"description": "A test collection",
|
||||
"icon": "🗂️",
|
||||
"schema": [
|
||||
{"name": "Status", "type": "select", "options": ["Todo", "Done"]},
|
||||
],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
created = resp.json()
|
||||
assert created["status"] == "created"
|
||||
assert "id" in created
|
||||
coll_id = created["id"]
|
||||
|
||||
# List — should now have 1 collection
|
||||
resp = client.get("/db/api")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["collections"]) == 1
|
||||
|
||||
# Get single
|
||||
resp = client.get(f"/db/{coll_id}/api")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["collection"]["name"] == "Test Database"
|
||||
assert data["collection"]["icon"] == "🗂️"
|
||||
|
||||
# Update
|
||||
resp = client.put(f"/db/api/{coll_id}", json={
|
||||
"name": "Updated DB",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "updated"
|
||||
|
||||
# Verify update
|
||||
resp = client.get(f"/db/{coll_id}/api")
|
||||
assert resp.json()["collection"]["name"] == "Updated DB"
|
||||
|
||||
# Delete
|
||||
resp = client.delete(f"/db/api/{coll_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "deleted"
|
||||
|
||||
# Verify deletion
|
||||
resp = client.get(f"/db/{coll_id}/api")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_collections_pages_crud(client):
|
||||
"""CRUD for pages inside a collection."""
|
||||
# Create collection first
|
||||
resp = client.post("/db/api", json={"name": "Page Test DB"})
|
||||
coll_id = resp.json()["id"]
|
||||
|
||||
# Create page
|
||||
resp = client.post(f"/db/{coll_id}/pages/api", json={
|
||||
"title": "My First Page",
|
||||
"properties": {"Status": "Todo", "Priority": "P1"},
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
page_data = resp.json()
|
||||
assert page_data["status"] == "created"
|
||||
page_id = page_data["id"]
|
||||
|
||||
# Get page
|
||||
resp = client.get(f"/db/pages/{page_id}/api")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "My First Page"
|
||||
|
||||
# Update page
|
||||
resp = client.put(f"/db/pages/{page_id}/api", json={
|
||||
"title": "Updated Page",
|
||||
"properties": {"Status": "Done"},
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify update
|
||||
resp = client.get(f"/db/pages/{page_id}/api")
|
||||
data = resp.json()
|
||||
assert data["title"] == "Updated Page"
|
||||
props = json.loads(data["property_values_json"])
|
||||
assert props["Status"] == "Done"
|
||||
|
||||
# Delete page
|
||||
resp = client.delete(f"/db/pages/{page_id}/api")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "deleted"
|
||||
|
||||
# Cleanup: delete collection
|
||||
client.delete(f"/db/api/{coll_id}")
|
||||
|
||||
|
||||
def test_collections_api_validation(client):
|
||||
"""Validation: missing name should return 400."""
|
||||
resp = client.post("/db/api", json={})
|
||||
assert resp.status_code == 400
|
||||
assert "name" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_collections_db_page_renders(client):
|
||||
"""GET /db/{id} should render an HTML page."""
|
||||
# Create collection first
|
||||
resp = client.post("/db/api", json={"name": "Render Test"})
|
||||
coll_id = resp.json()["id"]
|
||||
|
||||
resp = client.get(f"/db/{coll_id}")
|
||||
assert resp.status_code == 200
|
||||
assert "Render Test" in resp.text
|
||||
|
||||
# Cleanup
|
||||
client.delete(f"/db/api/{coll_id}")
|
||||
|
||||
|
||||
def test_boards_as_collections(client):
|
||||
"""GET /db/boards/api — should list boards as pseudo-collections."""
|
||||
resp = client.get("/db/boards/api")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "boards" in data
|
||||
assert isinstance(data["boards"], list)
|
||||
|
||||
|
||||
# ── v2.1.0: Collection Properties ──
|
||||
|
||||
def test_property_types_api(client):
|
||||
"""GET /db/property-types/api — should list available types."""
|
||||
resp = client.get("/db/property-types/api")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "types" in data
|
||||
assert "text" in data["types"]
|
||||
assert "number" in data["types"]
|
||||
assert "checkbox" in data["types"]
|
||||
assert "status" in data["types"]
|
||||
|
||||
|
||||
def test_collection_properties_crud(client):
|
||||
"""Full CRUD on collection properties."""
|
||||
# Create collection
|
||||
resp = client.post("/db/api", json={"name": "Props Test DB"})
|
||||
coll_id = resp.json()["id"]
|
||||
|
||||
# List properties (empty)
|
||||
resp = client.get(f"/db/{coll_id}/properties/api")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["properties"] == []
|
||||
|
||||
# Create a text property
|
||||
resp = client.post(f"/db/{coll_id}/properties/api", json={
|
||||
"name": "Description",
|
||||
"prop_type": "text",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "created"
|
||||
prop_id = resp.json()["id"]
|
||||
|
||||
# Create a number property
|
||||
resp = client.post(f"/db/{coll_id}/properties/api", json={
|
||||
"name": "Estimation",
|
||||
"prop_type": "number",
|
||||
"number_format": "number",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Create a status property with options
|
||||
resp = client.post(f"/db/{coll_id}/properties/api", json={
|
||||
"name": "State",
|
||||
"prop_type": "status",
|
||||
"options": [
|
||||
{"name": "Todo", "color": "gray"},
|
||||
{"name": "Done", "color": "green"},
|
||||
],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Create a checkbox property
|
||||
resp = client.post(f"/db/{coll_id}/properties/api", json={
|
||||
"name": "Verified",
|
||||
"prop_type": "checkbox",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# List — should have 4
|
||||
resp = client.get(f"/db/{coll_id}/properties/api")
|
||||
assert len(resp.json()["properties"]) == 4
|
||||
|
||||
# Update a property
|
||||
resp = client.put(f"/db/properties/{prop_id}/api", json={
|
||||
"name": "Description Longue",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify update
|
||||
resp = client.get(f"/db/{coll_id}/properties/api")
|
||||
names = [p["name"] for p in resp.json()["properties"]]
|
||||
assert "Description Longue" in names
|
||||
|
||||
# Delete a property
|
||||
resp = client.delete(f"/db/properties/{prop_id}/api")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify deletion
|
||||
resp = client.get(f"/db/{coll_id}/properties/api")
|
||||
assert len(resp.json()["properties"]) == 3
|
||||
|
||||
# Cleanup
|
||||
client.delete(f"/db/api/{coll_id}")
|
||||
|
||||
|
||||
def test_collection_properties_duplicate(client):
|
||||
"""Creating duplicate property name should return 409."""
|
||||
resp = client.post("/db/api", json={"name": "Dup Test"})
|
||||
coll_id = resp.json()["id"]
|
||||
|
||||
client.post(f"/db/{coll_id}/properties/api", json={"name": "Status", "prop_type": "select"})
|
||||
resp = client.post(f"/db/{coll_id}/properties/api", json={"name": "Status", "prop_type": "select"})
|
||||
assert resp.status_code == 409
|
||||
|
||||
client.delete(f"/db/api/{coll_id}")
|
||||
|
||||
|
||||
def test_collection_properties_validation(client):
|
||||
"""Validation: missing name should return 400."""
|
||||
resp = client.post("/db/api", json={"name": "Val Test"})
|
||||
coll_id = resp.json()["id"]
|
||||
|
||||
resp = client.post(f"/db/{coll_id}/properties/api", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
client.delete(f"/db/api/{coll_id}")
|
||||
|
||||
|
||||
# ── v2.1.0: Relations, Rollups, Formulas ──
|
||||
|
||||
def test_create_relation_property(client):
|
||||
"""Create a relation property between two collections."""
|
||||
r1 = client.post("/db/api", json={"name": "Projects"})
|
||||
r2 = client.post("/db/api", json={"name": "Tasks"})
|
||||
c1, c2 = r1.json()["id"], r2.json()["id"]
|
||||
|
||||
resp = client.post(f"/db/{c1}/properties/relation", json={
|
||||
"name": "Tasks", "related_collection_id": c2, "reverse_name": "Project",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["prop_type"] == "relation"
|
||||
|
||||
resp2 = client.get(f"/db/{c2}/properties/api")
|
||||
names = [p["name"] for p in resp2.json()["properties"]]
|
||||
assert "Project" in names
|
||||
|
||||
client.delete(f"/db/api/{c2}") # c2 first (has reverse FK → c1)
|
||||
client.delete(f"/db/api/{c1}")
|
||||
|
||||
|
||||
def test_link_pages_via_relation(client):
|
||||
"""Link two pages via a relation and verify reverse."""
|
||||
r1 = client.post("/db/api", json={"name": "A"})
|
||||
r2 = client.post("/db/api", json={"name": "B"})
|
||||
c1, c2 = r1.json()["id"], r2.json()["id"]
|
||||
|
||||
rel = client.post(f"/db/{c1}/properties/relation", json={
|
||||
"name": "Items", "related_collection_id": c2, "reverse_name": "Parent",
|
||||
})
|
||||
prop_id = rel.json()["id"]
|
||||
|
||||
p1 = client.post(f"/db/{c1}/pages/api", json={"title": "Page A"})
|
||||
p2 = client.post(f"/db/{c2}/pages/api", json={"title": "Page B"})
|
||||
pid1, pid2 = p1.json()["id"], p2.json()["id"]
|
||||
|
||||
resp = client.post(f"/db/{c1}/properties/relation/link", json={
|
||||
"property_id": prop_id, "source_page_id": pid1, "target_page_id": pid2,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
src = client.get(f"/db/pages/{pid1}/api").json()
|
||||
props = json.loads(src["property_values_json"])
|
||||
assert pid2 in props.get(str(prop_id), [])
|
||||
|
||||
tgt = client.get(f"/db/pages/{pid2}/api").json()
|
||||
tprops = json.loads(tgt["property_values_json"])
|
||||
assert any(pid1 in (v if isinstance(v, list) else []) for v in tprops.values())
|
||||
|
||||
client.delete(f"/db/api/{c2}") # c2 first
|
||||
client.delete(f"/db/api/{c1}")
|
||||
|
||||
|
||||
def test_rollup_compute(client):
|
||||
"""Compute rollup aggregation via relation."""
|
||||
r1 = client.post("/db/api", json={"name": "Proj"})
|
||||
r2 = client.post("/db/api", json={"name": "Task"})
|
||||
c1, c2 = r1.json()["id"], r2.json()["id"]
|
||||
|
||||
rel = client.post(f"/db/{c1}/properties/relation", json={
|
||||
"name": "TaskList", "related_collection_id": c2, "reverse_name": "ParentProj",
|
||||
})
|
||||
rel_id = rel.json()["id"]
|
||||
|
||||
num = client.post(f"/db/{c2}/properties/api", json={"name": "Hours", "prop_type": "number"})
|
||||
num_id = num.json()["id"]
|
||||
|
||||
proj = client.post(f"/db/{c1}/pages/api", json={"title": "Proj1"})
|
||||
pid = proj.json()["id"]
|
||||
|
||||
t1 = client.post(f"/db/{c2}/pages/api", json={"title": "Task 1", "properties": {str(num_id): 5}})
|
||||
t2 = client.post(f"/db/{c2}/pages/api", json={"title": "Task 2", "properties": {str(num_id): 10}})
|
||||
|
||||
client.post(f"/db/{c1}/properties/relation/link", json={
|
||||
"property_id": rel_id, "source_page_id": pid, "target_page_id": t1.json()["id"],
|
||||
})
|
||||
client.post(f"/db/{c1}/properties/relation/link", json={
|
||||
"property_id": rel_id, "source_page_id": pid, "target_page_id": t2.json()["id"],
|
||||
})
|
||||
|
||||
resp = client.post("/db/rollup/compute", json={
|
||||
"collection_id": c1, "relation_property_id": rel_id,
|
||||
"target_property_id": num_id, "page_id": pid, "function": "sum",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["result"] == 15.0
|
||||
|
||||
resp2 = client.post("/db/rollup/compute", json={
|
||||
"collection_id": c1, "relation_property_id": rel_id,
|
||||
"target_property_id": num_id, "page_id": pid, "function": "count",
|
||||
})
|
||||
assert resp2.json()["result"] == 2
|
||||
|
||||
client.delete(f"/db/api/{c2}") # c2 first
|
||||
client.delete(f"/db/api/{c1}")
|
||||
|
||||
|
||||
def test_formula_evaluate(client):
|
||||
"""Evaluate formula expressions."""
|
||||
# Function-based expressions work
|
||||
resp = client.post("/db/formula/evaluate", json={
|
||||
"expression": "round(3.14159, 2)", "context": {},
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["result"] == 3.14
|
||||
|
||||
resp = client.post("/db/formula/evaluate", json={
|
||||
"expression": "if(prop('Done'), 'OK', 'Pending')",
|
||||
"context": {"Done": True},
|
||||
})
|
||||
assert resp.json()["result"] == "OK"
|
||||
|
||||
resp = client.post("/db/formula/evaluate", json={
|
||||
"expression": "concat(prop('First'), ' ', prop('Last'))",
|
||||
"context": {"First": "John", "Last": "Doe"},
|
||||
})
|
||||
assert resp.json()["result"] == "John Doe"
|
||||
|
||||
resp = client.post("/db/formula/evaluate", json={
|
||||
"expression": "length(prop('Text'))",
|
||||
"context": {"Text": "Hello"},
|
||||
})
|
||||
assert resp.json()["result"] == 5
|
||||
|
||||
resp = client.post("/db/formula/evaluate", json={
|
||||
"expression": "toNumber('42')",
|
||||
"context": {},
|
||||
})
|
||||
assert resp.json()["result"] == 42.0
|
||||
|
||||
|
||||
def test_formula_empty_expression(client):
|
||||
"""Empty expression should return 400."""
|
||||
resp = client.post("/db/formula/evaluate", json={"expression": "", "context": {}})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ── v2.1.0: Views (Calendar, Gallery, List, Timeline) ──
|
||||
|
||||
def test_views_calendar(client):
|
||||
"""Calendar view renders with navigation."""
|
||||
r = client.post("/db/api", json={"name": "Cal DB"})
|
||||
cid = r.json()["id"]
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "Event 1", "properties": {"date": "2026-07-15"}})
|
||||
resp = client.get(f"/db/{cid}/view/calendar")
|
||||
assert resp.status_code == 200
|
||||
assert "July" in resp.text or "juillet" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_views_gallery(client):
|
||||
"""Gallery view renders card grid."""
|
||||
r = client.post("/db/api", json={"name": "Gal DB"})
|
||||
cid = r.json()["id"]
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "Card 1"})
|
||||
resp = client.get(f"/db/{cid}/view/gallery")
|
||||
assert resp.status_code == 200
|
||||
assert "gallery" in resp.text.lower() or "gal-card" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_views_list(client):
|
||||
"""List view renders compact items."""
|
||||
r = client.post("/db/api", json={"name": "List DB"})
|
||||
cid = r.json()["id"]
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "Item 1"})
|
||||
resp = client.get(f"/db/{cid}/view/list")
|
||||
assert resp.status_code == 200
|
||||
assert "list-item" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_views_timeline(client):
|
||||
"""Timeline view renders date bars."""
|
||||
r = client.post("/db/api", json={"name": "TL DB"})
|
||||
cid = r.json()["id"]
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"date": "2026-07-01...2026-07-15"}})
|
||||
resp = client.get(f"/db/{cid}/view/timeline")
|
||||
assert resp.status_code == 200
|
||||
assert "tl-bar" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_views_default_table(client):
|
||||
"""Default view renders as table."""
|
||||
r = client.post("/db/api", json={"name": "Tab DB"})
|
||||
cid = r.json()["id"]
|
||||
resp = client.get(f"/db/{cid}")
|
||||
assert resp.status_code == 200
|
||||
assert "<table>" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_views_calendar_navigation(client):
|
||||
"""Calendar supports month navigation via query params."""
|
||||
r = client.post("/db/api", json={"name": "Nav DB"})
|
||||
cid = r.json()["id"]
|
||||
resp = client.get(f"/db/{cid}/view/calendar?year=2026&month=8")
|
||||
assert resp.status_code == 200
|
||||
assert "August" in resp.text or "août" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
# ── v1.7.0: View Management ──
|
||||
|
||||
def test_view_config_update(client):
|
||||
"""Update view config (card_size, group_by)."""
|
||||
r = client.post("/db/api", json={"name": "V Config"})
|
||||
cid = r.json()["id"]
|
||||
views = client.get(f"/db/{cid}/views/api").json()["views"]
|
||||
vid = views[0]["id"]
|
||||
|
||||
resp = client.put(f"/db/views/{vid}/config", json={
|
||||
"card_size": "large", "group_by": "Status",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["config"]["card_size"] == "large"
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_save_view_as(client):
|
||||
"""Save current state as new view."""
|
||||
r = client.post("/db/api", json={"name": "Save As"})
|
||||
cid = r.json()["id"]
|
||||
|
||||
resp = client.post(f"/db/{cid}/views/save-as", json={
|
||||
"name": "My Kanban", "view_type": "board",
|
||||
"config": {"group_by": "Priority"},
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "My Kanban"
|
||||
|
||||
views = client.get(f"/db/{cid}/views/api").json()["views"]
|
||||
assert len(views) == 2
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_list_views(client):
|
||||
"""List views for a collection."""
|
||||
r = client.post("/db/api", json={"name": "V List"})
|
||||
cid = r.json()["id"]
|
||||
resp = client.get(f"/db/{cid}/views/api")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["views"]) == 1 # default view
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
# ── v2.1.0: Sub-items & Dependencies ──
|
||||
|
||||
def test_sub_items_crud(client):
|
||||
"""Create and list sub-items."""
|
||||
r = client.post("/db/api", json={"name": "Sub DB"})
|
||||
cid = r.json()["id"]
|
||||
p = client.post(f"/db/{cid}/pages/api", json={"title": "Parent Task"})
|
||||
pid = p.json()["id"]
|
||||
|
||||
# Create sub-item
|
||||
resp = client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "Child 1"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["parent_id"] == pid
|
||||
|
||||
resp2 = client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "Child 2"})
|
||||
assert resp2.status_code == 200
|
||||
|
||||
# List
|
||||
items = client.get(f"/db/{cid}/pages/{pid}/sub-items").json()["sub_items"]
|
||||
assert len(items) == 2
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_status_aggregate(client):
|
||||
"""Aggregate child statuses."""
|
||||
r = client.post("/db/api", json={"name": "Agg DB"})
|
||||
cid = r.json()["id"]
|
||||
p = client.post(f"/db/{cid}/pages/api", json={"title": "Parent", "properties": {"Status": "In Progress"}})
|
||||
pid = p.json()["id"]
|
||||
|
||||
client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "C1", "properties": {"Status": "Done"}})
|
||||
client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "C2", "properties": {"Status": "In Progress"}})
|
||||
|
||||
resp = client.get(f"/db/{cid}/pages/{pid}/status-aggregate")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 2
|
||||
assert data["done"] == 1
|
||||
assert data["all_done"] is False
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_dependencies_check(client):
|
||||
"""Dependency constraint check."""
|
||||
r = client.post("/db/api", json={"name": "Dep DB"})
|
||||
cid = r.json()["id"]
|
||||
a = client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"Status": "In Progress"}})
|
||||
b = client.post(f"/db/{cid}/pages/api", json={"title": "Task B", "properties": {"Status": "Done"}})
|
||||
aid, bid = a.json()["id"], b.json()["id"]
|
||||
|
||||
# A blocks B
|
||||
client.post(f"/db/{cid}/pages/{aid}/dependencies", json={"blocks": [bid]})
|
||||
|
||||
# Check if A can go to Done (should not, B is Done but blocks is on A, wait...)
|
||||
# B is Done, so A CAN transition
|
||||
resp = client.post(f"/db/{cid}/pages/{aid}/check-deps", json={"new_status": "Done"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["can_transition"] is True
|
||||
assert resp.json()["blocked_by"] == []
|
||||
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_dependencies_blocked(client):
|
||||
"""Dependency blocks transition when blocker is not done."""
|
||||
r = client.post("/db/api", json={"name": "Block DB"})
|
||||
cid = r.json()["id"]
|
||||
a = client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"Status": "In Progress"}})
|
||||
b = client.post(f"/db/{cid}/pages/api", json={"title": "Task B", "properties": {"Status": "Todo"}})
|
||||
aid, bid = a.json()["id"], b.json()["id"]
|
||||
|
||||
# A blocks B — A depends on B being done
|
||||
client.post(f"/db/{cid}/pages/{aid}/dependencies", json={"blocks": [bid]})
|
||||
|
||||
# B is Todo, so A CANNOT transition to Done
|
||||
resp = client.post(f"/db/{cid}/pages/{aid}/check-deps", json={"new_status": "Done"})
|
||||
assert resp.json()["can_transition"] is False
|
||||
assert len(resp.json()["blocked_by"]) == 1
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
# ── v2.1.0: My Tasks ──
|
||||
|
||||
def test_my_tasks_page(client):
|
||||
"""My Tasks dashboard renders."""
|
||||
resp = client.get("/my-tasks")
|
||||
assert resp.status_code == 200
|
||||
assert "My Tasks" in resp.text
|
||||
|
||||
|
||||
def test_my_tasks_api(client):
|
||||
"""My Tasks API returns JSON."""
|
||||
r = client.post("/db/api", json={"name": "My Project"})
|
||||
cid = r.json()["id"]
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "Task 1", "properties": {"Status": "Todo"}})
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "Task 2", "properties": {"Status": "In Progress"}})
|
||||
|
||||
resp = client.get("/my-tasks/api")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "tasks" in data
|
||||
assert data["total"] >= 2
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_my_tasks_view_today(client):
|
||||
resp = client.get("/my-tasks?view=today")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_my_tasks_view_overdue(client):
|
||||
resp = client.get("/my-tasks?view=overdue")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── v2.1.0: Workspace, Comments, Favorites, CSV ──
|
||||
|
||||
def test_workspace_crud(client):
|
||||
resp = client.post("/workspace", json={"name": "Team WS"})
|
||||
assert resp.status_code == 200
|
||||
ws_id = resp.json()["id"]
|
||||
members = client.get(f"/workspace/{ws_id}/members")
|
||||
assert len(members.json()["members"]) >= 1
|
||||
# cleanup
|
||||
with client as _:
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
|
||||
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_comments_crud(client):
|
||||
r = client.post("/db/api", json={"name": "Comment DB"})
|
||||
cid = r.json()["id"]
|
||||
p = client.post(f"/db/{cid}/pages/api", json={"title": "Discuss"})
|
||||
pid = p.json()["id"]
|
||||
|
||||
resp = client.post(f"/workspace/pages/{pid}/comments", json={"body": "Nice work!"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
comments = client.get(f"/workspace/pages/{pid}/comments").json()["comments"]
|
||||
assert len(comments) == 1
|
||||
assert comments[0]["body"] == "Nice work!"
|
||||
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_favorites_crud(client):
|
||||
"""Test favorites CRUD for sidebar pages — POST/DELETE /board/api/favorites/{page_id}."""
|
||||
# Create a page first
|
||||
r = client.post("/board/api/pages?section=Private&project=test/test")
|
||||
assert r.status_code == 200
|
||||
pid = r.json()["id"]
|
||||
|
||||
# Add to favorites
|
||||
resp = client.post(f"/board/api/favorites/{pid}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "added"
|
||||
|
||||
# List favorites
|
||||
favs = client.get("/board/api/favorites").json()["favorites"]
|
||||
assert pid in favs
|
||||
|
||||
# Remove from favorites
|
||||
resp = client.delete(f"/board/api/favorites/{pid}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "removed"
|
||||
|
||||
# List should be empty
|
||||
favs = client.get("/board/api/favorites").json()["favorites"]
|
||||
assert pid not in favs
|
||||
|
||||
|
||||
def test_csv_import_export(client):
|
||||
r = client.post("/db/api", json={"name": "CSV DB"})
|
||||
cid = r.json()["id"]
|
||||
|
||||
csv_data = "title,Status,Priority\nTask 1,Todo,P1\nTask 2,Done,P2"
|
||||
resp = client.post(f"/workspace/collections/{cid}/import/csv", json={"csv": csv_data})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["imported"] == 2
|
||||
|
||||
export = client.get(f"/workspace/collections/{cid}/export/csv")
|
||||
assert export.status_code == 200
|
||||
assert "Task 1" in export.text
|
||||
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_public_view(client):
|
||||
r = client.post("/db/api", json={"name": "Public DB"})
|
||||
cid = r.json()["id"]
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "Public Page"})
|
||||
resp = client.get(f"/workspace/public/{cid}")
|
||||
assert resp.status_code == 200
|
||||
assert "Public Page" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_db_templates(client):
|
||||
resp = client.post("/workspace/templates/database", json={
|
||||
"name": "Bug Tracker", "schema": [{"name": "Severity", "type": "select"}],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
tid = resp.json()["id"]
|
||||
|
||||
templates = client.get("/workspace/templates/database").json()["templates"]
|
||||
assert len(templates) >= 1
|
||||
|
||||
apply = client.post(f"/workspace/templates/database/{tid}/apply", json={"name": "Bugs v2"})
|
||||
assert apply.status_code == 200
|
||||
client.delete(f"/db/api/{apply.json()['collection_id']}")
|
||||
|
||||
|
||||
def test_page_history(client):
|
||||
r = client.post("/db/api", json={"name": "Hist DB"})
|
||||
cid = r.json()["id"]
|
||||
p = client.post(f"/db/{cid}/pages/api", json={"title": "History Page"})
|
||||
pid = p.json()["id"]
|
||||
|
||||
client.post(f"/workspace/pages/{pid}/history", json={
|
||||
"change_type": "created", "snapshot": {"title": "History Page"},
|
||||
})
|
||||
hist = client.get(f"/workspace/pages/{pid}/history").json()["history"]
|
||||
assert len(hist) == 1
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
# ── v2.1.0: Public API, Webhooks, PWA ──
|
||||
|
||||
def test_public_api_token(client):
|
||||
"""Generate a public API token."""
|
||||
resp = client.post("/api/v1/token")
|
||||
assert resp.status_code == 200
|
||||
token = resp.json()["token"]
|
||||
assert token.startswith("fd_")
|
||||
|
||||
|
||||
def test_public_api_with_default_key(client):
|
||||
"""Access public API with default backdoor key."""
|
||||
headers = {"Authorization": "Bearer fd-public-key"}
|
||||
r = client.post("/db/api", json={"name": "API DB"})
|
||||
cid = r.json()["id"]
|
||||
resp = client.get("/api/v1/collections", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_public_api_unauthorized(client):
|
||||
"""Public API rejects missing token."""
|
||||
resp = client.get("/api/v1/collections")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_public_api_pages(client):
|
||||
"""Access public pages API with default key."""
|
||||
headers = {"Authorization": "Bearer fd-public-key"}
|
||||
r = client.post("/db/api", json={"name": "API DB"})
|
||||
cid = r.json()["id"]
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "API Page"})
|
||||
resp = client.get(f"/api/v1/collections/{cid}/pages", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_webhooks_crud(client):
|
||||
"""Register and list outbound webhooks."""
|
||||
resp = client.post("/workspace/webhooks", json={"url": "https://example.com/hook", "event": "page.created"})
|
||||
assert resp.status_code == 200
|
||||
wh_id = resp.json()["id"]
|
||||
|
||||
hooks = client.get("/workspace/webhooks").json()["webhooks"]
|
||||
assert len(hooks) >= 1
|
||||
|
||||
client.delete(f"/workspace/webhooks/{wh_id}")
|
||||
assert len(client.get("/workspace/webhooks").json()["webhooks"]) == 0
|
||||
|
||||
|
||||
def test_pwa_manifest(client):
|
||||
resp = client.get("/manifest.json")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "FlowDeck"
|
||||
assert data["display"] == "standalone"
|
||||
|
||||