Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59a62ff2ad | ||
|
|
26d41cb864 | ||
|
|
10cbd7f52e | ||
|
|
ec96619341 | ||
|
|
d7c1f4c03e | ||
|
|
3fcfa8fdbb | ||
|
|
ce2bae1f1b | ||
|
|
85b4d624b8 | ||
|
|
ef8d4e6bca | ||
|
|
27352c84e5 | ||
|
|
bf329a4ba0 | ||
|
|
b505cfc156 | ||
|
|
3079d53cab | ||
|
|
d3bada54f0 | ||
|
|
eac6e5aed3 | ||
|
|
55853db625 | ||
|
|
fd1a4319b4 | ||
|
|
b818bc908f | ||
|
|
8a94c3b676 | ||
|
|
53d31572e6 | ||
|
|
3fcc12ad8c | ||
|
|
b88a972531 | ||
|
|
3d10400f8f | ||
|
|
9e45ea8870 | ||
|
|
d94dd3da99 | ||
|
|
aa434e9fe9 | ||
|
|
45911438f0 | ||
|
|
5ac8dd10b2 | ||
|
|
eca4067c82 | ||
|
|
fff502a700 | ||
|
|
fbd4b836f7 | ||
|
|
b835dd6b5e | ||
|
|
5372f4f22f | ||
|
|
39b485622d | ||
|
|
aba771400d | ||
|
|
2ca27d3398 | ||
|
|
9422753e8b | ||
|
|
3a94b06af4 | ||
|
|
b0d222b81a | ||
|
|
0ea447ee6f | ||
|
|
63773cb904 | ||
|
|
ec3ad6b1de | ||
|
|
cefc7eb356 | ||
|
|
de62628cb7 | ||
|
|
4806097bc5 | ||
|
|
1fc4c21e2a | ||
|
|
0d9c1bf9ee | ||
|
|
a49a9bae9b | ||
|
|
d5ba29714c | ||
|
|
feb29cbff3 | ||
|
|
79bc1bf1fd | ||
|
|
63c9a5fced | ||
|
|
cbebd1f537 | ||
|
|
b2486a6e11 | ||
|
|
0e99dc2251 | ||
|
|
68f7f97b0d | ||
|
|
8514386a9b | ||
|
|
ed465333e4 | ||
|
|
c49e152e7c | ||
|
|
8e6ed1e251 | ||
|
|
a5831456e1 | ||
|
|
0a675a94f7 | ||
|
|
47b0f80dfe | ||
|
|
c94dac2bbf | ||
|
|
29d1ccb3dc | ||
|
|
8d3bb36982 | ||
|
|
98bda37484 | ||
|
|
9100da74f3 | ||
|
|
74883688ef | ||
|
|
a5242ee79b | ||
|
|
10d10b28c8 | ||
|
|
2471119b4a | ||
|
|
02cf759ec8 | ||
|
|
978b286990 | ||
|
|
74102f00ab | ||
|
|
5dc8bd5a33 | ||
|
|
c2eb088bb2 | ||
|
|
5967dd9056 | ||
|
|
c681018262 | ||
|
|
a94794ec81 | ||
|
|
1bafbf1eff | ||
|
|
d3923c1d9a | ||
|
|
9f17c39599 | ||
|
|
2ee0a05efd | ||
|
|
04c059341d | ||
|
|
31fba6da39 | ||
|
|
b7c9401c92 | ||
|
|
33933352a8 | ||
|
|
6a2d56a7bd | ||
|
|
28a41a30da | ||
|
|
08c823d758 | ||
|
|
d97a515eef | ||
|
|
5b56f970ee | ||
|
|
37c8e99151 | ||
|
|
298617af58 | ||
|
|
2226e5e2c8 | ||
|
|
2534e2b425 | ||
|
|
bd02c9ea8a | ||
|
|
e85161dfc1 | ||
|
|
fe64617677 | ||
|
|
25386bc79f | ||
|
|
d751415308 | ||
|
|
6f1eabf91d | ||
|
|
84a126cfd1 | ||
|
|
7ea1c852dc | ||
|
|
c2ef7bfaa0 | ||
|
|
c990382085 | ||
|
|
b141af886d | ||
|
|
56c5739605 | ||
|
|
7613129204 | ||
|
|
f61f8b61ae | ||
|
|
3210ac65a6 | ||
|
|
cef267d7b5 | ||
|
|
043dd4871c | ||
|
|
b5dd37a652 | ||
|
|
d4fb095baf | ||
|
|
8c0b55635c | ||
|
|
631c106b01 | ||
|
|
30e1879ac2 | ||
|
|
6e4adaad8b | ||
|
|
50f8e0a938 | ||
|
|
4217978eaa | ||
|
|
ae2b1c5664 | ||
|
|
5b6b251119 | ||
|
|
0edcdbe82a | ||
|
|
f6a022edf2 | ||
|
|
6f40c8953a | ||
|
|
72636a77ea | ||
|
|
76c8a9a53c | ||
|
|
07a4f5ece6 | ||
|
|
d645126b53 | ||
|
|
7cefc08abc | ||
|
|
61174ee967 | ||
|
|
aa64863bf7 | ||
|
|
efd957e827 | ||
|
|
78092e6111 | ||
|
|
ec96fb86a5 | ||
|
|
3fdcc41d10 | ||
|
|
17666b51c2 | ||
|
|
aedb07c962 | ||
|
|
bf19af2347 | ||
|
|
fc6f2531b7 | ||
|
|
0d8507a5c7 | ||
|
|
b83d9b6d35 | ||
|
|
3c8529fd12 | ||
|
|
c5ffab1e39 | ||
|
|
f4cd301f52 | ||
|
|
de86457f90 | ||
|
|
364560c84b | ||
|
|
0429ffd824 | ||
|
|
a7767f3a94 | ||
|
|
e8f4cfb631 | ||
|
|
7c3f62d094 | ||
|
|
9a063bc766 | ||
|
|
234b880c5b | ||
|
|
724ff4c880 | ||
|
|
15bd9d5ed9 | ||
|
|
29ef0fb054 | ||
|
|
bd6fd62734 | ||
|
|
802d681212 | ||
|
|
8eb7049460 | ||
|
|
e2043123f0 | ||
|
|
8cca247fcd | ||
|
|
cb44652554 | ||
|
|
921f1b7bc1 | ||
|
|
8458cf4a29 | ||
|
|
b755f75f15 | ||
|
|
b771708bdc | ||
|
|
1e15e44a00 | ||
|
|
7edeb373f1 | ||
|
|
449550ac90 | ||
|
|
72dc4771b4 | ||
|
|
ec9eb99c98 | ||
|
|
de40c2d83e | ||
|
|
e04b3a38a4 | ||
|
|
b863afab59 | ||
|
|
a497c129d3 | ||
|
|
6f15ad3ad4 | ||
|
|
f25c8ebaf0 | ||
|
|
06bf016392 | ||
|
|
17824deae2 | ||
|
|
e3851b4f12 | ||
|
|
6c8327c021 | ||
|
|
1e36b03532 | ||
|
|
aa2354a25a | ||
|
|
ef88ee4c55 | ||
|
|
86b34dc063 | ||
|
|
6d98070986 | ||
|
|
0d66b5d543 | ||
|
|
e22efc1d9c | ||
|
|
c1f854a54a | ||
|
|
5cc27b4535 | ||
|
|
caa00c54bc | ||
|
|
6b000d892b | ||
|
|
e78ca4b775 | ||
|
|
5fe31aeffa | ||
|
|
7cbb226e62 | ||
|
|
e9d1c32b4f | ||
|
|
46336df21b | ||
|
|
00860417a8 | ||
|
|
5baae598bf | ||
|
|
9f667ae9e0 | ||
|
|
5d1857941e | ||
|
|
f262076545 | ||
|
|
4ea512d007 | ||
|
|
e42c5e1e4b | ||
|
|
e01e410d43 | ||
|
|
9e9ea181e6 | ||
|
|
057ff9f524 | ||
|
|
253f5b215c | ||
|
|
97d6ad7a91 | ||
|
|
ea591bd577 | ||
|
|
91032de704 | ||
|
|
51c6002f08 | ||
|
|
53865f136d | ||
|
|
c524478ff2 | ||
|
|
0a0a330b55 | ||
|
|
92eca626b7 | ||
|
|
0ad1a69994 | ||
|
|
3e291ddc67 | ||
|
|
a57b435bd2 | ||
|
|
c096f09b79 | ||
|
|
74651ee26c | ||
|
|
282eecf80c | ||
|
|
b865b5da6c | ||
|
|
589035336d | ||
|
|
c8685b6dcc | ||
|
|
b555b67546 | ||
|
|
a77eab6050 | ||
|
|
b94d25bff5 | ||
|
|
ca73c6902f | ||
|
|
6f8976817a | ||
|
|
ae3f8b473a | ||
|
|
3718ad488c | ||
|
|
e34ef6193c | ||
|
|
9a0a8893c8 | ||
|
|
1c88afda85 | ||
|
|
2f3e52ebb0 | ||
|
|
c3291b0231 | ||
|
|
9d300d1984 | ||
|
|
996e50bb06 | ||
|
|
cce132d771 | ||
|
|
dc630c9ba7 | ||
|
|
9ee0079a02 | ||
|
|
40a5d4edeb | ||
|
|
4dc9ff29b8 | ||
|
|
aec11a670a | ||
|
|
6e05f9e700 | ||
|
|
b32b94e863 | ||
|
|
58eacaa9d6 | ||
|
|
85e73f8c49 | ||
|
|
94e5e9c9f4 | ||
|
|
2abcfcf7d9 | ||
|
|
6cc94ecefa | ||
|
|
6db8eba670 | ||
|
|
ed0510ec9b | ||
|
|
48f33964b0 | ||
|
|
14ad34c886 | ||
|
|
4668eb77ed | ||
|
|
329948cf4c | ||
|
|
130a8a09ad | ||
|
|
1f31e33b69 | ||
|
|
97eda84cd1 | ||
|
|
7fe7a91788 | ||
|
|
6d0647f83d | ||
|
|
e3968356e2 | ||
|
|
d01b51bd5b | ||
|
|
4bdd4dfd90 | ||
|
|
4f66bf2312 | ||
|
|
e0987e38ff | ||
|
|
6b2abcd9f2 | ||
|
|
ec86c32245 | ||
|
|
d7c0d428e8 | ||
|
|
ab0eedd5fe | ||
|
|
b74e144ab3 | ||
|
|
c38b973281 | ||
|
|
004c47df34 | ||
|
|
17f158ca18 | ||
|
|
6e2b2ff757 | ||
|
|
7fa9a44dbb | ||
|
|
75dbdf2d4f | ||
|
|
21806aa14e | ||
|
|
e36dead312 | ||
|
|
f4ad4f5b6d | ||
|
|
34a0438764 | ||
|
|
e92c788e3d | ||
|
|
56682cc576 | ||
|
|
d8a2cebdd6 | ||
|
|
a43dcfcb9f | ||
|
|
5517dc83a8 | ||
|
|
0c70e320e0 | ||
|
|
6f06c232ba | ||
|
|
b377424412 | ||
|
|
446ef0dfbf | ||
|
|
ab8d7ce9c8 | ||
|
|
df44285df6 | ||
|
|
335120c200 | ||
|
|
f6aaaa123f | ||
|
|
8497a36da9 | ||
|
|
fc6e30aebb | ||
|
|
a8f9832535 | ||
|
|
08d2bac793 | ||
|
|
d97f79f737 | ||
|
|
51cad3ee16 | ||
|
|
5e3bcdcd63 | ||
|
|
47299113a8 | ||
|
|
722c7103de | ||
|
|
8bd737cef6 | ||
|
|
37b1a7b078 | ||
|
|
c59f38659e | ||
|
|
2314af9825 | ||
|
|
4be4c3432b | ||
|
|
4810ff18eb | ||
|
|
e9a419b83f | ||
|
|
54abe8ac3b | ||
|
|
c5398757ca | ||
|
|
e2a739c4c6 | ||
|
|
b01c5bbfe3 | ||
|
|
46e7c1d815 | ||
|
|
1e0afb0e69 | ||
|
|
05369f1856 | ||
|
|
418fe19f33 | ||
|
|
f8350f8161 | ||
|
|
c36a446451 | ||
|
|
1ab5b1271f | ||
|
|
373153de69 | ||
|
|
2f529e4d57 | ||
|
|
b8fa5f4d27 | ||
|
|
a531d6d466 | ||
|
|
2ca7834b43 | ||
|
|
3363a070cf | ||
|
|
0c54db66ac | ||
|
|
eddb69e58e | ||
|
|
1639884800 | ||
|
|
1ae4aa4ab4 | ||
|
|
ae0b964859 | ||
|
|
3e6323a856 | ||
|
|
eb417d4ed5 | ||
|
|
9542353b55 | ||
|
|
3417506062 | ||
|
|
47463a62e0 | ||
|
|
237914dfcd | ||
|
|
f28a80dc95 | ||
|
|
9103ee94fa | ||
|
|
4b45f52321 | ||
|
|
fc8012009a | ||
|
|
be73e08184 | ||
|
|
21e7e30b61 | ||
|
|
7f5ad2aaca | ||
|
|
db8d577c09 | ||
|
|
0593c588e9 | ||
|
|
608d44c33a | ||
|
|
1f26d61997 | ||
|
|
d4dcb06aee | ||
|
|
d3ace7f3ab | ||
|
|
32c1f70c53 | ||
|
|
246aef65ac | ||
|
|
ca144e29d1 | ||
|
|
e0102d2617 | ||
|
|
806ec495f0 | ||
|
|
f87aaa7ead | ||
|
|
be98f9d504 | ||
|
|
79b0bdd22d | ||
|
|
5850085c8b | ||
|
|
4f0bd85e35 | ||
|
|
db6acf0ec3 | ||
|
|
f7d9d91723 | ||
|
|
6033a0b1e4 | ||
|
|
c75cdab134 | ||
|
|
fe4e28e23c | ||
|
|
4a58261c02 | ||
|
|
124bb3cd9f | ||
|
|
ed1b5d4193 | ||
|
|
d92e26f01b | ||
|
|
6015a415d4 | ||
|
|
65fcda0ca0 | ||
|
|
b1304b76e8 | ||
|
|
85d983c56b | ||
|
|
f5fedf1d7b | ||
|
|
b2426a3504 | ||
|
|
0de4f411bd | ||
|
|
fbf0335c3a | ||
|
|
a251ccfe2d | ||
|
|
3ed2181e89 | ||
|
|
c86c04ca22 | ||
|
|
6b67b25d27 | ||
|
|
d940481a6f | ||
|
|
8aaf1676c0 | ||
|
|
7c8f8d719e | ||
|
|
80f56acf4c | ||
|
|
cd854e1dfe | ||
|
|
f752ae2ed7 | ||
|
|
ad95c87b01 | ||
|
|
4a6ed24315 | ||
|
|
e725398543 | ||
|
|
b8647f1a19 | ||
|
|
16532d10bd | ||
|
|
d94817bac5 |
@@ -5,6 +5,10 @@ GITEA_OAUTH_CLIENT_ID=
|
|||||||
GITEA_OAUTH_CLIENT_SECRET=
|
GITEA_OAUTH_CLIENT_SECRET=
|
||||||
GITEA_WEBHOOK_SECRET=
|
GITEA_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# ── GitHub ──
|
||||||
|
GITHUB_OAUTH_CLIENT_ID=
|
||||||
|
GITHUB_OAUTH_CLIENT_SECRET=
|
||||||
|
|
||||||
# ── App ──
|
# ── App ──
|
||||||
APP_SECRET_KEY=change-me-to-random
|
APP_SECRET_KEY=change-me-to-random
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
|
|||||||
@@ -8,3 +8,10 @@ venv/
|
|||||||
dist/
|
dist/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Understand-Anything: exclude intermediate files and local diff overlay
|
||||||
|
.ua/intermediate/
|
||||||
|
.ua/diff-overlay.json
|
||||||
|
.ua/tmp/
|
||||||
|
.ua/.trash-*/
|
||||||
|
.ua/.understandignore
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"outputLanguage":"fr"}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"lastAnalyzedAt": "2026-07-10T12:53:39Z",
|
||||||
|
"gitCommitHash": "c86c04ca220672fb012bb69916e930102be44ef4",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"analyzedFiles": 67
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
# Architecture FlowDeck — Document Complet v2.0
|
# Architecture FlowDeck — Document Complet v4.0
|
||||||
|
|
||||||
> **Version:** 2.0 · **Date:** 2026-07-10 · **Auteur:** Hermes-Deepin
|
> **Version:** 4.0 · **Date:** 2026-07-20 · **Auteur:** Hermes-Deepin
|
||||||
> **Cible:** Clone Notion intégré à Gitea avec support multi-utilisateurs
|
> **Cible:** Clone Notion intégré à Gitea/GitHub — multi-comptes, multi-intégrations
|
||||||
|
> **Version déployée:** v4.0.x (production : https://flowdeck.dracodev.net)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -47,34 +48,31 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
|
|||||||
│ │
|
│ │
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
│ │ Templates Jinja2 (SSR) │ │
|
│ │ Templates Jinja2 (SSR) │ │
|
||||||
│ │ ├─ dashboard.html — Projets + sidebar │ │
|
│ │ ├─ base.html — Layout Notion commun (sidebar) │ │
|
||||||
│ │ ├─ board.html — Kanban (wrapper) │ │
|
│ │ ├─ _header.html — Topbar + breadcrumbs │ │
|
||||||
│ │ ├─ board_fragment.html— Cartes Kanban │ │
|
│ │ ├─ _workspace_tree_macro.html — Macro arbre sidebar │ │
|
||||||
│ │ ├─ table_view.html — Vue table │ │
|
│ │ ├─ landing.html — Landing page non-auth │ │
|
||||||
│ │ ├─ calendar_view.html — Vue calendrier (v1.6) │ │
|
│ │ ├─ workspaces.html — Page Workspaces (locale/Gitea) │ │
|
||||||
│ │ ├─ timeline_view.html — Vue Gantt (v1.6) │ │
|
│ │ ├─ library.html — Bibliothèque multi-onglets │ │
|
||||||
│ │ ├─ gallery_view.html — Vue galerie (v1.6) │ │
|
│ │ ├─ local_workspace.html — Workspace local (explorer) │ │
|
||||||
│ │ ├─ list_view.html — Vue liste (v1.6) │ │
|
│ │ ├─ page_editor.html — Éditeur de blocs │ │
|
||||||
│ │ ├─ status_overview.html — Donut SVG │ │
|
│ │ ├─ trash.html — Corbeille (local only) │ │
|
||||||
│ │ ├─ team_load.html — Barres stacked │ │
|
│ │ ├─ settings.html — Settings / My Account / Admin │ │
|
||||||
│ │ ├─ detailed_board.html— Board détaillé │ │
|
│ │ ├─ accounts.html — Gestion comptes admin │ │
|
||||||
│ │ ├─ card_detail.html — Modale détail carte │ │
|
│ │ ├─ board.html — Kanban (wrapper) │ │
|
||||||
│ │ ├─ card.html — Carte individuelle │ │
|
│ │ ├─ board_fragment.html — Cartes Kanban │ │
|
||||||
│ │ ├─ page_editor.html — Éditeur de blocs │ │
|
│ │ ├─ table_view.html — Vue table │ │
|
||||||
│ │ ├─ my_tasks.html — Dashboard unifié (v1.9) │ │
|
│ │ ├─ login.html — Login/Register (local + OAuth) │ │
|
||||||
│ │ ├─ library.html — Bibliothèque de pages │ │
|
│ │ ├─ public_page.html — Page publiée (/p/slug) │ │
|
||||||
│ │ ├─ trash.html — Corbeille │ │
|
│ │ └─ (legacy: dashboard, detailed_board, card_detail, etc.) │ │
|
||||||
│ │ ├─ accounts.html — Gestion comptes │ │
|
|
||||||
│ │ ├─ notes.html — Notes Markdown │ │
|
|
||||||
│ │ └─ base.html — Layout commun │ │
|
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
│ │ JavaScript (CDN, pas de build step) │ │
|
│ │ JavaScript (servi localement, pas de CDN externe) │ │
|
||||||
│ │ ├─ HTMX 1.9 — AJAX sans JS │ │
|
|
||||||
│ │ ├─ Alpine.js 3.14 — Interactivité légère │ │
|
│ │ ├─ Alpine.js 3.14 — Interactivité légère │ │
|
||||||
|
│ │ ├─ HTMX 1.9 — AJAX sans JS (legacy) │ │
|
||||||
│ │ ├─ SortableJS 1.15 — Drag & drop │ │
|
│ │ ├─ SortableJS 1.15 — Drag & drop │ │
|
||||||
│ │ └─ highlight.js — Syntax highlighting (v2.0) │ │
|
│ │ └─ Prism.js — Syntax highlighting │ │
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
@@ -95,17 +93,27 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
|
|||||||
│ │
|
│ │
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
│ │ ROUTERS │ │
|
│ │ ROUTERS │ │
|
||||||
│ │ ├─ dashboard.py — / Dashboard │ │
|
│ │ ├─ main.py — FastAPI app, lifespan, CORS, 404 │ │
|
||||||
│ │ ├─ board.py — /board/{o}/{r} Board + vues │ │
|
│ │ ├─ dashboard.py — /, /workspaces, /library, /help │ │
|
||||||
│ │ │ — /board/api/... APIs board │ │
|
│ │ │ — /trash, /settings, /accounts │ │
|
||||||
│ │ ├─ api.py — /api/... CRUD + sync │ │
|
│ │ │ — /p/{slug} (pages publiques) │ │
|
||||||
│ │ ├─ notes.py — /notes/{o}/{r} Notes │ │
|
│ │ │ — _sidebar_data() — données sidebar │ │
|
||||||
│ │ ├─ auth.py — /auth/... OAuth2 │ │
|
│ │ ├─ board.py — /board/{o}/{r} Board + vues │ │
|
||||||
│ │ ├─ webhooks.py — /webhooks/... Gitea hooks │ │
|
│ │ │ — /board/api/pages CRUD pages │ │
|
||||||
│ │ ├─ my_tasks.py — /my-tasks (v1.9) │ │
|
│ │ │ — /board/api/trash Trash │ │
|
||||||
│ │ ├─ pages.py — /pages/... Pages CRUD │ │
|
│ │ ├─ api.py — /api/... CRUD + sync │ │
|
||||||
│ │ ├─ collections.py — /db/... Collections │ │
|
│ │ ├─ auth.py — /auth/... OAuth2 + local │ │
|
||||||
│ │ └─ editor.py — /api/editor/... Block editor │ │
|
│ │ ├─ local_workspace.py— /api/local-workspace CRUD + upload │ │
|
||||||
|
│ │ ├─ my_tasks.py — /my-tasks Dashboard │ │
|
||||||
|
│ │ ├─ library.py — /api/library/* API bibliothèque│ │
|
||||||
|
│ │ ├─ pages.py — /pages/... Pages CRUD │ │
|
||||||
|
│ │ ├─ collections.py — /db/... Collections │ │
|
||||||
|
│ │ ├─ editor.py — /api/editor/... Block editor │ │
|
||||||
|
│ │ ├─ private.py — /api/private/* Section privée │ │
|
||||||
|
│ │ ├─ public_api.py — /api/public/* Public API │ │
|
||||||
|
│ │ ├─ workspace.py — Workspaces API + Gitea projets │ │
|
||||||
|
│ │ ├─ webhooks.py — /webhooks/... Gitea hooks │ │
|
||||||
|
│ │ └─ admin.py — /api/admin/* Admin users │ │
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
@@ -138,6 +146,11 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
|
|||||||
│ │ │ ├─ property_values (legacy → collection_pages) │
|
│ │ │ ├─ property_values (legacy → collection_pages) │
|
||||||
│ │ │ ├─ ai_keywords │ │ │
|
│ │ │ ├─ ai_keywords │ │ │
|
||||||
│ │ │ ├─ pages │ │ │
|
│ │ │ ├─ pages │ │ │
|
||||||
|
│ │ │ ├─ page_shares (v4.0) │ │ │
|
||||||
|
│ │ │ ├─ recents (v4.0) │ │ │
|
||||||
|
│ │ │ ├─ tags (v4.0) │ │ │
|
||||||
|
│ │ │ ├─ page_tags (v4.0) │ │ │
|
||||||
|
│ │ │ ├─ gitea_private_pages (v4.0) │ │ │
|
||||||
│ │ │ ├─ users │ │ │
|
│ │ │ ├─ users │ │ │
|
||||||
│ │ │ ├─ user_tokens │ │ │
|
│ │ │ ├─ user_tokens │ │ │
|
||||||
│ │ │ ├─ workspaces (v2.0) │ │ │
|
│ │ │ ├─ workspaces (v2.0) │ │ │
|
||||||
@@ -685,42 +698,146 @@ POST /favorites → Ajouter favori
|
|||||||
DELETE /favorites/{id} → Retirer favori
|
DELETE /favorites/{id} → Retirer favori
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 4.6 Routes Sidebar & Library (v4.0)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /library → Bibliothèque dynamique
|
||||||
|
GET /api/library/recents → Pages récentes
|
||||||
|
GET /api/library/favorites → Favoris
|
||||||
|
GET /api/library/shared → Pages partagées
|
||||||
|
GET /api/library/published → Pages publiées
|
||||||
|
GET /api/library/private → Pages privées
|
||||||
|
GET /api/library/workspace → Pages du workspace
|
||||||
|
|
||||||
|
GET /api/local-workspace/items → Arborescence workspace
|
||||||
|
POST /api/local-workspace/items → Créer page/dossier
|
||||||
|
PUT /api/local-workspace/items/{id} → Renommer
|
||||||
|
DELETE /api/local-workspace/items/{id} → Soft delete
|
||||||
|
POST /api/local-workspace/upload → Upload fichier
|
||||||
|
GET /api/files/{ws_id}/{filename} → Servir fichier uploadé
|
||||||
|
|
||||||
|
GET /help → Page d'aide complète
|
||||||
|
GET /api/public/pages/{slug} → Page publique
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.7 Routes Share & Publish (v4.0)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/pages/{id}/share → Partager une page
|
||||||
|
DELETE /api/pages/{id}/share/{share_id} → Retirer partage
|
||||||
|
GET /api/pages/{id}/shares → Liste partages
|
||||||
|
POST /api/pages/{id}/publish → Publier page
|
||||||
|
DELETE /api/pages/{id}/publish → Dépublier
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Frontend
|
## 5. Système d'authentification (v4.0)
|
||||||
|
|
||||||
|
FlowDeck supporte **3 méthodes d'authentification** et la **connexion d'intégrations multiples** à un même compte local.
|
||||||
|
|
||||||
|
### 5.1 Types de comptes
|
||||||
|
|
||||||
|
| Type | Auth | Workspaces | Intégrations |
|
||||||
|
|------|------|------------|--------------|
|
||||||
|
| **Local pur** | email + password | Locaux uniquement | Aucune |
|
||||||
|
| **Local + intégrations** | email + password | Locaux + distants | Gitea et/ou GitHub |
|
||||||
|
| **OAuth pur** | Gitea ou GitHub | Distants uniquement | Non-déconnectable |
|
||||||
|
|
||||||
|
### 5.2 Comportement du sidebar selon le type de compte
|
||||||
|
|
||||||
|
```
|
||||||
|
LOCAL PUR:
|
||||||
|
├─ 📁 [nom workspace local]
|
||||||
|
├─ 📅 Meetings
|
||||||
|
├─ 🕒 Recents
|
||||||
|
├─ ⭐ Favorites
|
||||||
|
├─ 🤖 Agents
|
||||||
|
├─ 👥 Shared ← visible si pages partagées
|
||||||
|
├─ 🌐 Published ← visible si pages publiées
|
||||||
|
├─ (Private caché)
|
||||||
|
└─ 📚 Library / ✅ My Tasks / 🗑️ Trash / ❓ Help
|
||||||
|
|
||||||
|
LOCAL + GITEA/GITHUB:
|
||||||
|
├─ 🔗 [owner/repo] ← workspace distant actif
|
||||||
|
├─ 📅 Meetings
|
||||||
|
├─ 🕒 Recents
|
||||||
|
├─ ⭐ Favorites
|
||||||
|
├─ 🤖 Agents
|
||||||
|
├─ 👥 Shared
|
||||||
|
├─ 🌐 Published
|
||||||
|
├─ 🔒 Private ← visible (fichiers locaux liés au projet distant)
|
||||||
|
└─ 📚 / ✅ / 🗑️ / ❓
|
||||||
|
|
||||||
|
OAUTH PUR (GITEA/GITHUB):
|
||||||
|
├─ 🔗 [owner/repo] ← identité = compte Gitea/GitHub
|
||||||
|
├─ 🕒 Recents
|
||||||
|
├─ ⭐ Favorites
|
||||||
|
├─ 👥 Shared
|
||||||
|
├─ 🌐 Published
|
||||||
|
├─ (Private caché)
|
||||||
|
└─ 📚 / ✅ / ❓ (Trash caché — pas de workspace local)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Règles d'affichage
|
||||||
|
|
||||||
|
- **Section Private** : visible UNIQUEMENT si compte local + workspace distant actif
|
||||||
|
- **Trash** : visible si `auth_method == 'local'` ou si des pages locales existent
|
||||||
|
- **Boutons New File/New Folder** : cachés si `has_active_workspace == False`
|
||||||
|
- **Message "No workspace open"** : affiché dans la section Workspace si aucun workspace actif
|
||||||
|
- **Header sidebar** : toujours le compte local si auth_method=local, même avec intégrations
|
||||||
|
|
||||||
|
### 5.4 cookie `flowdeck_workspace`
|
||||||
|
|
||||||
|
- `""` → aucun workspace actif
|
||||||
|
- `"42"` → workspace local ID 42
|
||||||
|
- `"gitea:owner:repo"` → workspace distant Gitea
|
||||||
|
- Nettoyage automatique si cookie invalide (workspace supprimé ou autre user)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Frontend
|
||||||
|
|
||||||
### 5.1 Layout
|
### 5.1 Layout
|
||||||
|
|
||||||
```
|
```ascii
|
||||||
┌──────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
│ TOPBAR (44px, sticky) │
|
│ TOPBAR (unified-header) │
|
||||||
│ ┌──────────┬──────────────────────────────────┬────────────┐ │
|
│ ┌──────┬───────────────────────────────────────┬───────────┐ │
|
||||||
│ │ Logo │ Breadcrumbs: WS > Collection │ Share... │ │
|
│ │ «» │ Breadcrumbs: 🏠 Workspaces │ Share ⚙ │ │
|
||||||
│ │ FlowDeck │ (ou WS > Collection > Page) │ Settings │ │
|
│ └──────┴───────────────────────────────────────┴───────────┘ │
|
||||||
│ └──────────┴──────────────────────────────────┴────────────┘ │
|
|
||||||
├────────────┬─────────────────────────────────────────────────┤
|
├────────────┬─────────────────────────────────────────────────┤
|
||||||
│ SIDEBAR │ CONTENU PRINCIPAL │
|
│ SIDEBAR │ CONTENU PRINCIPAL │
|
||||||
│ (240px) │ │
|
│ (240px) │ │
|
||||||
│ │ ┌─────────────────────────────────────────────┐│
|
│ │ ┌─ Help Page ────────────────────────────────┐│
|
||||||
│ Workspace │ │ DATABASE VIEW ││
|
│ ┌────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ││
|
||||||
│ header │ │ ┌──────┬──────┬──────┬──────┬──────────┐ ││
|
│ │ Header │ │ │ │ 🚀 Getting │ │ 📝 Pages │ (...) ││
|
||||||
│ │ │ │Title │Statut│Priori│Assign│Due Date │ ││
|
│ │ [D] │ │ │ │ Started │ │ & Editor │ ││
|
||||||
│ 🔍 Search │ │ ├──────┼──────┼──────┼──────┼──────────┤ ││
|
│ │ Draco │ │ │ └──────────────┘ └──────────────┘ ││
|
||||||
│ │ │ │Tâche1│Todo │ P1 │Bruno │15 juil │ ││
|
│ └────────┘ │ └─────────────────────────────────────────────┘│
|
||||||
│ ▼ Recents │ │ │Tâche2│Doing │ P2 │ — │20 juil │ ││
|
│ │ │
|
||||||
│ · Proj A │ │ │Tâche3│Done │ P3 │Marie │10 juil │ ││
|
│ 🏠 💬 📨 🔍│ ┌─ Library ─────────────────────────────────┐│
|
||||||
│ · Proj B │ │ └──────┴──────┴──────┴──────┴──────────┘ ││
|
│ │ │ [Recents|Favorites|Shared|Published|...] ││
|
||||||
│ │ │ [+ New] [Filter] [Sort] [Search] [...] ││
|
│ 📁 Worksp. │ │ ┌──────┬──────────┬──────────┬───────────┐ ││
|
||||||
│ ▼ Private │ └─────────────────────────────────────────────┘│
|
│ 📄 New Pg │ │ │Name │Created by│Source │Last edited│ ││
|
||||||
│ · Notes │ │
|
│ 📁 New Fl │ │ ├──────┼──────────┼──────────┼───────────┤ ││
|
||||||
│ │ ┌─ Barre d'outils ────────────────────────────┐│
|
│ 📄 page 1 │ │ │doc A │Draco │Local │10 min ago │ ││
|
||||||
│ ▼ Shared │ │ [Table▼] [Kanban] [Calendar] [Gallery] ... ││
|
│ 📄 page 2 │ │ │doc B │bruno │Gitea │2 hours ago│ ││
|
||||||
│ · ... │ │ [Group▼] [Filter▼] [Sort▼] [Properties▼] ││
|
│ │ │ └──────┴──────────┴──────────┴───────────┘ ││
|
||||||
|
│ 📅 Meeting │ └─────────────────────────────────────────────┘│
|
||||||
|
│ 🕒 Recents │ │
|
||||||
|
│ ⭐ Favoris │ ┌─ Workspaces ───────────────────────────────┐│
|
||||||
|
│ 🤖 Agents │ │ ┌──────────┐ ┌──────────┐ ┌───┐ ┌───┐ ││
|
||||||
|
│ 👥 Shared │ │ │Project A │ │Project B │ │ + │ │ + │ ││
|
||||||
|
│ 🌐 Publ. │ │ │3 pages │ │12 pages │ │ │ │ │ ││
|
||||||
|
│ │ │ └──────────┘ └──────────┘ └───┘ └───┘ ││
|
||||||
|
│ ══════════ │ └─────────────────────────────────────────────┘│
|
||||||
|
│ 📚 Library │ │
|
||||||
|
│ ✅ My Task │ ┌─ Settings ─────────────────────────────────┐│
|
||||||
|
│ 🗑️ Trash │ │ My Account | Admin | Integrations ││
|
||||||
|
│ ❓ Help │ │ [Upload photo] [Full Name] [Email] [...] ││
|
||||||
│ │ └─────────────────────────────────────────────┘│
|
│ │ └─────────────────────────────────────────────┘│
|
||||||
│ ────────── │ │
|
│ 💬 [+📄] │ │
|
||||||
│ [+New page]│ ┌─ Contenu de la vue ─────────────────────────┐│
|
|
||||||
│ (sticky │ │ (table / board / calendar / gallery / ...) ││
|
|
||||||
│ footer) │ └─────────────────────────────────────────────┘│
|
|
||||||
└────────────┴─────────────────────────────────────────────────┘
|
└────────────┴─────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -800,6 +917,257 @@ User authorize ──→ GET /auth/callback?code=xxx
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 6.5 Modèle de comptes & intégrations (v4.0)
|
||||||
|
|
||||||
|
### 6.5.1 Types de comptes
|
||||||
|
|
||||||
|
FlowDeck supporte 3 types de comptes, déterminés par `auth_method` dans la table `users` :
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┬──────────────────┬─────────────────────────────────────┐
|
||||||
|
│ auth_method │ Login via │ Identité dans le sidebar │
|
||||||
|
├──────────────┼──────────────────┼─────────────────────────────────────┤
|
||||||
|
│ local │ email + password │ Nom local (ex: "Draco") │
|
||||||
|
│ gitea │ OAuth2 Gitea │ Nom Gitea + badge 🦎 Gitea │
|
||||||
|
│ github │ OAuth2 GitHub │ Nom GitHub + badge 🐙 GitHub │
|
||||||
|
└──────────────┴──────────────────┴─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règle fondamentale** : Un compte local avec intégration(s) reste un compte LOCAL.
|
||||||
|
Le badge OAuth (🦎/🐙) n'apparaît QUE pour les comptes dont `auth_method` est le provider.
|
||||||
|
|
||||||
|
```
|
||||||
|
Compte LOCAL avec intégrations :
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ [D] Draco │ ← Nom local, PAS de badge
|
||||||
|
│ [email protected] │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
|
||||||
|
Compte GITEA (auth_method=gitea) :
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ [B] bruno [🦎 Gitea] │ ← Badge visible
|
||||||
|
│ [email protected] │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5.2 Matrice des intégrations
|
||||||
|
|
||||||
|
```
|
||||||
|
Compte principal Peut ajouter Déconnexion possible
|
||||||
|
─────────────────────────────────────────────────────────
|
||||||
|
local Gitea ✅ Oui
|
||||||
|
local GitHub ✅ Oui
|
||||||
|
local Gitea + GitHub ✅ Les deux
|
||||||
|
─────────────────────────────────────────────────────────
|
||||||
|
gitea GitHub ✅ GitHub ❌ Gitea
|
||||||
|
github Gitea ✅ Gitea ❌ GitHub
|
||||||
|
```
|
||||||
|
|
||||||
|
**Settings → Integrations** : Affiche toutes les intégrations avec bouton
|
||||||
|
Disconnect. Le provider principal (auth_method) n'a PAS de bouton Disconnect.
|
||||||
|
|
||||||
|
### 6.5.3 Scénarios de workspace
|
||||||
|
|
||||||
|
```
|
||||||
|
SCÉNARIO A : Compte local pur
|
||||||
|
─────────────────────────────
|
||||||
|
· Login email+password
|
||||||
|
· Workspaces locaux uniquement
|
||||||
|
· Section Private : CACHÉE (tout est déjà local)
|
||||||
|
· Trash : ✅ (restauration locale)
|
||||||
|
|
||||||
|
SCÉNARIO B : Compte local + intégration(s)
|
||||||
|
──────────────────────────────────────────
|
||||||
|
· Login email+password
|
||||||
|
· Workspaces : locaux + Gitea + GitHub
|
||||||
|
· Section Private : VISIBLE si workspace distant actif
|
||||||
|
· Private : stockage DB locale, associé user+repo
|
||||||
|
· Trash : ✅ (local uniquement)
|
||||||
|
|
||||||
|
SCÉNARIO C : Compte OAuth pur (gitea ou github)
|
||||||
|
────────────────────────────────────────────────
|
||||||
|
· Login via OAuth
|
||||||
|
· Workspaces : distants du provider principal
|
||||||
|
· Section Private : VISIBLE (toujours)
|
||||||
|
· Trash : ❌ (pas de contenu local)
|
||||||
|
· Intégration secondaire : possible
|
||||||
|
· Déconnexion provider principal : impossible
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5.4 Sidebar — règles d'affichage par section
|
||||||
|
|
||||||
|
```
|
||||||
|
Section Scénario A Scénario B Scénario C
|
||||||
|
───────────── ────────────── ────────────────────── ──────────────
|
||||||
|
Recents ✅ (local) ✅ (tout) ✅ (tout)
|
||||||
|
Favorites ✅ (local) ✅ (tout) ✅ (tout)
|
||||||
|
Shared ✅ (local) ✅ (tout) ✅ (tout)
|
||||||
|
Published ✅ (local) ✅ (tout) ✅ (tout)
|
||||||
|
Agents ✅ ✅ ✅
|
||||||
|
Private ❌ CACHÉ ✅ si ws distant actif ✅
|
||||||
|
Workspace ✅ ✅ ✅
|
||||||
|
Trash ✅ (local) ✅ (local) ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque section a un bouton `→ Library` qui ouvre :
|
||||||
|
```
|
||||||
|
/library?tab=recents|favorites|shared|published|private|workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5.5 Système Share & Publish
|
||||||
|
|
||||||
|
Le bouton [Share] dans la topbar d'une page ouvre une modale à 2 tabs :
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ [ Share ] [ Publish ] │
|
||||||
|
├──────────────────────────────────────────────────────┤
|
||||||
|
│ Share tab : │
|
||||||
|
│ · Invite people : [email] [Invite] │
|
||||||
|
│ · General access : [🔒 Restricted] Copy link │
|
||||||
|
│ · Liste des personnes/groupes avec accès │
|
||||||
|
│ → Document listé dans sidebar "Shared" │
|
||||||
|
├──────────────────────────────────────────────────────┤
|
||||||
|
│ Publish tab : │
|
||||||
|
│ · [Publish to web] → URL publique générée │
|
||||||
|
│ · URL : https://flowdeck.draco.dev/p/<slug> │
|
||||||
|
│ · [Unpublish] │
|
||||||
|
│ → Document listé dans sidebar "Published" │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5.6 Stockage des fichiers Private
|
||||||
|
|
||||||
|
Quand un workspace distant est actif, les fichiers créés dans la section
|
||||||
|
Private sont stockés dans la **base de données locale** (table `pages` avec
|
||||||
|
`parent_section='Private'` et `workspace=gitea:<owner>/<repo>`).
|
||||||
|
|
||||||
|
Cela permet de :
|
||||||
|
- Prendre des notes personnelles liées à un projet sans les committer
|
||||||
|
- Garder des brouillons avant de les pousser
|
||||||
|
- Avoir un espace de travail privé même sur un repo partagé
|
||||||
|
|
||||||
|
### 6.5.7 Trash — règles
|
||||||
|
|
||||||
|
```
|
||||||
|
Contenu Trash ? Comportement
|
||||||
|
─────────────────────── ──────── ──────────────────────────
|
||||||
|
Workspace local ✅ Supprimé → restaurable
|
||||||
|
Dossier/fichier local ✅ Supprimé → restaurable
|
||||||
|
Fichier Gitea/GitHub ❌ Commit "delete" → pas trash
|
||||||
|
Page distante ❌ Commit "delete" → pas trash
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5.8 Édition de fichiers — UI unifiée
|
||||||
|
|
||||||
|
```
|
||||||
|
Fichier LOCAL Fichier DISTANT (Gitea/GitHub)
|
||||||
|
┌─────────────────────────┐ ┌─────────────────────────────┐
|
||||||
|
│ Même UI d'édition │ │ Même UI d'édition │
|
||||||
|
│ Même rendu visuel │ │ Même rendu visuel │
|
||||||
|
│ Sauvegarde auto DB │ │ + Bouton [Commit] │
|
||||||
|
│ Pas de commit │ │ + Champ message commit │
|
||||||
|
└─────────────────────────┘ └─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5.9 Page Library — `/library?tab=<tab>`
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Library │
|
||||||
|
│ [Recents] [Favorites] [Shared] [Published] │
|
||||||
|
│ [Private] [Workspace] │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ Filtres : [All] [Local] [Gitea] [GitHub] │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 📄 Document X Local · modifié il y a 2h │ │
|
||||||
|
│ │ 📄 README.md Gitea · bruno/flowdeck │ │
|
||||||
|
│ │ 📄 Notes Local · Workspace Projet A │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5.10 Schéma DB — mises à jour pour v4.0
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- users : ajout auth_method
|
||||||
|
ALTER TABLE users ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'local';
|
||||||
|
-- 'local' | 'gitea' | 'github'
|
||||||
|
|
||||||
|
-- users : colonnes OAuth existantes
|
||||||
|
-- gitea_id INTEGER (NULL si pas Gitea)
|
||||||
|
-- github_id INTEGER (NULL si pas GitHub)
|
||||||
|
-- gitea_token TEXT (token intégration, NULL si pas connecté)
|
||||||
|
-- github_token TEXT (token intégration, NULL si pas connecté)
|
||||||
|
|
||||||
|
-- pages : ajout published
|
||||||
|
ALTER TABLE pages ADD COLUMN is_published BOOLEAN NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE pages ADD COLUMN publish_slug TEXT;
|
||||||
|
ALTER TABLE pages ADD COLUMN is_shared BOOLEAN NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'restricted';
|
||||||
|
-- 'restricted' | 'link' | 'public'
|
||||||
|
|
||||||
|
-- page_shares : qui a accès à quel document
|
||||||
|
CREATE TABLE page_shares (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||||
|
shared_with_user_id INTEGER REFERENCES users(id),
|
||||||
|
shared_with_email TEXT,
|
||||||
|
permission TEXT NOT NULL DEFAULT 'view',
|
||||||
|
-- 'view' | 'comment' | 'edit'
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INTEGER REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- recents : historique de consultation
|
||||||
|
CREATE TABLE recents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
page_id INTEGER NOT NULL REFERENCES pages(id),
|
||||||
|
workspace TEXT NOT NULL,
|
||||||
|
source_type TEXT NOT NULL DEFAULT 'local',
|
||||||
|
-- 'local' | 'gitea' | 'github'
|
||||||
|
accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, page_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5.11 Plan de migration
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 — DB & Modèles
|
||||||
|
1. Ajouter auth_method aux users existants (auto-détecter)
|
||||||
|
2. Ajouter is_published, is_shared, share_mode aux pages
|
||||||
|
3. Créer table page_shares
|
||||||
|
4. Créer table recents
|
||||||
|
|
||||||
|
Phase 2 — Sidebar
|
||||||
|
1. Badge OAuth (🦎/🐙) basé sur auth_method
|
||||||
|
2. Afficher/cacher Private selon type de compte + workspace actif
|
||||||
|
3. Boutons Library par section
|
||||||
|
4. Trash filtré local-only
|
||||||
|
|
||||||
|
Phase 3 — Share & Publish
|
||||||
|
1. Modale Share à 2 tabs fonctionnelle
|
||||||
|
2. Share via email/user → table page_shares
|
||||||
|
3. Share via lien → share_mode='link'
|
||||||
|
4. Publish → is_published + publish_slug
|
||||||
|
5. Page publique servable à /p/<slug>
|
||||||
|
|
||||||
|
Phase 4 — Library
|
||||||
|
1. Page /library avec tabs
|
||||||
|
2. Filtrage par source (local/gitea/github)
|
||||||
|
3. Recents alimentés automatiquement
|
||||||
|
|
||||||
|
Phase 5 — Intégrations
|
||||||
|
1. GitHub OAuth déjà fait (providers.py)
|
||||||
|
2. Matrice de déconnexion respectée
|
||||||
|
3. Compte local peut connecter/déconnecter Gitea+GitHub
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 7. Intégration Gitea
|
## 7. Intégration Gitea
|
||||||
|
|
||||||
### 7.1 Flux de données
|
### 7.1 Flux de données
|
||||||
@@ -1302,7 +1670,41 @@ docker compose restart flowdeck
|
|||||||
- [Notion Help — Databases](https://www.notion.com/help/intro-to-databases)
|
- [Notion Help — Databases](https://www.notion.com/help/intro-to-databases)
|
||||||
- [Notion Help — Tasks & Dependencies](https://www.notion.com/help/tasks-and-dependencies)
|
- [Notion Help — Tasks & Dependencies](https://www.notion.com/help/tasks-and-dependencies)
|
||||||
- [Notion Help — Relations & Rollups](https://www.notion.com/help/relations-and-rollups)
|
- [Notion Help — Relations & Rollups](https://www.notion.com/help/relations-and-rollups)
|
||||||
- [Notion Data Sources (2025)](https://www.notionapps.com/blog/notion-data-sources-update-2025/)
|
|
||||||
- [react-notion-x](https://github.com/NotionX/react-notion-x)
|
|
||||||
- [FlowDeck — NOTION_DATABASE_TASKS_GUIDE.md](docs/NOTION_DATABASE_TASKS_GUIDE.md)
|
- [FlowDeck — NOTION_DATABASE_TASKS_GUIDE.md](docs/NOTION_DATABASE_TASKS_GUIDE.md)
|
||||||
- [FlowDeck — ROADMAP.md](ROADMAP.md)
|
- [FlowDeck — ROADMAP.md](ROADMAP.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Versions à venir
|
||||||
|
|
||||||
|
### v4.1.0 — Content Blocks enrichis (priorité standard)
|
||||||
|
|
||||||
|
- **Callout boxes** — Blocs d'alerte (info, warning, tip, danger)
|
||||||
|
- **Table of Contents** — Auto-généré depuis les headings
|
||||||
|
- **LaTeX / KaTeX** — Formules mathématiques
|
||||||
|
- **Toggle lists** — Listes pliables/dépliables
|
||||||
|
- **Multi-columns** — Mise en page 2-3 colonnes
|
||||||
|
|
||||||
|
### v4.2.0 — Export (priorité standard)
|
||||||
|
|
||||||
|
- Export **Markdown** avec images
|
||||||
|
- Export **PDF** (via WeasyPrint ou headless Chrome)
|
||||||
|
- Export **HTML** standalone (page auto-suffisante)
|
||||||
|
|
||||||
|
### v4.3.0 — Collaboration (priorité basse)
|
||||||
|
|
||||||
|
- **Commentaires inline** sur les pages
|
||||||
|
- **@mentions** pour notifier des utilisateurs
|
||||||
|
- **Notifications email** (changements, mentions)
|
||||||
|
- **Permissions par workspace** (viewer, editor, admin)
|
||||||
|
|
||||||
|
### v5.0.0 — Plateforme avancée (futur)
|
||||||
|
|
||||||
|
- **AI Assistants** — Génération de contenu, résumés, suggestions
|
||||||
|
- **Embeds** — Vidéos, PDF, Figma, Google Docs
|
||||||
|
- **Automatisations** — Règles déclenchées sur événements (Notion-style)
|
||||||
|
- **Base de données avancée** — Relations inter-collections, rollups
|
||||||
|
- **Kanban flexible** — Colonnes custom, WIP limits
|
||||||
|
- **API publique REST** — Tokens d'accès pour intégrations tierces
|
||||||
|
- **Volume Docker persistant** — `/data` monté pour survie des données
|
||||||
|
- **PostgreSQL** — Migration optionnelle pour scaling
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Stratégie de branches — FlowDeck
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
main ──●────────────●────── production (tags vX.Y.Z)
|
||||||
|
\ /
|
||||||
|
develop ●──●──●──●────── intégration continue
|
||||||
|
\ \ \
|
||||||
|
feat/xxx ● ● ●──── feature branches
|
||||||
|
fix/xxx ●─────────── hotfix branches
|
||||||
|
```
|
||||||
|
|
||||||
|
## Branches
|
||||||
|
|
||||||
|
| Branche | Rôle | Déploiement | Protection |
|
||||||
|
|---------|------|-------------|------------|
|
||||||
|
| `main` | Production | Docker auto sur :8080 | Push direct interdit, PR only |
|
||||||
|
| `develop` | Intégration | Staging (optionnel) | Push direct interdit, PR only |
|
||||||
|
| `feat/<nom>` | Nouvelle feature | Aucun | Libre |
|
||||||
|
| `fix/<nom>` | Correctif urgent | Aucun | Libre |
|
||||||
|
|
||||||
|
## Workflow quotidien
|
||||||
|
|
||||||
|
### 1. Démarrer une feature
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout develop
|
||||||
|
git pull origin develop
|
||||||
|
git checkout -b feat/ma-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Travailler
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Coder, commit souvent
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat: description claire de ce qui est fait"
|
||||||
|
git push origin feat/ma-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Créer une Pull Request
|
||||||
|
|
||||||
|
Aller sur https://git.dracodev.net/bruno/flowdeck/pulls
|
||||||
|
- **Base** : `develop`
|
||||||
|
- **Head** : `feat/ma-feature`
|
||||||
|
- Titre : descriptif
|
||||||
|
- Assigner un reviewer si applicable
|
||||||
|
|
||||||
|
### 4. Après merge
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout develop
|
||||||
|
git pull origin develop
|
||||||
|
git branch -d feat/ma-feature # supprimer la branche locale
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release (develop → main)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. S'assurer que develop est prêt
|
||||||
|
git checkout develop
|
||||||
|
git pull origin develop
|
||||||
|
|
||||||
|
# 2. Créer une PR develop → main sur Gitea
|
||||||
|
# (ou merger localement si admin)
|
||||||
|
git checkout main
|
||||||
|
git merge develop
|
||||||
|
git tag v4.0.1
|
||||||
|
git push origin main --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hotfix urgent
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout main
|
||||||
|
git checkout -b fix/urgence
|
||||||
|
# ... corriger ...
|
||||||
|
git commit -m "fix: description"
|
||||||
|
git push origin fix/urgence
|
||||||
|
# PR fix/urgence → main
|
||||||
|
# PUIS merger main → develop pour synchroniser
|
||||||
|
git checkout develop
|
||||||
|
git merge main
|
||||||
|
git push origin develop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions de commits
|
||||||
|
|
||||||
|
| Préfixe | Usage | Exemple |
|
||||||
|
|---------|-------|---------|
|
||||||
|
| `feat:` | Nouvelle fonctionnalité | `feat: landing page for visitors` |
|
||||||
|
| `fix:` | Correction de bug | `fix: redirect loop on /` |
|
||||||
|
| `refactor:` | Restructuration sans changement fonctionnel | `refactor: extract sidebar_data` |
|
||||||
|
| `test:` | Ajout/modification de tests | `test: onboarding flow e2e` |
|
||||||
|
| `docs:` | Documentation | `docs: update ROADMAP.md` |
|
||||||
|
| `style:` | Formatage, CSS | `style: mobile sidebar fixes` |
|
||||||
|
| `chore:` | Tâches de maintenance | `chore: bump version to 4.0.1` |
|
||||||
|
|
||||||
|
## Règles
|
||||||
|
|
||||||
|
1. **Jamais de push direct sur `main`** — toujours via PR depuis `develop` ou `fix/*`
|
||||||
|
2. **Jamais de push direct sur `develop`** — toujours via PR depuis `feat/*` ou `fix/*`
|
||||||
|
3. **Une branche = une feature / un fix** — éviter les branches fourre-tout
|
||||||
|
4. **Tests passent avant merge** — CI Gitea Actions doit être verte
|
||||||
|
5. **Commit + push après chaque modification significative**
|
||||||
|
6. **Nom de branche en kebab-case** : `feat/landing-page`, `fix/login-redirect`
|
||||||
|
7. **Supprimer la branche après merge** (sauf `main` et `develop`)
|
||||||
@@ -1,54 +1,196 @@
|
|||||||
# Changelog — FlowDeck
|
# Changelog — FlowDeck
|
||||||
|
|
||||||
|
## v2.7.1 (2026-07-13) — Private Pages Gitea
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Table `gitea_private_pages`**: pages FlowDeck locales liées à un projet Gitea (user_id, owner, repo)
|
||||||
|
- **API CRUD**: GET/POST/PUT/DELETE `/api/gitea/projects/{owner}/{repo}/private-pages`
|
||||||
|
- **UI**: liste des pages privées + éditeur titre/contenu dans le workspace Gitea
|
||||||
|
- **Sidebar**: lien "Private Pages" dans l'arborescence du workspace
|
||||||
|
- **Persistance**: les pages survivent au changement de workspace / session
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `get_user_repos(limit=100)` — `bruno/flowdeck` était tronqué à la page 2 (30 repos par défaut)
|
||||||
|
|
||||||
|
## v2.7.0 (2026-07-13) — Intégration Gitea Phase 1
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **OAuth2 Provider**: Gitea comme fournisseur OAuth2 pour FlowDeck (Client ID/Secret dans `.env`)
|
||||||
|
- **Redirect URI dynamique**: basé sur le header `Host` — fonctionne depuis `localhost` et `192.168.x.x`
|
||||||
|
- **Workspace listing**: 2 sections — My Workspaces + Gitea Projects (groupés par organisation)
|
||||||
|
- **Tabs par org**: All | user | org1 | org2 avec compteurs de projets
|
||||||
|
- **Barre de recherche**: filtrage live par nom/description dans Gitea Projects
|
||||||
|
- **Lazy tree**: arborescence chargée un dossier à la fois (API Gitea `GET /repos/{o}/{r}/contents`)
|
||||||
|
- **File viewer**: lecture fichiers Gitea avec décodage base64
|
||||||
|
- **Éditeur + commit**: modifier un fichier et commiter dans Gitea avec message personnalisé
|
||||||
|
- **Delete fichier**: suppression avec confirmation + commit
|
||||||
|
- **Arborescence dans le sidebar**: comme un workspace local, avec lazy-load des sous-dossiers
|
||||||
|
- **Settings › Integrations**: connecter/déconnecter Gitea (`GET /api/gitea/status`, `DELETE /api/gitea/disconnect`)
|
||||||
|
- **Fallback token admin**: opérations GET utilisent le token serveur si pas de token OAuth utilisateur
|
||||||
|
- **Write ops protégées**: commit et delete exigent un token OAuth utilisateur
|
||||||
|
- **Routes API**: `/api/gitea/orgs`, `/api/gitea/projects`, `/api/gitea/projects/{o}/{r}/tree`, `/api/gitea/projects/{o}/{r}/file`, `/api/gitea/projects/{o}/{r}/labels`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Bouton "Register with Gitea" ne crée plus de session admin fantôme (fallback retiré)
|
||||||
|
- `_sidebar_data` gère le cookie `gitea:owner:repo` pour l'arborescence
|
||||||
|
|
||||||
|
## v2.6.0 (2026-07-13) — My Account & Auth Locale
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Section My Account**: modifier username, full name, email, mot de passe
|
||||||
|
- **Toggle mot de passe**: bouton voir/cacher dans les formulaires login/register
|
||||||
|
- **Label "Email or username"**: login accepte les deux formats
|
||||||
|
- **Refresh cookie session**: après modification du compte, le cookie est mis à jour
|
||||||
|
- **Register avec Gitea**: boutons dynamiques "Login/Register with Gitea" selon l'onglet actif
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Session cookie non rafraîchi après `PUT /api/settings/account` (création nouvelle session)
|
||||||
|
- Input login `type="email"` → `type="text"` (accepte usernames non-email)
|
||||||
|
|
||||||
|
## v2.5.0 (2026-07-13) — Administration Avancée
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Premier compte = admin automatique**: `is_admin=1` pour le premier utilisateur créé
|
||||||
|
- **Panneau Admin dans Settings**: visible uniquement pour les admins
|
||||||
|
- **Users & Roles**: créer/modifier/supprimer utilisateurs, changer rôles
|
||||||
|
- **Table `login_history`**: audit des connexions (IP, user_agent, timestamp)
|
||||||
|
- **Statistiques par utilisateur**: workspaces, pages, dernière connexion
|
||||||
|
- **API `/api/admin/*`**: users, stats, audit — protégées par `admin_required`
|
||||||
|
- **CSRF fix**: token CSRF dans toutes les requêtes admin (`adminFetch`)
|
||||||
|
|
||||||
|
## v2.4.0 (2026-07-13) — Tags Personnalisés par Utilisateur
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Tags isolés par `user_id`**: chaque utilisateur voit uniquement ses propres tags
|
||||||
|
- **`UNIQUE(name, user_id)`**: remplace `UNIQUE(name)` dans la table `tags`
|
||||||
|
- **Migration**: tags existants assignés au premier utilisateur
|
||||||
|
- **Filtrage transparent**: l'API retourne les tags filtrés par `user_id` (session cookie)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Affichage des tags dans le menu contextuel workspace (sync Alpine.js)
|
||||||
|
- Création de tag avec couleur persistée
|
||||||
|
- Fermeture auto du menu contextuel après ajout de tag
|
||||||
|
- Filtrage par tags: `toggleTagFilter()` appelle `doFilter()`
|
||||||
|
- Pastilles tags: fond = couleur du tag, sans rond séparé
|
||||||
|
|
||||||
|
## v2.3.0 (2026-07-12) — Multi-Vues Workspace & Preview Panel
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Sélecteur de vue**: Tree, List, Details, Title, Content — 5 modes d'affichage
|
||||||
|
- **Vue List**: liste compacte avec Name, Type, Size, Modified
|
||||||
|
- **Vue Details**: table complète avec Path, Author, Tags
|
||||||
|
- **Vue Title**: grille de cartes avec icônes et métadonnées
|
||||||
|
- **Vue Content**: liste avec aperçu inline (taille ou nombre d'items)
|
||||||
|
- **Preview Panel**: panneau latéral droit avec contenu du fichier sélectionné
|
||||||
|
- Header avec icône + titre + bouton fermer
|
||||||
|
- Métadonnées (type, taille, date)
|
||||||
|
- Tags affichés en chips
|
||||||
|
- Corps avec rendu du contenu (blocks JSON → HTML, texte brut)
|
||||||
|
- Actions: Open, Rename, Delete
|
||||||
|
- Animation slide-in/out
|
||||||
|
- **Tri par date**: options Oldest/Newest dans le selecteur de tri
|
||||||
|
|
||||||
|
## v2.2.0 (2026-07-12) — Tags & Recherche Avancée
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Tags**: système complet de tags avec tables `tags` + `page_tags`, CRUD API
|
||||||
|
- **API Tags**: `GET/POST/DELETE /api/local-workspace/items/{id}/tags`, `GET /api/local-workspace/tags`
|
||||||
|
- **Vue Table enrichie**: colonnes Name, Path, Size, Modified, Type, Author, Tags
|
||||||
|
- **Filtrage par tags**: barre de chips avec compteurs, clic pour filtrer
|
||||||
|
- **Ajout/retrait tags inline**: bouton + dans la table, chips avec × pour retirer
|
||||||
|
- **Tree endpoint enrichi**: chaque nœud retourne `size`, `size_display`, `created_at`, `updated_at`, `author`, `tags`
|
||||||
|
- **Filtre "Dossiers"**: nouveau chip Folders dans la barre de filtres
|
||||||
|
- **Auto-switch table**: quand recherche/filtre actif, passe en vue table automatiquement
|
||||||
|
|
||||||
|
## v2.1.1 (2026-07-12) — Fix Arborescence Workspace
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Arborescence**: création fichier/dossier dans un sous-dossier met à jour l'arbre sans rechargement
|
||||||
|
- **parentFolder**: corrigé le bug où `parentFolder` était nullifié avant l'insertion dans l'arbre JS
|
||||||
|
- **displayTree**: nouvelle référence array après chaque mutation pour forcer le re-render Alpine.js
|
||||||
|
- **doSort/doFilter**: gèrent maintenant le cas où un filtre/tri est actif après création
|
||||||
|
|
||||||
|
## v2.1.0 (2026-07-10) — Intégrations Avancées
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **API publique**: `/api/v1` (collections, pages, my-tasks) avec token auth
|
||||||
|
- **Token generation**: `POST /api/v1/token`
|
||||||
|
- **Webhooks sortants**: `GET/POST/DELETE /workspace/webhooks` + dispatcher
|
||||||
|
- **PWA**: `GET /manifest.json`
|
||||||
|
- **CSRF**: `/api/v1` exempté
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- 73/73 passent (+6 tests v2.1)
|
||||||
|
|
||||||
|
## v2.0.0 (2026-07-10) — Multi-User Workspaces & Editor Complete
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Workspaces**: tables `workspaces` + `workspace_members`, CRUD API, roles admin/editor/commenter/viewer
|
||||||
|
- **Comments**: table `comments` avec thread (parent_id), resolved
|
||||||
|
- **Page History**: table `page_history`, snapshot JSON
|
||||||
|
- **Favorites**: table `favorites`, add/remove/list
|
||||||
|
- **Templates**: `database_templates` + `page_templates`, apply API
|
||||||
|
- **CSV Import/Export**: `POST .../import/csv`, `GET .../export/csv`
|
||||||
|
- **Public Sharing**: `GET /workspace/public/{id}` (read-only, no auth)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- 67/67 passent (+7 tests v2.0)
|
||||||
|
|
||||||
|
## v1.9.0 (2026-07-10) — My Tasks Dashboard
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **My Tasks**: `GET /my-tasks` (HTML), `GET /my-tasks/api` (JSON)
|
||||||
|
- Agrégation cross-collection, vues All/Today/Overdue/Next 7 days
|
||||||
|
|
||||||
|
## v1.8.0 (2026-07-10) — Sub-items & Dependencies
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Sub-items**: parent_id auto-référence, GET/POST sub-items
|
||||||
|
- **Status aggregate**: `GET .../status-aggregate`
|
||||||
|
- **Dependencies**: `POST .../dependencies`, `POST .../check-deps`
|
||||||
|
|
||||||
|
## v1.7.0 (2026-07-10) — Vues Améliorées
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **View config**: `PUT /db/views/{id}/config` (group_by, card_size, cover, visible)
|
||||||
|
- **Save view as**: `POST /db/{id}/views/save-as`
|
||||||
|
- **List views**: `GET /db/{id}/views/api`
|
||||||
|
|
||||||
|
## v1.6.0 (2026-07-10) — Vues Manquantes
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Calendar**: grid mensuel, navigation mois
|
||||||
|
- **Gallery**: cartes visuelles, card_size configurable
|
||||||
|
- **List**: vue compacte avec preview
|
||||||
|
- **Timeline**: barres horizontales Gantt
|
||||||
|
- **Table**: rendu SSR avec colonnes properties
|
||||||
|
- **View tabs**: navigation Table/Board/Calendar/Gallery/List/Timeline
|
||||||
|
|
||||||
|
## v1.5.0 (2026-07-10) — Relations & Rollups
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **FormulaEngine**: 19 fonctions (prop, if, concat, round, now, today, dateAdd, replace, ...)
|
||||||
|
- **RollupEngine**: 12 agrégations (count, sum, avg, min, max, range, unique, percent_checked)
|
||||||
|
- **Relation**: lien bidirectionnel, API create/link
|
||||||
|
|
||||||
|
## v1.4.0 (2026-07-10) — Propriétés Avancées
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Table `collection_properties`**: 21 types Notion
|
||||||
|
- **Service `property_types.py`**: validation, formatage, auto-values
|
||||||
|
|
||||||
## v1.3.0 (2026-07-10) — Database Concept
|
## v1.3.0 (2026-07-10) — Database Concept
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Nouvelles tables**: `collections`, `collection_pages`, `collection_views` — fondation Database Notion
|
- **Tables**: `collections`, `collection_pages`, `collection_views`
|
||||||
- **Router `/db`**: API CRUD complète pour collections et pages
|
- **Router `/db`**: CRUD collections + pages (16 endpoints)
|
||||||
- `GET/POST /db/api` — lister/créer collections
|
- **GiteaBoardCompat**: adaptateur boards legacy → 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
|
## v1.0.0 (2026-07-08) — Production
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **UI Notion-complete**: sidebar 240px, topbar 44px, dark mode (#191919/#222/#333)
|
- FastAPI lifespan, 28 tests, CI/CD Gitea Actions, CSS 31KB, 10 templates
|
||||||
- **5 vues**: Kanban, Detailed Board, Table, Status Overview (SVG donut), Team Load (stacked bars)
|
- 5 vues: Kanban, Table, Status Overview, Team Load, Detailed Board
|
||||||
- **Filtres cumulables AND** avec status dropdown checkboxes, filtres par assignee/tag
|
- Filtres cumulables AND, tri multi-critères
|
||||||
- **Tri multi-critères** hiérarchique (N niveaux, asc/desc)
|
- Card detail modal, checklists, commentaires Gitea
|
||||||
- **Card detail modal**: titre éditable, propriétés, checklists, commentaires Gitea
|
- Propriétés custom, AI keywords, sync API
|
||||||
- **Création d'issues inline** depuis le board
|
|
||||||
- **Propriétés custom** par projet: select, multi_select, date, person, text
|
|
||||||
- **AI keywords**: extraction automatique depuis labels + body, table `ai_keywords`
|
|
||||||
- **Sync API**: `POST /board/api/sync/{owner}/{repo}` — bidirectionnelle complète
|
|
||||||
- **CI/CD**: Gitea Actions workflow
|
|
||||||
- **Tests**: 25 tests, couvrant toutes les vues, APIs, filtres, tris
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- FastAPI `on_event` → `lifespan` handler (deprecation fix)
|
|
||||||
- Version bump 0.3.0 → 1.0.0
|
|
||||||
- CSS: 31 KB design system Notion (700+ lignes)
|
|
||||||
- 10 templates Jinja2 (base, board, card, detail, table, status, team, detailed, fragment, dashboard)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Team Load bug: status key mismatch (done → complete)
|
|
||||||
- CSRF: 403 protection vérifiée sur toutes les routes POST
|
|
||||||
|
|
||||||
## v0.3.0 (2026-06-??)
|
|
||||||
- OAuth2 Gitea, CSRF, rate limiting, webhooks
|
|
||||||
|
|
||||||
## v0.2.0 (2026-05-??)
|
|
||||||
- Dashboard + Board Kanban + Gitea sync + Notes + Docker
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# FlowDeck
|
# FlowDeck
|
||||||
|
|
||||||
Clone de l'interface **Notion** intégré nativement à **Gitea** — Kanban, Table, Status Overview, Team Load.
|
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
|
||||||
|
|
||||||
> **v0.4.0-dev** — Refonte UI Notion-style (Dark Mode, sidebar, topbar, multi-vues)
|
> **v2.1.0** — API publique, Webhooks sortants, PWA
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -13,43 +13,65 @@ docker compose up -d
|
|||||||
# → http://localhost:8080
|
# → http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
## Features (v0.4.0)
|
## Features
|
||||||
|
|
||||||
### UI Notion-Style
|
### Database & Properties (v1.3–v1.5)
|
||||||
- **Sidebar gauche** avec sections hiérarchiques (Home, Recents, Private, Library)
|
- **Collections**: databases indépendantes de Gitea, schéma JSON
|
||||||
- **Topbar** avec titre de page, statut, boutons d'action (Share, favori, notifications)
|
- **21 types de propriétés**: title, text, number, select, status, date, person, checkbox, url, email, phone, files, unique_id, relation, rollup, formula + auto-props
|
||||||
- **Dark mode** Notion: `#191919` fond, `#222222` sidebar, `#333333` actif
|
- **Relations bidirectionnelles** + **Rollups** (12 agrégations) + **Formulas** (19 fonctions)
|
||||||
- **Menu contextuel** (clic droit): Copy link, Duplicate, Rename, Move to Trash...
|
- **GiteaBoardCompat**: adaptateur boards Gitea legacy → Collections
|
||||||
- **Hover states** subtils avec icônes d'action qui apparaissent
|
|
||||||
- **Typographie Inter**, coins arrondis 4-6px
|
|
||||||
|
|
||||||
### Vues Multiples
|
### Vues Multiples (v1.6–v1.7)
|
||||||
- **Kanban board**: colonnes To-do/In progress/Complete avec sous-groupes (Design, Engineering)
|
- **Table**: colonnes SSR, triables
|
||||||
- **Table view**: colonnes Name, Status, Assign, Deadline, Team, AI keywords
|
- **Board (Kanban)**: colonnes, groupes, drag & drop
|
||||||
- **Status overview**: Donut chart avec segments, compteurs, pourcentages
|
- **Calendar**: grid mensuel, navigation, événements
|
||||||
- **Team Load**: Barres empilées par membre d'équipe
|
- **Gallery**: cartes visuelles, card_size configurable
|
||||||
- **Detailed board**: cartes avec toutes les propriétés visibles
|
- **List**: vue compacte avec preview
|
||||||
|
- **Timeline**: barres Gantt horizontales
|
||||||
|
- **Status Overview**: Donut chart SVG
|
||||||
|
- **Team Load**: Barres empilées
|
||||||
|
|
||||||
### Filtres & Tri
|
### Sub-items & Dépendances (v1.8)
|
||||||
- **Filtres cumulables** (logique AND) avec pastilles cliquables
|
- **Sub-items**: parent_id auto-référence, hiérarchie illimitée
|
||||||
- **Tri multi-critères** hiérarchique
|
- **Status aggregate**: parent = Done si tous enfants Done
|
||||||
- **Filtre par statut**: checkboxes multiples avec points colorés
|
- **Dependencies**: Blocking/Blocked by, contrainte de transition
|
||||||
- **Filtre AI keywords**: recherche + sélection multiple
|
|
||||||
|
### My Tasks (v1.9)
|
||||||
|
- Dashboard cross-collection agrégeant toutes les tâches assignées
|
||||||
|
- Vues: All, Today, Overdue, Next 7 days
|
||||||
|
|
||||||
|
### Multi-User & Collaboratif (v2.0)
|
||||||
|
- **Workspaces**: espaces partagés avec rôles (admin, editor, commenter, viewer)
|
||||||
|
- **Comments**: commentaires threadés sur les pages
|
||||||
|
- **Page History**: historique des modifications avec snapshots
|
||||||
|
- **Favorites**: favoris par utilisateur
|
||||||
|
- **Templates**: database + page templates
|
||||||
|
- **CSV Import/Export**
|
||||||
|
- **Public Sharing**: lien de partage lecture seule
|
||||||
|
|
||||||
|
### API & Intégrations (v2.1)
|
||||||
|
- **API publique REST**: `/api/v1` avec token auth
|
||||||
|
- **Webhooks sortants**: gestion + dispatcher d'événements
|
||||||
|
- **PWA**: manifest.json, prêt pour installation mobile
|
||||||
|
|
||||||
|
### UI Notion-Style (v1.1–v1.2)
|
||||||
|
- Sidebar gauche avec sections hiérarchiques
|
||||||
|
- Topbar avec breadcrumbs (workspace > projet > page)
|
||||||
|
- Dark mode Notion: `#191919` fond, `#222222` sidebar
|
||||||
|
- Éditeur de blocs: slash menu (/), navigation clavier, placeholders
|
||||||
|
|
||||||
### Intégration Gitea
|
### Intégration Gitea
|
||||||
- **Issues Gitea → cartes** Notion-style
|
- Issues Gitea → cartes, sync labels, milestones, assignees, due dates
|
||||||
- Sync labels, milestones, assignees, due dates
|
- OAuth2 Gitea, CSRF, rate limiting, webhooks
|
||||||
- OAuth2 Gitea, CSRF, rate limiting
|
|
||||||
- Webhooks pour sync temps réel
|
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
| Couche | Techno |
|
| Couche | Techno |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| Frontend | HTML5 + Jinja2 + CSS Notion, Alpine.js, HTMX, SortableJS |
|
| Frontend | Jinja2 + HTMX + Alpine.js + SortableJS + CSS |
|
||||||
| Backend | Python 3.12 + FastAPI + httpx |
|
| Backend | Python 3.12 + FastAPI + httpx |
|
||||||
| BDD | SQLite (WAL mode) — `/data/flowdeck.db` |
|
| BDD | SQLite (WAL, 21 tables) — `/data/flowdeck.db` |
|
||||||
| Déploiement | Docker (Python 3.12-slim), docker-compose |
|
| Déploiement | Docker (python:3.12-slim), docker-compose |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -60,19 +82,16 @@ APP_PORT=8080
|
|||||||
DATABASE_URL=sqlite:////data/flowdeck.db
|
DATABASE_URL=sqlite:////data/flowdeck.db
|
||||||
```
|
```
|
||||||
|
|
||||||
## Roadmap
|
## Tests
|
||||||
|
|
||||||
Voir [ROADMAP.md](ROADMAP.md) — v0.4.0 UI Notion → v1.0.0 Production
|
|
||||||
|
|
||||||
## Développement
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv venv
|
python3 -m pytest tests/ -v # 73/73 passent
|
||||||
source venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
uvicorn app.main:app --reload --port 8080
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
Voir [ROADMAP.md](ROADMAP.md) — 7/7 blocs complétés, 52/52 features ✅
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -1,7 +1,40 @@
|
|||||||
# Roadmap FlowDeck — Notion Clone
|
# Roadmap FlowDeck — Notion Clone
|
||||||
|
|
||||||
> **Vision**: Copie conforme de l'UI/UX Notion, intégrée à Gitea.
|
> **Vision**: Copie conforme de l'UI/UX Notion, intégrée à Gitea/GitHub.
|
||||||
> **19 images de référence** dans `images/` — analysées le 2026-07-08.
|
> **MVP en production** : v4.0.0 — authentification locale/OAuth, pages blocs, sidebar, library, share/publish, intégrations.
|
||||||
|
> **Branche principale** : `main` (production) · `develop` (intégration) · `feat/*` (features).
|
||||||
|
|
||||||
|
## 🏗️ Stratégie de branches
|
||||||
|
|
||||||
|
```
|
||||||
|
main ──●────────────●────── production (tags vX.Y.Z)
|
||||||
|
\ /
|
||||||
|
develop ●──●──●──●────── intégration continue
|
||||||
|
\ \ \
|
||||||
|
feat/xxx ● ● ●──── feature branches
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`main`** : code en production, déploiement Docker automatique
|
||||||
|
- **`develop`** : branche d'intégration, merges des `feat/*`
|
||||||
|
- **`feat/<nom>`** : une branche par feature/fix, PR → code review → merge dans `develop`
|
||||||
|
- **Tags** `vX.Y.Z` sur `main` pour chaque release
|
||||||
|
|
||||||
|
**Workflow** :
|
||||||
|
```bash
|
||||||
|
git checkout -b feat/mon-truc develop
|
||||||
|
# ... coder, commit, push ...
|
||||||
|
# Créer une PR feat/mon-truc → develop sur Gitea
|
||||||
|
# Après review + CI verte → merge
|
||||||
|
# Pour release: PR develop → main + tag vX.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règles** :
|
||||||
|
- Ne jamais commiter directement sur `main`
|
||||||
|
- Une branche = une feature / un fix
|
||||||
|
- Tests passent avant merge (CI Gitea Actions)
|
||||||
|
- Commit + push après chaque modification significative
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Completed
|
## Completed
|
||||||
|
|
||||||
@@ -54,168 +87,345 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
|||||||
- [x] Heading 4 support
|
- [x] Heading 4 support
|
||||||
- [x] Breadcrumbs contextuels (workspace > projet > page)
|
- [x] Breadcrumbs contextuels (workspace > projet > page)
|
||||||
|
|
||||||
|
### v1.3.0 ✅ — Database Concept
|
||||||
|
- [x] Tables `collections`, `collection_pages`, `collection_views`
|
||||||
|
- [x] Rétrocompatibilité Gitea via `GiteaBoardCompat`
|
||||||
|
- [x] Router `/db` — API CRUD collections + pages (12 endpoints)
|
||||||
|
- [x] 34/34 tests passent
|
||||||
|
|
||||||
|
### v1.4.0 ✅ — Propriétés Avancées
|
||||||
|
- [x] Table `collection_properties` — 21 types Notion
|
||||||
|
- [x] Service `property_types.py` — validation, formatage, auto-values
|
||||||
|
- [x] 38/38 tests passent
|
||||||
|
|
||||||
|
### v1.5.0 ✅ — Relations & Rollups
|
||||||
|
- [x] Type `relation` — lien bidirectionnel entre collections
|
||||||
|
- [x] Type `rollup` — aggregation via relation (COUNT, SUM, AVG, etc.)
|
||||||
|
- [x] Type `formula` — moteur d'expressions (19 fonctions)
|
||||||
|
- [x] Formula engine + Rollup engine
|
||||||
|
- [x] 43/43 tests passent
|
||||||
|
|
||||||
|
### v1.6.0 ✅ — Vues Manquantes
|
||||||
|
- [x] Calendar, Gallery, List, Timeline, Table views
|
||||||
|
- [x] View tabs navigation
|
||||||
|
- [x] 49/49 tests passent
|
||||||
|
|
||||||
|
### v1.7.0 ✅ — Vues Améliorées
|
||||||
|
### v1.8.0 ✅ — Sub-items & Dépendances
|
||||||
|
- [x] View config API, Save view as
|
||||||
|
- [x] Sub-items (parent_id auto-référence)
|
||||||
|
- [x] Status aggregate, Dependencies (blocking constraint)
|
||||||
|
- [x] 56/56 tests passent
|
||||||
|
|
||||||
|
### v1.9.0 ✅ — My Tasks & Dashboard Unifié
|
||||||
|
- [x] Vue `/my-tasks` — agrège pages assignées à l'utilisateur
|
||||||
|
- [x] Groupement par collection, filtres globaux
|
||||||
|
- [x] 60/60 tests passent
|
||||||
|
|
||||||
|
### v2.0.0 ✅ — Éditeur Complet & Multi-Utilisateurs
|
||||||
|
- [x] Workspaces, Comments, Page History, Favorites
|
||||||
|
- [x] Database Templates, Page Templates, CSV Import/Export
|
||||||
|
- [x] Public Sharing
|
||||||
|
- [x] 67/67 tests passent
|
||||||
|
|
||||||
|
### v2.1.0 ✅ — Workspace Local & Arborescence
|
||||||
|
### v2.2.0 ✅ — Tags & Recherche Avancée
|
||||||
|
### v2.3.0 ✅ — Multi-Vues Workspace & Preview Panel
|
||||||
|
### v2.4.0 ✅ — Tags Personnalisés par Utilisateur
|
||||||
|
### v2.5.0 ✅ — Administration Avancée
|
||||||
|
### v2.6.0 ✅ — My Account & Auth Locale
|
||||||
|
### v2.7.0 ✅ — Intégration Gitea Phase 1
|
||||||
|
### v2.7.1 ✅ — Private Pages Gitea
|
||||||
|
|
||||||
|
### v2.8.0 ✅ — Gitea Phase 2
|
||||||
|
- [x] Créer fichiers dans le repo (New file UI + API PUT)
|
||||||
|
- [x] Upload fichiers binaires (POST /upload, drag-drop)
|
||||||
|
- [x] Syntax highlighting via Prism.js CDN
|
||||||
|
- [x] Labels Gitea → tags FlowDeck (POST /sync-labels, 16 labels importés)
|
||||||
|
- [x] Historique commits par fichier (GET /commits?path=)
|
||||||
|
|
||||||
|
### v2.9.0 ✅ — GitHub OAuth Provider
|
||||||
|
- [x] GitHubProvider dans `app/auth/providers.py`
|
||||||
|
- [x] GitHubAdapter dans `app/services/github_adapter.py`
|
||||||
|
- [x] Config GitHub dans `.env.example`
|
||||||
|
|
||||||
|
### v3.0.0 ✅ — UX Professionnelle
|
||||||
|
- [x] Multi-sélection (checkboxes + Shift/Ctrl-click dans le sidebar)
|
||||||
|
- [x] Raccourcis clavier (Ctrl+N, F2, Delete, Escape, Ctrl+S)
|
||||||
|
- [x] Inline rename (double-clic → champ inline)
|
||||||
|
- [x] Loading skeletons (animations pulse dans workspaces + file viewer)
|
||||||
|
- [x] Grid/card view (mode grille/vignettes)
|
||||||
|
- [x] Table sorting (tri par colonnes)
|
||||||
|
- [x] Sélecteur de vue: Tree, List, Details, Title, Content (5 modes)
|
||||||
|
- [x] Preview Panel latéral (contenu, métadonnées, tags, actions)
|
||||||
|
- [x] Tri par date (Oldest/Newest)
|
||||||
|
|
||||||
|
### v4.0.0 ✅ — Accounts, Integrations & Sharing (MVP)
|
||||||
|
- [x] `auth_method` sur users (local/gitea/github)
|
||||||
|
- [x] Badge OAuth dans sidebar
|
||||||
|
- [x] Settings page avec Connect/Disconnect Gitea + GitHub
|
||||||
|
- [x] Sidebar rules (Private = distant seulement, Trash local-only)
|
||||||
|
- [x] Library page avec tabs + filtres source (All/Local/Gitea/GitHub)
|
||||||
|
- [x] Modale Share (2 tabs: Share + Publish)
|
||||||
|
- [x] Page publique `/p/<slug>` (no auth, rendu HTML)
|
||||||
|
- [x] Table `page_shares`, colonnes `is_published`, `is_shared`, `share_mode`
|
||||||
|
- [x] Table `recents` — tracking automatique
|
||||||
|
- [x] Édition unifiée local/distant (même UI, bouton Commit pour Gitea)
|
||||||
|
- [x] 133 tests passent
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Upcoming
|
## 🔜 Prochaines versions
|
||||||
|
|
||||||
### v1.3.0 — Database Concept (Bloc 1 du gap analysis)
|
### v4.0.1 — Onboarding & Polish ✅ (2026-07-18)
|
||||||
|
> **Objectif** : rendre l'app utilisable dès la première visite, sans friction. **COMPLETED**.
|
||||||
|
|
||||||
- [ ] **Table `collections`** — base de données abstraite, indépendante de Gitea
|
- [x] **Landing page `/` pour visiteurs non-auth** — template `landing.html`, design Notion dark
|
||||||
- `id`, `name`, `description`, `icon`, `schema_json`
|
- [x] **Page `/auth/register` GET** — formulaire d'inscription dédié (tab register actif)
|
||||||
- Optionnellement liée à Gitea (`gitea_owner`, `gitea_repo`)
|
- [x] **Redirection `/` intelligente** — non-auth → landing, auth sans Gitea → local workspace, auth+Gitea → dashboard
|
||||||
- Supporte les templates de database
|
- [x] **Empty states** — déjà présents dans local workspace (« Welcome to your Workspace »)
|
||||||
- [ ] **Table `collection_pages`** — remplace `cards`, pages génériques
|
- [x] **Page 404 stylisée** — thème Notion sombre, JSON pour `/api/*`, HTML pour le reste
|
||||||
- `collection_id`, `title`, `position`, `parent_id`, `property_values_json`
|
- [x] **Unifier les routes API** — `/api/pages` GET/POST → 307 redirect vers `/board/api/pages`
|
||||||
- `gitea_issue_id` optionnel pour les pages liées à Gitea
|
- [x] **Page Settings** — lien déjà présent dans le menu utilisateur (Settings → `/accounts/settings`)
|
||||||
- Auto-propriétés : `created_at`, `updated_at`, `created_by`, `updated_by`
|
|
||||||
- [ ] **Table `collection_views`** — vues configurables par collection
|
|
||||||
- `name`, `view_type` (table/board/calendar/timeline/gallery/list)
|
|
||||||
- `config_json` (filtres, tris, group_by, visible_properties, card_size...)
|
|
||||||
- `position` pour l'ordre des onglets
|
|
||||||
- [ ] **Rétrocompatibilité Gitea** — adaptateur `GiteaBoardCompat`
|
|
||||||
- Les boards Gitea existants deviennent des collections automatiquement
|
|
||||||
- Migration transparente des `cards` → `collection_pages`
|
|
||||||
- [ ] **Database lock** — mode lecture seule (`collections.is_locked`)
|
|
||||||
- [ ] **Database description** — texte riche au-dessus de la database
|
|
||||||
|
|
||||||
### v1.4.0 — Propriétés Avancées (Bloc 2)
|
### v4.0.2 — Qualité & Robustesse ✅ (2026-07-18)
|
||||||
|
> **Objectif** : zéro crash, zéro regression, DX impeccable.
|
||||||
|
|
||||||
- [ ] **Table `collection_properties`** — remplace `project_properties`
|
- [x] **Gestion d'erreurs** — try/catch dans create_page, message user-friendly (500 JSON)
|
||||||
- Liée à `collections` (pas à `project_owner/name`)
|
- [x] **Validation inputs** — login/register déjà validés, titre vide → "Untitled"
|
||||||
- Supporte tous les types Notion
|
- [x] **Rate limiting** — 100 req/min/IP déjà en place, testé OK
|
||||||
- [ ] **Type `number`** — avec formatage (nombre, %, €, $, £, ¥)
|
- [x] **Session expiry UX** — redirect avec ?expired=1, bannière sur la page login
|
||||||
- [ ] **Type `checkbox`** — booléen natif
|
- [x] **CSRF token refresh** — auto-refresh sur 403, endpoint `/api/csrf-token`
|
||||||
- [ ] **Type `url`** — lien cliquable
|
- [x] **Mobile responsive** — sidebar slide-in, modales centrées, landing adaptative
|
||||||
- [ ] **Type `email`** — email cliquable
|
- [x] **Tests de non-régression** — +10 tests (landing, register, 404, validation, duplicate, expiry)
|
||||||
- [ ] **Type `phone`** — téléphone cliquable
|
- [x] **143 tests passent**
|
||||||
- [ ] **Type `status`** — select avec couleurs forcées (9 couleurs Notion)
|
|
||||||
- [ ] **Type `files`** — uploads/images attachés aux pages
|
|
||||||
- [ ] **Type `unique_id`** — ID auto-incrémenté par collection
|
|
||||||
- [ ] **Type `created_time` / `created_by`** — auto-propriétés
|
|
||||||
- [ ] **Type `last_edited_time` / `last_edited_by`** — auto-propriétés
|
|
||||||
- [ ] **Migration** `project_properties` → `collection_properties`
|
|
||||||
|
|
||||||
### v1.5.0 — Relations & Rollups (Bloc 2 suite)
|
---
|
||||||
|
|
||||||
- [ ] **Type `relation`** — lien bidirectionnel entre collections
|
### v4.1.0 — Database: Data Sources & Linked Databases ✅ (2026-07-21)
|
||||||
- `related_collection_id`, `reverse_name` auto-généré
|
> **Objectif** : Collections multi-sources, databases liées (linked). **COMPLETED**.
|
||||||
- Stockage : JSON array de page IDs dans `property_values_json`
|
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 1](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
|
||||||
- [ ] **Type `rollup`** — agrégation via relation
|
|
||||||
- Functions : COUNT, COUNT_VALUES, SUM, AVG, MEDIAN, MIN, MAX, RANGE, UNIQUE
|
|
||||||
- Dépend de `relation_property_id` + `target_property_id`
|
|
||||||
- [ ] **Type `formula`** — propriétés calculées
|
|
||||||
- Moteur d'expressions JavaScript-like
|
|
||||||
- Fonctions : `prop()`, `now()`, `today()`, `if()`, `concat()`, `round()`, etc.
|
|
||||||
- [ ] **Formula engine** — `services/formula_engine.py`
|
|
||||||
- [ ] **Rollup engine** — `services/rollup_engine.py`
|
|
||||||
|
|
||||||
### v1.6.0 — Vues Manquantes (Bloc 3)
|
- [x] **Table `collection_data_sources`** — multi-sources par collection (FK `collection_id` + `source_collection_id`, flag `is_linked`)
|
||||||
|
- [x] **API sources** — `GET /db/{id}/sources/api` (liste), `POST /db/{id}/sources/api` (ajouter), `DELETE /db/{id}/sources/{sid}/api` (retirer)
|
||||||
|
- [x] **API linked DB** — `POST /db/{id}/linked/api` (crée une collection liée : copie vues + propriétés + data source `is_linked=1`)
|
||||||
|
- [x] **Full-page vs Inline** — `POST /db/{id}/toggle-inline/api` (toggle), `POST /db/inline/api` (créer inline avec `parent_page_id`)
|
||||||
|
- [x] **Règle permissions** — linked DB hérite du `workspace_id` de la source → mêmes ACLs
|
||||||
|
- [x] **Migration** — ajout colonnes `workspace_id`, `created_by` sur `collections`
|
||||||
|
- [x] **153 tests passent** (+10 nouveaux)
|
||||||
|
|
||||||
- [ ] **Calendar view** — regroupement par propriété Date (jour/semaine/mois)
|
### v4.2.0 — Database: Templates & Dashboards ✅ (2026-07-21)
|
||||||
- Template `calendar_view.html`
|
> **Objectif** : Templates réutilisables + dashboards combinant vues/widgets. **COMPLETED**.
|
||||||
- Navigation mois précédent/suivant
|
|
||||||
- Création inline par clic sur un jour
|
|
||||||
- [ ] **Timeline/Gantt view** — axe X = date range, barres horizontales
|
|
||||||
- Template `timeline_view.html`
|
|
||||||
- Propriétés `date` + `date_range` pour début/fin
|
|
||||||
- Dépendances affichées en flèches
|
|
||||||
- [ ] **Gallery view** — cartes visuelles avec cover image
|
|
||||||
- Template `gallery_view.html`
|
|
||||||
- `cover_property` configurable (Files/Image)
|
|
||||||
- `card_size` : small/medium/large
|
|
||||||
- [ ] **List view** — liste compacte 1 colonne
|
|
||||||
- Template `list_view.html`
|
|
||||||
- Preview du contenu sous le titre
|
|
||||||
|
|
||||||
### v1.7.0 — Vues Améliorées (Bloc 3 suite)
|
- [x] **Table `page_templates` enrichie** — colonnes `description`, `is_recurring`, `recurrence_rule` (daily/weekly/monthly/yearly)
|
||||||
|
- [x] **Table `collection_dashboards`** — layout widgets (grille configurable : `columns`, `widgets` avec `view_id`, `x`, `y`, `width`, `height`)
|
||||||
|
- [x] **API templates CRUD complet** — `PUT /workspace/collections/{id}/templates/page/{tid}` (update), `DELETE` (delete)
|
||||||
|
- [x] **API dashboards CRUD** — `GET/POST/PUT/DELETE /workspace/collections/{id}/dashboards`
|
||||||
|
- [x] **159 tests passent** (+6 nouveaux)
|
||||||
|
|
||||||
- [ ] **Group_by configurable** — Kanban groupé par n'importe quelle propriété Select/Status
|
### v4.3.0 — Database Views complètes (10 types)
|
||||||
- Plus seulement les labels Gitea
|
> **Objectif** : Tous les types de vues Notion avec layouts.
|
||||||
- UI de sélection du group_by dans la barre d'outils
|
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 3](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
|
||||||
- [ ] **Visible properties par vue** — toggle colonnes dans chaque vue
|
|
||||||
- [ ] **Card size** — small/medium/large pour board et gallery
|
|
||||||
- [ ] **Cover image property** — choisir la propriété de cover
|
|
||||||
- [ ] **Groupes de filtres** — (A OR B) AND C, UI de construction de filtres
|
|
||||||
- [ ] **Save view as** — sauvegarder état courant comme nouvelle vue
|
|
||||||
|
|
||||||
### v1.8.0 — Sub-items & Dépendances (Bloc 4)
|
- [ ] **Chart** — barres, courbes, camemberts, donuts, scatter (Chart.js)
|
||||||
|
- [ ] **Form** — formulaire HTML → POST création page
|
||||||
|
- [ ] **Map** — Leaflet/OSM pour propriété Place
|
||||||
|
- [ ] **Feed** — vue chronologique type fil d'actualité
|
||||||
|
- [ ] **Timeline/Gantt** — barres horizontales avec dépendances
|
||||||
|
- [ ] **Layout options** — card size, preview, open in peek/side/full
|
||||||
|
- [ ] **View tabs** — navigation fluide entre vues (existant à enrichir)
|
||||||
|
|
||||||
- [ ] **Sub-items sur cartes Kanban** — `parent_id` dans `collection_pages`
|
### v4.4.0 — Tasks, Sub-items & Dependencies
|
||||||
- Auto-référence : une page peut être enfant d'une autre dans la même collection
|
> **Objectif** : Système complet de tâches Notion-style.
|
||||||
- [ ] **Affichage imbriqué** — cartes indentées sous le parent dans le board
|
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 4](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
|
||||||
- CSS `.sub-item` avec indentation visuelle
|
|
||||||
- Compteur de sous-tâches sur la carte parent
|
|
||||||
- [ ] **Bouton "+ Sub-item"** — création inline de sous-tâche
|
|
||||||
- [ ] **État parent = agrégation enfants** — "Done" seulement si tous enfants Done
|
|
||||||
- [ ] **Dependencies (Blocking ↔ Blocked by)**
|
|
||||||
- 2e relation auto-référencée
|
|
||||||
- Propriétés `Blocks` et `Blocked by` créées automatiquement
|
|
||||||
- [ ] **Contrainte de dépendance** — empêcher "Done" si bloque des tâches non terminées
|
|
||||||
- [ ] **Flèches de dépendance** — visuel dans Timeline et Kanban
|
|
||||||
|
|
||||||
### v1.9.0 — My Tasks & Dashboard Unifié (Bloc 5)
|
- [ ] **Flag `is_task`** sur collections — Turn into Tasks
|
||||||
|
- [ ] **Propriété ID** auto-générée (TASK-XXX)
|
||||||
|
- [ ] **Sub-items** — parent_id enrichi, modes d'affichage (nested/flattened/card)
|
||||||
|
- [ ] **Table `page_dependencies`** — bloque/bloqué par
|
||||||
|
- [ ] **API dependencies** — POST/GET/DELETE /db/{c}/pages/{p}/dependencies
|
||||||
|
- [ ] **Auto-shift dates** — overlap / maintain_time / never + skip weekends
|
||||||
|
- [ ] **UI dépendances** — graphe ou liste, toggle sub-items
|
||||||
|
- [ ] **Filtres sub-items** — parents only / all / sub-items only
|
||||||
|
|
||||||
- [ ] **Vue `/my-tasks`** — agrège toutes les pages assignées à l'utilisateur
|
### v4.5.0 — Sprints & My Tasks
|
||||||
- Scan cross-collection (toutes les databases du workspace)
|
> **Objectif** : Sprints agiles + vue My Tasks cross-databases.
|
||||||
- Filtre automatique : `Assignee = current_user` ET `Status ≠ Done`
|
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 5](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
|
||||||
- [ ] **Groupement par collection** — sections par database d'origine
|
|
||||||
- [ ] **Filtres globaux** — Status, Due Date, Collection
|
|
||||||
- [ ] **Calendrier intégré** — My Tasks en vue calendrier
|
|
||||||
- [ ] **Vue Today** — ce qui est dû aujourd'hui
|
|
||||||
- [ ] **Vue Overdue** — tâches en retard
|
|
||||||
- [ ] **Vue Upcoming** — 7 prochains jours
|
|
||||||
|
|
||||||
### v2.0.0 — Éditeur Complet & Multi-Utilisateurs (Blocs 6-7)
|
- [ ] **Tables `sprints`, `sprint_pages`** — sprints agiles
|
||||||
|
- [ ] **API sprints** — CRUD + assignation pages, vélocité
|
||||||
|
- [ ] **Sprint board** — Current Sprint / Planning / Backlog
|
||||||
|
- [ ] **Burndown chart** — vélocité, points complétés vs restants
|
||||||
|
- [ ] **My Tasks** — agrégation cross-databases (toutes les task DBs)
|
||||||
|
- [ ] **My Tasks API** — /my-tasks?view=all|today|overdue|upcoming
|
||||||
|
- [ ] **My Tasks UI** — groupes par collection, status badges, filtres
|
||||||
|
|
||||||
- [ ] **Drag blocks to reorder** — réorganisation des blocs dans l'éditeur
|
### v4.6.0 — Content Blocks enrichis
|
||||||
- [ ] **Colonnes (2, 3, 4, 5)** — layout multi-colonnes dans l'éditeur
|
> **Objectif** : parité d'édition avec Notion.
|
||||||
- [ ] **Callout blocks** — bloc avec icône et fond coloré
|
|
||||||
- [ ] **Toggle blocks** — bloc repliable
|
|
||||||
- [ ] **Code blocks avec syntax highlighting** — highlight.js
|
|
||||||
- [ ] **Image resize/alignment** — redimensionnement et alignement
|
|
||||||
- [ ] **Embeds** — vidéo, fichier, bookmark web
|
|
||||||
- [ ] **Database templates** — structures de database réutilisables
|
|
||||||
- [ ] **Page templates** — pages modèles dans une database
|
|
||||||
- [ ] **Inline databases** — databases embarquées dans une page
|
|
||||||
- [ ] **Linked databases** — même database affichée dans plusieurs pages
|
|
||||||
- [ ] **Multi-user workspaces** — collaboration temps réel
|
|
||||||
- Workspaces partagés avec permissions (read/write/admin)
|
|
||||||
- User presence (qui est en ligne, qui édite quelle page)
|
|
||||||
- Historique des modifications par page
|
|
||||||
- Commentaires sur les pages (pas juste les issues Gitea)
|
|
||||||
- [ ] **Favorites** — système de favoris fonctionnel
|
|
||||||
- [ ] **CSV import/export** pour databases
|
|
||||||
- [ ] **Public sharing** — lien de partage public pour une vue
|
|
||||||
|
|
||||||
### v2.1.0 — Intégrations Avancées (futur)
|
- [ ] **Callout blocks** — boîtes colorées (info, warning, tip, success)
|
||||||
|
- [ ] **Table of contents** — auto-généré depuis les headings
|
||||||
|
- [ ] **Math equations** — LaTeX / KaTeX inline + block
|
||||||
|
- [ ] **Toggle lists** — contenu expandable/collapsible (déjà partiel)
|
||||||
|
- [ ] **Multi-colonnes** — layout flexible (2, 3 colonnes)
|
||||||
|
|
||||||
- [ ] **API publique** — REST API documentée pour usage externe
|
### v4.7.0 — Export
|
||||||
- [ ] **Webhooks sortants** — notifier des services externes
|
- [ ] **Export Markdown** — avec images et sous-pages (déjà partiel dans editor)
|
||||||
- [ ] **n8n integration** — nœud FlowDeck pour workflows
|
- [ ] **Export PDF** — mise en page fidèle, table des matières
|
||||||
- [ ] **Slack/Discord integration** — notifications dans les channels
|
- [ ] **Export HTML** — site statique standalone
|
||||||
- [ ] **Google Calendar sync** — synchronisation bidirectionnelle
|
|
||||||
- [ ] **Mobile PWA** — Progressive Web App pour mobile
|
### v4.8.0 — Collaboration
|
||||||
- [ ] **PostgreSQL migration** — si passage multi-tenant
|
- [ ] **Inline comments** — commentaires sur sélection de texte
|
||||||
|
- [ ] **@mentions** — notifier un utilisateur → page/commentaire
|
||||||
|
- [ ] **Email notifications** — changements, mentions
|
||||||
|
|
||||||
|
### v4.9.0 — FlowDeck Agent (Agent IA natif)
|
||||||
|
|
||||||
|
**Objectif :** Un agent IA intégré à FlowDeck, capable de planifier, rechercher et **agir** directement sur les workspaces — collections, pages, propriétés, vues, issues Gitea. Inspiré de Notion Agent (2026).
|
||||||
|
|
||||||
|
**Documentation :**
|
||||||
|
|
||||||
|
[`docs/Flowdeck_Agent_integration.md`](docs/Flowdeck_Agent_integration.md) (702 lignes) ·
|
||||||
|
[`docs/Guide_Complet_Notion_Agent_2026.md`](docs/Guide_Complet_Notion_Agent_2026.md) (464 lignes)
|
||||||
|
|
||||||
|
#### Concept
|
||||||
|
Là où un assistant IA classique répond (`prompt → réponse`), FlowDeck Agent **réalise** :
|
||||||
|
`objectif → planification → collecte contexte → actions API → résultat`
|
||||||
|
|
||||||
|
| Notion AI | FlowDeck Agent |
|
||||||
|
|-----------|----------------|
|
||||||
|
| Répond, résume, réécrit | Planifie, recherche, **agit** |
|
||||||
|
| Bloc de texte | Crée collections, pages, propriétés, vues |
|
||||||
|
| Sans état | Historique conversation + snapshots rollback |
|
||||||
|
| Aucune action DB | Modifie le workspace via API FastAPI existantes |
|
||||||
|
|
||||||
|
#### Fonctionnalités clés
|
||||||
|
- [ ] **Interface conversationnelle** — panneau agent dans le sidebar (🤖 Agents) + popup chat
|
||||||
|
- [ ] **Boucle ReAct** — Raisonnement → Action → Observation → itération (max 12 tours)
|
||||||
|
- [ ] **Streaming SSE** — affichage temps réel du raisonnement et des actions
|
||||||
|
- [ ] **10+ outils actionnables** — search, read/write collections/pages, views, properties, Gitea sync
|
||||||
|
- [ ] **Permissions** — même ACL que l'utilisateur (`PermissionManager`), audit log + rollback
|
||||||
|
- [ ] **Contexte automatique** — workspace actif, pages mentionnées, fichiers uploadés
|
||||||
|
- [ ] **Custom agents** — instructions, modèle, outils, scope par agent
|
||||||
|
- [ ] **Déclencheurs** — planifiés, webhooks, événements workspace
|
||||||
|
- [ ] **Skills réutilisables** — templates d'instructions + outils (ex: "Rédiger rapport hebdo")
|
||||||
|
- [ ] **Multi-LLM** — GPT, Claude, Gemini, modèles locaux (Ollama)
|
||||||
|
|
||||||
|
#### Architecture (nouveaux composants)
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── routers/agent.py — /api/agent/* (run, conversations, tools)
|
||||||
|
├── services/agent_engine.py — Orchestrateur ReAct + boucle tool calls
|
||||||
|
├── services/llm_client.py — Abstraction multi-fournisseur LLM
|
||||||
|
├── services/tool_registry.py — 10+ outils wrappant les API existantes
|
||||||
|
├── services/context_builder.py — Collecte contexte workspace
|
||||||
|
└── templates/
|
||||||
|
├── agent_panel.html — Panneau conversation Alpine.js
|
||||||
|
└── agent_message.html — Fragment message (streaming)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Nouvelles tables
|
||||||
|
| Table | Rôle |
|
||||||
|
|-------|------|
|
||||||
|
| `agents` | Config agent (nom, instructions, modèle, tools scope) |
|
||||||
|
| `agent_conversations` | Sessions (titre, agent, workspace, statut) |
|
||||||
|
| `agent_messages` | Messages (role, content, tool_calls, artifacts) |
|
||||||
|
| `agent_actions` | Journal d'audit (tool, args, result, undo_snapshot) |
|
||||||
|
| `agent_skills` | Skills réutilisables (nom, prompt template, outils) |
|
||||||
|
| `agent_triggers` | Déclencheurs (cron, webhook, event, agent cible) |
|
||||||
|
|
||||||
|
#### Cas d'usage FlowDeck
|
||||||
|
- **Créer un CRM complet** — agent crée collection, propriétés, vues, exemples
|
||||||
|
- **Résumer les notes de réunion** — lit les pages, synthétise, crée une page résumé
|
||||||
|
- **Générer des OKR** — crée la database OKR avec relations et vues
|
||||||
|
- **Analyser un repo Gitea** — lit les issues, crée un dashboard de suivi
|
||||||
|
- **Rédiger un rapport hebdomadaire** — agrège les pages modifiées, génère le rapport
|
||||||
|
|
||||||
|
#### Plan de migration (5 phases)
|
||||||
|
1. **Phase 1 — Core Agent** : AgentEngine + LLMClient + 3 outils (search, read, create) + SSE streaming
|
||||||
|
2. **Phase 2 — UI** : agent_panel.html, historique conversations, sélecteur de modèle
|
||||||
|
3. **Phase 3 — Outils avancés** : 7 outils supplémentaires (views, properties, Gitea, uploads)
|
||||||
|
4. **Phase 4 — Autonomie** : custom agents, skills, déclencheurs planifiés
|
||||||
|
5. **Phase 5 — Plateforme** : API publique agent → intégrations tierces, marketplace skills
|
||||||
|
|
||||||
|
#### Limitations (v1)
|
||||||
|
- Pas de modification concurrente (lock optimiste DB)
|
||||||
|
- Pas d'appels API externes non-FlowDeck (sandbox strict)
|
||||||
|
- Budget tokens max par conversation (500k tokens)
|
||||||
|
- Timeout 5 minutes par run
|
||||||
|
|
||||||
|
### v5.0.0 — Command Palette & Recherche
|
||||||
|
- [ ] **Command palette** — Ctrl+K / Ctrl+P recherche universelle
|
||||||
|
- [ ] **Quick actions** — navigation, création, commandes
|
||||||
|
|
||||||
|
### v5.1.0 — Automations
|
||||||
|
- [ ] **Database automations** — if-this-then-that
|
||||||
|
- [ ] **Buttons** — cliquables déclenchant actions
|
||||||
|
|
||||||
|
### v5.2.0 — Infrastructure & Polish
|
||||||
|
> **Objectif** : Qualité de code, design system, backup, CI/CD.
|
||||||
|
> **Items issus de l'ancien docs/ROADMAP.md (v3.0)**
|
||||||
|
|
||||||
|
**Design system**
|
||||||
|
- [ ] **Design tokens** — `design-tokens.css` (couleurs, espacements, typographie unifiés)
|
||||||
|
- [ ] **Composants réutilisables** — boutons, inputs, modales, dropdowns, toasts
|
||||||
|
|
||||||
|
**Sécurité & Utilisateur**
|
||||||
|
- [ ] **API Tokens** — générer/révoquer des clés API utilisateur
|
||||||
|
- [ ] **Sessions actives** — voir et révoquer les sessions
|
||||||
|
- [ ] **Onboarding wizard** — `/welcome` au premier lancement (créer compte → lier forges → premier projet)
|
||||||
|
|
||||||
|
**Infrastructure**
|
||||||
|
- [ ] **Migrations versionnées** — Alembic ou table `schema_version`
|
||||||
|
- [ ] **Backup automatique** — cron daily → fichier daté
|
||||||
|
- [ ] **Index manquants** — `users.email`, `forge_connections.user_id`
|
||||||
|
- [ ] **Linting** — ruff (Python), eslint (JS)
|
||||||
|
- [ ] **Tests parallèles** — pytest-xdist
|
||||||
|
- [ ] **Build Docker multi-stage** — optimiser taille d'image
|
||||||
|
|
||||||
|
**Forge integration**
|
||||||
|
- [ ] **DB: table `projects`** — type (builtin/gitea/github), forge_id, clone_url, default_branch, language
|
||||||
|
- [ ] **Cron: sync périodique des projets** — configurable (défaut: chaque heure)
|
||||||
|
- [ ] **GitHubAdapter** complet — API GitHub v3 → interface ForgeAdapter
|
||||||
|
|
||||||
|
**Tests (cibles)**
|
||||||
|
- [x] **143 tests** — cible 100+ atteinte ✅
|
||||||
|
- [ ] Tests d'intégration auth (OAuth mock)
|
||||||
|
- [ ] Tests des adapters forge (mock HTTP)
|
||||||
|
- [ ] Tests multi-user (permissions croisées)
|
||||||
|
|
||||||
|
### v5.3.0 — Database Avancée
|
||||||
|
- [ ] Inline databases dans n'importe quelle page
|
||||||
|
- [ ] Templates de database (Project tracker, CRM…)
|
||||||
|
- [ ] Validation des propriétés (required, unique, min/max)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v6.0.0 — Pro (futur)
|
||||||
|
|
||||||
|
- [ ] **PWA** — Progressive Web App, offline support
|
||||||
|
- [ ] **SSO/SAML** — enterprise authentication
|
||||||
|
- [ ] **Granular permissions** — page-level, property-level access control
|
||||||
|
- [ ] **Web Clipper** — extension navigateur
|
||||||
|
- [ ] **API publique** — REST API + webhooks documentés
|
||||||
|
- [ ] **Realtime editing** — WebSocket, curseurs multi-utilisateurs
|
||||||
|
- [ ] **Synced blocks** — bloc synchronisé entre plusieurs pages
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Résumé des phases
|
## 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 ⬜
|
v0.x–v1.x ✅ v2.x ✅ v3.0.0 ✅ v4.0.0 ✅ v4.0.1 ✅
|
||||||
Production Pages Editor DB Concept Properties Relations Views View+
|
Base + Kanban Éditeur + Gitea UX Pro MVP Onboarding
|
||||||
──────────────────────────────────────────────
|
+ DB + Auth + Workspaces + Multi-select Accounts & Polish
|
||||||
52 fonctionnalités identifiées dans le gap analysis
|
+ UI Notion + Tags + Admin + Sharing COMPLETED
|
||||||
│
|
+ GitHub OAuth + Library
|
||||||
├─ 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
|
|
||||||
|
|
||||||
v1.8.0 ⬜ v1.9.0 ⬜ v2.0.0 ⬜ v2.1.0 ⬜
|
v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3–4.5 ⬜ v4.6.0 ⬜ v4.7.0 ⬜ v4.8–4.9 ⬜ v5.x–v6.0 ⬜
|
||||||
Sub-items My Tasks Editor+ Future
|
Quality Data Sources Templates + Views/ Content Export Collab + Pro + Agent
|
||||||
Multi-user
|
& Tests & Linked DB Dashboards Tasks Blocks PDF/MD Agent IA (futur)
|
||||||
|
📚 NOTION_DATABASE_TASKS_GUIDE.md v2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
*Dernière mise à jour: 2026-07-21 — v4.2.0 Templates & Dashboards complété ✅*
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# FlowDeck — Grille de Tests Visuels
|
||||||
|
|
||||||
|
> Version: 2.1.0 | Dernière mise à jour: 2026-07-10 | Dernier run: 2026-07-10 | Résultat: 55/61
|
||||||
|
|
||||||
|
## Résumé du dernier run (2026-07-10)
|
||||||
|
|
||||||
|
| Section | Pass | Fail | Notes |
|
||||||
|
|---------|------|------|-------|
|
||||||
|
| Dashboard | 8/9 | ❌ D6 Quick Find | Quick Find pas implémenté dans le dashboard |
|
||||||
|
| Board Kanban | 9/9 | — | OK |
|
||||||
|
| Drag & Drop | 5/5 | — | ✅ Fix CSRF + refresh appliqué |
|
||||||
|
| Création Issue | 6/6 | — | ✅ Fix CSRF appliqué |
|
||||||
|
| Vues | 7/7 | — | OK |
|
||||||
|
| Filtres & Tri | 7/7 | — | OK |
|
||||||
|
| Notes | 3/3 | — | OK |
|
||||||
|
| Sidebar | 6/6 | — | OK |
|
||||||
|
| Workspaces | 6/6 | — | OK |
|
||||||
|
| Commentaires | 3/3 | — | OK |
|
||||||
|
| Favoris | 4/4 | — | OK |
|
||||||
|
| Webhooks | 4/5 | ⚠️ No secret | Webhook status OK, secret non configuré |
|
||||||
|
| PWA | 0/3 | ❌ P1-P3 | manifest.json + SW non implémentés |
|
||||||
|
| Éditeur | 3/5 | ⚠️ E4-E5 | Commandes `/` + drag blocks à vérifier |
|
||||||
|
| **Total** | **55** | **6** | |
|
||||||
|
|
||||||
|
### Bugs corrigés ce run
|
||||||
|
- `8aaf167`: Refresh board après drag & drop
|
||||||
|
- `[latest]`: CSRF token manquant sur fetch POST (board.html, card_detail.html)
|
||||||
|
|
||||||
|
### Reste à faire
|
||||||
|
- PWA (manifest.json + service worker)
|
||||||
|
- Quick Find dans le dashboard
|
||||||
|
- Config webhook secret
|
||||||
|
- Commandes `/` dans l'éditeur
|
||||||
|
|
||||||
|
Tests manuels de régression visuelle et fonctionnelle. Cocher après chaque release.
|
||||||
|
|
||||||
|
## Pré-requis
|
||||||
|
|
||||||
|
- FlowDeck lancé (Docker: `APP_PORT=8082 docker compose up -d`)
|
||||||
|
- Gitea accessible (https://git.dracodev.net)
|
||||||
|
- Au moins 1 projet avec des issues existantes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Dashboard (Accueil)
|
||||||
|
|
||||||
|
| ID | Test | Action | Résultat attendu | ✅ |
|
||||||
|
|----|------|--------|-----------------|----|
|
||||||
|
| D1 | Chargement | Ouvrir `/` | Dashboard affiché, sidebar visible | |
|
||||||
|
| D2 | Liste projets | Vérifier la liste | Projets Gitea listés, triés par mise à jour | |
|
||||||
|
| D3 | Recherche projets | Taper dans la barre de recherche | Filtrage en temps réel | |
|
||||||
|
| D4 | Lien projet | Cliquer sur un projet | Navigation vers le board du projet | |
|
||||||
|
| D5 | Workspace menu | Cliquer sur le workspace header | Menu déroulant des workspaces | |
|
||||||
|
| D6 | Quick Find | Cliquer 🔍 dans la sidebar | Modal de recherche rapide | |
|
||||||
|
| D7 | Theme toggle | Basculer le thème | Dark ↔ Light, persisté localStorage | |
|
||||||
|
| D8 | Collapse sidebar | Cliquer ⏴ | Sidebar se réduit/étend | |
|
||||||
|
| D9 | Sidebar sections | Cliquer Meetings/Recents | Sections expand/collapse | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Board Kanban
|
||||||
|
|
||||||
|
| ID | Test | Action | Résultat attendu | ✅ |
|
||||||
|
|----|------|--------|-----------------|----|
|
||||||
|
| B1 | Chargement board | Ouvrir `/board/{owner}/{repo}` | Colonnes affichées avec cartes | |
|
||||||
|
| B2 | Colonnes par défaut | Vérifier les colonnes | Backlog, À faire, En cours, Révision, Terminé | |
|
||||||
|
| B3 | Cartes dans colonnes | Vérifier le mapping | Issues dans bonnes colonnes selon labels/état | |
|
||||||
|
| B4 | Labels sur cartes | Vérifier l'affichage | Labels visibles avec couleur Gitea | |
|
||||||
|
| B5 | Assignee avatar | Vérifier les cartes | Avatar + login si assigné | |
|
||||||
|
| B6 | Milestone sur carte | Vérifier | 📅 + nom milestone si défini | |
|
||||||
|
| B7 | Priorité | Vérifier | Badge priorité si défini dans FlowDeck | |
|
||||||
|
| B8 | Due date | Vérifier | ⏰ + date, style "overdue" si dépassée | |
|
||||||
|
| B9 | Compteur colonne | Vérifier chaque colonne | Nombre de cartes affiché dans le header | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Drag & Drop
|
||||||
|
|
||||||
|
| ID | Test | Action | Résultat attendu | ✅ |
|
||||||
|
|----|------|--------|-----------------|----|
|
||||||
|
| DD1 | Move carte | Drag carte vers autre colonne | Carte déplacée visuellement | |
|
||||||
|
| DD2 | Persistence | Après DD1, rafraîchir la page | Carte reste dans nouvelle colonne | |
|
||||||
|
| DD3 | Refresh auto | Après move, attendre | La vue se rafraîchit automatiquement | |
|
||||||
|
| DD4 | Compteurs | Après move | Compteurs de colonnes mis à jour | |
|
||||||
|
| DD5 | Move → Gitea | Vérifier sur Gitea | Label/état synchronisé si mapping existe | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Création d'Issue
|
||||||
|
|
||||||
|
| ID | Test | Action | Résultat attendu | ✅ |
|
||||||
|
|----|------|--------|-----------------|----|
|
||||||
|
| C1 | Bouton New | Cliquer "New ▾" dans la toolbar | Formulaire de création affiché | |
|
||||||
|
| C2 | Création simple | Titre + "Create" | Issue créée sur Gitea, apparaît au refresh | |
|
||||||
|
| C3 | Création + statut | Titre + sélection statut | Issue créée avec le bon statut | |
|
||||||
|
| C4 | Annulation | Ouvrir form → ✕ | Formulaire masqué | |
|
||||||
|
| C5 | Enter pour créer | Titre + Entrée | Issue créée (submit au Enter) | |
|
||||||
|
| C6 | Titre vide | "Create" sans titre | Rien ne se passe (validé client) | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Vues du Board
|
||||||
|
|
||||||
|
| ID | Test | Action | Résultat attendu | ✅ |
|
||||||
|
|----|------|--------|-----------------|----|
|
||||||
|
| V1 | Vue Kanban | Tab "Board" | Vue kanban avec colonnes | |
|
||||||
|
| V2 | Vue Table | Tab "Table" | Vue tableau lignes/colonnes | |
|
||||||
|
| V3 | Vue Status | Tab "Status" | Regroupement par statut | |
|
||||||
|
| V4 | Vue TeamLoad | Tab "Workload" | Répartition par assignee | |
|
||||||
|
| V5 | Vue Detailed | Tab "Detailed" | Vue détaillée | |
|
||||||
|
| V6 | Switch vue | Alterner entre vues | Contenu mis à jour sans rechargement page | |
|
||||||
|
| V7 | URL view param | `?view=table` | Vue table chargée directement | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Filtres & Tri
|
||||||
|
|
||||||
|
| ID | Test | Action | Résultat attendu | ✅ |
|
||||||
|
|----|------|--------|-----------------|----|
|
||||||
|
| F1 | Filtre milestone | Sélectionner un milestone | Seules les issues du milestone affichées | |
|
||||||
|
| F2 | Filtre label | Sélectionner un label | Seules les issues avec ce label | |
|
||||||
|
| F3 | Combinaison filtres | Milestone + Label | Les deux filtres appliqués (AND) | |
|
||||||
|
| F4 | Reset filtres | Sélectionner "Tous" | Toutes les issues affichées | |
|
||||||
|
| F5 | Tri par propriété | Add sort → choisir champ | Liste triée selon critère | |
|
||||||
|
| F6 | Tri direction | Toggle asc/desc | Direction inversée | |
|
||||||
|
| F7 | Supprimer tri | Delete sort | Tri retiré | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Notes
|
||||||
|
|
||||||
|
| ID | Test | Action | Résultat attendu | ✅ |
|
||||||
|
|----|------|--------|-----------------|----|
|
||||||
|
| N1 | Accès notes | Cliquer "📝 Notes" dans le board | Page notes du projet | |
|
||||||
|
| N2 | Édition notes | Modifier le contenu | Sauvegarde automatique | |
|
||||||
|
| N3 | Markdown | Saisir `# Titre`, `**gras**` | Rendu Markdown | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Sidebar & Navigation
|
||||||
|
|
||||||
|
| ID | Test | Action | Résultat attendu | ✅ |
|
||||||
|
|----|------|--------|-----------------|----|
|
||||||
|
| S1 | Navigation page | Cliquer sur une page sidebar | Board du projet chargé | |
|
||||||
|
| S2 | Library | Cliquer 📚 sur une page | Library view avec recents/favorites/shared | |
|
||||||
|
| S3 | New sub-page | Cliquer + sur une page | Création sous-page | |
|
||||||
|
| S4 | Context menu | Clic droit sur page sidebar | Menu contextuel (rename, delete, etc.) | |
|
||||||
|
| S5 | Tree toggle | Cliquer ▶ d'un dossier | Sous-éléments affichés/masqués | |
|
||||||
|
| S6 | Drag tree | Drag & drop élément sidebar | Réorganisation de l'arbre | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Workspaces Multi-User
|
||||||
|
|
||||||
|
| ID | Test | Action | Résultat attendu | ✅ |
|
||||||
|
|----|------|--------|-----------------|----|
|
||||||
|
| W1 | Lister workspaces | Voir le menu workspace | Workspaces disponibles listés | |
|
||||||
|
| W2 | Créer workspace | Créer nouveau workspace | Workspace ajouté | |
|
||||||
|
| W3 | Membres | Voir liste membres | Membres affichés avec rôles | |
|
||||||
|
| W4 | Ajouter membre | Ajouter un utilisateur | Membre ajouté | |
|
||||||
|
| W5 | Rôle membre | Changer rôle | Rôle mis à jour | |
|
||||||
|
| W6 | Supprimer membre | Retirer un membre | Membre retiré | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Commentaires & Historique
|
||||||
|
|
||||||
|
| ID | Test | Action | Résultat attendu | ✅ |
|
||||||
|
|----|------|--------|-----------------|----|
|
||||||
|
| H1 | Voir commentaires | Ouvrir commentaires d'une page | Liste des commentaires | |
|
||||||
|
| H2 | Ajouter commentaire | Écrire + envoyer | Commentaire ajouté | |
|
||||||
|
| H3 | Historique | Voir historique d'une page | Liste des versions/modifications | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Favoris & Partage
|
||||||
|
|
||||||
|
| ID | Test | Action | Résultat attendu | ✅ |
|
||||||
|
|----|------|--------|-----------------|----|
|
||||||
|
| FA1 | Ajouter favori | Mettre en favori une page | Apparaît dans les favoris | |
|
||||||
|
| FA2 | Retirer favori | Retirer des favoris | Disparaît de la liste | |
|
||||||
|
| FA3 | Vue favorites | Tab "Favorites" dans Library | Liste des favoris | |
|
||||||
|
| FA4 | Vue shared | Tab "Shared" dans Library | Pages partagées | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Webhooks & API Publique
|
||||||
|
|
||||||
|
| ID | Test | Action | Résultat attendu | ✅ |
|
||||||
|
|----|------|--------|-----------------|----|
|
||||||
|
| WH1 | Register webhook | POST /api/webhooks/register/{o}/{r} | Webhook enregistré | |
|
||||||
|
| WH2 | Webhook status | GET /api/webhooks/status/{o}/{r} | Statut retourné | |
|
||||||
|
| WH3 | Reception webhook | Push sur Gitea → webhook reçu | Issue synchronisée | |
|
||||||
|
| WH4 | API health | GET /api/health | `{"status":"ok","db":true,"gitea":true}` | |
|
||||||
|
| WH5 | API projects | GET /api/projects | Liste JSON des projets | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. PWA & Responsive
|
||||||
|
|
||||||
|
| ID | Test | Action | Résultat attendu | ✅ |
|
||||||
|
|----|------|--------|-----------------|----|
|
||||||
|
| P1 | Mobile view | Réduire fenêtre < 768px | Layout responsive, sidebar masquable | |
|
||||||
|
| P2 | Install PWA | "Installer" dans Chrome | Icône sur l'écran d'accueil | |
|
||||||
|
| P3 | Offline cache | Couper réseau, recharger | Page servie depuis cache | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Éditeur Notion-like
|
||||||
|
|
||||||
|
| ID | Test | Action | Résultat attendu | ✅ |
|
||||||
|
|----|------|--------|-----------------|----|
|
||||||
|
| E1 | Éditeur riche | Ouvrir une page | Éditeur bloc-style Notion | |
|
||||||
|
| E2 | Blocs texte | Taper du texte, Entrée | Nouveau bloc créé | |
|
||||||
|
| E3 | Markdown inline | `**gras**`, `*italique*` | Formatage appliqué | |
|
||||||
|
| E4 | `/` commandes | Taper `/` | Menu de commandes (headings, listes, etc.) | |
|
||||||
|
| E5 | Drag blocks | Drag & drop blocs | Réorganisation | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exécution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lancer FlowDeck
|
||||||
|
cd ~/workspace/flowdeck && docker compose up -d
|
||||||
|
|
||||||
|
# Vérifier
|
||||||
|
curl -s http://localhost:8082/api/health
|
||||||
|
# → {"status":"ok","version":"2.1.0","db":true,"gitea":true}
|
||||||
|
|
||||||
|
# Ouvrir
|
||||||
|
# Dashboard: http://localhost:8082/
|
||||||
|
# Board: http://localhost:8082/board/bruno/flowdeck
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total: 61 tests** | Dernier run: ________ | Résultat: ___/61
|
||||||
@@ -1,177 +1,62 @@
|
|||||||
# WORKLOAD — FlowDeck Notion Clone
|
# WORKLOAD — FlowDeck Notion Clone
|
||||||
|
|
||||||
> **Début**: 2026-07-08 | **Objectif**: Clone Notion intégré Gitea
|
> **Début**: 2026-07-08 | **Version**: v2.2.0 | **Statut**: EN COURS 🔄
|
||||||
|
> **Cible v3.0**: Multi-User, Multi-Forge (Gitea/GitHub), Standalone
|
||||||
|
|
||||||
## Avancement Global
|
## Avancement Global
|
||||||
|
|
||||||
| Phase | Description | Statut | Progression |
|
| Version | Description | Statut | Tests |
|
||||||
|-------|-------------|--------|-------------|
|
|---------|-------------|--------|-------|
|
||||||
| Phase 1 | Documents & Planification | ✅ | 100% |
|
| v0.2–v0.9 | Base → Backend | ✅ | — |
|
||||||
| Phase 2 | Layout Notion (sidebar + topbar + contenu) | ✅ | 100% |
|
| v1.0 | Production (lifespan, CI/CD) | ✅ | 28/28 |
|
||||||
| Phase 3 | Sidebar (sections, hover, context menu) | ✅ | 100% |
|
| v1.1 | Pages & Sidebar Notion | ✅ | — |
|
||||||
| Phase 4 | Vues (Kanban, Table, Status, Team Load) | 🚧 | 40% |
|
| v1.2 | Éditeur Notion (slash menu, blocs) | ✅ | — |
|
||||||
| Phase 5 | Filtres & Tri | 🚧 | 25% |
|
| v1.3 | Database Concept (collections) | ✅ | 34/34 |
|
||||||
| Phase 6 | Cartes enrichies | 🚧 | 30% |
|
| v1.4 | Propriétés Avancées (21 types) | ✅ | 38/38 |
|
||||||
| Phase 7 | Intégration Gitea complète | 🚧 | 15% |
|
| v1.5 | Relations, Rollups, Formulas | ✅ | 43/43 |
|
||||||
| Phase 8 | Polish & Tests | ⬜ | 0% |
|
| v1.6 | Vues (Calendar, Gallery, List, Timeline) | ✅ | 49/49 |
|
||||||
|
| v1.7 | Vues Améliorées (view config, save-as) | ✅ | — |
|
||||||
|
| v1.8 | Sub-items & Dépendances | ✅ | 56/56 |
|
||||||
|
| v1.9 | My Tasks Dashboard | ✅ | 60/60 |
|
||||||
|
| v2.0 | Multi-User + Editor Complete | ✅ | 67/67 |
|
||||||
|
| v2.1 | Public API, Webhooks, PWA | ✅ | 73/73 |
|
||||||
|
| v2.2 | Share/Publish, Favorites, Library | ✅ | 73/73 |
|
||||||
|
| v3.0 | **Auth locale, Multi-Forge, Standalone** | 🔲 | — |
|
||||||
|
|
||||||
---
|
## Blocs Complétés
|
||||||
|
|
||||||
## Phase 1: Documents & Planification
|
| Bloc | Features | Statut |
|
||||||
|
|------|----------|--------|
|
||||||
|
| Bloc 1 | Database Concept | ✅ 6/6 |
|
||||||
|
| Bloc 2 | Propriétés Avancées | ✅ 14/14 |
|
||||||
|
| Bloc 3 | Vues Manquantes | ✅ 10/10 |
|
||||||
|
| Bloc 4 | Sub-items & Dépendances | ✅ 7/7 |
|
||||||
|
| Bloc 5 | My Tasks | ✅ 7/7 |
|
||||||
|
| Bloc 6 | Éditeur Complet | ✅ 8/8 |
|
||||||
|
| Bloc 7 | Fonctions Transversales | ✅ |
|
||||||
|
|
||||||
### 1.1 Analyse des images Notion
|
**Total: 52/52 features (100%)**
|
||||||
- [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)
|
|
||||||
|
|
||||||
### 1.2 Documents
|
## Architecture
|
||||||
- [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)
|
|
||||||
|
|
||||||
---
|
### Tables (21)
|
||||||
|
`users`, `user_tokens`, `boards`, `cards`, `notes`, `col_mapping`, `checklists`, `checklist_items`, `project_properties`, `property_values`, `ai_keywords`, `pages`, `collections`, `collection_pages`, `collection_views`, `collection_properties`, `workspaces`, `workspace_members`, `comments`, `page_history`, `favorites`, `database_templates`, `page_templates`, `webhook_subscriptions`
|
||||||
|
|
||||||
## Phase 2: Layout Notion
|
### Routeurs (11)
|
||||||
|
`dashboard`, `board`, `notes`, `api`, `auth`, `webhooks`, `collections`, `my_tasks`, `workspace`, `public_api` + app-level routes
|
||||||
|
|
||||||
### 2.1 Structure HTML
|
### Services (6)
|
||||||
- [x] `base.html`: layout 3 zones (sidebar fixe gauche, topbar fixe haut, contenu scrollable)
|
`GiteaClient`, `FormulaEngine`, `RollupEngine`, `GiteaBoardCompat`, `property_types`, `webhook_outbound`
|
||||||
- [x] Fond global: `#191919`
|
|
||||||
- [x] Police Inter importée via Google Fonts
|
|
||||||
|
|
||||||
### 2.2 Sidebar (gauche, ~240px, fond #222)
|
### Endpoints (85+)
|
||||||
- [x] Workspace info (avatar B + nom Bruno)
|
CRUD collections/pages, 5 vues HTML, relations/rollups/formulas, sub-items/dependencies, my-tasks, workspaces, comments, history, favorites, templates, CSV import/export, public sharing, public API v1, webhooks, PWA manifest
|
||||||
- [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)
|
## Stack
|
||||||
- [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)
|
- **Backend**: Python 3.12 + FastAPI + httpx
|
||||||
- [x] Image de couverture optionnelle
|
- **Frontend**: Jinja2 + HTMX + Alpine.js + SortableJS + CSS 31KB
|
||||||
- [x] Titre de page (28-32px bold)
|
- **BDD**: SQLite WAL mode, 21 tables, foreign keys ON
|
||||||
- [x] Barre de navigation des vues (onglets)
|
- **Auth**: OAuth2 Gitea + sessions signed (itsdangerous) + token API
|
||||||
- [x] Barre d'outils (filtres, tri, recherche, New)
|
- **Déploiement**: Docker (python:3.12-slim), docker-compose, port 8080
|
||||||
- [x] Zone de contenu principale (Kanban / Table / Graphiques)
|
- **Tests**: pytest, 73 tests, TestClient avec SQLite temporaire
|
||||||
|
- **CI/CD**: Gitea Actions (.gitea/workflows/ci.yml)
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: CSS — Design System
|
|
||||||
|
|
||||||
### 3.1 Variables CSS
|
|
||||||
```css
|
|
||||||
:root {
|
|
||||||
--bg-primary: #191919;
|
|
||||||
--bg-secondary: #222222;
|
|
||||||
--bg-tertiary: #333333;
|
|
||||||
--bg-hover: #2F2E2E;
|
|
||||||
--text-primary: #FFFFFF;
|
|
||||||
--text-secondary: #A0A0A0;
|
|
||||||
--color-blue: #3366CC;
|
|
||||||
--color-green: #00CC66;
|
|
||||||
--color-yellow: #FFD700;
|
|
||||||
--font-sans: 'Inter', sans-serif;
|
|
||||||
--radius-sm: 4px;
|
|
||||||
--radius-md: 6px;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 Composants
|
|
||||||
- [x] `.sidebar` — 240px fixe, fond #222
|
|
||||||
- [x] `.sidebar-section` — titre 12px + liste
|
|
||||||
- [x] `.sidebar-item` — 14px, padding 8px 12px, hover #333
|
|
||||||
- [x] `.sidebar-item.active` — fond #333
|
|
||||||
- [x] `.topbar` — hauteur 44px, flex
|
|
||||||
- [x] `.view-tabs` — onglets horizontaux
|
|
||||||
- [x] `.view-tab` — inactif: texte gris, actif: fond #333 + texte blanc
|
|
||||||
- [x] `.kanban-board` — flex horizontal, gap 12px
|
|
||||||
- [x] `.kanban-column` — min-width 260px, arrondi 6px
|
|
||||||
- [x] `.kanban-card` — fond selon statut, padding 12px, arrondi 6px
|
|
||||||
- [x] `.btn-primary` — bleu #3366CC
|
|
||||||
- [x] `.btn-secondary` — gris #333333
|
|
||||||
- [x] `.filter-pill` — pastille avec texte + croix de suppression
|
|
||||||
- [x] `.sort-panel` — menu déroulant avec options
|
|
||||||
- [x] `.dropdown-menu` — fond #333, bordure subtile, ombre
|
|
||||||
- [x] `.tooltip` — fond #3A3A3A, texte blanc
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: JavaScript (Alpine.js)
|
|
||||||
|
|
||||||
### 4.1 Sidebar
|
|
||||||
- [x] Toggle collapse/expand sidebar
|
|
||||||
- [x] Hover reveal buttons (... et +)
|
|
||||||
- [x] Context menu au clic droit
|
|
||||||
- [ ] Drag & drop pour réorganiser les favoris
|
|
||||||
|
|
||||||
### 4.2 Vues
|
|
||||||
- [x] Switch entre vues (Kanban, Table, Status, Team Load, Detailed)
|
|
||||||
- [x] Kanban: drag & drop cartes (SortableJS)
|
|
||||||
- [ ] Table: tri par colonne, redimensionnement
|
|
||||||
- [ ] Status Overview: Donut chart (SVG ou Canvas)
|
|
||||||
- [ ] Team Load: Barres empilées
|
|
||||||
|
|
||||||
### 4.3 Filtres & Tri
|
|
||||||
- [x] Ajout/suppression filtres (UI)
|
|
||||||
- [ ] Logique AND entre filtres
|
|
||||||
- [ ] Tri multi-niveaux
|
|
||||||
- [ ] Persistance dans l'URL ou localStorage
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: Backend (FastAPI)
|
|
||||||
|
|
||||||
### 5.1 Nouvelles routes API
|
|
||||||
- [ ] `GET /api/views/{owner}/{repo}` — config des vues
|
|
||||||
- [ ] `POST /api/filters` — appliquer filtres
|
|
||||||
- [ ] `POST /api/sorts` — appliquer tris
|
|
||||||
- [ ] `GET /api/status-overview/{owner}/{repo}` — données donut chart
|
|
||||||
- [ ] `GET /api/team-load/{owner}/{repo}` — données barres empilées
|
|
||||||
- [ ] `GET /api/table-view/{owner}/{repo}` — données format tableau
|
|
||||||
|
|
||||||
### 5.2 Base de données (nouvelles tables)
|
|
||||||
- [ ] `view_config` — configuration par vue
|
|
||||||
- [ ] `filters` — filtres sauvegardés par utilisateur
|
|
||||||
- [ ] `sorts` — tris sauvegardés
|
|
||||||
- [ ] `card_properties` — propriétés custom par carte
|
|
||||||
|
|
||||||
### 5.3 Gitea Sync
|
|
||||||
- [x] Labels → tags couleur sur cartes
|
|
||||||
- [ ] Milestones → groupes/sprints
|
|
||||||
- [x] Assignees → avatars + noms
|
|
||||||
- [x] Due dates → échéances (via Gitea API)
|
|
||||||
- [ ] Comments → affichage dans cartes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- **Couleurs exactes** extraites des analyses: fond `#191919`, sidebar `#222222`, actif `#333333`, hover `#2F2E2E`, texte `#FFFFFF`, secondaire `#A0A0A0`
|
|
||||||
- **Police**: Inter (Google Fonts), tailles: 12px (petit), 14px (normal), 28-32px (titres)
|
|
||||||
- **Coins arrondis**: 4px (boutons/cartes), 6px (colonnes)
|
|
||||||
- **Espacements**: padding 8-12px, gap 8-12px
|
|
||||||
- **Icônes**: utiliser des émojis ou SVG inline pour éviter dépendance externe
|
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""Multi-forge OAuth providers — Gitea + GitHub."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthProvider(ABC):
|
||||||
|
"""Abstract OAuth2 provider interface."""
|
||||||
|
|
||||||
|
name: str = ""
|
||||||
|
icon: str = "🔗"
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
"""Whether this provider is configured."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
||||||
|
"""Build the authorization URL."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def exchange_code(self, code: str, redirect_uri: str | None = None) -> dict | None:
|
||||||
|
"""Exchange authorization code for access token."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_user(self, access_token: str) -> dict | None:
|
||||||
|
"""Fetch user profile from the provider."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_repositories(self, access_token: str) -> list[dict]:
|
||||||
|
"""List all repositories accessible to this user."""
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaProvider(OAuthProvider):
|
||||||
|
"""Gitea OAuth2 provider."""
|
||||||
|
|
||||||
|
name = "gitea"
|
||||||
|
icon = "🔗"
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, client_id: str, client_secret: str, redirect_uri: str):
|
||||||
|
self.base = base_url.rstrip("/")
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
|
self.redirect_uri = redirect_uri
|
||||||
|
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
return bool(self.client_id and self.client_secret)
|
||||||
|
|
||||||
|
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
||||||
|
params = {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"redirect_uri": redirect_uri or self.redirect_uri,
|
||||||
|
"response_type": "code",
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
if force_login:
|
||||||
|
# Gitea supports prompt=login param (undocumented but works)
|
||||||
|
# If not, fallback: add _force= timestamp cache-buster
|
||||||
|
params["_force"] = str(int(time.time()))
|
||||||
|
return f"{self.base}/login/oauth/authorize?" + urlencode(params)
|
||||||
|
|
||||||
|
async def exchange_code(self, code: str, redirect_uri: str | None = None) -> dict | None:
|
||||||
|
url = f"{self.base}/login/oauth/access_token"
|
||||||
|
data = {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"code": code,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"redirect_uri": redirect_uri or self.redirect_uri,
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
r = await client.post(url, json=data, headers={"Accept": "application/json"})
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.error("Gitea token exchange failed: %s", r.text)
|
||||||
|
return None
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
async def get_user(self, access_token: str) -> dict | None:
|
||||||
|
url = f"{self.base}/api/v1/user"
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
r = await client.get(url, headers={"Authorization": f"token {access_token}"})
|
||||||
|
if r.status_code != 200:
|
||||||
|
return None
|
||||||
|
data = r.json()
|
||||||
|
return {
|
||||||
|
"login": data.get("login") or data.get("username", ""),
|
||||||
|
"full_name": data.get("full_name", ""),
|
||||||
|
"email": data.get("email", ""),
|
||||||
|
"avatar_url": data.get("avatar_url", ""),
|
||||||
|
"provider_id": str(data.get("id", "")),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def list_repositories(self, access_token: str) -> list[dict]:
|
||||||
|
repos = []
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
for page in range(1, 6):
|
||||||
|
r = await client.get(
|
||||||
|
f"{self.base}/api/v1/user/repos",
|
||||||
|
headers={"Authorization": f"token {access_token}"},
|
||||||
|
params={"page": page, "limit": 50},
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
break
|
||||||
|
data = r.json()
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
for repo in data:
|
||||||
|
repos.append({
|
||||||
|
"id": str(repo.get("id", "")),
|
||||||
|
"name": repo.get("name", ""),
|
||||||
|
"full_name": repo.get("full_name", ""),
|
||||||
|
"description": repo.get("description", ""),
|
||||||
|
"html_url": repo.get("html_url", ""),
|
||||||
|
"clone_url": repo.get("clone_url", ""),
|
||||||
|
"default_branch": repo.get("default_branch", "main"),
|
||||||
|
"language": repo.get("language", ""),
|
||||||
|
"updated_at": repo.get("updated_at", ""),
|
||||||
|
"private": repo.get("private", False),
|
||||||
|
"forge": "gitea",
|
||||||
|
})
|
||||||
|
return repos
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubProvider(OAuthProvider):
|
||||||
|
"""GitHub OAuth2 provider."""
|
||||||
|
|
||||||
|
name = "github"
|
||||||
|
icon = "🐙"
|
||||||
|
|
||||||
|
def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
|
self.redirect_uri = redirect_uri
|
||||||
|
self.authorize_url = "https://github.com/login/oauth/authorize"
|
||||||
|
self.token_url = "https://github.com/login/oauth/access_token"
|
||||||
|
self.api_url = "https://api.github.com"
|
||||||
|
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
return bool(self.client_id and self.client_secret)
|
||||||
|
|
||||||
|
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
||||||
|
return (
|
||||||
|
f"{self.authorize_url}?"
|
||||||
|
+ urlencode({
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"redirect_uri": redirect_uri or self.redirect_uri,
|
||||||
|
"scope": "repo,user",
|
||||||
|
"state": state,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
async def exchange_code(self, code: str, redirect_uri: str | None = None) -> dict | None:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
r = await client.post(
|
||||||
|
self.token_url,
|
||||||
|
data={
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": self.redirect_uri,
|
||||||
|
},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.error("GitHub token exchange failed: %s", r.text)
|
||||||
|
return None
|
||||||
|
data = r.json()
|
||||||
|
if "access_token" not in data:
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_user(self, access_token: str) -> dict | None:
|
||||||
|
url = f"{self.api_url}/user"
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
r = await client.get(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"},
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return None
|
||||||
|
data = r.json()
|
||||||
|
return {
|
||||||
|
"login": data.get("login", ""),
|
||||||
|
"full_name": data.get("name", "") or data.get("login", ""),
|
||||||
|
"email": data.get("email", ""),
|
||||||
|
"avatar_url": data.get("avatar_url", ""),
|
||||||
|
"provider_id": str(data.get("id", "")),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def list_repositories(self, access_token: str) -> list[dict]:
|
||||||
|
repos = []
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
for page in range(1, 6):
|
||||||
|
r = await client.get(
|
||||||
|
f"{self.api_url}/user/repos",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"},
|
||||||
|
params={"page": page, "per_page": 50, "sort": "updated"},
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
break
|
||||||
|
data = r.json()
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
for repo in data:
|
||||||
|
repos.append({
|
||||||
|
"id": str(repo.get("id", "")),
|
||||||
|
"name": repo.get("name", ""),
|
||||||
|
"full_name": repo.get("full_name", ""),
|
||||||
|
"description": repo.get("description", ""),
|
||||||
|
"html_url": repo.get("html_url", ""),
|
||||||
|
"clone_url": repo.get("clone_url", ""),
|
||||||
|
"default_branch": repo.get("default_branch", "main"),
|
||||||
|
"language": repo.get("language", ""),
|
||||||
|
"updated_at": repo.get("updated_at", ""),
|
||||||
|
"private": repo.get("private", False),
|
||||||
|
"forge": "github",
|
||||||
|
})
|
||||||
|
return repos
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════ Provider registry ═══════════
|
||||||
|
|
||||||
|
def get_providers() -> list[OAuthProvider]:
|
||||||
|
"""Return all configured OAuth providers."""
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
providers: list[OAuthProvider] = []
|
||||||
|
|
||||||
|
gitea = GiteaProvider(
|
||||||
|
base_url=settings.gitea_url,
|
||||||
|
client_id=settings.gitea_oauth_client_id,
|
||||||
|
client_secret=settings.gitea_oauth_client_secret,
|
||||||
|
redirect_uri=settings.oauth_redirect_uri,
|
||||||
|
)
|
||||||
|
if gitea.is_enabled():
|
||||||
|
providers.append(gitea)
|
||||||
|
|
||||||
|
github = GitHubProvider(
|
||||||
|
client_id=settings.github_oauth_client_id or "",
|
||||||
|
client_secret=settings.github_oauth_client_secret or "",
|
||||||
|
redirect_uri=settings.oauth_redirect_uri or "",
|
||||||
|
)
|
||||||
|
if github.is_enabled():
|
||||||
|
providers.append(github)
|
||||||
|
|
||||||
|
return providers
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider(name: str) -> OAuthProvider | None:
|
||||||
|
"""Get a specific provider by name."""
|
||||||
|
for p in get_providers():
|
||||||
|
if p.name == name:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
@@ -13,10 +13,17 @@ class Settings(BaseSettings):
|
|||||||
# Gitea
|
# Gitea
|
||||||
gitea_url: str = "https://git.dracodev.net"
|
gitea_url: str = "https://git.dracodev.net"
|
||||||
gitea_token: str = "change-me"
|
gitea_token: str = "change-me"
|
||||||
gitea_oauth_client_id: str = ""
|
gitea_oauth_client_id: str = "test-id"
|
||||||
gitea_oauth_client_secret: str = ""
|
gitea_oauth_client_secret: str = "test-secret"
|
||||||
gitea_webhook_secret: str = ""
|
gitea_webhook_secret: str = ""
|
||||||
|
|
||||||
|
# GitHub
|
||||||
|
github_oauth_client_id: str = ""
|
||||||
|
github_oauth_client_secret: str = ""
|
||||||
|
|
||||||
|
# Standalone mode
|
||||||
|
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
|
||||||
|
|
||||||
# OAuth2
|
# OAuth2
|
||||||
oauth_redirect_uri: str = "http://localhost:8080/auth/callback"
|
oauth_redirect_uri: str = "http://localhost:8080/auth/callback"
|
||||||
|
|
||||||
@@ -46,7 +53,12 @@ class Settings(BaseSettings):
|
|||||||
if self.database_url == "sqlite:///:memory:":
|
if self.database_url == "sqlite:///:memory:":
|
||||||
return Path(":memory:") # Special SQLite in-memory
|
return Path(":memory:") # Special SQLite in-memory
|
||||||
if self.database_url.startswith("sqlite:///"):
|
if self.database_url.startswith("sqlite:///"):
|
||||||
return Path(self.database_url.replace("sqlite:///", "/"))
|
p = self.database_url.replace("sqlite:///", "", 1)
|
||||||
|
# On Windows, don't prepend / if path starts with a drive letter
|
||||||
|
import re
|
||||||
|
if re.match(r'^[a-zA-Z]:', p):
|
||||||
|
return Path(p)
|
||||||
|
return Path("/" + p)
|
||||||
return Path("/data/flowdeck.db")
|
return Path("/data/flowdeck.db")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,24 @@ def init_db():
|
|||||||
full_name TEXT NOT NULL DEFAULT '',
|
full_name TEXT NOT NULL DEFAULT '',
|
||||||
email TEXT NOT NULL DEFAULT '',
|
email TEXT NOT NULL DEFAULT '',
|
||||||
avatar_url TEXT NOT NULL DEFAULT '',
|
avatar_url TEXT NOT NULL DEFAULT '',
|
||||||
is_admin BOOLEAN NOT NULL DEFAULT 0,
|
avatar_color TEXT NOT NULL DEFAULT '#3A3A3A',
|
||||||
|
password_hash TEXT,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_login TIMESTAMP,
|
||||||
|
login_attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
locked_until TIMESTAMP,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS login_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
ip_address TEXT DEFAULT '',
|
||||||
|
user_agent TEXT DEFAULT '',
|
||||||
|
logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_tokens (
|
CREATE TABLE IF NOT EXISTS user_tokens (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
gitea_user_id INTEGER NOT NULL UNIQUE,
|
gitea_user_id INTEGER NOT NULL UNIQUE,
|
||||||
@@ -39,6 +53,19 @@ def init_db():
|
|||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_oauth_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
refresh_token TEXT,
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
instance_url TEXT DEFAULT '',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, provider)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS boards (
|
CREATE TABLE IF NOT EXISTS boards (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
project_owner TEXT NOT NULL,
|
project_owner TEXT NOT NULL,
|
||||||
@@ -105,8 +132,8 @@ def init_db():
|
|||||||
project_owner TEXT NOT NULL,
|
project_owner TEXT NOT NULL,
|
||||||
project_name TEXT NOT NULL,
|
project_name TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
prop_type TEXT NOT NULL DEFAULT 'select', -- select, multi_select, date, person, text
|
prop_type TEXT NOT NULL DEFAULT 'select',
|
||||||
options_json TEXT DEFAULT '[]', -- for select/multi_select
|
options_json TEXT DEFAULT '[]',
|
||||||
position INTEGER NOT NULL DEFAULT 0,
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(project_owner, project_name, name)
|
UNIQUE(project_owner, project_name, name)
|
||||||
@@ -134,10 +161,13 @@ def init_db():
|
|||||||
CREATE TABLE IF NOT EXISTS pages (
|
CREATE TABLE IF NOT EXISTS pages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
workspace TEXT NOT NULL,
|
workspace TEXT NOT NULL,
|
||||||
|
workspace_id INTEGER REFERENCES workspaces(id),
|
||||||
title TEXT NOT NULL DEFAULT 'New page',
|
title TEXT NOT NULL DEFAULT 'New page',
|
||||||
content TEXT NOT NULL DEFAULT '',
|
content TEXT NOT NULL DEFAULT '',
|
||||||
parent_section TEXT DEFAULT 'Private',
|
parent_section TEXT DEFAULT 'Private',
|
||||||
parent_id INTEGER REFERENCES pages(id),
|
parent_id INTEGER REFERENCES pages(id),
|
||||||
|
share_mode TEXT DEFAULT 'private',
|
||||||
|
published INTEGER DEFAULT 0,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -162,7 +192,7 @@ def init_db():
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
title TEXT NOT NULL DEFAULT '',
|
title TEXT NOT NULL DEFAULT '',
|
||||||
icon TEXT DEFAULT '📄',
|
icon TEXT DEFAULT 'file',
|
||||||
position INTEGER NOT NULL DEFAULT 0,
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
parent_id INTEGER REFERENCES collection_pages(id),
|
parent_id INTEGER REFERENCES collection_pages(id),
|
||||||
gitea_issue_id INTEGER,
|
gitea_issue_id INTEGER,
|
||||||
@@ -184,12 +214,124 @@ def init_db():
|
|||||||
position INTEGER NOT NULL DEFAULT 0,
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- v1.4.0: Propriétés Avancées — collection_properties (remplace project_properties)
|
||||||
|
CREATE TABLE IF NOT EXISTS collection_properties (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
prop_type TEXT NOT NULL DEFAULT 'text',
|
||||||
|
options_json TEXT DEFAULT '[]',
|
||||||
|
number_format TEXT DEFAULT 'number',
|
||||||
|
related_collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL,
|
||||||
|
reverse_name TEXT,
|
||||||
|
relation_property_id INTEGER REFERENCES collection_properties(id) ON DELETE SET NULL,
|
||||||
|
target_property_id INTEGER REFERENCES collection_properties(id) ON DELETE SET NULL,
|
||||||
|
rollup_function TEXT,
|
||||||
|
formula_expression TEXT,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
required BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
visible_in_views BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(collection_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- v2.0.0: Multi-user — workspaces, members, comments, history, favorites, templates
|
||||||
|
CREATE TABLE IF NOT EXISTS workspaces (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
settings_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
role TEXT NOT NULL DEFAULT 'editor',
|
||||||
|
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(workspace_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS comments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
parent_id INTEGER REFERENCES comments(id),
|
||||||
|
resolved BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS page_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
change_type TEXT NOT NULL,
|
||||||
|
snapshot_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- v2.2.0: Migrate favorites — drop old schema referencing collection_pages
|
||||||
|
DROP TABLE IF EXISTS favorites;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
page_id INTEGER NOT NULL REFERENCES pages(id),
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, page_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS database_templates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
schema_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS page_templates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL DEFAULT 'Default',
|
||||||
|
property_values_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
content_json TEXT DEFAULT '[]',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- v2.2.0: Tags system
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT DEFAULT '#787774',
|
||||||
|
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(name, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS page_tags (
|
||||||
|
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (page_id, tag_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pt_page ON page_tags(page_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pt_tag ON page_tags(tag_id);
|
||||||
""")
|
""")
|
||||||
# Migration: add parent_id if missing (v1.0.0+)
|
# Migration: add parent_id if missing (v1.0.0+)
|
||||||
try:
|
try:
|
||||||
conn.execute("ALTER TABLE pages ADD COLUMN parent_id INTEGER REFERENCES pages(id)")
|
conn.execute("ALTER TABLE pages ADD COLUMN parent_id INTEGER REFERENCES pages(id)")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
|
# Migration: add avatar_color (v2.3.0+)
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE users ADD COLUMN avatar_color TEXT NOT NULL DEFAULT '#3A3A3A'")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
# Migration: add sort_order for drag & drop tree reordering (v1.1.0+)
|
# Migration: add sort_order for drag & drop tree reordering (v1.1.0+)
|
||||||
try:
|
try:
|
||||||
conn.execute("ALTER TABLE pages ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0")
|
conn.execute("ALTER TABLE pages ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0")
|
||||||
@@ -206,6 +348,171 @@ def init_db():
|
|||||||
conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT")
|
conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'private'")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE pages ADD COLUMN published INTEGER DEFAULT 0")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE pages ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
# v2.2: Auth locale
|
||||||
|
for col in ["password_hash", "is_active", "last_login", "login_attempts", "locked_until"]:
|
||||||
|
try:
|
||||||
|
conn.execute(f"ALTER TABLE users ADD COLUMN {col} {'TEXT' if col in ('password_hash','last_login','locked_until') else 'INTEGER NOT NULL DEFAULT ' + ('1' if col=='is_active' else '0')}")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
# v2.5: Login history
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("""CREATE TABLE IF NOT EXISTS login_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
ip_address TEXT DEFAULT '',
|
||||||
|
user_agent TEXT DEFAULT '',
|
||||||
|
logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)""")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
# v2.6: Tags per-user
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE tags ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id)")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
# Recreate UNIQUE constraint for per-user tags
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS tags_new (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, color TEXT DEFAULT '#787774', user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(name, user_id))")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO tags_new (id, name, color, user_id, created_at) SELECT id, name, color, COALESCE(user_id,0), created_at FROM tags")
|
||||||
|
conn.execute("DROP TABLE tags")
|
||||||
|
conn.execute("ALTER TABLE tags_new RENAME TO tags")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
conn.commit()
|
||||||
|
# v2.7: Private pages for Gitea workspaces
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS gitea_private_pages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
gitea_owner TEXT NOT NULL,
|
||||||
|
gitea_repo TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT 'Untitled',
|
||||||
|
content TEXT DEFAULT '',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# ── v4.0: Account & Integrations ──
|
||||||
|
# 1) auth_method on users
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE users ADD COLUMN auth_method TEXT DEFAULT 'local'")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
# Detect existing auth: if user has a gitea_token in user_tokens → 'gitea'
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET auth_method='gitea' WHERE id IN (SELECT gitea_user_id FROM user_tokens)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET auth_method='local' WHERE auth_method IS NULL"
|
||||||
|
)
|
||||||
|
# 2) Publishing & sharing columns on pages
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE pages ADD COLUMN is_published INTEGER NOT NULL DEFAULT 0")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE pages ADD COLUMN publish_slug TEXT DEFAULT ''")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE pages ADD COLUMN is_shared INTEGER NOT NULL DEFAULT 0")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
# 3) page_shares table
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS page_shares (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||||
|
shared_with_user_id INTEGER REFERENCES users(id),
|
||||||
|
shared_with_email TEXT DEFAULT '',
|
||||||
|
permission TEXT NOT NULL DEFAULT 'view',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INTEGER REFERENCES users(id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# 4) recents table
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS recents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
page_id INTEGER NOT NULL REFERENCES pages(id),
|
||||||
|
workspace TEXT NOT NULL DEFAULT '',
|
||||||
|
source_type TEXT NOT NULL DEFAULT 'local',
|
||||||
|
accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, page_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# v4.1.0: Data Sources & Linked Databases
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS collection_data_sources (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
|
source_collection_id INTEGER NOT NULL REFERENCES collections(id),
|
||||||
|
source_name TEXT DEFAULT '',
|
||||||
|
is_linked BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(collection_id, source_collection_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# Add workspace_id to collections if missing (v4.1.0 migration)
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE collections ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
# Add created_by to collections if missing
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE collections ADD COLUMN created_by INTEGER REFERENCES users(id)")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# v4.2.0: Collection Templates (enhanced) + Dashboards
|
||||||
|
# Add description, is_recurring, recurrence_rule to page_templates
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE page_templates ADD COLUMN description TEXT DEFAULT ''")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE page_templates ADD COLUMN is_recurring BOOLEAN NOT NULL DEFAULT 0")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE page_templates ADD COLUMN recurrence_rule TEXT DEFAULT ''")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Dashboard: combinaison de vues/widgets sur une page
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS collection_dashboards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL DEFAULT 'Dashboard',
|
||||||
|
layout_json TEXT NOT NULL DEFAULT '{"columns":1,"widgets":[]}',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
@@ -12,8 +12,12 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import init_db
|
from app.db import init_db
|
||||||
from app.middleware.csrf import CSRFMiddleware
|
from app.middleware.csrf import CSRFMiddleware
|
||||||
from app.routers import dashboard, board, notes, api, auth, webhooks, collections
|
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware
|
||||||
|
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing
|
||||||
|
from app.routers.gitea import router as gitea_router
|
||||||
|
from app.routers.github_routes import router as github_router
|
||||||
from app.services.gitea_client import gitea
|
from app.services.gitea_client import gitea
|
||||||
|
from app.services.webhook_outbound import init_webhook_tables
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
||||||
@@ -25,19 +29,23 @@ logger = logging.getLogger(__name__)
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI):
|
async def lifespan(_app: FastAPI):
|
||||||
init_db()
|
init_db()
|
||||||
|
init_webhook_tables()
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
from app.password_utils import hash_password
|
||||||
|
admin_hash = hash_password("FlowDeck2026!")
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)"
|
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)",
|
||||||
|
(admin_hash,)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info("FlowDeck v1.3.0 started on port %d", settings.app_port)
|
logger.info("FlowDeck v4.0.0 started on port %d", settings.app_port)
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="1.3.0",
|
version="4.0.0",
|
||||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
@@ -45,6 +53,8 @@ app = FastAPI(
|
|||||||
|
|
||||||
app.add_middleware(SessionMiddleware, secret_key=settings.app_secret_key, max_age=3600)
|
app.add_middleware(SessionMiddleware, secret_key=settings.app_secret_key, max_age=3600)
|
||||||
app.add_middleware(CSRFMiddleware)
|
app.add_middleware(CSRFMiddleware)
|
||||||
|
app.add_middleware(ContentSecurityPolicyMiddleware)
|
||||||
|
app.add_middleware(RateLimitMiddleware)
|
||||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
@@ -54,5 +64,98 @@ app.include_router(notes.router)
|
|||||||
app.include_router(api.router)
|
app.include_router(api.router)
|
||||||
app.include_router(webhooks.router)
|
app.include_router(webhooks.router)
|
||||||
app.include_router(collections.router)
|
app.include_router(collections.router)
|
||||||
|
app.include_router(my_tasks.router)
|
||||||
|
app.include_router(workspace.router)
|
||||||
|
app.include_router(library.router)
|
||||||
|
app.include_router(admin.router)
|
||||||
|
app.include_router(gitea_router)
|
||||||
|
app.include_router(github_router)
|
||||||
|
app.include_router(public_api.router)
|
||||||
|
app.include_router(sharing.router)
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
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"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════ API aliases (v4.0.1) ═══════════
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/csrf-token")
|
||||||
|
async def csrf_token_endpoint(request: Request):
|
||||||
|
"""Return a fresh CSRF token. Used by the frontend to auto-recover from 403."""
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import secrets
|
||||||
|
token = secrets.token_hex(32)
|
||||||
|
response = JSONResponse({"csrf_token": token})
|
||||||
|
response.set_cookie(
|
||||||
|
"csrf_token", token,
|
||||||
|
httponly=False, samesite="lax", max_age=86400, path="/",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/pages")
|
||||||
|
async def api_pages_alias(request: Request):
|
||||||
|
"""Alias /api/pages → /board/api/pages for API path consistency."""
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
qs = str(request.url.query)
|
||||||
|
target = f"/board/api/pages{'?' + qs if qs else ''}"
|
||||||
|
return RedirectResponse(url=target, status_code=307)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/pages")
|
||||||
|
async def api_pages_post_alias(request: Request):
|
||||||
|
"""Alias POST /api/pages → /board/api/pages for API path consistency."""
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
return RedirectResponse(url="/board/api/pages", status_code=307)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════ Styled 404 handler ═══════════
|
||||||
|
|
||||||
|
|
||||||
|
NOT_FOUND_HTML = """<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>404 — FlowDeck</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#191919;--text:#e0e0e0;--text-dim:#999;--accent:#2383E2}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center}
|
||||||
|
.box h1{font-size:6rem;font-weight:800;opacity:.08;line-height:1}
|
||||||
|
.box p{font-size:18px;color:var(--text-dim);margin:8px 0 24px}
|
||||||
|
.box a{color:var(--accent);text-decoration:none;font-size:14px}
|
||||||
|
.box a:hover{text-decoration:underline}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="box">
|
||||||
|
<h1>404</h1>
|
||||||
|
<p>This page doesn't exist or has been moved.</p>
|
||||||
|
<a href="/">← Back to FlowDeck</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(404)
|
||||||
|
async def not_found_handler(request: Request, exc):
|
||||||
|
"""Redirect 404 HTML pages to /workspaces. API routes still get JSON."""
|
||||||
|
# Preserve JSON 404 for all API-like paths (including /db/xxx/api)
|
||||||
|
if "/api" in request.url.path:
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse({"detail": "Not found"}, status_code=404)
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
return RedirectResponse("/workspaces", status_code=302)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||||
EXCLUDED_PATHS = {"/api/webhook", "/auth/callback", "/board/api/pages", "/db/"}
|
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token"}
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||||
@@ -33,6 +33,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|||||||
httponly=False, # Must be readable by JS
|
httponly=False, # Must be readable by JS
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
max_age=86400,
|
max_age=86400,
|
||||||
|
path="/",
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""FlowDeck — Security middleware: CSP headers + rate limiting."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
# ── Constants ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Allowed extensions for file uploads
|
||||||
|
ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
|
||||||
|
# Images
|
||||||
|
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico",
|
||||||
|
# Documents
|
||||||
|
".pdf", ".md", ".markdown", ".txt", ".log", ".csv",
|
||||||
|
# Code
|
||||||
|
".py", ".js", ".jsx", ".ts", ".tsx", ".html", ".htm", ".xml", ".css",
|
||||||
|
".json", ".yaml", ".yml", ".toml", ".sql", ".sh", ".bash", ".zsh",
|
||||||
|
".ps1", ".rs", ".go", ".java", ".rb", ".php", ".c", ".cpp", ".h",
|
||||||
|
".swift", ".kt", ".scala", ".r",
|
||||||
|
# Archives
|
||||||
|
".zip", ".tar", ".gz", ".rar", ".7z",
|
||||||
|
# Misc
|
||||||
|
".env", ".cfg", ".conf", ".ini", ".dockerfile", ".makefile",
|
||||||
|
})
|
||||||
|
|
||||||
|
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
||||||
|
|
||||||
|
def validate_upload(filename: str, size: int) -> str | None:
|
||||||
|
"""Validate upload filename and size. Returns error message or None."""
|
||||||
|
if size > MAX_UPLOAD_SIZE:
|
||||||
|
return f"File '{filename}' exceeds maximum size of 10 MB"
|
||||||
|
ext = _ext(filename)
|
||||||
|
if ext and ext not in ALLOWED_EXTENSIONS:
|
||||||
|
return f"File extension '{ext}' is not allowed"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ext(filename: str) -> str:
|
||||||
|
"""Extract lowercase extension from filename."""
|
||||||
|
if "." in filename:
|
||||||
|
return "." + filename.rsplit(".", 1)[-1].lower()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Content-Security-Policy Middleware ────────────────────────
|
||||||
|
|
||||||
|
class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Sets Content-Security-Policy headers on all HTML responses.
|
||||||
|
|
||||||
|
A permissive-yet-safe policy for a Notion-style app that needs:
|
||||||
|
- inline scripts (Alpine.js, HTMX)
|
||||||
|
- inline styles
|
||||||
|
- font loading
|
||||||
|
- images from various sources
|
||||||
|
- media (audio/video)
|
||||||
|
- websocket connections for HMR/SSE
|
||||||
|
"""
|
||||||
|
|
||||||
|
CSP_HEADER = "Content-Security-Policy"
|
||||||
|
CSP_VALUE = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||||
|
"img-src 'self' data: blob: https:; "
|
||||||
|
"font-src 'self' data: https://fonts.gstatic.com; "
|
||||||
|
"connect-src 'self' https: wss:; "
|
||||||
|
"media-src 'self' blob:; "
|
||||||
|
"frame-src 'self'; "
|
||||||
|
"object-src 'none'; "
|
||||||
|
"base-uri 'self'; "
|
||||||
|
"form-action 'self'; "
|
||||||
|
)
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
# Only set CSP on HTML responses
|
||||||
|
content_type = response.headers.get("content-type", "")
|
||||||
|
if "text/html" in content_type:
|
||||||
|
response.headers[self.CSP_HEADER] = self.CSP_VALUE
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ── Rate Limiting Middleware ──────────────────────────────────
|
||||||
|
|
||||||
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Simple in-memory sliding-window rate limiter per IP.
|
||||||
|
|
||||||
|
Default: 100 requests per minute per IP for API routes.
|
||||||
|
Non-API routes (HTML pages, static files) are not rate-limited.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Paths that should be rate-limited
|
||||||
|
RATE_LIMITED_PREFIXES: tuple[str, ...] = (
|
||||||
|
"/api/", "/board/api/", "/auth/",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Paths exempt from rate limiting even under an API prefix
|
||||||
|
EXEMPT_PATHS: frozenset[str] = frozenset({
|
||||||
|
"/api/health",
|
||||||
|
"/api/frontend-error",
|
||||||
|
"/api/frontend-errors",
|
||||||
|
})
|
||||||
|
|
||||||
|
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
|
||||||
|
super().__init__(app)
|
||||||
|
self.max_requests = max_requests
|
||||||
|
self.window_seconds = window_seconds
|
||||||
|
self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
path = request.url.path
|
||||||
|
|
||||||
|
# Only rate-limit API routes
|
||||||
|
if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Exempt health check and error capture
|
||||||
|
if path in self.EXEMPT_PATHS:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
window_start, count = self._store[ip]
|
||||||
|
if now - window_start > self.window_seconds:
|
||||||
|
self._store[ip] = (now, 1)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
if count >= self.max_requests:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Rate limit exceeded", "detail": f"Max {self.max_requests} req/min per IP"},
|
||||||
|
status_code=429,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._store[ip] = (window_start, count + 1)
|
||||||
|
return await call_next(request)
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""FlowDeck — Pydantic request models for API validation."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import UploadFile
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
from app.middleware.security import ALLOWED_EXTENSIONS, MAX_UPLOAD_SIZE, _ext
|
||||||
|
|
||||||
|
|
||||||
|
# ── File Save ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class FileSaveRequest(BaseModel):
|
||||||
|
"""Request model for saving/updating a file via Gitea."""
|
||||||
|
path: str = Field(..., min_length=1, description="File path in the repository")
|
||||||
|
content: str = Field(..., description="File content (UTF-8 encoded)")
|
||||||
|
message: str = Field(default="Update via FlowDeck", description="Commit message")
|
||||||
|
sha: Optional[str] = Field(default=None, description="SHA of the file being updated (required for updates)")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_path_extension(self):
|
||||||
|
ext = _ext(self.path)
|
||||||
|
if ext and ext not in ALLOWED_EXTENSIONS:
|
||||||
|
raise ValueError(f"File extension '{ext}' is not allowed")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
# ── Upload ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class UploadValidationResult(BaseModel):
|
||||||
|
"""Result of validating an upload."""
|
||||||
|
filename: str
|
||||||
|
size: int
|
||||||
|
extension: str
|
||||||
|
valid: bool
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_upload_request(file: UploadFile) -> Optional[str]:
|
||||||
|
"""Validate an uploaded file (size + extension). Returns error message or None."""
|
||||||
|
# Size check — we can't read the full file without a size attribute,
|
||||||
|
# but Starlette's UploadFile has a size property from Content-Length
|
||||||
|
if hasattr(file, 'size') and file.size is not None and file.size > MAX_UPLOAD_SIZE:
|
||||||
|
return f"File '{file.filename}' exceeds maximum size of 10 MB"
|
||||||
|
|
||||||
|
# Extension check
|
||||||
|
if file.filename:
|
||||||
|
ext = _ext(file.filename)
|
||||||
|
if ext and ext not in ALLOWED_EXTENSIONS:
|
||||||
|
return f"File extension '{ext}' is not allowed"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Issue Create / Update ────────────────────────────────────
|
||||||
|
|
||||||
|
class IssueCreateRequest(BaseModel):
|
||||||
|
"""Request model for creating a Gitea issue."""
|
||||||
|
title: str = Field(..., min_length=1, max_length=500)
|
||||||
|
body: str = Field(default="")
|
||||||
|
labels: str = Field(default="", description="Comma-separated label IDs")
|
||||||
|
milestone: str = Field(default="", description="Milestone ID")
|
||||||
|
assignee: str = Field(default="")
|
||||||
|
|
||||||
|
|
||||||
|
class IssueUpdateRequest(BaseModel):
|
||||||
|
"""Request model for updating a Gitea issue (partial update)."""
|
||||||
|
title: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
body: Optional[str] = Field(default=None)
|
||||||
|
state: Optional[str] = Field(default=None, pattern=r"^(open|closed)$")
|
||||||
|
labels: Optional[str] = Field(default=None, description="Comma-separated label IDs")
|
||||||
|
milestone: Optional[str] = Field(default=None)
|
||||||
|
assignee: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Card Move ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CardMoveRequest(BaseModel):
|
||||||
|
"""Request model for moving a card between columns."""
|
||||||
|
owner: str = Field(..., min_length=1)
|
||||||
|
repo: str = Field(..., min_length=1)
|
||||||
|
issue_id: int = Field(..., gt=0)
|
||||||
|
column: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Column Mapping ───────────────────────────────────────────
|
||||||
|
|
||||||
|
class ColMappingRequest(BaseModel):
|
||||||
|
"""Request model for column-to-label mapping."""
|
||||||
|
owner: str = Field(..., min_length=1)
|
||||||
|
repo: str = Field(..., min_length=1)
|
||||||
|
column: str = Field(..., min_length=1)
|
||||||
|
gitea_label: str = Field(..., min_length=1)
|
||||||
|
close_issue: bool = Field(default=False)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""FlowDeck — Standardized response models."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
"""Standardized error response.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
ErrorResponse(error="Rate limit exceeded", detail="Max 100 req/min per IP")
|
||||||
|
"""
|
||||||
|
error: str
|
||||||
|
detail: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"example": {"error": "Not found", "detail": "Board not found"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SuccessResponse(BaseModel):
|
||||||
|
"""Standardized success response.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
SuccessResponse(status="ok", data={"issue_id": 42})
|
||||||
|
"""
|
||||||
|
status: str = "ok"
|
||||||
|
data: Optional[dict[str, Any]] = None
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"example": {"status": "ok", "data": {"issue_id": 42, "column": "Done"}}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Password hashing and login security utilities."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Hash a password using SHA-256 + random salt (16 bytes).
|
||||||
|
Format: salt_hex:hash_hex (64 + 64 = 128 chars)
|
||||||
|
Fallback for bcrypt — we use SHA-256 for SQLite simplicity
|
||||||
|
but with proper salt per password."""
|
||||||
|
salt = secrets.token_hex(16)
|
||||||
|
h = hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
|
||||||
|
return f"{salt}:{h}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, stored: str) -> bool:
|
||||||
|
"""Verify a password against its stored hash."""
|
||||||
|
try:
|
||||||
|
salt, h = stored.split(":", 1)
|
||||||
|
expected = hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
|
||||||
|
return h == expected
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_locked(locked_until: str | None) -> bool:
|
||||||
|
"""Check if account is temporarily locked."""
|
||||||
|
if not locked_until:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return float(locked_until) > time.time()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""FlowDeck — Admin API: users, roles, stats, audit."""
|
||||||
|
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
router = APIRouter(tags=["admin"], prefix="/api/admin")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dependency ──
|
||||||
|
async def admin_required(request: Request):
|
||||||
|
from app.auth.session import get_current_user
|
||||||
|
user = await get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
|
# Also check DB directly (session cookie may be stale)
|
||||||
|
if not user.get("is_admin"):
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
||||||
|
if not row or not row["is_admin"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# ── Users ──
|
||||||
|
@router.get("/users")
|
||||||
|
async def list_users(_admin=Depends(admin_required)):
|
||||||
|
"""List all users with workspace/file/folder counts and storage usage."""
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT u.id, u.login, u.full_name, u.email, u.is_admin, u.is_active,
|
||||||
|
u.last_login, u.created_at,
|
||||||
|
(SELECT COUNT(*) FROM workspaces WHERE owner_id=u.id) AS ws_count,
|
||||||
|
(SELECT COUNT(*) FROM pages WHERE workspace_id IN (SELECT id FROM workspaces WHERE owner_id=u.id)) AS page_count
|
||||||
|
FROM users u
|
||||||
|
ORDER BY u.id
|
||||||
|
""").fetchall()
|
||||||
|
users = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
d["file_count"] = d["page_count"]
|
||||||
|
d["folder_count"] = 0
|
||||||
|
d["total_mb"] = 0
|
||||||
|
users.append(d)
|
||||||
|
return {"users": users}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users")
|
||||||
|
async def create_user(request: Request, _admin=Depends(admin_required)):
|
||||||
|
"""Create a new user (admin only)."""
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.password_utils import hash_password
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
login = body.get("login", "").strip()
|
||||||
|
name = body.get("name", login)
|
||||||
|
email = body.get("email", login)
|
||||||
|
password = body.get("password", "").strip()
|
||||||
|
is_admin = int(body.get("is_admin", 0))
|
||||||
|
if not login or not password:
|
||||||
|
return JSONResponse({"error": "Login and password required"}, status_code=400)
|
||||||
|
if len(password) < 6:
|
||||||
|
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||||
|
with get_conn() as conn:
|
||||||
|
existing = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()
|
||||||
|
if existing:
|
||||||
|
return JSONResponse({"error": "User already exists"}, status_code=409)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(login, name, email, hash_password(password), is_admin),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
uid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||||
|
return {"status": "ok", "user": {"id": uid, "login": login}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/users/{user_id:int}")
|
||||||
|
async def update_user(user_id: int, request: Request, _admin=Depends(admin_required)):
|
||||||
|
"""Update a user: name, email, password, admin status, active status."""
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.password_utils import hash_password
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
with get_conn() as conn:
|
||||||
|
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||||
|
if not user:
|
||||||
|
return JSONResponse({"error": "User not found"}, status_code=404)
|
||||||
|
if "name" in body:
|
||||||
|
conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["name"], user_id))
|
||||||
|
if "email" in body:
|
||||||
|
conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"], user_id))
|
||||||
|
if "password" in body and body["password"].strip():
|
||||||
|
pw = body["password"].strip()
|
||||||
|
if len(pw) < 6:
|
||||||
|
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||||
|
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), user_id))
|
||||||
|
if "is_admin" in body:
|
||||||
|
conn.execute("UPDATE users SET is_admin=? WHERE id=?", (int(body["is_admin"]), user_id))
|
||||||
|
if "is_active" in body:
|
||||||
|
conn.execute("UPDATE users SET is_active=? WHERE id=?", (int(body["is_active"]), user_id))
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id:int}")
|
||||||
|
async def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||||
|
"""Delete a user and cascade their data."""
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||||
|
if not user:
|
||||||
|
return JSONResponse({"error": "User not found"}, status_code=404)
|
||||||
|
# Cascade delete: first delete child records
|
||||||
|
conn.execute("DELETE FROM login_history WHERE user_id=?", (user_id,))
|
||||||
|
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (user_id,))
|
||||||
|
conn.execute("DELETE FROM user_tokens WHERE gitea_user_id=?", (user_id,))
|
||||||
|
conn.execute("DELETE FROM workspace_members WHERE user_id=?", (user_id,))
|
||||||
|
conn.execute("DELETE FROM comments WHERE user_id=?", (user_id,))
|
||||||
|
conn.execute("DELETE FROM page_history WHERE user_id=?", (user_id,))
|
||||||
|
conn.execute("DELETE FROM favorites WHERE user_id=?", (user_id,))
|
||||||
|
conn.execute("DELETE FROM gitea_private_pages WHERE user_id=?", (user_id,))
|
||||||
|
conn.execute("DELETE FROM tags WHERE user_id=?", (user_id,))
|
||||||
|
# Delete workspaces owned by this user
|
||||||
|
ws_rows = conn.execute("SELECT id FROM workspaces WHERE owner_id=?", (user_id,)).fetchall()
|
||||||
|
for ws in ws_rows:
|
||||||
|
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws["id"],))
|
||||||
|
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws["id"],))
|
||||||
|
conn.execute("DELETE FROM workspaces WHERE owner_id=?", (user_id,))
|
||||||
|
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stats ──
|
||||||
|
@router.get("/stats")
|
||||||
|
async def user_stats(_admin=Depends(admin_required)):
|
||||||
|
"""Aggregate stats: total users, workspaces, files, storage."""
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
|
total_ws = conn.execute("SELECT COUNT(*) FROM workspaces").fetchone()[0]
|
||||||
|
total_files = conn.execute("SELECT COUNT(*) FROM pages").fetchone()[0]
|
||||||
|
total_folders = 0
|
||||||
|
total_bytes = 0
|
||||||
|
return {
|
||||||
|
"total_users": total_users,
|
||||||
|
"total_workspaces": total_ws,
|
||||||
|
"total_files": total_files,
|
||||||
|
"total_folders": total_folders,
|
||||||
|
"total_mb": round(total_bytes / (1024 * 1024), 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Audit ──
|
||||||
|
@router.get("/audit")
|
||||||
|
async def audit_log(limit: int = 100, _admin=Depends(admin_required)):
|
||||||
|
"""Recent login history."""
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT lh.id, lh.user_id, u.login, u.full_name,
|
||||||
|
lh.ip_address, lh.user_agent, lh.logged_at
|
||||||
|
FROM login_history lh
|
||||||
|
JOIN users u ON u.id = lh.user_id
|
||||||
|
ORDER BY lh.logged_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", (min(limit, 500),)).fetchall()
|
||||||
|
return {"entries": [dict(r) for r in rows]}
|
||||||
@@ -39,7 +39,7 @@ def _check_rate_limit(request: Request) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
async def health():
|
async def health(request: Request):
|
||||||
"""Health check: DB + Gitea connectivity."""
|
"""Health check: DB + Gitea connectivity."""
|
||||||
db_ok = False
|
db_ok = False
|
||||||
gitea_ok = False
|
gitea_ok = False
|
||||||
@@ -57,7 +57,7 @@ async def health():
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
||||||
"version": "1.3.0",
|
"version": request.app.version,
|
||||||
"db": db_ok,
|
"db": db_ok,
|
||||||
"gitea": gitea_ok,
|
"gitea": gitea_ok,
|
||||||
}
|
}
|
||||||
@@ -623,3 +623,55 @@ async def get_collaborators(owner: str, repo: str):
|
|||||||
return {"collaborators": collaborators}
|
return {"collaborators": collaborators}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"collaborators": [], "error": str(e)}
|
return {"collaborators": [], "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Frontend Error Capture ───────────────────────────────────
|
||||||
|
# Hermes diagnostique le frontend en appelant GET /api/frontend-errors
|
||||||
|
|
||||||
|
_frontend_errors: list[dict] = []
|
||||||
|
_MAX_STORED_ERRORS = 100
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/frontend-error")
|
||||||
|
async def capture_frontend_error(request: Request):
|
||||||
|
"""Reçoit les erreurs JS du navigateur. Appelé automatiquement par app.js."""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return {"status": "ignored", "reason": "invalid json"}
|
||||||
|
|
||||||
|
msg = body.get("message", "")
|
||||||
|
err_type = body.get("type", "error")
|
||||||
|
source = body.get("source", "")
|
||||||
|
line = body.get("line", 0)
|
||||||
|
|
||||||
|
# Dédupliquer les erreurs identiques consécutives
|
||||||
|
if _frontend_errors and _frontend_errors[-1].get("message") == msg:
|
||||||
|
_frontend_errors[-1]["count"] = _frontend_errors[-1].get("count", 1) + 1
|
||||||
|
_frontend_errors[-1]["time"] = body.get("time", "")
|
||||||
|
else:
|
||||||
|
body["count"] = 1
|
||||||
|
_frontend_errors.append(body)
|
||||||
|
|
||||||
|
while len(_frontend_errors) > _MAX_STORED_ERRORS:
|
||||||
|
_frontend_errors.pop(0)
|
||||||
|
|
||||||
|
if err_type == "error":
|
||||||
|
logger.warning("Frontend JS error: %s (%s:%s)", msg, source, line)
|
||||||
|
else:
|
||||||
|
logger.warning("Frontend unhandled rejection: %s", msg)
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/frontend-errors")
|
||||||
|
async def get_frontend_errors(request: Request, clear: bool = True):
|
||||||
|
"""Retourne les erreurs frontend récentes. Hermes appelle cette route."""
|
||||||
|
errors = list(_frontend_errors)
|
||||||
|
if clear:
|
||||||
|
_frontend_errors.clear()
|
||||||
|
return {
|
||||||
|
"errors": errors,
|
||||||
|
"count": len(errors),
|
||||||
|
"cleared": clear,
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,37 +8,251 @@ from fastapi import APIRouter, Request, Query
|
|||||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||||
|
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
from app.auth.oauth import gitea_oauth
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["auth"], prefix="/auth")
|
router = APIRouter(tags=["auth"], prefix="/auth")
|
||||||
|
|
||||||
|
LOCAL_LOGIN_HTML = """<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FlowDeck — Login</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box;}
|
||||||
|
body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;}
|
||||||
|
.login-box{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;width:100%;max-width:400px;}
|
||||||
|
.login-box h1{font-size:24px;margin-bottom:8px;}
|
||||||
|
.login-box p{color:rgba(255,255,255,.5);font-size:14px;margin-bottom:24px;}
|
||||||
|
.form-group{margin-bottom:16px;}
|
||||||
|
.form-group label{display:block;font-size:13px;color:rgba(255,255,255,.6);margin-bottom:6px;}
|
||||||
|
.form-group input{width:100%;padding:10px 36px 10px 12px;background:#2A2A2A;border:1px solid rgba(255,255,255,.1);border-radius:8px;color:#fff;font-size:14px;outline:none;}
|
||||||
|
.pw-wrapper{position:relative;}
|
||||||
|
.pw-toggle{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:rgba(255,255,255,.4);cursor:pointer;font-size:16px;padding:4px;line-height:1;}
|
||||||
|
.pw-toggle:hover{color:rgba(255,255,255,.8);}
|
||||||
|
.form-group input:focus{border-color:#2383E2;box-shadow:0 0 0 1px #2383E2;}
|
||||||
|
.btn{width:100%;padding:12px;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;margin-top:8px;}
|
||||||
|
.btn-primary{background:#2383E2;color:#fff;}
|
||||||
|
.btn-primary:hover{background:#2C8CEB;}
|
||||||
|
.btn-secondary{background:#333;color:#fff;margin-top:12px;}
|
||||||
|
.btn-secondary:hover{background:#444;}
|
||||||
|
.tabs{display:flex;gap:0;margin-bottom:24px;border-bottom:1px solid rgba(255,255,255,.08);}
|
||||||
|
.tab{flex:1;text-align:center;padding:12px;cursor:pointer;font-size:14px;color:rgba(255,255,255,.5);border-bottom:2px solid transparent;background:none;border-top:none;border-left:none;border-right:none;}
|
||||||
|
.tab.active{color:#fff;border-bottom-color:#2383E2;}
|
||||||
|
.error{background:rgba(255,80,80,.15);color:#ff5050;padding:10px;border-radius:8px;font-size:13px;margin-bottom:12px;display:none;}
|
||||||
|
.success{background:rgba(80,255,80,.15);color:#50ff50;padding:10px;border-radius:8px;font-size:13px;margin-bottom:12px;display:none;}
|
||||||
|
.oauth-section{margin-top:20px;border-top:1px solid rgba(255,255,255,.08);padding-top:20px;}
|
||||||
|
.oauth-btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:10px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid rgba(255,255,255,.12);background:#2A2A2A;color:#fff;}
|
||||||
|
.oauth-btn:hover{background:#333;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-box">
|
||||||
|
<h1>FlowDeck</h1>
|
||||||
|
<p>Login or create an account to continue</p>
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" onclick="switchTab('login')" id="tab-login">Login</button>
|
||||||
|
<button class="tab" onclick="switchTab('register')" id="tab-register">Register</button>
|
||||||
|
</div>
|
||||||
|
<div id="expired-msg" class="success" style="display:none">⚠️ Your session has expired. Please log in again.</div>
|
||||||
|
<div id="error-msg" class="error"></div>
|
||||||
|
<div id="success-msg" class="success"></div>
|
||||||
|
<form id="login-form" onsubmit="handleLogin(event)">
|
||||||
|
<div class="form-group"><label>Email or username</label><input type="text" id="email" required autocomplete="username"></div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password</label>
|
||||||
|
<div class="pw-wrapper">
|
||||||
|
<input type="password" id="password" required minlength="6" autocomplete="current-password">
|
||||||
|
<button type="button" class="pw-toggle" onclick="togglePassword()" title="Show password">👁</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="name-group" style="display:none"><label>Name</label><input type="text" id="name"></div>
|
||||||
|
<button type="submit" class="btn btn-primary" id="submit-btn">Login</button>
|
||||||
|
</form>
|
||||||
|
<div class="oauth-section">
|
||||||
|
<p style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
|
||||||
|
<button class="oauth-btn" onclick="window.location='/auth/login?provider=gitea'" style="margin-bottom:8px">🔗 <span id="gitea-btn-label">Login</span> with Gitea</button>
|
||||||
|
<button class="oauth-btn" onclick="window.location='/auth/login?provider=github'">🐙 <span id="github-btn-label">Login</span> with GitHub</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// Show session expired banner if ?expired=1 in URL
|
||||||
|
(function(){if(location.search.includes('expired=1')){var el=document.getElementById('expired-msg');if(el)el.style.display='block';}})();
|
||||||
|
let mode='login';
|
||||||
|
function switchTab(t){mode=t;document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));document.getElementById('tab-'+t).classList.add('active');document.getElementById('name-group').style.display=t==='register'?'block':'none';document.getElementById('submit-btn').textContent=t==='register'?'Register':'Login';document.getElementById('error-msg').style.display='none';document.getElementById('success-msg').style.display='none';var lbl=t==='register'?'Register':'Login';var e1=document.getElementById('gitea-btn-label');var e2=document.getElementById('github-btn-label');if(e1)e1.textContent=lbl;if(e2)e2.textContent=lbl;}
|
||||||
|
function togglePassword(){var pw=document.getElementById('password');var btn=pw.parentElement.querySelector('.pw-toggle');if(pw.type==='password'){pw.type='text';btn.textContent='🙈';}else{pw.type='password';btn.textContent='👁';}}
|
||||||
|
async function handleLogin(e){e.preventDefault();const email=document.getElementById('email').value;const password=document.getElementById('password').value;const name=document.getElementById('name').value;const url=mode==='register'?'/auth/register':'/auth/local-login';const body={email,password};if(mode==='register')body.name=name;try{const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok){document.getElementById('error-msg').textContent=d.error||'Error';document.getElementById('error-msg').style.display='block';return;}if(d.status==='ok'){window.location='/workspaces';}}catch(err){document.getElementById('error-msg').textContent='Network error';document.getElementById('error-msg').style.display='block';}}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/register")
|
||||||
|
async def register_page(request: Request):
|
||||||
|
"""Show the registration page (local login page with register tab active)."""
|
||||||
|
return HTMLResponse(LOCAL_LOGIN_HTML.replace(
|
||||||
|
'class="tab active" onclick="switchTab(\'login\')"',
|
||||||
|
'class="tab" onclick="switchTab(\'login\')"'
|
||||||
|
).replace(
|
||||||
|
'class="tab" onclick="switchTab(\'register\')"',
|
||||||
|
'class="tab active" onclick="switchTab(\'register\')"'
|
||||||
|
).replace(
|
||||||
|
'let mode=\'login\';',
|
||||||
|
'let mode=\'register\';'
|
||||||
|
).replace(
|
||||||
|
'id="name-group" style="display:none"',
|
||||||
|
'id="name-group" style="display:block"'
|
||||||
|
).replace(
|
||||||
|
'id="submit-btn">Login<',
|
||||||
|
'id="submit-btn">Register<'
|
||||||
|
), status_code=200)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login")
|
@router.get("/login")
|
||||||
async def login(request: Request):
|
async def login(request: Request, provider: str = Query("gitea")):
|
||||||
"""Redirect to Gitea OAuth2 authorize page."""
|
"""Redirect to OAuth2 authorize page or show local login page."""
|
||||||
if not gitea_oauth.enabled:
|
# Local login page (POST handled by /auth/local-login)
|
||||||
# Fallback: use global token, create a fake session
|
from fastapi.responses import HTMLResponse
|
||||||
from app.db import get_conn
|
if provider == "local":
|
||||||
with get_conn() as conn:
|
return HTMLResponse(LOCAL_LOGIN_HTML, status_code=200)
|
||||||
user = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone()
|
|
||||||
if not user:
|
# OAuth flow — check if provider is configured
|
||||||
conn.execute("INSERT INTO users (login, full_name, email, avatar_url) VALUES ('admin', 'Admin', '', '')")
|
from app.auth.providers import get_provider
|
||||||
conn.commit()
|
oauth_provider = get_provider(provider)
|
||||||
user = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone()
|
if not oauth_provider:
|
||||||
user_data = dict(user)
|
# OAuth provider not configured — show error instead of auto-creating admin
|
||||||
session = SessionManager.create_session(user_data)
|
return HTMLResponse(
|
||||||
response = RedirectResponse(url="/", status_code=302)
|
f"""<!DOCTYPE html><html><head><title>FlowDeck</title><style>
|
||||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
|
body{{background:#191919;color:#fff;font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center;}}
|
||||||
return response
|
.box{{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;max-width:420px;}}
|
||||||
|
h1{{font-size:20px;margin-bottom:12px;}}p{{color:rgba(255,255,255,.5);font-size:14px;margin-bottom:16px;}}
|
||||||
|
a{{color:#2383E2;}}
|
||||||
|
</style></head><body><div class="box">
|
||||||
|
<h1>⚠️ {provider.title()} OAuth not configured</h1>
|
||||||
|
<p>The {provider} integration has not been set up by the server administrator.</p>
|
||||||
|
<p><a href="/auth/login?provider=local">↩ Use local login</a></p>
|
||||||
|
</div></body></html>""",
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
|
||||||
state = secrets.token_hex(32)
|
state = secrets.token_hex(32)
|
||||||
request.session["oauth_state"] = state
|
request.session["oauth_state"] = state
|
||||||
auth_url = gitea_oauth.get_authorize_url(state)
|
request.session["oauth_provider"] = provider
|
||||||
|
# Link mode: connect OAuth to current local account instead of creating new user
|
||||||
|
mode = request.query_params.get("mode", "")
|
||||||
|
# Encode auth mode in state to survive session loss during OAuth redirect
|
||||||
|
signed_state = f"{state}:{mode}" if mode else state
|
||||||
|
request.session["oauth_mode"] = mode
|
||||||
|
# Dynamic redirect URI based on incoming Host header
|
||||||
|
host = request.headers.get("host", "localhost:8080")
|
||||||
|
dynamic_redirect_uri = f"http://{host}/auth/callback"
|
||||||
|
auth_url = oauth_provider.get_authorize_url(signed_state, redirect_uri=dynamic_redirect_uri, force_login=(mode == "link"))
|
||||||
return RedirectResponse(url=auth_url, status_code=302)
|
return RedirectResponse(url=auth_url, status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register")
|
||||||
|
async def register(request: Request):
|
||||||
|
"""Register a new local account."""
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.password_utils import hash_password
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
email = body.get("email", "").strip()
|
||||||
|
password = body.get("password", "").strip()
|
||||||
|
name = body.get("name", email.split("@")[0] if "@" in email else email)
|
||||||
|
|
||||||
|
if not email or not password:
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse({"error": "Email and password required"}, status_code=400)
|
||||||
|
if len(password) < 6:
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
existing = conn.execute("SELECT id FROM users WHERE login=?", (email,)).fetchone()
|
||||||
|
if existing:
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse({"error": "Account already exists"}, status_code=409)
|
||||||
|
# First real user (excluding default admin with no password) is admin
|
||||||
|
real_user_count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM users WHERE password_hash IS NOT NULL AND password_hash != ''"
|
||||||
|
).fetchone()[0]
|
||||||
|
is_admin = 1 if real_user_count == 0 else 0
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(email, name, email, hash_password(password), is_admin),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone()
|
||||||
|
user_data = dict(user)
|
||||||
|
# Log login
|
||||||
|
_log_login(user_data["id"], request)
|
||||||
|
session = SessionManager.create_session(user_data)
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
response = JSONResponse({"status": "ok", "user": {"login": email, "name": name}})
|
||||||
|
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/local-login")
|
||||||
|
async def local_login(request: Request):
|
||||||
|
"""Login with email + password."""
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.password_utils import verify_password, is_locked
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import json, time
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
email = body.get("email", "").strip()
|
||||||
|
password = body.get("password", "").strip()
|
||||||
|
|
||||||
|
if not email or not password:
|
||||||
|
return JSONResponse({"error": "Email and password required"}, status_code=400)
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone()
|
||||||
|
if not user:
|
||||||
|
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
||||||
|
|
||||||
|
ud = dict(user)
|
||||||
|
if not ud.get("is_active"):
|
||||||
|
return JSONResponse({"error": "Account disabled"}, status_code=403)
|
||||||
|
if is_locked(ud.get("locked_until")):
|
||||||
|
return JSONResponse({"error": "Account temporarily locked. Try again later."}, status_code=423)
|
||||||
|
|
||||||
|
if not verify_password(password, ud.get("password_hash", "")):
|
||||||
|
with get_conn() as conn:
|
||||||
|
attempts = (ud.get("login_attempts", 0) or 0) + 1
|
||||||
|
lock = None
|
||||||
|
if attempts >= 5:
|
||||||
|
lock = str(time.time() + 900) # 15 min lock
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET login_attempts=?, locked_until=? WHERE id=?",
|
||||||
|
(attempts, lock, ud["id"]),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
||||||
|
|
||||||
|
# Successful login
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET login_attempts=0, locked_until=NULL, last_login=? WHERE id=?",
|
||||||
|
(str(time.time()), ud["id"]),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
session = SessionManager.create_session(ud)
|
||||||
|
_log_login(ud["id"], request)
|
||||||
|
response = JSONResponse({"status": "ok", "user": {"login": ud["login"], "name": ud["full_name"]}})
|
||||||
|
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/callback")
|
@router.get("/callback")
|
||||||
async def callback(
|
async def callback(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -46,13 +260,32 @@ async def callback(
|
|||||||
state: str = Query(...),
|
state: str = Query(...),
|
||||||
):
|
):
|
||||||
"""Handle OAuth2 callback from Gitea."""
|
"""Handle OAuth2 callback from Gitea."""
|
||||||
# Validate state
|
# Recover mode from state suffix (state:mode format), then validate
|
||||||
expected_state = request.session.get("oauth_state", "")
|
expected_state = request.session.get("oauth_state", "")
|
||||||
if not expected_state or state != expected_state:
|
provider_name = request.session.get("oauth_provider", "gitea")
|
||||||
return HTMLResponse("<h1>Invalid state</h1>", status_code=400)
|
|
||||||
|
|
||||||
# Exchange code for token
|
auth_mode = ""
|
||||||
token_data = await gitea_oauth.exchange_code(code)
|
raw_state = state
|
||||||
|
if ":" in state:
|
||||||
|
raw_state, auth_mode = state.rsplit(":", 1)
|
||||||
|
if auth_mode:
|
||||||
|
request.session["oauth_mode"] = auth_mode
|
||||||
|
|
||||||
|
# Validate: state token must match session, unless session lost and mode present
|
||||||
|
if expected_state and raw_state != expected_state:
|
||||||
|
return HTMLResponse("<h1>Invalid state</h1>", status_code=400)
|
||||||
|
if not expected_state and not auth_mode:
|
||||||
|
return HTMLResponse("<h1>Session expired — please try connecting again</h1>", status_code=400)
|
||||||
|
|
||||||
|
from app.auth.providers import get_provider
|
||||||
|
oauth_provider = get_provider(provider_name)
|
||||||
|
if not oauth_provider:
|
||||||
|
return HTMLResponse(f"<h1>Unknown provider: {provider_name}</h1>", status_code=400)
|
||||||
|
|
||||||
|
# Exchange code for token — use dynamic redirect URI matching the authorize step
|
||||||
|
host = request.headers.get("host", "localhost:8080")
|
||||||
|
dynamic_redirect_uri = f"http://{host}/auth/callback"
|
||||||
|
token_data = await oauth_provider.exchange_code(code, redirect_uri=dynamic_redirect_uri)
|
||||||
if not token_data:
|
if not token_data:
|
||||||
return HTMLResponse("<h1>Token exchange failed</h1>", status_code=400)
|
return HTMLResponse("<h1>Token exchange failed</h1>", status_code=400)
|
||||||
|
|
||||||
@@ -61,41 +294,67 @@ async def callback(
|
|||||||
return HTMLResponse("<h1>No access token</h1>", status_code=400)
|
return HTMLResponse("<h1>No access token</h1>", status_code=400)
|
||||||
|
|
||||||
# Get user info
|
# Get user info
|
||||||
user = await gitea_oauth.get_user(access_token)
|
oauth_user = await oauth_provider.get_user(access_token)
|
||||||
if not user:
|
if not oauth_user:
|
||||||
return HTMLResponse("<h1>Failed to get user</h1>", status_code=400)
|
return HTMLResponse("<h1>Failed to get user</h1>", status_code=400)
|
||||||
|
|
||||||
|
# Link mode: connect OAuth to current session user (for Settings → Integrations)
|
||||||
|
oauth_mode = request.session.pop("oauth_mode", "")
|
||||||
|
if oauth_mode == "link":
|
||||||
|
from app.auth.session import get_current_user as gcu
|
||||||
|
current = await gcu(request)
|
||||||
|
if not current:
|
||||||
|
return HTMLResponse("<h1>Not logged in — please log in first</h1>", status_code=400)
|
||||||
|
from app.db import get_conn as _gc
|
||||||
|
with _gc() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT OR REPLACE INTO user_oauth_tokens
|
||||||
|
(user_id, provider, access_token, refresh_token, expires_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""",
|
||||||
|
(current["id"], provider_name, access_token, token_data.get("refresh_token"), token_data.get("expires_at")),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return RedirectResponse(url="/settings#integrations", status_code=302)
|
||||||
|
|
||||||
# Store user in DB
|
# Store user in DB
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
login_id = f"{provider_name}_{oauth_user['login']}"
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO users (login, full_name, email, avatar_url)
|
"""INSERT INTO users (login, full_name, email, avatar_url, auth_method)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(login)
|
ON CONFLICT(login)
|
||||||
DO UPDATE SET full_name=excluded.full_name, email=excluded.email, avatar_url=excluded.avatar_url""",
|
DO UPDATE SET full_name=excluded.full_name, email=excluded.email, avatar_url=excluded.avatar_url, auth_method=excluded.auth_method""",
|
||||||
(user["login"], user.get("full_name", ""), user.get("email", ""), user.get("avatar_url", "")),
|
(login_id, oauth_user.get("full_name", ""), oauth_user.get("email", ""), oauth_user.get("avatar_url", ""), provider_name),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
# Store OAuth token
|
||||||
# Store token
|
uid = conn.execute("SELECT id FROM users WHERE login=?", (login_id,)).fetchone()
|
||||||
SessionManager.store_token(user["id"], access_token)
|
if uid:
|
||||||
|
conn.execute(
|
||||||
# Also store user in local DB
|
"""INSERT OR REPLACE INTO user_oauth_tokens
|
||||||
with get_conn() as conn:
|
(user_id, provider, access_token, refresh_token, expires_at, updated_at)
|
||||||
db_user = conn.execute("SELECT * FROM users WHERE login=?", (user["login"],)).fetchone()
|
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""",
|
||||||
user_data = dict(db_user) if db_user else user
|
(uid["id"], provider_name, access_token, token_data.get("refresh_token"), token_data.get("expires_at")),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
user = conn.execute("SELECT * FROM users WHERE id=?", (uid["id"],)).fetchone()
|
||||||
|
else:
|
||||||
|
user = conn.execute("SELECT * FROM users WHERE login=?", (login_id,)).fetchone()
|
||||||
|
user_data = dict(user) if user else oauth_user
|
||||||
|
|
||||||
# Create session
|
# Create session
|
||||||
session = SessionManager.create_session(user_data)
|
session = SessionManager.create_session(user_data)
|
||||||
response = RedirectResponse(url="/", status_code=302)
|
_log_login(user_data["id"], request)
|
||||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
|
response = RedirectResponse(url="/workspaces", status_code=302)
|
||||||
|
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logout")
|
@router.get("/logout")
|
||||||
async def logout():
|
async def logout():
|
||||||
"""Clear session and redirect to dashboard."""
|
"""Clear session and redirect to login page."""
|
||||||
response = RedirectResponse(url="/", status_code=302)
|
response = RedirectResponse(url="/auth/login?provider=local", status_code=302)
|
||||||
response.delete_cookie("flowdeck_session")
|
response.delete_cookie("flowdeck_session")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -108,3 +367,19 @@ async def current_user(request: Request):
|
|||||||
if not user:
|
if not user:
|
||||||
return {"authenticated": False}
|
return {"authenticated": False}
|
||||||
return {"authenticated": True, "user": user}
|
return {"authenticated": True, "user": user}
|
||||||
|
|
||||||
|
# ── Helpers ──
|
||||||
|
def _log_login(user_id: int, request: Request):
|
||||||
|
"""Record login in history."""
|
||||||
|
try:
|
||||||
|
from app.db import get_conn
|
||||||
|
ip = request.client.host if request.client else ''
|
||||||
|
ua = request.headers.get('user-agent', '')[:500]
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO login_history (user_id, ip_address, user_agent) VALUES (?, ?, ?)",
|
||||||
|
(user_id, ip, ua),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, HTTPException, Query
|
from fastapi import APIRouter, Request, HTTPException, Query
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
from app.services.gitea_client import gitea
|
from app.services.gitea_client import gitea
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
|
from app.routers.dashboard import _get_app_version
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["board"], prefix="/board")
|
router = APIRouter(tags=["board"], prefix="/board")
|
||||||
@@ -62,7 +63,7 @@ def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict:
|
|||||||
"bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄",
|
"bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄",
|
||||||
"design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒",
|
"design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒",
|
||||||
}
|
}
|
||||||
icon = "📄"
|
icon = "file"
|
||||||
for lbl in labels:
|
for lbl in labels:
|
||||||
for kw, emoji in icon_map.items():
|
for kw, emoji in icon_map.items():
|
||||||
if kw in lbl.get("name", "").lower():
|
if kw in lbl.get("name", "").lower():
|
||||||
@@ -140,10 +141,150 @@ def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, m
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _file_icon(name: str, content_format: str = "") -> str:
|
||||||
|
"""Map file extension to icon name (SVG-safe)."""
|
||||||
|
import re
|
||||||
|
# FlowDeck internal pages (no extension)
|
||||||
|
if content_format and content_format != 'file':
|
||||||
|
return 'edit'
|
||||||
|
n = name.lower()
|
||||||
|
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): return 'image'
|
||||||
|
if n.endswith('.pdf'): return 'file'
|
||||||
|
if re.search(r'\.(md|markdown)$', n): return 'edit'
|
||||||
|
if n.endswith('.py'): return 'file'
|
||||||
|
if re.search(r'\.(js|jsx|ts|tsx)$', n): return 'file'
|
||||||
|
if re.search(r'\.(html?|xml)$', n): return 'file'
|
||||||
|
if n.endswith('.css'): return 'file'
|
||||||
|
if n.endswith('.json'): return 'file'
|
||||||
|
if n.endswith('.sql'): return 'file'
|
||||||
|
if re.search(r'\.(sh|bash|zsh)$', n): return 'file'
|
||||||
|
if n.endswith('.ps1'): return 'file'
|
||||||
|
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n): return 'file'
|
||||||
|
if re.search(r'\.(txt|log)$', n): return 'file'
|
||||||
|
if re.search(r'\.(zip|tar|gz|rar|7z)$', n): return 'file'
|
||||||
|
return 'file'
|
||||||
|
|
||||||
|
|
||||||
|
def _load_workspace_pages(ws_cookie: str) -> list:
|
||||||
|
"""Load top-level pages with children for the active workspace."""
|
||||||
|
if not ws_cookie:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
ws_id = int(ws_cookie)
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, title, parent_section, content_format FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC",
|
||||||
|
(ws_id,),
|
||||||
|
).fetchall()
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
is_folder = r["parent_section"] == "Workspace"
|
||||||
|
title = r["title"] or "Untitled"
|
||||||
|
sub_children = _load_children(r["id"])
|
||||||
|
items.append({
|
||||||
|
"db_id": r["id"], "name": title,
|
||||||
|
"id": f"page/{r['id']}",
|
||||||
|
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
|
||||||
|
"is_folder": is_folder,
|
||||||
|
"child_count": len(sub_children),
|
||||||
|
"children": sub_children,
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
except (ValueError, Exception):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _load_children(parent_id: int) -> list:
|
||||||
|
"""Recursively load children of a page."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, title, parent_section, content_format FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at",
|
||||||
|
(parent_id,),
|
||||||
|
).fetchall()
|
||||||
|
children = []
|
||||||
|
for r in rows:
|
||||||
|
is_folder = r["parent_section"] == "Workspace"
|
||||||
|
title = r["title"] or "Untitled"
|
||||||
|
sub_children = _load_children(r["id"])
|
||||||
|
children.append({
|
||||||
|
"db_id": r["id"], "name": title,
|
||||||
|
"id": f"page/{r['id']}",
|
||||||
|
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
|
||||||
|
"is_folder": is_folder,
|
||||||
|
"child_count": len(sub_children),
|
||||||
|
"children": sub_children,
|
||||||
|
})
|
||||||
|
return children
|
||||||
|
|
||||||
|
|
||||||
|
def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]:
|
||||||
|
"""Return list of local workspaces for a user."""
|
||||||
|
if not user:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name",
|
||||||
|
(user["id"],)
|
||||||
|
).fetchall()
|
||||||
|
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
ws_name = user.get("login", "Bruno") if user else "Bruno"
|
ws_name = user.get("login", "Bruno") if user else "Bruno"
|
||||||
ws_key = f"{owner}/{repo}" if owner and repo else ws_name # workspace = project context
|
ws_key = f"{owner}/{repo}" if owner and repo else ws_name # workspace = project context
|
||||||
|
|
||||||
|
# Active workspace name from cookie (for local workspace display)
|
||||||
|
from app.routers.dashboard import WORKSPACE_COOKIE
|
||||||
|
ws_cookie = request.cookies.get(WORKSPACE_COOKIE, "")
|
||||||
|
active_ws_name = "Workspace"
|
||||||
|
workspace_pages = []
|
||||||
|
gitea_workspace = False
|
||||||
|
gitea_owner = ""
|
||||||
|
gitea_repo = ""
|
||||||
|
has_active_workspace = False
|
||||||
|
|
||||||
|
if ws_cookie and ws_cookie.startswith("gitea:"):
|
||||||
|
# Gitea workspace: preserve context across pages
|
||||||
|
parts = ws_cookie.split(":", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
gitea_owner = parts[1]
|
||||||
|
gitea_repo = parts[2]
|
||||||
|
active_ws_name = f"{gitea_owner}/{gitea_repo}"
|
||||||
|
gitea_workspace = True
|
||||||
|
has_active_workspace = True
|
||||||
|
workspace_pages = [] # loaded client-side
|
||||||
|
# Get local workspace ID for mirror
|
||||||
|
if user:
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
||||||
|
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
local_ws_id = row["id"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif ws_cookie and user:
|
||||||
|
try:
|
||||||
|
wsi = int(ws_cookie)
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?",
|
||||||
|
(wsi, user["id"])
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
active_ws_name = row["name"]
|
||||||
|
workspace_pages = _load_workspace_pages(ws_cookie)
|
||||||
|
has_active_workspace = True
|
||||||
|
except (ValueError, Exception):
|
||||||
|
pass
|
||||||
|
local_ws_id = 0
|
||||||
recent = []
|
recent = []
|
||||||
if owner and repo:
|
if owner and repo:
|
||||||
view_map = {
|
view_map = {
|
||||||
@@ -156,7 +297,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
|||||||
active = first
|
active = first
|
||||||
recent.append({
|
recent.append({
|
||||||
"id": f"{owner}/{repo}/{view}",
|
"id": f"{owner}/{repo}/{view}",
|
||||||
"name": label, "icon": "📁" if first else "·",
|
"name": label, "icon": "folder" if first else "",
|
||||||
"url": f"/board/{owner}/{repo}?view={view}",
|
"url": f"/board/{owner}/{repo}?view={view}",
|
||||||
"active": active, "indent": indent,
|
"active": active, "indent": indent,
|
||||||
"depth": indent, "has_children": False, "children": [],
|
"depth": indent, "has_children": False, "children": [],
|
||||||
@@ -169,11 +310,96 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
|||||||
recent.append(p)
|
recent.append(p)
|
||||||
# Private pages: same as recent but filtered for page/ items (non-board views)
|
# 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/")]
|
private_items = [r for r in recent if r.get("active") or r["id"].startswith("page/")]
|
||||||
|
|
||||||
|
# Load favorite pages from DB
|
||||||
|
uid = user["id"] if user and user.get("id") else 1
|
||||||
|
with get_conn() as conn:
|
||||||
|
fav_rows = conn.execute(
|
||||||
|
"SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f "
|
||||||
|
"JOIN pages p ON p.id = f.page_id "
|
||||||
|
"WHERE f.user_id=? ORDER BY f.position", (uid,)
|
||||||
|
).fetchall()
|
||||||
|
favorites = []
|
||||||
|
for r in fav_rows:
|
||||||
|
favorites.append({
|
||||||
|
"id": f"page/{r['id']}",
|
||||||
|
"db_id": r["id"],
|
||||||
|
"name": r["title"] or "New page",
|
||||||
|
"icon": "📄",
|
||||||
|
"url": f"/pages/{r['id']}",
|
||||||
|
"active": False,
|
||||||
|
"indent": 0,
|
||||||
|
"depth": 0,
|
||||||
|
"has_children": False,
|
||||||
|
"children": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Load shared pages (anyone with link or published)
|
||||||
|
with get_conn() as conn:
|
||||||
|
shared_rows = conn.execute(
|
||||||
|
"SELECT id, title, workspace, share_mode, published FROM pages "
|
||||||
|
"WHERE share_mode='anyone' OR published=1 ORDER BY updated_at DESC LIMIT 20"
|
||||||
|
).fetchall()
|
||||||
|
shared_pages = []
|
||||||
|
published_pages = []
|
||||||
|
for r in shared_rows:
|
||||||
|
page_entry = {
|
||||||
|
"id": f"page/{r['id']}",
|
||||||
|
"db_id": r["id"],
|
||||||
|
"name": r["title"] or "New page",
|
||||||
|
"icon": "🌐" if r["published"] else "🔗",
|
||||||
|
"url": f"/pages/{r['id']}",
|
||||||
|
"active": False,
|
||||||
|
"indent": 0,
|
||||||
|
"depth": 0,
|
||||||
|
"has_children": False,
|
||||||
|
"children": [],
|
||||||
|
}
|
||||||
|
if r["published"]:
|
||||||
|
published_pages.append(page_entry)
|
||||||
|
else:
|
||||||
|
shared_pages.append(page_entry)
|
||||||
|
|
||||||
|
# Auth method & OAuth badge data
|
||||||
|
auth_method = "local"
|
||||||
|
gitea_linked = False
|
||||||
|
github_linked = False
|
||||||
|
if user and user.get("id"):
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||||
|
if am_row and am_row["auth_method"]:
|
||||||
|
auth_method = am_row["auth_method"]
|
||||||
|
tokens = conn.execute(
|
||||||
|
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
|
||||||
|
).fetchall()
|
||||||
|
for t in tokens:
|
||||||
|
if t["provider"] == "gitea":
|
||||||
|
gitea_linked = True
|
||||||
|
elif t["provider"] == "github":
|
||||||
|
github_linked = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
||||||
|
"active_ws_name": active_ws_name,
|
||||||
|
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else ws_key,
|
||||||
|
"workspace_pages": workspace_pages,
|
||||||
|
"gitea_workspace": gitea_workspace,
|
||||||
|
"gitea_owner": gitea_owner,
|
||||||
|
"gitea_repo": gitea_repo,
|
||||||
|
"local_ws_id": local_ws_id,
|
||||||
"current_page": repo or "Dashboard", "last_edited": "now",
|
"current_page": repo or "Dashboard", "last_edited": "now",
|
||||||
"recent_pages": recent, "private_pages": private_items,
|
"recent_pages": recent, "private_pages": private_items,
|
||||||
"favorite_pages": [], "user": user,
|
"favorite_pages": favorites, "shared_pages": shared_pages,
|
||||||
"workspace_key": ws_key}
|
"published_pages": published_pages,
|
||||||
|
"user": user,
|
||||||
|
"auth_method": auth_method,
|
||||||
|
"gitea_linked": gitea_linked,
|
||||||
|
"github_linked": github_linked,
|
||||||
|
"has_active_workspace": has_active_workspace,
|
||||||
|
"app_version": _get_app_version(),
|
||||||
|
"local_workspaces": _local_workspaces_for_user(user)}
|
||||||
|
|
||||||
|
|
||||||
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
||||||
@@ -275,19 +501,108 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
|||||||
# ═══════════ Library page ═══════════
|
# ═══════════ Library page ═══════════
|
||||||
|
|
||||||
@router.get("/library", response_class=HTMLResponse)
|
@router.get("/library", response_class=HTMLResponse)
|
||||||
async def library_page(request: Request):
|
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||||
"""Library page avec onglets Recents, Favorites, Shared, Private."""
|
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||||
sidebar = _sidebar_data(request)
|
sidebar = _sidebar_data(request, owner, repo)
|
||||||
# Enrich with timestamps
|
# Load all pages for the workspace from DB
|
||||||
for p in sidebar["recent_pages"]:
|
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||||
p["last_edited"] = "now"
|
with get_conn() as conn:
|
||||||
for p in sidebar["private_pages"]:
|
if ws_key:
|
||||||
p["last_edited"] = "now"
|
rows = conn.execute(
|
||||||
|
"SELECT id, title, workspace, updated_at FROM pages "
|
||||||
|
"WHERE workspace=? ORDER BY updated_at DESC",
|
||||||
|
(ws_key,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, title, workspace, updated_at FROM pages "
|
||||||
|
"ORDER BY updated_at DESC",
|
||||||
|
).fetchall()
|
||||||
|
all_pages = []
|
||||||
|
for r in rows:
|
||||||
|
page = dict(r)
|
||||||
|
all_pages.append({
|
||||||
|
"id": f"page/{page['id']}",
|
||||||
|
"name": page["title"] or "Untitled",
|
||||||
|
"icon": "📄",
|
||||||
|
"url": f"/pages/{page['id']}",
|
||||||
|
"created_by": "You",
|
||||||
|
"source": page.get("workspace") or "Private",
|
||||||
|
"last_edited": page.get("updated_at", "now"),
|
||||||
|
"last_visited": page.get("updated_at", "now"),
|
||||||
|
})
|
||||||
|
sidebar["recent_pages"] = all_pages
|
||||||
|
sidebar["favorite_pages"] = []
|
||||||
|
sidebar["private_pages"] = [p for p in all_pages if p.get("source") == "🔒 Private"]
|
||||||
|
sidebar["shared_pages"] = []
|
||||||
template = env.get_template("library.html")
|
template = env.get_template("library.html")
|
||||||
return template.render(**sidebar)
|
return template.render(**sidebar)
|
||||||
|
|
||||||
|
# ═══════════ Favorites API ═══════════
|
||||||
|
|
||||||
|
@router.get("/api/favorites")
|
||||||
|
async def list_favorites(request: Request):
|
||||||
|
"""List favorited page IDs for the current user."""
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
uid = user["id"] if user and user.get("id") else 1
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT page_id FROM favorites WHERE user_id=? ORDER BY position", (uid,)
|
||||||
|
).fetchall()
|
||||||
|
return {"favorites": [r["page_id"] for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/favorites/{page_id:int}")
|
||||||
|
async def add_favorite(request: Request, page_id: int):
|
||||||
|
"""Add a page to favorites."""
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
uid = user["id"] if user and user.get("id") else 1
|
||||||
|
with get_conn() as conn:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
|
||||||
|
).fetchone()
|
||||||
|
if not existing:
|
||||||
|
pos = conn.execute(
|
||||||
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM favorites WHERE user_id=?", (uid,)
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO favorites (user_id, page_id, position) VALUES (?,?,?)",
|
||||||
|
(uid, page_id, pos),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "added", "page_id": page_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/favorites/{page_id:int}")
|
||||||
|
async def remove_favorite(request: Request, page_id: int):
|
||||||
|
"""Remove a page from favorites."""
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
uid = user["id"] if user and user.get("id") else 1
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id))
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "removed", "page_id": page_id}
|
||||||
|
|
||||||
|
# ═══════════ Share API ═══════════
|
||||||
|
|
||||||
|
@router.post("/api/share/{page_id:int}")
|
||||||
|
async def update_share(request: Request, page_id: int):
|
||||||
|
"""Save share settings for a page."""
|
||||||
|
import json
|
||||||
|
body = await request.json()
|
||||||
|
mode = body.get("mode", "private")
|
||||||
|
published = body.get("published", False)
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pages SET share_mode=?, published=? WHERE id=?",
|
||||||
|
(mode, 1 if published else 0, page_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok", "share_mode": mode, "published": published}
|
||||||
|
|
||||||
|
|
||||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||||
|
|
||||||
@router.get("/api/trash")
|
@router.get("/api/trash")
|
||||||
@@ -499,23 +814,29 @@ async def create_page(request: Request, title: str = Query(default=""),
|
|||||||
"""Create a new Markdown page, optionally as a sub-page."""
|
"""Create a new Markdown page, optionally as a sub-page."""
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
||||||
with get_conn() as conn:
|
page_title = title.strip() if title else "Untitled"
|
||||||
# Compute next sort_order for this parent
|
try:
|
||||||
next_order = 0
|
with get_conn() as conn:
|
||||||
parent_val = parent_id if parent_id > 0 else None
|
# Compute next sort_order for this parent
|
||||||
row = conn.execute(
|
next_order = 0
|
||||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?",
|
parent_val = parent_id if parent_id > 0 else None
|
||||||
(ws_key, parent_val),
|
row = conn.execute(
|
||||||
).fetchone()
|
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?",
|
||||||
if row:
|
(ws_key, parent_val),
|
||||||
next_order = row[0]
|
).fetchone()
|
||||||
cur = conn.execute(
|
if row:
|
||||||
"INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)",
|
next_order = row[0]
|
||||||
(ws_key, title, section, parent_val, next_order),
|
cur = conn.execute(
|
||||||
)
|
"INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)",
|
||||||
conn.commit()
|
(ws_key, page_title, section, parent_val, next_order),
|
||||||
page_id = cur.lastrowid
|
)
|
||||||
return {"status": "ok", "id": page_id, "title": title, "workspace": ws_key, "parent_id": parent_id}
|
conn.commit()
|
||||||
|
page_id = cur.lastrowid
|
||||||
|
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("create_page failed: %s", e)
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse({"error": "Failed to create page", "detail": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/pages/{page_id}")
|
@router.get("/api/pages/{page_id}")
|
||||||
@@ -565,42 +886,49 @@ async def save_page_blocks(request: Request, page_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/api/pages/{page_id}/move")
|
@router.put("/api/pages/{page_id}/move")
|
||||||
async def move_page(request: Request, page_id: int,
|
async def move_page(request: Request, page_id: int):
|
||||||
new_parent_id: int = Query(default=0),
|
"""Move a page to another workspace or reorder within tree.
|
||||||
new_order: int = Query(default=0)):
|
|
||||||
"""Move/reorder a page in the tree (drag & drop).
|
Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
|
||||||
new_parent_id=0 means move to root level.
|
- workspace_id: move page to a different workspace
|
||||||
new_order=0 means append to end (or set explicit position)."""
|
- parent_id: change parent (0 = root level)
|
||||||
|
- new_order: position among siblings (0 = append)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
new_ws_id = body.get("workspace_id")
|
||||||
|
new_parent_id = body.get("parent_id", 0)
|
||||||
|
new_order = body.get("new_order", 0)
|
||||||
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
# Verify page exists
|
row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||||
row = conn.execute("SELECT id, workspace FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Page not found")
|
raise HTTPException(404, "Page not found")
|
||||||
# Update parent (None for root level)
|
|
||||||
conn.execute(
|
if new_ws_id:
|
||||||
"UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
# Move to a different workspace: get the workspace name
|
||||||
(new_parent_id if new_parent_id > 0 else None, page_id),
|
ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone()
|
||||||
)
|
if not ws_row:
|
||||||
# Update sort order
|
return JSONResponse({"status": "error", "detail": "Workspace not found"}, status_code=404)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
"UPDATE pages SET workspace_id=?, workspace=?, parent_id=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||||
(new_order, page_id),
|
(new_ws_id, ws_row["name"], page_id),
|
||||||
)
|
)
|
||||||
# Re-index siblings to ensure no gaps
|
else:
|
||||||
siblings = conn.execute(
|
# Reorder within same workspace
|
||||||
"SELECT id, sort_order FROM pages WHERE workspace=? AND parent_id IS ? AND id != ? ORDER BY sort_order ASC",
|
conn.execute(
|
||||||
(row["workspace"], new_parent_id if new_parent_id > 0 else None, page_id),
|
"UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||||
).fetchall()
|
(new_parent_id if new_parent_id > 0 else None, page_id),
|
||||||
# Recompute sort_order for all siblings after the insertion point
|
)
|
||||||
insert_point = new_order
|
conn.execute(
|
||||||
reorder = []
|
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||||
for sib in siblings:
|
(new_order, page_id),
|
||||||
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()
|
conn.commit()
|
||||||
return {"status": "ok", "id": page_id, "parent_id": new_parent_id, "order": new_order}
|
return {"status": "ok", "id": page_id}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/pages/{page_id}")
|
@router.delete("/api/pages/{page_id}")
|
||||||
@@ -618,17 +946,68 @@ async def delete_page(request: Request, page_id: int):
|
|||||||
|
|
||||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||||
async def view_page(request: Request, page_id: int):
|
async def view_page(request: Request, page_id: int):
|
||||||
"""Render a page as HTML."""
|
"""Render a page as HTML, or a file viewer for uploaded files."""
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
||||||
sidebar = _sidebar_data(request)
|
page = dict(row)
|
||||||
ctx = {**sidebar, "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 = _sidebar_data(request, owner, repo)
|
||||||
|
# Check if page is favorited
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
uid = user["id"] if user and user.get("id") else 1
|
||||||
|
with get_conn() as conn:
|
||||||
|
fav = conn.execute(
|
||||||
|
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
# Build page_data, including file metadata for uploaded files
|
||||||
|
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))}
|
||||||
|
|
||||||
|
# For file pages, extract file metadata and add to page_data
|
||||||
|
if page.get("content_format") == "file":
|
||||||
|
import json as _json
|
||||||
|
try:
|
||||||
|
meta = _json.loads(page.get("content", "{}"))
|
||||||
|
except _json.JSONDecodeError:
|
||||||
|
meta = {}
|
||||||
|
file_path = meta.get("file_path", "").replace("\\", "/")
|
||||||
|
mime_type = meta.get("mime_type", "application/octet-stream")
|
||||||
|
file_size = meta.get("size", 0)
|
||||||
|
# Build workspace_id from file_path
|
||||||
|
fp_parts = file_path.split("/")
|
||||||
|
ws_id = ""
|
||||||
|
for p in fp_parts:
|
||||||
|
if p.startswith("workspace_"):
|
||||||
|
ws_id = p.replace("workspace_", "")
|
||||||
|
break
|
||||||
|
filename = fp_parts[-1] if fp_parts else page.get("title", "File")
|
||||||
|
file_url = f"/api/files/{ws_id}/{filename}" if ws_id else ""
|
||||||
|
page_data["file_url"] = file_url
|
||||||
|
page_data["file_mime"] = mime_type
|
||||||
|
page_data["file_size"] = file_size
|
||||||
|
page_data["file_name"] = filename
|
||||||
|
|
||||||
|
from app.routers.dashboard import _nav_breadcrumb
|
||||||
|
with get_conn() as conn:
|
||||||
|
nav_crumbs = _nav_breadcrumb(conn, page_id)
|
||||||
|
ctx = {**sidebar, "page": page, "page_favorited": fav is not None,
|
||||||
|
"page_share_mode": page.get("share_mode", "private"),
|
||||||
|
"page_published": bool(page.get("published", 0)),
|
||||||
|
"page_data": page_data,
|
||||||
|
"breadcrumb_items": nav_crumbs,
|
||||||
|
"nav_workspace_id": page.get("workspace_id") or 0,
|
||||||
|
"nav_page_id": page_id}
|
||||||
template = env.get_template("page_editor.html")
|
template = env.get_template("page_editor.html")
|
||||||
return template.render(**ctx)
|
response = template.render(**ctx)
|
||||||
|
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/sync/{owner}/{repo}")
|
@router.post("/api/sync/{owner}/{repo}")
|
||||||
async def sync_project(owner: str, repo: str):
|
async def sync_project(owner: str, repo: str):
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"""FlowDeck — Gitea integration API routes."""
|
||||||
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
router = APIRouter(tags=["gitea"], prefix="/api/gitea")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_gitea(request: Request):
|
||||||
|
"""Return a per-user GiteaClient or raise 401.
|
||||||
|
|
||||||
|
Only returns a client if the user has personally connected their Gitea
|
||||||
|
account (OAuth token). No fallback to admin token — each user must link
|
||||||
|
their own Gitea account to see Gitea projects.
|
||||||
|
"""
|
||||||
|
from app.services.gitea_client import get_user_gitea_client
|
||||||
|
client = get_user_gitea_client(request)
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(status_code=401, detail="Gitea account not linked. Go to Settings → Integrations to connect.")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _require_user_gitea(request: Request):
|
||||||
|
"""Return a per-user GiteaClient or raise 401 (required for write ops)."""
|
||||||
|
from app.services.gitea_client import get_user_gitea_client
|
||||||
|
client = get_user_gitea_client(request)
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(status_code=401, detail="Gitea not connected. Go to Settings → Integrations or register with Gitea.")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
# ── Orgs ──
|
||||||
|
@router.get("/orgs")
|
||||||
|
async def list_orgs(request: Request):
|
||||||
|
"""List organizations the user belongs to."""
|
||||||
|
gitea = _require_gitea(request)
|
||||||
|
try:
|
||||||
|
orgs = await gitea.get_user_orgs()
|
||||||
|
return {"orgs": orgs}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Projects (repos) ──
|
||||||
|
@router.get("/projects")
|
||||||
|
async def list_projects(request: Request, org: str = ""):
|
||||||
|
"""List Gitea repos: user repos (org='') or org repos."""
|
||||||
|
gitea = _require_gitea(request)
|
||||||
|
try:
|
||||||
|
if org:
|
||||||
|
repos = await gitea.get_org_repos(org)
|
||||||
|
else:
|
||||||
|
repos = await gitea.get_user_repos(limit=100)
|
||||||
|
return {"projects": repos}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Repo tree ──
|
||||||
|
@router.get("/projects/{owner}/{repo}/tree")
|
||||||
|
async def repo_tree(request: Request, owner: str, repo: str, path: str = ""):
|
||||||
|
"""Get contents of a repo directory (one level)."""
|
||||||
|
gitea = _require_gitea(request)
|
||||||
|
try:
|
||||||
|
items = await gitea.get_repo_contents(owner, repo, path)
|
||||||
|
tree = []
|
||||||
|
for item in items:
|
||||||
|
tree.append({
|
||||||
|
"name": item.get("name"),
|
||||||
|
"path": item.get("path"),
|
||||||
|
"type": "folder" if item.get("type") == "dir" else "file",
|
||||||
|
"size": item.get("size", 0),
|
||||||
|
"sha": item.get("sha"),
|
||||||
|
})
|
||||||
|
return {"tree": tree}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
# ── File content ──
|
||||||
|
@router.get("/projects/{owner}/{repo}/file")
|
||||||
|
async def get_file(request: Request, owner: str, repo: str, path: str):
|
||||||
|
"""Get file content (decoded)."""
|
||||||
|
gitea = _require_gitea(request)
|
||||||
|
try:
|
||||||
|
content = await gitea.get_file_content(owner, repo, path)
|
||||||
|
return {"content": content, "path": path}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Save file (create or update) ──
|
||||||
|
@router.put("/projects/{owner}/{repo}/file")
|
||||||
|
async def save_file(request: Request, owner: str, repo: str):
|
||||||
|
"""Create or update a file in the repo."""
|
||||||
|
import json
|
||||||
|
gitea = _require_gitea(request) # admin token can write too
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
path = body.get("path", "").strip("/")
|
||||||
|
content = body.get("content", "")
|
||||||
|
message = body.get("message", "Update via FlowDeck")
|
||||||
|
sha = body.get("sha")
|
||||||
|
if not path:
|
||||||
|
return JSONResponse({"error": "Path required"}, status_code=400)
|
||||||
|
try:
|
||||||
|
result = await gitea.create_or_update_file(owner, repo, path, content, message, sha)
|
||||||
|
return {"status": "ok", "commit": result.get("commit", {})}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Upload file (binary) ──
|
||||||
|
@router.post("/projects/{owner}/{repo}/upload")
|
||||||
|
async def upload_file(request: Request, owner: str, repo: str):
|
||||||
|
"""Upload a binary file to the repo."""
|
||||||
|
import base64
|
||||||
|
gitea = _require_gitea(request)
|
||||||
|
try:
|
||||||
|
form = await request.form()
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"error": "Invalid form data"}, status_code=400)
|
||||||
|
file = form.get("file")
|
||||||
|
folder = form.get("folder", "")
|
||||||
|
message = form.get("message", "Upload via FlowDeck")
|
||||||
|
if not file:
|
||||||
|
return JSONResponse({"error": "No file provided"}, status_code=400)
|
||||||
|
try:
|
||||||
|
content = await file.read()
|
||||||
|
encoded = base64.b64encode(content).decode("utf-8")
|
||||||
|
path = f"{folder.strip('/')}/{file.filename}".strip("/") if folder.strip("/") else file.filename
|
||||||
|
result = await gitea.create_or_update_file(owner, repo, path, encoded, message, sha=None)
|
||||||
|
return {"status": "ok", "path": path, "commit": result.get("commit", {})}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Delete file ──
|
||||||
|
@router.delete("/projects/{owner}/{repo}/file")
|
||||||
|
async def delete_file(request: Request, owner: str, repo: str):
|
||||||
|
"""Delete a file from the repo."""
|
||||||
|
import json
|
||||||
|
gitea = _require_gitea(request) # admin token can write too
|
||||||
|
path = request.query_params.get("path", "")
|
||||||
|
sha = request.query_params.get("sha", "")
|
||||||
|
message = request.query_params.get("message", "Delete via FlowDeck")
|
||||||
|
if not path or not sha:
|
||||||
|
return JSONResponse({"error": "Path and SHA required"}, status_code=400)
|
||||||
|
try:
|
||||||
|
await gitea.delete_file(owner, repo, path, sha, message)
|
||||||
|
return {"status": "ok"}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Labels ──
|
||||||
|
@router.get("/projects/{owner}/{repo}/labels")
|
||||||
|
async def get_labels(request: Request, owner: str, repo: str):
|
||||||
|
"""Get labels for a repo (mapped to tags)."""
|
||||||
|
gitea = _require_gitea(request)
|
||||||
|
try:
|
||||||
|
labels = await gitea.get_labels(owner, repo)
|
||||||
|
return {"labels": [
|
||||||
|
{"id": l["id"], "name": l["name"], "color": l.get("color", "#787774")}
|
||||||
|
for l in labels
|
||||||
|
]}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Account linking ──
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def gitea_status(request: Request):
|
||||||
|
"""Check if the current user has Gitea linked."""
|
||||||
|
from app.services.gitea_client import get_user_gitea_client
|
||||||
|
client = get_user_gitea_client(request)
|
||||||
|
return {"linked": client is not None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/disconnect")
|
||||||
|
async def disconnect_gitea(request: Request):
|
||||||
|
"""Remove all Gitea OAuth tokens for the current user."""
|
||||||
|
from app.auth.session import SessionManager
|
||||||
|
from app.db import get_conn
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
if not user:
|
||||||
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'", (user["id"],))
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Private Pages (local FlowDeck pages linked to Gitea project) ──
|
||||||
|
|
||||||
|
@router.get("/projects/{owner}/{repo}/private-pages")
|
||||||
|
async def list_private_pages(owner: str, repo: str, request: Request):
|
||||||
|
"""List private pages for this Gitea project."""
|
||||||
|
from app.auth.session import SessionManager
|
||||||
|
from app.db import get_conn
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
if not user:
|
||||||
|
return JSONResponse({"pages": []})
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, title, updated_at FROM gitea_private_pages WHERE user_id=? AND gitea_owner=? AND gitea_repo=? ORDER BY updated_at DESC",
|
||||||
|
(user["id"], owner, repo),
|
||||||
|
).fetchall()
|
||||||
|
return {"pages": [{"id": r["id"], "title": r["title"], "updated_at": r["updated_at"]} for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects/{owner}/{repo}/private-pages")
|
||||||
|
async def create_private_page(owner: str, repo: str, request: Request):
|
||||||
|
"""Create a new private page for this Gitea project."""
|
||||||
|
from app.auth.session import SessionManager
|
||||||
|
from app.db import get_conn
|
||||||
|
import json
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
if not user:
|
||||||
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
title = body.get("title", "Untitled").strip() or "Untitled"
|
||||||
|
with get_conn() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"INSERT INTO gitea_private_pages (user_id, gitea_owner, gitea_repo, title) VALUES (?,?,?,?)",
|
||||||
|
(user["id"], owner, repo, title),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok", "page": {"id": cursor.lastrowid, "title": title}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||||
|
async def get_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||||
|
"""Get a single private page."""
|
||||||
|
from app.auth.session import SessionManager
|
||||||
|
from app.db import get_conn
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
if not user:
|
||||||
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM gitea_private_pages WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
|
||||||
|
(page_id, user["id"], owner, repo),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||||
|
return {"page": {"id": row["id"], "title": row["title"], "content": row["content"], "updated_at": row["updated_at"]}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||||
|
async def update_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||||
|
"""Update a private page."""
|
||||||
|
from app.auth.session import SessionManager
|
||||||
|
from app.db import get_conn
|
||||||
|
import json
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
if not user:
|
||||||
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
title = body.get("title", "").strip()
|
||||||
|
content = body.get("content", "")
|
||||||
|
with get_conn() as conn:
|
||||||
|
if title:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE gitea_private_pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
|
||||||
|
(title, page_id, user["id"], owner, repo),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE gitea_private_pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
|
||||||
|
(content, page_id, user["id"], owner, repo),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||||
|
async def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||||
|
"""Delete a private page."""
|
||||||
|
from app.auth.session import SessionManager
|
||||||
|
from app.db import get_conn
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
if not user:
|
||||||
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM gitea_private_pages WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
|
||||||
|
(page_id, user["id"], owner, repo),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Commit history for a file ──
|
||||||
|
@router.get("/projects/{owner}/{repo}/commits")
|
||||||
|
async def file_commits(request: Request, owner: str, repo: str, path: str = ""):
|
||||||
|
"""Get recent commits for a specific file."""
|
||||||
|
gitea = _require_gitea(request)
|
||||||
|
try:
|
||||||
|
commits = await gitea.get_file_commits(owner, repo, path)
|
||||||
|
return {"commits": commits}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sync labels to tags ──
|
||||||
|
@router.post("/projects/{owner}/{repo}/sync-labels")
|
||||||
|
async def sync_labels(request: Request, owner: str, repo: str):
|
||||||
|
"""Sync Gitea labels to FlowDeck tags for the current user."""
|
||||||
|
from app.auth.session import get_current_user as gcu
|
||||||
|
from app.db import get_conn
|
||||||
|
user = await gcu(request)
|
||||||
|
if not user:
|
||||||
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||||
|
gitea = _require_gitea(request)
|
||||||
|
try:
|
||||||
|
labels = await gitea.get_labels(owner, repo)
|
||||||
|
except Exception:
|
||||||
|
labels = []
|
||||||
|
imported = 0
|
||||||
|
with get_conn() as conn:
|
||||||
|
for label in labels:
|
||||||
|
name = label.get("name", "")
|
||||||
|
color = label.get("color", "#787774")
|
||||||
|
if not name: continue
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM tags WHERE name=? AND user_id=?", (name, user["id"])
|
||||||
|
).fetchone()
|
||||||
|
if not existing:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)",
|
||||||
|
(name, f"#{color}", user["id"]),
|
||||||
|
)
|
||||||
|
imported += 1
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok", "imported": imported, "total": len(labels)}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""GitHub OAuth — status and disconnect routes."""
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
router = APIRouter(tags=["github"], prefix="/api/github")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def github_status(request: Request):
|
||||||
|
"""Check if the current user has GitHub linked."""
|
||||||
|
from app.auth.session import SessionManager
|
||||||
|
from app.db import get_conn
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
if not user:
|
||||||
|
return {"linked": False}
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='github' AND access_token IS NOT NULL AND access_token != ''",
|
||||||
|
(user["id"],)
|
||||||
|
).fetchone()
|
||||||
|
return {"linked": row is not None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/disconnect")
|
||||||
|
async def disconnect_github(request: Request):
|
||||||
|
"""Remove all GitHub OAuth tokens for the current user."""
|
||||||
|
from app.auth.session import SessionManager
|
||||||
|
from app.db import get_conn
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
if not user:
|
||||||
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=? AND provider='github'", (user["id"],))
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
"""FlowDeck — Library API: recents, favorites, shared, published, private, workspace."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.auth.session import SessionManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["library"], prefix="/api/library")
|
||||||
|
|
||||||
|
SOURCE_TYPES = {"all", "local", "gitea", "github"}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_id(request: Request) -> int:
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
return user["id"] if user and user.get("id") else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_source_type(workspace: str) -> str:
|
||||||
|
ws = (workspace or "").strip()
|
||||||
|
if not ws:
|
||||||
|
return "local"
|
||||||
|
if ws.startswith("github:"):
|
||||||
|
return "github"
|
||||||
|
if "/" in ws:
|
||||||
|
return "gitea"
|
||||||
|
return "local"
|
||||||
|
|
||||||
|
|
||||||
|
def _source_label(source_type: str, workspace: str) -> str:
|
||||||
|
if source_type == "gitea":
|
||||||
|
return workspace
|
||||||
|
elif source_type == "github":
|
||||||
|
return workspace.replace("github:", "", 1)
|
||||||
|
return workspace or "Private"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_item(db_row: dict, uid: int = 1) -> dict:
|
||||||
|
workspace = db_row.get("workspace", "") or ""
|
||||||
|
source_type = _detect_source_type(workspace)
|
||||||
|
page_id = db_row["id"]
|
||||||
|
title = db_row.get("title") or "Untitled"
|
||||||
|
|
||||||
|
# URL
|
||||||
|
if source_type in ("gitea", "github"):
|
||||||
|
ws = workspace.replace("github:", "", 1) if source_type == "github" else workspace
|
||||||
|
parts = ws.split("/", 1)
|
||||||
|
url = f"/gitea-workspace?owner={parts[0]}&repo={parts[1] if len(parts) > 1 else ''}"
|
||||||
|
else:
|
||||||
|
url = f"/pages/{page_id}"
|
||||||
|
|
||||||
|
# Icon
|
||||||
|
content_format = db_row.get("content_format", "blocks")
|
||||||
|
if db_row.get("is_folder"):
|
||||||
|
icon = "📁"
|
||||||
|
elif content_format == "file":
|
||||||
|
icon = "📄"
|
||||||
|
fn = title.lower()
|
||||||
|
if fn.endswith(".pdf"): icon = "📕"
|
||||||
|
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️"
|
||||||
|
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜"
|
||||||
|
else:
|
||||||
|
icon = "📝"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": page_id,
|
||||||
|
"title": title,
|
||||||
|
"icon": icon,
|
||||||
|
"is_folder": bool(db_row.get("is_folder", 0)),
|
||||||
|
"source_type": source_type,
|
||||||
|
"source_label": _source_label(source_type, workspace),
|
||||||
|
"workspace": workspace,
|
||||||
|
"workspace_name": _source_label(source_type, workspace),
|
||||||
|
"author": db_row.get("author") or "",
|
||||||
|
"author_initial": (db_row.get("author") or "?")[0].upper(),
|
||||||
|
"updated_at": db_row.get("updated_at", ""),
|
||||||
|
"visited_at": db_row.get("visited_at") or "",
|
||||||
|
"has_children": False,
|
||||||
|
"children": [],
|
||||||
|
"url": url,
|
||||||
|
"content_format": content_format,
|
||||||
|
"favorited": bool(db_row.get("favorited", 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[str, list]:
|
||||||
|
if source_type == "local":
|
||||||
|
query += " AND (p.workspace = '' OR (p.workspace NOT LIKE '%/%' AND p.workspace NOT LIKE 'github:%'))"
|
||||||
|
elif source_type == "gitea":
|
||||||
|
query += " AND p.workspace LIKE '%/%' AND p.workspace NOT LIKE 'github:%'"
|
||||||
|
elif source_type == "github":
|
||||||
|
query += " AND p.workspace LIKE 'github:%'"
|
||||||
|
return query, params
|
||||||
|
|
||||||
|
|
||||||
|
def _rows_to_items(rows, uid: int = 1) -> list:
|
||||||
|
return [_build_item(dict(r), uid) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_children(items: list) -> list:
|
||||||
|
"""Add has_children info to items in a single batch query."""
|
||||||
|
if not items:
|
||||||
|
return items
|
||||||
|
ids = [it["id"] for it in items]
|
||||||
|
placeholders = ",".join(["?" for _ in ids])
|
||||||
|
with get_conn() as conn:
|
||||||
|
child_rows = conn.execute(
|
||||||
|
f"SELECT parent_id, COUNT(*) as cnt FROM pages "
|
||||||
|
f"WHERE parent_id IN ({placeholders}) AND deleted_at IS NULL "
|
||||||
|
f"GROUP BY parent_id",
|
||||||
|
ids,
|
||||||
|
).fetchall()
|
||||||
|
child_counts = {r["parent_id"]: r["cnt"] for r in child_rows}
|
||||||
|
for it in items:
|
||||||
|
it["has_children"] = bool(child_counts.get(it["id"], 0))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════ Tab endpoints ═══════════
|
||||||
|
|
||||||
|
BASE_SELECT = (
|
||||||
|
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
|
||||||
|
"p.parent_id, p.share_mode, p.parent_section"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recents")
|
||||||
|
async def library_recents(
|
||||||
|
request: Request,
|
||||||
|
source_type: str = Query(default="all"),
|
||||||
|
tree: int = Query(default=0),
|
||||||
|
workspace_id: int = Query(default=0),
|
||||||
|
):
|
||||||
|
if source_type not in SOURCE_TYPES:
|
||||||
|
source_type = "all"
|
||||||
|
|
||||||
|
uid = _get_user_id(request)
|
||||||
|
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
# Hierarchical view: show only root-level pages at the top
|
||||||
|
if tree:
|
||||||
|
query += " AND p.parent_id IS NULL"
|
||||||
|
|
||||||
|
# Filter by active workspace
|
||||||
|
if workspace_id:
|
||||||
|
query += " AND p.workspace_id = ?"
|
||||||
|
params.append(workspace_id)
|
||||||
|
|
||||||
|
query, params = _apply_source_filter(query, params, source_type)
|
||||||
|
query += " ORDER BY p.updated_at DESC LIMIT 50"
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
|
||||||
|
items = _rows_to_items(rows, uid)
|
||||||
|
_enrich_children(items)
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/favorites")
|
||||||
|
async def library_favorites(
|
||||||
|
request: Request,
|
||||||
|
source_type: str = Query(default="all"),
|
||||||
|
tree: int = Query(default=0),
|
||||||
|
):
|
||||||
|
if source_type not in SOURCE_TYPES:
|
||||||
|
source_type = "all"
|
||||||
|
|
||||||
|
uid = _get_user_id(request)
|
||||||
|
query = (
|
||||||
|
f"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
|
||||||
|
f"p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
|
||||||
|
f"FROM favorites f JOIN pages p ON p.id = f.page_id "
|
||||||
|
f"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||||
|
)
|
||||||
|
params: list = [uid]
|
||||||
|
if tree:
|
||||||
|
query += " AND p.parent_id IS NULL"
|
||||||
|
query, params = _apply_source_filter(query, params, source_type)
|
||||||
|
query += " ORDER BY f.position"
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
|
||||||
|
items = _rows_to_items(rows, uid)
|
||||||
|
_enrich_children(items)
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shared")
|
||||||
|
async def library_shared(
|
||||||
|
request: Request,
|
||||||
|
source_type: str = Query(default="all"),
|
||||||
|
tree: int = Query(default=0),
|
||||||
|
):
|
||||||
|
if source_type not in SOURCE_TYPES:
|
||||||
|
source_type = "all"
|
||||||
|
|
||||||
|
uid = _get_user_id(request)
|
||||||
|
query = f"{BASE_SELECT} FROM pages p WHERE p.share_mode != 'private' AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||||
|
params: list = []
|
||||||
|
if tree:
|
||||||
|
query += " AND p.parent_id IS NULL"
|
||||||
|
query, params = _apply_source_filter(query, params, source_type)
|
||||||
|
query += " ORDER BY p.updated_at DESC LIMIT 50"
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
|
||||||
|
items = _rows_to_items(rows, uid)
|
||||||
|
_enrich_children(items)
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/published")
|
||||||
|
async def library_published(
|
||||||
|
request: Request,
|
||||||
|
source_type: str = Query(default="all"),
|
||||||
|
tree: int = Query(default=0),
|
||||||
|
):
|
||||||
|
if source_type not in SOURCE_TYPES:
|
||||||
|
source_type = "all"
|
||||||
|
|
||||||
|
uid = _get_user_id(request)
|
||||||
|
query = f"{BASE_SELECT} FROM pages p WHERE p.published = 1 AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||||
|
params: list = []
|
||||||
|
if tree:
|
||||||
|
query += " AND p.parent_id IS NULL"
|
||||||
|
query, params = _apply_source_filter(query, params, source_type)
|
||||||
|
query += " ORDER BY p.updated_at DESC LIMIT 50"
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
|
||||||
|
items = _rows_to_items(rows, uid)
|
||||||
|
_enrich_children(items)
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/private")
|
||||||
|
async def library_private(
|
||||||
|
request: Request,
|
||||||
|
source_type: str = Query(default="all"),
|
||||||
|
tree: int = Query(default=0),
|
||||||
|
):
|
||||||
|
if source_type not in SOURCE_TYPES:
|
||||||
|
source_type = "all"
|
||||||
|
|
||||||
|
uid = _get_user_id(request)
|
||||||
|
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL"
|
||||||
|
params: list = []
|
||||||
|
if tree:
|
||||||
|
query += " AND p.parent_id IS NULL"
|
||||||
|
query, params = _apply_source_filter(query, params, source_type)
|
||||||
|
query += " ORDER BY p.updated_at DESC LIMIT 50"
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
|
||||||
|
items = _rows_to_items(rows, uid)
|
||||||
|
_enrich_children(items)
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/local-workspace-children/{item_id:int}")
|
||||||
|
async def library_local_workspace_children(item_id: int, request: Request):
|
||||||
|
"""Return children of a local workspace item for tree expansion."""
|
||||||
|
uid = _get_user_id(request)
|
||||||
|
with get_conn() as conn:
|
||||||
|
# Get the item to find its workspace
|
||||||
|
item = conn.execute(
|
||||||
|
"SELECT workspace_id FROM local_workspace_items WHERE id=? AND deleted_at IS NULL",
|
||||||
|
[item_id],
|
||||||
|
).fetchone()
|
||||||
|
if not item:
|
||||||
|
return {"items": []}
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, name, is_folder, parent_id, content_format, size, "
|
||||||
|
"COALESCE(updated_at, created_at) as updated_at "
|
||||||
|
"FROM local_workspace_items "
|
||||||
|
"WHERE parent_id = ? AND deleted_at IS NULL "
|
||||||
|
"ORDER BY is_folder DESC, name COLLATE NOCASE",
|
||||||
|
[item_id],
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
name = r["name"] or "Untitled"
|
||||||
|
is_folder = bool(r["is_folder"])
|
||||||
|
icon = "📁" if is_folder else "📄"
|
||||||
|
fn = name.lower()
|
||||||
|
if not is_folder:
|
||||||
|
if fn.endswith(".pdf"): icon = "📕"
|
||||||
|
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️"
|
||||||
|
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜"
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
child_count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
|
||||||
|
[r["id"]],
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"id": r["id"],
|
||||||
|
"title": name,
|
||||||
|
"icon": icon,
|
||||||
|
"is_folder": is_folder,
|
||||||
|
"source_type": "local-ws",
|
||||||
|
"source_label": "",
|
||||||
|
"workspace": "",
|
||||||
|
"workspace_name": "",
|
||||||
|
"author": "",
|
||||||
|
"author_initial": "?",
|
||||||
|
"updated_at": r["updated_at"] or "",
|
||||||
|
"visited_at": "",
|
||||||
|
"has_children": child_count > 0,
|
||||||
|
"children": [],
|
||||||
|
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
|
||||||
|
"content_format": r["content_format"] or "file",
|
||||||
|
"favorited": False,
|
||||||
|
"size_display": _format_size(r["size"]) if r["size"] else "",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/children/{page_id:int}")
|
||||||
|
async def library_children(page_id: int, request: Request):
|
||||||
|
"""Return child pages for a given parent page (for tree expansion in Library)."""
|
||||||
|
uid = _get_user_id(request)
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"{BASE_SELECT}, (SELECT COUNT(*) FROM pages c WHERE c.parent_id = p.id AND c.deleted_at IS NULL) as child_count "
|
||||||
|
f"FROM pages p WHERE p.parent_id = ? AND p.deleted_at IS NULL "
|
||||||
|
f"ORDER BY p.title COLLATE NOCASE",
|
||||||
|
[page_id],
|
||||||
|
).fetchall()
|
||||||
|
items = _rows_to_items(rows, uid)
|
||||||
|
_enrich_children(items)
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/repository")
|
||||||
|
async def library_repository(
|
||||||
|
request: Request,
|
||||||
|
gitea_owner: str = Query(default=""),
|
||||||
|
gitea_repo: str = Query(default=""),
|
||||||
|
):
|
||||||
|
"""Return Gitea repository pages/files for the Library Repository tab."""
|
||||||
|
if not gitea_owner or not gitea_repo:
|
||||||
|
return {"items": []}
|
||||||
|
|
||||||
|
uid = _get_user_id(request)
|
||||||
|
ws_key = f"{gitea_owner}/{gitea_repo}"
|
||||||
|
query = f"{BASE_SELECT} FROM pages p WHERE p.workspace = ? AND p.deleted_at IS NULL"
|
||||||
|
params = [ws_key]
|
||||||
|
query += " ORDER BY p.updated_at DESC LIMIT 100"
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
|
||||||
|
items = _rows_to_items(rows, uid)
|
||||||
|
_enrich_children(items)
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/local-workspace")
|
||||||
|
async def library_local_workspace(
|
||||||
|
request: Request,
|
||||||
|
workspace_id: int = Query(default=0),
|
||||||
|
):
|
||||||
|
"""Return local workspace items (files/folders) formatted for Library display."""
|
||||||
|
from app.routers.dashboard import _get_active_workspace
|
||||||
|
uid = _get_user_id(request)
|
||||||
|
|
||||||
|
# Get the active workspace
|
||||||
|
ws = _get_active_workspace(request, user_id=uid)
|
||||||
|
if not ws:
|
||||||
|
return {"items": []}
|
||||||
|
|
||||||
|
ws_id = workspace_id or ws["id"]
|
||||||
|
|
||||||
|
# Query local workspace tree
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, name, is_folder, parent_id, content_format, size, "
|
||||||
|
"COALESCE(updated_at, created_at) as updated_at "
|
||||||
|
"FROM local_workspace_items "
|
||||||
|
"WHERE workspace_id = ? AND deleted_at IS NULL "
|
||||||
|
"ORDER BY is_folder DESC, name COLLATE NOCASE",
|
||||||
|
[ws_id],
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
name = r["name"] or "Untitled"
|
||||||
|
is_folder = bool(r["is_folder"])
|
||||||
|
icon = "📁" if is_folder else "📄"
|
||||||
|
fn = name.lower()
|
||||||
|
if not is_folder:
|
||||||
|
if fn.endswith(".pdf"): icon = "📕"
|
||||||
|
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️"
|
||||||
|
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜"
|
||||||
|
|
||||||
|
# Check for children
|
||||||
|
child_count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
|
||||||
|
[r["id"]],
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"id": r["id"],
|
||||||
|
"title": name,
|
||||||
|
"icon": icon,
|
||||||
|
"is_folder": is_folder,
|
||||||
|
"source_type": "local-ws",
|
||||||
|
"source_label": ws.get("name", "Workspace"),
|
||||||
|
"workspace": ws.get("name", ""),
|
||||||
|
"workspace_name": ws.get("name", ""),
|
||||||
|
"author": "",
|
||||||
|
"author_initial": "?",
|
||||||
|
"updated_at": r["updated_at"] or "",
|
||||||
|
"visited_at": "",
|
||||||
|
"has_children": child_count > 0,
|
||||||
|
"children": [],
|
||||||
|
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
|
||||||
|
"content_format": r["content_format"] or "file",
|
||||||
|
"favorited": False,
|
||||||
|
"size_display": _format_size(r["size"]) if r["size"] else "",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_size(size_bytes):
|
||||||
|
if not size_bytes: return ""
|
||||||
|
if size_bytes < 1024: return f"{size_bytes} B"
|
||||||
|
if size_bytes < 1048576: return f"{size_bytes/1024:.1f} KB"
|
||||||
|
if size_bytes < 1073741824: return f"{size_bytes/1048576:.1f} MB"
|
||||||
|
return f"{size_bytes/1073741824:.1f} GB"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/workspace")
|
||||||
|
async def library_workspace(
|
||||||
|
request: Request,
|
||||||
|
source_type: str = Query(default="all"),
|
||||||
|
tree: int = Query(default=0),
|
||||||
|
workspace_id: int = Query(default=0),
|
||||||
|
):
|
||||||
|
if source_type not in SOURCE_TYPES:
|
||||||
|
source_type = "all"
|
||||||
|
|
||||||
|
uid = _get_user_id(request)
|
||||||
|
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if tree:
|
||||||
|
query += " AND p.parent_id IS NULL"
|
||||||
|
|
||||||
|
if workspace_id:
|
||||||
|
query += " AND p.workspace_id = ?"
|
||||||
|
params.append(workspace_id)
|
||||||
|
|
||||||
|
query, params = _apply_source_filter(query, params, source_type)
|
||||||
|
query += " ORDER BY p.updated_at DESC LIMIT 50"
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
|
||||||
|
items = _rows_to_items(rows, uid)
|
||||||
|
_enrich_children(items)
|
||||||
|
return {"items": items}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""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 jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
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 = {}
|
||||||
|
try:
|
||||||
|
props = json.loads(p["property_values_json"] or "{}")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
due_date = props.get("due_date", "")
|
||||||
|
status = props.get("status", "—")
|
||||||
|
assignee = props.get("assignee", "")
|
||||||
|
if view != "all" and assignee and assignee != user_login:
|
||||||
|
continue
|
||||||
|
collection_tasks.append({
|
||||||
|
"id": p["id"],
|
||||||
|
"title": p["title"] or "Untitled",
|
||||||
|
"icon": p["icon"] or "📋",
|
||||||
|
"status": status,
|
||||||
|
"due_date": due_date,
|
||||||
|
})
|
||||||
|
if collection_tasks:
|
||||||
|
grouped[col["name"]] = collection_tasks
|
||||||
|
|
||||||
|
total = sum(len(t) for t in grouped.values())
|
||||||
|
|
||||||
|
# Build content HTML
|
||||||
|
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_cls = t['status'].lower().replace(' ', '-') if t.get('status') else 'none'
|
||||||
|
status_badge = f"<span class='mt-status mt-{status_cls}'>{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>"
|
||||||
|
|
||||||
|
content_html = f"""<div style="max-width:800px;margin:0 auto;padding:40px 24px;">
|
||||||
|
<h1 style="font-size:24px;margin:0 0 8px;">📋 My Tasks</h1>
|
||||||
|
<p style="color:var(--text-dim);font-size:14px;margin-bottom:24px;">{total} tasks across {len(grouped)} collections</p>
|
||||||
|
<div class="view-tabs" style="display:flex;gap:4px;margin-bottom:24px;border-bottom:1px solid var(--border);padding-bottom:12px;">
|
||||||
|
<a class="tab{' active' if view=='all' else ''}" href="/my-tasks?view=all" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">All</a>
|
||||||
|
<a class="tab{' active' if view=='today' else ''}" href="/my-tasks?view=today" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Today</a>
|
||||||
|
<a class="tab{' active' if view=='overdue' else ''}" href="/my-tasks?view=overdue" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Overdue</a>
|
||||||
|
<a class="tab{' active' if view=='upcoming' else ''}" href="/my-tasks?view=upcoming&days=7" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Next 7 days</a>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.tab.active{{background:var(--accent);color:#fff!important;}}
|
||||||
|
.tab:hover{{background:var(--bg-hover);color:var(--text);}}
|
||||||
|
.mt-section{{margin-bottom:24px;}}
|
||||||
|
.mt-section-header{{font-size:13px;font-weight:600;color:var(--text-dim);margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px;}}
|
||||||
|
.mt-count{{color:var(--text-dim);font-weight:400;opacity:.5;}}
|
||||||
|
.mt-item{{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg-card);border-radius:8px;margin-bottom:3px;border:1px solid var(--border);}}
|
||||||
|
.mt-item:hover{{border-color:var(--accent);}}
|
||||||
|
.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:var(--text-dim);}}
|
||||||
|
.mt-todo,.mt-none{{background:rgba(255,255,255,.05);color:var(--text-dim);}}
|
||||||
|
.mt-in-progress{{background:rgba(35,131,226,.15);color:#2C8CEB;}}
|
||||||
|
.mt-done,.mt-completed,.mt-complete{{background:rgba(0,200,100,.15);color:#00CC66;}}
|
||||||
|
</style>
|
||||||
|
{sections}
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
from app.routers.dashboard import _sidebar_data
|
||||||
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||||
|
sidebar = _sidebar_data(request, [])
|
||||||
|
block_tpl = env.from_string(
|
||||||
|
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||||
|
)
|
||||||
|
return block_tpl.render(
|
||||||
|
**sidebar,
|
||||||
|
request=request,
|
||||||
|
content_html=content_html,
|
||||||
|
page_title="My Tasks",
|
||||||
|
title_prefix="My Tasks",
|
||||||
|
page_icon="📋",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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,245 @@
|
|||||||
|
"""FlowDeck — Sharing, Publishing & Recents API (v4.0)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.auth.session import SessionManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["sharing"], prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_auth(request: Request) -> dict:
|
||||||
|
"""Require an authenticated session. Returns user dict or raises 401."""
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(title: str) -> str:
|
||||||
|
"""Generate a URL-safe slug from a page title."""
|
||||||
|
slug = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
|
||||||
|
slug = re.sub(r"[^\w\s-]", "", slug.lower())
|
||||||
|
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
|
||||||
|
return slug or "untitled"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Page Sharing ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pages/{page_id}/share")
|
||||||
|
async def share_page(page_id: int, request: Request):
|
||||||
|
"""Invite a user or email to a page."""
|
||||||
|
user = _require_auth(request)
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
target_user_id = body.get("user_id")
|
||||||
|
email = body.get("email", "")
|
||||||
|
permission = body.get("permission", "view")
|
||||||
|
|
||||||
|
if permission not in ("view", "comment", "edit"):
|
||||||
|
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
|
||||||
|
|
||||||
|
if not target_user_id and not email:
|
||||||
|
raise HTTPException(400, "Provide user_id or email to share with.")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
# Verify page exists
|
||||||
|
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||||
|
if not page:
|
||||||
|
raise HTTPException(404, "Page not found")
|
||||||
|
|
||||||
|
# Verify target user exists if user_id given
|
||||||
|
if target_user_id:
|
||||||
|
target = conn.execute("SELECT id FROM users WHERE id=?", (target_user_id,)).fetchone()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(404, "Target user not found")
|
||||||
|
|
||||||
|
cur = conn.execute(
|
||||||
|
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?)""",
|
||||||
|
(page_id, target_user_id, email, permission, user["id"]),
|
||||||
|
)
|
||||||
|
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": cur.lastrowid,
|
||||||
|
"page_id": page_id,
|
||||||
|
"shared_with_user_id": target_user_id,
|
||||||
|
"shared_with_email": email,
|
||||||
|
"permission": permission,
|
||||||
|
"status": "shared",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/pages/{page_id}/share/{share_id}")
|
||||||
|
async def remove_share(page_id: int, share_id: int, request: Request):
|
||||||
|
"""Remove a share invitation."""
|
||||||
|
_require_auth(request)
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM page_shares WHERE id=? AND page_id=?",
|
||||||
|
(share_id, page_id),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Share entry not found")
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,))
|
||||||
|
# If no more shares, unset is_shared
|
||||||
|
remaining = conn.execute(
|
||||||
|
"SELECT COUNT(*) AS c FROM page_shares WHERE page_id=?", (page_id,)
|
||||||
|
).fetchone()["c"]
|
||||||
|
if remaining == 0:
|
||||||
|
conn.execute("UPDATE pages SET is_shared=0 WHERE id=?", (page_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"status": "removed", "share_id": share_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pages/{page_id}/shares")
|
||||||
|
async def list_shares(page_id: int, request: Request):
|
||||||
|
"""Get all shares for a page."""
|
||||||
|
_require_auth(request)
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
page = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||||
|
if not page:
|
||||||
|
raise HTTPException(404, "Page not found")
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT s.*, u.login, u.full_name, u.avatar_url
|
||||||
|
FROM page_shares s
|
||||||
|
LEFT JOIN users u ON s.shared_with_user_id = u.id
|
||||||
|
WHERE s.page_id=?
|
||||||
|
ORDER BY s.created_at DESC""",
|
||||||
|
(page_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"page_id": page_id,
|
||||||
|
"shares": [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"shared_with_user_id": r["shared_with_user_id"],
|
||||||
|
"shared_with_email": r["shared_with_email"],
|
||||||
|
"permission": r["permission"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
"created_by": r["created_by"],
|
||||||
|
"user_login": r["login"],
|
||||||
|
"user_full_name": r["full_name"],
|
||||||
|
"user_avatar_url": r["avatar_url"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Page Publishing ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pages/{page_id}/publish")
|
||||||
|
async def publish_page(page_id: int, request: Request):
|
||||||
|
"""Publish a page (is_published=1) with a URL slug."""
|
||||||
|
user = _require_auth(request)
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
page = conn.execute(
|
||||||
|
"SELECT id, title, is_published FROM pages WHERE id=?", (page_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not page:
|
||||||
|
raise HTTPException(404, "Page not found")
|
||||||
|
|
||||||
|
slug = _slugify(page["title"])
|
||||||
|
# Ensure uniqueness by appending suffix if needed
|
||||||
|
base_slug = slug
|
||||||
|
counter = 1
|
||||||
|
while conn.execute(
|
||||||
|
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
|
||||||
|
).fetchone():
|
||||||
|
slug = f"{base_slug}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?",
|
||||||
|
(slug, page_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"page_id": page_id,
|
||||||
|
"is_published": True,
|
||||||
|
"publish_slug": slug,
|
||||||
|
"status": "published",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/pages/{page_id}/publish")
|
||||||
|
async def unpublish_page(page_id: int, request: Request):
|
||||||
|
"""Unpublish a page."""
|
||||||
|
_require_auth(request)
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
page = conn.execute(
|
||||||
|
"SELECT id, is_published FROM pages WHERE id=?", (page_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not page:
|
||||||
|
raise HTTPException(404, "Page not found")
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
|
||||||
|
(page_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"page_id": page_id,
|
||||||
|
"is_published": False,
|
||||||
|
"status": "unpublished",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Recents ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recents/track")
|
||||||
|
async def track_recent(request: Request):
|
||||||
|
"""Record a page access in recents."""
|
||||||
|
user = _require_auth(request)
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
page_id = body.get("page_id")
|
||||||
|
workspace = body.get("workspace", "")
|
||||||
|
source_type = body.get("source_type", "local")
|
||||||
|
|
||||||
|
if not page_id:
|
||||||
|
raise HTTPException(400, "page_id is required")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
page = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||||
|
if not page:
|
||||||
|
raise HTTPException(404, "Page not found")
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO recents (user_id, page_id, workspace, source_type, accessed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, page_id)
|
||||||
|
DO UPDATE SET workspace=excluded.workspace,
|
||||||
|
source_type=excluded.source_type,
|
||||||
|
accessed_at=excluded.accessed_at""",
|
||||||
|
(user["id"], page_id, workspace, source_type, datetime.utcnow().isoformat()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "tracked",
|
||||||
|
"user_id": user["id"],
|
||||||
|
"page_id": page_id,
|
||||||
|
"accessed_at": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
"""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"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/collections/{collection_id}/templates/page/{tid}")
|
||||||
|
async def update_page_template(request: Request, collection_id: int, tid: int):
|
||||||
|
"""Update a page template — name, properties, content, recurrence."""
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
with get_conn() as conn:
|
||||||
|
tmpl = conn.execute(
|
||||||
|
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
|
||||||
|
).fetchone()
|
||||||
|
if not tmpl:
|
||||||
|
raise HTTPException(404, "Template not found")
|
||||||
|
|
||||||
|
name = body.get("name", tmpl["name"])
|
||||||
|
tmpl_dict = dict(tmpl)
|
||||||
|
description = body.get("description", tmpl_dict.get("description", ""))
|
||||||
|
property_values_json = json.dumps(body.get("properties", json.loads(tmpl["property_values_json"])))
|
||||||
|
content_json = json.dumps(body.get("content", json.loads(tmpl_dict.get("content_json", "[]"))))
|
||||||
|
is_recurring = int(body.get("is_recurring", tmpl_dict.get("is_recurring", 0)))
|
||||||
|
recurrence_rule = body.get("recurrence_rule", tmpl_dict.get("recurrence_rule", ""))
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE page_templates SET name=?, description=?, property_values_json=?,
|
||||||
|
content_json=?, is_recurring=?, recurrence_rule=? WHERE id=?""",
|
||||||
|
(name, description, property_values_json, content_json, is_recurring, recurrence_rule, tid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"id": tid, "status": "updated"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/collections/{collection_id}/templates/page/{tid}")
|
||||||
|
async def delete_page_template(request: Request, collection_id: int, tid: int):
|
||||||
|
"""Delete a page template."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
tmpl = conn.execute(
|
||||||
|
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
|
||||||
|
).fetchone()
|
||||||
|
if not tmpl:
|
||||||
|
raise HTTPException(404, "Template not found")
|
||||||
|
conn.execute("DELETE FROM page_templates WHERE id=?", (tid,))
|
||||||
|
conn.commit()
|
||||||
|
return {"id": tid, "status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── v4.2.0: Dashboards ──
|
||||||
|
|
||||||
|
@router.get("/collections/{collection_id}/dashboards")
|
||||||
|
async def list_dashboards(request: Request, collection_id: int):
|
||||||
|
"""List all dashboards for a collection."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM collection_dashboards WHERE collection_id=? ORDER BY name", (collection_id,)
|
||||||
|
).fetchall()
|
||||||
|
return {"dashboards": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collections/{collection_id}/dashboards")
|
||||||
|
async def create_dashboard(request: Request, collection_id: int):
|
||||||
|
"""Create a new dashboard for a collection."""
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
name = body.get("name", "Dashboard").strip()
|
||||||
|
layout = json.dumps(body.get("layout", {"columns": 1, "widgets": []}))
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||||
|
if not coll:
|
||||||
|
raise HTTPException(404, "Collection not found")
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO collection_dashboards (collection_id, name, layout_json) VALUES (?,?,?)",
|
||||||
|
(collection_id, name, layout),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/collections/{collection_id}/dashboards/{did}")
|
||||||
|
async def update_dashboard(request: Request, collection_id: int, did: int):
|
||||||
|
"""Update a dashboard — name or layout (widgets grid)."""
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
with get_conn() as conn:
|
||||||
|
dash = conn.execute(
|
||||||
|
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
|
||||||
|
).fetchone()
|
||||||
|
if not dash:
|
||||||
|
raise HTTPException(404, "Dashboard not found")
|
||||||
|
|
||||||
|
name = body.get("name", dash["name"])
|
||||||
|
layout = json.dumps(body.get("layout", json.loads(dash["layout_json"])))
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE collection_dashboards SET name=?, layout_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||||
|
(name, layout, did),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"id": did, "status": "updated"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/collections/{collection_id}/dashboards/{did}")
|
||||||
|
async def delete_dashboard(request: Request, collection_id: int, did: int):
|
||||||
|
"""Delete a dashboard."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
dash = conn.execute(
|
||||||
|
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
|
||||||
|
).fetchone()
|
||||||
|
if not dash:
|
||||||
|
raise HTTPException(404, "Dashboard not found")
|
||||||
|
conn.execute("DELETE FROM collection_dashboards WHERE id=?", (did,))
|
||||||
|
conn.commit()
|
||||||
|
return {"id": did, "status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── CSV Import/Export ──
|
||||||
|
|
||||||
|
@router.post("/collections/{collection_id}/import/csv")
|
||||||
|
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,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
|
||||||
@@ -13,11 +13,16 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class GiteaClient:
|
class GiteaClient:
|
||||||
"""Async Gitea API v1 client with simple TTL cache."""
|
"""Async Gitea API v1 client with simple TTL cache.
|
||||||
|
|
||||||
def __init__(self) -> None:
|
Can be instantiated with a per-user token (OAuth) or falls back
|
||||||
|
to the server-wide admin token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, user_token: str | None = None) -> None:
|
||||||
self._base = f"{settings.gitea_url}/api/v1"
|
self._base = f"{settings.gitea_url}/api/v1"
|
||||||
self._headers = {"Authorization": f"token {settings.gitea_token}"}
|
token = user_token or settings.gitea_token
|
||||||
|
self._headers = {"Authorization": f"token {token}"}
|
||||||
self._cache: dict[str, tuple[datetime, Any]] = {}
|
self._cache: dict[str, tuple[datetime, Any]] = {}
|
||||||
self._ttl = timedelta(seconds=settings.gitea_cache_ttl)
|
self._ttl = timedelta(seconds=settings.gitea_cache_ttl)
|
||||||
|
|
||||||
@@ -295,6 +300,129 @@ class GiteaClient:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ── repo contents ──
|
||||||
|
|
||||||
|
async def get_repo_contents(self, owner, repo, path=""):
|
||||||
|
"""Get contents of a repo directory (lazy: one level)."""
|
||||||
|
url = f"{self._base}/repos/{owner}/{repo}/contents"
|
||||||
|
if path:
|
||||||
|
url += f"/{path}"
|
||||||
|
cache_key = f"contents:{owner}:{repo}:{path}"
|
||||||
|
cached = self._cached(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(url, headers=self._headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = [data]
|
||||||
|
self._set_cache(cache_key, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_file_content(self, owner, repo, path):
|
||||||
|
"""Get raw file content (decoded from base64)."""
|
||||||
|
import base64
|
||||||
|
cache_key = f"file:{owner}:{repo}:{path}"
|
||||||
|
cached = self._cached(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
item = resp.json()
|
||||||
|
content = ""
|
||||||
|
if item.get("encoding") == "base64" and item.get("content"):
|
||||||
|
try:
|
||||||
|
content = base64.b64decode(item["content"]).decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
content = "[binary file]"
|
||||||
|
self._set_cache(cache_key, content)
|
||||||
|
return content
|
||||||
|
|
||||||
|
async def create_or_update_file(self, owner, repo, path, content, message, sha=None):
|
||||||
|
"""Create or update a file. Requires `sha` for updates."""
|
||||||
|
import base64
|
||||||
|
body = {
|
||||||
|
"content": base64.b64encode(content.encode("utf-8")).decode("utf-8"),
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
if sha:
|
||||||
|
body["sha"] = sha
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.put(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
|
||||||
|
headers=self._headers,
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
parent = "/".join(path.split("/")[:-1])
|
||||||
|
for p in (parent, path.rsplit("/", 1)[0] if "/" in path else ""):
|
||||||
|
self._cache.pop(f"contents:{owner}:{repo}:{p}", None)
|
||||||
|
self._cache.pop(f"file:{owner}:{repo}:{path}", None)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def delete_file(self, owner, repo, path, sha, message):
|
||||||
|
"""Delete a file from the repo."""
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.delete(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
|
||||||
|
headers=self._headers,
|
||||||
|
json={"message": message, "sha": sha},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
parent = "/".join(path.split("/")[:-1])
|
||||||
|
for p in (parent, path.rsplit("/", 1)[0] if "/" in path else ""):
|
||||||
|
self._cache.pop(f"contents:{owner}:{repo}:{p}", None)
|
||||||
|
self._cache.pop(f"file:{owner}:{repo}:{path}", None)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _invalidate_tree_cache(self, owner, repo):
|
||||||
|
keys = [k for k in self._cache if k.startswith(f"contents:{owner}:{repo}:") or k.startswith(f"file:{owner}:{repo}:")]
|
||||||
|
for k in keys:
|
||||||
|
del self._cache[k]
|
||||||
|
|
||||||
|
async def get_file_commits(self, owner: str, repo: str, path: str) -> list[dict]:
|
||||||
|
"""Get recent commits affecting a specific file (cached 5 min)."""
|
||||||
|
key = f"commits:{owner}:{repo}:{path}"
|
||||||
|
cached = self._cached(key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/commits",
|
||||||
|
headers=self._headers,
|
||||||
|
params={"path": path, "limit": 20},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
self._set_cache(key, data)
|
||||||
|
return data
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
# ── singleton ──
|
# ── singleton ──
|
||||||
|
|
||||||
gitea = GiteaClient()
|
gitea = GiteaClient()
|
||||||
|
|
||||||
|
# ── Helper: get per-user GiteaClient ──
|
||||||
|
|
||||||
|
def get_user_gitea_client(request):
|
||||||
|
"""Return a GiteaClient authenticated with the user's OAuth token, or None."""
|
||||||
|
from app.auth.session import SessionManager
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea' ORDER BY updated_at DESC LIMIT 1",
|
||||||
|
(user["id"],),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return GiteaClient(user_token=row["access_token"])
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""FlowDeck — GitHub API adapter with caching."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_TTL = 30 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubAdapter:
|
||||||
|
"""Async GitHub API client (v3 REST) with simple TTL cache.
|
||||||
|
|
||||||
|
Authenticated via OAuth2 Bearer token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, access_token: str, ttl: int = DEFAULT_TTL) -> None:
|
||||||
|
self._base = "https://api.github.com"
|
||||||
|
self._headers = {
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
}
|
||||||
|
self._cache: dict[str, tuple[datetime, Any]] = {}
|
||||||
|
self._ttl = timedelta(seconds=ttl)
|
||||||
|
|
||||||
|
# ── cache helpers ──
|
||||||
|
|
||||||
|
def _cached(self, key: str) -> Any | None:
|
||||||
|
entry = self._cache.get(key)
|
||||||
|
if entry and entry[0] > datetime.now():
|
||||||
|
return entry[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _set_cache(self, key: str, value: Any) -> None:
|
||||||
|
self._cache[key] = (datetime.now() + self._ttl, value)
|
||||||
|
|
||||||
|
# ── repos ──
|
||||||
|
|
||||||
|
async def list_repos(self, page: int = 1, per_page: int = 50) -> list[dict]:
|
||||||
|
"""List repositories for the authenticated user (paginated)."""
|
||||||
|
cache_key = f"repos:{page}:{per_page}"
|
||||||
|
cached = self._cached(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base}/user/repos",
|
||||||
|
headers=self._headers,
|
||||||
|
params={"page": page, "per_page": per_page, "sort": "updated"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
self._set_cache(cache_key, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def list_all_repos(self) -> list[dict]:
|
||||||
|
"""Fetch all user repos across pages (up to 5 pages / 250 repos)."""
|
||||||
|
all_repos: list[dict] = []
|
||||||
|
for page in range(1, 6):
|
||||||
|
repos = await self.list_repos(page=page)
|
||||||
|
if not repos:
|
||||||
|
break
|
||||||
|
all_repos.extend(repos)
|
||||||
|
return all_repos
|
||||||
|
|
||||||
|
# ── repo tree ──
|
||||||
|
|
||||||
|
async def get_repo_tree(
|
||||||
|
self, owner: str, repo: str, sha: str | None = None, recursive: bool = True
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Get the git tree for a repo. If ``sha`` is omitted, resolves the
|
||||||
|
default branch first."""
|
||||||
|
cache_key = f"tree:{owner}:{repo}:{sha or 'default'}:{recursive}"
|
||||||
|
cached = self._cached(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
# Resolve default branch commit SHA if not provided
|
||||||
|
if sha is None:
|
||||||
|
repo_info = await client.get(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}",
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
repo_info.raise_for_status()
|
||||||
|
sha = repo_info.json()["default_branch"]
|
||||||
|
|
||||||
|
# Now get the commit to find tree SHA
|
||||||
|
branch_resp = await client.get(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/branches/{sha}",
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
branch_resp.raise_for_status()
|
||||||
|
sha = branch_resp.json()["commit"]["commit"]["tree"]["sha"]
|
||||||
|
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if recursive:
|
||||||
|
params["recursive"] = "1"
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/git/trees/{sha}",
|
||||||
|
headers=self._headers,
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
items = data.get("tree", [])
|
||||||
|
# Truncated responses — fetch remaining pages if needed
|
||||||
|
while data.get("truncated", False):
|
||||||
|
logger.warning("GitHub tree truncated for %s/%s — results incomplete", owner, repo)
|
||||||
|
break
|
||||||
|
|
||||||
|
self._set_cache(cache_key, items)
|
||||||
|
return items
|
||||||
|
|
||||||
|
# ── file content ──
|
||||||
|
|
||||||
|
async def get_file_content(self, owner: str, repo: str, path: str) -> str:
|
||||||
|
"""Get decoded file content from a repo."""
|
||||||
|
cache_key = f"file:{owner}:{repo}:{path}"
|
||||||
|
cached = self._cached(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
item = resp.json()
|
||||||
|
|
||||||
|
content = ""
|
||||||
|
if item.get("encoding") == "base64" and item.get("content"):
|
||||||
|
try:
|
||||||
|
content = base64.b64decode(item["content"]).decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
content = "[binary file]"
|
||||||
|
|
||||||
|
self._set_cache(cache_key, content)
|
||||||
|
return content
|
||||||
|
|
||||||
|
# ── token validation ──
|
||||||
|
|
||||||
|
async def validate_token(self) -> bool:
|
||||||
|
"""Check whether the access token is still valid."""
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base}/user",
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
return resp.status_code == 200
|
||||||
@@ -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,253 @@
|
|||||||
|
{# ── Unified Header (Notion-inspired breadcrumb navigation) ──
|
||||||
|
Parameters available in calling context:
|
||||||
|
breadcrumb_items: list of {label, url, id, icon, menu} — last item = current page
|
||||||
|
page_icon: str — emoji icon
|
||||||
|
page_title: str — fallback current-page label when no breadcrumb_items
|
||||||
|
right_actions: str — raw HTML for right-side action buttons
|
||||||
|
hamburger_click: str — Alpine expression for hamburger button
|
||||||
|
hide_hamburger: bool — if truthy, hides the hamburger button
|
||||||
|
hide_home: bool — if truthy, do not prepend the "Home" crumb
|
||||||
|
nav_workspace_id: int — workspace id used to fetch the navigation menu
|
||||||
|
nav_page_id: int — current page id (enables the sibling nav menu)
|
||||||
|
#}
|
||||||
|
{% set ns = namespace(items=[]) %}
|
||||||
|
{% if not hide_home %}
|
||||||
|
{% set ns.items = ns.items + [{"label": "Home", "url": "/workspaces", "id": none, "icon": "home", "menu": false, "home": true}] %}
|
||||||
|
{% endif %}
|
||||||
|
{% if breadcrumb_items %}
|
||||||
|
{% for item in breadcrumb_items %}
|
||||||
|
{% set ns.items = ns.items + [{
|
||||||
|
"label": item.get('label') or item.get('name') or 'Untitled',
|
||||||
|
"url": item.get('url'),
|
||||||
|
"id": item.get('id'),
|
||||||
|
"icon": item.get('icon'),
|
||||||
|
"menu": (item.get('id') is not none)
|
||||||
|
}] %}
|
||||||
|
{% endfor %}
|
||||||
|
{% elif page_title %}
|
||||||
|
{% set ns.items = ns.items + [{
|
||||||
|
"label": page_title,
|
||||||
|
"url": none,
|
||||||
|
"id": nav_page_id if nav_page_id is defined else none,
|
||||||
|
"icon": page_icon if page_icon is defined else none,
|
||||||
|
"menu": (nav_page_id is defined and nav_page_id)
|
||||||
|
}] %}
|
||||||
|
{% endif %}
|
||||||
|
<header class="topbar unified-header">
|
||||||
|
{% if not hide_hamburger %}
|
||||||
|
<button class="hamburger-btn"
|
||||||
|
@click="{{ hamburger_click|default('sidebarCollapsed ? (sidebarCollapsed = false) : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
|
||||||
|
title="Toggle sidebar">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script type="application/json" id="fd-breadcrumb-data">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
|
||||||
|
|
||||||
|
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
|
||||||
|
@mouseleave="dragCloseTimer()">
|
||||||
|
{% if page_icon %}
|
||||||
|
<span class="header-page-icon">{% if page_icon in ("folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap","paperclip","key","refresh","upload") %}{{ fd_icon(page_icon,18) }}{% else %}{{ page_icon }}{% endif %}</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<template x-for="(crumb, di) in display" :key="di">
|
||||||
|
<span class="crumb-cell">
|
||||||
|
{# ── Collapsed "…" segment ── #}
|
||||||
|
<template x-if="crumb.ellipsis">
|
||||||
|
<span class="crumb-anchor"
|
||||||
|
@mouseenter="cancelCloseTimer(); ellipsisOpen = true"
|
||||||
|
@mouseleave="dragCloseTimer()">
|
||||||
|
<button type="button" class="breadcrumb-link crumb-ellipsis">…</button>
|
||||||
|
<div class="fd-nav-menu" x-show="ellipsisOpen" x-cloak x-transition.opacity
|
||||||
|
@mouseenter="cancelCloseTimer()"
|
||||||
|
@mouseleave="dragCloseTimer()">
|
||||||
|
<template x-for="h in crumb.hidden" :key="h.id">
|
||||||
|
<div class="fd-nav-item" @click.stop="go(h.url); closeAll()">
|
||||||
|
<span class="fd-nav-ico" :title="h.icon || 'file'"></span>
|
||||||
|
<span class="fd-nav-label" x-text="h.label"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{# ── Plain link crumb (Home / non-menu with url) ── #}
|
||||||
|
<template x-if="!crumb.ellipsis && !crumb.menu && crumb.url">
|
||||||
|
<a class="breadcrumb-link" :href="crumb.url" x-text="crumb.label"
|
||||||
|
@mouseenter="closeAll()"></a>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{# ── Plain current crumb (no menu, no url) ── #}
|
||||||
|
<template x-if="!crumb.ellipsis && !crumb.menu && !crumb.url">
|
||||||
|
<span class="breadcrumb-current" x-text="crumb.label"
|
||||||
|
@mouseenter="closeAll()"></span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{# ── Interactive crumb with navigation dropdown (Notion-style hover) ── #}
|
||||||
|
<template x-if="!crumb.ellipsis && crumb.menu">
|
||||||
|
<span class="crumb-anchor"
|
||||||
|
@mouseenter="cancelCloseTimer(); openMenu(crumb.origIndex)"
|
||||||
|
@mouseleave="dragCloseTimer()">
|
||||||
|
<button type="button" class="breadcrumb-link"
|
||||||
|
:class="{ 'breadcrumb-current': crumb.origIndex === crumbs.length - 1 }"
|
||||||
|
x-text="crumb.label"></button>
|
||||||
|
<div class="fd-nav-menu"
|
||||||
|
x-show="openIdx === crumb.origIndex" x-cloak x-transition.opacity
|
||||||
|
@mouseenter="cancelCloseTimer()"
|
||||||
|
@mouseleave="dragCloseTimer()">
|
||||||
|
<div class="fd-nav-section" x-text="crumb.home ? 'Workspaces' : 'Private'"></div>
|
||||||
|
<template x-if="loading">
|
||||||
|
<div class="fd-nav-empty">Loading…</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="!loading && activeItems.length === 0">
|
||||||
|
<div class="fd-nav-empty">No pages</div>
|
||||||
|
</template>
|
||||||
|
<template x-for="item in activeItems" :key="item.id">
|
||||||
|
<div class="fd-nav-item"
|
||||||
|
@mouseenter="openSub(item)"
|
||||||
|
@click.stop="go(item.url); closeAll()">
|
||||||
|
<span class="fd-nav-ico" :title="item.icon || 'file'"></span>
|
||||||
|
<span class="fd-nav-label" x-text="item.name"></span>
|
||||||
|
<span class="fd-nav-arrow" x-show="item.has_children">›</span>
|
||||||
|
<div class="fd-nav-menu fd-nav-submenu" x-show="subOpenId === item.id" x-cloak
|
||||||
|
@mouseenter="cancelCloseTimer()"
|
||||||
|
@mouseleave="dragCloseTimer()">
|
||||||
|
<template x-for="s in subItems" :key="s.id">
|
||||||
|
<div class="fd-nav-item" @click.stop="go(s.url); closeAll()">
|
||||||
|
<span class="fd-nav-ico" :title="s.icon || 'file'"></span>
|
||||||
|
<span class="fd-nav-label" x-text="s.name"></span>
|
||||||
|
<span class="fd-nav-arrow" x-show="s.has_children">›</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<span class="breadcrumb-sep" x-show="di < display.length - 1">/</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar-right header-actions">
|
||||||
|
{{ right_actions|safe if right_actions else '' }}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', function () {
|
||||||
|
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
|
||||||
|
if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true;
|
||||||
|
Alpine.data('fdBreadcrumb', function () {
|
||||||
|
return {
|
||||||
|
crumbs: [],
|
||||||
|
wsId: 0,
|
||||||
|
openIdx: null,
|
||||||
|
ellipsisOpen: false,
|
||||||
|
loading: false,
|
||||||
|
cache: {},
|
||||||
|
activeItems: [],
|
||||||
|
subOpenId: null,
|
||||||
|
subItems: [],
|
||||||
|
closeTimer: null,
|
||||||
|
|
||||||
|
init: function () {
|
||||||
|
var el = document.getElementById('fd-breadcrumb-data');
|
||||||
|
if (el) {
|
||||||
|
try {
|
||||||
|
var d = JSON.parse(el.textContent);
|
||||||
|
this.crumbs = (d.crumbs || []).map(function (c, i) { c.origIndex = i; return c; });
|
||||||
|
this.wsId = d.workspace_id || 0;
|
||||||
|
} catch (e) { this.crumbs = []; }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get display() {
|
||||||
|
var c = this.crumbs;
|
||||||
|
if (c.length <= 4) return c;
|
||||||
|
var head = [c[0], c[1]];
|
||||||
|
var hidden = c.slice(2, c.length - 2);
|
||||||
|
var tail = [c[c.length - 2], c[c.length - 1]];
|
||||||
|
return head.concat([{ ellipsis: true, hidden: hidden }], tail);
|
||||||
|
},
|
||||||
|
|
||||||
|
parentIdFor: function (origIndex) {
|
||||||
|
if (origIndex <= 0) return null;
|
||||||
|
var prev = this.crumbs[origIndex - 1];
|
||||||
|
return prev && prev.id ? prev.id : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchMenu: function (parentId) {
|
||||||
|
var key = parentId == null ? 'root' : String(parentId);
|
||||||
|
var self = this;
|
||||||
|
if (this.cache[key]) return Promise.resolve(this.cache[key]);
|
||||||
|
var url = '/api/nav/menu?';
|
||||||
|
if (this.wsId) url += 'workspace_id=' + this.wsId + '&';
|
||||||
|
if (parentId != null) url += 'parent_id=' + parentId;
|
||||||
|
return fetch(url, { credentials: 'same-origin' })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) { self.cache[key] = d.items || []; return self.cache[key]; })
|
||||||
|
.catch(function () { return []; });
|
||||||
|
},
|
||||||
|
|
||||||
|
openMenu: function (origIndex) {
|
||||||
|
if (this.openIdx === origIndex) return; // already open
|
||||||
|
var self = this;
|
||||||
|
// Close sub first
|
||||||
|
this.subOpenId = null;
|
||||||
|
this.subItems = [];
|
||||||
|
this.openIdx = origIndex;
|
||||||
|
this.loading = true;
|
||||||
|
this.fetchMenu(this.parentIdFor(origIndex)).then(function (items) {
|
||||||
|
if (self.openIdx === origIndex) { self.activeItems = items; }
|
||||||
|
self.loading = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
openSub: function (item) {
|
||||||
|
var self = this;
|
||||||
|
if (!item.has_children) { this.subOpenId = null; this.subItems = []; return; }
|
||||||
|
if (this.subOpenId === item.id) return;
|
||||||
|
this.subOpenId = item.id;
|
||||||
|
this.subItems = [];
|
||||||
|
this.fetchMenu(item.id).then(function (items) {
|
||||||
|
if (self.subOpenId === item.id) { self.subItems = items; }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Hover close timer ──
|
||||||
|
dragCloseTimer: function () {
|
||||||
|
var self = this;
|
||||||
|
this.cancelCloseTimer();
|
||||||
|
this.closeTimer = setTimeout(function () {
|
||||||
|
self.closeAll();
|
||||||
|
}, 200);
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelCloseTimer: function () {
|
||||||
|
if (this.closeTimer) {
|
||||||
|
clearTimeout(this.closeTimer);
|
||||||
|
this.closeTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeAll: function () {
|
||||||
|
this.cancelCloseTimer();
|
||||||
|
this.openIdx = null;
|
||||||
|
this.ellipsisOpen = false;
|
||||||
|
this.subOpenId = null;
|
||||||
|
this.subItems = [];
|
||||||
|
this.activeItems = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
go: function (url) { if (url) window.location.href = url; }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
{# FlowDeck — Outline SVG Icon Set (v4.0+)
|
||||||
|
Usage: {{ fd_icon('folder') }} {{ fd_icon('star', 16) }}
|
||||||
|
All icons use stroke="currentColor" for theme compatibility.
|
||||||
|
#}
|
||||||
|
{%- macro fd_icon(name, size=18) -%}
|
||||||
|
{%- set s = size|string -%}
|
||||||
|
{%- if name == 'folder' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
{%- elif name == 'file' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
|
{%- elif name == 'calendar' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||||
|
{%- elif name == 'clock' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
{%- elif name == 'star' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
||||||
|
{%- elif name == 'bot' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/></svg>
|
||||||
|
{%- elif name == 'users' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
{%- elif name == 'globe' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||||
|
{%- elif name == 'lock' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||||
|
{%- elif name == 'book' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||||
|
{%- elif name == 'check-square' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||||
|
{%- elif name == 'trash' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
|
{%- elif name == 'help-circle' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||||
|
{%- elif name == 'settings' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
{%- elif name == 'refresh' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||||
|
{%- elif name == 'log-out' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||||
|
{%- elif name == 'message-square' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
{%- elif name == 'home' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||||
|
{%- elif name == 'search' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
{%- elif name == 'link' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||||
|
{%- elif name == 'plus' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
{%- elif name == 'bell' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||||
|
{%- elif name == 'image' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||||
|
{%- elif name == 'download' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
{%- elif name == 'chevron-left' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
{%- elif name == 'chevron-right' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
{%- elif name == 'list' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
||||||
|
{%- elif name == 'bar-chart' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
||||||
|
{%- elif name == 'grid' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||||
|
{%- elif name == 'align-left' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/></svg>
|
||||||
|
{%- elif name == 'corner-down-right' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/></svg>
|
||||||
|
{%- elif name == 'copy' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
{%- elif name == 'chevron-down' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
|
{%- elif name == 'key' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
||||||
|
{%- elif name == 'inbox' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
|
||||||
|
{%- elif name == 'edit' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
{%- elif name == 'copy' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
{%- elif name == 'eye-off' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||||
|
{%- elif name == 'eye' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
{%- elif name == 'share' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||||
|
{%- elif name == 'more-horizontal' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
|
||||||
|
{%- elif name == 'x' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
{%- elif name == 'paperclip' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||||
|
{%- elif name == 'external-link' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||||
|
{%- elif name == 'sparkles' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/></svg>
|
||||||
|
{%- elif name == 'lightbulb' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>
|
||||||
|
{%- elif name == 'tag' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
|
||||||
|
{%- elif name == 'file-text' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||||
|
{%- elif name == 'save' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||||
|
{%- elif name == 'upload' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
|
{%- elif name == 'trending-up' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
|
||||||
|
{%- elif name == 'zap' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||||
|
{%- elif name == 'alert-triangle' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||||
|
{%- elif name == 'user' -%}
|
||||||
|
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endmacro -%}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{% from '_icons.html' import fd_icon %}
|
||||||
|
{% macro render_workspace_tree(pages, depth=0) %}
|
||||||
|
{% for page in pages %}
|
||||||
|
{% set safe_name = page.name | replace("'", "\\'") %}
|
||||||
|
<li class="sidebar-item ws-tree-item"
|
||||||
|
data-id="{{ page.db_id }}"
|
||||||
|
data-is-folder="{{ 'true' if page.is_folder else 'false' }}"
|
||||||
|
data-depth="{{ depth }}"
|
||||||
|
data-name="{{ safe_name }}"
|
||||||
|
:class="{ active: activeNodeId === {{ page.db_id }} }"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="dragStart($event, {{ page.db_id }})"
|
||||||
|
@dragover.prevent="dragOver($event, {{ page.db_id }}, {{ 'true' if page.is_folder else 'false' }})"
|
||||||
|
@dragleave="dragLeave($event)"
|
||||||
|
@drop.prevent="drop($event, {{ page.db_id }})"
|
||||||
|
style="padding-left:{{ 4 + depth * 12 }}px;"
|
||||||
|
@contextmenu.prevent="showWsContext($event, {{ page.db_id }}, '{{ safe_name }}', {{ 'true' if page.is_folder else 'false' }})"
|
||||||
|
@touchstart="wsTouchStart($event)"
|
||||||
|
@touchend="wsTouchEnd($event, {{ page.db_id }}, '{{ safe_name }}', {{ 'true' if page.is_folder else 'false' }})"
|
||||||
|
@touchmove="wsTouchMove($event)">
|
||||||
|
{% if page.is_folder %}
|
||||||
|
<button class="tree-chevron"
|
||||||
|
@click.stop="toggleTreeFolder({{ page.db_id }})"
|
||||||
|
:class="{ open: expandedFolders[{{ page.db_id }}] }"
|
||||||
|
title="Toggle folder">▶</button>
|
||||||
|
{% else %}
|
||||||
|
<span style="width:12px;flex-shrink:0;"></span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="page-icon">{% set valid_icons = ["folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap"] %}{% if page.icon in valid_icons %}{{ fd_icon(page.icon,14) }}{% elif page.is_folder %}{{ fd_icon("folder",14) }}{% else %}{{ fd_icon("file",14) }}{% endif %}</span>
|
||||||
|
{% if page.is_folder %}
|
||||||
|
<span class="page-name tree-folder-link"
|
||||||
|
@click.stop="navigateToFolder({{ page.db_id }})"
|
||||||
|
draggable="false"
|
||||||
|
title="Open folder">{{ page.name }}{% if page.child_count %} <span class="child-count">({{ page.child_count }})</span>{% endif %}</span>
|
||||||
|
{% else %}
|
||||||
|
<a href="/pages/{{ page.db_id }}" class="page-name" draggable="false">{{ page.name }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% if page.children %}
|
||||||
|
<li x-show="expandedFolders[{{ page.db_id }}]" x-transition style="list-style:none;margin:0;padding:0;">
|
||||||
|
<ul style="list-style:none;margin:0;padding:0;">
|
||||||
|
{{ render_workspace_tree(page.children, depth + 1) }}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endmacro %}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block page_icon %}👤{% endblock %}
|
{% block page_icon %}{{ fd_icon("user",18) }}{% endblock %}
|
||||||
{% block page_title %}Accounts{% endblock %}
|
{% block page_title %}Accounts{% endblock %}
|
||||||
|
{% block title_prefix %}Accounts{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-title-area">
|
<div class="page-title-area">
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
<span class="page-icon-lg">👤</span>
|
<span class="page-icon-lg">{{ fd_icon("user",24) }}</span>
|
||||||
<h1>Account Management</h1>
|
<h1>Account Management</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,7 +50,7 @@
|
|||||||
<div style="background:var(--bg-secondary); border-radius:8px; padding:20px; margin-bottom:24px;">
|
<div style="background:var(--bg-secondary); border-radius:8px; padding:20px; margin-bottom:24px;">
|
||||||
<h3 style="margin-bottom:12px;">Connected Accounts</h3>
|
<h3 style="margin-bottom:12px;">Connected Accounts</h3>
|
||||||
<div style="display:flex; align-items:center; gap:12px; padding:12px; border:1px solid var(--border); border-radius:6px;">
|
<div style="display:flex; align-items:center; gap:12px; padding:12px; border:1px solid var(--border); border-radius:6px;">
|
||||||
<span style="font-size:20px;">🔗</span>
|
<span style="font-size:20px;">{{ fd_icon("link",20) }}</span>
|
||||||
<div style="flex:1;">
|
<div style="flex:1;">
|
||||||
<div style="font-weight:600;">Gitea</div>
|
<div style="font-weight:600;">Gitea</div>
|
||||||
<div style="font-size:12px; color:var(--text-dim);">
|
<div style="font-size:12px; color:var(--text-dim);">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block page_icon %}📁{% endblock %}
|
{% block page_icon %}{{ fd_icon("folder",18) }}{% endblock %}
|
||||||
{% block page_title %}Kanban board new{% endblock %}
|
{% block page_title %}Kanban board new{% endblock %}
|
||||||
|
{% block title_prefix %}Kanban board new{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Cover image -->
|
<!-- Cover image -->
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
<!-- Page title -->
|
<!-- Page title -->
|
||||||
<div class="page-title-area">
|
<div class="page-title-area">
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
<span class="page-icon-lg">📁</span>
|
<span class="page-icon-lg">{{ fd_icon("folder",24) }}</span>
|
||||||
<h1>{{ repo }}</h1>
|
<h1>{{ repo }}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,31 +41,31 @@
|
|||||||
hx-get="/board/{{ owner }}/{{ repo }}/view/kanban"
|
hx-get="/board/{{ owner }}/{{ repo }}/view/kanban"
|
||||||
hx-target="#view-content" hx-swap="innerHTML"
|
hx-target="#view-content" hx-swap="innerHTML"
|
||||||
@click="setActiveTab($el)">
|
@click="setActiveTab($el)">
|
||||||
<span class="tab-icon">📊</span> Kanban board
|
<span class="tab-icon">{{ fd_icon("bar-chart",14) }}</span> Kanban board
|
||||||
</button>
|
</button>
|
||||||
<button class="view-tab" data-view="detailed"
|
<button class="view-tab" data-view="detailed"
|
||||||
hx-get="/board/{{ owner }}/{{ repo }}/view/detailed"
|
hx-get="/board/{{ owner }}/{{ repo }}/view/detailed"
|
||||||
hx-target="#view-content" hx-swap="innerHTML"
|
hx-target="#view-content" hx-swap="innerHTML"
|
||||||
@click="setActiveTab($el)">
|
@click="setActiveTab($el)">
|
||||||
<span class="tab-icon">📋</span> Detailed board
|
<span class="tab-icon">{{ fd_icon("list",14) }}</span> Detailed board
|
||||||
</button>
|
</button>
|
||||||
<button class="view-tab" data-view="table"
|
<button class="view-tab" data-view="table"
|
||||||
hx-get="/board/{{ owner }}/{{ repo }}/view/table"
|
hx-get="/board/{{ owner }}/{{ repo }}/view/table"
|
||||||
hx-target="#view-content" hx-swap="innerHTML"
|
hx-target="#view-content" hx-swap="innerHTML"
|
||||||
@click="setActiveTab($el)">
|
@click="setActiveTab($el)">
|
||||||
<span class="tab-icon">☰</span> Table view
|
<span class="tab-icon">{{ fd_icon("align-left",14) }}</span> Table view
|
||||||
</button>
|
</button>
|
||||||
<button class="view-tab" data-view="status"
|
<button class="view-tab" data-view="status"
|
||||||
hx-get="/board/{{ owner }}/{{ repo }}/view/status"
|
hx-get="/board/{{ owner }}/{{ repo }}/view/status"
|
||||||
hx-target="#view-content" hx-swap="innerHTML"
|
hx-target="#view-content" hx-swap="innerHTML"
|
||||||
@click="setActiveTab($el)">
|
@click="setActiveTab($el)">
|
||||||
<span class="tab-icon">📈</span> Status overview
|
<span class="tab-icon">{{ fd_icon("trending-up",14) }}</span> Status overview
|
||||||
</button>
|
</button>
|
||||||
<button class="view-tab" data-view="teamload"
|
<button class="view-tab" data-view="teamload"
|
||||||
hx-get="/board/{{ owner }}/{{ repo }}/view/teamload"
|
hx-get="/board/{{ owner }}/{{ repo }}/view/teamload"
|
||||||
hx-target="#view-content" hx-swap="innerHTML"
|
hx-target="#view-content" hx-swap="innerHTML"
|
||||||
@click="setActiveTab($el)">
|
@click="setActiveTab($el)">
|
||||||
<span class="tab-icon">👥</span> Team Load
|
<span class="tab-icon">{{ fd_icon("users",14) }}</span> Team Load
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@
|
|||||||
<span class="sort-direction" @click="toggleDir(i)">
|
<span class="sort-direction" @click="toggleDir(i)">
|
||||||
<span x-text="s.dir === 'asc' ? 'Ascending ↑' : 'Descending ↓'"></span>
|
<span x-text="s.dir === 'asc' ? 'Ascending ↑' : 'Descending ↓'"></span>
|
||||||
</span>
|
</span>
|
||||||
<button class="sort-delete" @click="removeSort(i)" title="Delete">🗑</button>
|
<button class="sort-delete" @click="removeSort(i)" title="Delete">{{ fd_icon("trash",14) }}</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<button class="dropdown-option" @click="addSort()">+ Add sort</button>
|
<button class="dropdown-option" @click="addSort()">+ Add sort</button>
|
||||||
@@ -118,8 +119,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="toolbar-btn" title="Properties">⚡</button>
|
<button class="toolbar-btn" title="Properties">{{ fd_icon("zap",14) }}</button>
|
||||||
<button class="toolbar-btn" title="Search">🔍</button>
|
<button class="toolbar-btn" title="Search">{{ fd_icon("search",14) }}</button>
|
||||||
<button class="toolbar-btn">⋯</button>
|
<button class="toolbar-btn">⋯</button>
|
||||||
<button class="btn-new" @click="showNewIssue()">New ▾</button>
|
<button class="btn-new" @click="showNewIssue()">New ▾</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,7 +245,11 @@
|
|||||||
title: '', status: 'todo',
|
title: '', status: 'todo',
|
||||||
create() {
|
create() {
|
||||||
if (!this.title.trim()) return;
|
if (!this.title.trim()) return;
|
||||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, { method: 'POST' })
|
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||||
|
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||||
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
this.title = '';
|
this.title = '';
|
||||||
@@ -277,7 +282,16 @@
|
|||||||
onEnd: function(evt) {
|
onEnd: function(evt) {
|
||||||
const cardId = evt.item.dataset.cardId;
|
const cardId = evt.item.dataset.cardId;
|
||||||
const toStatus = evt.to.dataset.status;
|
const toStatus = evt.to.dataset.status;
|
||||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, { method: 'POST' });
|
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||||
|
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// ponytail: refresh current view after move
|
||||||
|
const activeTab = document.querySelector('.view-tab.active');
|
||||||
|
if (activeTab) activeTab.click();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<span class="card-tag" style="background: {{ card.tag_color }}20; color: {{ card.tag_color }};">{{ card.tag }}</span>
|
<span class="card-tag" style="background: {{ card.tag_color }}20; color: {{ card.tag_color }};">{{ card.tag }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if card.due_date %}
|
{% if card.due_date %}
|
||||||
<div class="card-date"><span>📅</span> {{ card.due_date }}</div>
|
<div class="card-date"><span>{{ fd_icon("calendar",14) }}</span> {{ card.due_date }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if card.due_date %}
|
{% if card.due_date %}
|
||||||
<div class="card-date">
|
<div class="card-date">
|
||||||
<span>📅</span> {{ card.due_date }}
|
<span>{{ fd_icon("calendar",14) }}</span> {{ card.due_date }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,13 +23,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Assignee -->
|
<!-- Assignee -->
|
||||||
<div class="filter-pill" style="cursor:pointer;">
|
<div class="filter-pill" style="cursor:pointer;">
|
||||||
<span>👤</span>
|
<span>{{ fd_icon("user",14) }}</span>
|
||||||
{% if issue.assignee %}{{ issue.assignee }}{% else %}Unassigned{% endif %}
|
{% if issue.assignee %}{{ issue.assignee }}{% else %}Unassigned{% endif %}
|
||||||
<span style="font-size:10px;">▾</span>
|
<span style="font-size:10px;">▾</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Deadline -->
|
<!-- Deadline -->
|
||||||
<div class="filter-pill" style="cursor:pointer;">
|
<div class="filter-pill" style="cursor:pointer;">
|
||||||
<span>📅</span>
|
<span>{{ fd_icon("calendar",14) }}</span>
|
||||||
{% if issue.due_date %}{{ issue.due_date }}{% else %}No deadline{% endif %}
|
{% if issue.due_date %}{{ issue.due_date }}{% else %}No deadline{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<!-- Labels -->
|
<!-- Labels -->
|
||||||
@@ -99,6 +99,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// ponytail: CSRF helper
|
||||||
|
function getCsrf() {
|
||||||
|
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
function cardDetail() {
|
function cardDetail() {
|
||||||
return {
|
return {
|
||||||
updateField(field, value) {
|
updateField(field, value) {
|
||||||
@@ -109,18 +115,27 @@
|
|||||||
console.log('Toggle status');
|
console.log('Toggle status');
|
||||||
},
|
},
|
||||||
toggleChecklistItem(id, checked) {
|
toggleChecklistItem(id, checked) {
|
||||||
fetch(`/api/checklist-items/${id}?checked=${checked}`, { method: 'PATCH' });
|
fetch(`/api/checklist-items/${id}?checked=${checked}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'X-CSRF-Token': getCsrf() }
|
||||||
|
});
|
||||||
},
|
},
|
||||||
addChecklistItem(clId) {
|
addChecklistItem(clId) {
|
||||||
const content = prompt('Item name:');
|
const content = prompt('Item name:');
|
||||||
if (content) {
|
if (content) {
|
||||||
fetch(`/api/checklist-items/${owner}/${repo}/${issue_id}/${clId}?content=${encodeURIComponent(content)}`, { method: 'POST' })
|
fetch(`/api/checklist-items/${owner}/${repo}/${issue_id}/${clId}?content=${encodeURIComponent(content)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': getCsrf() }
|
||||||
|
})
|
||||||
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
|
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addChecklist() {
|
addChecklist() {
|
||||||
const title = prompt('Checklist name:') || 'Checklist';
|
const title = prompt('Checklist name:') || 'Checklist';
|
||||||
fetch(`/api/checklists/${owner}/${repo}/${issue_id}?title=${encodeURIComponent(title)}`, { method: 'POST' })
|
fetch(`/api/checklists/${owner}/${repo}/${issue_id}?title=${encodeURIComponent(title)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': getCsrf() }
|
||||||
|
})
|
||||||
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
|
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
|
||||||
},
|
},
|
||||||
addComment(body) {
|
addComment(body) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block page_icon %}🏠{% endblock %}
|
{% block page_icon %}{{ fd_icon("home",18) }}{% endblock %}
|
||||||
{% block page_title %}Home{% endblock %}
|
{% block page_title %}Home{% endblock %}
|
||||||
|
{% block title_prefix %}Home{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-title-area" style="padding-top: 24px;">
|
<div class="page-title-area" style="padding-top: 24px;">
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
border-radius: 6px; transition: background 0.15s; cursor: pointer; color: var(--text-primary); text-decoration: none;"
|
border-radius: 6px; transition: background 0.15s; cursor: pointer; color: var(--text-primary); text-decoration: none;"
|
||||||
onmouseover="this.style.background='var(--bg-hover)'"
|
onmouseover="this.style.background='var(--bg-hover)'"
|
||||||
onmouseout="this.style.background='transparent'">
|
onmouseout="this.style.background='transparent'">
|
||||||
<span style="font-size:18px;">📁</span>
|
<span style="font-size:18px;">{{ fd_icon("folder",20) }}</span>
|
||||||
<div style="flex:1; min-width:0;">
|
<div style="flex:1; min-width:0;">
|
||||||
<div style="font-weight:500; font-size:14px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
<div style="font-weight:500; font-size:14px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||||
{{ repo.name }}
|
{{ repo.name }}
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
<span style="font-size:12px; color: var(--text-dim);">🟡 {{ repo.language }}</span>
|
<span style="font-size:12px; color: var(--text-dim);">🟡 {{ repo.language }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if repo.private %}
|
{% if repo.private %}
|
||||||
<span style="font-size:12px; color: var(--text-dim);">🔒</span>
|
<span style="font-size:12px; color: var(--text-dim);">{{ fd_icon("lock",14) }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span style="font-size:12px; color: var(--text-dim);">→</span>
|
<span style="font-size:12px; color: var(--text-dim);">→</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +66,7 @@
|
|||||||
|
|
||||||
{% if not repos %}
|
{% if not repos %}
|
||||||
<div style="text-align:center; padding:48px; color:var(--text-dim);">
|
<div style="text-align:center; padding:48px; color:var(--text-dim);">
|
||||||
<div style="font-size:48px; margin-bottom:16px;">📭</div>
|
<div style="margin-bottom:16px;">{{ fd_icon("inbox",48) }}</div>
|
||||||
<h3 style="color:var(--text-secondary);">Aucun projet trouvé</h3>
|
<h3 style="color:var(--text-secondary);">Aucun projet trouvé</h3>
|
||||||
<p style="margin-top:8px;">
|
<p style="margin-top:8px;">
|
||||||
{% if search %}
|
{% if search %}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if card.due_date %}
|
{% if card.due_date %}
|
||||||
<div class="card-date">
|
<div class="card-date">
|
||||||
<span>📅</span>
|
<span>{{ fd_icon("calendar",14) }}</span>
|
||||||
<span style="font-size:12px;">{{ card.due_date }}</span>
|
<span style="font-size:12px;">{{ card.due_date }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -0,0 +1,819 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block page_title %}{{ workspace_name }}{% endblock %}
|
||||||
|
{% block title_prefix %}{{ workspace_name }}{% endblock %}
|
||||||
|
{% block page_icon %}{{ fd_icon("link",18) }}{% endblock %}
|
||||||
|
|
||||||
|
{% block topbar %}
|
||||||
|
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
|
||||||
|
{% set page_icon = "link" %}
|
||||||
|
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">' ~ fd_icon("file",14) ~ '+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">' ~ fd_icon("upload",14) ~ '</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">' ~ fd_icon("refresh",14) ~ '</button><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
||||||
|
{% include '_header.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Prism.js syntax highlighting -->
|
||||||
|
<link rel="stylesheet" href="/static/css/prism.css">
|
||||||
|
<script src="/static/js/prism.min.js"></script>
|
||||||
|
<style>
|
||||||
|
.gw-main{display:flex;flex-direction:column;height:calc(100vh - 48px);overflow:hidden;}
|
||||||
|
.gw-content{padding:24px;overflow-y:auto;flex:1;}
|
||||||
|
.gw-content h3{font-size:16px;margin-bottom:12px;color:var(--text-dim);}
|
||||||
|
.gw-content pre{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:70vh;overflow-y:auto;}
|
||||||
|
.gw-content pre[class*="language-"]{background:transparent;border:none;padding:0;margin:0;white-space:pre;word-break:normal;}
|
||||||
|
.gw-content code[class*="language-"]{font-size:13px;line-height:1.5;tab-size:4;}
|
||||||
|
.gw-content .empty-state{text-align:center;padding:60px 20px;color:var(--text-dim);}
|
||||||
|
.gw-content .empty-state h2{font-size:18px;margin-bottom:8px;color:var(--text);}
|
||||||
|
.gw-content textarea{width:100%;min-height:50vh;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;padding:12px;font-family:monospace;resize:vertical;outline:none;}
|
||||||
|
.gw-content textarea:focus{border-color:var(--accent);}
|
||||||
|
.gw-file-toolbar{display:flex;align-items:center;gap:8px;padding:8px 24px;background:var(--bg-secondary);border-bottom:1px solid var(--border);font-size:13px;color:var(--text-dim);}
|
||||||
|
.gw-file-toolbar strong{color:var(--text);margin-right:12px;}
|
||||||
|
.gw-commit-bar{display:flex;align-items:center;gap:8px;padding:12px 24px;background:var(--bg-secondary);border-top:1px solid var(--border);flex-wrap:wrap;}
|
||||||
|
.gw-commit-bar input{flex:1;min-width:180px;padding:8px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;outline:none;}
|
||||||
|
.gw-commit-bar input:focus{border-color:var(--accent);}
|
||||||
|
|
||||||
|
/* Prism theme tweaks for dark UI */
|
||||||
|
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('giteaWorkspace', () => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const owner = params.get('owner') || '';
|
||||||
|
const repo = params.get('repo') || '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
owner, repo,
|
||||||
|
showFile: false,
|
||||||
|
showEditor: false,
|
||||||
|
showPrivate: false,
|
||||||
|
privatePages: [],
|
||||||
|
editingPrivate: null,
|
||||||
|
editingPrivateTitle: '',
|
||||||
|
editingPrivateContent: '',
|
||||||
|
filePath: '',
|
||||||
|
fileContent: '',
|
||||||
|
fileSize: 0,
|
||||||
|
fileSha: '',
|
||||||
|
fileLoading: false,
|
||||||
|
fileLanguage: 'text',
|
||||||
|
editContent: '',
|
||||||
|
commitMessage: 'Update via FlowDeck',
|
||||||
|
commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'),
|
||||||
|
// File tree browser
|
||||||
|
treeItems: [],
|
||||||
|
sortedTreeItems: [],
|
||||||
|
treeLoading: false,
|
||||||
|
folderStack: [],
|
||||||
|
currentPath: '',
|
||||||
|
// Context menu
|
||||||
|
gwCtx: { visible: false, x: 0, y: 0, item: null },
|
||||||
|
|
||||||
|
_sortTree() {
|
||||||
|
this.sortedTreeItems = [...this.treeItems].sort(function(a, b) {
|
||||||
|
if (a.type === b.type) return a.name.localeCompare(b.name);
|
||||||
|
return a.type === 'folder' ? -1 : 1;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Expose globally for header to access
|
||||||
|
window._gwData = this;
|
||||||
|
// Set cookie for sidebar
|
||||||
|
document.cookie = 'flowdeck_workspace=gitea:' + this.owner + ':' + this.repo + ';path=/;SameSite=Lax';
|
||||||
|
// Load sidebar tree (into gitea section)
|
||||||
|
this.loadSidebarTree();
|
||||||
|
// Load main content tree
|
||||||
|
this.loadMainTree('');
|
||||||
|
// Listen for sidebar clicks on Gitea items
|
||||||
|
this.setupSidebarClicks();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadMainTree(path) {
|
||||||
|
this.treeLoading = true;
|
||||||
|
this.currentPath = path;
|
||||||
|
try {
|
||||||
|
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree';
|
||||||
|
if (path) url += '?path=' + encodeURIComponent(path);
|
||||||
|
var r = await fetch(url);
|
||||||
|
if (!r.ok) { this.treeItems = []; this.sortedTreeItems = []; return; }
|
||||||
|
var d = await r.json();
|
||||||
|
this.treeItems = d.tree || [];
|
||||||
|
this._sortTree();
|
||||||
|
} catch(e) { this.treeItems = []; this.sortedTreeItems = []; }
|
||||||
|
finally { this.treeLoading = false; }
|
||||||
|
},
|
||||||
|
|
||||||
|
drillDown(path) {
|
||||||
|
this.folderStack.push(path.split('/').pop());
|
||||||
|
this.loadMainTree(path);
|
||||||
|
},
|
||||||
|
|
||||||
|
navigateToFolder(idx) {
|
||||||
|
// Truncate stack and rebuild path
|
||||||
|
this.folderStack = this.folderStack.slice(0, idx + 1);
|
||||||
|
var path = this.folderStack.join('/');
|
||||||
|
this.loadMainTree(path);
|
||||||
|
},
|
||||||
|
|
||||||
|
navigateToRoot() {
|
||||||
|
this.folderStack = [];
|
||||||
|
this.loadMainTree('');
|
||||||
|
},
|
||||||
|
|
||||||
|
openGwContext(ev, item) {
|
||||||
|
this.gwCtx = { visible: true, x: ev.clientX, y: ev.clientY, item: item };
|
||||||
|
},
|
||||||
|
gwRename() {
|
||||||
|
this.gwCtx.visible = false;
|
||||||
|
var item = this.gwCtx.item;
|
||||||
|
if (!item) return;
|
||||||
|
var newName = prompt('Rename:', item.name);
|
||||||
|
if (!newName || !newName.trim() || newName.trim() === item.name) return;
|
||||||
|
var self = this;
|
||||||
|
var oldPath = item.path;
|
||||||
|
var newPath = oldPath.replace(/[^/]+$/, newName.trim());
|
||||||
|
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath })
|
||||||
|
}).then(function(r){ return r.json(); })
|
||||||
|
.then(function(d){
|
||||||
|
if (d.status === 'ok') { self.refreshTree(); }
|
||||||
|
else { window.showToast('Rename failed', 'error'); }
|
||||||
|
})
|
||||||
|
.catch(function(){ window.showToast('Rename failed', 'error'); });
|
||||||
|
},
|
||||||
|
gwDelete() {
|
||||||
|
this.gwCtx.visible = false;
|
||||||
|
var item = this.gwCtx.item;
|
||||||
|
if (!item) return;
|
||||||
|
if (!confirm('Delete ' + item.name + '?')) return;
|
||||||
|
var self = this;
|
||||||
|
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {
|
||||||
|
method: 'DELETE'
|
||||||
|
}).then(function(r){
|
||||||
|
if (r.ok) { self.refreshTree(); }
|
||||||
|
else { window.showToast('Delete failed', 'error'); }
|
||||||
|
}).catch(function(){ window.showToast('Delete failed', 'error'); });
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadSidebarTree() {
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree');
|
||||||
|
if (!r.ok) {
|
||||||
|
// If API fails (e.g. no Gitea token), set a message
|
||||||
|
var container = document.getElementById('sidebar-gitea-items');
|
||||||
|
if (container) container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">'+getSvgIcon('link',14)+'</span><span class="page-name text-dim">Connect Gitea to browse files</span></li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var d = await r.json();
|
||||||
|
var items = d.tree || [];
|
||||||
|
var container = document.getElementById('sidebar-gitea-items');
|
||||||
|
if (!container) return;
|
||||||
|
// Ensure gitea section is visible
|
||||||
|
if (window.appState && window.appState.sectionsOpen && !window.appState.sectionsOpen.gitea) {
|
||||||
|
window.appState.sectionsOpen.gitea = true;
|
||||||
|
}
|
||||||
|
container.innerHTML = '';
|
||||||
|
// Build tree HTML as string and set once (more performant)
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
var item = items[i];
|
||||||
|
var icon = item.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
|
||||||
|
html += '<li class="sidebar-item" data-gitea-path="' + item.path + '" data-gitea-type="' + (item.type === 'folder' ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '') + '" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
|
||||||
|
html += '<span class="page-icon">' + icon + '</span>';
|
||||||
|
html += '<span class="page-name">' + item.name + '</span>';
|
||||||
|
html += '</li>';
|
||||||
|
}
|
||||||
|
// Add Private Pages link
|
||||||
|
html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>';
|
||||||
|
html += '<li class="sidebar-item" id="gitea-private-link" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
|
||||||
|
html += '<span class="page-icon">'+getSvgIcon('lock',14)+'</span><span class="page-name">Private Pages</span></li>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
// Re-attach event listeners
|
||||||
|
this.setupSidebarClicks();
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Gitea sidebar tree load failed:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setupSidebarClicks() {
|
||||||
|
var self = this;
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var el = e.target.closest('.sidebar-item[data-gitea-path]');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
var path = el.getAttribute('data-gitea-path');
|
||||||
|
var type = el.getAttribute('data-gitea-type');
|
||||||
|
|
||||||
|
// If clicking the checkbox, let its own handler deal with selection
|
||||||
|
if (e.target.classList.contains('gitea-checkbox')) return;
|
||||||
|
|
||||||
|
// If clicking the inline rename input, don't navigate
|
||||||
|
if (e.target.classList.contains('inline-rename-input')) return;
|
||||||
|
|
||||||
|
// If Shift or Ctrl/Meta is pressed, use multi-selection
|
||||||
|
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
self.selectItem(path, el, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal click: navigate (open file/folder)
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Update visual "active" state
|
||||||
|
document.querySelectorAll('.sidebar-item.active').forEach(function(si) {
|
||||||
|
si.classList.remove('active');
|
||||||
|
});
|
||||||
|
el.classList.add('active');
|
||||||
|
|
||||||
|
if (type === 'folder') {
|
||||||
|
self.loadSubdir(path, el);
|
||||||
|
} else {
|
||||||
|
self.openFile(path, el.getAttribute('data-gitea-sha'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for custom delete event on gitea sidebar items
|
||||||
|
document.addEventListener('gitea-delete', function(e) {
|
||||||
|
var el = e.target;
|
||||||
|
var path = el.getAttribute('data-gitea-path');
|
||||||
|
if (!path) return;
|
||||||
|
if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return;
|
||||||
|
var sha = el.getAttribute('data-gitea-sha') || '';
|
||||||
|
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {
|
||||||
|
method: 'DELETE',
|
||||||
|
}).then(function(r) {
|
||||||
|
if (r.ok) self.refreshTree();
|
||||||
|
}).catch(function() {});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadSubdir(path, el) {
|
||||||
|
var self = this;
|
||||||
|
// Check if already loaded
|
||||||
|
var ul = el.querySelector('ul');
|
||||||
|
if (ul) {
|
||||||
|
ul.style.display = ul.style.display === 'none' ? '' : 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree?path=' + encodeURIComponent(path));
|
||||||
|
if (!r.ok) return;
|
||||||
|
var d = await r.json();
|
||||||
|
var children = d.tree || [];
|
||||||
|
ul = document.createElement('ul');
|
||||||
|
ul.className = 'sidebar-items';
|
||||||
|
ul.style.paddingLeft = '20px';
|
||||||
|
children.forEach(function(child) {
|
||||||
|
var icon = child.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'sidebar-item';
|
||||||
|
li.setAttribute('data-gitea-path', child.path);
|
||||||
|
li.setAttribute('data-gitea-type', child.type === 'folder' ? 'folder' : 'file');
|
||||||
|
li.setAttribute('data-gitea-sha', child.sha || '');
|
||||||
|
li.style.cssText = 'cursor:pointer;display:flex;align-items:center;gap:4px;';
|
||||||
|
// Checkbox
|
||||||
|
var cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
cb.className = 'gitea-checkbox';
|
||||||
|
cb.style.cssText = 'flex-shrink:0;margin:0;display:none;';
|
||||||
|
cb.addEventListener('click', function(ev) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
self.selectItem(child.path, li, ev);
|
||||||
|
});
|
||||||
|
li.appendChild(cb);
|
||||||
|
// Icon
|
||||||
|
var iconSpan = document.createElement('span');
|
||||||
|
iconSpan.className = 'sidebar-icon';
|
||||||
|
iconSpan.textContent = icon;
|
||||||
|
li.appendChild(iconSpan);
|
||||||
|
// Name
|
||||||
|
var nameSpan = document.createElement('span');
|
||||||
|
nameSpan.textContent = child.name;
|
||||||
|
nameSpan.addEventListener('dblclick', function(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
self.startInlineRename(nameSpan, child.path, li);
|
||||||
|
});
|
||||||
|
li.appendChild(nameSpan);
|
||||||
|
ul.appendChild(li);
|
||||||
|
});
|
||||||
|
el.appendChild(ul);
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async openFile(path, sha) {
|
||||||
|
this.filePath = path;
|
||||||
|
this.fileSha = sha || '';
|
||||||
|
this.showEditor = false;
|
||||||
|
this.showFile = true;
|
||||||
|
this.fileLoading = true;
|
||||||
|
this.fileLanguage = this.getLanguage(path);
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(path));
|
||||||
|
if (r.ok) {
|
||||||
|
var d = await r.json();
|
||||||
|
this.fileContent = d.content || '';
|
||||||
|
this.fileSize = d.content ? d.content.length : 0;
|
||||||
|
} else {
|
||||||
|
this.fileContent = '[Error loading file]';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
this.fileContent = '[Error loading file]';
|
||||||
|
}
|
||||||
|
this.fileLoading = false;
|
||||||
|
// Highlight after Alpine renders
|
||||||
|
var self = this;
|
||||||
|
this.$nextTick(function() {
|
||||||
|
var block = self.$refs.codeBlock;
|
||||||
|
if (block && block.textContent && typeof Prism !== 'undefined') {
|
||||||
|
Prism.highlightElement(block);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getLanguage(path) {
|
||||||
|
var ext = (path || '').split('.').pop().toLowerCase();
|
||||||
|
var map = {
|
||||||
|
js:'javascript', jsx:'javascript', ts:'typescript', tsx:'typescript',
|
||||||
|
py:'python', rb:'ruby', rs:'rust', go:'go', java:'java', kt:'kotlin',
|
||||||
|
c:'c', cpp:'c', h:'c', hpp:'c', cs:'csharp',
|
||||||
|
html:'markup', htm:'markup', xml:'markup', svg:'markup', css:'css', scss:'css',
|
||||||
|
json:'json', yaml:'yaml', yml:'yaml', toml:'toml', ini:'ini',
|
||||||
|
md:'markdown', sql:'sql', sh:'bash', bash:'bash', zsh:'bash',
|
||||||
|
php:'php', swift:'swift', r:'r', lua:'lua', dart:'dart',
|
||||||
|
dockerfile:'docker', makefile:'makefile', cmake:'cmake',
|
||||||
|
};
|
||||||
|
return map[ext] || 'text';
|
||||||
|
},
|
||||||
|
|
||||||
|
openEditor() {
|
||||||
|
this.editContent = this.fileContent;
|
||||||
|
this.showEditor = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveFile() {
|
||||||
|
if (!this.filePath) return;
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: this.filePath, content: this.editContent,
|
||||||
|
message: this.commitMessage, sha: this.fileSha,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
this.fileContent = this.editContent;
|
||||||
|
this.showEditor = false;
|
||||||
|
if (!this.commitHistory.includes(this.commitMessage)) {
|
||||||
|
this.commitHistory.unshift(this.commitMessage);
|
||||||
|
if (this.commitHistory.length > 10) this.commitHistory.pop();
|
||||||
|
localStorage.setItem('fd_commit_msgs', JSON.stringify(this.commitHistory));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var d = await r.json();
|
||||||
|
window.showToast('Failed to save: ' + (d.error || 'Unknown error'), 'error');
|
||||||
|
}
|
||||||
|
} catch(e) { window.showToast('Network error', 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteCurrentFile() {
|
||||||
|
if (!this.filePath) return;
|
||||||
|
if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return;
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
this.showFile = false;
|
||||||
|
this.filePath = '';
|
||||||
|
await this.refreshTree();
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshTree() {
|
||||||
|
// Reload main tree and sidebar tree without full page reload
|
||||||
|
this.loadMainTree(this.currentPath);
|
||||||
|
this.loadSidebarTree();
|
||||||
|
},
|
||||||
|
|
||||||
|
formatSize(bytes) {
|
||||||
|
if (!bytes) return '';
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes/1048576).toFixed(1) + ' MB';
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Private Pages ──
|
||||||
|
|
||||||
|
async loadPrivatePages() {
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages');
|
||||||
|
if (r.ok) { var d = await r.json(); this.privatePages = d.pages || []; }
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
newPrivate() {
|
||||||
|
this.editingPrivate = 'new';
|
||||||
|
this.editingPrivateTitle = '';
|
||||||
|
this.editingPrivateContent = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
async openPrivate(id) {
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id);
|
||||||
|
if (r.ok) {
|
||||||
|
var d = await r.json();
|
||||||
|
this.editingPrivate = id;
|
||||||
|
this.editingPrivateTitle = d.page.title;
|
||||||
|
this.editingPrivateContent = d.page.content;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async savePrivate() {
|
||||||
|
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages';
|
||||||
|
var method = 'POST';
|
||||||
|
if (this.editingPrivate !== 'new') {
|
||||||
|
url += '/' + this.editingPrivate;
|
||||||
|
method = 'PUT';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var r = await fetch(url, {
|
||||||
|
method, headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''},
|
||||||
|
body: JSON.stringify({title: this.editingPrivateTitle, content: this.editingPrivateContent})
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
this.editingPrivate = null;
|
||||||
|
this.editingPrivateTitle = '';
|
||||||
|
this.editingPrivateContent = '';
|
||||||
|
await this.loadPrivatePages();
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create new file
|
||||||
|
showNewFile: false,
|
||||||
|
newFilePath: '',
|
||||||
|
newFileContent: '',
|
||||||
|
newFileMsg: 'Create via FlowDeck',
|
||||||
|
async createNewFile() {
|
||||||
|
if (!this.newFilePath.trim()) return;
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type':'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
||||||
|
body: JSON.stringify({path: this.newFilePath.trim(), content: this.newFileContent, message: this.newFileMsg || 'Create via FlowDeck'})
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
this.showNewFile = false;
|
||||||
|
this.newFilePath = '';
|
||||||
|
this.newFileContent = '';
|
||||||
|
this.newFileMsg = 'Create via FlowDeck';
|
||||||
|
await this.loadSidebarTree();
|
||||||
|
} else {
|
||||||
|
var d = await r.json();
|
||||||
|
window.showToast('Failed: ' + (d.error || d.detail || 'Unknown error'), 'error');
|
||||||
|
}
|
||||||
|
} catch(e) { window.showToast('Error: ' + e.message, 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Upload files
|
||||||
|
triggerUpload() { document.getElementById('gitea-upload-input').click(); },
|
||||||
|
async doUpload(ev) {
|
||||||
|
var files = ev.target.files;
|
||||||
|
if (!files.length) return;
|
||||||
|
for (var i = 0; i < files.length; i++) {
|
||||||
|
var form = new FormData();
|
||||||
|
form.append('file', files[i]);
|
||||||
|
form.append('message', 'Upload ' + files[i].name + ' via FlowDeck');
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'X-CSRF-Token': this.getCsrfToken()},
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
if (!r.ok) { var d = await r.json(); console.error('Upload failed:', d.error); }
|
||||||
|
} catch(e) { console.error('Upload error:', e); }
|
||||||
|
}
|
||||||
|
await this.loadSidebarTree();
|
||||||
|
ev.target.value = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
async deletePrivate(id) {
|
||||||
|
if (!confirm('Delete this private page?')) return;
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id, {
|
||||||
|
method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''}
|
||||||
|
});
|
||||||
|
if (r.ok) await this.loadPrivatePages();
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
getCsrfToken() {
|
||||||
|
return document.cookie.split('; ').find(function(c) { return c.startsWith('csrf_token='); })?.split('=')[1] || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Multi-selection ──
|
||||||
|
multiSelect: false,
|
||||||
|
selectedPaths: [],
|
||||||
|
lastClickedPath: null,
|
||||||
|
|
||||||
|
toggleMultiSelect() {
|
||||||
|
this.multiSelect = !this.multiSelect;
|
||||||
|
var cbs = document.querySelectorAll('.gitea-checkbox');
|
||||||
|
var self = this;
|
||||||
|
cbs.forEach(function(cb) { cb.style.display = self.multiSelect ? '' : 'none'; });
|
||||||
|
if (!this.multiSelect) {
|
||||||
|
// Clear selection when leaving multi-select mode
|
||||||
|
cbs.forEach(function(cb) { cb.checked = false; });
|
||||||
|
this.selectedPaths = [];
|
||||||
|
this.lastClickedPath = null;
|
||||||
|
// Remove selected class
|
||||||
|
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
|
||||||
|
el.classList.remove('gitea-selected');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectItem(path, li, ev) {
|
||||||
|
if (ev.shiftKey && this.lastClickedPath) {
|
||||||
|
// Range select
|
||||||
|
var all = Array.from(document.querySelectorAll('.sidebar-item[data-gitea-path]'));
|
||||||
|
var startIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === this.lastClickedPath; }.bind(this));
|
||||||
|
var endIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === path; }.bind(this));
|
||||||
|
if (startIdx >= 0 && endIdx >= 0) {
|
||||||
|
var lo = Math.min(startIdx, endIdx);
|
||||||
|
var hi = Math.max(startIdx, endIdx);
|
||||||
|
this.selectedPaths = [];
|
||||||
|
for (var i = lo; i <= hi; i++) {
|
||||||
|
this.selectedPaths.push(all[i].getAttribute('data-gitea-path'));
|
||||||
|
all[i].classList.add('gitea-selected');
|
||||||
|
var cb = all[i].querySelector('.gitea-checkbox');
|
||||||
|
if (cb) cb.checked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.multiSelect = true;
|
||||||
|
this.toggleMultiSelect(); // ensure checkboxes are visible
|
||||||
|
} else if (ev.ctrlKey || ev.metaKey) {
|
||||||
|
// Toggle single
|
||||||
|
var idx = this.selectedPaths.indexOf(path);
|
||||||
|
if (idx >= 0) {
|
||||||
|
this.selectedPaths.splice(idx, 1);
|
||||||
|
li.classList.remove('gitea-selected');
|
||||||
|
var cb = li.querySelector('.gitea-checkbox');
|
||||||
|
if (cb) cb.checked = false;
|
||||||
|
} else {
|
||||||
|
this.selectedPaths.push(path);
|
||||||
|
li.classList.add('gitea-selected');
|
||||||
|
var cb = li.querySelector('.gitea-checkbox');
|
||||||
|
if (cb) cb.checked = true;
|
||||||
|
}
|
||||||
|
this.multiSelect = this.selectedPaths.length > 0;
|
||||||
|
this.toggleMultiSelect();
|
||||||
|
} else {
|
||||||
|
// Normal select — clear multi-selection
|
||||||
|
this.selectedPaths = [path];
|
||||||
|
this.lastClickedPath = path;
|
||||||
|
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
|
||||||
|
el.classList.remove('gitea-selected');
|
||||||
|
});
|
||||||
|
li.classList.add('gitea-selected', 'active');
|
||||||
|
var cb = li.querySelector('.gitea-checkbox');
|
||||||
|
if (cb) cb.checked = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isSelected(path) {
|
||||||
|
return this.selectedPaths.indexOf(path) >= 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Inline rename ──
|
||||||
|
startInlineRename(nameSpan, path, li) {
|
||||||
|
var originalName = nameSpan.textContent;
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.value = originalName;
|
||||||
|
input.className = 'inline-rename-input';
|
||||||
|
input.style.cssText = 'flex:1;min-width:0;padding:2px 6px;background:var(--bg-tertiary);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-size:13px;outline:none;';
|
||||||
|
nameSpan.replaceWith(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
var self = this;
|
||||||
|
var finish = function() { self.finishInlineRename(input, originalName, path, li); };
|
||||||
|
var cancel = function() {
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.className = 'page-name';
|
||||||
|
span.textContent = originalName;
|
||||||
|
input.replaceWith(span);
|
||||||
|
};
|
||||||
|
input.addEventListener('blur', finish);
|
||||||
|
input.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') finish();
|
||||||
|
if (e.key === 'Escape') { e.stopPropagation(); cancel(); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
finishInlineRename(input, originalName, path, li) {
|
||||||
|
if (input._renaming) return; // guard against double-fire
|
||||||
|
input._renaming = true;
|
||||||
|
var newName = input.value.trim();
|
||||||
|
if (!newName || newName === originalName) {
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.className = 'page-name';
|
||||||
|
span.textContent = originalName;
|
||||||
|
input.replaceWith(span);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var self = this;
|
||||||
|
// Construct new path by replacing the last segment
|
||||||
|
var parts = path.split('/');
|
||||||
|
parts.pop();
|
||||||
|
var newPath = (parts.length > 0 ? parts.join('/') + '/' : '') + newName;
|
||||||
|
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/rename', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
||||||
|
body: JSON.stringify({old_path: path, new_path: newPath, new_name: newName})
|
||||||
|
}).then(function(r) {
|
||||||
|
if (r.ok) {
|
||||||
|
self.refreshTree();
|
||||||
|
} else {
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.className = 'page-name';
|
||||||
|
span.textContent = originalName;
|
||||||
|
input.replaceWith(span);
|
||||||
|
window.showToast('Rename failed', 'error');
|
||||||
|
}
|
||||||
|
}).catch(function() {
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.className = 'page-name';
|
||||||
|
span.textContent = originalName;
|
||||||
|
input.replaceWith(span);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<div class="gw-main" x-data="giteaWorkspace">
|
||||||
|
<!-- File view -->
|
||||||
|
<div x-show="showFile && !showEditor" x-transition>
|
||||||
|
<div class="gw-file-toolbar">
|
||||||
|
<strong x-text="filePath || ''"></strong>
|
||||||
|
<span x-text="formatSize(fileSize)"></span>
|
||||||
|
<span style="flex:1;"></span>
|
||||||
|
<button class="btn" @click="openEditor()">{{ fd_icon("edit",14) }} Edit</button>
|
||||||
|
<button class="btn btn-danger" @click="deleteCurrentFile()">{{ fd_icon("trash",14) }} Delete</button>
|
||||||
|
</div>
|
||||||
|
<div class="gw-content">
|
||||||
|
<!-- Skeleton loading -->
|
||||||
|
<div x-show="fileLoading" class="skeleton-file">
|
||||||
|
<div class="skeleton" style="height:15px;width:60%;margin-bottom:12px;"></div>
|
||||||
|
<div class="skeleton" style="height:15px;width:80%;margin-bottom:12px;"></div>
|
||||||
|
<div class="skeleton" style="height:15px;width:40%;margin-bottom:12px;"></div>
|
||||||
|
<div class="skeleton" style="height:15px;width:75%;margin-bottom:12px;"></div>
|
||||||
|
<div class="skeleton" style="height:15px;width:55%;margin-bottom:12px;"></div>
|
||||||
|
<div class="skeleton" style="height:15px;width:90%;margin-bottom:12px;"></div>
|
||||||
|
<div class="skeleton" style="height:15px;width:35%;margin-bottom:12px;"></div>
|
||||||
|
<div class="skeleton" style="height:15px;width:65%;margin-bottom:12px;"></div>
|
||||||
|
<div class="skeleton" style="height:15px;width:45%;margin-bottom:12px;"></div>
|
||||||
|
<div class="skeleton" style="height:15px;width:70%;margin-bottom:12px;"></div>
|
||||||
|
<div class="skeleton" style="height:15px;width:50%;"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Syntax-highlighted code -->
|
||||||
|
<pre x-show="!fileLoading"><code :class="'language-'+fileLanguage" x-ref="codeBlock" x-text="fileContent"></code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Editor view -->
|
||||||
|
<template x-if="showEditor">
|
||||||
|
<div>
|
||||||
|
<div class="gw-file-toolbar">
|
||||||
|
<strong>Editing: <span x-text="filePath || 'new file'"></span></strong>
|
||||||
|
<span style="flex:1;"></span>
|
||||||
|
<button class="btn" @click="showEditor=false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<div class="gw-content">
|
||||||
|
<textarea x-model="editContent"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="gw-commit-bar">
|
||||||
|
<input type="text" x-model="commitMessage" placeholder="Commit message" list="commit-history">
|
||||||
|
<datalist id="commit-history">
|
||||||
|
<template x-for="msg in commitHistory" :key="msg">
|
||||||
|
<option :value="msg">
|
||||||
|
</template>
|
||||||
|
</datalist>
|
||||||
|
<button class="btn-primary" @click="saveFile()">{{ fd_icon("save",14) }} Commit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- File tree browser (like local-workspace) -->
|
||||||
|
<template x-if="!showFile && !showEditor && !showPrivate">
|
||||||
|
<div class="gw-content">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="gw-breadcrumb" style="display:flex;align-items:center;gap:4px;margin-bottom:16px;font-size:14px;color:var(--text-secondary);">
|
||||||
|
<a href="#" @click.prevent="navigateToRoot()" style="color:var(--text-secondary);text-decoration:none;" x-text="owner + '/' + repo"></a>
|
||||||
|
<template x-for="(b, i) in folderStack" :key="i">
|
||||||
|
<span style="display:contents;">
|
||||||
|
<span style="color:var(--text-tertiary);">/</span>
|
||||||
|
<a href="#" @click.prevent="navigateToFolder(i)" style="color:var(--text-secondary);text-decoration:none;" x-text="b"></a>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span x-show="treeLoading" style="color:var(--text-tertiary);">Loading...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tree items -->
|
||||||
|
<div x-show="!treeLoading">
|
||||||
|
<template x-if="treeItems.length === 0">
|
||||||
|
<div class="empty-state">
|
||||||
|
<h2>{{ fd_icon("folder",16) }} Empty</h2>
|
||||||
|
<p>No files in this folder</p>
|
||||||
|
<button class="btn btn-primary" @click="showNewFile=!showNewFile">{{ fd_icon("file",14) }}+ New file</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="ws-tree">
|
||||||
|
<template x-for="item in sortedTreeItems" :key="item.path">
|
||||||
|
<div class="ws-tree-item"
|
||||||
|
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
|
||||||
|
@contextmenu.prevent="openGwContext($event, item)"
|
||||||
|
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
|
||||||
|
<span x-html="item.type==='folder' ? getSvgIcon('folder',16) : getSvgIcon('file',16)" style="font-size:16px;"></span>
|
||||||
|
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
|
||||||
|
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Context menu -->
|
||||||
|
<div class="gw-context-menu"
|
||||||
|
x-show="gwCtx.visible"
|
||||||
|
:style="'left:' + gwCtx.x + 'px; top:' + gwCtx.y + 'px'"
|
||||||
|
@click.outside="gwCtx.visible = false"
|
||||||
|
style="display:none;position:fixed;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-popover);z-index:1100;min-width:160px;padding:4px;">
|
||||||
|
<div class="ctx-item" @click="gwRename()">{{ fd_icon("edit",14) }} Rename</div>
|
||||||
|
<div class="ctx-item ctx-danger" @click="gwDelete()">{{ fd_icon("trash",14) }} Delete</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New file form -->
|
||||||
|
<div x-show="showNewFile" style="margin-top:16px;max-width:400px;">
|
||||||
|
<input type="text" x-model="newFilePath" placeholder="path/to/file.md" class="settings-input" style="margin-bottom:8px;">
|
||||||
|
<textarea x-model="newFileContent" placeholder="File content..." class="settings-input" style="height:120px;margin-bottom:8px;"></textarea>
|
||||||
|
<input type="text" x-model="newFileMsg" placeholder="Commit message" class="settings-input" style="margin-bottom:8px;">
|
||||||
|
<button class="btn btn-primary" @click="createNewFile()">{{ fd_icon("save",14) }} Create file</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Private pages view -->
|
||||||
|
<template x-if="showPrivate && !editingPrivate">
|
||||||
|
<div class="gw-content">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
||||||
|
<h3>{{ fd_icon("lock",14) }} Private Pages</h3>
|
||||||
|
<button class="btn btn-primary" onclick="window.FlowDeck.createPage()">+ New Page</button>
|
||||||
|
</div>
|
||||||
|
<template x-if="privatePages.length === 0">
|
||||||
|
<div class="empty-state" style="padding:40px 20px;">
|
||||||
|
<p>No private pages yet. Create one to store notes linked to this project.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-for="pp in privatePages" :key="pp.id">
|
||||||
|
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:8px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;"
|
||||||
|
@click="openPrivate(pp.id)">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:500;" x-text="pp.title"></div>
|
||||||
|
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">{{ fd_icon('trash',14) }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Private page editor -->
|
||||||
|
<template x-if="editingPrivate">
|
||||||
|
<div>
|
||||||
|
<div class="gw-file-toolbar">
|
||||||
|
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
|
||||||
|
<span style="flex:1;"></span>
|
||||||
|
<button class="btn" @click="savePrivate()">{{ fd_icon("save",14) }} Save</button>
|
||||||
|
<button class="btn" @click="editingPrivate=null;editingPrivateTitle='';editingPrivateContent='';">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<div class="gw-content">
|
||||||
|
<input x-model="editingPrivateTitle" placeholder="Page title" style="width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:16px;margin-bottom:12px;outline:none;">
|
||||||
|
<textarea x-model="editingPrivateContent" placeholder="Start writing…" style="min-height:60vh;"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
{% from '_icons.html' import fd_icon %}
|
||||||
|
<title>FlowDeck — All-in-one workspace</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #191919;
|
||||||
|
--bg-card: #1e1e1e;
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--text-dim: #999;
|
||||||
|
--accent: #2383E2;
|
||||||
|
--accent-hover: #2C8CEB;
|
||||||
|
--border: rgba(255,255,255,.06);
|
||||||
|
--radius: 12px;
|
||||||
|
}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box;}
|
||||||
|
body{
|
||||||
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
||||||
|
background:var(--bg);
|
||||||
|
color:var(--text);
|
||||||
|
min-height:100vh;
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
}
|
||||||
|
/* Nav */
|
||||||
|
.landing-nav{
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:space-between;
|
||||||
|
padding:16px 32px;
|
||||||
|
border-bottom:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.landing-logo{
|
||||||
|
font-size:20px;
|
||||||
|
font-weight:700;
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
gap:8px;
|
||||||
|
text-decoration:none;
|
||||||
|
color:var(--text);
|
||||||
|
}
|
||||||
|
.landing-nav-actions{
|
||||||
|
display:flex;
|
||||||
|
gap:12px;
|
||||||
|
align-items:center;
|
||||||
|
}
|
||||||
|
.btn{
|
||||||
|
padding:10px 20px;
|
||||||
|
border-radius:8px;
|
||||||
|
font-size:14px;
|
||||||
|
font-weight:500;
|
||||||
|
cursor:pointer;
|
||||||
|
text-decoration:none;
|
||||||
|
border:none;
|
||||||
|
transition:all .15s;
|
||||||
|
}
|
||||||
|
.btn-primary{
|
||||||
|
background:var(--accent);
|
||||||
|
color:#fff;
|
||||||
|
}
|
||||||
|
.btn-primary:hover{background:var(--accent-hover);}
|
||||||
|
.btn-secondary{
|
||||||
|
background:transparent;
|
||||||
|
color:var(--text);
|
||||||
|
border:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover{background:rgba(255,255,255,.05);}
|
||||||
|
|
||||||
|
/* Hero */
|
||||||
|
.hero{
|
||||||
|
text-align:center;
|
||||||
|
padding:80px 32px 60px;
|
||||||
|
max-width:720px;
|
||||||
|
margin:0 auto;
|
||||||
|
}
|
||||||
|
.hero h1{
|
||||||
|
font-size:clamp(2rem,5vw,3.2rem);
|
||||||
|
font-weight:800;
|
||||||
|
letter-spacing:-.5px;
|
||||||
|
margin-bottom:16px;
|
||||||
|
line-height:1.2;
|
||||||
|
}
|
||||||
|
.hero .gradient-text{
|
||||||
|
background:linear-gradient(135deg,#2383E2,#6C5CE7);
|
||||||
|
-webkit-background-clip:text;
|
||||||
|
-webkit-text-fill-color:transparent;
|
||||||
|
background-clip:text;
|
||||||
|
}
|
||||||
|
.hero p{
|
||||||
|
font-size:18px;
|
||||||
|
color:var(--text-dim);
|
||||||
|
line-height:1.6;
|
||||||
|
margin-bottom:32px;
|
||||||
|
}
|
||||||
|
.hero-actions{
|
||||||
|
display:flex;
|
||||||
|
gap:12px;
|
||||||
|
justify-content:center;
|
||||||
|
flex-wrap:wrap;
|
||||||
|
}
|
||||||
|
.hero-actions .btn{
|
||||||
|
font-size:15px;
|
||||||
|
padding:12px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Features grid */
|
||||||
|
.features{
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:repeat(auto-fit,minmax(260px,1fr));
|
||||||
|
gap:16px;
|
||||||
|
max-width:1000px;
|
||||||
|
margin:0 auto;
|
||||||
|
padding:0 32px 60px;
|
||||||
|
}
|
||||||
|
.feature-card{
|
||||||
|
background:var(--bg-card);
|
||||||
|
border:1px solid var(--border);
|
||||||
|
border-radius:var(--radius);
|
||||||
|
padding:28px 24px;
|
||||||
|
transition:border-color .15s;
|
||||||
|
}
|
||||||
|
.feature-card:hover{border-color:rgba(255,255,255,.12);}
|
||||||
|
.feature-icon{font-size:28px;margin-bottom:12px;}
|
||||||
|
.feature-card h3{font-size:16px;font-weight:600;margin-bottom:8px;}
|
||||||
|
.feature-card p{font-size:14px;color:var(--text-dim);line-height:1.5;}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.landing-footer{
|
||||||
|
margin-top:auto;
|
||||||
|
text-align:center;
|
||||||
|
padding:32px;
|
||||||
|
border-top:1px solid var(--border);
|
||||||
|
font-size:13px;
|
||||||
|
color:var(--text-dim);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="landing-nav">
|
||||||
|
<a href="/" class="landing-logo">
|
||||||
|
<span>{{ fd_icon("book",20) }}</span> FlowDeck
|
||||||
|
</a>
|
||||||
|
<div class="landing-nav-actions">
|
||||||
|
<a href="/auth/login?provider=local" class="btn btn-secondary">Log in</a>
|
||||||
|
<a href="/auth/register" class="btn btn-primary">Get started free</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<h1>Your <span class="gradient-text">Notion-style</span> workspace,<br>self-hosted & integrated</h1>
|
||||||
|
<p>
|
||||||
|
Write, organize, and collaborate — with block-based editing,
|
||||||
|
Kanban boards, databases, and deep Gitea/GitHub integration.
|
||||||
|
All on your own server.
|
||||||
|
</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a href="/auth/register" class="btn btn-primary">Get started — it's free</a>
|
||||||
|
<a href="/auth/login?provider=local" class="btn btn-secondary">I already have an account</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="features">
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">{{ fd_icon("edit",24) }}</div>
|
||||||
|
<h3>Block Editor</h3>
|
||||||
|
<p>Notion-style editor with slash commands, markdown shortcuts, headings, code blocks, to-dos, and more.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">{{ fd_icon("bar-chart",24) }}</div>
|
||||||
|
<h3>Kanban & Tables</h3>
|
||||||
|
<p>Visual project management with drag-drop boards, table views, and database collections.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">{{ fd_icon("link",24) }}</div>
|
||||||
|
<h3>Gitea & GitHub</h3>
|
||||||
|
<p>Browse repos, edit files, commit changes — all from your workspace sidebar.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">{{ fd_icon("globe",24) }}</div>
|
||||||
|
<h3>Publish to Web</h3>
|
||||||
|
<p>One-click publish any page to a public URL. Share with anyone, no account needed.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">{{ fd_icon("lock",24) }}</div>
|
||||||
|
<h3>Self-Hosted</h3>
|
||||||
|
<p>Your data stays on your server. No vendor lock-in, full control, Docker single-container deploy.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">{{ fd_icon("zap",24) }}</div>
|
||||||
|
<h3>Fast & Light</h3>
|
||||||
|
<p>Alpine.js + FastAPI + SQLite. No React bloat, no Node.js needed. Sub-100ms page loads.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="landing-footer">
|
||||||
|
FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<form hx-post="/notes/{{ owner }}/{{ repo }}" hx-target=".notes-editor" hx-swap="outerHTML">
|
<form hx-post="/notes/{{ owner }}/{{ repo }}" hx-target=".notes-editor" hx-swap="outerHTML">
|
||||||
<textarea name="content" class="notes-textarea" placeholder="Notes markdown pour ce projet...">{{ content }}</textarea>
|
<textarea name="content" class="notes-textarea" placeholder="Notes markdown pour ce projet...">{{ content }}</textarea>
|
||||||
<div class="notes-actions">
|
<div class="notes-actions">
|
||||||
<button type="submit" class="btn btn-primary">💾 Sauvegarder</button>
|
<button type="submit" class="btn btn-primary">{{ fd_icon("save",14) }} Sauvegarder</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ title }} — FlowDeck</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #191919;
|
||||||
|
--bg-card: #1e1e1e;
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--text-dim: #999;
|
||||||
|
--accent: #4c9aff;
|
||||||
|
--radius: 12px;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.7;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.pub-header {
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.06);
|
||||||
|
padding: 14px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: rgba(255,255,255,.01);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.pub-brand {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pub-brand span { font-size: 18px; }
|
||||||
|
.pub-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
background: rgba(76,154,255,.12);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.pub-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 24px 80px;
|
||||||
|
}
|
||||||
|
.pub-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: -.5px;
|
||||||
|
}
|
||||||
|
.pub-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.pub-content {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.pub-content p {
|
||||||
|
margin: 4px 0;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.pub-content h1, .pub-content h2, .pub-content h3, .pub-content h4 {
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.pub-content pre {
|
||||||
|
background: rgba(255,255,255,.05);
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.pub-content code {
|
||||||
|
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.pub-content blockquote {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 4px 16px;
|
||||||
|
opacity: .85;
|
||||||
|
}
|
||||||
|
.pub-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.pub-content hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid rgba(255,255,255,.08);
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.pub-content details {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.pub-content summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.pub-footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,.06);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.pub-footer a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="pub-header">
|
||||||
|
<a href="/" class="pub-brand">
|
||||||
|
<span>{{ fd_icon("book",20) }}</span> FlowDeck
|
||||||
|
</a>
|
||||||
|
<span class="pub-badge">{{ fd_icon("globe",14) }} Published</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="pub-container">
|
||||||
|
<h1 class="pub-title">{{ title }}</h1>
|
||||||
|
{% if updated_at %}
|
||||||
|
<div class="pub-meta">Last updated: {{ updated_at[:10] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="pub-content">
|
||||||
|
{{ content_html | safe }}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="pub-footer">
|
||||||
|
Made with <a href="/">FlowDeck</a> — a Notion-style workspace
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,803 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block page_title %}Settings{% endblock %}
|
||||||
|
{% block title_prefix %}Settings{% endblock %}
|
||||||
|
{% block page_icon %}{{ fd_icon("settings",18) }}{% endblock %}
|
||||||
|
|
||||||
|
{% block topbar %}
|
||||||
|
{% set page_icon = "settings" %}
|
||||||
|
{% set page_title = "Settings" %}
|
||||||
|
{% include '_header.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.settings-overlay{position:fixed;inset:0;z-index:500;display:flex;background:rgba(0,0,0,.4);backdrop-filter:blur(2px);}
|
||||||
|
.settings-panel{display:flex;width:1050px;max-width:98vw;height:85vh;margin:auto;background:var(--bg-primary);border:1px solid var(--border-strong);border-radius:14px;box-shadow:var(--shadow-modal);overflow:hidden;}
|
||||||
|
.settings-nav{width:200px;min-width:200px;background:var(--bg-sidebar);border-right:1px solid var(--border);padding:8px 0;overflow-y:auto;}
|
||||||
|
.settings-nav-header{padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;}
|
||||||
|
.settings-nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;border-radius:0;}
|
||||||
|
.settings-nav-item:hover{background:var(--bg-hover);}
|
||||||
|
.settings-nav-item.active{background:rgba(35,131,226,.12);color:var(--text);font-weight:500;}
|
||||||
|
.settings-content{flex:1;overflow-y:auto;padding:24px 32px;}
|
||||||
|
.settings-content h2{font-size:18px;font-weight:600;margin-bottom:4px;}
|
||||||
|
.settings-content .section-desc{font-size:12px;color:var(--text-dim);margin-bottom:24px;}
|
||||||
|
.setting-group{margin-bottom:28px;}
|
||||||
|
.setting-group h3{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid var(--border);}
|
||||||
|
.setting-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);}
|
||||||
|
.setting-row:last-child{border-bottom:none;}
|
||||||
|
.setting-label{font-size:14px;color:var(--text);}
|
||||||
|
.setting-desc{font-size:12px;color:var(--text-dim);margin-top:2px;}
|
||||||
|
.setting-control{flex-shrink:0;margin-left:16px;}
|
||||||
|
|
||||||
|
/* Avatar */
|
||||||
|
.avatar-section{display:flex;align-items:center;gap:16px;padding:12px 0;}
|
||||||
|
.avatar-preview{width:64px;height:64px;border-radius:50%;background:var(--bg-tertiary);display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:700;color:var(--text);overflow:hidden;flex-shrink:0;}
|
||||||
|
.avatar-preview img{width:100%;height:100%;object-fit:cover;}
|
||||||
|
.avatar-upload-label{cursor:pointer;font-size:13px;color:var(--accent);padding:6px 12px;border:1px solid var(--accent);border-radius:6px;transition:all 150ms;}
|
||||||
|
.avatar-upload-label:hover{background:rgba(35,131,226,.1);}
|
||||||
|
.avatar-upload-input{display:none;}
|
||||||
|
|
||||||
|
/* Avatar color swatches */
|
||||||
|
.avatar-color-swatch{width:40px;height:40px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:var(--text-primary);cursor:pointer;border:2px solid transparent;transition:all 120ms;}
|
||||||
|
.avatar-color-swatch:hover{transform:scale(1.1);}
|
||||||
|
.avatar-color-swatch.selected{border-color:var(--accent);box-shadow:0 0 0 2px rgba(35,131,226,.4);}
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.tag-mgmt-item{display:flex;align-items:center;gap:10px;padding:8px 10px;background:var(--bg-tertiary);border-radius:8px;margin-bottom:4px;}
|
||||||
|
.tag-mgmt-item .tag-color-dot{width:14px;height:14px;border-radius:50%;flex-shrink:0;}
|
||||||
|
.tag-name{flex:1;font-size:13px;color:var(--text);}
|
||||||
|
.tag-count{font-size:12px;color:var(--text-dim);}
|
||||||
|
.new-tag-form{display:flex;align-items:center;gap:10px;margin-top:8px;padding:8px 10px;background:var(--bg-tertiary);border:1px dashed var(--border);border-radius:8px;}
|
||||||
|
.new-tag-form input{flex:1;background:none;border:none;color:var(--text);font-size:13px;outline:none;}
|
||||||
|
.color-swatch{width:22px;height:22px;border-radius:6px;cursor:pointer;border:2px solid transparent;transition:all 120ms;}
|
||||||
|
.color-swatch:hover{transform:scale(1.15);}
|
||||||
|
.color-swatch.selected{border-color:var(--text);box-shadow:0 0 0 2px var(--accent);}
|
||||||
|
|
||||||
|
/* Admin tables */
|
||||||
|
.table-wrap{overflow-x:auto;border-radius:8px;border:1px solid var(--border);}
|
||||||
|
.admin-table{width:100%;border-collapse:collapse;font-size:13px;}
|
||||||
|
.admin-table th{background:var(--bg-tertiary);color:var(--text-dim);font-weight:500;padding:8px 12px;text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap;}
|
||||||
|
.admin-table td{padding:8px 12px;border-top:1px solid var(--border);color:var(--text);}
|
||||||
|
.admin-table tr:hover td{background:var(--bg-secondary);}
|
||||||
|
.stat-card{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:14px;text-align:center;}
|
||||||
|
.stat-value{font-size:22px;font-weight:700;color:var(--accent);}
|
||||||
|
.stat-label{font-size:11px;color:var(--text-dim);margin-top:2px;text-transform:uppercase;letter-spacing:.5px;}
|
||||||
|
.role-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:500;}
|
||||||
|
.role-admin{background:rgba(35,131,226,.2);color:var(--accent);}
|
||||||
|
.role-user{background:rgba(120,119,116,.2);color:var(--text-dim);}
|
||||||
|
.status-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:5px;vertical-align:middle;}
|
||||||
|
.status-dot.active{background:var(--toast-success-bg);}
|
||||||
|
.status-dot.inactive{background:var(--danger);}
|
||||||
|
|
||||||
|
/* Close button */
|
||||||
|
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;}
|
||||||
|
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
|
||||||
|
<div class="settings-panel" style="position:relative;">
|
||||||
|
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
|
||||||
|
|
||||||
|
<!-- Left navigation -->
|
||||||
|
<div class="settings-nav">
|
||||||
|
<div class="settings-nav-header">Account</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'"><span class="nav-icon-inline">{{ fd_icon("user",14) }}</span> My account</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
|
||||||
|
<div class="settings-nav-header">Workspace</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
|
||||||
|
<div class="settings-nav-header">Features</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
|
||||||
|
<!-- Admin nav: only visible to admins -->
|
||||||
|
<template x-if="userIsAdmin">
|
||||||
|
<div>
|
||||||
|
<div class="settings-nav-header">Admin</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users & Roles</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right content -->
|
||||||
|
<div class="settings-content">
|
||||||
|
|
||||||
|
<!-- Account -->
|
||||||
|
<div x-show="activeSection==='account'">
|
||||||
|
<h2>My account</h2>
|
||||||
|
<p class="section-desc">Manage your profile and preferences.</p>
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>Profile</h3>
|
||||||
|
<div class="avatar-section">
|
||||||
|
<div class="avatar-preview" :style="{background: avatarColor || '#3A3A3A'}">
|
||||||
|
<img x-show="avatarUrl" :src="avatarUrl" alt="Avatar">
|
||||||
|
<span x-show="!avatarUrl">{{ user.get('full_name', user.get('login',''))[:1].upper() }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="avatar-upload-label" style="margin-bottom:4px;">
|
||||||
|
Upload photo
|
||||||
|
<input type="file" class="avatar-upload-input" accept="image/*" @change="uploadAvatar($event)">
|
||||||
|
</label>
|
||||||
|
<div style="font-size:11px;color:var(--text-dim);margin-top:8px;">JPG, PNG or GIF — max 2MB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px;">Or pick a color:</div>
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||||||
|
<template x-for="c in avatarColors" :key="c">
|
||||||
|
<div class="avatar-color-swatch" :style="{background: c}"
|
||||||
|
:class="{ selected: avatarColor === c }"
|
||||||
|
@click="selectAvatarColor(c)">
|
||||||
|
<span>{{ user.get('full_name', user.get('login',''))[:1].upper() }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Editable fields -->
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>Account details</h3>
|
||||||
|
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||||||
|
<div class="setting-label">Username</div>
|
||||||
|
<input type="text" class="settings-input" x-model="accountForm.login" style="max-width:300px;">
|
||||||
|
</div>
|
||||||
|
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||||||
|
<div class="setting-label">Full name</div>
|
||||||
|
<input type="text" class="settings-input" x-model="accountForm.full_name" style="max-width:300px;">
|
||||||
|
</div>
|
||||||
|
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||||||
|
<div class="setting-label">Email</div>
|
||||||
|
<input type="email" class="settings-input" x-model="accountForm.email" style="max-width:300px;">
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
<button class="btn btn-primary" @click="saveAccount()" :disabled="accountSaving" style="width:auto;padding:8px 20px;font-size:13px;">
|
||||||
|
<span x-show="!accountSaving">{{ fd_icon("save",14) }} Save changes</span>
|
||||||
|
<span x-show="accountSaving">Saving…</span>
|
||||||
|
</button>
|
||||||
|
<span x-show="accountMsg" x-text="accountMsg" style="margin-left:12px;font-size:13px;color:var(--toast-success-bg);"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password change -->
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>Change password</h3>
|
||||||
|
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||||||
|
<div class="setting-label">New password</div>
|
||||||
|
<div class="pw-wrapper" style="max-width:300px;">
|
||||||
|
<input :type="pwVisible ? 'text' : 'password'" class="settings-input" x-model="accountForm.password" placeholder="Leave blank to keep current" style="padding-right:36px;">
|
||||||
|
<button type="button" class="pw-toggle" @click="pwVisible=!pwVisible" x-text="pwVisible ? '{{ fd_icon('eye-off',14) }}' : '{{ fd_icon('eye',14) }}'" style="top:50%;"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
<button class="btn btn-primary" @click="saveAccount()" :disabled="accountSaving || !accountForm.password" style="width:auto;padding:8px 20px;font-size:13px;">
|
||||||
|
<span x-show="!accountSaving">{{ fd_icon("lock",14) }} Update password</span>
|
||||||
|
<span x-show="accountSaving">Saving…</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preferences -->
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>Preferences</h3>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Theme</div>
|
||||||
|
<div class="setting-desc">Choose between light and dark appearance</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<select x-model="theme" @change="applyTheme()" style="background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;padding:6px 10px;outline:none;">
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Default view</div>
|
||||||
|
<div class="setting-desc">Workspace view mode when opening a workspace</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<select x-model="defaultView" @change="saveDefaultView()" style="background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;padding:6px 10px;outline:none;">
|
||||||
|
<option value="tree">{{ fd_icon("folder",14) }} Tree</option>
|
||||||
|
<option value="list">{{ fd_icon("list",14) }} List</option>
|
||||||
|
<option value="details">{{ fd_icon("bar-chart",14) }} Details</option>
|
||||||
|
<option value="title">{{ fd_icon("grid",14) }} Title</option>
|
||||||
|
<option value="content">{{ fd_icon("align-left",14) }} Content</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notifications -->
|
||||||
|
<div x-show="activeSection==='notifications'">
|
||||||
|
<h2>Notifications</h2>
|
||||||
|
<p class="section-desc">Choose when and how you want to be notified.</p>
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>Email notifications</h3>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Comments</div>
|
||||||
|
<div class="setting-desc">When someone comments on your pages</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control"><span style="font-size:12px;color:var(--text-dim);">Coming soon</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Mentions</div>
|
||||||
|
<div class="setting-desc">When someone @mentions you</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control"><span style="font-size:12px;color:var(--text-dim);">Coming soon</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Workspace -->
|
||||||
|
<div x-show="activeSection==='workspace'">
|
||||||
|
<h2>Workspace</h2>
|
||||||
|
<p class="section-desc">General workspace settings.</p>
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>General</h3>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Workspace name</div>
|
||||||
|
<div class="setting-desc">{{ workspace_name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div x-show="activeSection==='tags'">
|
||||||
|
<h2>Tags</h2>
|
||||||
|
<p class="section-desc">Manage all tags across your workspace.</p>
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>All tags</h3>
|
||||||
|
<template x-for="tag in allTags" :key="tag.id">
|
||||||
|
<div class="tag-mgmt-item">
|
||||||
|
<div class="tag-color-dot" :style="{background: tag.color}"></div>
|
||||||
|
<span class="tag-name"
|
||||||
|
x-text="tag.name"
|
||||||
|
@dblclick="renameTag(tag)"
|
||||||
|
title="Double-click to rename"
|
||||||
|
style="cursor:pointer;"></span>
|
||||||
|
<span class="tag-count" x-text="tag.count + ' items'"></span>
|
||||||
|
<input type="color" :value="tag.color" @input="updateTagColor(tag.id, $event.target.value)" style="width:24px;height:24px;border:none;cursor:pointer;background:none;padding:0;" title="Change color">
|
||||||
|
<button class="tag-chip rm-btn" @click="deletingTag=tag" title="Delete">×</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="new-tag-form">
|
||||||
|
<template x-for="c in presetColors" :key="c">
|
||||||
|
<div class="color-swatch" :class="{ selected: newTagColor === c }" :style="{background: c}" @click="newTagColor=c"></div>
|
||||||
|
</template>
|
||||||
|
<input type="text" placeholder="New tag..." x-model="newTagName" @keydown.enter="createTag()">
|
||||||
|
<button class="btn btn-primary" @click="createTag()" :disabled="!newTagName.trim()" style="padding:4px 12px;font-size:12px;">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Delete confirmation modal -->
|
||||||
|
<div class="modal-overlay" x-show="deletingTag" @click.self="deletingTag=null" @keydown.escape="deletingTag=null" style="z-index:1000;">
|
||||||
|
<div class="modal-box" style="max-width:360px;">
|
||||||
|
<h3 style="margin:0 0 8px;">Delete tag</h3>
|
||||||
|
<p style="color:var(--text-dim);margin:0 0 16px;">
|
||||||
|
Are you sure you want to delete the tag "<strong x-text="deletingTag?.name"></strong>"?
|
||||||
|
<span x-show="deletingTag?.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
|
||||||
|
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag?.count"></span> item(s).
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||||
|
<button class="btn" @click="deletingTag=null">Cancel</button>
|
||||||
|
<button class="btn btn-danger" @click="confirmDeleteTag()">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Rename modal -->
|
||||||
|
<div class="modal-overlay" x-show="renamingTag" @click.self="renamingTag=null" @keydown.escape="renamingTag=null" style="z-index:1000;">
|
||||||
|
<div class="modal-box" style="max-width:360px;">
|
||||||
|
<h3 style="margin:0 0 12px;">Rename tag</h3>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;">
|
||||||
|
<div class="tag-color-dot" :style="{background: renamingTag?.color}" style="width:16px;height:16px;"></div>
|
||||||
|
<input type="text" class="settings-input" x-model="renameValue"
|
||||||
|
@keydown.enter="confirmRenameTag()" @keydown.escape="renamingTag=null"
|
||||||
|
style="flex:1;" autofocus>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px;">
|
||||||
|
<button class="btn" @click="renamingTag=null">Cancel</button>
|
||||||
|
<button class="btn btn-primary" @click="confirmRenameTag()" :disabled="!renameValue.trim()">Rename</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Features -->
|
||||||
|
<div x-show="activeSection==='features'">
|
||||||
|
<h2>Features</h2>
|
||||||
|
<p class="section-desc">Integrations and feature toggles.</p>
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>Integrations</h3>
|
||||||
|
<!-- Gitea -->
|
||||||
|
<div class="setting-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Gitea <span x-show="authMethod==='gitea'" style="font-size:10px;background:var(--accent);color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">Primary</span></div>
|
||||||
|
<div class="setting-desc">
|
||||||
|
<span x-show="giteaLinked">Connected to git.dracodev.net</span>
|
||||||
|
<span x-show="!giteaLinked">Connect your Gitea account to access projects</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<span x-show="giteaLinked" style="font-size:12px;color:var(--toast-success-bg);">✅ Active</span>
|
||||||
|
<button x-show="giteaLinked && authMethod!=='gitea'" class="btn-sm" style="color:var(--danger);" @click="disconnectGitea()">Disconnect</button>
|
||||||
|
<a x-show="!giteaLinked" class="btn-sm" href="/auth/login?provider=gitea&mode=link" style="text-decoration:none;color:var(--accent);">{{ fd_icon("link",14) }} Connect</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- GitHub -->
|
||||||
|
<div class="setting-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">GitHub <span x-show="authMethod==='github'" style="font-size:10px;background:var(--accent);color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">Primary</span></div>
|
||||||
|
<div class="setting-desc">
|
||||||
|
<span x-show="githubLinked">Connected to github.com</span>
|
||||||
|
<span x-show="!githubLinked">Connect your GitHub account to access repositories</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<span x-show="githubLinked" style="font-size:12px;color:var(--toast-success-bg);">✅ Active</span>
|
||||||
|
<button x-show="githubLinked && authMethod!=='github'" class="btn-sm" style="color:var(--danger);" @click="disconnectGithub()">Disconnect</button>
|
||||||
|
<a x-show="!githubLinked" class="btn-sm" href="/auth/login?provider=github&mode=link" style="text-decoration:none;color:var(--accent);">{{ fd_icon("link",14) }} Connect</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin: Users & Roles -->
|
||||||
|
<div x-show="activeSection==='admin-users'">
|
||||||
|
<h2>Users & Roles</h2>
|
||||||
|
<p class="section-desc">Manage user accounts, roles, and permissions.</p>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px;">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" x-text="adminStats.total_users">—</div>
|
||||||
|
<div class="stat-label">Total Users</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" x-text="adminStats.total_workspaces">—</div>
|
||||||
|
<div class="stat-label">Workspaces</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" x-text="adminStats.total_files">—</div>
|
||||||
|
<div class="stat-label">Files</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" x-text="adminStats.total_mb + ' MB'">— MB</div>
|
||||||
|
<div class="stat-label">Storage</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create User Button -->
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<button class="btn btn-primary" @click="showCreateUser=!showCreateUser" style="font-size:13px;">+ New User</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create User Form -->
|
||||||
|
<div x-show="showCreateUser" class="new-tag-form" style="margin-bottom:16px;">
|
||||||
|
<input type="text" placeholder="Login" x-model="newUser.login" style="width:120px;">
|
||||||
|
<input type="text" placeholder="Full name" x-model="newUser.name" style="width:140px;">
|
||||||
|
<input type="email" placeholder="Email" x-model="newUser.email" style="width:160px;">
|
||||||
|
<input type="password" placeholder="Password" x-model="newUser.password" style="width:130px;">
|
||||||
|
<label style="font-size:11px;color:var(--text-dim);white-space:nowrap;">
|
||||||
|
<input type="checkbox" x-model="newUser.is_admin"> Admin
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-primary" @click="adminCreateUser()" :disabled="!newUser.login || !newUser.password" style="padding:4px 12px;font-size:12px;">Create</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Table -->
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Workspaces</th>
|
||||||
|
<th>Files</th>
|
||||||
|
<th>Folders</th>
|
||||||
|
<th>Storage</th>
|
||||||
|
<th>Last Login</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="u in adminUsers" :key="u.id">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="font-weight:500;" x-text="u.full_name || u.login"></div>
|
||||||
|
<div style="font-size:11px;color:var(--text-dim);" x-text="u.login"></div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="role-badge" :class="u.is_admin ? 'role-admin' : 'role-user'" x-text="u.is_admin ? 'Admin' : 'User'"></span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-dot" :class="u.is_active ? 'active' : 'inactive'"></span>
|
||||||
|
<span x-text="u.is_active ? 'Active' : 'Inactive'" style="font-size:12px;color:var(--text-dim);"></span>
|
||||||
|
</td>
|
||||||
|
<td x-text="u.ws_count" style="text-align:center;"></td>
|
||||||
|
<td x-text="u.file_count" style="text-align:center;"></td>
|
||||||
|
<td x-text="u.folder_count" style="text-align:center;"></td>
|
||||||
|
<td x-text="u.total_mb + ' MB'" style="text-align:right;"></td>
|
||||||
|
<td>
|
||||||
|
<span style="font-size:11px;color:var(--text-dim);" x-text="u.last_login ? new Date(u.last_login*1000).toLocaleDateString() : 'Never'"></span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="display:flex;gap:4px;">
|
||||||
|
<button class="btn-sm" @click="editingUser=u; editUserForm={login:u.login,name:u.full_name,email:u.email,is_admin:u.is_admin,is_active:u.is_active}" title="Edit">{{ fd_icon("edit",14) }}</button>
|
||||||
|
<button class="btn-sm btn-sm-danger" @click="adminDeleteUser(u.id)" title="Delete" x-show="adminUsers.length > 1">{{ fd_icon("trash",14) }}</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit User Modal -->
|
||||||
|
<div class="modal-overlay" x-show="editingUser" @click.self="editingUser=null" @keydown.escape="editingUser=null" style="z-index:1001;">
|
||||||
|
<div class="modal-box" style="max-width:400px;">
|
||||||
|
<h3 style="margin:0 0 12px;">Edit User: <span x-text="editUserForm.login"></span></h3>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:10px;">
|
||||||
|
<input type="text" class="settings-input" placeholder="Full name" x-model="editUserForm.name">
|
||||||
|
<input type="email" class="settings-input" placeholder="Email" x-model="editUserForm.email">
|
||||||
|
<input type="password" class="settings-input" placeholder="New password (leave blank to keep)" x-model="editUserForm.password">
|
||||||
|
<label style="font-size:13px;color:var(--text);display:flex;align-items:center;gap:8px;">
|
||||||
|
<input type="checkbox" x-model="editUserForm.is_admin"> Admin role
|
||||||
|
</label>
|
||||||
|
<label style="font-size:13px;color:var(--text);display:flex;align-items:center;gap:8px;">
|
||||||
|
<input type="checkbox" x-model="editUserForm.is_active"> Active account
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px;">
|
||||||
|
<button class="btn" @click="editingUser=null">Cancel</button>
|
||||||
|
<button class="btn btn-primary" @click="adminUpdateUser()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin: Audit Log -->
|
||||||
|
<div x-show="activeSection==='admin-audit'">
|
||||||
|
<h2>Audit Log</h2>
|
||||||
|
<p class="section-desc">Login history and security events.</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>User Agent</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="e in auditEntries" :key="e.id">
|
||||||
|
<tr>
|
||||||
|
<td><span style="font-size:12px;" x-text="new Date(e.logged_at+'Z').toLocaleString()"></span></td>
|
||||||
|
<td>
|
||||||
|
<span style="font-weight:500;" x-text="e.full_name || e.login"></span>
|
||||||
|
<span style="font-size:11px;color:var(--text-dim);display:block;" x-text="e.login"></span>
|
||||||
|
</td>
|
||||||
|
<td><code style="font-size:11px;" x-text="e.ip_address || '—'"></code></td>
|
||||||
|
<td><span style="font-size:11px;color:var(--text-dim);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;" x-text="e.user_agent || '—'"></span></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="auditEntries.length === 0">
|
||||||
|
<td colspan="4" style="text-align:center;color:var(--text-dim);padding:20px;">No login events yet</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', function() {
|
||||||
|
Alpine.data('settingsInit', function() {
|
||||||
|
return {
|
||||||
|
activeSection: 'account',
|
||||||
|
allTags: [],
|
||||||
|
newTagName: '',
|
||||||
|
newTagColor: '#787774',
|
||||||
|
deletingTag: null,
|
||||||
|
renamingTag: null,
|
||||||
|
renameValue: '',
|
||||||
|
presetColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
|
||||||
|
avatarUrl: '{{ avatar_url or "" }}',
|
||||||
|
avatarColor: '{{ avatar_color or "#3A3A3A" }}',
|
||||||
|
avatarColors: ['#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#448361','#337EA9','#9065B0','#C94D8B','#787774','#3A3A3A'],
|
||||||
|
defaultView: localStorage.getItem('fd_default_view') || 'tree',
|
||||||
|
userInfo: {full_name: '{{ user.full_name or "" }}', login: '{{ user.login or "" }}', email: '{{ user.email or "" }}'},
|
||||||
|
userIsAdmin: {{ 'true' if user.get('is_admin') else 'false' }},
|
||||||
|
// Auth & Integrations
|
||||||
|
authMethod: '{{ auth_method }}',
|
||||||
|
giteaLinked: false,
|
||||||
|
githubLinked: false,
|
||||||
|
// Theme
|
||||||
|
theme: localStorage.getItem('fd_theme') || 'light',
|
||||||
|
// Account form
|
||||||
|
accountForm: {login:'{{ user.login or "" }}', full_name:'{{ user.full_name or "" }}', email:'{{ user.email or "" }}', password:''},
|
||||||
|
accountSaving: false,
|
||||||
|
accountMsg: '',
|
||||||
|
pwVisible: false,
|
||||||
|
// Admin data
|
||||||
|
adminUsers: [],
|
||||||
|
adminStats: {},
|
||||||
|
auditEntries: [],
|
||||||
|
showCreateUser: false,
|
||||||
|
newUser: {login:'',name:'',email:'',password:'',is_admin:false},
|
||||||
|
editingUser: null,
|
||||||
|
editUserForm: {login:'',name:'',email:'',password:'',is_admin:false,is_active:true},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadTags();
|
||||||
|
await this.loadGiteaStatus();
|
||||||
|
await this.loadGithubStatus();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadTags() {
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/settings/tags/all');
|
||||||
|
var d = await r.json();
|
||||||
|
this.allTags = d.tags || [];
|
||||||
|
} catch(e) { this.allTags = []; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTag() {
|
||||||
|
var n = this.newTagName.trim();
|
||||||
|
if (!n) return;
|
||||||
|
var r = await fetch('/api/settings/tags', {
|
||||||
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({name: n, color: this.newTagColor})
|
||||||
|
});
|
||||||
|
if (r.ok) { this.newTagName = ''; this.newTagColor = '#787774'; await this.loadTags(); }
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateTagColor(id, color) {
|
||||||
|
await fetch('/api/settings/tags/' + id, {
|
||||||
|
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({color: color})
|
||||||
|
});
|
||||||
|
await this.loadTags();
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteTag(id) {
|
||||||
|
if (!confirm('Delete this tag?')) return;
|
||||||
|
await fetch('/api/settings/tags/' + id, { method: 'DELETE' });
|
||||||
|
await this.loadTags();
|
||||||
|
},
|
||||||
|
|
||||||
|
async renameTag(tag) {
|
||||||
|
this.renamingTag = tag;
|
||||||
|
this.renameValue = tag.name;
|
||||||
|
// Focus the input after Alpine renders the modal
|
||||||
|
var self = this;
|
||||||
|
this.$nextTick(function() {
|
||||||
|
var input = document.querySelector('.modal-box input[type=text]');
|
||||||
|
if (input) { input.focus(); input.select(); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async confirmRenameTag() {
|
||||||
|
var tag = this.renamingTag;
|
||||||
|
var newName = this.renameValue.trim();
|
||||||
|
if (!tag || !newName || newName.toLowerCase() === tag.name.toLowerCase()) {
|
||||||
|
this.renamingTag = null; return;
|
||||||
|
}
|
||||||
|
await fetch('/api/settings/tags/' + tag.id, {
|
||||||
|
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({name: newName})
|
||||||
|
});
|
||||||
|
this.renamingTag = null;
|
||||||
|
await this.loadTags();
|
||||||
|
},
|
||||||
|
|
||||||
|
async confirmDeleteTag() {
|
||||||
|
var tag = this.deletingTag;
|
||||||
|
if (!tag) return;
|
||||||
|
await fetch('/api/settings/tags/' + tag.id, { method: 'DELETE' });
|
||||||
|
this.deletingTag = null;
|
||||||
|
await this.loadTags();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Admin ──
|
||||||
|
getCsrfToken() {
|
||||||
|
return document.cookie.split('; ').find(r => r.startsWith('csrf_token='))?.split('=')[1] || '';
|
||||||
|
},
|
||||||
|
async adminFetch(url, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
opts.headers = opts.headers || {};
|
||||||
|
opts.headers['X-CSRF-Token'] = this.getCsrfToken();
|
||||||
|
return await fetch(url, opts);
|
||||||
|
},
|
||||||
|
async loadAdminUsers() {
|
||||||
|
try {
|
||||||
|
var r = await this.adminFetch('/api/admin/users');
|
||||||
|
var d = await r.json();
|
||||||
|
this.adminUsers = d.users || [];
|
||||||
|
} catch(e) { this.adminUsers = []; }
|
||||||
|
await this.loadAdminStats();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadAdminStats() {
|
||||||
|
try {
|
||||||
|
var r = await this.adminFetch('/api/admin/stats');
|
||||||
|
this.adminStats = await r.json();
|
||||||
|
} catch(e) { this.adminStats = {}; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadAdminAudit() {
|
||||||
|
try {
|
||||||
|
var r = await this.adminFetch('/api/admin/audit?limit=200');
|
||||||
|
var d = await r.json();
|
||||||
|
this.auditEntries = d.entries || [];
|
||||||
|
} catch(e) { this.auditEntries = []; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async adminCreateUser() {
|
||||||
|
var u = this.newUser;
|
||||||
|
if (!u.login || !u.password) return;
|
||||||
|
try {
|
||||||
|
var r = await this.adminFetch('/api/admin/users', {
|
||||||
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({login:u.login, name:u.name, email:u.email, password:u.password, is_admin:u.is_admin?1:0})
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
this.newUser = {login:'',name:'',email:'',password:'',is_admin:false};
|
||||||
|
this.showCreateUser = false;
|
||||||
|
await this.loadAdminUsers();
|
||||||
|
} else {
|
||||||
|
var d = await r.json();
|
||||||
|
window.showToast(d.error || 'Failed to create user', 'error');
|
||||||
|
}
|
||||||
|
} catch(e) { window.showToast('Network error', 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
async adminUpdateUser() {
|
||||||
|
var f = this.editUserForm;
|
||||||
|
if (!f.login) return;
|
||||||
|
var body = {name: f.name, email: f.email, is_admin: f.is_admin?1:0, is_active: f.is_active?1:0};
|
||||||
|
if (f.password) body.password = f.password;
|
||||||
|
try {
|
||||||
|
var r = await this.adminFetch('/api/admin/users/' + this.editingUser.id, {
|
||||||
|
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
this.editingUser = null;
|
||||||
|
await this.loadAdminUsers();
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async adminDeleteUser(id) {
|
||||||
|
if (!confirm('Permanently delete this user and all their data?')) return;
|
||||||
|
try {
|
||||||
|
await this.adminFetch('/api/admin/users/' + id, { method: 'DELETE' });
|
||||||
|
await this.loadAdminUsers();
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveAccount() {
|
||||||
|
this.accountSaving = true;
|
||||||
|
this.accountMsg = '';
|
||||||
|
var body = {};
|
||||||
|
var f = this.accountForm;
|
||||||
|
if (f.full_name !== this.userInfo.full_name) body.full_name = f.full_name;
|
||||||
|
if (f.login !== this.userInfo.login) body.login = f.login;
|
||||||
|
if (f.email !== this.userInfo.email) body.email = f.email;
|
||||||
|
if (f.password) body.password = f.password;
|
||||||
|
if (Object.keys(body).length === 0) { this.accountSaving = false; return; }
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/settings/account', {
|
||||||
|
method: 'PUT', headers: {'Content-Type':'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
var d = await r.json();
|
||||||
|
if (r.ok) {
|
||||||
|
this.userInfo = {login: d.user.login, full_name: d.user.full_name, email: d.user.email};
|
||||||
|
this.accountForm.password = '';
|
||||||
|
this.accountMsg = '✓ Saved!';
|
||||||
|
setTimeout(() => { this.accountMsg = ''; }, 3000);
|
||||||
|
} else {
|
||||||
|
this.accountMsg = '✗ ' + (d.error || 'Error');
|
||||||
|
}
|
||||||
|
} catch(e) { this.accountMsg = '✗ Network error'; }
|
||||||
|
this.accountSaving = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadAvatar(ev) {
|
||||||
|
var file = ev.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
var form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
var r = await fetch('/api/settings/avatar', { method: 'POST', body: form });
|
||||||
|
if (r.ok) {
|
||||||
|
var d = await r.json();
|
||||||
|
this.avatarUrl = d.avatar_url + '?t=' + Date.now();
|
||||||
|
this.avatarColor = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async selectAvatarColor(color) {
|
||||||
|
this.avatarColor = color;
|
||||||
|
var r = await fetch('/api/settings/avatar-color', {
|
||||||
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({color: color})
|
||||||
|
});
|
||||||
|
if (r.ok) { this.avatarUrl = ''; }
|
||||||
|
},
|
||||||
|
|
||||||
|
applyTheme() {
|
||||||
|
localStorage.setItem('fd_theme', this.theme);
|
||||||
|
document.documentElement.setAttribute('data-theme', this.theme);
|
||||||
|
},
|
||||||
|
|
||||||
|
saveDefaultView() {
|
||||||
|
localStorage.setItem('fd_default_view', this.defaultView);
|
||||||
|
},
|
||||||
|
async loadGiteaStatus() {
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/gitea/status');
|
||||||
|
var d = await r.json();
|
||||||
|
this.giteaLinked = d.linked || false;
|
||||||
|
} catch(e) { this.giteaLinked = false; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async disconnectGitea() {
|
||||||
|
if (!confirm('Disconnect Gitea? You can reconnect anytime.')) return;
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/gitea/disconnect', { method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken()} });
|
||||||
|
if (r.ok) { this.giteaLinked = false; }
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
async loadGithubStatus() {
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/github/status');
|
||||||
|
var d = await r.json();
|
||||||
|
this.githubLinked = d.linked || false;
|
||||||
|
} catch(e) { this.githubLinked = false; }
|
||||||
|
},
|
||||||
|
async disconnectGithub() {
|
||||||
|
if (!confirm('Disconnect GitHub? You can reconnect anytime.')) return;
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/github/disconnect', { method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken()} });
|
||||||
|
if (r.ok) { this.githubLinked = false; }
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
closeSettings() {
|
||||||
|
// Refresh parent page after settings changes, then navigate back
|
||||||
|
if (window.opener && !window.opener.closed) {
|
||||||
|
window.opener.location.reload();
|
||||||
|
window.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If navigated directly to settings, redirect to workspaces (forces refresh)
|
||||||
|
if (!document.referrer || document.referrer === window.location.href) {
|
||||||
|
window.location.href = '/workspaces?ts=' + Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Go back and force refresh
|
||||||
|
var prev = document.referrer;
|
||||||
|
if (prev && prev.includes('/settings')) {
|
||||||
|
// Came from another settings tab — go back two steps then refresh
|
||||||
|
history.go(-2);
|
||||||
|
setTimeout(function() { window.location.reload(); }, 100);
|
||||||
|
} else {
|
||||||
|
history.back();
|
||||||
|
setTimeout(function() { window.location.reload(); }, 100);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
{% from '_icons.html' import fd_icon %}
|
||||||
<!-- Table View — Notion style -->
|
<!-- Table View — Notion style -->
|
||||||
<div id="view-content" x-data="tableView()">
|
<div id="view-content" x-data="tableView()">
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
@@ -9,23 +10,23 @@
|
|||||||
<span x-show="sortField === 'name'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
<span x-show="sortField === 'name'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||||
</th>
|
</th>
|
||||||
<th @click="sortBy('status')">
|
<th @click="sortBy('status')">
|
||||||
<span class="th-icon">⚙</span> Status
|
<span class="th-icon">{{ fd_icon("settings",14) }}</span> Status
|
||||||
<span x-show="sortField === 'status'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
<span x-show="sortField === 'status'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||||
</th>
|
</th>
|
||||||
<th @click="sortBy('assignee')">
|
<th @click="sortBy('assignee')">
|
||||||
<span class="th-icon">👤</span> Assign
|
<span class="th-icon">{{ fd_icon("user",14) }}</span> Assign
|
||||||
<span x-show="sortField === 'assignee'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
<span x-show="sortField === 'assignee'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||||
</th>
|
</th>
|
||||||
<th @click="sortBy('deadline')">
|
<th @click="sortBy('deadline')">
|
||||||
<span class="th-icon">📅</span> Deadline
|
<span class="th-icon">{{ fd_icon("calendar",14) }}</span> Deadline
|
||||||
<span x-show="sortField === 'deadline'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
<span x-show="sortField === 'deadline'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||||
</th>
|
</th>
|
||||||
<th @click="sortBy('team')">
|
<th @click="sortBy('team')">
|
||||||
<span class="th-icon">👥</span> Team
|
<span class="th-icon">{{ fd_icon("users",14) }}</span> Team
|
||||||
<span x-show="sortField === 'team'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
<span x-show="sortField === 'team'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||||
</th>
|
</th>
|
||||||
<th @click="sortBy('keywords')">
|
<th @click="sortBy('keywords')">
|
||||||
<span class="th-icon">🏷</span> AI keywords
|
<span class="th-icon">{{ fd_icon("tag",14) }}</span> AI keywords
|
||||||
<span x-show="sortField === 'keywords'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
<span x-show="sortField === 'keywords'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
{% from '_icons.html' import fd_icon %}
|
||||||
<!-- Team Load — Notion-style stacked bar chart -->
|
<!-- Team Load — Notion-style stacked bar chart -->
|
||||||
<div id="view-content" x-data="teamLoad()" class="team-load">
|
<div id="view-content" x-data="teamLoad()" class="team-load">
|
||||||
{% for member in team_data %}
|
{% for member in team_data %}
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
|
|
||||||
{% if not team_data %}
|
{% if not team_data %}
|
||||||
<div style="text-align:center; padding:48px; width:100%; color:var(--text-dim);">
|
<div style="text-align:center; padding:48px; width:100%; color:var(--text-dim);">
|
||||||
<div style="font-size:48px; margin-bottom:16px;">👥</div>
|
<div style="margin-bottom:16px;">{{ fd_icon("users",48) }}</div>
|
||||||
<h3>No team data</h3>
|
<h3>No team data</h3>
|
||||||
<p style="margin-top:8px;">Assign issues to team members to see their workload.</p>
|
<p style="margin-top:8px;">Assign issues to team members to see their workload.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block page_icon %}🗑️{% endblock %}
|
{% block page_icon %}{{ fd_icon("trash",18) }}{% endblock %}
|
||||||
{% block page_title %}Trash{% endblock %}
|
{% block page_title %}Trash{% endblock %}
|
||||||
|
{% block title_prefix %}Trash{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-title-area">
|
<div class="page-title-area">
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
<span class="page-icon-lg">🗑️</span>
|
<span class="page-icon-lg">{{ fd_icon("trash",24) }}</span>
|
||||||
<h1>Trash</h1>
|
<h1>Trash</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
|
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div style="position:relative; margin-bottom:12px;">
|
<div style="position:relative; margin-bottom:12px;">
|
||||||
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">🔍</span>
|
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
|
||||||
<input type="text" placeholder="Search pages in Trash" x-model="search"
|
<input type="text" placeholder="Search pages in Trash" x-model="search"
|
||||||
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
|
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
@@ -21,10 +22,10 @@
|
|||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div style="display:flex; gap:8px; margin-bottom:16px;">
|
<div style="display:flex; gap:8px; margin-bottom:16px;">
|
||||||
<button class="trash-filter active">
|
<button class="trash-filter active">
|
||||||
<span style="color:#5b9bd5;">👤</span> Last edited by ▾
|
<span style="color:var(--accent);">{{ fd_icon("user",14) }}</span> Last edited by ▾
|
||||||
</button>
|
</button>
|
||||||
<button class="trash-filter">
|
<button class="trash-filter">
|
||||||
<span>📁</span> In ▾
|
<span>{{ fd_icon("folder",14) }}</span> In ▾
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,12 +42,12 @@
|
|||||||
↩️
|
↩️
|
||||||
</button>
|
</button>
|
||||||
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
|
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
|
||||||
🗑️
|
{{ fd_icon("trash",14) }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div x-show="filteredItems.length === 0" class="lib-empty">
|
<div x-show="filteredItems.length === 0" class="lib-empty">
|
||||||
<div style="font-size:48px;margin-bottom:16px;">🗑️</div>
|
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",14) }}</div>
|
||||||
<h3>Trash is empty</h3>
|
<h3>Trash is empty</h3>
|
||||||
<p>Deleted pages will appear here for 30 days.</p>
|
<p>Deleted pages will appear here for 30 days.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block page_title %}Workspace{% endblock %}
|
||||||
|
{% block title_prefix %}Workspace{% endblock %}
|
||||||
|
{% block page_icon %}{{ fd_icon("home",18) }}{% endblock %}
|
||||||
|
|
||||||
|
{% block topbar %}
|
||||||
|
{% set page_icon = "home" %}
|
||||||
|
{% set page_title = "Workspace — Projects" %}
|
||||||
|
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
||||||
|
{% include '_header.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.workspace-layout{max-width:900px;margin:0 auto;padding:32px 24px;}
|
||||||
|
.workspace-header{margin-bottom:24px;}
|
||||||
|
.workspace-header h1{font-size:28px;font-weight:600;margin-bottom:8px;color:var(--text-primary);}
|
||||||
|
.workspace-header p{color:var(--text-secondary);font-size:14px;}
|
||||||
|
|
||||||
|
.forge-section{margin-bottom:32px;}
|
||||||
|
.forge-section h2{font-size:14px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px;font-weight:500;display:flex;align-items:center;gap:8px;}
|
||||||
|
.forge-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:500;}
|
||||||
|
.forge-badge.gitea{background:rgba(96,155,80,.15);color:var(--green);}
|
||||||
|
.forge-badge.github{background:rgba(110,84,148,.15);color:var(--purple);}
|
||||||
|
.forge-badge.builtin{background:rgba(35,131,226,.15);color:var(--accent);}
|
||||||
|
|
||||||
|
.project-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;}
|
||||||
|
.project-card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-lg);padding:16px;cursor:pointer;transition:background var(--transition);}
|
||||||
|
.project-card:hover{background:var(--bg-hover);border-color:var(--border-strong);}
|
||||||
|
.project-card-top{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:8px;}
|
||||||
|
.project-card-icon{font-size:20px;}
|
||||||
|
.project-card-name{font-size:14px;font-weight:500;color:var(--text-primary);margin-bottom:4px;}
|
||||||
|
.project-card-desc{font-size:12px;color:var(--text-secondary);margin-bottom:8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
|
||||||
|
.project-card-meta{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--text-dim);}
|
||||||
|
|
||||||
|
.empty-state{text-align:center;padding:40px;color:var(--text-dim);font-size:14px;}
|
||||||
|
.empty-state .btn{margin-top:12px;display:inline-block;padding:8px 16px;background:var(--accent);color:#fff;border-radius:var(--radius-md);text-decoration:none;font-size:13px;}
|
||||||
|
|
||||||
|
.new-project-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;background:rgba(35,131,226,.15);color:var(--accent);border:1px solid rgba(35,131,226,.3);border-radius:var(--radius-md);cursor:pointer;font-size:13px;}
|
||||||
|
.new-project-btn:hover{background:rgba(35,131,226,.25);}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;z-index:500;}
|
||||||
|
.modal-box{background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);padding:32px;width:400px;max-width:90vw;box-shadow:var(--shadow-modal);}
|
||||||
|
.modal-box h3{font-size:16px;margin-bottom:16px;color:var(--text-primary);}
|
||||||
|
.modal-input{width:100%;padding:10px 12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-md);color:var(--text-primary);font-size:14px;margin-bottom:12px;outline:none;}
|
||||||
|
.modal-input:focus{border-color:var(--accent);}
|
||||||
|
.modal-actions{display:flex;gap:8px;justify-content:flex-end;}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="workspace-layout" x-data="workspacePage()">
|
||||||
|
|
||||||
|
<div class="workspace-header">
|
||||||
|
<h1>{{ fd_icon("home",20) }} Workspace</h1>
|
||||||
|
<p>All your projects in one place — built-in, Gitea, and GitHub</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Built-in Projects -->
|
||||||
|
<div class="forge-section">
|
||||||
|
<h2><span class="forge-badge builtin">Built-in</span> My Projects</h2>
|
||||||
|
<div class="project-grid">
|
||||||
|
<template x-for="p in builtinProjects" :key="p.id">
|
||||||
|
<div class="project-card" @click="openProject(p)">
|
||||||
|
<div class="project-card-top">
|
||||||
|
<span class="project-card-icon" x-html="getSvgIcon(p.icon || 'folder',20)"></span>
|
||||||
|
<span class="forge-badge builtin">Built-in</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-card-name" x-text="p.name"></div>
|
||||||
|
<div class="project-card-desc" x-text="p.description || 'No description'"></div>
|
||||||
|
<div class="project-card-meta">
|
||||||
|
<span x-text="p.pageCount + ' pages'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="project-card" style="border:1px dashed var(--border-strong);display:flex;align-items:center;justify-content:center;min-height:100px" @click="showCreateModal = true">
|
||||||
|
<span style="color:var(--text-dim);font-size:24px">+</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gitea Projects -->
|
||||||
|
<div class="forge-section" x-show="giteaProjects.length">
|
||||||
|
<h2><span class="forge-badge gitea">{{ fd_icon("link",14) }} Gitea</span> Repositories</h2>
|
||||||
|
<div class="project-grid">
|
||||||
|
<template x-for="p in giteaProjects" :key="p.id">
|
||||||
|
<div class="project-card" @click="openProject(p)">
|
||||||
|
<div class="project-card-top">
|
||||||
|
<span class="project-card-icon">{{ fd_icon("folder",14) }}</span>
|
||||||
|
<span class="forge-badge gitea">Gitea</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-card-name" x-text="p.name"></div>
|
||||||
|
<div class="project-card-desc" x-text="p.description || 'No description'"></div>
|
||||||
|
<div class="project-card-meta">
|
||||||
|
<span x-text="p.full_name"></span>
|
||||||
|
<span x-show="p.language" x-text="'· ' + p.language"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GitHub Projects -->
|
||||||
|
<div class="forge-section" x-show="githubProjects.length">
|
||||||
|
<h2><span class="forge-badge github">{{ fd_icon("link",14) }} GitHub</span> Repositories</h2>
|
||||||
|
<div class="project-grid">
|
||||||
|
<template x-for="p in githubProjects" :key="p.id">
|
||||||
|
<div class="project-card" @click="openProject(p)">
|
||||||
|
<div class="project-card-top">
|
||||||
|
<span class="project-card-icon">{{ fd_icon("folder",14) }}</span>
|
||||||
|
<span class="forge-badge github">GitHub</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-card-name" x-text="p.name"></div>
|
||||||
|
<div class="project-card-desc" x-text="p.description || 'No description'"></div>
|
||||||
|
<div class="project-card-meta">
|
||||||
|
<span x-text="p.full_name"></span>
|
||||||
|
<span x-show="p.language" x-text="'· ' + p.language"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div class="empty-state" x-show="!builtinProjects.length && !giteaProjects.length && !githubProjects.length">
|
||||||
|
<p>No projects yet. Create a new project or connect a forge.</p>
|
||||||
|
<a href="/auth/login?provider=local" class="btn">Get Started</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Modal -->
|
||||||
|
<div class="modal-overlay" x-show="showCreateModal" @click.outside="showCreateModal=false" @keydown.escape="showCreateModal=false">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3>Create New Project</h3>
|
||||||
|
<input class="modal-input" x-model="newProjectName" placeholder="Project name" @keydown.enter="createProject">
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="settings-btn settings-btn-secondary" @click="showCreateModal=false">Cancel</button>
|
||||||
|
<button class="settings-btn settings-btn-primary" @click="createProject">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function workspacePage() {
|
||||||
|
return {
|
||||||
|
builtinProjects: [],
|
||||||
|
giteaProjects: [],
|
||||||
|
githubProjects: [],
|
||||||
|
showCreateModal: false,
|
||||||
|
newProjectName: '',
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/workspace/projects');
|
||||||
|
const data = await r.json();
|
||||||
|
this.builtinProjects = data.builtin || [];
|
||||||
|
this.giteaProjects = data.gitea || [];
|
||||||
|
this.githubProjects = data.github || [];
|
||||||
|
} catch(e) { console.error('Failed to load projects', e); }
|
||||||
|
},
|
||||||
|
|
||||||
|
openProject(p) {
|
||||||
|
if (p.forge === 'gitea') {
|
||||||
|
const parts = p.full_name.split('/');
|
||||||
|
window.location = `/board/${parts[0]}/${parts[1]}`;
|
||||||
|
} else if (p.forge === 'github') {
|
||||||
|
window.open(p.html_url, '_blank');
|
||||||
|
} else {
|
||||||
|
window.location = `/pages/${p.id}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createProject() {
|
||||||
|
if (!this.newProjectName.trim()) return;
|
||||||
|
const r = await fetch('/api/workspace/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({name: this.newProjectName.trim()})
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
const p = await r.json();
|
||||||
|
this.builtinProjects.unshift({...p, icon: 'folder', pageCount: 0});
|
||||||
|
this.newProjectName = '';
|
||||||
|
this.showCreateModal = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block page_title %}Workspaces{% endblock %}
|
||||||
|
{% block title_prefix %}Workspaces{% endblock %}
|
||||||
|
{% block page_icon %}{{ fd_icon("home",18) }}{% endblock %}
|
||||||
|
|
||||||
|
{% block topbar %}
|
||||||
|
{% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %}
|
||||||
|
{% set page_icon = "home" %}
|
||||||
|
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
||||||
|
{% include '_header.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.ws-page{max-width:1000px;margin:0 auto;padding:40px 24px;}
|
||||||
|
.ws-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;}
|
||||||
|
.ws-header h1{font-size:24px;}
|
||||||
|
.ws-section{margin-bottom:40px;}
|
||||||
|
.ws-section-title{font-size:14px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px;display:flex;align-items:center;gap:8px;}
|
||||||
|
.ws-section-title .badge{font-size:11px;background:var(--bg-tertiary);color:var(--text-dim);padding:1px 8px;border-radius:10px;}
|
||||||
|
|
||||||
|
.ws-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px;}
|
||||||
|
.ws-card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:20px;cursor:pointer;transition:background 150ms, border-color 150ms;}
|
||||||
|
.ws-card:hover{background:var(--bg-tertiary);border-color:var(--border-strong);}
|
||||||
|
.ws-card.active{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
|
||||||
|
.ws-card-name{font-size:15px;font-weight:500;margin-bottom:2px;}
|
||||||
|
.ws-card-sub{font-size:12px;color:var(--text-dim);margin-bottom:4px;}
|
||||||
|
.ws-card-count{font-size:12px;color:var(--text-dim);}
|
||||||
|
.ws-card-actions{display:flex;gap:4px;margin-top:8px;opacity:0;transition:opacity 150ms;}
|
||||||
|
.ws-card:hover .ws-card-actions{opacity:1;}
|
||||||
|
|
||||||
|
/* Gitea org grouping */
|
||||||
|
.gitea-org{margin-bottom:16px;}
|
||||||
|
.gitea-org-header{font-size:14px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:8px;margin-bottom:8px;}
|
||||||
|
.gitea-org-header img{border-radius:4px;}
|
||||||
|
.gitea-org-avatar{width:24px;height:24px;border-radius:4px;background:var(--bg-tertiary);display:flex;align-items:center;justify-content:center;font-size:14px;}
|
||||||
|
.gitea-empty{padding:24px;text-align:center;color:var(--text-dim);font-size:14px;border:1px dashed var(--border);border-radius:var(--radius);}
|
||||||
|
.gitea-connect{display:inline-block;margin-top:8px;color:var(--accent);cursor:pointer;font-size:13px;}
|
||||||
|
.gitea-connect:hover{text-decoration:underline;}
|
||||||
|
.gitea-loading{color:var(--text-dim);font-size:13px;padding:12px 0;}
|
||||||
|
|
||||||
|
/* Org tabs */
|
||||||
|
.org-tab{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:6px;font-size:13px;color:var(--text-dim);background:transparent;border:1px solid var(--border);cursor:pointer;white-space:nowrap;transition:all 100ms;}
|
||||||
|
.org-tab:hover{background:var(--bg-tertiary);color:var(--text);}
|
||||||
|
.org-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
|
||||||
|
.org-tab-badge{font-size:10px;padding:0 5px;border-radius:8px;background:rgba(255,255,255,.15);}
|
||||||
|
[data-theme="light"] .org-tab-badge{background:rgba(0,0,0,.08);}
|
||||||
|
.search-input:focus{border-color:var(--accent);}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="ws-page" x-data="workspacesPage()">
|
||||||
|
|
||||||
|
<div class="ws-header">
|
||||||
|
<h1>{{ fd_icon("home",20) }} Workspaces</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Local Workspaces ── -->
|
||||||
|
<div class="ws-section">
|
||||||
|
<div class="ws-section-title">{{ fd_icon("folder",16) }} My Workspaces <span class="badge" x-text="workspaces.length"></span></div>
|
||||||
|
<div class="ws-grid">
|
||||||
|
<template x-for="ws in workspaces" :key="'local-'+ws.id">
|
||||||
|
<div class="ws-card" :class="{active:ws.id===activeId}" @click="selectLocal(ws)">
|
||||||
|
<div class="ws-card-name" x-text="ws.name"></div>
|
||||||
|
<div class="ws-card-count" x-text="ws.page_count+' page(s)'"></div>
|
||||||
|
<div class="ws-card-actions" @click.stop>
|
||||||
|
<button class="btn-sm" @click="renameWs(ws)">{{ fd_icon("edit",14) }}</button>
|
||||||
|
<button class="btn-sm" style="color:var(--danger);border-color:rgba(255,80,80,.3)" @click="deleteWs(ws)">{{ fd_icon("trash",14) }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="ws-card btn-new" @click="showCreate=true" style="display:flex;align-items:center;justify-content:center;height:auto;min-height:140px;">
|
||||||
|
<span style="font-size:48px;line-height:1;">+</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Gitea Projects ── -->
|
||||||
|
<div class="ws-section">
|
||||||
|
<div class="ws-section-title">{{ fd_icon("link",16) }} Gitea Projects <span class="badge" x-text="giteaTotal"></span></div>
|
||||||
|
|
||||||
|
<!-- Not linked -->
|
||||||
|
<div class="gitea-empty" x-show="giteaStatus==='not_linked'">
|
||||||
|
<div>Connect your Gitea account to see your projects.</div>
|
||||||
|
<a class="gitea-connect" href="/auth/login?provider=gitea&mode=link">{{ fd_icon("link",14) }} Connect Gitea</a>
|
||||||
|
<span style="margin:0 8px;color:var(--text-dim);">or</span>
|
||||||
|
<a class="gitea-connect" href="/auth/login?provider=gitea">{{ fd_icon("edit",14) }} Register with Gitea</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading skeletons -->
|
||||||
|
<div x-show="giteaStatus==='loading'" class="ws-grid">
|
||||||
|
<template x-for="i in 6" :key="i">
|
||||||
|
<div class="ws-card" style="pointer-events:none;">
|
||||||
|
<div class="skeleton" style="height:16px;width:65%;margin-bottom:8px;"></div>
|
||||||
|
<div class="skeleton" style="height:12px;width:45%;margin-bottom:6px;"></div>
|
||||||
|
<div class="skeleton" style="height:12px;width:80%;"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div class="gitea-empty" x-show="giteaStatus==='error'">
|
||||||
|
<div>{{ fd_icon("alert-triangle",14) }} Could not load Gitea projects.</div>
|
||||||
|
<button class="gitea-connect" @click="loadGitea()">Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projects (tabs by org + search) -->
|
||||||
|
<div x-show="giteaStatus==='ok' && giteaGroups.length > 0">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap;">
|
||||||
|
<!-- Org tabs -->
|
||||||
|
<div style="display:flex;gap:4px;overflow-x:auto;flex:1;min-width:0;">
|
||||||
|
<button class="org-tab" :class="{active:activeOrg==='all'}" @click="activeOrg='all'">
|
||||||
|
All <span class="org-tab-badge" x-text="giteaTotal"></span>
|
||||||
|
</button>
|
||||||
|
<template x-for="g in giteaGroups" :key="g.org">
|
||||||
|
<button class="org-tab" :class="{active:activeOrg===g.org}" @click="activeOrg=g.org">
|
||||||
|
<span x-text="g.org"></span>
|
||||||
|
<span class="org-tab-badge" x-text="g.projects.length"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<!-- Search -->
|
||||||
|
<input type="text" class="search-input" x-model="giteaSearch" placeholder="Filter projects…" style="width:200px;padding:6px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project cards (filtered) -->
|
||||||
|
<template x-for="group in filteredGroups" :key="group.org">
|
||||||
|
<div class="gitea-org">
|
||||||
|
<div class="gitea-org-header">
|
||||||
|
<span class="gitea-org-avatar" x-text="group.org.charAt(0).toUpperCase()"></span>
|
||||||
|
<span x-text="group.org"></span>
|
||||||
|
</div>
|
||||||
|
<div class="ws-grid">
|
||||||
|
<template x-for="proj in group.projects" :key="proj.id">
|
||||||
|
<div class="ws-card" @click="openGitea(proj)">
|
||||||
|
<div class="ws-card-name" x-text="proj.name"></div>
|
||||||
|
<div class="ws-card-sub">
|
||||||
|
<span x-text="group.org"></span>
|
||||||
|
<span style="margin:0 4px">/</span>
|
||||||
|
<span x-text="proj.name"></span>
|
||||||
|
</div>
|
||||||
|
<div class="ws-card-count">
|
||||||
|
<span x-show="proj.private" style="margin-right:6px">{{ fd_icon("lock",14) }}</span>
|
||||||
|
<span x-text="proj.description || proj.language || ''" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px;"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="gitea-empty" x-show="filteredGroups.length===0" style="margin-top:12px;">
|
||||||
|
No projects match your search.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No projects at all -->
|
||||||
|
<div class="gitea-empty" x-show="giteaStatus==='ok' && giteaTotal==0 && giteaGroups.length===0">
|
||||||
|
No Gitea projects found.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Rename dialog -->
|
||||||
|
<div class="dialog-overlay" x-show="showCreate||showRename" @click.outside="showCreate=false;showRename=false">
|
||||||
|
<div class="dialog-box">
|
||||||
|
<h3 x-text="showRename?'Rename Workspace':'New Workspace'"></h3>
|
||||||
|
<input class="dialog-input" x-model="wsName" placeholder="Workspace name" @keydown.enter="showRename?doRename():doCreate()">
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="btn btn-secondary" @click="showCreate=false;showRename=false">Cancel</button>
|
||||||
|
<button class="btn btn-primary" @click="showRename?doRename():doCreate()" x-text="showRename?'Rename':'Create'"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function workspacesPage() {
|
||||||
|
return {
|
||||||
|
workspaces: [],
|
||||||
|
activeId: null,
|
||||||
|
showCreate: false,
|
||||||
|
showRename: false,
|
||||||
|
wsName: '',
|
||||||
|
renameTarget: null,
|
||||||
|
|
||||||
|
// Gitea
|
||||||
|
giteaStatus: 'loading', // loading|ok|not_linked|error
|
||||||
|
giteaGroups: [],
|
||||||
|
giteaTotal: 0,
|
||||||
|
activeOrg: 'all',
|
||||||
|
giteaSearch: '',
|
||||||
|
|
||||||
|
get filteredGroups() {
|
||||||
|
var self = this;
|
||||||
|
var q = (this.giteaSearch || '').toLowerCase();
|
||||||
|
var groups = this.activeOrg === 'all' ? this.giteaGroups : this.giteaGroups.filter(function(g) { return g.org === self.activeOrg; });
|
||||||
|
if (!q) return groups;
|
||||||
|
return groups.map(function(g) {
|
||||||
|
return {org: g.org, avatar: g.avatar, projects: g.projects.filter(function(p) {
|
||||||
|
return p.name.toLowerCase().includes(q) || (p.description||'').toLowerCase().includes(q);
|
||||||
|
})};
|
||||||
|
}).filter(function(g) { return g.projects.length > 0; });
|
||||||
|
},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.load();
|
||||||
|
await this.loadGitea();
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
const r = await fetch('/api/workspaces');
|
||||||
|
const d = await r.json();
|
||||||
|
this.workspaces = d.workspaces || [];
|
||||||
|
this.activeId = d.active_id;
|
||||||
|
},
|
||||||
|
|
||||||
|
async selectLocal(ws) {
|
||||||
|
await fetch(`/api/workspaces/${ws.id}/select`, {method:'POST'});
|
||||||
|
window.location = '/local-workspace';
|
||||||
|
},
|
||||||
|
|
||||||
|
async doCreate() {
|
||||||
|
if (!this.wsName.trim()) return;
|
||||||
|
await fetch('/api/workspaces', {
|
||||||
|
method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({name:this.wsName.trim()})
|
||||||
|
});
|
||||||
|
this.wsName = '';
|
||||||
|
this.showCreate = false;
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
renameWs(ws) {
|
||||||
|
this.renameTarget = ws;
|
||||||
|
this.wsName = ws.name;
|
||||||
|
this.showRename = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async doRename() {
|
||||||
|
if (!this.wsName.trim()||!this.renameTarget) return;
|
||||||
|
await fetch(`/api/workspaces/${this.renameTarget.id}`, {
|
||||||
|
method:'PUT',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({name:this.wsName.trim()})
|
||||||
|
});
|
||||||
|
this.wsName = '';
|
||||||
|
this.showRename = false;
|
||||||
|
this.renameTarget = null;
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteWs(ws) {
|
||||||
|
if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return;
|
||||||
|
await fetch(`/api/workspaces/${ws.id}`, {method:'DELETE'});
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Gitea ──
|
||||||
|
|
||||||
|
async loadGitea() {
|
||||||
|
this.giteaStatus = 'loading';
|
||||||
|
try {
|
||||||
|
// Get orgs
|
||||||
|
const orgsRes = await fetch('/api/gitea/orgs');
|
||||||
|
if (orgsRes.status === 401) { this.giteaStatus = 'not_linked'; return; }
|
||||||
|
if (!orgsRes.ok) { this.giteaStatus = 'error'; return; }
|
||||||
|
const orgsData = await orgsRes.json();
|
||||||
|
const orgs = orgsData.orgs || [];
|
||||||
|
|
||||||
|
// Fetch repos: user repos + org repos
|
||||||
|
const groups = [];
|
||||||
|
|
||||||
|
// Get username for the "personal" tab
|
||||||
|
let myLogin = 'Me';
|
||||||
|
try {
|
||||||
|
const meRes = await fetch('/auth/user');
|
||||||
|
if (meRes.ok) {
|
||||||
|
const meData = await meRes.json();
|
||||||
|
myLogin = meData.user?.login || 'Me';
|
||||||
|
if (myLogin.includes('_')) myLogin = myLogin.split('_').pop(); // strip gitea_ prefix if present
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
// User repos (no org filter)
|
||||||
|
try {
|
||||||
|
const userRes = await fetch('/api/gitea/projects');
|
||||||
|
if (userRes.ok) {
|
||||||
|
const d = await userRes.json();
|
||||||
|
const repos = d.projects || [];
|
||||||
|
if (repos.length) {
|
||||||
|
groups.push({org: myLogin, avatar: '', projects: repos.map(r => ({...r, owner: r.owner || {login: myLogin}}))});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
// Org repos
|
||||||
|
for (const org of orgs) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/gitea/projects?org=' + encodeURIComponent(org.username || org.login || org.name));
|
||||||
|
if (res.ok) {
|
||||||
|
const d = await res.json();
|
||||||
|
if (d.projects && d.projects.length) {
|
||||||
|
groups.push({org: org.username || org.login || org.name, avatar: org.avatar_url || '', projects: d.projects});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.giteaGroups = groups;
|
||||||
|
this.giteaTotal = groups.reduce((sum, g) => sum + g.projects.length, 0);
|
||||||
|
this.giteaStatus = 'ok';
|
||||||
|
} catch(e) {
|
||||||
|
this.giteaStatus = 'error';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openGitea(proj) {
|
||||||
|
const owner = proj.owner?.login || proj.owner?.username || proj.full_name?.split('/')[0] || '';
|
||||||
|
const repo = proj.name;
|
||||||
|
if (owner && repo) {
|
||||||
|
// Set workspace cookie so sidebar shows tree
|
||||||
|
document.cookie = 'flowdeck_workspace=gitea:' + owner + ':' + repo + ';path=/;SameSite=Lax';
|
||||||
|
window.location = `/gitea-workspace?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,702 @@
|
|||||||
|
# FlowDeck Agent — Intégration & Fonctionnement
|
||||||
|
|
||||||
|
> **Version:** 1.0 · **Date:** 2026-07-20 · **Auteur:** Hermes-Deepin
|
||||||
|
> **Cible:** Agent IA natif pour FlowDeck, inspiré de Notion Agent (2026)
|
||||||
|
> **Prérequis:** FlowDeck v4.0.x · FastAPI · SQLite · Gitea/GitHub
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table des matières
|
||||||
|
|
||||||
|
1. [Vision & Objectifs](#1-vision--objectifs)
|
||||||
|
2. [Concept : de l'IA qui répond à l'IA qui réalise](#2-concept)
|
||||||
|
3. [Positionnement dans l'architecture FlowDeck](#3-positionnement)
|
||||||
|
4. [Placement dans l'interface (sidebar 🤖 Agents)](#4-placement-interface)
|
||||||
|
5. [Boucle d'exécution de l'agent](#5-boucle-dexécution)
|
||||||
|
6. [Sources de contexte FlowDeck](#6-sources-de-contexte)
|
||||||
|
7. [Modèle de données (nouvelles tables)](#7-modèle-de-données)
|
||||||
|
8. [Routes & API Agent](#8-routes--api)
|
||||||
|
9. [Le service AgentEngine](#9-service-agentengine)
|
||||||
|
10. [Système d'outils (Tools)](#10-système-doutils)
|
||||||
|
11. [Permissions & sécurité](#11-permissions--sécurité)
|
||||||
|
12. [Custom Agents & déclencheurs](#12-custom-agents)
|
||||||
|
13. [Skills réutilisables](#13-skills)
|
||||||
|
14. [Cas d'usage FlowDeck](#14-cas-dusage)
|
||||||
|
15. [Plan de migration](#15-plan-de-migration)
|
||||||
|
16. [Limitations](#16-limitations)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Vision & Objectifs
|
||||||
|
|
||||||
|
FlowDeck Agent transforme FlowDeck d'un clone Notion **passif** (où l'utilisateur crée manuellement collections, pages, propriétés) en un espace de travail **agentique** où une IA peut :
|
||||||
|
|
||||||
|
- Comprendre un objectif exprimé en langage naturel
|
||||||
|
- Lire le contexte du workspace (collections, pages, propriétés, issues Gitea)
|
||||||
|
- Planifier une suite d'actions
|
||||||
|
- **Exécuter** ces actions via l'API interne (créer collections, pages, propriétés, vues, sync Gitea)
|
||||||
|
- Retourner un résultat consolidé
|
||||||
|
|
||||||
|
L'agent réutilise l'infrastructure existante : **il n'appelle pas directement la DB**, il passe par les mêmes routers FastAPI (`/db/*`, `/board/api/*`) que l'utilisateur, garantissant cohérence, validation et respect du `PermissionManager`.
|
||||||
|
|
||||||
|
Cette fonctionnalité concrétise la ligne `v5.0.0 — AI Assistants` de la roadmap, en s'inspirant directement du modèle Notion Agent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Concept : de l'IA qui répond à l'IA qui réalise {#2-concept}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Assistant classique :
|
||||||
|
Utilisateur → Prompt → Réponse texte
|
||||||
|
FlowDeck Agent :
|
||||||
|
Utilisateur → Objectif → Planification → Collecte contexte → Actions API → Résultat
|
||||||
|
|
||||||
|
|
||||||
|
| Notion AI | FlowDeck Agent |
|
||||||
|
|-----------|----------------|
|
||||||
|
| Répond, résume, réécrit | Planifie, recherche, **agit** |
|
||||||
|
| Bloc de texte | Crée collections, pages, propriétés, vues |
|
||||||
|
| Sans état | Historique de conversation + snapshots |
|
||||||
|
| Aucune action DB | Modifie le workspace via routers FastAPI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Positionnement dans l'architecture FlowDeck {#3-positionnement}
|
||||||
|
|
||||||
|
L'agent s'insère comme un **nouveau service** et un **nouveau router**, sans modifier le cœur existant.
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CLIENT (Browser) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ agent_panel.html — Panneau conversation (Alpine.js) │ │
|
||||||
|
│ │ agent_message.html — Fragment message (HTMX streaming) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────┬───────────────────────────────────────┘
|
||||||
|
│ HTTP (SSE stream / JSON)
|
||||||
|
┌──────────────────────────▼───────────────────────────────────────┐
|
||||||
|
│ FASTAPI (Python 3.11+) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ROUTERS │ │
|
||||||
|
│ │ ├─ agent.py ← NOUVEAU /api/agent/* │ │
|
||||||
|
│ │ └─ (existants: board.py, api.py, collections.py, ...) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ SERVICES │ │
|
||||||
|
│ │ ├─ AgentEngine ← NOUVEAU (orchestrateur) │ │
|
||||||
|
│ │ ├─ ToolRegistry ← NOUVEAU (outils actionnables) │ │
|
||||||
|
│ │ ├─ LLMClient ← NOUVEAU (GPT/Claude/Gemini/local) │ │
|
||||||
|
│ │ ├─ ContextBuilder ← NOUVEAU (collecte workspace) │ │
|
||||||
|
│ │ ├─ SearchEngine (réutilisé — FTS5) │ │
|
||||||
|
│ │ ├─ PermissionManager (réutilisé — ACL) │ │
|
||||||
|
│ │ └─ GiteaClient (réutilisé — API Gitea) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ DATA LAYER — SQLite │ │
|
||||||
|
│ │ ├─ agents ← NOUVEAU │ │
|
||||||
|
│ │ ├─ agent_conversations ← NOUVEAU │ │
|
||||||
|
│ │ ├─ agent_messages ← NOUVEAU │ │
|
||||||
|
│ │ ├─ agent_actions ← NOUVEAU (journal audit) │ │
|
||||||
|
│ │ ├─ agent_skills ← NOUVEAU │ │
|
||||||
|
│ │ └─ (tables existantes: collections, pages, ...) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────┬───────────────────────────────────────┘
|
||||||
|
│ HTTPS
|
||||||
|
┌──────────────────┴──────────────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────┐ ┌──────────────────────────┐
|
||||||
|
│ GITEA API REST │ │ PROVIDER LLM │
|
||||||
|
│ (contexte issues)│ │ OpenAI / Anthropic / │
|
||||||
|
│ │ │ Gemini / Ollama local │
|
||||||
|
└──────────────────┘ └──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Principe clé — Tool-calling en boucle fermée :**
|
||||||
|
Le `LLMClient` ne touche jamais la base. Il émet des *intentions d'outil* (function calls). L'`AgentEngine` les traduit en appels HTTP internes vers les routers existants, exécutés avec la session et les permissions de l'utilisateur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Placement dans l'interface (sidebar 🤖 Agents) {#4-placement-interface}
|
||||||
|
|
||||||
|
Le sidebar FlowDeck contient déjà une entrée **🤖 Agents** (voir ARCHITECTURE §5.2). Elle devient le point d'entrée de l'agent.
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────┬─────────────────────────────────────────────────┐
|
||||||
|
│ SIDEBAR │ CONTENU PRINCIPAL │
|
||||||
|
│ │ │
|
||||||
|
│ 🕒 Recents │ ┌─ Page / Collection courante ──────────────┐ │
|
||||||
|
│ ⭐ Favoris │ │ │ │
|
||||||
|
│ 🤖 Agents ◄┼── clic ouvre le panneau agent (droite) │ │
|
||||||
|
│ 👥 Shared │ └────────────────────────────────────────────┘ │
|
||||||
|
│ │ ┌────────────┐ │
|
||||||
|
│ │ │ 🤖 Agent │ │
|
||||||
|
│ │ ├────────────┤ │
|
||||||
|
│ │ │ Historique │ │
|
||||||
|
│ │ │ Réponses │ │
|
||||||
|
│ │ │ ───────── │ │
|
||||||
|
│ │ │ @Sources │ │
|
||||||
|
│ │ │ 📎 Fichier │ │
|
||||||
|
│ │ │ [Modèle ▾] │ │
|
||||||
|
│ │ │ [Saisie... ]│ │
|
||||||
|
│ │ └────────────┘ │
|
||||||
|
└────────────┴─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Deux modes d'accès, comme Notion Agent :
|
||||||
|
|
||||||
|
- **Icône flottante** (coin inférieur droit) — accès global rapide
|
||||||
|
- **Section 🤖 Agents** du sidebar — liste des agents (personnel + custom)
|
||||||
|
|
||||||
|
L'affichage de la section 🤖 Agents est déjà prévu dans les 3 scénarios de compte (A/B/C) — voir ARCHITECTURE §6.5.4 : **toujours visible**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Boucle d'exécution de l'agent {#5-boucle-dexécution}
|
||||||
|
|
||||||
|
Adaptation du modèle Notion (Compréhension → Contexte → Raisonnement → Action) à l'architecture FlowDeck :
|
||||||
|
|
||||||
|
```
|
||||||
|
Utilisateur formule un objectif
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ 1. COMPRÉHENSION (AgentEngine) │
|
||||||
|
│ Parse objectif + instructions custom │
|
||||||
|
└──────────────────┬──────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ 2. COLLECTE CONTEXTE (ContextBuilder) │
|
||||||
|
│ - Page/collection courante (cookie ws) │
|
||||||
|
│ - @mentions explicites │
|
||||||
|
│ - SearchEngine FTS5 (pages pertinentes) │
|
||||||
|
│ - Issues Gitea liées (GiteaClient) │
|
||||||
|
│ - Fichiers joints │
|
||||||
|
│ ⚠ filtré par PermissionManager │
|
||||||
|
└──────────────────┬──────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ 3. RAISONNEMENT (LLMClient + ToolRegistry) │
|
||||||
|
│ LLM reçoit: objectif + contexte + schéma │
|
||||||
|
│ des outils disponibles (function calling) │
|
||||||
|
│ → émet un plan / une intention d'outil │
|
||||||
|
└──────────────────┬──────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ 4. ACTION (AgentEngine → routers FastAPI) │
|
||||||
|
│ Exécute l'appel outil → route interne │
|
||||||
|
│ POST /db → create_collection │
|
||||||
|
│ POST /db/{id}/properties → add_property │
|
||||||
|
│ POST /db/{id}/pages → create_page │
|
||||||
|
│ Journalise dans agent_actions │
|
||||||
|
└──────────────────┬──────────────────────────┘
|
||||||
|
▼
|
||||||
|
Résultat outil réinjecté dans le LLM
|
||||||
|
│
|
||||||
|
┌────────┴────────┐
|
||||||
|
│ Objectif atteint?│
|
||||||
|
└────┬────────┬────┘
|
||||||
|
non │ │ oui
|
||||||
|
│ ▼
|
||||||
|
(retour étape 3) ┌─────────────────────────────┐
|
||||||
|
│ 5. RÉSULTAT consolidé + lien │
|
||||||
|
│ vers les objets créés │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette boucle **ReAct** (Reason + Act) se répète jusqu'à `max_iterations` (défaut : 12) ou atteinte de l'objectif.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Sources de contexte FlowDeck {#6-sources-de-contexte}
|
||||||
|
|
||||||
|
Correspondance avec les sources Notion Agent, adaptées aux entités FlowDeck :
|
||||||
|
|
||||||
|
| Source Notion | Équivalent FlowDeck | Mécanisme |
|
||||||
|
|---------------|---------------------|-----------|
|
||||||
|
| Pages, sous-pages | `pages`, `collection_pages` (parent_id) | `SearchEngine` FTS5 + lecture directe |
|
||||||
|
| Bases de données | `collections` + `collection_properties` | Lecture schéma `schema_json` |
|
||||||
|
| Wikis / docs internes | Blocs éditeur (`content_json`) | Parsing blocs |
|
||||||
|
| Jira | **Issues Gitea/GitHub** | `GiteaClient` (issues, labels, milestones) |
|
||||||
|
| Slack / Drive | Fichiers uploadés (`/api/files/`) | Lecture `files` property |
|
||||||
|
| Fichiers joints | Upload local-workspace | PDF/MD parsing |
|
||||||
|
| `@Projet Alpha` | `@collection` / `@page` / `@repo` | Résolution de mention |
|
||||||
|
|
||||||
|
**Résolution des mentions** — L'utilisateur peut cibler :
|
||||||
|
|
||||||
|
|
||||||
|
@collection:Roadmap → force le contexte sur une collection
|
||||||
|
@page:123 → une page précise
|
||||||
|
@repo:bruno/flowdeck → issues d'un repo Gitea
|
||||||
|
@ws → workspace courant complet
|
||||||
|
|
||||||
|
|
||||||
|
Le `ContextBuilder` respecte strictement le `PermissionManager` : l'agent ne voit **que** ce que l'utilisateur peut voir (même token, mêmes ACL — voir ARCHITECTURE §6.2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Modèle de données (nouvelles tables) {#7-modèle-de-données}
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Définition d'un agent (personnel ou custom)
|
||||||
|
CREATE TABLE agents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
workspace_id INTEGER REFERENCES workspaces(id),
|
||||||
|
name TEXT NOT NULL DEFAULT 'FlowDeck Agent',
|
||||||
|
icon TEXT DEFAULT '🤖',
|
||||||
|
agent_type TEXT NOT NULL DEFAULT 'personal',
|
||||||
|
-- 'personal' | 'custom'
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
|
||||||
|
-- Instructions personnalisées (ton, format, règles métier)
|
||||||
|
system_instructions TEXT DEFAULT '',
|
||||||
|
|
||||||
|
-- Modèle IA par défaut
|
||||||
|
model TEXT DEFAULT 'claude-opus-4-8',
|
||||||
|
-- gpt-* | claude-* | gemini-* | ollama:*
|
||||||
|
|
||||||
|
-- Périmètre custom agent : collections/repos autorisés
|
||||||
|
scope_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
-- {collections:[3,7], repos:["bruno/flowdeck"], tools:["create_page"]}
|
||||||
|
|
||||||
|
-- Déclencheurs (custom agents)
|
||||||
|
trigger_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
-- {type:"schedule", cron:"0 9 * * 1"} | {type:"webhook", event:"issue.opened"}
|
||||||
|
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INTEGER REFERENCES users(id),
|
||||||
|
UNIQUE(workspace_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Conversations (fils de discussion, comme Notion history)
|
||||||
|
CREATE TABLE agent_conversations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
title TEXT DEFAULT 'New conversation',
|
||||||
|
-- Contexte de départ (page/collection ouverte à l'invocation)
|
||||||
|
context_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Messages d'une conversation
|
||||||
|
CREATE TABLE agent_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
-- 'user' | 'assistant' | 'tool' | 'system'
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
-- Pour role='tool' : nom de l'outil + payload + résultat
|
||||||
|
tool_calls_json TEXT DEFAULT '[]',
|
||||||
|
model TEXT,
|
||||||
|
tokens_used INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Journal d'audit : CHAQUE action modifiant le workspace
|
||||||
|
CREATE TABLE agent_actions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
|
||||||
|
tool_name TEXT NOT NULL,
|
||||||
|
-- create_collection | create_page | add_property | ...
|
||||||
|
target_type TEXT,
|
||||||
|
-- collection | page | property | view | issue
|
||||||
|
target_id TEXT,
|
||||||
|
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
result_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
status TEXT NOT NULL DEFAULT 'success',
|
||||||
|
-- success | error | reverted
|
||||||
|
-- Snapshot avant modification → permet le rollback
|
||||||
|
undo_snapshot_json TEXT DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
executed_by INTEGER REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Skills réutilisables (mini-prompts)
|
||||||
|
CREATE TABLE agent_skills (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
workspace_id INTEGER REFERENCES workspaces(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
-- "Génération de PRD", "Analyse SWOT", "Préparation sprint"
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
prompt_template TEXT NOT NULL,
|
||||||
|
-- Outils que la skill peut invoquer
|
||||||
|
allowed_tools_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INTEGER REFERENCES users(id),
|
||||||
|
UNIQUE(workspace_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_agent_conv_user ON agent_conversations(user_id, updated_at);
|
||||||
|
CREATE INDEX idx_agent_msg_conv ON agent_messages(conversation_id, created_at);
|
||||||
|
CREATE INDEX idx_agent_action_conv ON agent_actions(conversation_id);
|
||||||
|
|
||||||
|
|
||||||
|
Intégration au diagramme de relations existant
|
||||||
|
|
||||||
|
|
||||||
|
users ──1:N──► agent_conversations ──1:N──► agent_messages
|
||||||
|
│ │
|
||||||
|
│ └──1:N──► agent_actions ──► (collections/pages/...)
|
||||||
|
│
|
||||||
|
workspaces ──1:N──► agents ──1:N──► agent_conversations
|
||||||
|
│
|
||||||
|
└──1:N──► agent_skills
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 8. Routes & API Agent {#8-routes--api}
|
||||||
|
|
||||||
|
Nouveau router agent.py, préfixe /api/agent.
|
||||||
|
|
||||||
|
# Agents (définitions)
|
||||||
|
```
|
||||||
|
GET /api/agent → Liste agents du workspace
|
||||||
|
POST /api/agent → Créer custom agent
|
||||||
|
GET /api/agent/{agent_id} → Détail agent
|
||||||
|
PUT /api/agent/{agent_id} → Update (instructions, scope, model)
|
||||||
|
DELETE /api/agent/{agent_id} → Supprimer custom agent
|
||||||
|
```
|
||||||
|
|
||||||
|
# Conversations
|
||||||
|
```
|
||||||
|
GET /api/agent/conversations → Historique des conversations
|
||||||
|
POST /api/agent/conversations → Nouvelle conversation
|
||||||
|
GET /api/agent/conversations/{id} → Messages d'une conversation
|
||||||
|
DELETE /api/agent/conversations/{id} → Supprimer conversation
|
||||||
|
```
|
||||||
|
|
||||||
|
# Exécution (cœur)
|
||||||
|
```
|
||||||
|
POST /api/agent/conversations/{id}/run → Envoyer un objectif (SSE stream)
|
||||||
|
body: {message, model?, mentions?, files?, skill_id?}
|
||||||
|
→ stream: reasoning → tool_call → tool_result → ... → final
|
||||||
|
```
|
||||||
|
|
||||||
|
# Actions & audit
|
||||||
|
```
|
||||||
|
GET /api/agent/conversations/{id}/actions → Journal des actions
|
||||||
|
POST /api/agent/actions/{action_id}/undo → Annuler une action (rollback)
|
||||||
|
```
|
||||||
|
|
||||||
|
# Skills
|
||||||
|
```
|
||||||
|
GET /api/agent/skills → Liste skills
|
||||||
|
POST /api/agent/skills → Créer skill
|
||||||
|
POST /api/agent/skills/{id}/apply → Appliquer une skill
|
||||||
|
```
|
||||||
|
|
||||||
|
# Custom agents — déclencheurs
|
||||||
|
```
|
||||||
|
POST /api/agent/{agent_id}/trigger → Déclenchement manuel
|
||||||
|
```
|
||||||
|
|
||||||
|
# (déclenchement auto via scheduler interne + webhooks.py)
|
||||||
|
|
||||||
|
|
||||||
|
Streaming — La route /run renvoie un flux Server-Sent Events (SSE) pour afficher en temps réel le raisonnement et chaque action, à la manière de l'interface conversationnelle Notion. Le frontend (Alpine.js) consomme le flux et met à jour agent_panel.html.
|
||||||
|
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
## 9. Le service AgentEngine {#9-service-agentengine}
|
||||||
|
|
||||||
|
Orchestrateur central. Pseudo-implémentation :
|
||||||
|
|
||||||
|
```
|
||||||
|
class AgentEngine:
|
||||||
|
"""Orchestrateur ReAct pour FlowDeck Agent."""
|
||||||
|
|
||||||
|
def __init__(self, db, user, request):
|
||||||
|
self.db = db
|
||||||
|
self.user = user
|
||||||
|
self.request = request # pour réutiliser session/cookies
|
||||||
|
self.llm = LLMClient()
|
||||||
|
self.tools = ToolRegistry(db, user, request)
|
||||||
|
self.ctx = ContextBuilder(db, user)
|
||||||
|
self.perms = PermissionManager(db, user)
|
||||||
|
|
||||||
|
async def run(self, conversation_id, objective, *, model=None,
|
||||||
|
mentions=None, files=None, skill_id=None):
|
||||||
|
agent = self._load_agent(conversation_id)
|
||||||
|
model = model or agent.model
|
||||||
|
|
||||||
|
# 1. COMPRÉHENSION + instructions custom + skill
|
||||||
|
system = self._build_system_prompt(agent, skill_id)
|
||||||
|
|
||||||
|
# 2. COLLECTE CONTEXTE (respecte permissions)
|
||||||
|
context = await self.ctx.build(
|
||||||
|
workspace=self._active_workspace(),
|
||||||
|
mentions=mentions,
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": f"{objective}\n\n# Contexte\n{context}"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# 3-4. BOUCLE RAISONNEMENT ↔ ACTION
|
||||||
|
for _ in range(MAX_ITERATIONS): # défaut 12
|
||||||
|
response = await self.llm.complete(
|
||||||
|
model=model,
|
||||||
|
messages=messages,
|
||||||
|
tools=self.tools.schema(scope=agent.scope), # function calling
|
||||||
|
stream=True,
|
||||||
|
)
|
||||||
|
yield {"type": "reasoning", "content": response.text}
|
||||||
|
|
||||||
|
if not response.tool_calls:
|
||||||
|
yield {"type": "final", "content": response.text}
|
||||||
|
break
|
||||||
|
|
||||||
|
for call in response.tool_calls:
|
||||||
|
# Vérif permission AVANT exécution
|
||||||
|
self.perms.assert_can(call.tool, call.args)
|
||||||
|
|
||||||
|
result = await self.tools.execute(call.tool, call.args)
|
||||||
|
|
||||||
|
# Journal d'audit + snapshot rollback
|
||||||
|
self._log_action(conversation_id, call, result)
|
||||||
|
|
||||||
|
yield {"type": "action",
|
||||||
|
"tool": call.tool,
|
||||||
|
"target": result.get("target"),
|
||||||
|
"status": result["status"]}
|
||||||
|
|
||||||
|
messages.append({"role": "tool",
|
||||||
|
"name": call.tool,
|
||||||
|
"content": result})
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Points clés :
|
||||||
|
- stream=True → SSE vers le frontend
|
||||||
|
- Chaque tool_call passe par PermissionManager.assert_can() avant exécution
|
||||||
|
- Chaque action modifiant la DB écrit un agent_actions avec undo_snapshot_json → rollback possible
|
||||||
|
- La boucle réutilise le même token Gitea que l'utilisateur (via user_tokens)
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
10. Système d'outils (Tools) {#10-système-doutils}
|
||||||
|
|
||||||
|
Les outils sont des wrappers autour des routers FastAPI existants — l'agent ne réinvente rien.
|
||||||
|
|
||||||
|
| Outil | Route interne appelée | Ce que ça fait |
|
||||||
|
|-------|----------------------|----------------|
|
||||||
|
| `search_workspace` | `SearchEngine` (FTS5) | Recherche full-text pages/collections |
|
||||||
|
| `read_collection` | `GET /db/{id}` | Lit schéma + pages d'une collection |
|
||||||
|
| `read_page` | `GET /db/{c}/pages/{p}` | Lit une page + propriétés + blocs |
|
||||||
|
| `create_collection` | `POST /db` | Crée une database |
|
||||||
|
| `add_property` | `POST /db/{id}/properties` | Ajoute une propriété typée |
|
||||||
|
| `create_view` | `POST /db/{id}/views` | Crée une vue (table/board/calendar…) |
|
||||||
|
| `create_page` | `POST /db/{id}/pages` | Crée une page + valeurs propriétés |
|
||||||
|
| `update_page` | `PUT /db/{c}/pages/{p}` | Modifie propriétés/contenu |
|
||||||
|
| `write_blocks` | `POST /board/api/pages/{id}/blocks` | Écrit du contenu (éditeur de blocs) |
|
||||||
|
| `add_relation` | `POST /db/{id}/properties` (type relation) | Lie deux collections |
|
||||||
|
| `create_sub_item` | `POST /db/{c}/pages/{p}/sub-items` | Crée un sub-item |
|
||||||
|
| `add_dependency` | `POST /db/{c}/pages/{p}/dependencies` | Ajoute blocks/blocked_by |
|
||||||
|
| `sync_gitea` | `POST /board/api/sync/{o}/{r}` | Synchronise issues ↔ collection |
|
||||||
|
| `read_gitea_issues` | `GiteaClient` | Lit issues/labels/milestones |
|
||||||
|
| `create_gitea_issue` | `POST /api/issues/{o}/{r}` | Crée une issue Gitea |
|
||||||
|
| `apply_template` | `POST /db/{id}/templates/{t}/apply` | Applique un template |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Schéma d'un outil (function calling) exposé au LLM :
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"name": "create_page",
|
||||||
|
"description": "Crée une page dans une collection FlowDeck avec ses valeurs de propriétés.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"collection_id": {"type": "integer"},
|
||||||
|
"title": {"type": "string"},
|
||||||
|
"property_values": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Map property_id → valeur (respecte le type)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["collection_id", "title"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Filtrage par scope — Pour un custom agent, ToolRegistry.schema(scope=...) ne renvoie que les outils autorisés dans agents.scope_json.tools, et limite les cibles aux collections/repos de scope_json.
|
||||||
|
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
## 11. Permissions & sécurité {#11-permissions--sécurité}
|
||||||
|
|
||||||
|
L'agent agit avec les permissions de l'utilisateur, jamais au-delà (principe Notion Agent : « mêmes permissions que l'utilisateur »).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────────────────────┐
|
||||||
|
│ GARDE-FOUS AGENT │
|
||||||
|
│ │
|
||||||
|
│ 1. Identité → session utilisateur + user_tokens │
|
||||||
|
│ 2. ACL → PermissionManager.assert_can() par outil│
|
||||||
|
│ 3. Scope custom → scope_json limite collections/repos │
|
||||||
|
│ 4. Database lock → collection is_locked ⇒ outils write KO │
|
||||||
|
│ 5. Audit → agent_actions journalise TOUT │
|
||||||
|
│ 6. Rollback → undo_snapshot_json par action │
|
||||||
|
│ 7. Rate limiting → RateLimiter (middleware existant) │
|
||||||
|
│ 8. Confirmation → actions destructives ⇒ approbation UI │
|
||||||
|
└───────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Mode approbation — Configurable par agent :
|
||||||
|
- auto : l'agent exécute directement (création de contenu)
|
||||||
|
- confirm : actions destructives (delete_page, sync massif) demandent une confirmation dans le panneau avant exécution
|
||||||
|
Cohérent avec le modèle de rôles workspace (owner/admin/editor/commenter/viewer, ARCHITECTURE §6.2) : un agent lancé par un viewer ne peut que lire.
|
||||||
|
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
## 12. Custom Agents & déclencheurs {#12-custom-agents}
|
||||||
|
|
||||||
|
Comme Notion (Custom Agents 2026), FlowDeck distingue :
|
||||||
|
| Type | Portée | Exemple |
|
||||||
|
|------|--------|---------|
|
||||||
|
| **Personal Agent** | Généraliste, tout le workspace visible | Assistant quotidien |
|
||||||
|
| **Custom Agent** | Spécialisé, scope restreint, déclencheurs | Agent Reporting, Agent Support |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Déclencheurs (agents.trigger_json) :
|
||||||
|
```
|
||||||
|
type: "manual" → bouton dans l'UI
|
||||||
|
type: "schedule" → cron interne (ex: "0 9 * * 1" = lundi 9h)
|
||||||
|
type: "webhook" → branché sur webhooks.py (issue.opened, issue.closed…)
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemples de custom agents FlowDeck :
|
||||||
|
- Agent Reporting — chaque lundi, scanne toutes les collections, produit une page « Rapport hebdo » avec KPI
|
||||||
|
- Agent Gitea Triage — à issue.opened, classe l'issue dans la bonne collection et remplit ses propriétés
|
||||||
|
- Agent Sprint — prépare une collection sprint depuis les issues du milestone courant
|
||||||
|
Le scheduler s'appuie sur le lifespan FastAPI (main.py) et les webhooks réutilisent le router webhooks.py existant.
|
||||||
|
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
## 13. Skills réutilisables {#13-skills}
|
||||||
|
|
||||||
|
Une skill est un mini-prompt paramétrable + liste d'outils autorisés (table agent_skills).
|
||||||
|
```
|
||||||
|
Skill "Préparation de sprint"
|
||||||
|
├─ prompt_template:
|
||||||
|
│ "Analyse les issues ouvertes du repo {repo}, crée une collection
|
||||||
|
│ sprint '{sprint_name}', ajoute les propriétés Status/Priority/Assignee,
|
||||||
|
│ importe chaque issue comme page, groupe en vue Board."
|
||||||
|
├─ allowed_tools: [read_gitea_issues, create_collection,
|
||||||
|
│ add_property, create_page, create_view]
|
||||||
|
└─ Invocation: POST /api/agent/skills/{id}/apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Autres skills livrées par défaut : Analyse SWOT, Génération de compte-rendu, Génération de PRD, Synthèse GitHub/Gitea.
|
||||||
|
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
## 14. Cas d'usage FlowDeck {#14-cas-dusage}
|
||||||
|
|
||||||
|
Gestion de produit
|
||||||
|
|
||||||
|
> « Crée une roadmap Q3 à partir des issues du repo bruno/flowdeck »
|
||||||
|
|
||||||
|
```
|
||||||
|
L'agent : read_gitea_issues → create_collection("Roadmap Q3") → add_property(Status, Priority, DueDate) → create_page × N → create_view(timeline).
|
||||||
|
Développement logiciel
|
||||||
|
```
|
||||||
|
|
||||||
|
> « Analyse les bugs des 6 derniers mois et crée un plan d'amélioration »
|
||||||
|
|
||||||
|
```
|
||||||
|
sync_gitea → search_workspace → analyse LLM → create_collection("Plan qualité") → create_page (causes, actions) → add_dependency.
|
||||||
|
Direction
|
||||||
|
```
|
||||||
|
|
||||||
|
> « Génère les OKR du prochain trimestre depuis nos objectifs actuels »
|
||||||
|
|
||||||
|
```
|
||||||
|
read_collection(Objectifs) → raisonnement → create_collection("OKR Q4") → create_page × N.
|
||||||
|
Multi-étapes (comme Notion)
|
||||||
|
```
|
||||||
|
|
||||||
|
> « Résume les notes de réunion du trimestre et crée les tâches de suivi »
|
||||||
|
|
||||||
|
```
|
||||||
|
search_workspace(meetings) → synthèse → write_blocks (page résumé) → create_page × N (tâches) dans la collection Tasks.
|
||||||
|
```
|
||||||
|
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
## 15. Plan de migration {#15-plan-de-migration}
|
||||||
|
|
||||||
|
Phase 1 — Fondations DB & Service
|
||||||
|
1. Créer tables agents, agent_conversations, agent_messages,
|
||||||
|
agent_actions, agent_skills
|
||||||
|
2. Implémenter LLMClient (abstraction multi-provider)
|
||||||
|
3. Implémenter ContextBuilder (réutilise SearchEngine)
|
||||||
|
|
||||||
|
Phase 2 — Tool-calling
|
||||||
|
1. ToolRegistry : wrappers read-only d'abord (search, read_*)
|
||||||
|
2. Ajouter outils write (create_collection, create_page, ...)
|
||||||
|
3. Brancher PermissionManager.assert_can() sur chaque outil
|
||||||
|
4. Journal agent_actions + snapshots rollback
|
||||||
|
|
||||||
|
Phase 3 — Interface
|
||||||
|
1. agent_panel.html (Alpine.js) branché sur section 🤖 Agents
|
||||||
|
2. Streaming SSE (/run)
|
||||||
|
3. Icône flottante coin inférieur droit
|
||||||
|
4. Affichage temps réel reasoning + actions
|
||||||
|
|
||||||
|
Phase 4 — Personnalisation
|
||||||
|
1. Instructions custom par agent
|
||||||
|
2. Skills (table + /apply)
|
||||||
|
3. Sélecteur de modèle (GPT/Claude/Gemini/Ollama)
|
||||||
|
|
||||||
|
Phase 5 — Custom Agents
|
||||||
|
1. Scope + trigger_json
|
||||||
|
2. Scheduler (lifespan FastAPI)
|
||||||
|
3. Déclencheurs webhook (via webhooks.py)
|
||||||
|
4. Mode approbation (auto/confirm)
|
||||||
|
|
||||||
|
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
## 16. Limitations {#16-limitations}
|
||||||
|
|
||||||
|
Alignées sur les limites connues de Notion Agent, adaptées à FlowDeck. L'agent ne peut pas :
|
||||||
|
- Modifier les paramètres du workspace (/workspace/{id}/settings)
|
||||||
|
- Gérer les membres / rôles (workspace_members)
|
||||||
|
- Gérer l'authentification ou les intégrations OAuth (connexion/déconnexion Gitea/GitHub)
|
||||||
|
- Publier ou partager publiquement une page sans confirmation utilisateur (/api/pages/{id}/publish, /share)
|
||||||
|
- Modifier une collection verrouillée (is_locked = 1)
|
||||||
|
- Agir au-delà des permissions de l'utilisateur qui l'invoque
|
||||||
|
- Supprimer définitivement (hard delete) sans mode confirm
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
Résumé
|
||||||
|
|
||||||
|
FlowDeck Agent porte le modèle Notion Agent dans FlowDeck en réutilisant l'infrastructure existante :
|
||||||
|
- Un service AgentEngine orchestrant une boucle ReAct (raisonnement ↔ action)
|
||||||
|
- Des outils qui sont de simples wrappers des routers FastAPI existants (/db/*, /board/api/*, Gitea)
|
||||||
|
- Un contexte collecté via SearchEngine + GiteaClient, filtré par PermissionManager
|
||||||
|
- Un audit complet (agent_actions) avec rollback
|
||||||
|
- Custom Agents, skills et déclencheurs pour l'automatisation
|
||||||
|
Le passage de « l'IA qui répond » à « l'IA qui réalise le travail » se fait sans réécrire FlowDeck : l'agent agit comme un utilisateur privilégié mais contraint, à travers les mêmes portes que l'interface humaine.
|
||||||
|
|
||||||
|
|
||||||
|
J'ai créé le document `Flowdeck_Agent_integration.md`. Points saillants de l'intégration proposée :
|
||||||
|
|
||||||
|
- **Réutilisation maximale** : l'agent n'accède jamais directement à SQLite — il appelle les routers FastAPI existants (`/db/*`, `/board/api/*`, `GiteaClient`), ce qui garantit validation, cohérence et respect du `PermissionManager`.
|
||||||
|
|
||||||
|
- **Boucle ReAct** : Compréhension → Contexte → Raisonnement → Action, calquée sur le fonctionnement de Notion Agent mais mappée sur vos entités (`collections`, `collection_pages`, issues Gitea).
|
||||||
|
|
||||||
|
- **5 nouvelles tables** seulement (`agents`, `agent_conversations`, `agent_messages`, `agent_actions`, `agent_skills`), plus un router `agent.py` et 4 services.
|
||||||
|
|
||||||
|
- **Sécurité** : audit complet avec `undo_snapshot_json` pour rollback, respect des rôles workspace, mode `confirm` pour les actions destructives.
|
||||||
|
|
||||||
|
- **Custom Agents + Skills + déclencheurs** (cron via `lifespan`, webhooks via votre `webhooks.py` existant).
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
# Guide complet — Notion Agent (2026)
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
Notion Agent est l'évolution la plus avancée de Notion AI. Contrairement à un assistant conversationnel classique qui répond uniquement à des questions, Notion Agent est conçu pour **agir directement dans votre espace de travail**.
|
||||||
|
|
||||||
|
L'objectif est de transformer l'IA d'un simple outil de rédaction en un véritable collaborateur numérique capable d'exécuter des tâches complètes à travers les pages, bases de données et outils connectés.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. Qu'est-ce que Notion Agent ?
|
||||||
|
|
||||||
|
Notion Agent est un agent IA intégré nativement dans Notion.
|
||||||
|
|
||||||
|
Il possède :
|
||||||
|
|
||||||
|
- Un accès au contexte de votre espace de travail
|
||||||
|
- La capacité de lire des pages et bases de données
|
||||||
|
- La capacité de créer et modifier du contenu
|
||||||
|
- La possibilité d'effectuer des tâches multi‑étapes
|
||||||
|
- L'accès à certaines sources externes et outils connectés
|
||||||
|
|
||||||
|
Conceptuellement :
|
||||||
|
|
||||||
|
Assistant IA traditionnel :
|
||||||
|
Utilisateur → Prompt → Réponse
|
||||||
|
|
||||||
|
Notion Agent :
|
||||||
|
Utilisateur → Objectif → Planification → Recherche → Actions → Résultat
|
||||||
|
|
||||||
|
L'agent agit davantage comme un employé numérique que comme un chatbot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. Où apparaît-il dans l'interface ?
|
||||||
|
|
||||||
|
## Emplacement principal
|
||||||
|
|
||||||
|
Notion affiche l'agent sous la forme d'une icône circulaire représentant un visage.
|
||||||
|
|
||||||
|
Position habituelle :
|
||||||
|
|
||||||
|
- Coin inférieur droit de l'écran
|
||||||
|
|
||||||
|
Ou :
|
||||||
|
|
||||||
|
- Section AI dans la barre latérale gauche
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interface de conversation
|
||||||
|
|
||||||
|
Lorsque l'utilisateur ouvre l'agent :
|
||||||
|
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ Conversation Agent │
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ Historique │
|
||||||
|
│ │
|
||||||
|
│ Réponses │
|
||||||
|
│ │
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ @Sources │
|
||||||
|
│ 📎 Fichier │
|
||||||
|
│ Modèle IA │
|
||||||
|
│ Zone de saisie │
|
||||||
|
└──────────────────────────┘
|
||||||
|
|
||||||
|
L'interface ressemble à ChatGPT mais avec davantage de contrôles contextuels.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Comment fonctionne l'agent ?
|
||||||
|
|
||||||
|
## Étape 1 : Compréhension
|
||||||
|
|
||||||
|
L'utilisateur formule un objectif.
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
- Construis un CRM.
|
||||||
|
- Résume les notes de réunion du trimestre.
|
||||||
|
- Crée les OKR du prochain trimestre.
|
||||||
|
|
||||||
|
L'agent interprète l'intention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 2 : Collecte du contexte
|
||||||
|
|
||||||
|
Il récupère automatiquement :
|
||||||
|
|
||||||
|
- La page actuelle
|
||||||
|
- Les blocs sélectionnés
|
||||||
|
- Les bases de données liées
|
||||||
|
- Les documents référencés
|
||||||
|
- Les permissions utilisateur
|
||||||
|
|
||||||
|
Il fonctionne avec les mêmes permissions que l'utilisateur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 3 : Raisonnement
|
||||||
|
|
||||||
|
L'agent décompose la demande.
|
||||||
|
|
||||||
|
Exemple :
|
||||||
|
|
||||||
|
Créer un CRM
|
||||||
|
|
||||||
|
→ déterminer les propriétés
|
||||||
|
→ créer la base
|
||||||
|
→ créer les vues
|
||||||
|
→ créer les relations
|
||||||
|
→ générer des exemples
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 4 : Action
|
||||||
|
|
||||||
|
L'agent modifie directement Notion.
|
||||||
|
|
||||||
|
Contrairement à un chatbot traditionnel, il peut :
|
||||||
|
|
||||||
|
- créer
|
||||||
|
- modifier
|
||||||
|
- organiser
|
||||||
|
- restructurer
|
||||||
|
|
||||||
|
le contenu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Sources de connaissances
|
||||||
|
|
||||||
|
## Sources Notion
|
||||||
|
|
||||||
|
L'agent peut consulter :
|
||||||
|
|
||||||
|
- Pages
|
||||||
|
- Sous-pages
|
||||||
|
- Bases de données
|
||||||
|
- Wikis
|
||||||
|
- Documents internes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources connectées
|
||||||
|
|
||||||
|
Selon la configuration du workspace :
|
||||||
|
|
||||||
|
- Jira
|
||||||
|
- Slack
|
||||||
|
- Google Drive
|
||||||
|
- GitHub
|
||||||
|
- Figma
|
||||||
|
- Outils connectés compatibles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers
|
||||||
|
|
||||||
|
L'utilisateur peut joindre :
|
||||||
|
|
||||||
|
- PDF
|
||||||
|
- Documents
|
||||||
|
- Présentations
|
||||||
|
- Fichiers importés
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mention manuelle
|
||||||
|
|
||||||
|
L'utilisateur peut préciser :
|
||||||
|
|
||||||
|
@Projet Alpha
|
||||||
|
|
||||||
|
ou
|
||||||
|
|
||||||
|
@Équipe Marketing
|
||||||
|
|
||||||
|
pour forcer l'utilisation d'un contexte particulier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. Ce que Notion Agent peut faire
|
||||||
|
|
||||||
|
## Création de contenu
|
||||||
|
|
||||||
|
- Pages
|
||||||
|
- Sous-pages
|
||||||
|
- Documentation
|
||||||
|
- SOP
|
||||||
|
- Guides
|
||||||
|
- Rapports
|
||||||
|
- Comptes-rendus
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modification de contenu
|
||||||
|
|
||||||
|
- Réécriture
|
||||||
|
- Résumé
|
||||||
|
- Traduction
|
||||||
|
- Simplification
|
||||||
|
- Restructuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bases de données
|
||||||
|
|
||||||
|
L'agent peut :
|
||||||
|
|
||||||
|
- créer une base
|
||||||
|
- ajouter des propriétés
|
||||||
|
- ajouter des relations
|
||||||
|
- créer des vues
|
||||||
|
- remplir les données
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recherche documentaire
|
||||||
|
|
||||||
|
Il peut analyser :
|
||||||
|
|
||||||
|
- des centaines de pages
|
||||||
|
- plusieurs bases de données
|
||||||
|
- des documents attachés
|
||||||
|
|
||||||
|
pour produire un résultat consolidé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Analyse
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
- Identifier les tendances
|
||||||
|
- Comparer des projets
|
||||||
|
- Détecter les risques
|
||||||
|
- Produire des synthèses exécutives
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Travail multi‑étapes
|
||||||
|
|
||||||
|
Exemple :
|
||||||
|
|
||||||
|
« Analyse les incidents des 6 derniers mois et crée un plan d'amélioration. »
|
||||||
|
|
||||||
|
L'agent :
|
||||||
|
|
||||||
|
1. collecte les incidents
|
||||||
|
2. les analyse
|
||||||
|
3. identifie les causes
|
||||||
|
4. produit un rapport
|
||||||
|
5. crée les tâches
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Fonctionnalités avancées
|
||||||
|
|
||||||
|
## Changement de modèle IA
|
||||||
|
|
||||||
|
Le sélecteur de modèle permet selon la disponibilité :
|
||||||
|
|
||||||
|
- GPT
|
||||||
|
- Claude
|
||||||
|
- Gemini
|
||||||
|
- autres modèles partenaires
|
||||||
|
|
||||||
|
Certains modèles peuvent n'utiliser que le Web alors que d'autres exploitent le contexte Notion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Historique
|
||||||
|
|
||||||
|
L'agent conserve l'historique des conversations.
|
||||||
|
|
||||||
|
Permet :
|
||||||
|
|
||||||
|
- reprendre un travail
|
||||||
|
- relancer une tâche
|
||||||
|
- réutiliser un contexte
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instructions personnalisées
|
||||||
|
|
||||||
|
L'utilisateur peut définir :
|
||||||
|
|
||||||
|
- ton
|
||||||
|
- style
|
||||||
|
- format
|
||||||
|
- règles métier
|
||||||
|
|
||||||
|
Exemple :
|
||||||
|
|
||||||
|
« Toujours écrire les procédures en format SOP. »
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
Notion introduit des compétences réutilisables.
|
||||||
|
|
||||||
|
Une skill agit comme un mini‑prompt réutilisable.
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
- Génération de compte-rendu
|
||||||
|
- Analyse SWOT
|
||||||
|
- Préparation de sprint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Ce que l'agent ne peut pas faire
|
||||||
|
|
||||||
|
À ce jour, certaines limitations existent.
|
||||||
|
|
||||||
|
Il ne peut généralement pas :
|
||||||
|
|
||||||
|
- modifier les permissions des pages
|
||||||
|
- partager des pages
|
||||||
|
- gérer la facturation
|
||||||
|
- modifier les paramètres du workspace
|
||||||
|
- créer certaines propriétés avancées complexes
|
||||||
|
- créer des rappels système
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. Différence entre Notion AI et Notion Agent
|
||||||
|
|
||||||
|
## Notion AI (ancienne approche)
|
||||||
|
|
||||||
|
- Répond
|
||||||
|
- Résume
|
||||||
|
- Réécrit
|
||||||
|
|
||||||
|
## Notion Agent
|
||||||
|
|
||||||
|
- Planifie
|
||||||
|
- Recherche
|
||||||
|
- Agit
|
||||||
|
- Modifie le workspace
|
||||||
|
- Réalise des tâches complètes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. Custom Agents (2026)
|
||||||
|
|
||||||
|
Notion a également lancé les Custom Agents.
|
||||||
|
|
||||||
|
Différence :
|
||||||
|
|
||||||
|
Notion Agent :
|
||||||
|
- Agent personnel généraliste
|
||||||
|
|
||||||
|
Custom Agent :
|
||||||
|
- Agent spécialisé
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
- Agent RH
|
||||||
|
- Agent Support
|
||||||
|
- Agent Produit
|
||||||
|
- Agent Marketing
|
||||||
|
- Agent GitHub
|
||||||
|
- Agent Reporting
|
||||||
|
|
||||||
|
Ces agents peuvent fonctionner automatiquement selon des déclencheurs et horaires.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. Cas d'usage réels
|
||||||
|
|
||||||
|
## Gestion de produit
|
||||||
|
|
||||||
|
- Générer des PRD
|
||||||
|
- Créer des roadmaps
|
||||||
|
- Analyser Jira
|
||||||
|
|
||||||
|
## Développement logiciel
|
||||||
|
|
||||||
|
- Synthèse GitHub
|
||||||
|
- Documentation technique
|
||||||
|
- Suivi des bugs
|
||||||
|
|
||||||
|
## Marketing
|
||||||
|
|
||||||
|
- Rapports de campagne
|
||||||
|
- Veille concurrentielle
|
||||||
|
- Création de contenu
|
||||||
|
|
||||||
|
## Direction
|
||||||
|
|
||||||
|
- OKR
|
||||||
|
- KPI
|
||||||
|
- Rapports exécutifs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. Architecture conceptuelle
|
||||||
|
|
||||||
|
Utilisateur
|
||||||
|
↓
|
||||||
|
Notion Agent
|
||||||
|
↓
|
||||||
|
Contexte Workspace
|
||||||
|
↓
|
||||||
|
Sources Connectées
|
||||||
|
↓
|
||||||
|
Moteur IA
|
||||||
|
↓
|
||||||
|
Planification
|
||||||
|
↓
|
||||||
|
Actions Notion
|
||||||
|
↓
|
||||||
|
Résultat
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 12. Vision produit
|
||||||
|
|
||||||
|
Notion cherche à transformer son produit en système d'exploitation du travail.
|
||||||
|
|
||||||
|
L'évolution observée :
|
||||||
|
|
||||||
|
2018 → Notes
|
||||||
|
|
||||||
|
2020 → Bases de données
|
||||||
|
|
||||||
|
2023 → Notion AI
|
||||||
|
|
||||||
|
2025 → Notion Agent
|
||||||
|
|
||||||
|
2026 → Custom Agents
|
||||||
|
|
||||||
|
Direction probable :
|
||||||
|
|
||||||
|
- équipes d'agents spécialisés
|
||||||
|
- automatisation continue
|
||||||
|
- orchestration inter‑outils
|
||||||
|
- assistants autonomes de projet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Résumé
|
||||||
|
|
||||||
|
Notion Agent est un agent IA intégré profondément dans Notion.
|
||||||
|
|
||||||
|
Ses caractéristiques majeures :
|
||||||
|
|
||||||
|
- Compréhension du contexte
|
||||||
|
- Accès aux connaissances du workspace
|
||||||
|
- Raisonnement multi‑étapes
|
||||||
|
- Création et modification de contenu
|
||||||
|
- Gestion des bases de données
|
||||||
|
- Analyse documentaire
|
||||||
|
- Personnalisation via instructions et skills
|
||||||
|
- Intégration avec outils externes
|
||||||
|
- Possibilité d'évoluer vers des agents spécialisés
|
||||||
|
|
||||||
|
Il représente le passage de « l'IA qui répond » à « l'IA qui réalise le travail » directement dans l'environnement Notion.
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
# 📚 Guide complet : Databases & Database Views dans Notion
|
||||||
|
|
||||||
|
> Guide de référence de A à Z sur le fonctionnement des bases de données (Databases) et des vues de bases de données (Database Views) dans Notion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table des matières
|
||||||
|
|
||||||
|
1. [Introduction : Qu'est-ce qu'une Database ?](#1-introduction)
|
||||||
|
2. [Concepts fondamentaux](#2-concepts-fondamentaux)
|
||||||
|
3. [Créer une database](#3-créer-une-database)
|
||||||
|
4. [Databases pleine page vs. en ligne (inline)](#4-full-page-vs-inline)
|
||||||
|
5. [Les propriétés (Database Properties)](#5-les-propriétés)
|
||||||
|
6. [Relations & Rollups](#6-relations--rollups)
|
||||||
|
7. [Formules (Formulas)](#7-formules)
|
||||||
|
8. [Templates de database](#8-templates-de-database)
|
||||||
|
9. [Sous-éléments, dépendances & tâches/sprints](#9-tâches-sous-items-sprints)
|
||||||
|
10. [Data sources & databases liées](#10-data-sources)
|
||||||
|
11. [Les Database Views (vues)](#11-les-database-views)
|
||||||
|
12. [Filtres, tris et regroupements](#12-filtres-tris-regroupements)
|
||||||
|
13. [Tableau récapitulatif des vues](#13-récapitulatif-des-vues)
|
||||||
|
14. [Cas d'usage concrets](#14-cas-dusage)
|
||||||
|
15. [Limites à connaître](#15-limites)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Introduction
|
||||||
|
|
||||||
|
Les **Databases** (bases de données) sont l'une des fonctionnalités **fondamentales** de Notion. Ce sont des **collections de pages Notion** qui vous permettent d'organiser l'information de multiples façons.
|
||||||
|
|
||||||
|
Ce qui rend les databases Notion uniques :
|
||||||
|
|
||||||
|
- **Chaque élément est sa propre page** : chaque ligne/élément est une page Notion complète que vous pouvez ouvrir, formater et remplir de contenu (texte, images, sous-pages, etc.).
|
||||||
|
- **Propriétés personnalisables** : ajoutez du contexte (dates, statuts, personnes, liens…) à chaque élément.
|
||||||
|
- **Visualisation multiple** : les mêmes données peuvent être affichées en table, calendrier, tableau kanban, chart, etc.
|
||||||
|
- **Sources multiples** : une seule database peut regrouper plusieurs jeux de données (ex. un CRM tout-en-un : contacts, entreprises, deals).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Concepts fondamentaux
|
||||||
|
|
||||||
|
| Concept | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Database** | Le conteneur global qui regroupe une ou plusieurs sources de données et une ou plusieurs vues. |
|
||||||
|
| **Data source** | Un ensemble de pages. Chaque database contient **au moins une** data source. |
|
||||||
|
| **Page / Item** | Chaque ligne = une page Notion complète, ouvrable et éditable. |
|
||||||
|
| **Property (propriété)** | Champ de métadonnées attaché à chaque page (date, statut, personne…). |
|
||||||
|
| **View (vue)** | Une manière d'afficher les données (table, board, calendrier…). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Créer une database
|
||||||
|
|
||||||
|
**Méthode 1 — Nouvelle page**
|
||||||
|
1. Créez une nouvelle page → sous **Get started with**, sélectionnez **Table** (ou `•••` → **Database**).
|
||||||
|
|
||||||
|
**Méthode 2 — Commande slash**
|
||||||
|
- Dans une page existante, tapez `/database`.
|
||||||
|
|
||||||
|
**Options à la création :**
|
||||||
|
- **New empty database** : partir de zéro.
|
||||||
|
- **Link to existing data source** : réutiliser des données existantes du workspace.
|
||||||
|
- **Build with AI** : donner un prompt à Notion AI pour générer la database.
|
||||||
|
- **Suggested template** : choisir un modèle proposé.
|
||||||
|
|
||||||
|
**Premières étapes de configuration :**
|
||||||
|
1. **Créer une page** → bouton **New page**.
|
||||||
|
2. **Ajouter une propriété** → **Add property** (ex. Status, Due date).
|
||||||
|
3. **Ajouter une vue** → par défaut en **Table view**, mais autant de vues que souhaité.
|
||||||
|
4. **Éditer la vue** → nom, layout, propriétés visibles, filtres, tris, groupes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Full-page vs. Inline
|
||||||
|
|
||||||
|
### Databases pleine page (Full-page)
|
||||||
|
- Apparaissent comme n'importe quelle page dans la sidebar.
|
||||||
|
- **Verrouillage** possible : `•••` → **Lock database** (empêche la modification des propriétés/options).
|
||||||
|
- Transformer en inline : glisser dans une autre page → `⋮⋮` → **Turn into inline**.
|
||||||
|
|
||||||
|
### Databases en ligne (Inline)
|
||||||
|
- Contrôles et menus **masqués** jusqu'au survol.
|
||||||
|
- Étendre en pleine page : cliquer sur **⤡** en haut.
|
||||||
|
- Transformer en pleine page : `⋮⋮` puis glisser vers la sidebar.
|
||||||
|
- Actions via `⋮⋮` : **Delete, Duplicate, Move, Copy link**.
|
||||||
|
|
||||||
|
### Dupliquer une database
|
||||||
|
- **Inline** : survol → `⋮⋮` → **Duplicate**.
|
||||||
|
- **Full-page** : `•••` → **Duplicate**.
|
||||||
|
- Choix : **Duplicate with content** (avec les pages) ou **without content** (structure seule).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Les propriétés
|
||||||
|
|
||||||
|
Les propriétés ajoutent du contexte et permettent de **filtrer, trier et rechercher**.
|
||||||
|
|
||||||
|
### Types de propriétés disponibles
|
||||||
|
|
||||||
|
| Propriété | Usage |
|
||||||
|
|-----------|-------|
|
||||||
|
| **Text** | Texte formaté (notes, résumés, descriptions). |
|
||||||
|
| **Number** | Nombres, avec format devise ou barre de progression. |
|
||||||
|
| **Select** | Une option parmi une liste de tags. |
|
||||||
|
| **Status** | Progression : To-do, In Progress, Complete. |
|
||||||
|
| **Multi-Select** | Plusieurs tags/catégories. |
|
||||||
|
| **Date** | Date ou plage de dates (heure optionnelle). |
|
||||||
|
| **Formula** | Calculs basés sur d'autres propriétés. |
|
||||||
|
| **Relation** | Connexion entre databases. |
|
||||||
|
| **Rollup** | Agrégation d'infos issues d'une relation. |
|
||||||
|
| **Person** | Assigner une personne/groupe du workspace. |
|
||||||
|
| **File** | Upload de fichiers et images. |
|
||||||
|
| **Checkbox** | Vrai/faux (tâches légères). |
|
||||||
|
| **URL** | Lien web. |
|
||||||
|
| **Email** | Adresse email (ouvre le client mail). |
|
||||||
|
| **Phone** | Numéro (déclenche l'appel). |
|
||||||
|
| **Created time / by** | Auto-généré (non éditable). |
|
||||||
|
| **Last edited time / by** | Auto-mis à jour (non éditable). |
|
||||||
|
| **Button** | Automatiser des actions en un clic. |
|
||||||
|
| **ID** | Identifiant numérique unique, immuable. |
|
||||||
|
| **Place** | Localisation (services de localisation, nom, adresse). |
|
||||||
|
|
||||||
|
> 💡 **Notion AI** peut auto-remplir certains types de propriétés.
|
||||||
|
|
||||||
|
### Gérer les propriétés
|
||||||
|
- **Créer** : **Add property** ou `+` à droite → nom + type.
|
||||||
|
- **Éditer** : menu ⚙️ en haut → **Edit properties**.
|
||||||
|
- **Masquer/afficher** : **Property visibility** → 👁️.
|
||||||
|
- **Valeurs** : la façon d'éditer varie par type (date picker, tags, upload de fichier…).
|
||||||
|
|
||||||
|
> ⚠️ **Limite** : max **500 propriétés** par database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Relations & Rollups
|
||||||
|
|
||||||
|
### Relations
|
||||||
|
Une **relation** connecte les éléments de deux databases (ou d'une database à elle-même).
|
||||||
|
|
||||||
|
**Exemple** : une database *Customers* + une database *Items* → savoir quel client a acheté quel article.
|
||||||
|
|
||||||
|
**Créer une relation :**
|
||||||
|
1. Ajouter une propriété → type **Relation**.
|
||||||
|
2. Chercher la database cible.
|
||||||
|
3. **Add relation**.
|
||||||
|
|
||||||
|
**Relations bidirectionnelles (2-way) :**
|
||||||
|
- Par défaut, les relations sont **1-way**.
|
||||||
|
- Activer **Show on [database]** pour synchroniser dans les deux sens.
|
||||||
|
|
||||||
|
**Options :**
|
||||||
|
- **Limiter** le nombre de pages liées (1 page ou illimité).
|
||||||
|
- **Property visibility** : Always show / Hide when empty / Always hide.
|
||||||
|
- **Personnaliser les propriétés affichées** dans la relation.
|
||||||
|
- **Relation vers elle-même** : ex. tâches liées entre elles.
|
||||||
|
|
||||||
|
### Rollups
|
||||||
|
Un **rollup** agrège les données issues d'une relation (compter, additionner, moyenne…).
|
||||||
|
|
||||||
|
> ⚠️ On **ne peut pas faire un rollup d'un rollup** (risque de boucles).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Formules
|
||||||
|
|
||||||
|
Une propriété **Formula** exécute des calculs basés sur d'autres propriétés, via le langage de formules Notion.
|
||||||
|
|
||||||
|
### Où utiliser les formules
|
||||||
|
- Comme **propriété de database**.
|
||||||
|
- Dans une **action de bouton**.
|
||||||
|
- Dans une **action de database button**.
|
||||||
|
- Dans une **action d'automatisation**.
|
||||||
|
|
||||||
|
### Exemples concrets
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Due date = date de début + 2 semaines
|
||||||
|
|
||||||
|
dateAdd(Start Date, 2, "week")
|
||||||
|
Marquer "Overdue" en rouge/gras si dépassé et non terminé
|
||||||
|
|
||||||
|
if(and(now() > Due Date, Status != "Done"), style("Overdue", "red", "b"), "")
|
||||||
|
Nombre de tâches liées
|
||||||
|
|
||||||
|
Tasks.length()
|
||||||
|
|
||||||
|
|
||||||
|
### Éléments du langage
|
||||||
|
- **Properties** : `prop("Nom")`
|
||||||
|
- **Opérateurs math** : `+ - * % ^`
|
||||||
|
- **Booléens** : `true`, `false`
|
||||||
|
- **Comparaison** : `== > >= < <= !=`
|
||||||
|
- **Logiques** : `and`, `or`, `not`
|
||||||
|
- **Ternaire** : `X ? Y : Z` (équivalent à `if(X, Y, Z)`)
|
||||||
|
|
||||||
|
### Fonctions courantes
|
||||||
|
`if()`, `ifs()`, `empty()`, `length()`, `substring()`, `contains()`, `replace()`, `lower()`, `upper()`, `style()`, `format()`, `now()`, `today()`, `dateAdd()`, `dateBetween()`, `.first()`, `.every()`, `.filter()`, `.map()`, `round()`, `min()`, `max()`, `sum()`, `mean()`…
|
||||||
|
|
||||||
|
> 💡 **Notion AI** peut créer/éditer vos formules à partir d'une description.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Templates de database
|
||||||
|
|
||||||
|
Les **templates** permettent de répliquer une structure de page en un clic (ex. notes de réunion, rapport de bug, spec design).
|
||||||
|
|
||||||
|
**Créer un template :**
|
||||||
|
1. Flèche déroulante à côté de **New** → **+ New template**.
|
||||||
|
2. Nommer le template (= nom affiché).
|
||||||
|
3. Prédéfinir propriétés (ex. Priority = P1) et contenu (texte, images, embeds, sous-pages).
|
||||||
|
|
||||||
|
**Utiliser un template :** menu déroulant du bouton **New** → choisir le template.
|
||||||
|
|
||||||
|
**Templates récurrents :** répétition quotidienne, hebdomadaire, mensuelle ou annuelle.
|
||||||
|
|
||||||
|
**Nesting (imbrication) :**
|
||||||
|
- Pas de nesting dans un template récurrent **quotidien**.
|
||||||
|
- Max **3 niveaux** d'imbrication.
|
||||||
|
|
||||||
|
> ⚠️ Les templates sont **propres à chaque database**. Évitez de remplir les propriétés relation dans un template (sauf si vous voulez lier toutes les pages à la même cible).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Tâches, sous-items & sprints
|
||||||
|
|
||||||
|
### Sub-items (sous-éléments)
|
||||||
|
Découpent une tâche en plus petites parties, visibles dans toutes les vues.
|
||||||
|
- **Activer** : ⚙️ → **More settings** → **Sub-items** → **Turn on**.
|
||||||
|
- **Affichage** : *Nested in toggle*, *Flattened list*, *Card property*…
|
||||||
|
- **Filtres** : Parents only / Parents and sub-items / Sub-items only.
|
||||||
|
|
||||||
|
### Dependencies (dépendances)
|
||||||
|
Connectent les tâches de façon linéaire (A bloque B).
|
||||||
|
- Options de décalage automatique des dates :
|
||||||
|
- **Shift only when dates overlap**
|
||||||
|
- **Shift & maintain time between items**
|
||||||
|
- **Do not automatically shift**
|
||||||
|
- Option pour éviter les week-ends.
|
||||||
|
|
||||||
|
### Task database
|
||||||
|
Une database avec les propriétés **status, assignee, due date**.
|
||||||
|
- **Activer** : ⚙️ → **More settings** → **Turn into Tasks**.
|
||||||
|
- Les tâches assignées apparaissent dans **My tasks** (widget dans Home).
|
||||||
|
- Propriété **ID** : identifiant unique, URL directe (`notion.so/TASK-123`).
|
||||||
|
|
||||||
|
### Sprints
|
||||||
|
Nécessitent une **Task database**.
|
||||||
|
- **Activer** : ⚙️ → **More settings** → **Sprints** → **Turn on sprints**.
|
||||||
|
- **Sprint board** : Current Sprint / Sprint planning / Backlog.
|
||||||
|
- **Sprints database** : vue générale + Timeline.
|
||||||
|
- **Réglages** : durée (1 à 8 semaines), jour de début, gestion des tâches incomplètes, complétion automatique.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Data sources
|
||||||
|
|
||||||
|
Chaque database contient **au moins une data source**. Une database peut en contenir **plusieurs**.
|
||||||
|
|
||||||
|
**Cas d'usage multi-sources :**
|
||||||
|
- **CRM** : contacts + entreprises + deals + activités.
|
||||||
|
- **Gestion de projet** : membres + projets + tâches + ressources.
|
||||||
|
- **Recrutement** : candidats + postes + départements + entretiens.
|
||||||
|
|
||||||
|
**Ajouter/gérer :** icône slider → **Manage data sources**.
|
||||||
|
- **Sources** : celles originaires de cette database (Move / Move to Trash / Add).
|
||||||
|
- **Linked** : celles venant d'ailleurs (X pour retirer).
|
||||||
|
|
||||||
|
**Databases liées (linked) :**
|
||||||
|
- Les vues/filtres/tris/groupes créés sur une database liée **n'affectent pas** l'originale.
|
||||||
|
- En revanche, l'édition du **titre, des propriétés ou des pages** est **répercutée** sur la source d'origine.
|
||||||
|
- Le niveau d'accès respecte celui de la database originale.
|
||||||
|
|
||||||
|
> ⚠️ Pour partager une page contenant une data source liée, le destinataire doit avoir accès à la **database originale**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Les Database Views
|
||||||
|
|
||||||
|
Une **vue** est une manière d'afficher/organiser les données. Chaque vue peut avoir ses propres filtres, tris, groupes et propriétés visibles.
|
||||||
|
|
||||||
|
### Types de vues disponibles
|
||||||
|
|
||||||
|
| Vue | Description | Idéal pour |
|
||||||
|
|-----|-------------|-----------|
|
||||||
|
| **Table** | Format tableur classique (lignes/colonnes). | Données structurées, aperçu complet. |
|
||||||
|
| **Board (Kanban)** | Colonnes de cartes regroupées par propriété (ex. Status). | Workflows, gestion de tâches. |
|
||||||
|
| **Timeline** | Chronologie horizontale (type Gantt). | Planification, dépendances. |
|
||||||
|
| **Calendar** | Vue calendrier basée sur une propriété Date. | Événements, deadlines. |
|
||||||
|
| **List** | Liste minimaliste et épurée. | Notes, docs, lecture simple. |
|
||||||
|
| **Gallery** | Cartes visuelles avec aperçu d'image/contenu. | Moodboards, portfolios. |
|
||||||
|
| **Chart** | Graphiques (barres, courbes, camemberts…). | Analyse, reporting, dashboards. |
|
||||||
|
| **Form** | Formulaire de saisie qui crée de nouvelles entrées. | Collecte de données, sondages. |
|
||||||
|
| **Map** | Localisation des éléments (propriété Place). | Lieux, adresses. |
|
||||||
|
| **Feed** | Flux de contenu de type actualité. | Mises à jour, annonces. |
|
||||||
|
|
||||||
|
### Layouts (mises en page)
|
||||||
|
Chaque vue possède des **options de layout** personnalisables : taille des cartes, aperçu d'image, propriétés affichées, ouverture des pages, etc.
|
||||||
|
|
||||||
|
### Dashboards
|
||||||
|
Combinez plusieurs vues liées + charts sur une même page pour un tableau de bord centralisé.
|
||||||
|
|
||||||
|
### Ajouter/éditer une vue
|
||||||
|
1. Cliquer sur **+** à côté des vues existantes.
|
||||||
|
2. Choisir le type de layout.
|
||||||
|
3. Éditer : nom, layout, visibilité des propriétés, filtres, tris, groupes.
|
||||||
|
|
||||||
|
> 💡 Vous pouvez ajouter une **vue liée** d'une autre data source dans n'importe quelle database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Filtres, tris & regroupements
|
||||||
|
|
||||||
|
### Filtres (Filters)
|
||||||
|
Affichent uniquement les éléments correspondant à des critères.
|
||||||
|
- Ex. : Status = "In Progress", Due Date = cette semaine.
|
||||||
|
- Filtres multiples avec logique **AND / OR**.
|
||||||
|
- Groupes de filtres avancés (imbriqués).
|
||||||
|
|
||||||
|
### Tris (Sorts)
|
||||||
|
Ordonnent les éléments selon une ou plusieurs propriétés (ascendant/descendant).
|
||||||
|
|
||||||
|
### Regroupements (Groups)
|
||||||
|
Regroupent visuellement les éléments par propriété (ex. par Status dans un board, par assigné, par date).
|
||||||
|
- **Group** (regroupement principal) et **Sub-group** (sous-regroupement, notamment en board).
|
||||||
|
|
||||||
|
> ⚠️ Sur une **database liée**, filtres/tris/groupes sont locaux et n'affectent pas l'originale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Récapitulatif des vues
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
📊 Table → tableur (colonnes/lignes)
|
||||||
|
📋 Board → kanban (colonnes de cartes)
|
||||||
|
📅 Calendar → calendrier (par date)
|
||||||
|
⏱️ Timeline → chronologie / Gantt
|
||||||
|
📝 List → liste épurée
|
||||||
|
🖼️ Gallery → cartes visuelles
|
||||||
|
📈 Chart → graphiques
|
||||||
|
🗺️ Map → carte géographique
|
||||||
|
📰 Feed → flux d'actualité
|
||||||
|
🗒️ Form → formulaire de saisie
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Cas d'usage
|
||||||
|
|
||||||
|
- **Gestion de projet** : Task database + sous-items + dépendances + sprints, vues Board/Timeline/Calendar.
|
||||||
|
- **CRM** : database multi-sources (contacts, entreprises, deals), vues filtrées par étape.
|
||||||
|
- **Notes de réunion** : templates récurrents + relation vers clients/projets.
|
||||||
|
- **Suivi OKR** : relation entre database d'OKR et database de tâches, rollups pour la progression.
|
||||||
|
- **Portfolio / moodboard** : vue Gallery avec propriété File.
|
||||||
|
- **Collecte de données** : vue Form partagée en externe.
|
||||||
|
- **Reporting** : Dashboard combinant charts et vues liées.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Limites à connaître
|
||||||
|
|
||||||
|
- **500 propriétés max** par database.
|
||||||
|
- **Pas de rollup d'un rollup** (évite les boucles).
|
||||||
|
- **Au-delà de 1 000 éléments** : les nouvelles pages peuvent apparaître au milieu de la collection (indexation).
|
||||||
|
- **Export CSV** : les relations s'exportent en URL texte brut ; **impossible de réimporter** pour rétablir les relations.
|
||||||
|
- **Duplication d'une database avec relation** : la relation dupliquée devient **1-way** (convertible à nouveau en 2-way).
|
||||||
|
- **Templates** : non partagés entre databases ; nesting limité à 3 niveaux ; pas de nesting dans un template récurrent quotidien.
|
||||||
|
- **Databases liées** : nécessitent l'accès à la database originale pour le partage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ En résumé
|
||||||
|
|
||||||
|
Une **Database Notion** = une collection de pages, décrites par des **propriétés**, reliées entre elles par des **relations/rollups**, enrichies par des **formules**, structurées par des **templates**, et affichées à travers de multiples **vues** (table, board, calendrier, timeline, gallery, chart, form, map, feed, list) — chacune personnalisable par des **filtres, tris et regroupements**. Le tout peut regrouper plusieurs **data sources** pour créer des outils tout-en-un (CRM, gestion de projet, recrutement…).
|
||||||
|
|
||||||
|
*Guide basé sur la documentation officielle du Notion Help Center.*
|
||||||
|
|
||||||
|
*Database*
|
||||||
|
https://www.notion.com/help/category/databases
|
||||||
|
https://www.notion.com/help/intro-to-databases
|
||||||
|
https://www.notion.com/help/database-properties
|
||||||
|
https://www.notion.com/help/relations-and-rollups
|
||||||
|
https://www.notion.com/help/formulas
|
||||||
|
https://www.notion.com/help/formula-syntax
|
||||||
|
https://www.notion.com/help/database-templates
|
||||||
|
https://www.notion.com/help/tasks-and-dependencies
|
||||||
|
https://www.notion.com/help/sprints
|
||||||
|
https://www.notion.com/help/data-sources-and-linked-databases
|
||||||
|
|
||||||
|
*Database view*
|
||||||
|
https://www.notion.com/help/category/database-views
|
||||||
|
https://www.notion.com/help/category/database-views/all
|
||||||
|
https://www.notion.com/help/views-filters-and-sorts
|
||||||
|
https://www.notion.com/help/tables
|
||||||
|
https://www.notion.com/help/boards
|
||||||
|
https://www.notion.com/help/timelines
|
||||||
|
https://www.notion.com/help/calendars
|
||||||
|
https://www.notion.com/help/lists
|
||||||
|
https://www.notion.com/help/dashboards
|
||||||
|
https://www.notion.com/help/galleries
|
||||||
|
https://www.notion.com/help/charts
|
||||||
|
https://www.notion.com/help/forms
|
||||||
|
https://www.notion.com/help/maps
|
||||||
|
https://www.notion.com/help/feeds
|
||||||
|
https://www.notion.com/help/layouts
|
||||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 120 KiB |