Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,5 +1,58 @@
|
||||
# Changelog — FlowDeck
|
||||
|
||||
## v1.5.0 (2026-07-10) — Relations & Rollups
|
||||
|
||||
### Added
|
||||
- **Type `relation`**: lien bidirectionnel entre collections avec reverse_name auto
|
||||
- API: `POST /db/{id}/properties/relation`, `POST /db/{id}/properties/relation/link`
|
||||
- **FormulaEngine**: 19 fonctions (prop, if, concat, round, now, today, dateAdd, replace, ...)
|
||||
- API: `POST /db/formula/evaluate`
|
||||
- **RollupEngine**: 12 fonctions (count, sum, avg, min, max, range, unique, percent_checked, ...)
|
||||
- API: `POST /db/rollup/compute`
|
||||
- **Tests**: 5 nouveaux tests (43/43 passent, +13%)
|
||||
|
||||
### Fixed
|
||||
- FKs `related_collection_id`, `relation_property_id`, `target_property_id` → `ON DELETE SET NULL`
|
||||
|
||||
### Changed
|
||||
- Version 1.4.0 → 1.5.0
|
||||
|
||||
## v1.4.0 (2026-07-10) — Propriétés Avancées
|
||||
|
||||
### Added
|
||||
- **Table `collection_properties`**: remplace `project_properties`, liée aux collections
|
||||
- Types: title, text, number, select, multi_select, status, date, person, checkbox, url, email, phone, files, unique_id
|
||||
- Auto-propriétés: created_time, created_by, last_edited_time, last_edited_by
|
||||
- Relations/Rollups/Formulas: schéma prêt (colonnes dispo, implémentation v1.5)
|
||||
- **Service `property_types.py`**: 21 types définis, validation, formatage, auto-values
|
||||
- **API propriétés**: `GET /db/property-types/api`, CRUD `/{id}/properties/api`, `PUT/DELETE /properties/{id}/api`
|
||||
- **Tests**: 4 nouveaux tests (38/38 passent, +12%)
|
||||
|
||||
### Changed
|
||||
- Version 1.3.0 → 1.4.0
|
||||
|
||||
## v1.3.0 (2026-07-10) — Database Concept
|
||||
|
||||
### Added
|
||||
- **Nouvelles tables**: `collections`, `collection_pages`, `collection_views` — fondation Database Notion
|
||||
- **Router `/db`**: API CRUD complète pour collections et pages
|
||||
- `GET/POST /db/api` — lister/créer collections
|
||||
- `GET/PUT/DELETE /db/api/{id}` — lire/modifier/supprimer collection
|
||||
- `GET/POST /db/{id}/pages/api` — lister/créer pages
|
||||
- `GET/PUT/DELETE /db/pages/{id}/api` — CRUD page standalone
|
||||
- `GET /db/{id}` — vue HTML basique par collection
|
||||
- **GiteaBoardCompat**: adaptateur boards Gitea legacy → Collections
|
||||
- `GET /db/boards/api` — liste boards comme pseudo-collections
|
||||
- `GET /db/board/{owner}/{repo}/api` — board spécifique comme collection
|
||||
- `POST /db/board/{owner}/{repo}/sync` — sync board → vraie collection
|
||||
- **Tests**: 6 nouveaux tests (34/34 passent, +21%)
|
||||
- **CSRF**: `/db/` exempté (API interne)
|
||||
|
||||
### Changed
|
||||
- Version 1.0.0 → 1.3.0 (main.py, api.py, health)
|
||||
- `db.py` init: 3 nouvelles tables + index
|
||||
- Architecture DB: `collection_pages` utilise `property_values_json` (JSON flexible)
|
||||
|
||||
## v1.0.0 (2026-07-08) — Production
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# FlowDeck
|
||||
|
||||
Clone de l'interface **Notion** intégré nativement à **Gitea** — Kanban, Table, Status Overview, Team Load.
|
||||
Clone de l'interface **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Table, multi-vues.
|
||||
|
||||
> **v0.4.0-dev** — Refonte UI Notion-style (Dark Mode, sidebar, topbar, multi-vues)
|
||||
> **v1.3.0** — Database Concept (collections, pages, vues, GiteaBoardCompat)
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -13,28 +13,33 @@ 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)
|
||||
### Database Concept (v1.3.0)
|
||||
- **Collections**: databases indépendantes de Gitea, schéma JSON, icon, description
|
||||
- **Pages**: propriétés JSON, position, parent_id (sub-items ready), lien Gitea optionnel
|
||||
- **Vues**: configurables (type, filters, sorts, visible_properties)
|
||||
- **GiteaBoardCompat**: adaptateur boards Gitea legacy → Collections
|
||||
- **API REST**: CRUD complet collections + pages (12 endpoints)
|
||||
|
||||
### UI Notion-Style (v1.1–v1.2)
|
||||
- **Sidebar gauche** avec sections hiérarchiques (Recents, Private, Library, Trash)
|
||||
- **Topbar** avec breadcrumbs (workspace > projet > page)
|
||||
- **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
|
||||
- **Éditeur de blocs** Notion-style: slash menu (/), navigation clavier, placeholders
|
||||
- **Drag & drop** pages dans la sidebar (SortableJS)
|
||||
|
||||
### 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 (v0.5–v0.8)
|
||||
- **Kanban board**: colonnes avec sous-groupes, drag & drop cartes
|
||||
- **Table view**: colonnes triables, groupes rétractables
|
||||
- **Status overview**: Donut chart SVG avec segments, compteurs
|
||||
- **Team Load**: Barres empilées par membre
|
||||
- **Detailed board**: cartes avec propriétés visibles
|
||||
|
||||
### Filtres & Tri
|
||||
- **Filtres cumulables** (logique AND) avec pastilles cliquables
|
||||
### Filtres & Tri (v0.7)
|
||||
- **Filtres cumulables** (logique AND) avec pastilles
|
||||
- **Tri multi-critères** hiérarchique
|
||||
- **Filtre par statut**: checkboxes multiples avec points colorés
|
||||
- **Filtre AI keywords**: recherche + sélection multiple
|
||||
- **Filtre par statut**: checkboxes multiples
|
||||
|
||||
### Intégration Gitea
|
||||
- **Issues Gitea → cartes** Notion-style
|
||||
@@ -46,10 +51,10 @@ docker compose up -d
|
||||
|
||||
| Couche | Techno |
|
||||
|--------|--------|
|
||||
| Frontend | HTML5 + Jinja2 + CSS Notion, Alpine.js, HTMX, SortableJS |
|
||||
| Frontend | Jinja2 + HTMX + Alpine.js + SortableJS + CSS 31KB |
|
||||
| Backend | Python 3.12 + FastAPI + httpx |
|
||||
| BDD | SQLite (WAL mode) — `/data/flowdeck.db` |
|
||||
| Déploiement | Docker (Python 3.12-slim), docker-compose |
|
||||
| Déploiement | Docker (python:3.12-slim), docker-compose |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -62,7 +67,7 @@ DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
|
||||
## Roadmap
|
||||
|
||||
Voir [ROADMAP.md](ROADMAP.md) — v0.4.0 UI Notion → v1.0.0 Production
|
||||
Voir [ROADMAP.md](ROADMAP.md) — v1.4.0 Propriétés Avancées → v2.0.0 Multi-Users.
|
||||
|
||||
## Développement
|
||||
|
||||
@@ -73,6 +78,12 @@ pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --port 8080
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/ -v # 34/34 passent
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
MIT
|
||||
|
||||
@@ -3,34 +3,201 @@
|
||||
> **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
|
||||
|
||||
---
|
||||
|
||||
## Upcoming
|
||||
|
||||
### 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 — Intégrations Avancées (futur)
|
||||
|
||||
### 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 ⬜
|
||||
Editor+ Future
|
||||
Multi-user
|
||||
```
|
||||
|
||||
@@ -1,177 +1,71 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Objectif**: Clone Notion intégré Gitea
|
||||
> **Début**: 2026-07-08 | **Version actuelle**: v1.3.0 | **Prochaine**: v1.4.0
|
||||
|
||||
## 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% |
|
||||
| Phase | 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 (14 types) | ⬜ | — |
|
||||
| v1.5 | Relations & Rollups | ⬜ | — |
|
||||
| v1.6 | Vues Calendrier/Timeline/Gallery/List | ⬜ | — |
|
||||
| v1.7 | Vues Améliorées (group_by, card_size) | ⬜ | — |
|
||||
| v1.8 | Sub-items & Dépendances | ⬜ | — |
|
||||
| v1.9 | My Tasks & Dashboard Unifié | ⬜ | — |
|
||||
| v2.0 | Éditeur Complet & Multi-Users | ⬜ | — |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Documents & Planification
|
||||
## v1.3.0 — Database Concept (complété 2026-07-10)
|
||||
|
||||
### 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)
|
||||
### Tables
|
||||
- [x] `collections` — base de données abstraite, schéma JSON, lien Gitea optionnel
|
||||
- [x] `collection_pages` — pages avec `parent_id`, `property_values_json`, `position`
|
||||
- [x] `collection_views` — vues configurables (`view_type`, `config_json`)
|
||||
- [x] Index: `idx_cp_collection`, `idx_cp_parent`, `idx_cp_gitea`
|
||||
|
||||
### 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)
|
||||
### API
|
||||
- [x] `GET/POST /db/api` — lister/créer collections
|
||||
- [x] `GET/PUT/DELETE /db/api/{id}` — CRUD collection
|
||||
- [x] `GET/POST /db/{id}/pages/api` — pages dans une collection
|
||||
- [x] `GET/PUT/DELETE /db/pages/{id}/api` — CRUD page standalone
|
||||
- [x] `GET /db/{id}` — vue HTML collection
|
||||
- [x] `GET /db/boards/api` — boards Gitea comme collections
|
||||
- [x] `GET /db/board/{o}/{r}/api` — board spécifique
|
||||
- [x] `POST /db/board/{o}/{r}/sync` — sync board → collection
|
||||
|
||||
### Adapter
|
||||
- [x] `GiteaBoardCompat` — `from_board()`, `from_card()`, `list_boards_as_collections()`, `sync_to_collection()`
|
||||
|
||||
### Infra
|
||||
- [x] CSRF exempté pour `/db/`
|
||||
- [x] Version 1.0.0 → 1.3.0
|
||||
- [x] 34/34 tests passent
|
||||
- [x] Tag git v1.3.0
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Layout Notion
|
||||
## Prochaines étapes (v1.4.0 — Propriétés Avancées)
|
||||
|
||||
### 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
|
||||
### Table `collection_properties`
|
||||
- [ ] Remplacer `project_properties` (liée à `project_owner/name`) par `collection_properties` (liée à `collections`)
|
||||
- [ ] Types: number, checkbox, url, email, phone, status (9 couleurs), files, unique_id
|
||||
- [ ] Auto-propriétés: created_time, created_by, last_edited_time, last_edited_by
|
||||
- [ ] Format number: nombre, %, €, $, £, ¥
|
||||
|
||||
### 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
|
||||
|
||||
### 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 (...)
|
||||
|
||||
### 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
|
||||
|
||||
---
|
||||
### Intégration UI
|
||||
- [ ] Property editor inline par type (select dropdown, date picker, checkbox...)
|
||||
- [ ] Affichage des propriétés dans la modale de détail page
|
||||
- [ ] Migration project_properties → collection_properties
|
||||
|
||||
## 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
|
||||
- **Stack**: FastAPI + Jinja2 + HTMX + Alpine.js + SortableJS + SQLite (WAL)
|
||||
- **Docker**: python:3.12-slim, port 8080, volume /data
|
||||
- **Tests**: pytest, 34 tests, client TestClient avec SQLite temporaire
|
||||
- **CI/CD**: Gitea Actions (.gitea/workflows/ci.yml)
|
||||
|
||||
@@ -130,7 +130,168 @@ 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,
|
||||
title TEXT NOT NULL DEFAULT 'New page',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
parent_section TEXT DEFAULT 'Private',
|
||||
parent_id INTEGER REFERENCES pages(id),
|
||||
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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
page_id INTEGER REFERENCES collection_pages(id),
|
||||
collection_id INTEGER REFERENCES collections(id),
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, page_id, collection_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
|
||||
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.0",
|
||||
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", "/board/api/pages", "/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:
|
||||
|
||||
@@ -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")
|
||||
@@ -56,7 +57,7 @@ async def health():
|
||||
|
||||
return {
|
||||
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
||||
"version": "1.0.0",
|
||||
"version": "2.1.0",
|
||||
"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}")
|
||||
|
||||
@@ -115,18 +115,65 @@ 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 _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
|
||||
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/")]
|
||||
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
||||
"current_page": repo or "Dashboard", "last_edited": "now",
|
||||
"recent_pages": recent, "private_pages": private_items,
|
||||
"favorite_pages": [], "user": user,
|
||||
"workspace_key": ws_key}
|
||||
|
||||
|
||||
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
||||
@@ -225,15 +272,66 @@ 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):
|
||||
"""Library page avec onglets Recents, Favorites, Shared, Private."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
sidebar = _sidebar_data(request)
|
||||
# Enrich with timestamps
|
||||
for p in sidebar["recent_pages"]:
|
||||
p["last_edited"] = "now"
|
||||
for p in sidebar["private_pages"]:
|
||||
p["last_edited"] = "now"
|
||||
template = env.get_template("library.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
# ═══════════ 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 +489,146 @@ 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."""
|
||||
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)
|
||||
sidebar = _sidebar_data(request)
|
||||
ctx = {**sidebar, "page": dict(row)}
|
||||
template = env.get_template("page_editor.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
@router.post("/api/sync/{owner}/{repo}")
|
||||
async def sync_project(owner: str, repo: str):
|
||||
|
||||
@@ -8,78 +8,128 @@ from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.services.gitea_client import gitea
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
def _sidebar_data(request: Request, repos: list[dict]) -> dict:
|
||||
"""Build sidebar context from Gitea repos."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
workspace_name = user.get("login", "Bruno") if user else "Bruno"
|
||||
workspace_initial = workspace_name[0].upper() if workspace_name else "B"
|
||||
ws = user.get("login", "Bruno") if user else "Bruno"
|
||||
initial = ws[0].upper() if ws else "B"
|
||||
|
||||
# Build recent pages from repos
|
||||
recent_pages = []
|
||||
private_pages = []
|
||||
for repo in repos[:10]:
|
||||
full_name = repo.get("full_name", "")
|
||||
parts = full_name.split("/") if "/" in full_name else [workspace_name, full_name]
|
||||
page = {
|
||||
recent_pages.append({
|
||||
"id": full_name,
|
||||
"name": repo.get("name", full_name),
|
||||
"icon": "📁",
|
||||
"url": f"/board/{full_name}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
}
|
||||
recent_pages.append(page)
|
||||
private_pages.append({**page})
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
})
|
||||
|
||||
return {
|
||||
"workspace_name": workspace_name,
|
||||
"workspace_initial": workspace_initial,
|
||||
"current_page": "Dashboard",
|
||||
"last_edited": "now",
|
||||
"workspace_name": ws, "workspace_initial": initial,
|
||||
"current_page": "Dashboard", "last_edited": "now",
|
||||
"recent_pages": recent_pages,
|
||||
"private_pages": private_pages,
|
||||
"private_pages": [],
|
||||
"favorite_pages": [],
|
||||
"user": user,
|
||||
"workspace_key": "",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
async def trash_page(request: Request):
|
||||
"""Trash page."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
sidebar = _sidebar_data(request, [])
|
||||
template = env.get_template("trash.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
async def library_page(request: Request):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
sidebar = _sidebar_data(request, [])
|
||||
for p in sidebar["recent_pages"]:
|
||||
p["last_edited"] = "now"
|
||||
for p in sidebar["private_pages"]:
|
||||
p["last_edited"] = "now"
|
||||
template = env.get_template("library.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
async def view_page_root(request: Request, page_id: int):
|
||||
"""Render a Markdown page at root level with workspace context."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
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)
|
||||
ws = page.get("workspace", "")
|
||||
parts = ws.split("/") if "/" in ws else ["", ""]
|
||||
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
# Load sub-pages
|
||||
with get_conn() as conn:
|
||||
subs = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs]}
|
||||
template = env.get_template("page_editor.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
@router.get("/accounts", response_class=HTMLResponse)
|
||||
async def accounts_page(request: Request):
|
||||
"""Account management panel."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall()
|
||||
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
|
||||
template = env.get_template("accounts.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(
|
||||
request: Request,
|
||||
search: str = Query(default=""),
|
||||
show_archived: bool = Query(default=False),
|
||||
):
|
||||
"""Dashboard: liste tous les projets Gitea (user + orgs)."""
|
||||
try:
|
||||
repos = await gitea.get_user_repos(page=1, limit=50)
|
||||
|
||||
if search:
|
||||
q = search.lower()
|
||||
repos = [r for r in repos if q in r.get("full_name", "").lower()
|
||||
or q in (r.get("description") or "").lower()]
|
||||
|
||||
if not show_archived:
|
||||
repos = [r for r in repos if not r.get("archived", False)]
|
||||
|
||||
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Dashboard error: %s", e)
|
||||
repos = []
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
sidebar = _sidebar_data(request, repos)
|
||||
template = env.get_template("dashboard.html")
|
||||
return template.render(
|
||||
request=request,
|
||||
repos=repos,
|
||||
search=search,
|
||||
show_archived=show_archived,
|
||||
**sidebar,
|
||||
)
|
||||
return template.render(request=request, repos=repos, search=search,
|
||||
show_archived=show_archived, **sidebar)
|
||||
|
||||
@@ -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,140 +12,220 @@
|
||||
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'>
|
||||
<div class="app-layout" x-data="appState()">
|
||||
|
||||
<!-- ═══════════ SIDEBAR ═══════════ -->
|
||||
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }" id="sidebar">
|
||||
{# ─── 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 }" id="sidebar"
|
||||
x-data x-init="$nextTick(() => { if(typeof initTreeSortable==='function') initTreeSortable(); })">
|
||||
<!-- Workspace header -->
|
||||
<div class="sidebar-workspace">
|
||||
<div class="sidebar-workspace" @click="toggleWorkspaceMenu">
|
||||
<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>
|
||||
</div>
|
||||
<span class="workspace-chevron">▾</span>
|
||||
</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="/" 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>
|
||||
|
||||
<!-- 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"><li class="sidebar-item empty-hint"><span class="page-icon">👥</span><span class="page-name text-dim">No shared pages</span></li></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>
|
||||
@@ -157,16 +237,16 @@
|
||||
<header class="topbar">
|
||||
<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,13 +255,20 @@
|
||||
</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>
|
||||
|
||||
@@ -240,10 +327,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
|
||||
@@ -290,9 +377,82 @@
|
||||
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,
|
||||
|
||||
// ── Sections collapsible ──
|
||||
sectionsOpen: { recents: true, private: true, meetings: true, favorites: true, agents: true, shared: true, apps: true },
|
||||
toggleSection(key) { this.sectionsOpen[key] = !this.sectionsOpen[key]; this.sectionsOpen = Object.assign({}, this.sectionsOpen); },
|
||||
toggleWorkspaceMenu() { this.sidebarCollapsed = !this.sidebarCollapsed; },
|
||||
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) {
|
||||
window.location.href = url;
|
||||
@@ -310,18 +470,118 @@
|
||||
|
||||
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();
|
||||
|
||||
switch(action) {
|
||||
case 'favorite':
|
||||
alert(`Toggled favorite for: ${pageName}`);
|
||||
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) {
|
||||
window.location.href = '/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); });
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
{% 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 -->
|
||||
<div class="view-tabs" x-data="{ tab: 'recents' }">
|
||||
<button class="view-tab" :class="{ active: tab === 'recents' }" @click="tab = 'recents'">
|
||||
<span class="tab-icon">🕒</span> Recents
|
||||
</button>
|
||||
<button class="view-tab" :class="{ active: tab === 'favorites' }" @click="tab = 'favorites'">
|
||||
<span class="tab-icon">⭐</span> Favorites
|
||||
</button>
|
||||
<button class="view-tab" :class="{ active: tab === 'shared' }" @click="tab = 'shared'">
|
||||
<span class="tab-icon">👥</span> Shared
|
||||
</button>
|
||||
<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 x-data="{ tab: 'recents' }" 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 recent_pages %}{{ page_row(page) }}{% endfor %}
|
||||
{% if not recent_pages %}
|
||||
<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 favorite_pages %}{{ page_row(page) }}{% endfor %}
|
||||
{% if not favorite_pages %}
|
||||
<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'">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Private -->
|
||||
<div x-show="tab === 'private'">
|
||||
{% for page in private_pages %}{{ page_row(page) }}{% endfor %}
|
||||
{% if not private_pages %}
|
||||
<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>
|
||||
{% 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,250 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}📄{% endblock %}
|
||||
{% block page_title %}{{ page.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-editor-wrapper" x-data="editor('{{ page.id }}', '{{ page.title|e }}', '{{ page.content_format }}', '{{ page.content|e }}')" x-init="init()">
|
||||
|
||||
<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;autoSave()"
|
||||
@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>
|
||||
|
||||
<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>
|
||||
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,'>');}
|
||||
|
||||
// Slash menu — event delegation on list, single innerHTML set
|
||||
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';
|
||||
// Event delegation: single click handler for all items
|
||||
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 editor(pid,title,fmt,raw){
|
||||
const self={
|
||||
pid,pageTitle:title,blocks:[],dirty:false,saving:false,lastSaved:'',st:null,
|
||||
fmt:{open:false,top:0,left:0,idx:-1},
|
||||
|
||||
init(){
|
||||
_bid=Math.floor(Math.random()*10000);
|
||||
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);
|
||||
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 DOM→data, render, add listeners
|
||||
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');};});
|
||||
// Update empty class: browser may insert <br> in contentEditable — strip and apply class
|
||||
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
|
||||
},
|
||||
|
||||
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);},
|
||||
|
||||
// KEY HANDLER
|
||||
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;
|
||||
}
|
||||
|
||||
// Slash detection — use idx, not bid (bid can get stale after render)
|
||||
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);}
|
||||
|
||||
// ENTER: split block
|
||||
|
||||
// Markdown shortcuts on space — use idx
|
||||
if(e.key===' '){const i2=idx;setTimeout(()=>this.checkMd(i2),15);}
|
||||
|
||||
// ENTER: split block
|
||||
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;
|
||||
}
|
||||
|
||||
// ARROWS
|
||||
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;}
|
||||
|
||||
// BACKSPACE
|
||||
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;}
|
||||
|
||||
// Markdown shortcuts on space
|
||||
if(e.key===' '){const b2=bid;setTimeout(()=>this.checkMd(b2),15);}
|
||||
|
||||
// Ctrl shortcuts
|
||||
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();
|
||||
// Update sidebar items with new title
|
||||
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;},
|
||||
};
|
||||
return self;
|
||||
}
|
||||
|
||||
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,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,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 │ "[email protected]"
|
||||
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) |
|
||||
|
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: 164 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 63 KiB |
@@ -19,8 +19,10 @@
|
||||
--text-primary: #FFFFFF;
|
||||
--text-secondary: #A0A0A0;
|
||||
--text-dim: #6B6B6B;
|
||||
--text-muted: #4A4A4A;
|
||||
|
||||
--border: #333333;
|
||||
--border-color: rgba(255,255,255,0.06);
|
||||
--border-light: #3a3a3a;
|
||||
|
||||
/* ===== Couleurs d'accent Notion ===== */
|
||||
@@ -70,7 +72,9 @@
|
||||
--text-primary: #1A1A1A;
|
||||
--text-secondary: #6B6B6B;
|
||||
--text-dim: #9B9B9B;
|
||||
--text-muted: #B0B0B0;
|
||||
--border: #E0E0E0;
|
||||
--border-color: rgba(0,0,0,0.06);
|
||||
--border-light: #EDEDEC;
|
||||
--blue-bg: #E8F0FE;
|
||||
--green-bg: #E6F4EA;
|
||||
@@ -217,6 +221,31 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Navigation icon row (Notion top bar) */
|
||||
.sidebar-nav-row {
|
||||
display: flex; align-items: center; gap: 3px;
|
||||
padding: 6px 6px; margin: 0 2px;
|
||||
}
|
||||
.nav-icon-btn {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px; border-radius: 6px;
|
||||
color: var(--text-secondary); transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.nav-icon-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.nav-icon-btn.active { background: var(--bg-active); color: var(--text-primary); }
|
||||
/* Home button: icon + text */
|
||||
.nav-icon-btn.home-btn {
|
||||
padding: 6px 8px; gap: 8px;
|
||||
font-size: 14px; font-weight: 500;
|
||||
}
|
||||
/* Collapse button on the right */
|
||||
.nav-icon-btn.collapse-btn { margin-left: auto; }
|
||||
|
||||
/* Empty hint */
|
||||
.sidebar-item.empty-hint { color: var(--text-dim); font-size: 12px; cursor: default; }
|
||||
.sidebar-item.empty-hint:hover { background: none; }
|
||||
.sidebar-item.empty-hint .page-name { font-size: 12px; }
|
||||
|
||||
/* Sections */
|
||||
.sidebar-section {
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
@@ -311,11 +340,58 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
/* Tree toggle button (▶/▼ chevron) */
|
||||
.tree-toggle {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 9px;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease, background var(--transition);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.tree-toggle:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tree-toggle.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tree-toggle-placeholder {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Tree children wrapper */
|
||||
.tree-children-wrapper {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tree-children-wrapper .sidebar-items {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.sidebar-item .page-icon {
|
||||
font-size: 14px;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.sidebar-item:active .page-icon {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sidebar-item .page-name {
|
||||
@@ -353,10 +429,6 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* Indented sub-items */
|
||||
.sidebar-item.indent-1 { padding-left: 24px; }
|
||||
.sidebar-item.indent-2 { padding-left: 40px; }
|
||||
|
||||
/* Sidebar footer */
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
@@ -1445,6 +1517,51 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
||||
.text-xs { font-size: 12px; }
|
||||
.text-sm { font-size: 13px; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
|
||||
/* Library table */
|
||||
.library-table-header {
|
||||
display: flex; padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; font-weight: 600; color: var(--text-dim);
|
||||
}
|
||||
.library-table-row {
|
||||
display: flex; align-items: center; padding: 8px 12px; text-decoration: none;
|
||||
color: var(--text-primary); border-radius: 6px; transition: background 0.15s;
|
||||
}
|
||||
.library-table-row:hover { background: var(--bg-hover); }
|
||||
.lib-col { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lib-col-name { flex: 1.5; display: flex; align-items: center; gap: 10px; }
|
||||
.lib-col-author { width: 140px; font-size: 13px; color: var(--text-dim); }
|
||||
.lib-col-source { width: 120px; font-size: 13px; color: var(--text-dim); }
|
||||
.lib-col-edited { width: 140px; font-size: 13px; color: var(--text-dim); }
|
||||
.lib-col-visited { width: 140px; font-size: 13px; color: var(--text-dim); }
|
||||
.lib-empty { text-align: center; padding: 48px; color: var(--text-dim); }
|
||||
.lib-empty h3 { font-size: 16px; margin-bottom: 8px; color: var(--text-primary); }
|
||||
.lib-empty p { font-size: 13px; }
|
||||
|
||||
/* Trash */
|
||||
.trash-filter {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px; border-radius: 6px;
|
||||
font-size: 13px; color: var(--text-primary);
|
||||
background: var(--bg-tertiary); transition: background 0.15s;
|
||||
}
|
||||
.trash-filter:hover { background: var(--bg-hover); }
|
||||
.trash-filter.active { color: #5b9bd5; background: rgba(91,155,213,0.1); }
|
||||
.trash-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 12px; border-radius: 6px; transition: background 0.15s;
|
||||
}
|
||||
.trash-item:hover { background: var(--bg-hover); }
|
||||
.trash-item-icon { font-size: 18px; flex-shrink: 0; }
|
||||
.trash-item-info { flex: 1; min-width: 0; }
|
||||
.trash-item-name { display: block; font-size: 14px; color: var(--text-primary); font-weight: 500; }
|
||||
.trash-item-path { display: block; font-size: 12px; color: var(--text-dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.trash-item-btn {
|
||||
width: 32px; height: 32px; border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 14px; color: var(--text-secondary); transition: background 0.15s;
|
||||
}
|
||||
.trash-item-btn:hover { background: var(--bg-tertiary); }
|
||||
.ml-auto { margin-left: auto; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
@@ -1457,3 +1574,588 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
||||
/* Sortable ghost */
|
||||
.sortable-ghost { opacity: 0.4; }
|
||||
.sortable-drag { box-shadow: 0 8px 24px rgba(0,0,0,0.4) !important; }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
NOTION-STYLE BLOCK EDITOR
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Editor wrapper */
|
||||
.page-editor-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Page title area */
|
||||
.page-cover-area {
|
||||
padding: 48px 64px 12px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-title-block {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-icon-emoji {
|
||||
font-size: 36px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.page-title-input {
|
||||
flex: 1;
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
min-height: 50px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
.page-title-input:empty::before {
|
||||
content: 'New page';
|
||||
color: var(--text-dim);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Blocks container */
|
||||
.blocks-container {
|
||||
padding: 8px 64px 96px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.blocks-empty {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.blocks-empty-hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* Block wrapper */
|
||||
.block-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
margin: 1px 0;
|
||||
border-radius: 4px;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
.block-wrapper:hover {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
/* 6-dot drag handle */
|
||||
.block-handle {
|
||||
position: absolute;
|
||||
left: -40px;
|
||||
top: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
border-radius: 3px;
|
||||
fill: var(--text-dim);
|
||||
}
|
||||
.block-handle.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.block-handle:hover {
|
||||
background: var(--bg-hover);
|
||||
fill: var(--text-secondary);
|
||||
}
|
||||
.block-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Block actions (+ button) */
|
||||
.block-actions {
|
||||
position: absolute;
|
||||
right: -60px;
|
||||
top: 2px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.block-actions.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.block-add-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.block-add-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.block-drag-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
cursor: grab;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.block-drag-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Block content (base) */
|
||||
.block-content {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
padding: 3px 2px;
|
||||
min-height: 28px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.block-content.empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: #9B9A97;
|
||||
pointer-events: none;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.block-h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
padding: 8px 2px 4px;
|
||||
}
|
||||
.block-h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
padding: 6px 2px 3px;
|
||||
}
|
||||
.block-h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
padding: 4px 2px 2px;
|
||||
}
|
||||
|
||||
.block-h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
padding: 4px 2px 2px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Bulleted list */
|
||||
.block-bullet {
|
||||
padding-left: 18px;
|
||||
position: relative;
|
||||
}
|
||||
.block-bullet::before {
|
||||
content: '\2022';
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 3px;
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Numbered list */
|
||||
.block-numbered {
|
||||
padding-left: 22px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* To-do */
|
||||
.block-todo {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 2px 2px;
|
||||
}
|
||||
.block-todo .todo-checkbox {
|
||||
margin-top: 5px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--blue);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.block-todo div {
|
||||
flex: 1;
|
||||
outline: none;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
min-height: 26px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.block-content.empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: #9B9A97;
|
||||
pointer-events: none;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Toggle */
|
||||
.block-toggle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
.toggle-chevron {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-primary);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
margin-top: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.toggle-chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.toggle-chevron:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.block-toggle div {
|
||||
flex: 1;
|
||||
outline: none;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
min-height: 26px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.block-content.empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: #9B9A97;
|
||||
pointer-events: none;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Quote */
|
||||
.block-quote {
|
||||
border-left: 3px solid var(--text-primary);
|
||||
padding: 4px 14px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Code block */
|
||||
.block-code {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.code-lang-label {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.block-code pre {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.block-code code {
|
||||
outline: none;
|
||||
font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
display: block;
|
||||
min-height: 28px;
|
||||
}
|
||||
.block-code code:empty::before {
|
||||
content: 'Type code...';
|
||||
color: #9B9A97;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.block-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 8px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Callout */
|
||||
.block-callout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.block-callout .callout-icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.block-callout div {
|
||||
flex: 1;
|
||||
outline: none;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
min-height: 24px;
|
||||
}
|
||||
.block-content.empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: #9B9A97;
|
||||
pointer-events: none;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Image block */
|
||||
.block-image-wrapper {
|
||||
margin: 8px 0;
|
||||
}
|
||||
.image-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.image-placeholder:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* SLASH COMMAND MENU */
|
||||
.slash-menu {
|
||||
position: fixed;
|
||||
z-index: 999;
|
||||
background: var(--bg-modal);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
width: 320px;
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.slash-search {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.slash-search input {
|
||||
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;
|
||||
}
|
||||
.slash-search input:focus {
|
||||
border-color: var(--blue);
|
||||
}
|
||||
|
||||
.slash-groups {
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.slash-group {
|
||||
padding: 0;
|
||||
}
|
||||
.slash-group + .slash-group {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.slash-group-label {
|
||||
padding: 6px 12px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.slash-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-radius: 0;
|
||||
}
|
||||
.slash-item:hover,
|
||||
.slash-item.selected {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.slash-item-icon {
|
||||
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;
|
||||
}
|
||||
|
||||
.slash-item-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.slash-item-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
.slash-item-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.slash-item-shortcut {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* FORMAT TOOLBAR (FLOATING) */
|
||||
.format-toolbar {
|
||||
position: fixed;
|
||||
z-index: 998;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--bg-modal);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.format-toolbar button {
|
||||
width: 30px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.format-toolbar button:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.format-separator {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--border);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* EDITOR STATUSBAR */
|
||||
.editor-statusbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: var(--sidebar-width);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 16px;
|
||||
background: var(--bg-primary);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
z-index: 50;
|
||||
height: 28px;
|
||||
}
|
||||
.statusbar-right {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Inline formatting inside blocks */
|
||||
.block-content strong { font-weight: 700; }
|
||||
.block-content em { font-style: italic; }
|
||||
.block-content code {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--pink);
|
||||
}
|
||||
.block-content s { text-decoration: line-through; }
|
||||
.block-content u { text-decoration: underline; }
|
||||
.block-content a { color: var(--blue); text-decoration: underline; }
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.page-cover-area { padding: 32px 16px 8px; }
|
||||
.page-title-input { font-size: 28px; }
|
||||
.blocks-container { padding: 8px 16px 96px; }
|
||||
.block-handle { display: none; }
|
||||
.block-actions { display: none; }
|
||||
.editor-statusbar { left: 0; }
|
||||
}
|
||||
|
||||
@@ -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,7 +34,7 @@ def test_health(client):
|
||||
data = resp.json()
|
||||
assert "status" in data
|
||||
assert data["db"] is True
|
||||
assert data["version"] == "1.0.0"
|
||||
assert data["version"] == "2.1.0"
|
||||
|
||||
|
||||
def test_dashboard(client):
|
||||
@@ -190,7 +191,7 @@ def test_csrf_protects_sync(client):
|
||||
|
||||
def test_version_in_health(client):
|
||||
resp = client.get("/api/health")
|
||||
assert resp.json()["version"] == "1.0.0"
|
||||
assert resp.json()["version"] == "2.1.0"
|
||||
|
||||
|
||||
def test_db_tables_exist(client):
|
||||
@@ -202,7 +203,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 +224,773 @@ 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):
|
||||
r = client.post("/db/api", json={"name": "Fav DB"})
|
||||
cid = r.json()["id"]
|
||||
|
||||
resp = client.post("/workspace/favorites", json={"collection_id": cid})
|
||||
assert resp.status_code == 200
|
||||
|
||||
favs = client.get("/workspace/favorites").json()["favorites"]
|
||||
assert len(favs) >= 1
|
||||
|
||||
fav_id = favs[0]["id"]
|
||||
client.delete(f"/workspace/favorites/{fav_id}")
|
||||
assert len(client.get("/workspace/favorites").json()["favorites"]) == 0
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
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"
|
||||
|
||||