Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44bf469c53 | ||
|
|
3c5eac3429 | ||
|
|
db4329eee2 | ||
|
|
122ccfb3c3 | ||
|
|
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 | ||
|
|
c574d2c8b5 | ||
|
|
b9723dffab | ||
|
|
3967c7df85 | ||
|
|
66ae1289a9 | ||
|
|
1dea15ad90 | ||
|
|
f04003f060 | ||
|
|
780cccf79f | ||
|
|
c02e00726e | ||
|
|
a585587e4c | ||
|
|
8aa1a703b5 | ||
|
|
205f09838f | ||
|
|
c8c08f5589 | ||
|
|
d4cd45c6cb | ||
|
|
06cdec52fa | ||
|
|
755404fbb0 | ||
|
|
c1dba816de | ||
|
|
aa3d5d87c8 | ||
|
|
4c49d43c66 | ||
|
|
b826cfc247 | ||
|
|
c00442c693 | ||
|
|
1d2c2c0919 | ||
|
|
92dad6db1f | ||
|
|
4eaec52d66 | ||
|
|
bed326ee9b | ||
|
|
e3180c6255 | ||
|
|
24e979a05f | ||
|
|
b10118eddc | ||
|
|
a5e91cf8c7 | ||
|
|
b50d920c36 |
@@ -5,6 +5,10 @@ GITEA_OAUTH_CLIENT_ID=
|
||||
GITEA_OAUTH_CLIENT_SECRET=
|
||||
GITEA_WEBHOOK_SECRET=
|
||||
|
||||
# ── GitHub ──
|
||||
GITHUB_OAUTH_CLIENT_ID=
|
||||
GITHUB_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# ── App ──
|
||||
APP_SECRET_KEY=change-me-to-random
|
||||
APP_HOST=0.0.0.0
|
||||
|
||||
@@ -8,3 +8,10 @@ venv/
|
||||
dist/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Understand-Anything: exclude intermediate files and local diff overlay
|
||||
.ua/intermediate/
|
||||
.ua/diff-overlay.json
|
||||
.ua/tmp/
|
||||
.ua/.trash-*/
|
||||
.ua/.understandignore
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"outputLanguage":"fr"}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"lastAnalyzedAt": "2026-07-10T12:53:39Z",
|
||||
"gitCommitHash": "c86c04ca220672fb012bb69916e930102be44ef4",
|
||||
"version": "1.0.0",
|
||||
"analyzedFiles": 67
|
||||
}
|
||||
@@ -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,32 +1,196 @@
|
||||
# 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
|
||||
|
||||
### Added
|
||||
- **Tables**: `collections`, `collection_pages`, `collection_views`
|
||||
- **Router `/db`**: CRUD collections + pages (16 endpoints)
|
||||
- **GiteaBoardCompat**: adaptateur boards legacy → Collections
|
||||
|
||||
## v1.0.0 (2026-07-08) — Production
|
||||
|
||||
### Added
|
||||
- **UI Notion-complete**: sidebar 240px, topbar 44px, dark mode (#191919/#222/#333)
|
||||
- **5 vues**: Kanban, Detailed Board, Table, Status Overview (SVG donut), Team Load (stacked bars)
|
||||
- **Filtres cumulables AND** avec status dropdown checkboxes, filtres par assignee/tag
|
||||
- **Tri multi-critères** hiérarchique (N niveaux, asc/desc)
|
||||
- **Card detail modal**: titre éditable, propriétés, checklists, commentaires Gitea
|
||||
- **Création d'issues inline** depuis le board
|
||||
- **Propriétés custom** par projet: select, multi_select, date, person, text
|
||||
- **AI keywords**: extraction automatique depuis labels + body, table `ai_keywords`
|
||||
- **Sync API**: `POST /board/api/sync/{owner}/{repo}` — bidirectionnelle complète
|
||||
- **CI/CD**: Gitea Actions workflow
|
||||
- **Tests**: 25 tests, couvrant toutes les vues, APIs, filtres, tris
|
||||
|
||||
### Changed
|
||||
- FastAPI `on_event` → `lifespan` handler (deprecation fix)
|
||||
- Version bump 0.3.0 → 1.0.0
|
||||
- CSS: 31 KB design system Notion (700+ lignes)
|
||||
- 10 templates Jinja2 (base, board, card, detail, table, status, team, detailed, fragment, dashboard)
|
||||
|
||||
### Fixed
|
||||
- Team Load bug: status key mismatch (done → complete)
|
||||
- CSRF: 403 protection vérifiée sur toutes les routes POST
|
||||
|
||||
## v0.3.0 (2026-06-??)
|
||||
- OAuth2 Gitea, CSRF, rate limiting, webhooks
|
||||
|
||||
## v0.2.0 (2026-05-??)
|
||||
- Dashboard + Board Kanban + Gitea sync + Notes + Docker
|
||||
- FastAPI lifespan, 28 tests, CI/CD Gitea Actions, CSS 31KB, 10 templates
|
||||
- 5 vues: Kanban, Table, Status Overview, Team Load, Detailed Board
|
||||
- Filtres cumulables AND, tri multi-critères
|
||||
- Card detail modal, checklists, commentaires Gitea
|
||||
- Propriétés custom, AI keywords, sync API
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# FlowDeck
|
||||
|
||||
Clone de l'interface **Notion** intégré nativement à **Gitea** — Kanban, Table, Status Overview, Team Load.
|
||||
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
|
||||
|
||||
> **v0.4.0-dev** — Refonte UI Notion-style (Dark Mode, sidebar, topbar, multi-vues)
|
||||
> **v2.1.0** — API publique, Webhooks sortants, PWA
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -13,43 +13,65 @@ docker compose up -d
|
||||
# → http://localhost:8080
|
||||
```
|
||||
|
||||
## Features (v0.4.0)
|
||||
## Features
|
||||
|
||||
### UI Notion-Style
|
||||
- **Sidebar gauche** avec sections hiérarchiques (Home, Recents, Private, Library)
|
||||
- **Topbar** avec titre de page, statut, boutons d'action (Share, favori, notifications)
|
||||
- **Dark mode** Notion: `#191919` fond, `#222222` sidebar, `#333333` actif
|
||||
- **Menu contextuel** (clic droit): Copy link, Duplicate, Rename, Move to Trash...
|
||||
- **Hover states** subtils avec icônes d'action qui apparaissent
|
||||
- **Typographie Inter**, coins arrondis 4-6px
|
||||
### Database & Properties (v1.3–v1.5)
|
||||
- **Collections**: databases indépendantes de Gitea, schéma JSON
|
||||
- **21 types de propriétés**: title, text, number, select, status, date, person, checkbox, url, email, phone, files, unique_id, relation, rollup, formula + auto-props
|
||||
- **Relations bidirectionnelles** + **Rollups** (12 agrégations) + **Formulas** (19 fonctions)
|
||||
- **GiteaBoardCompat**: adaptateur boards Gitea legacy → Collections
|
||||
|
||||
### Vues Multiples
|
||||
- **Kanban board**: colonnes To-do/In progress/Complete avec sous-groupes (Design, Engineering)
|
||||
- **Table view**: colonnes Name, Status, Assign, Deadline, Team, AI keywords
|
||||
- **Status overview**: Donut chart avec segments, compteurs, pourcentages
|
||||
- **Team Load**: Barres empilées par membre d'équipe
|
||||
- **Detailed board**: cartes avec toutes les propriétés visibles
|
||||
### Vues Multiples (v1.6–v1.7)
|
||||
- **Table**: colonnes SSR, triables
|
||||
- **Board (Kanban)**: colonnes, groupes, drag & drop
|
||||
- **Calendar**: grid mensuel, navigation, événements
|
||||
- **Gallery**: cartes visuelles, card_size configurable
|
||||
- **List**: vue compacte avec preview
|
||||
- **Timeline**: barres Gantt horizontales
|
||||
- **Status Overview**: Donut chart SVG
|
||||
- **Team Load**: Barres empilées
|
||||
|
||||
### Filtres & Tri
|
||||
- **Filtres cumulables** (logique AND) avec pastilles cliquables
|
||||
- **Tri multi-critères** hiérarchique
|
||||
- **Filtre par statut**: checkboxes multiples avec points colorés
|
||||
- **Filtre AI keywords**: recherche + sélection multiple
|
||||
### Sub-items & Dépendances (v1.8)
|
||||
- **Sub-items**: parent_id auto-référence, hiérarchie illimitée
|
||||
- **Status aggregate**: parent = Done si tous enfants Done
|
||||
- **Dependencies**: Blocking/Blocked by, contrainte de transition
|
||||
|
||||
### My Tasks (v1.9)
|
||||
- Dashboard cross-collection agrégeant toutes les tâches assignées
|
||||
- Vues: All, Today, Overdue, Next 7 days
|
||||
|
||||
### Multi-User & Collaboratif (v2.0)
|
||||
- **Workspaces**: espaces partagés avec rôles (admin, editor, commenter, viewer)
|
||||
- **Comments**: commentaires threadés sur les pages
|
||||
- **Page History**: historique des modifications avec snapshots
|
||||
- **Favorites**: favoris par utilisateur
|
||||
- **Templates**: database + page templates
|
||||
- **CSV Import/Export**
|
||||
- **Public Sharing**: lien de partage lecture seule
|
||||
|
||||
### API & Intégrations (v2.1)
|
||||
- **API publique REST**: `/api/v1` avec token auth
|
||||
- **Webhooks sortants**: gestion + dispatcher d'événements
|
||||
- **PWA**: manifest.json, prêt pour installation mobile
|
||||
|
||||
### UI Notion-Style (v1.1–v1.2)
|
||||
- Sidebar gauche avec sections hiérarchiques
|
||||
- Topbar avec breadcrumbs (workspace > projet > page)
|
||||
- Dark mode Notion: `#191919` fond, `#222222` sidebar
|
||||
- Éditeur de blocs: slash menu (/), navigation clavier, placeholders
|
||||
|
||||
### Intégration Gitea
|
||||
- **Issues Gitea → cartes** Notion-style
|
||||
- Sync labels, milestones, assignees, due dates
|
||||
- OAuth2 Gitea, CSRF, rate limiting
|
||||
- Webhooks pour sync temps réel
|
||||
- Issues Gitea → cartes, sync labels, milestones, assignees, due dates
|
||||
- OAuth2 Gitea, CSRF, rate limiting, webhooks
|
||||
|
||||
## Stack
|
||||
|
||||
| Couche | Techno |
|
||||
|--------|--------|
|
||||
| Frontend | HTML5 + Jinja2 + CSS Notion, Alpine.js, HTMX, SortableJS |
|
||||
| Frontend | Jinja2 + HTMX + Alpine.js + SortableJS + CSS |
|
||||
| Backend | Python 3.12 + FastAPI + httpx |
|
||||
| BDD | SQLite (WAL mode) — `/data/flowdeck.db` |
|
||||
| Déploiement | Docker (Python 3.12-slim), docker-compose |
|
||||
| BDD | SQLite (WAL, 21 tables) — `/data/flowdeck.db` |
|
||||
| Déploiement | Docker (python:3.12-slim), docker-compose |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -60,19 +82,16 @@ APP_PORT=8080
|
||||
DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
Voir [ROADMAP.md](ROADMAP.md) — v0.4.0 UI Notion → v1.0.0 Production
|
||||
|
||||
## Développement
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --port 8080
|
||||
python3 -m pytest tests/ -v # 73/73 passent
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
Voir [ROADMAP.md](ROADMAP.md) — 7/7 blocs complétés, 52/52 features ✅
|
||||
|
||||
## Licence
|
||||
|
||||
MIT
|
||||
|
||||
@@ -1,36 +1,426 @@
|
||||
# Roadmap FlowDeck — Notion Clone
|
||||
|
||||
> **Vision**: Copie conforme de l'UI/UX Notion, intégrée à Gitea.
|
||||
> **19 images de référence** dans `images/` — analysées le 2026-07-08.
|
||||
> **Vision**: Copie conforme de l'UI/UX Notion, intégrée à Gitea/GitHub.
|
||||
> **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).
|
||||
|
||||
## v0.2.0 ✅ — Base
|
||||
## 🏗️ 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
|
||||
|
||||
### v0.2.0 ✅ — Base
|
||||
Dashboard + Board Kanban + Gitea sync + Notes + Docker
|
||||
|
||||
## v0.3.0 ✅ — Auth
|
||||
### v0.3.0 ✅ — Auth
|
||||
OAuth2 Gitea, token par utilisateur, CSRF, rate limiting, webhooks
|
||||
|
||||
## v0.4.0 ✅ — UI Notion
|
||||
### v0.4.0 ✅ — UI Notion
|
||||
Layout 3 zones (sidebar 240px + topbar 44px), dark mode, CSS 31KB, Alpine.js
|
||||
|
||||
## v0.5.0 ✅ — Kanban
|
||||
### v0.5.0 ✅ — Kanban
|
||||
Colonnes, groupes, cartes enrichies, drag&drop, modale détail, checklists, commentaires
|
||||
|
||||
## v0.6.0 ✅ — Table View
|
||||
### v0.6.0 ✅ — Table View
|
||||
6 colonnes triables, groupes rétractables
|
||||
|
||||
## v0.7.0 ✅ — Filtres & Tri
|
||||
### v0.7.0 ✅ — Filtres & Tri
|
||||
Filtres cumulables AND, status dropdown, sort multi-critères
|
||||
|
||||
## v0.8.0 ✅ — Vues Spéciales
|
||||
### v0.8.0 ✅ — Vues Spéciales
|
||||
Status Overview (donut SVG), Team Load (barres), Detailed Board
|
||||
|
||||
## v0.9.0 ✅ — Backend
|
||||
### v0.9.0 ✅ — Backend
|
||||
Propriétés custom, AI keywords, sync API, 12 tables DB
|
||||
|
||||
## v1.0.0 ✅ — Production
|
||||
### v1.0.0 ✅ — Production
|
||||
- [x] FastAPI lifespan (deprecation fix)
|
||||
- [x] 28 tests — 5 vues, filtres, tris, APIs, CSRF, CORS, DB
|
||||
- [x] CI/CD Gitea Actions (`.gitea/workflows/ci.yml`)
|
||||
- [x] CHANGELOG.md, ARCHITECTURE.md à jour
|
||||
- [x] Version 1.0.0 partout (main.py, api.py, health)
|
||||
- [x] Tag git v1.0.0
|
||||
|
||||
### v1.1.0 ✅ — Pages & Sidebar
|
||||
- [x] Pages Markdown avec arbre imbriqué (parent_id, sort_order)
|
||||
- [x] Block editor mode (content_format='blocks', JSON storage)
|
||||
- [x] Soft delete → Trash → Restore → Permanent delete
|
||||
- [x] Drag & drop réorganisation pages
|
||||
- [x] Sidebar Notion-style avec sections Recents/Private/Shared
|
||||
- [x] "+ New page" footer button
|
||||
- [x] Workspace header avec avatar + chevron toggle
|
||||
|
||||
### v1.2.0 ✅ — Éditeur Notion
|
||||
- [x] Slash menu (/) 15 commandes fonctionnelles
|
||||
- [x] Navigation clavier (flèches, Enter, Escape)
|
||||
- [x] Placeholders Notion-style (Heading 1/2/3/4, Toggle, Quote, Code)
|
||||
- [x] Suppression outline blanc sur les blocs
|
||||
- [x] Heading 4 support
|
||||
- [x] Breadcrumbs contextuels (workspace > projet > page)
|
||||
|
||||
### v1.3.0 ✅ — Database Concept
|
||||
- [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
|
||||
|
||||
---
|
||||
|
||||
## 🔜 Prochaines versions
|
||||
|
||||
### v4.0.1 — Onboarding & Polish ✅ (2026-07-18)
|
||||
> **Objectif** : rendre l'app utilisable dès la première visite, sans friction. **COMPLETED**.
|
||||
|
||||
- [x] **Landing page `/` pour visiteurs non-auth** — template `landing.html`, design Notion dark
|
||||
- [x] **Page `/auth/register` GET** — formulaire d'inscription dédié (tab register actif)
|
||||
- [x] **Redirection `/` intelligente** — non-auth → landing, auth sans Gitea → local workspace, auth+Gitea → dashboard
|
||||
- [x] **Empty states** — déjà présents dans local workspace (« Welcome to your Workspace »)
|
||||
- [x] **Page 404 stylisée** — thème Notion sombre, JSON pour `/api/*`, HTML pour le reste
|
||||
- [x] **Unifier les routes API** — `/api/pages` GET/POST → 307 redirect vers `/board/api/pages`
|
||||
- [x] **Page Settings** — lien déjà présent dans le menu utilisateur (Settings → `/accounts/settings`)
|
||||
|
||||
### v4.0.2 — Qualité & Robustesse ✅ (2026-07-18)
|
||||
> **Objectif** : zéro crash, zéro regression, DX impeccable.
|
||||
|
||||
- [x] **Gestion d'erreurs** — try/catch dans create_page, message user-friendly (500 JSON)
|
||||
- [x] **Validation inputs** — login/register déjà validés, titre vide → "Untitled"
|
||||
- [x] **Rate limiting** — 100 req/min/IP déjà en place, testé OK
|
||||
- [x] **Session expiry UX** — redirect avec ?expired=1, bannière sur la page login
|
||||
- [x] **CSRF token refresh** — auto-refresh sur 403, endpoint `/api/csrf-token`
|
||||
- [x] **Mobile responsive** — sidebar slide-in, modales centrées, landing adaptative
|
||||
- [x] **Tests de non-régression** — +10 tests (landing, register, 404, validation, duplicate, expiry)
|
||||
- [x] **143 tests passent**
|
||||
|
||||
---
|
||||
|
||||
### v4.1.0 — Database: Data Sources & Linked Databases ✅ (2026-07-21)
|
||||
> **Objectif** : Collections multi-sources, databases liées (linked). **COMPLETED**.
|
||||
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 1](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
|
||||
|
||||
- [x] **Table `collection_data_sources`** — multi-sources par collection (FK `collection_id` + `source_collection_id`, flag `is_linked`)
|
||||
- [x] **API sources** — `GET /db/{id}/sources/api` (liste), `POST /db/{id}/sources/api` (ajouter), `DELETE /db/{id}/sources/{sid}/api` (retirer)
|
||||
- [x] **API linked DB** — `POST /db/{id}/linked/api` (crée une collection liée : copie vues + propriétés + data source `is_linked=1`)
|
||||
- [x] **Full-page vs Inline** — `POST /db/{id}/toggle-inline/api` (toggle), `POST /db/inline/api` (créer inline avec `parent_page_id`)
|
||||
- [x] **Règle permissions** — linked DB hérite du `workspace_id` de la source → mêmes ACLs
|
||||
- [x] **Migration** — ajout colonnes `workspace_id`, `created_by` sur `collections`
|
||||
- [x] **153 tests passent** (+10 nouveaux)
|
||||
|
||||
### v4.2.0 — Database: Templates & Dashboards ✅ (2026-07-21)
|
||||
> **Objectif** : Templates réutilisables + dashboards combinant vues/widgets. **COMPLETED**.
|
||||
|
||||
- [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)
|
||||
|
||||
### v4.3.0 — Database Views complètes (10 types) ✅ (2026-07-21)
|
||||
> **Objectif** : Tous les types de vues Notion avec layouts. **COMPLETED**.
|
||||
|
||||
- [x] **Chart** — barres, courbes, camemberts, donuts, scatter (Chart.js CDN)
|
||||
- [x] **Form** — formulaire HTML auto-généré depuis les propriétés → POST création page
|
||||
- [x] **Map** — Leaflet/OSM pour propriété Place (lat,lng parsing)
|
||||
- [x] **Feed** — vue chronologique type fil d'actualité (newest first)
|
||||
- [x] **Timeline/Gantt** — barres horizontales avec group_by et plages de dates
|
||||
- [x] **View tabs** — 11 onglets de navigation (table, board, calendar, gallery, list, timeline, gantt, chart, form, map, feed)
|
||||
- [x] **Layout options** — card_size, chart_type, group_by, place_property via config_json
|
||||
- [x] **166 tests passent** (+7 nouveaux)
|
||||
|
||||
### v4.4.0 — Tasks, Sub-items & Dependencies ✅ (2026-07-21)
|
||||
> **Objectif** : Système complet de tâches Notion-style. **COMPLETED**.
|
||||
|
||||
- [x] **Flag `is_task`** sur collections — PUT /db/{id}/toggle-task/api (Turn into Tasks)
|
||||
- [x] **Table `page_dependencies`** — bloque/bloqué par/related avec auto_shift
|
||||
- [x] **API dependencies** — GET/POST/DELETE /db/{c}/pages/{p}/dependencies/api
|
||||
- [x] **Auto-shift dates** — POST /db/{c}/pages/{p}/auto-shift/api (skip_weekends option)
|
||||
- [x] **171 tests passent** (+5 nouveaux)
|
||||
|
||||
### v4.5.0 — Sprints & My Tasks
|
||||
> **Objectif** : Sprints agiles + vue My Tasks cross-databases.
|
||||
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 5](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
|
||||
|
||||
- [ ] **Tables `sprints`, `sprint_pages`** — sprints agiles
|
||||
- [ ] **API sprints** — CRUD + assignation pages, vélocité
|
||||
- [ ] **Sprint board** — Current Sprint / Planning / Backlog
|
||||
- [ ] **Burndown chart** — vélocité, points complétés vs restants
|
||||
- [ ] **My Tasks** — agrégation cross-databases (toutes les task DBs)
|
||||
- [ ] **My Tasks API** — /my-tasks?view=all|today|overdue|upcoming
|
||||
- [ ] **My Tasks UI** — groupes par collection, status badges, filtres
|
||||
|
||||
### v4.6.0 — Content Blocks enrichis
|
||||
> **Objectif** : parité d'édition avec Notion.
|
||||
|
||||
- [ ] **Callout blocks** — boîtes colorées (info, warning, tip, success)
|
||||
- [ ] **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)
|
||||
|
||||
### v4.7.0 — Export
|
||||
- [ ] **Export Markdown** — avec images et sous-pages (déjà partiel dans editor)
|
||||
- [ ] **Export PDF** — mise en page fidèle, table des matières
|
||||
- [ ] **Export HTML** — site statique standalone
|
||||
|
||||
### v4.8.0 — Collaboration
|
||||
- [ ] **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
|
||||
|
||||
```
|
||||
v0.x–v1.x ✅ v2.x ✅ v3.0.0 ✅ v4.0.0 ✅ v4.0.1 ✅
|
||||
Base + Kanban Éditeur + Gitea UX Pro MVP Onboarding
|
||||
+ DB + Auth + Workspaces + Multi-select Accounts & Polish
|
||||
+ UI Notion + Tags + Admin + Sharing COMPLETED
|
||||
+ GitHub OAuth + Library
|
||||
|
||||
v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4.0 ✅ v4.5.0 ⬜ v4.6.0 ⬜ v4.7.0 ⬜ v4.8–4.9 ⬜ v5.x–v6.0 ⬜
|
||||
Quality Data Sources Templates + 10 Views Tasks & Sprints & Content Export Collab + Pro + Agent
|
||||
& Tests & Linked DB Dashboards complets Dependencies My Tasks Blocks PDF/MD Agent IA (futur)
|
||||
```
|
||||
|
||||
*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
|
||||
|
||||
> **Début**: 2026-07-08 | **Objectif**: Clone Notion intégré Gitea
|
||||
> **Début**: 2026-07-08 | **Version**: v2.2.0 | **Statut**: EN COURS 🔄
|
||||
> **Cible v3.0**: Multi-User, Multi-Forge (Gitea/GitHub), Standalone
|
||||
|
||||
## Avancement Global
|
||||
|
||||
| Phase | Description | Statut | Progression |
|
||||
|-------|-------------|--------|-------------|
|
||||
| Phase 1 | Documents & Planification | ✅ | 100% |
|
||||
| Phase 2 | Layout Notion (sidebar + topbar + contenu) | ✅ | 100% |
|
||||
| Phase 3 | Sidebar (sections, hover, context menu) | ✅ | 100% |
|
||||
| Phase 4 | Vues (Kanban, Table, Status, Team Load) | 🚧 | 40% |
|
||||
| Phase 5 | Filtres & Tri | 🚧 | 25% |
|
||||
| Phase 6 | Cartes enrichies | 🚧 | 30% |
|
||||
| Phase 7 | Intégration Gitea complète | 🚧 | 15% |
|
||||
| Phase 8 | Polish & Tests | ⬜ | 0% |
|
||||
| Version | Description | Statut | Tests |
|
||||
|---------|-------------|--------|-------|
|
||||
| v0.2–v0.9 | Base → Backend | ✅ | — |
|
||||
| v1.0 | Production (lifespan, CI/CD) | ✅ | 28/28 |
|
||||
| v1.1 | Pages & Sidebar Notion | ✅ | — |
|
||||
| v1.2 | Éditeur Notion (slash menu, blocs) | ✅ | — |
|
||||
| v1.3 | Database Concept (collections) | ✅ | 34/34 |
|
||||
| v1.4 | Propriétés Avancées (21 types) | ✅ | 38/38 |
|
||||
| v1.5 | Relations, Rollups, Formulas | ✅ | 43/43 |
|
||||
| v1.6 | Vues (Calendar, Gallery, List, Timeline) | ✅ | 49/49 |
|
||||
| v1.7 | Vues Améliorées (view config, save-as) | ✅ | — |
|
||||
| v1.8 | Sub-items & Dépendances | ✅ | 56/56 |
|
||||
| v1.9 | My Tasks Dashboard | ✅ | 60/60 |
|
||||
| v2.0 | Multi-User + Editor Complete | ✅ | 67/67 |
|
||||
| v2.1 | Public API, Webhooks, PWA | ✅ | 73/73 |
|
||||
| v2.2 | Share/Publish, Favorites, Library | ✅ | 73/73 |
|
||||
| v3.0 | **Auth locale, Multi-Forge, Standalone** | 🔲 | — |
|
||||
|
||||
---
|
||||
## Blocs Complétés
|
||||
|
||||
## Phase 1: Documents & Planification
|
||||
| Bloc | Features | Statut |
|
||||
|------|----------|--------|
|
||||
| Bloc 1 | Database Concept | ✅ 6/6 |
|
||||
| Bloc 2 | Propriétés Avancées | ✅ 14/14 |
|
||||
| Bloc 3 | Vues Manquantes | ✅ 10/10 |
|
||||
| Bloc 4 | Sub-items & Dépendances | ✅ 7/7 |
|
||||
| Bloc 5 | My Tasks | ✅ 7/7 |
|
||||
| Bloc 6 | Éditeur Complet | ✅ 8/8 |
|
||||
| Bloc 7 | Fonctions Transversales | ✅ |
|
||||
|
||||
### 1.1 Analyse des images Notion
|
||||
- [x] Pull des 19 images de référence
|
||||
- [x] Analyse du layout complet Notion (~1400px, dark mode)
|
||||
- [x] Analyse Kanban board 1 (colonnes + cartes + groupes Design/Engineering)
|
||||
- [x] Analyse Detailed board (cartes avec propriétés visibles)
|
||||
- [x] Analyse Table view (colonnes Name, Status, Assign, Deadline, Team, AI keywords)
|
||||
- [x] Analyse Search & Filter (barre de recherche, panneau filtres)
|
||||
- [x] Analyse Panel Filter (liste des propriétés filtrables)
|
||||
- [x] Analyse Status Panel Filter (checkboxes multiples + Clear selection)
|
||||
- [x] Analyse Sort Panel 1 (simple: propriété + ascendant/descendant)
|
||||
- [x] Analyse Sort Panel 2 (multi-critères: 3 sorts hiérarchiques)
|
||||
- [x] Analyse Filters Cumulables (AND, pastilles, + Filter, Reset)
|
||||
- [x] Analyse AI Keywords Filter (recherche + tags colorés)
|
||||
- [x] Analyse Status Overview (donut chart 18 total, segments, légende)
|
||||
- [x] Analyse Team Load (barres empilées par avatar)
|
||||
- [x] Analyse Sidebar Layout (sections: Home, Recents, Favorites, Library)
|
||||
- [x] Analyse Sidebar Context Menu (right-click: Copy link, Duplicate, Rename, Move to Trash...)
|
||||
- [x] Analyse Sidebar Mouse Over Elements (hover: fond #333, boutons ... et +)
|
||||
- [x] Analyse Sidebar Mouse Over Section (tooltip "Open in Library", icônes)
|
||||
- [x] Analyse Kanban Board 2 (structure groupes + colonnes)
|
||||
**Total: 52/52 features (100%)**
|
||||
|
||||
### 1.2 Documents
|
||||
- [x] ROADMAP.md mis à jour avec vision v0.4 → v1.0
|
||||
- [x] WORKLOAD.md créé
|
||||
- [ ] README.md mis à jour
|
||||
- [ ] ARCHITECTURE.md mis à jour
|
||||
- [ ] CHANGELOG.md (après premier commit de code)
|
||||
## Architecture
|
||||
|
||||
---
|
||||
### Tables (21)
|
||||
`users`, `user_tokens`, `boards`, `cards`, `notes`, `col_mapping`, `checklists`, `checklist_items`, `project_properties`, `property_values`, `ai_keywords`, `pages`, `collections`, `collection_pages`, `collection_views`, `collection_properties`, `workspaces`, `workspace_members`, `comments`, `page_history`, `favorites`, `database_templates`, `page_templates`, `webhook_subscriptions`
|
||||
|
||||
## Phase 2: Layout Notion
|
||||
### Routeurs (11)
|
||||
`dashboard`, `board`, `notes`, `api`, `auth`, `webhooks`, `collections`, `my_tasks`, `workspace`, `public_api` + app-level routes
|
||||
|
||||
### 2.1 Structure HTML
|
||||
- [x] `base.html`: layout 3 zones (sidebar fixe gauche, topbar fixe haut, contenu scrollable)
|
||||
- [x] Fond global: `#191919`
|
||||
- [x] Police Inter importée via Google Fonts
|
||||
### Services (6)
|
||||
`GiteaClient`, `FormulaEngine`, `RollupEngine`, `GiteaBoardCompat`, `property_types`, `webhook_outbound`
|
||||
|
||||
### 2.2 Sidebar (gauche, ~240px, fond #222)
|
||||
- [x] Workspace info (avatar B + nom Bruno)
|
||||
- [x] Boutons Home, Search, Updates, Settings
|
||||
- [x] Section Recents (avec indentation sous-pages)
|
||||
- [x] Section Favorites (Agents, Shared, Private)
|
||||
- [x] Section Notion apps (Calendar, Desktop)
|
||||
- [x] Section Library (My Tasks, Marketplace, Help, Trash)
|
||||
- [x] Footer: Invite members, New chat Ctrl+O, bouton édition
|
||||
### Endpoints (85+)
|
||||
CRUD collections/pages, 5 vues HTML, relations/rollups/formulas, sub-items/dependencies, my-tasks, workspaces, comments, history, favorites, templates, CSV import/export, public sharing, public API v1, webhooks, PWA manifest
|
||||
|
||||
### 2.3 Topbar (haut, fond #191919)
|
||||
- [x] Logo workspace + nom à gauche
|
||||
- [x] Titre page + statut (Private/Shared) au centre
|
||||
- [x] Icônes: notifications, historique, recherche
|
||||
- [x] Boutons: Share, favori, menu (...)
|
||||
## Stack
|
||||
|
||||
### 2.4 Content Area (fond #191919)
|
||||
- [x] Image de couverture optionnelle
|
||||
- [x] Titre de page (28-32px bold)
|
||||
- [x] Barre de navigation des vues (onglets)
|
||||
- [x] Barre d'outils (filtres, tri, recherche, New)
|
||||
- [x] Zone de contenu principale (Kanban / Table / Graphiques)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: CSS — Design System
|
||||
|
||||
### 3.1 Variables CSS
|
||||
```css
|
||||
:root {
|
||||
--bg-primary: #191919;
|
||||
--bg-secondary: #222222;
|
||||
--bg-tertiary: #333333;
|
||||
--bg-hover: #2F2E2E;
|
||||
--text-primary: #FFFFFF;
|
||||
--text-secondary: #A0A0A0;
|
||||
--color-blue: #3366CC;
|
||||
--color-green: #00CC66;
|
||||
--color-yellow: #FFD700;
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Composants
|
||||
- [x] `.sidebar` — 240px fixe, fond #222
|
||||
- [x] `.sidebar-section` — titre 12px + liste
|
||||
- [x] `.sidebar-item` — 14px, padding 8px 12px, hover #333
|
||||
- [x] `.sidebar-item.active` — fond #333
|
||||
- [x] `.topbar` — hauteur 44px, flex
|
||||
- [x] `.view-tabs` — onglets horizontaux
|
||||
- [x] `.view-tab` — inactif: texte gris, actif: fond #333 + texte blanc
|
||||
- [x] `.kanban-board` — flex horizontal, gap 12px
|
||||
- [x] `.kanban-column` — min-width 260px, arrondi 6px
|
||||
- [x] `.kanban-card` — fond selon statut, padding 12px, arrondi 6px
|
||||
- [x] `.btn-primary` — bleu #3366CC
|
||||
- [x] `.btn-secondary` — gris #333333
|
||||
- [x] `.filter-pill` — pastille avec texte + croix de suppression
|
||||
- [x] `.sort-panel` — menu déroulant avec options
|
||||
- [x] `.dropdown-menu` — fond #333, bordure subtile, ombre
|
||||
- [x] `.tooltip` — fond #3A3A3A, texte blanc
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: JavaScript (Alpine.js)
|
||||
|
||||
### 4.1 Sidebar
|
||||
- [x] Toggle collapse/expand sidebar
|
||||
- [x] Hover reveal buttons (... et +)
|
||||
- [x] Context menu au clic droit
|
||||
- [ ] Drag & drop pour réorganiser les favoris
|
||||
|
||||
### 4.2 Vues
|
||||
- [x] Switch entre vues (Kanban, Table, Status, Team Load, Detailed)
|
||||
- [x] Kanban: drag & drop cartes (SortableJS)
|
||||
- [ ] Table: tri par colonne, redimensionnement
|
||||
- [ ] Status Overview: Donut chart (SVG ou Canvas)
|
||||
- [ ] Team Load: Barres empilées
|
||||
|
||||
### 4.3 Filtres & Tri
|
||||
- [x] Ajout/suppression filtres (UI)
|
||||
- [ ] Logique AND entre filtres
|
||||
- [ ] Tri multi-niveaux
|
||||
- [ ] Persistance dans l'URL ou localStorage
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Backend (FastAPI)
|
||||
|
||||
### 5.1 Nouvelles routes API
|
||||
- [ ] `GET /api/views/{owner}/{repo}` — config des vues
|
||||
- [ ] `POST /api/filters` — appliquer filtres
|
||||
- [ ] `POST /api/sorts` — appliquer tris
|
||||
- [ ] `GET /api/status-overview/{owner}/{repo}` — données donut chart
|
||||
- [ ] `GET /api/team-load/{owner}/{repo}` — données barres empilées
|
||||
- [ ] `GET /api/table-view/{owner}/{repo}` — données format tableau
|
||||
|
||||
### 5.2 Base de données (nouvelles tables)
|
||||
- [ ] `view_config` — configuration par vue
|
||||
- [ ] `filters` — filtres sauvegardés par utilisateur
|
||||
- [ ] `sorts` — tris sauvegardés
|
||||
- [ ] `card_properties` — propriétés custom par carte
|
||||
|
||||
### 5.3 Gitea Sync
|
||||
- [x] Labels → tags couleur sur cartes
|
||||
- [ ] Milestones → groupes/sprints
|
||||
- [x] Assignees → avatars + noms
|
||||
- [x] Due dates → échéances (via Gitea API)
|
||||
- [ ] Comments → affichage dans cartes
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Couleurs exactes** extraites des analyses: fond `#191919`, sidebar `#222222`, actif `#333333`, hover `#2F2E2E`, texte `#FFFFFF`, secondaire `#A0A0A0`
|
||||
- **Police**: Inter (Google Fonts), tailles: 12px (petit), 14px (normal), 28-32px (titres)
|
||||
- **Coins arrondis**: 4px (boutons/cartes), 6px (colonnes)
|
||||
- **Espacements**: padding 8-12px, gap 8-12px
|
||||
- **Icônes**: utiliser des émojis ou SVG inline pour éviter dépendance externe
|
||||
- **Backend**: Python 3.12 + FastAPI + httpx
|
||||
- **Frontend**: Jinja2 + HTMX + Alpine.js + SortableJS + CSS 31KB
|
||||
- **BDD**: SQLite WAL mode, 21 tables, foreign keys ON
|
||||
- **Auth**: OAuth2 Gitea + sessions signed (itsdangerous) + token API
|
||||
- **Déploiement**: Docker (python:3.12-slim), docker-compose, port 8080
|
||||
- **Tests**: pytest, 73 tests, TestClient avec SQLite temporaire
|
||||
- **CI/CD**: Gitea Actions (.gitea/workflows/ci.yml)
|
||||
|
||||
@@ -0,0 +1,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_url: str = "https://git.dracodev.net"
|
||||
gitea_token: str = "change-me"
|
||||
gitea_oauth_client_id: str = ""
|
||||
gitea_oauth_client_secret: str = ""
|
||||
gitea_oauth_client_id: str = "test-id"
|
||||
gitea_oauth_client_secret: str = "test-secret"
|
||||
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
|
||||
oauth_redirect_uri: str = "http://localhost:8080/auth/callback"
|
||||
|
||||
@@ -46,7 +53,12 @@ class Settings(BaseSettings):
|
||||
if self.database_url == "sqlite:///:memory:":
|
||||
return Path(":memory:") # Special SQLite in-memory
|
||||
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")
|
||||
|
||||
|
||||
|
||||
@@ -27,10 +27,24 @@ def init_db():
|
||||
full_name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
is_admin BOOLEAN NOT NULL DEFAULT 0,
|
||||
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
|
||||
);
|
||||
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
gitea_user_id INTEGER NOT NULL UNIQUE,
|
||||
@@ -39,6 +53,19 @@ def init_db():
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_oauth_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
provider TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT,
|
||||
expires_at TIMESTAMP,
|
||||
instance_url TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, provider)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS boards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_owner TEXT NOT NULL,
|
||||
@@ -105,8 +132,8 @@ def init_db():
|
||||
project_owner TEXT NOT NULL,
|
||||
project_name TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
prop_type TEXT NOT NULL DEFAULT 'select', -- select, multi_select, date, person, text
|
||||
options_json TEXT DEFAULT '[]', -- for select/multi_select
|
||||
prop_type TEXT NOT NULL DEFAULT 'select',
|
||||
options_json TEXT DEFAULT '[]',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(project_owner, project_name, name)
|
||||
@@ -130,6 +157,408 @@ def init_db():
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(project_owner, project_name, keyword)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace TEXT NOT NULL,
|
||||
workspace_id INTEGER REFERENCES workspaces(id),
|
||||
title TEXT NOT NULL DEFAULT 'New page',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
parent_section TEXT DEFAULT 'Private',
|
||||
parent_id INTEGER REFERENCES pages(id),
|
||||
share_mode TEXT DEFAULT 'private',
|
||||
published INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- v1.3.0: Database Concept — Notion-style collections
|
||||
CREATE TABLE IF NOT EXISTS collections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
icon TEXT DEFAULT '📋',
|
||||
schema_json TEXT NOT NULL DEFAULT '[]',
|
||||
gitea_owner TEXT,
|
||||
gitea_repo TEXT,
|
||||
is_locked BOOLEAN NOT NULL DEFAULT 0,
|
||||
is_inline BOOLEAN NOT NULL DEFAULT 0,
|
||||
parent_page_id INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT DEFAULT 'file',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
parent_id INTEGER REFERENCES collection_pages(id),
|
||||
gitea_issue_id INTEGER,
|
||||
gitea_issue_number INTEGER,
|
||||
property_values_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cp_collection ON collection_pages(collection_id, position);
|
||||
CREATE INDEX IF NOT EXISTS idx_cp_parent ON collection_pages(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cp_gitea ON collection_pages(gitea_issue_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_views (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT 'Default View',
|
||||
view_type TEXT NOT NULL DEFAULT 'table',
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- v1.4.0: Propriétés Avancées — collection_properties (remplace project_properties)
|
||||
CREATE TABLE IF NOT EXISTS collection_properties (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
prop_type TEXT NOT NULL DEFAULT 'text',
|
||||
options_json TEXT DEFAULT '[]',
|
||||
number_format TEXT DEFAULT 'number',
|
||||
related_collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL,
|
||||
reverse_name TEXT,
|
||||
relation_property_id INTEGER REFERENCES collection_properties(id) ON DELETE SET NULL,
|
||||
target_property_id INTEGER REFERENCES collection_properties(id) ON DELETE SET NULL,
|
||||
rollup_function TEXT,
|
||||
formula_expression TEXT,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
required BOOLEAN NOT NULL DEFAULT 0,
|
||||
visible_in_views BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(collection_id, name)
|
||||
);
|
||||
|
||||
-- v2.0.0: Multi-user — workspaces, members, comments, history, favorites, templates
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||
settings_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
role TEXT NOT NULL DEFAULT 'editor',
|
||||
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(workspace_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
parent_id INTEGER REFERENCES comments(id),
|
||||
resolved BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS page_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
change_type TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- v2.2.0: Migrate favorites — drop old schema referencing collection_pages
|
||||
DROP TABLE IF EXISTS favorites;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id),
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, page_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS database_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
schema_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS page_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT 'Default',
|
||||
property_values_json TEXT NOT NULL DEFAULT '{}',
|
||||
content_json TEXT DEFAULT '[]',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 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+)
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN parent_id INTEGER REFERENCES pages(id)")
|
||||
except sqlite3.OperationalError:
|
||||
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+)
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Migration: add content_format for Notion-style block editor (v1.2.0+)
|
||||
# 'markdown' = legacy textarea, 'blocks' = JSON array of block objects
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN content_format TEXT NOT NULL DEFAULT 'markdown'")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Migration: add deleted_at for trash/soft-delete
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'private'")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN published INTEGER DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# v2.2: Auth locale
|
||||
for col in ["password_hash", "is_active", "last_login", "login_attempts", "locked_until"]:
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE users ADD COLUMN {col} {'TEXT' if col in ('password_hash','last_login','locked_until') else 'INTEGER NOT NULL DEFAULT ' + ('1' if col=='is_active' else '0')}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# 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()
|
||||
|
||||
# v4.4.0: Tasks & Dependencies
|
||||
# Add is_task flag to collections
|
||||
try:
|
||||
conn.execute("ALTER TABLE collections ADD COLUMN is_task BOOLEAN NOT NULL DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
# Dependencies table: bloque/bloqué par with auto-shift config
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS page_dependencies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||
dependency_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||
dependency_type TEXT NOT NULL DEFAULT 'blocks',
|
||||
auto_shift TEXT DEFAULT 'overlap',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(page_id, dependency_id, dependency_type)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# v4.5.0: Sprints
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sprints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
goal TEXT DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'planning',
|
||||
auto_complete BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sprint_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sprint_id INTEGER NOT NULL REFERENCES sprints(id) ON DELETE CASCADE,
|
||||
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||
status_at_start TEXT DEFAULT '',
|
||||
velocity_points INTEGER DEFAULT 1,
|
||||
UNIQUE(sprint_id, page_id)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
@@ -12,8 +12,12 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||
from app.config import settings
|
||||
from app.db import init_db
|
||||
from app.middleware.csrf import CSRFMiddleware
|
||||
from app.routers import dashboard, board, notes, api, auth, webhooks
|
||||
from app.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.webhook_outbound import init_webhook_tables
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
||||
@@ -25,19 +29,23 @@ logger = logging.getLogger(__name__)
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
init_db()
|
||||
init_webhook_tables()
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
admin_hash = hash_password("FlowDeck2026!")
|
||||
with get_conn() as conn:
|
||||
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()
|
||||
logger.info("FlowDeck v1.0.0 started on port %d", settings.app_port)
|
||||
logger.info("FlowDeck v4.0.0 started on port %d", settings.app_port)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="1.0.0",
|
||||
version="4.0.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
@@ -45,6 +53,8 @@ app = FastAPI(
|
||||
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.app_secret_key, max_age=3600)
|
||||
app.add_middleware(CSRFMiddleware)
|
||||
app.add_middleware(ContentSecurityPolicyMiddleware)
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
app.include_router(auth.router)
|
||||
@@ -53,5 +63,99 @@ app.include_router(board.router)
|
||||
app.include_router(notes.router)
|
||||
app.include_router(api.router)
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(collections.router)
|
||||
app.include_router(my_tasks.router)
|
||||
app.include_router(workspace.router)
|
||||
app.include_router(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.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,11 +16,11 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/auth/callback"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token"}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Webhook receiver and OAuth callback are exempt
|
||||
if request.url.path in self.EXCLUDED_PATHS:
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
if any(request.url.path.startswith(p) for p in self.EXCLUDED_PATHS):
|
||||
return await call_next(request)
|
||||
|
||||
if request.method in self.SAFE_METHODS:
|
||||
@@ -33,6 +33,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
httponly=False, # Must be readable by JS
|
||||
samesite="lax",
|
||||
max_age=86400,
|
||||
path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@@ -0,0 +1,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]}
|
||||
@@ -13,6 +13,7 @@ from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.routers.board import _issue_column, _map_issue_to_card, STATUS_COLORS, STATUS_LABELS
|
||||
from app.services.gitea_client import gitea
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api"], prefix="/api")
|
||||
@@ -38,7 +39,7 @@ def _check_rate_limit(request: Request) -> bool:
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
async def health(request: Request):
|
||||
"""Health check: DB + Gitea connectivity."""
|
||||
db_ok = False
|
||||
gitea_ok = False
|
||||
@@ -56,7 +57,7 @@ async def health():
|
||||
|
||||
return {
|
||||
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
||||
"version": "1.0.0",
|
||||
"version": request.app.version,
|
||||
"db": db_ok,
|
||||
"gitea": gitea_ok,
|
||||
}
|
||||
@@ -538,6 +539,40 @@ async def delete_checklist(checklist_id: int):
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── v1.0.0: User management ──
|
||||
|
||||
@router.get("/users/me")
|
||||
async def get_my_profile(request: Request):
|
||||
"""Get current user profile."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return {"login": "guest", "full_name": "Guest", "email": ""}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM users WHERE login=?", (user.get("login", ""),)
|
||||
).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
return {"login": user.get("login", ""), "full_name": "", "email": ""}
|
||||
|
||||
|
||||
@router.put("/users/me")
|
||||
async def update_my_profile(request: Request, full_name: str = Query(default=""),
|
||||
email: str = Query(default="")):
|
||||
"""Update current user's local profile."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
login = user.get("login", "")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET full_name=?, email=? WHERE login=?",
|
||||
(full_name, email, login),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── v0.5.0: Card priority & due date ──
|
||||
|
||||
@router.post("/card/{owner}/{repo}/{issue_id}")
|
||||
@@ -588,3 +623,55 @@ async def get_collaborators(owner: str, repo: str):
|
||||
return {"collaborators": collaborators}
|
||||
except Exception as 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 app.auth.session import SessionManager
|
||||
from app.auth.oauth import gitea_oauth
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["auth"], prefix="/auth")
|
||||
|
||||
LOCAL_LOGIN_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FlowDeck — Login</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;}
|
||||
.login-box{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;width:100%;max-width:400px;}
|
||||
.login-box h1{font-size:24px;margin-bottom:8px;}
|
||||
.login-box p{color:rgba(255,255,255,.5);font-size:14px;margin-bottom:24px;}
|
||||
.form-group{margin-bottom:16px;}
|
||||
.form-group label{display:block;font-size:13px;color:rgba(255,255,255,.6);margin-bottom:6px;}
|
||||
.form-group input{width:100%;padding:10px 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")
|
||||
async def login(request: Request):
|
||||
"""Redirect to Gitea OAuth2 authorize page."""
|
||||
if not gitea_oauth.enabled:
|
||||
# Fallback: use global token, create a fake session
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone()
|
||||
if not user:
|
||||
conn.execute("INSERT INTO users (login, full_name, email, avatar_url) VALUES ('admin', 'Admin', '', '')")
|
||||
conn.commit()
|
||||
user = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone()
|
||||
user_data = dict(user)
|
||||
session = SessionManager.create_session(user_data)
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
|
||||
return response
|
||||
async def login(request: Request, provider: str = Query("gitea")):
|
||||
"""Redirect to OAuth2 authorize page or show local login page."""
|
||||
# Local login page (POST handled by /auth/local-login)
|
||||
from fastapi.responses import HTMLResponse
|
||||
if provider == "local":
|
||||
return HTMLResponse(LOCAL_LOGIN_HTML, status_code=200)
|
||||
|
||||
# OAuth flow — check if provider is configured
|
||||
from app.auth.providers import get_provider
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
# OAuth provider not configured — show error instead of auto-creating admin
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html><head><title>FlowDeck</title><style>
|
||||
body{{background:#191919;color:#fff;font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center;}}
|
||||
.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)
|
||||
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)
|
||||
|
||||
|
||||
@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")
|
||||
async def callback(
|
||||
request: Request,
|
||||
@@ -46,13 +260,32 @@ async def callback(
|
||||
state: str = Query(...),
|
||||
):
|
||||
"""Handle OAuth2 callback from Gitea."""
|
||||
# Validate state
|
||||
# Recover mode from state suffix (state:mode format), then validate
|
||||
expected_state = request.session.get("oauth_state", "")
|
||||
if not expected_state or state != expected_state:
|
||||
return HTMLResponse("<h1>Invalid state</h1>", status_code=400)
|
||||
provider_name = request.session.get("oauth_provider", "gitea")
|
||||
|
||||
# Exchange code for token
|
||||
token_data = await gitea_oauth.exchange_code(code)
|
||||
auth_mode = ""
|
||||
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:
|
||||
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)
|
||||
|
||||
# Get user info
|
||||
user = await gitea_oauth.get_user(access_token)
|
||||
if not user:
|
||||
oauth_user = await oauth_provider.get_user(access_token)
|
||||
if not oauth_user:
|
||||
return HTMLResponse("<h1>Failed to get user</h1>", status_code=400)
|
||||
|
||||
# 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
|
||||
from app.db import get_conn
|
||||
login_id = f"{provider_name}_{oauth_user['login']}"
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO users (login, full_name, email, avatar_url)
|
||||
VALUES (?, ?, ?, ?)
|
||||
"""INSERT INTO users (login, full_name, email, avatar_url, auth_method)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(login)
|
||||
DO UPDATE SET full_name=excluded.full_name, email=excluded.email, avatar_url=excluded.avatar_url""",
|
||||
(user["login"], user.get("full_name", ""), user.get("email", ""), user.get("avatar_url", "")),
|
||||
DO UPDATE SET full_name=excluded.full_name, email=excluded.email, avatar_url=excluded.avatar_url, auth_method=excluded.auth_method""",
|
||||
(login_id, oauth_user.get("full_name", ""), oauth_user.get("email", ""), oauth_user.get("avatar_url", ""), provider_name),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Store token
|
||||
SessionManager.store_token(user["id"], access_token)
|
||||
|
||||
# Also store user in local DB
|
||||
with get_conn() as conn:
|
||||
db_user = conn.execute("SELECT * FROM users WHERE login=?", (user["login"],)).fetchone()
|
||||
user_data = dict(db_user) if db_user else user
|
||||
# Store OAuth token
|
||||
uid = conn.execute("SELECT id FROM users WHERE login=?", (login_id,)).fetchone()
|
||||
if uid:
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO user_oauth_tokens
|
||||
(user_id, provider, access_token, refresh_token, expires_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""",
|
||||
(uid["id"], provider_name, access_token, token_data.get("refresh_token"), token_data.get("expires_at")),
|
||||
)
|
||||
conn.commit()
|
||||
user = conn.execute("SELECT * FROM users WHERE id=?", (uid["id"],)).fetchone()
|
||||
else:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (login_id,)).fetchone()
|
||||
user_data = dict(user) if user else oauth_user
|
||||
|
||||
# Create session
|
||||
session = SessionManager.create_session(user_data)
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
|
||||
_log_login(user_data["id"], request)
|
||||
response = RedirectResponse(url="/workspaces", status_code=302)
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout():
|
||||
"""Clear session and redirect to dashboard."""
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
"""Clear session and redirect to login page."""
|
||||
response = RedirectResponse(url="/auth/login?provider=local", status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
|
||||
@@ -108,3 +367,19 @@ async def current_user(request: Request):
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
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
|
||||
|
||||
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.services.gitea_client import gitea
|
||||
from app.auth.session import SessionManager
|
||||
from app.routers.dashboard import _get_app_version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
@@ -62,7 +63,7 @@ def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict:
|
||||
"bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄",
|
||||
"design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒",
|
||||
}
|
||||
icon = "📄"
|
||||
icon = "file"
|
||||
for lbl in labels:
|
||||
for kw, emoji in icon_map.items():
|
||||
if kw in lbl.get("name", "").lower():
|
||||
@@ -115,18 +116,290 @@ def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _sidebar_data(request: Request, owner: str, repo: str) -> dict:
|
||||
def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, max_depth: int = 3) -> list[dict]:
|
||||
"""Build nested page tree recursively. max_depth prevents infinite recursion."""
|
||||
if depth >= max_depth:
|
||||
return []
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL ORDER BY sort_order ASC, updated_at DESC",
|
||||
(ws_key, parent_id),
|
||||
).fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
children = _build_page_tree(conn, row["id"], ws_key, depth + 1, max_depth)
|
||||
items.append({
|
||||
"id": f"page/{row['id']}",
|
||||
"db_id": row["id"],
|
||||
"name": row["title"] or "New page",
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{row['id']}",
|
||||
"active": False,
|
||||
"depth": depth,
|
||||
"has_children": len(children) > 0,
|
||||
"children": children,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def _file_icon(name: str, content_format: str = "") -> str:
|
||||
"""Map file extension to 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:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws = user.get("login", "Bruno") if user else "Bruno"
|
||||
recent = [{"id": f"{owner}/{repo}", "name": repo, "icon": "📁",
|
||||
"url": f"/board/{owner}/{repo}", "active": True, "indent": 0}]
|
||||
for sv in ["Detailed board", "Table view", "Status overview", "Team Load"]:
|
||||
recent.append({"id": f"{owner}/{repo}/{sv.lower().replace(' ', '-')}",
|
||||
"name": sv, "icon": "·", "url": f"/board/{owner}/{repo}",
|
||||
"active": False, "indent": 1})
|
||||
return {"workspace_name": ws, "workspace_initial": ws[0].upper() if ws else "B",
|
||||
"current_page": repo, "last_edited": "2m ago",
|
||||
"recent_pages": recent, "private_pages": recent[:1], "user": user}
|
||||
ws_name = user.get("login", "Bruno") if user else "Bruno"
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ws_name # workspace = project context
|
||||
|
||||
# Active workspace name from cookie (for local workspace display)
|
||||
from app.routers.dashboard import WORKSPACE_COOKIE
|
||||
ws_cookie = request.cookies.get(WORKSPACE_COOKIE, "")
|
||||
active_ws_name = "Workspace"
|
||||
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 = []
|
||||
if owner and repo:
|
||||
view_map = {
|
||||
"Kanban board": "kanban", "Detailed board": "detailed",
|
||||
"Table view": "table", "Status overview": "status", "Team Load": "teamload",
|
||||
}
|
||||
first = True
|
||||
for label, view in view_map.items():
|
||||
indent = 0 if first else 1
|
||||
active = first
|
||||
recent.append({
|
||||
"id": f"{owner}/{repo}/{view}",
|
||||
"name": label, "icon": "folder" if first else "",
|
||||
"url": f"/board/{owner}/{repo}?view={view}",
|
||||
"active": active, "indent": indent,
|
||||
"depth": indent, "has_children": False, "children": [],
|
||||
})
|
||||
first = False
|
||||
# Load pages as nested tree for this project workspace
|
||||
with get_conn() as conn:
|
||||
tree_pages = _build_page_tree(conn, None, ws_key)
|
||||
for p in tree_pages:
|
||||
recent.append(p)
|
||||
# Private pages: same as recent but filtered for page/ items (non-board views)
|
||||
private_items = [r for r in recent if r.get("active") or r["id"].startswith("page/")]
|
||||
|
||||
# Load favorite pages from DB
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
fav_rows = conn.execute(
|
||||
"SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f "
|
||||
"JOIN pages p ON p.id = f.page_id "
|
||||
"WHERE f.user_id=? ORDER BY f.position", (uid,)
|
||||
).fetchall()
|
||||
favorites = []
|
||||
for r in fav_rows:
|
||||
favorites.append({
|
||||
"id": f"page/{r['id']}",
|
||||
"db_id": r["id"],
|
||||
"name": r["title"] or "New page",
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{r['id']}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
})
|
||||
|
||||
# Load shared pages (anyone with link or published)
|
||||
with get_conn() as conn:
|
||||
shared_rows = conn.execute(
|
||||
"SELECT id, title, workspace, share_mode, published FROM pages "
|
||||
"WHERE share_mode='anyone' OR published=1 ORDER BY updated_at DESC LIMIT 20"
|
||||
).fetchall()
|
||||
shared_pages = []
|
||||
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",
|
||||
"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",
|
||||
"recent_pages": recent, "private_pages": private_items,
|
||||
"favorite_pages": favorites, "shared_pages": shared_pages,
|
||||
"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 = ""):
|
||||
@@ -225,15 +498,155 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
||||
return cards
|
||||
|
||||
|
||||
# ═══════════ Library page ═══════════
|
||||
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
# Load all pages for the workspace from DB
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
with get_conn() as conn:
|
||||
if ws_key:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"WHERE workspace=? ORDER BY updated_at DESC",
|
||||
(ws_key,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"ORDER BY updated_at DESC",
|
||||
).fetchall()
|
||||
all_pages = []
|
||||
for r in rows:
|
||||
page = dict(r)
|
||||
all_pages.append({
|
||||
"id": f"page/{page['id']}",
|
||||
"name": page["title"] or "Untitled",
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{page['id']}",
|
||||
"created_by": "You",
|
||||
"source": page.get("workspace") or "Private",
|
||||
"last_edited": page.get("updated_at", "now"),
|
||||
"last_visited": page.get("updated_at", "now"),
|
||||
})
|
||||
sidebar["recent_pages"] = all_pages
|
||||
sidebar["favorite_pages"] = []
|
||||
sidebar["private_pages"] = [p for p in all_pages if p.get("source") == "🔒 Private"]
|
||||
sidebar["shared_pages"] = []
|
||||
template = env.get_template("library.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
# ═══════════ Favorites API ═══════════
|
||||
|
||||
@router.get("/api/favorites")
|
||||
async def list_favorites(request: Request):
|
||||
"""List favorited page IDs for the current user."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT page_id FROM favorites WHERE user_id=? ORDER BY position", (uid,)
|
||||
).fetchall()
|
||||
return {"favorites": [r["page_id"] for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/favorites/{page_id:int}")
|
||||
async def add_favorite(request: Request, page_id: int):
|
||||
"""Add a page to favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM favorites WHERE user_id=?", (uid,)
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO favorites (user_id, page_id, position) VALUES (?,?,?)",
|
||||
(uid, page_id, pos),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "added", "page_id": page_id}
|
||||
|
||||
|
||||
@router.delete("/api/favorites/{page_id:int}")
|
||||
async def remove_favorite(request: Request, page_id: int):
|
||||
"""Remove a page from favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id))
|
||||
conn.commit()
|
||||
return {"status": "removed", "page_id": page_id}
|
||||
|
||||
# ═══════════ Share API ═══════════
|
||||
|
||||
@router.post("/api/share/{page_id:int}")
|
||||
async def update_share(request: Request, page_id: int):
|
||||
"""Save share settings for a page."""
|
||||
import json
|
||||
body = await request.json()
|
||||
mode = body.get("mode", "private")
|
||||
published = body.get("published", False)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET share_mode=?, published=? WHERE id=?",
|
||||
(mode, 1 if published else 0, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok", "share_mode": mode, "published": published}
|
||||
|
||||
|
||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||
|
||||
@router.get("/api/trash")
|
||||
async def list_trash(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()
|
||||
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
|
||||
|
||||
|
||||
@router.post("/api/trash/{page_id}/restore")
|
||||
async def restore_page(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok", "restored": page_id}
|
||||
|
||||
|
||||
@router.delete("/api/trash/{page_id}")
|
||||
async def permanent_delete(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
||||
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok", "deleted": page_id}
|
||||
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
async def trash_page(request: Request):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
template = env.get_template("trash.html")
|
||||
return template.render(**_sidebar_data(request))
|
||||
|
||||
|
||||
# ═══════════ Board page ═══════════
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def board(request: Request, owner: str, repo: str):
|
||||
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
template = env.get_template("board.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, groups=[], **sidebar)
|
||||
return template.render(request=request, owner=owner, repo=repo, groups=[],
|
||||
initial_view=view, **sidebar)
|
||||
|
||||
|
||||
# ═══════════ View fragments ═══════════
|
||||
@@ -391,7 +804,210 @@ async def extract_ai_keywords(owner: str, repo: str):
|
||||
return {"status": "ok", "issues_scanned": len(issues)}
|
||||
|
||||
|
||||
# ═══════════ v0.9.0: Sync endpoint ═══════════
|
||||
# ═══════════ Pages Markdown ═══════════
|
||||
|
||||
@router.post("/api/pages")
|
||||
async def create_page(request: Request, title: str = Query(default=""),
|
||||
section: str = Query(default="Private"),
|
||||
project: str = Query(default=""),
|
||||
parent_id: int = Query(default=0)):
|
||||
"""Create a new Markdown page, optionally as a sub-page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
||||
page_title = title.strip() if title else "Untitled"
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
# Compute next sort_order for this parent
|
||||
next_order = 0
|
||||
parent_val = parent_id if parent_id > 0 else None
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?",
|
||||
(ws_key, parent_val),
|
||||
).fetchone()
|
||||
if row:
|
||||
next_order = row[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)",
|
||||
(ws_key, page_title, section, parent_val, next_order),
|
||||
)
|
||||
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}")
|
||||
async def get_page(page_id: int):
|
||||
"""Get a Markdown page."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}")
|
||||
async def update_page(request: Request, page_id: int, title: str = Query(default=""),
|
||||
content: str = Query(default=""),
|
||||
content_format: str = Query(default="")):
|
||||
"""Update a page's title and/or content. Accepts JSON body for blocks."""
|
||||
with get_conn() as conn:
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
if content:
|
||||
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id))
|
||||
if content_format:
|
||||
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/blocks")
|
||||
async def save_page_blocks(request: Request, page_id: int):
|
||||
"""Save blocks JSON content (Notion-style block editor)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body")
|
||||
blocks_json = json.dumps(body.get("blocks", []))
|
||||
title = body.get("title", "")
|
||||
with get_conn() as conn:
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(blocks_json, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}/move")
|
||||
async def move_page(request: Request, page_id: int):
|
||||
"""Move a page to another workspace or reorder within tree.
|
||||
|
||||
Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
|
||||
- workspace_id: move page to a different workspace
|
||||
- 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:
|
||||
row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
if new_ws_id:
|
||||
# Move to a different workspace: get the workspace name
|
||||
ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone()
|
||||
if not ws_row:
|
||||
return JSONResponse({"status": "error", "detail": "Workspace not found"}, status_code=404)
|
||||
conn.execute(
|
||||
"UPDATE pages SET workspace_id=?, workspace=?, parent_id=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_ws_id, ws_row["name"], page_id),
|
||||
)
|
||||
else:
|
||||
# Reorder within same workspace
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_parent_id if new_parent_id > 0 else None, page_id),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_order, page_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}")
|
||||
async def delete_page(request: Request, page_id: int):
|
||||
"""Move a page to trash (soft delete)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
import datetime
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
async def view_page(request: Request, page_id: int):
|
||||
"""Render a page as HTML, or a file viewer for uploaded files."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
||||
page = dict(row)
|
||||
|
||||
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")
|
||||
response = template.render(**ctx)
|
||||
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
||||
|
||||
|
||||
@router.post("/api/sync/{owner}/{repo}")
|
||||
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,695 @@
|
||||
"""FlowDeck — Workspace, Comments, Favorites, History, Templates (v2.0.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
# ── v4.5.0: Sprints ──
|
||||
|
||||
@router.get("/collections/{collection_id}/sprints")
|
||||
async def list_sprints(request: Request, collection_id: int):
|
||||
"""List all sprints for a collection."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM sprints WHERE collection_id=? ORDER BY start_date DESC", (collection_id,)
|
||||
).fetchall()
|
||||
sprints = []
|
||||
for r in rows:
|
||||
s = dict(r)
|
||||
# Count pages in sprint
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM sprint_pages WHERE sprint_id=?", (r["id"],)
|
||||
).fetchone()["cnt"]
|
||||
s["page_count"] = count
|
||||
sprints.append(s)
|
||||
return {"sprints": sprints}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints")
|
||||
async def create_sprint(request: Request, collection_id: int):
|
||||
"""Create a new sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "").strip()
|
||||
start_date = body.get("start_date", "")
|
||||
end_date = body.get("end_date", "")
|
||||
if not name or not start_date or not end_date:
|
||||
raise HTTPException(400, "name, start_date, end_date are required")
|
||||
|
||||
goal = body.get("goal", "")
|
||||
status = body.get("status", "planning")
|
||||
auto_complete = int(body.get("auto_complete", 1))
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO sprints (collection_id, name, start_date, end_date, goal, status, auto_complete) VALUES (?,?,?,?,?,?,?)",
|
||||
(collection_id, name, start_date, end_date, goal, status, auto_complete),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/collections/{collection_id}/sprints/{sid}")
|
||||
async def update_sprint(request: Request, collection_id: int, sid: int):
|
||||
"""Update a sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
|
||||
).fetchone()
|
||||
if not sprint:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
|
||||
name = body.get("name", sprint["name"])
|
||||
start_date = body.get("start_date", sprint["start_date"])
|
||||
end_date = body.get("end_date", sprint["end_date"])
|
||||
goal = body.get("goal", sprint["goal"])
|
||||
status = body.get("status", sprint["status"])
|
||||
auto_complete = int(body.get("auto_complete", sprint["auto_complete"]))
|
||||
|
||||
conn.execute(
|
||||
"UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=?, auto_complete=? WHERE id=?",
|
||||
(name, start_date, end_date, goal, status, auto_complete, sid),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": sid, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/sprints/{sid}")
|
||||
async def delete_sprint(request: Request, collection_id: int, sid: int):
|
||||
"""Delete a sprint."""
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
|
||||
).fetchone()
|
||||
if not sprint:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
conn.execute("DELETE FROM sprints WHERE id=?", (sid,))
|
||||
conn.commit()
|
||||
return {"id": sid, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints/{sid}/assign")
|
||||
async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
|
||||
"""Assign a page to a sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
page_id = body.get("page_id")
|
||||
if not page_id:
|
||||
raise HTTPException(400, "page_id is required")
|
||||
|
||||
velocity_points = body.get("velocity_points", 1)
|
||||
status_at_start = body.get("status_at_start", "")
|
||||
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute("SELECT id FROM sprints WHERE id=?", (sid,)).fetchone()
|
||||
if not sprint:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
page = conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO sprint_pages (sprint_id, page_id, status_at_start, velocity_points) VALUES (?,?,?,?)",
|
||||
(sid, page_id, status_at_start, velocity_points),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(409, "Page already assigned to this sprint")
|
||||
return {"sprint_id": sid, "page_id": page_id, "status": "assigned"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}")
|
||||
async def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
|
||||
"""Remove a page from a sprint."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(404, "Assignment not found")
|
||||
conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id))
|
||||
conn.commit()
|
||||
return {"sprint_id": sid, "page_id": page_id, "status": "removed"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/sprints/burndown/{sid}")
|
||||
async def sprint_burndown(request: Request, collection_id: int, sid: int):
|
||||
"""Calculate burndown data for a sprint."""
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
|
||||
).fetchone()
|
||||
if not sprint:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
|
||||
pages = conn.execute(
|
||||
"""SELECT sp.velocity_points, cp.property_values_json
|
||||
FROM sprint_pages sp JOIN collection_pages cp ON sp.page_id=cp.id
|
||||
WHERE sp.sprint_id=?""", (sid,)
|
||||
).fetchall()
|
||||
|
||||
total_points = sum(p["velocity_points"] for p in pages)
|
||||
completed = 0
|
||||
for p in pages:
|
||||
props = json.loads(p["property_values_json"])
|
||||
for v in props.values():
|
||||
if isinstance(v, str) and v.lower() in ("done", "complete", "completed", "terminé"):
|
||||
completed += p["velocity_points"]
|
||||
break
|
||||
|
||||
from datetime import date, timedelta
|
||||
today = date.today()
|
||||
start = date.fromisoformat(sprint["start_date"]) if sprint["start_date"] else today
|
||||
end = date.fromisoformat(sprint["end_date"]) if sprint["end_date"] else today
|
||||
total_days = max((end - start).days, 1)
|
||||
elapsed = max((today - start).days, 0)
|
||||
ideal_burn = total_points - (total_points * elapsed / total_days)
|
||||
|
||||
return {
|
||||
"sprint": sprint["name"],
|
||||
"total_points": total_points,
|
||||
"completed_points": completed,
|
||||
"remaining_points": total_points - completed,
|
||||
"ideal_remaining": round(ideal_burn, 1),
|
||||
"start_date": sprint["start_date"],
|
||||
"end_date": sprint["end_date"],
|
||||
"days_elapsed": elapsed,
|
||||
"days_total": total_days,
|
||||
}
|
||||
|
||||
|
||||
# ── CSV Import/Export ──
|
||||
|
||||
@router.post("/collections/{collection_id}/import/csv")
|
||||
async def import_csv(request: Request, collection_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
csv_data = body.get("csv", "")
|
||||
if not csv_data:
|
||||
raise HTTPException(400, "csv field required")
|
||||
|
||||
reader = csv.DictReader(io.StringIO(csv_data))
|
||||
rows_imported = 0
|
||||
with get_conn() as conn:
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) FROM collection_pages WHERE collection_id=?", (collection_id,)
|
||||
).fetchone()[0]
|
||||
for row in reader:
|
||||
title = row.get("title", row.get("Title", row.get("Name", "Imported")))
|
||||
props = {k: v for k, v in row.items() if k.lower() != "title"}
|
||||
max_pos += 1
|
||||
conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
|
||||
(collection_id, title, max_pos, json.dumps(props)),
|
||||
)
|
||||
rows_imported += 1
|
||||
conn.commit()
|
||||
return {"imported": rows_imported}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/export/csv")
|
||||
async def export_csv(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)
|
||||
).fetchall()
|
||||
|
||||
# Collect all property keys
|
||||
all_keys = set()
|
||||
rows = []
|
||||
for p in pages:
|
||||
props = json.loads(p["property_values_json"])
|
||||
all_keys.update(props.keys())
|
||||
rows.append({"title": p["title"], **props})
|
||||
|
||||
output = io.StringIO()
|
||||
fieldnames = ["title"] + sorted(all_keys)
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=export.csv"},
|
||||
)
|
||||
|
||||
|
||||
# ── Webhooks Outbound Management ──
|
||||
|
||||
@router.get("/webhooks")
|
||||
async def list_webhooks(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
|
||||
return {"webhooks": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/webhooks")
|
||||
async def create_webhook(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
url = body.get("url", "").strip()
|
||||
event = body.get("event", "page.created")
|
||||
secret = body.get("secret", "")
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?,?,?)",
|
||||
(url, event, secret),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "url": url, "event": event, "status": "registered"}
|
||||
|
||||
|
||||
@router.delete("/webhooks/{wh_id}")
|
||||
async def delete_webhook(request: Request, wh_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
|
||||
conn.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
# ── Public Sharing ──
|
||||
|
||||
@router.get("/public/{collection_id}")
|
||||
async def public_view(request: Request, collection_id: int):
|
||||
"""Simple public read-only view — no auth required."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
pages = conn.execute(
|
||||
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
items = "".join(
|
||||
f"<li>{p['icon']} <b>{p['title']}</b></li>"
|
||||
for p in pages
|
||||
)
|
||||
return HTMLResponse(f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>{coll['name']} — FlowDeck Public</title>
|
||||
<style>body{{font-family:system-ui;background:#191919;color:#fff;padding:20px}}
|
||||
h1{{font-size:24px}} li{{padding:8px;border-bottom:1px solid #333}}</style></head>
|
||||
<body><h1>{coll['icon']} {coll['name']}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
|
||||
@@ -0,0 +1,222 @@
|
||||
"""FlowDeck — GiteaBoardCompat: legacy board → Collection adapter (v1.3.0).
|
||||
|
||||
Converts existing Gitea-linked boards and cards into the new collection model
|
||||
without breaking the existing board routes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
|
||||
class GiteaBoardCompat:
|
||||
"""Adaptateur: board Gitea legacy → Collection.
|
||||
|
||||
Permet de visualiser les boards Gitea existants comme des collections,
|
||||
avec rétrocompatibilité totale. Les anciennes routes /board/... continuent
|
||||
de fonctionner normalement.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def from_board(board_row) -> dict:
|
||||
"""Convertit un board existant en pseudo-collection."""
|
||||
columns = json.loads(board_row.get("columns_json", "[]"))
|
||||
return {
|
||||
"collection_id": board_row["id"],
|
||||
"name": f"{board_row['project_owner']}/{board_row['project_name']}",
|
||||
"description": f"Board Gitea: {board_row['project_name']}",
|
||||
"icon": "📋",
|
||||
"gitea_owner": board_row["project_owner"],
|
||||
"gitea_repo": board_row["project_name"],
|
||||
"is_gitea_linked": True,
|
||||
"is_locked": False,
|
||||
"schema": [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Status", "type": "select", "options": [
|
||||
{"name": c, "color": "gray"} for c in columns
|
||||
]},
|
||||
{"name": "Priority", "type": "select", "options": [
|
||||
{"name": "P1", "color": "red"},
|
||||
{"name": "P2", "color": "orange"},
|
||||
{"name": "P3", "color": "yellow"},
|
||||
{"name": "P4", "color": "gray"},
|
||||
]},
|
||||
{"name": "Due Date", "type": "date"},
|
||||
{"name": "Assignee", "type": "person"},
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_card(card_row, gitea_issue: Optional[dict] = None) -> dict:
|
||||
"""Convertit une card legacy en pseudo collection_page."""
|
||||
title = gitea_issue.get("title", f"Card #{card_row['id']}") if gitea_issue else f"Card #{card_row['id']}"
|
||||
priority = card_row.get("priority", "Medium")
|
||||
due_date = card_row.get("due_date", "")
|
||||
|
||||
props = {
|
||||
"Status": card_row.get("column_name", "Backlog"),
|
||||
"Priority": priority,
|
||||
"Due Date": due_date if due_date else None,
|
||||
}
|
||||
|
||||
if gitea_issue:
|
||||
assignee = gitea_issue.get("assignee")
|
||||
if assignee:
|
||||
props["Assignee"] = [{"login": assignee.get("login", ""), "avatar_url": assignee.get("avatar_url", "")}]
|
||||
|
||||
return {
|
||||
"id": card_row["id"],
|
||||
"title": title,
|
||||
"position": card_row.get("position", 0),
|
||||
"gitea_issue_id": card_row.get("gitea_issue_id"),
|
||||
"gitea_issue_number": gitea_issue.get("number") if gitea_issue else None,
|
||||
"property_values_json": json.dumps(props),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def list_boards_as_collections() -> list[dict]:
|
||||
"""Liste tous les boards comme des collections compatibles."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM boards ORDER BY project_owner, project_name").fetchall()
|
||||
return [GiteaBoardCompat.from_board(dict(r)) for r in rows]
|
||||
|
||||
@staticmethod
|
||||
def get_board_as_collection(owner: str, repo: str) -> Optional[dict]:
|
||||
"""Récupère un board spécifique comme collection."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
|
||||
(owner, repo),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return GiteaBoardCompat.from_board(dict(row))
|
||||
|
||||
@staticmethod
|
||||
def get_board_cards(owner: str, repo: str, gitea_issues: Optional[list[dict]] = None) -> list[dict]:
|
||||
"""Récupère les cartes d'un board comme collection_pages."""
|
||||
with get_conn() as conn:
|
||||
board = conn.execute(
|
||||
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
||||
(owner, repo),
|
||||
).fetchone()
|
||||
if not board:
|
||||
return []
|
||||
|
||||
cards = conn.execute(
|
||||
"SELECT * FROM cards WHERE board_id=? ORDER BY position",
|
||||
(board["id"],),
|
||||
).fetchall()
|
||||
|
||||
issues_by_id = {}
|
||||
if gitea_issues:
|
||||
issues_by_id = {i.get("id"): i for i in gitea_issues}
|
||||
|
||||
return [
|
||||
GiteaBoardCompat.from_card(dict(c), issues_by_id.get(c["gitea_issue_id"]))
|
||||
for c in cards
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> Optional[int]:
|
||||
"""Sync un board Gitea vers une vraie collection.
|
||||
|
||||
Crée ou met à jour une collection liée à Gitea et importe les pages.
|
||||
Retourne l'ID de la collection.
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
board = conn.execute(
|
||||
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
|
||||
(owner, repo),
|
||||
).fetchone()
|
||||
if not board:
|
||||
return None
|
||||
|
||||
board_dict = dict(board)
|
||||
columns = json.loads(board_dict.get("columns_json", "[]"))
|
||||
|
||||
# Upsert collection
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM collections WHERE gitea_owner=? AND gitea_repo=?",
|
||||
(owner, repo),
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
coll_id = existing["id"]
|
||||
conn.execute(
|
||||
"UPDATE collections SET name=?, schema_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(f"{owner}/{repo}", json.dumps([
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Status", "type": "select",
|
||||
"options": [{"name": c, "color": "gray"} for c in columns]},
|
||||
{"name": "Priority", "type": "select",
|
||||
"options": [{"name": "P1", "color": "red"}, {"name": "P2", "color": "orange"},
|
||||
{"name": "P3", "color": "yellow"}, {"name": "P4", "color": "gray"}]},
|
||||
]), coll_id),
|
||||
)
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections (name, icon, schema_json, gitea_owner, gitea_repo)
|
||||
VALUES (?, '📋', ?, ?, ?)""",
|
||||
(f"{owner}/{repo}", json.dumps([
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Status", "type": "select",
|
||||
"options": [{"name": c, "color": "gray"} for c in columns]},
|
||||
{"name": "Priority", "type": "select",
|
||||
"options": [{"name": "P1", "color": "red"}, {"name": "P2", "color": "orange"},
|
||||
{"name": "P3", "color": "yellow"}, {"name": "P4", "color": "gray"}]},
|
||||
]), owner, repo),
|
||||
)
|
||||
coll_id = cur.lastrowid
|
||||
|
||||
# Create default view
|
||||
conn.execute(
|
||||
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?, ?, ?, ?)",
|
||||
(coll_id, "Default View", "table", json.dumps({"visible_properties": ["Title", "Status", "Priority"]})),
|
||||
)
|
||||
|
||||
# Sync cards → collection_pages
|
||||
cards = conn.execute(
|
||||
"SELECT * FROM cards WHERE board_id=? ORDER BY position",
|
||||
(board_dict["id"],),
|
||||
).fetchall()
|
||||
|
||||
issues_by_id = {i.get("id"): i for i in gitea_issues}
|
||||
|
||||
for card in cards:
|
||||
card_dict = dict(card)
|
||||
issue = issues_by_id.get(card_dict.get("gitea_issue_id"))
|
||||
title = issue.get("title", f"Issue #{card_dict.get('gitea_issue_id', card_dict['id'])}") if issue else f"Card #{card_dict['id']}"
|
||||
|
||||
props = {
|
||||
"Status": card_dict.get("column_name", "Backlog"),
|
||||
"Priority": card_dict.get("priority", "Medium"),
|
||||
}
|
||||
|
||||
# Upsert collection_page
|
||||
existing_page = conn.execute(
|
||||
"SELECT id FROM collection_pages WHERE collection_id=? AND gitea_issue_id=?",
|
||||
(coll_id, card_dict.get("gitea_issue_id")),
|
||||
).fetchone()
|
||||
|
||||
if existing_page:
|
||||
conn.execute(
|
||||
"""UPDATE collection_pages SET title=?, position=?, property_values_json=?,
|
||||
updated_at=CURRENT_TIMESTAMP WHERE id=?""",
|
||||
(title, card_dict.get("position", 0), json.dumps(props), existing_page["id"]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, position, gitea_issue_id, gitea_issue_number,
|
||||
property_values_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(coll_id, title, card_dict.get("position", 0),
|
||||
card_dict.get("gitea_issue_id"), issue.get("number") if issue else None,
|
||||
json.dumps(props)),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return coll_id
|
||||
@@ -0,0 +1,223 @@
|
||||
"""FlowDeck — Formula Engine (v1.5.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
class FormulaEngine:
|
||||
"""Moteur d'expressions JavaScript-like pour propriétés calculées.
|
||||
|
||||
Usage:
|
||||
engine = FormulaEngine()
|
||||
result = engine.evaluate("prop('Price') * prop('Qty')", {"Price": 10, "Qty": 5})
|
||||
# → 50
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._functions: dict[str, Callable] = {
|
||||
"prop": self._prop,
|
||||
"now": self._now,
|
||||
"today": self._today,
|
||||
"if": self._if,
|
||||
"concat": self._concat,
|
||||
"round": self._round,
|
||||
"contains": self._contains,
|
||||
"length": self._length,
|
||||
"toNumber": self._to_number,
|
||||
"formatDate": self._format_date,
|
||||
"dateAdd": self._date_add,
|
||||
"dateSubtract": self._date_subtract,
|
||||
"replace": self._replace,
|
||||
"replaceAll": self._replace_all,
|
||||
"join": self._join,
|
||||
"empty": self._empty,
|
||||
"and": self._and,
|
||||
"or": self._or,
|
||||
"not": self._not,
|
||||
"start": self._start,
|
||||
"end": self._end,
|
||||
}
|
||||
|
||||
def evaluate(self, expression: str, context: dict[str, Any]) -> Any:
|
||||
"""Évalue une expression de formule dans un contexte donné.
|
||||
|
||||
Le contexte contient les valeurs des propriétés accessibles via prop('name').
|
||||
"""
|
||||
if not expression or not expression.strip():
|
||||
return None
|
||||
try:
|
||||
return self._eval_expr(expression.strip(), context)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _eval_expr(self, expr: str, ctx: dict[str, Any]) -> Any:
|
||||
"""Parse et évalue l'expression."""
|
||||
expr = expr.strip()
|
||||
|
||||
# Function call
|
||||
if "(" in expr and expr.endswith(")"):
|
||||
paren = expr.index("(")
|
||||
func_name = expr[:paren].strip()
|
||||
args_str = expr[paren + 1:-1]
|
||||
args = self._parse_args(args_str)
|
||||
evaluated = [self._eval_expr(a.strip(), ctx) for a in args]
|
||||
if func_name in self._functions:
|
||||
return self._functions[func_name](ctx, *evaluated)
|
||||
return None
|
||||
|
||||
# String literal
|
||||
if (expr.startswith("'") and expr.endswith("'")) or \
|
||||
(expr.startswith('"') and expr.endswith('"')):
|
||||
return expr[1:-1]
|
||||
|
||||
# Number
|
||||
try:
|
||||
return float(expr) if "." in expr else int(expr)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Boolean
|
||||
if expr.lower() == "true":
|
||||
return True
|
||||
if expr.lower() == "false":
|
||||
return False
|
||||
if expr.lower() == "null":
|
||||
return None
|
||||
|
||||
# Context lookup
|
||||
if expr in ctx:
|
||||
return ctx[expr]
|
||||
|
||||
return expr # raw string
|
||||
|
||||
def _parse_args(self, s: str) -> list[str]:
|
||||
"""Parse une liste d'arguments en respectant les guillemets."""
|
||||
args = []
|
||||
current = ""
|
||||
depth = 0
|
||||
in_quote = False
|
||||
quote_char = ""
|
||||
for ch in s:
|
||||
if ch in ("'", '"') and not in_quote:
|
||||
in_quote = True
|
||||
quote_char = ch
|
||||
current += ch
|
||||
elif ch == quote_char and in_quote:
|
||||
in_quote = False
|
||||
current += ch
|
||||
elif ch == "(":
|
||||
depth += 1
|
||||
current += ch
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
current += ch
|
||||
elif ch == "," and depth == 0 and not in_quote:
|
||||
args.append(current.strip())
|
||||
current = ""
|
||||
else:
|
||||
current += ch
|
||||
if current.strip():
|
||||
args.append(current.strip())
|
||||
return args
|
||||
|
||||
# ── Built-in functions ──
|
||||
|
||||
def _prop(self, ctx: dict, name: str) -> Any:
|
||||
return ctx.get(name)
|
||||
|
||||
def _now(self, ctx: dict) -> str:
|
||||
return datetime.now().isoformat()
|
||||
|
||||
def _today(self, ctx: dict) -> str:
|
||||
return date.today().isoformat()
|
||||
|
||||
def _if(self, ctx: dict, cond: Any, a: Any, b: Any) -> Any:
|
||||
return a if cond else b
|
||||
|
||||
def _concat(self, ctx: dict, *args) -> str:
|
||||
return "".join(str(a) for a in args)
|
||||
|
||||
def _round(self, ctx: dict, n: Any, d: int = 0) -> float:
|
||||
return round(float(n), int(d))
|
||||
|
||||
def _contains(self, ctx: dict, s: Any, sub: str) -> bool:
|
||||
return str(sub) in str(s)
|
||||
|
||||
def _length(self, ctx: dict, s: Any) -> int:
|
||||
return len(str(s))
|
||||
|
||||
def _to_number(self, ctx: dict, s: Any) -> float:
|
||||
try:
|
||||
return float(s)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
def _format_date(self, ctx: dict, d: str, fmt: str = "%Y-%m-%d") -> str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(d.replace("Z", "+00:00"))
|
||||
return dt.strftime(fmt)
|
||||
except Exception:
|
||||
return str(d)
|
||||
|
||||
def _date_add(self, ctx: dict, d: str, n: int, unit: str = "days") -> str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(d.replace("Z", "+00:00"))
|
||||
if unit == "days":
|
||||
dt += timedelta(days=int(n))
|
||||
elif unit == "hours":
|
||||
dt += timedelta(hours=int(n))
|
||||
elif unit == "minutes":
|
||||
dt += timedelta(minutes=int(n))
|
||||
return dt.isoformat()
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def _date_subtract(self, ctx: dict, d: str, n: int, unit: str = "days") -> str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(d.replace("Z", "+00:00"))
|
||||
if unit == "days":
|
||||
dt -= timedelta(days=int(n))
|
||||
elif unit == "hours":
|
||||
dt -= timedelta(hours=int(n))
|
||||
elif unit == "minutes":
|
||||
dt -= timedelta(minutes=int(n))
|
||||
return dt.isoformat()
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def _replace(self, ctx: dict, s: Any, old: str, new: str) -> str:
|
||||
return str(s).replace(old, new)
|
||||
|
||||
def _replace_all(self, ctx: dict, s: Any, old: str, new: str) -> str:
|
||||
return str(s).replace(old, new)
|
||||
|
||||
def _join(self, ctx: dict, sep: str, *args) -> str:
|
||||
return sep.join(str(a) for a in args)
|
||||
|
||||
def _empty(self, ctx: dict, v: Any) -> bool:
|
||||
return v is None or v == "" or v == [] or v == 0
|
||||
|
||||
def _and(self, ctx: dict, *args) -> bool:
|
||||
return all(args)
|
||||
|
||||
def _or(self, ctx: dict, *args) -> bool:
|
||||
return any(args)
|
||||
|
||||
def _not(self, ctx: dict, v: Any) -> bool:
|
||||
return not v
|
||||
|
||||
def _start(self, ctx: dict, s: Any) -> str:
|
||||
"""Extract start date from a date range (iso1...iso2 or just iso1)."""
|
||||
val = str(s)
|
||||
if "..." in val:
|
||||
return val.split("...")[0]
|
||||
return val
|
||||
|
||||
def _end(self, ctx: dict, s: Any) -> Optional[str]:
|
||||
"""Extract end date from a date range."""
|
||||
val = str(s)
|
||||
if "..." in val:
|
||||
parts = val.split("...")
|
||||
return parts[1] if len(parts) > 1 else None
|
||||
return None
|
||||
@@ -13,11 +13,16 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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._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._ttl = timedelta(seconds=settings.gitea_cache_ttl)
|
||||
|
||||
@@ -295,6 +300,129 @@ class GiteaClient:
|
||||
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 ──
|
||||
|
||||
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 %}
|
||||
@@ -0,0 +1,132 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}{{ fd_icon("user",18) }}{% endblock %}
|
||||
{% block page_title %}Accounts{% endblock %}
|
||||
{% block title_prefix %}Accounts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">{{ fd_icon("user",24) }}</span>
|
||||
<h1>Account Management</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 0 24px; max-width: 900px;" x-data="accountsData()">
|
||||
<!-- Current user profile -->
|
||||
<div style="background:var(--bg-secondary); border-radius:8px; padding:20px; margin-bottom:24px;">
|
||||
<h3 style="margin-bottom:16px;">Your Profile</h3>
|
||||
<div style="display:flex; gap:16px; align-items:flex-start;">
|
||||
<div class="workspace-avatar" style="width:48px; height:48px; font-size:20px;">
|
||||
{{ user.login[0] if user else 'B' }}
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<div style="display:flex; gap:12px; margin-bottom:12px;">
|
||||
<div style="flex:1;">
|
||||
<label style="font-size:12px; color:var(--text-dim);">Username</label>
|
||||
<input type="text" value="{{ user.login }}" disabled
|
||||
style="width:100%; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px;
|
||||
padding:8px; color:var(--text-dim); font-size:14px;">
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<label style="font-size:12px; color:var(--text-dim);">Display name</label>
|
||||
<input type="text" x-model="profile.full_name"
|
||||
style="width:100%; background:var(--bg-secondary); border:1px solid var(--border); border-radius:6px;
|
||||
padding:8px; color:var(--text-primary); font-size:14px; outline:none;">
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<label style="font-size:12px; color:var(--text-dim);">Email</label>
|
||||
<input type="text" x-model="profile.email"
|
||||
style="width:100%; background:var(--bg-secondary); border:1px solid var(--border); border-radius:6px;
|
||||
padding:8px; color:var(--text-primary); font-size:14px; outline:none;">
|
||||
</div>
|
||||
<button class="btn-new" @click="saveProfile()">Save Profile</button>
|
||||
<span x-show="saved" style="color:var(--green); font-size:13px; margin-left:8px;">✓ Saved</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected accounts -->
|
||||
<div style="background:var(--bg-secondary); border-radius:8px; padding:20px; margin-bottom:24px;">
|
||||
<h3 style="margin-bottom:12px;">Connected Accounts</h3>
|
||||
<div style="display:flex; align-items:center; gap:12px; padding:12px; border:1px solid var(--border); border-radius:6px;">
|
||||
<span style="font-size:20px;">{{ fd_icon("link",20) }}</span>
|
||||
<div style="flex:1;">
|
||||
<div style="font-weight:600;">Gitea</div>
|
||||
<div style="font-size:12px; color:var(--text-dim);">
|
||||
{% if user %}Connected as {{ user.login }}{% else %}Not connected{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if user %}
|
||||
<a href="/auth/logout" class="toolbar-btn" style="color:var(--red);">Disconnect</a>
|
||||
{% else %}
|
||||
<a href="/auth/login" class="btn-new">Connect Gitea</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users in workspace -->
|
||||
<div style="background:var(--bg-secondary); border-radius:8px; padding:20px;">
|
||||
<h3 style="margin-bottom:12px;">Workspace Members</h3>
|
||||
<div style="font-size:13px; color:var(--text-dim); margin-bottom:12px;">
|
||||
Members are managed through Gitea. New users connect via OAuth and appear here automatically.
|
||||
</div>
|
||||
<table class="data-table" style="width:100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Email</th>
|
||||
<th>Joined</th>
|
||||
<th>Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<div class="card-avatar" style="width:24px; height:24px; font-size:11px;">{{ u.login[0] }}</div>
|
||||
<span>{{ u.login }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ u.email or '—' }}</td>
|
||||
<td>{{ u.created_at[:10] if u.created_at else '—' }}</td>
|
||||
<td>{% if u.is_admin %}Admin{% else %}Member{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not users %}
|
||||
<tr><td colspan="4" style="text-align:center; color:var(--text-dim); padding:24px;">No members yet</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function accountsData() {
|
||||
return {
|
||||
profile: { full_name: '', email: '' },
|
||||
saved: false,
|
||||
async init() {
|
||||
try {
|
||||
const r = await fetch('/api/users/me');
|
||||
const data = await r.json();
|
||||
this.profile = { full_name: data.full_name || '', email: data.email || '' };
|
||||
} catch(e) {}
|
||||
},
|
||||
saveProfile() {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const token = csrf ? csrf[1] : '';
|
||||
fetch(`/api/users/me?full_name=${encodeURIComponent(this.profile.full_name)}&email=${encodeURIComponent(this.profile.email)}`, {
|
||||
method: 'PUT', headers: { 'X-CSRF-Token': token }
|
||||
}).then(() => {
|
||||
this.saved = true;
|
||||
setTimeout(() => this.saved = false, 3000);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}📁{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("folder",18) }}{% endblock %}
|
||||
{% block page_title %}Kanban board new{% endblock %}
|
||||
{% block title_prefix %}Kanban board new{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Cover image -->
|
||||
@@ -15,7 +16,7 @@
|
||||
<!-- Page title -->
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">📁</span>
|
||||
<span class="page-icon-lg">{{ fd_icon("folder",24) }}</span>
|
||||
<h1>{{ repo }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,31 +41,31 @@
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/kanban"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">📊</span> Kanban board
|
||||
<span class="tab-icon">{{ fd_icon("bar-chart",14) }}</span> Kanban board
|
||||
</button>
|
||||
<button class="view-tab" data-view="detailed"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/detailed"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">📋</span> Detailed board
|
||||
<span class="tab-icon">{{ fd_icon("list",14) }}</span> Detailed board
|
||||
</button>
|
||||
<button class="view-tab" data-view="table"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/table"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">☰</span> Table view
|
||||
<span class="tab-icon">{{ fd_icon("align-left",14) }}</span> Table view
|
||||
</button>
|
||||
<button class="view-tab" data-view="status"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/status"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">📈</span> Status overview
|
||||
<span class="tab-icon">{{ fd_icon("trending-up",14) }}</span> Status overview
|
||||
</button>
|
||||
<button class="view-tab" data-view="teamload"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/teamload"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">👥</span> Team Load
|
||||
<span class="tab-icon">{{ fd_icon("users",14) }}</span> Team Load
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -110,7 +111,7 @@
|
||||
<span class="sort-direction" @click="toggleDir(i)">
|
||||
<span x-text="s.dir === 'asc' ? 'Ascending ↑' : 'Descending ↓'"></span>
|
||||
</span>
|
||||
<button class="sort-delete" @click="removeSort(i)" title="Delete">🗑</button>
|
||||
<button class="sort-delete" @click="removeSort(i)" title="Delete">{{ fd_icon("trash",14) }}</button>
|
||||
</div>
|
||||
</template>
|
||||
<button class="dropdown-option" @click="addSort()">+ Add sort</button>
|
||||
@@ -118,8 +119,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="toolbar-btn" title="Properties">⚡</button>
|
||||
<button class="toolbar-btn" title="Search">🔍</button>
|
||||
<button class="toolbar-btn" title="Properties">{{ fd_icon("zap",14) }}</button>
|
||||
<button class="toolbar-btn" title="Search">{{ fd_icon("search",14) }}</button>
|
||||
<button class="toolbar-btn">⋯</button>
|
||||
<button class="btn-new" @click="showNewIssue()">New ▾</button>
|
||||
</div>
|
||||
@@ -153,6 +154,15 @@
|
||||
<script>
|
||||
const owner = '{{ owner }}';
|
||||
const repo = '{{ repo }}';
|
||||
const initialView = '{{ initial_view }}';
|
||||
|
||||
// Auto-switch to view from URL param
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (initialView && initialView !== 'kanban') {
|
||||
const tab = document.querySelector(`.view-tab[data-view="${initialView}"]`);
|
||||
if (tab) tab.click();
|
||||
}
|
||||
});
|
||||
|
||||
function setActiveTab(el) {
|
||||
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
|
||||
@@ -235,7 +245,11 @@
|
||||
title: '', status: 'todo',
|
||||
create() {
|
||||
if (!this.title.trim()) return;
|
||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, { method: 'POST' })
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
this.title = '';
|
||||
@@ -268,7 +282,16 @@
|
||||
onEnd: function(evt) {
|
||||
const cardId = evt.item.dataset.cardId;
|
||||
const toStatus = evt.to.dataset.status;
|
||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, { method: 'POST' });
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(() => {
|
||||
// ponytail: refresh current view after move
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<span class="card-tag" style="background: {{ card.tag_color }}20; color: {{ card.tag_color }};">{{ card.tag }}</span>
|
||||
{% endif %}
|
||||
{% if card.due_date %}
|
||||
<div class="card-date"><span>📅</span> {{ card.due_date }}</div>
|
||||
<div class="card-date"><span>{{ fd_icon("calendar",14) }}</span> {{ card.due_date }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{% endif %}
|
||||
{% if card.due_date %}
|
||||
<div class="card-date">
|
||||
<span>📅</span> {{ card.due_date }}
|
||||
<span>{{ fd_icon("calendar",14) }}</span> {{ card.due_date }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -23,13 +23,13 @@
|
||||
</div>
|
||||
<!-- Assignee -->
|
||||
<div class="filter-pill" style="cursor:pointer;">
|
||||
<span>👤</span>
|
||||
<span>{{ fd_icon("user",14) }}</span>
|
||||
{% if issue.assignee %}{{ issue.assignee }}{% else %}Unassigned{% endif %}
|
||||
<span style="font-size:10px;">▾</span>
|
||||
</div>
|
||||
<!-- Deadline -->
|
||||
<div class="filter-pill" style="cursor:pointer;">
|
||||
<span>📅</span>
|
||||
<span>{{ fd_icon("calendar",14) }}</span>
|
||||
{% if issue.due_date %}{{ issue.due_date }}{% else %}No deadline{% endif %}
|
||||
</div>
|
||||
<!-- Labels -->
|
||||
@@ -99,6 +99,12 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ponytail: CSRF helper
|
||||
function getCsrf() {
|
||||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function cardDetail() {
|
||||
return {
|
||||
updateField(field, value) {
|
||||
@@ -109,18 +115,27 @@
|
||||
console.log('Toggle status');
|
||||
},
|
||||
toggleChecklistItem(id, checked) {
|
||||
fetch(`/api/checklist-items/${id}?checked=${checked}`, { method: 'PATCH' });
|
||||
fetch(`/api/checklist-items/${id}?checked=${checked}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'X-CSRF-Token': getCsrf() }
|
||||
});
|
||||
},
|
||||
addChecklistItem(clId) {
|
||||
const content = prompt('Item name:');
|
||||
if (content) {
|
||||
fetch(`/api/checklist-items/${owner}/${repo}/${issue_id}/${clId}?content=${encodeURIComponent(content)}`, { method: 'POST' })
|
||||
fetch(`/api/checklist-items/${owner}/${repo}/${issue_id}/${clId}?content=${encodeURIComponent(content)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': getCsrf() }
|
||||
})
|
||||
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
|
||||
}
|
||||
},
|
||||
addChecklist() {
|
||||
const title = prompt('Checklist name:') || 'Checklist';
|
||||
fetch(`/api/checklists/${owner}/${repo}/${issue_id}?title=${encodeURIComponent(title)}`, { method: 'POST' })
|
||||
fetch(`/api/checklists/${owner}/${repo}/${issue_id}?title=${encodeURIComponent(title)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': getCsrf() }
|
||||
})
|
||||
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
|
||||
},
|
||||
addComment(body) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}🏠{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("home",18) }}{% endblock %}
|
||||
{% block page_title %}Home{% endblock %}
|
||||
{% block title_prefix %}Home{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<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;"
|
||||
onmouseover="this.style.background='var(--bg-hover)'"
|
||||
onmouseout="this.style.background='transparent'">
|
||||
<span style="font-size:18px;">📁</span>
|
||||
<span style="font-size:18px;">{{ fd_icon("folder",20) }}</span>
|
||||
<div style="flex:1; min-width:0;">
|
||||
<div style="font-weight:500; font-size:14px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||
{{ repo.name }}
|
||||
@@ -56,7 +57,7 @@
|
||||
<span style="font-size:12px; color: var(--text-dim);">🟡 {{ repo.language }}</span>
|
||||
{% endif %}
|
||||
{% if repo.private %}
|
||||
<span style="font-size:12px; color: var(--text-dim);">🔒</span>
|
||||
<span style="font-size:12px; color: var(--text-dim);">{{ fd_icon("lock",14) }}</span>
|
||||
{% endif %}
|
||||
<span style="font-size:12px; color: var(--text-dim);">→</span>
|
||||
</div>
|
||||
@@ -65,7 +66,7 @@
|
||||
|
||||
{% if not repos %}
|
||||
<div style="text-align:center; padding:48px; color:var(--text-dim);">
|
||||
<div style="font-size:48px; margin-bottom:16px;">📭</div>
|
||||
<div style="margin-bottom:16px;">{{ fd_icon("inbox",48) }}</div>
|
||||
<h3 style="color:var(--text-secondary);">Aucun projet trouvé</h3>
|
||||
<p style="margin-top:8px;">
|
||||
{% if search %}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
{% endif %}
|
||||
{% if card.due_date %}
|
||||
<div class="card-date">
|
||||
<span>📅</span>
|
||||
<span>{{ fd_icon("calendar",14) }}</span>
|
||||
<span style="font-size:12px;">{{ card.due_date }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -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">
|
||||
<textarea name="content" class="notes-textarea" placeholder="Notes markdown pour ce projet...">{{ content }}</textarea>
|
||||
<div class="notes-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 Sauvegarder</button>
|
||||
<button type="submit" class="btn btn-primary">{{ fd_icon("save",14) }} Sauvegarder</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -0,0 +1,935 @@
|
||||
{% extends "base.html" %} {% block page_icon %}{% if page.content_format == 'file' %}{{ fd_icon("paperclip",14) }}{% else %}{{ fd_icon("file",14) }}{% endif %}{% endblock %} {% block
|
||||
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
|
||||
{% set page_title = page.title %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()">' ~ fd_icon("lock",14) ~ ' Share ▾</button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %} {% block content %}
|
||||
<div class="page-editor-wrapper" x-data="editorState()">
|
||||
<!-- Share/More dialogs (in content area, triggered by header buttons) -->
|
||||
|
||||
<!-- ═══════════ Share / Publish Dialog ═══════════ -->
|
||||
<div
|
||||
class="share-dialog"
|
||||
x-show="shareOpen"
|
||||
@click.outside="!_justOpened && (shareOpen = false)"
|
||||
x-transition.opacity.scale.origin.top.right
|
||||
>
|
||||
<!-- Header with tabs -->
|
||||
<div class="sd-header">
|
||||
<button
|
||||
class="sd-tab"
|
||||
:class="{ active: shareTab === 'share' }"
|
||||
@click="shareTab = 'share'"
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
<button
|
||||
class="sd-tab"
|
||||
:class="{ active: shareTab === 'publish' }"
|
||||
@click="shareTab = 'publish'"
|
||||
>
|
||||
Publish
|
||||
<span class="sd-badge" x-show="pagePublished"></span>
|
||||
</button>
|
||||
<button class="sd-close" @click="shareOpen = false">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ SHARE TAB ═══════ -->
|
||||
<div class="sd-body" x-show="shareTab === 'share'" x-transition>
|
||||
<!-- Invite input -->
|
||||
<div class="sd-invite-row">
|
||||
<input
|
||||
type="email"
|
||||
class="sd-input"
|
||||
placeholder="Add people, groups or emails..."
|
||||
x-model="inviteEmail"
|
||||
@keydown.enter="shareInvite()"
|
||||
/>
|
||||
<button class="sd-btn-primary" @click="shareInvite()">
|
||||
Invite
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Context card (shown when independent permissions) -->
|
||||
<div class="sd-context-card" x-show="false">
|
||||
Share settings on this page are unlinked from parent
|
||||
page
|
||||
</div>
|
||||
|
||||
<!-- Participants list -->
|
||||
<div class="sd-participants" x-show="accessList.length">
|
||||
<template x-for="a in accessList" :key="a.email">
|
||||
{% raw %}
|
||||
<div class="sd-participant">
|
||||
<div class="sd-participant-info">
|
||||
<div
|
||||
class="sd-avatar"
|
||||
x-text="a.email[0].toUpperCase()"
|
||||
></div>
|
||||
<div>
|
||||
<div
|
||||
class="sd-participant-name"
|
||||
x-text="a.email"
|
||||
></div>
|
||||
<div
|
||||
class="sd-participant-email"
|
||||
x-text="a.email"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="sd-perm-btn"
|
||||
@click="a.permOpen = !a.permOpen"
|
||||
>
|
||||
<span
|
||||
x-text="a.permission === 'editor' ? 'Full access' : a.permission === 'commenter' ? 'Can comment' : 'Can view'"
|
||||
></span>
|
||||
▾
|
||||
</button>
|
||||
<div
|
||||
class="sd-perm-menu"
|
||||
x-show="a.permOpen"
|
||||
@click.outside="a.permOpen = false"
|
||||
>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'editor' }"
|
||||
@click="a.permission='editor'; a.permOpen=false"
|
||||
>
|
||||
<span>Can edit</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Edit, suggest and comment</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'commenter' }"
|
||||
@click="a.permission='commenter'; a.permOpen=false"
|
||||
>
|
||||
<span>Can comment</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Suggest and comment only</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'viewer' }"
|
||||
@click="a.permission='viewer'; a.permOpen=false"
|
||||
>
|
||||
<span>Can view</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Read only</span
|
||||
>
|
||||
</div>
|
||||
<div class="sd-perm-sep"></div>
|
||||
<div
|
||||
class="sd-perm-option danger"
|
||||
@click="removeAccess(a.email); a.permOpen=false"
|
||||
>
|
||||
Remove
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endraw %}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- General Access -->
|
||||
<div class="sd-section">
|
||||
<div class="sd-section-title">General access</div>
|
||||
<div style="position: relative">
|
||||
<button
|
||||
class="sd-access-btn"
|
||||
@click="accessMenuOpen = !accessMenuOpen"
|
||||
>
|
||||
<span
|
||||
x-text="generalAccess === 'invited' ? 'Only people invited' : 'Anyone with the link'"
|
||||
></span>
|
||||
<span class="chevron-down">▾</span>
|
||||
</button>
|
||||
<div
|
||||
class="sd-access-menu"
|
||||
x-show="accessMenuOpen"
|
||||
@click.outside="accessMenuOpen = false"
|
||||
x-transition
|
||||
>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: generalAccess === 'invited' }"
|
||||
@click="generalAccess='invited'; accessMenuOpen=false"
|
||||
>
|
||||
<span>{{ fd_icon('lock',14) }} Only people invited</span>
|
||||
<span class="sd-perm-desc"
|
||||
>People need to be invited</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: generalAccess === 'anyone' }"
|
||||
@click="generalAccess='anyone'; accessMenuOpen=false"
|
||||
>
|
||||
<span>{{ fd_icon('globe',14) }} Anyone with the link</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Can view the page</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Public permission when link is open -->
|
||||
<div
|
||||
x-show="generalAccess === 'anyone'"
|
||||
style="
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
"
|
||||
>Anyone with the link</span
|
||||
>
|
||||
<select class="sd-select-sm" x-model="publicPerm">
|
||||
<option value="viewer">Can view</option>
|
||||
<option value="commenter">Can comment</option>
|
||||
<option value="editor">Can edit</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sd-footer">
|
||||
<a href="#" class="sd-footer-link" @click.prevent
|
||||
>? Learn about sharing</a
|
||||
>
|
||||
<button class="sd-footer-action" @click="copyPageLink">
|
||||
{{ fd_icon("link",14) }} Copy link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ PUBLISH TAB ═══════ -->
|
||||
<div
|
||||
class="sd-body"
|
||||
x-show="shareTab === 'publish'"
|
||||
x-transition
|
||||
>
|
||||
<!-- Before publishing -->
|
||||
<div x-show="!pagePublished" class="sd-publish-hero">
|
||||
<div class="sd-publish-preview">
|
||||
<div class="sd-preview-bar"></div>
|
||||
<div class="sd-preview-title">
|
||||
{{ fd_icon('globe',14) }} {{ page.title }}
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="sd-publish-heading">Publish to web</h3>
|
||||
<p class="sd-publish-desc">
|
||||
Make this page visible to anyone on the internet.
|
||||
You can share the link with anyone.
|
||||
</p>
|
||||
<button
|
||||
class="sd-btn-primary sd-btn-full"
|
||||
@click="publishPage()"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
<p class="sd-publish-note">
|
||||
Your page will be visible to anyone with the link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- After publishing -->
|
||||
<div x-show="pagePublished">
|
||||
<div class="sd-url-bar">
|
||||
<span
|
||||
style="font-size: 13px; color: var(--text-dim)"
|
||||
>{{ workspace_key or 'flowdeck' }}</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="sd-url-input"
|
||||
:value="publishedUrl || pageUrl"
|
||||
readonly
|
||||
@click="$event.target.select()"
|
||||
/>
|
||||
<button
|
||||
class="sd-btn-primary"
|
||||
style="
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
"
|
||||
@click="copyPageLink"
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- Settings list -->
|
||||
<div class="sd-settings">
|
||||
<div class="sd-setting-row">
|
||||
<span>{{ fd_icon("link",14) }} Link expires</span>
|
||||
<span style="color: var(--text-dim)"
|
||||
>Never ▾</span
|
||||
>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>{{ fd_icon('search',14) }} Search engine indexing</span>
|
||||
<span style="color: var(--text-dim)"
|
||||
>Off ▾</span
|
||||
>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>{{ fd_icon("file",14) }} Allow duplicate as template</span>
|
||||
<label class="toggle-switch sm">
|
||||
<input type="checkbox" checked />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>{{ fd_icon("edit",14) }} Allow editing</span>
|
||||
<label class="toggle-switch sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="pubAllowEdit"
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>{{ fd_icon("message-square",14) }} Allow comments</span>
|
||||
<label class="toggle-switch sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="pubAllowComments"
|
||||
checked
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<button
|
||||
class="sd-danger-btn"
|
||||
@click="unpublishPage()"
|
||||
>
|
||||
Unpublish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Activity Popover ═══════════ -->
|
||||
<div class="activity-popover"
|
||||
x-show="activityOpen"
|
||||
@click.outside="!_justOpened && (activityOpen = false)"
|
||||
x-transition.opacity.scale.origin.top.right
|
||||
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:280px;padding:12px;">
|
||||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">Edited <span x-text="timeAgo"></span></div>
|
||||
<template x-if="pageCreated">
|
||||
<div style="font-size:12px;color:var(--text-tertiary);">Created <span x-text="formatDate(pageCreated)"></span></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- More menu dropdown -->
|
||||
<div
|
||||
class="more-menu"
|
||||
x-show="moreOpen"
|
||||
@click.outside="!_justOpened && (moreOpen = false)"
|
||||
x-transition
|
||||
>
|
||||
<div class="more-menu-item" @click="exportPage">↗ Export</div>
|
||||
<div class="more-menu-item" @click="duplicatePage">
|
||||
{{ fd_icon("copy",14) }} Duplicate
|
||||
</div>
|
||||
<div class="more-menu-item" @click="movePage">↗ Move to</div>
|
||||
<div class="more-menu-sep"></div>
|
||||
<div class="more-menu-item danger" @click="deletePage">
|
||||
{{ fd_icon("trash",14) }} Move to Trash
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Move to Dialog ═══════════ -->
|
||||
<div class="move-dialog"
|
||||
x-show="moveOpen"
|
||||
@click.outside="moveOpen = false"
|
||||
x-transition.opacity.scale
|
||||
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:320px;padding:16px;">
|
||||
<h3 style="font-size:14px;margin-bottom:12px;">Move to workspace</h3>
|
||||
<div x-show="moveLoading" style="color:var(--text-tertiary);text-align:center;padding:20px;">Loading...</div>
|
||||
<div x-show="!moveLoading" style="max-height:240px;overflow-y:auto;">
|
||||
<template x-for="ws in moveWorkspaces" :key="ws.id">
|
||||
<div class="move-ws-item"
|
||||
@click="doMove(ws.id)"
|
||||
style="display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;">
|
||||
<span>{{ fd_icon("folder",14) }}</span>
|
||||
<span x-text="ws.name" style="flex:1;"></span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);" x-text="ws.page_count + ' pages'"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="moveWorkspaces.length === 0" style="color:var(--text-tertiary);text-align:center;padding:12px;">
|
||||
No workspaces available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Page Content ═══════════ -->
|
||||
<div class="page-cover-area">
|
||||
<div class="page-title-block">
|
||||
<span class="page-icon-emoji">{% if page.content_format == 'file' %}{{ fd_icon("paperclip",14) }}{% else %}{{ fd_icon("file",14) }}{% endif %}</span>
|
||||
<div
|
||||
class="page-title-input"
|
||||
contenteditable="true"
|
||||
id="_titleEl"
|
||||
@input="dirty=true;save()"
|
||||
@keydown.enter.prevent="focusBlock()"
|
||||
@paste.prevent="pastePlain($event)"
|
||||
spellcheck="false"
|
||||
>
|
||||
{{ page.title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="blocks-container"
|
||||
id="_blocksCt"
|
||||
@click="onCtClick($event)"
|
||||
></div>
|
||||
<div
|
||||
id="_slashMenu"
|
||||
class="slash-menu"
|
||||
style="display: none; position: fixed; z-index: 999"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="format-toolbar"
|
||||
x-show="fmt.open"
|
||||
:style="{top:fmt.top+'px',left:fmt.left+'px'}"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<button @click="fmtApply('bold')"><strong>B</strong></button>
|
||||
<button @click="fmtApply('italic')"><em>I</em></button>
|
||||
<button @click="fmtApply('underline')"><u>U</u></button>
|
||||
<button @click="fmtApply('strikeThrough')"><s>S</s></button>
|
||||
<button @click="fmtLink()">{{ fd_icon("link",14) }}</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Copy Link Toast ═══════════ -->
|
||||
<div
|
||||
class="sd-toast"
|
||||
x-show="toastVisible"
|
||||
x-transition
|
||||
x-text="toastMsg"
|
||||
></div>
|
||||
|
||||
<!-- ═══════════ Get Started Toolbar (bottom) ═══════════ -->
|
||||
<div
|
||||
class="get-started-toolbar"
|
||||
x-show="blocks.length === 1 && blocks[0].type === 'paragraph' && !blocks[0].content.trim()"
|
||||
>
|
||||
<span class="gs-label">Get started with</span>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="replaceBlock(0, 'paragraph');blocks[0].content='Ask AI to write...';render()"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon("sparkles",14) }}</span> Ask AI
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="replaceBlock(0, 'heading_1');blocks[0].content='AI Meeting Note';render()"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon('edit',14) }}</span> AI meeting note
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="replaceBlock(0, 'paragraph');blocks[0].content='Database';render()"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon("folder",14) }}</span> Database
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="replaceBlock(0, 'paragraph');blocks[0].content='Form';render()"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon("copy",14) }}</span> Form
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="replaceBlock(0, 'paragraph');blocks[0].content='Templates';render()"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon("file",14) }}</span> Templates
|
||||
</button>
|
||||
<div style="position: relative">
|
||||
<button class="gs-pill" @click="gsMoreOpen = !gsMoreOpen">
|
||||
<span>⋯</span>
|
||||
</button>
|
||||
<div
|
||||
class="gs-more-dropdown"
|
||||
x-show="gsMoreOpen"
|
||||
@click.outside="gsMoreOpen = false"
|
||||
x-transition
|
||||
>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("bar-chart",14) }} Table
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("copy",14) }} Board
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon('list',14) }} List
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("calendar",14) }} Timeline
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("calendar",14) }} Calendar
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("image",14) }} Gallery
|
||||
</div>
|
||||
<div class="gs-more-sep"></div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("download",14) }} Import
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-statusbar">
|
||||
<span x-show="saving">Saving...</span>
|
||||
<span x-show="!saving&&dirty">Unsaved</span>
|
||||
<span x-show="!saving&&!dirty&&lastSaved"
|
||||
>Saved <span x-text="lastSaved"></span
|
||||
></span>
|
||||
<span class="statusbar-right" x-text="blocks.length+' blocks'"></span>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script type="application/json" id="page-data">
|
||||
{{ page_data | tojson }}
|
||||
</script>
|
||||
<script>
|
||||
/* eslint-disable */
|
||||
const CMDS=[{name:'BASIC',items:[
|
||||
{id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'},
|
||||
{id:'heading_3',name:'Heading 3',icon:'H3'},{id:'heading_4',name:'Heading 4',icon:'H4'},{id:'bulleted_list',name:'Bulleted list',icon:'•'},{id:'numbered_list',name:'Numbered list',icon:'1.'},
|
||||
{id:'to_do',name:'To-do',icon:'☐'},{id:'toggle',name:'Toggle',icon:'▶'},{id:'quote',name:'Quote',icon:'❝'},
|
||||
{id:'callout',name:'Callout',icon:'lightbulb'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'},
|
||||
]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'image'}]}];
|
||||
|
||||
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
|
||||
function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;}
|
||||
function ce(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(false);window.getSelection().removeAllRanges();window.getSelection().addRange(r);}
|
||||
function cs(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(true);window.getSelection().removeAllRanges();window.getSelection().addRange(r);}
|
||||
function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
|
||||
const SM={_o:false,_i:-1,_s:0,_q:'',_it:[],
|
||||
init(){const e=document.getElementById('_slashMenu');if(!e)return;e.innerHTML='';
|
||||
const inp=document.createElement('input');inp.type='text';inp.placeholder='Filter...';
|
||||
inp.style.cssText='width:100%;padding:6px 10px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box';
|
||||
inp.oninput=()=>SM.flt();inp.onkeydown=ev=>SM._kd(ev);
|
||||
const hdr=document.createElement('div');hdr.style.cssText='padding:8px;border-bottom:1px solid var(--border)';hdr.appendChild(inp);
|
||||
const list=document.createElement('div');list.style.cssText='overflow-y:auto;max-height:340px';
|
||||
list.addEventListener('click',ev=>{const it=ev.target.closest('.slash-item');if(it){ev.preventDefault();SM.sel(it.dataset.sid);}});
|
||||
e.appendChild(hdr);e.appendChild(list);
|
||||
SM._e=e;SM._in=inp;SM._li=list;
|
||||
document.addEventListener('mousedown',ev=>{if(!e.contains(ev.target))SM.close();});
|
||||
},
|
||||
open(i,el){if(!SM._e)SM.init();SM.close();SM._i=i;SM._s=0;SM._in.value='';SM.flt();
|
||||
const r=el.getBoundingClientRect();SM._e.style.display='block';
|
||||
SM._e.style.top=Math.max(4,(r.bottom+4>window.innerHeight-340)?r.top-344:r.bottom+4)+'px';
|
||||
SM._e.style.left=Math.min(r.left,window.innerWidth-324)+'px';
|
||||
setTimeout(()=>SM._in.focus(),10);SM._o=true;},
|
||||
close(){if(SM._e)SM._e.style.display='none';SM._o=false;SM._in&&(SM._in.value='');},
|
||||
flt(){if(!SM._li)return;SM._it=[];const q=(SM._in.value||'').toLowerCase();SM._q=q;let h='';
|
||||
for(const g of CMDS){let items=g.items;if(q)items=items.filter(c=>c.name.toLowerCase().includes(q)||c.id.includes(q));if(!items.length)continue;
|
||||
h+=`<div style="padding:6px 12px 4px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px">${g.name}</div>`;
|
||||
items.forEach(c=>{SM._it.push(c);
|
||||
h+=`<div class="slash-item" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:8px 12px;font-size:14px;color:var(--text-primary);cursor:pointer">
|
||||
<span style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;font-size:16px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span>
|
||||
<span style="flex:1;font-weight:500">${c.name}</span></div>`;});}
|
||||
SM._li.innerHTML=h;},
|
||||
_kd(e){if(e.key==='ArrowDown'){e.preventDefault();SM._s=Math.min(SM._s+1,SM._it.length-1);SM._rs();}
|
||||
else if(e.key==='ArrowUp'){e.preventDefault();SM._s=Math.max(SM._s-1,0);SM._rs();}
|
||||
else if(e.key==='Enter'){e.preventDefault();const c=SM._it[SM._s];if(c)SM.sel(c.id);}
|
||||
else if(e.key==='Escape'){e.preventDefault();SM.close();}},
|
||||
_rs(){const its=SM._li.querySelectorAll('.slash-item');its.forEach((el,i)=>{el.classList.toggle('selected',i===SM._s);});},
|
||||
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
|
||||
get isOpen(){return SM._o;}};
|
||||
|
||||
function renderBlock(b,idx){
|
||||
if(b.type==='divider')return `<div class="block-wrapper"><hr class="block-divider"></div>`;
|
||||
if(b.type==='image')return `<div class="block-wrapper"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'"></div></div>`;
|
||||
if(b.type==='embed'){
|
||||
if(b.embed_type==='pdf')return `<div class="block-wrapper"><div class="block-content block-embed" style="padding:0;"><iframe src="${b.src||''}" style="width:100%;height:80vh;border:none;border-radius:8px;" onerror="this.style.display='none';this.nextElementSibling.style.display='block';"></iframe><div style="display:none;text-align:center;padding:32px;color:var(--text-secondary);"><svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><p style="margin-top:8px;">Failed to load PDF</p><a href="${b.src||''}" target="_blank" style="color:var(--accent);">Open in new tab →</a></div></div></div>`;
|
||||
if(b.embed_type==='download'){
|
||||
var sz=b.file_size||0;var szStr=sz<1024?sz+' B':sz<1048576?(sz/1024).toFixed(1)+' KB':(sz/1048576).toFixed(1)+' MB';
|
||||
return `<div class="block-wrapper"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;"><div style="font-size:48px;margin-bottom:12px;">{{ fd_icon("paperclip",14) }}</div><div style="font-size:16px;font-weight:500;color:var(--text-primary);margin-bottom:4px;">${esc(b.file_name||'File')}</div><div style="font-size:12px;color:var(--text-tertiary);margin-bottom:16px;">${szStr} · ${esc(b.file_mime||'')}</div><a href="${b.src||''}" download style="display:inline-block;padding:8px 20px;background:var(--accent);color:#fff;text-decoration:none;border-radius:8px;font-size:13px;font-weight:500;">⬇ Download</a></div></div>`;
|
||||
}
|
||||
}
|
||||
const ph=b.type==='heading_1'?'Heading 1':b.type==='heading_2'?'Heading 2':b.type==='heading_3'?'Heading 3':b.type==='heading_4'?'Heading 4':b.type==='bulleted_list'?'List':b.type==='numbered_list'?'List':b.type==='to_do'?'To-do':b.type==='toggle'?'Toggle list':b.type==='quote'?'Empty quote':b.type==='code'?'Type code...':b.type==='callout'?'Type something...':'Press \'/\' for commands...';
|
||||
let inner='';
|
||||
if(b.type==='to_do')inner=`<input type="checkbox" class="todo-checkbox" ${b.checked?'checked':''} onchange="E._doToggle('${b.id}',this.checked)"><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
|
||||
else if(b.type==='toggle')inner=`<button class="toggle-chevron ${b.expanded?'open':''}" onclick="E._doToggleExpand('${b.id}')">▶</button><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
|
||||
else if(b.type==='code')inner=`<div class="code-lang-label">${b.language||'Plain Text'}</div><pre><code data-bid="${b.id}" contenteditable="true" spellcheck="false">${esc(b.content)}</code></pre>`;
|
||||
else if(b.type==='callout')inner=`<span class="callout-icon">${b.icon||'lightbulb'}</span><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
|
||||
else inner=`<div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
|
||||
const tag=b.type.startsWith('heading_')?'h'+b.type.slice(-1):b.type==='quote'?'blockquote':'div';
|
||||
const cls=tag==='div'?`block-content ${b.type==='bulleted_list'?'block-bullet':b.type==='numbered_list'?'block-numbered':b.type==='to_do'?'block-todo':b.type==='toggle'?'block-toggle':b.type==='code'?'block-code':b.type==='callout'?'block-callout':''}`:`block-content block-${tag}`;
|
||||
const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}"`:'';
|
||||
return `<div class="block-wrapper">
|
||||
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
|
||||
<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>
|
||||
<${tag}${style} class="${cls}">${inner}</${tag}>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
window.E=null;
|
||||
function editorState(){
|
||||
return {
|
||||
pid:null,pageTitle:'',blocks:[],dirty:false,saving:false,lastSaved:'',st:null,
|
||||
updatedAt:'{{ page.get("updated_at", "") }}',
|
||||
fmt:{open:false,top:0,left:0,idx:-1},
|
||||
shareOpen:false,moreOpen:false,gsMoreOpen:false,accessMenuOpen:false,
|
||||
moveOpen:false,moveWorkspaces:[],moveLoading:false,
|
||||
activityOpen:false,pageCreated:'{{ page.get("created_at", "") }}',
|
||||
shareTab:'share',favorited:false,
|
||||
pagePublished:{{ page_published | tojson }},
|
||||
generalAccess:'{{ page_share_mode }}',publicPerm:'viewer',
|
||||
pubAllowEdit:false,pubAllowComments:true,
|
||||
pageUrl:window.location.href,publishedUrl:'',
|
||||
inviteEmail:'',invitePermission:'editor',accessList:[],
|
||||
toastVisible:false,toastMsg:'',
|
||||
get timeAgo() {
|
||||
if (!this.updatedAt) return 'just now';
|
||||
var diff = Math.floor((Date.now() - new Date(this.updatedAt + 'Z').getTime()) / 1000);
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
},
|
||||
formatDate(d) {
|
||||
if (!d) return '';
|
||||
var dt = new Date(d + 'Z');
|
||||
return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +
|
||||
' at ' + dt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
},
|
||||
init(){
|
||||
_bid=Math.floor(Math.random()*10000);
|
||||
const dataEl=document.getElementById('page-data');
|
||||
if(dataEl){try{const data=JSON.parse(dataEl.textContent);this.pid=data.id;this.pageTitle=data.title||'';this.favorited=data.favorited||false;const fmt=data.content_format||'blocks';const raw=data.content||'';
|
||||
if(fmt==='file'){this.fileData=data;this.loadFileContent(data);}
|
||||
else if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);this.blocks.forEach(b=>{if(!b.id)b.id=genId()});}catch(e){this.blocks=[];}}
|
||||
else if(raw&&raw.trim())this.blocks=this.md2b(raw);}catch(e){}}
|
||||
if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];
|
||||
window.E=this;SM.init();this.render();
|
||||
setTimeout(()=>{this.focusBlock();const ct=document.getElementById('_blocksCt');if(!ct)return;
|
||||
ct.addEventListener('keydown',e=>this.onKd(e));
|
||||
ct.addEventListener('paste',e=>{e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();});
|
||||
if(typeof Sortable!=='undefined')Sortable.create(ct,{draggable:'.block-wrapper',handle:'.block-handle',animation:150,ghostClass:'sortable-ghost',dragClass:'sortable-drag',onEnd:evt=>{if(evt.oldIndex===evt.newIndex)return;const[item]=this.blocks.splice(evt.oldIndex,1);this.blocks.splice(evt.newIndex,0,item);this.dirty=true;this.autoSave();this.render();}});
|
||||
},100);
|
||||
},
|
||||
fileData:null,
|
||||
async loadFileContent(data){
|
||||
const url=data.file_url||'';const mime=data.file_mime||'';const name=data.file_name||'';const self=this;
|
||||
if(!url){this.blocks=[this.mkB('paragraph','File not available')];this.render();return;}
|
||||
const ext=name.toLowerCase().split('.').pop();
|
||||
if(mime.startsWith('image/')||['png','jpg','jpeg','gif','webp','svg','bmp','ico'].includes(ext)){
|
||||
this.blocks=[this.mkB('image','',{src:url,alt:name})];this.render();return;
|
||||
}
|
||||
if(mime==='application/pdf'||ext==='pdf'){
|
||||
this.blocks=[this.mkB('embed','',{src:url,embed_type:'pdf'})];this.render();return;
|
||||
}
|
||||
const codeExts=['py','js','ts','jsx','tsx','html','htm','css','json','xml','yaml','yml','md','markdown','sql','sh','bash','ps1','bat','cmd','rb','go','rs','java','c','cpp','h','hpp','php','swift','kt','scala','r','toml','ini','cfg','conf','env','txt','log'];
|
||||
const langMap={'py':'python','js':'javascript','ts':'typescript','jsx':'javascript','tsx':'typescript','html':'html','htm':'html','css':'css','json':'json','xml':'xml','yaml':'yaml','yml':'yaml','md':'markdown','markdown':'markdown','sql':'sql','sh':'bash','bash':'bash','ps1':'powershell','bat':'plaintext','cmd':'plaintext','rb':'ruby','go':'go','rs':'rust','java':'java','c':'c','cpp':'cpp','h':'c','hpp':'cpp','php':'php','swift':'swift','kt':'kotlin','scala':'scala','r':'r','toml':'toml','ini':'ini','cfg':'ini','conf':'ini','env':'ini','txt':'plaintext','log':'plaintext'};
|
||||
if(mime.startsWith('text/')||codeExts.includes(ext)){
|
||||
try{const r=await fetch(url);const text=await r.text();
|
||||
if(ext==='md'||ext==='markdown'){this.blocks=this.md2b(text);}
|
||||
else{this.blocks=[this.mkB('code',text,{language:langMap[ext]||'Plain Text'})];}
|
||||
this.render();
|
||||
}catch(e){this.blocks=[this.mkB('paragraph','Error loading file: '+e.message)];this.render();}
|
||||
return;
|
||||
}
|
||||
this.blocks=[this.mkB('embed','',{src:url,embed_type:'download',file_name:name,file_size:data.file_size||0,file_mime:mime})];this.render();
|
||||
},
|
||||
mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;return b;},
|
||||
getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;},
|
||||
getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);},
|
||||
getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};},
|
||||
focusBlock(){const b=this.blocks[0];if(!b)return;const el=this.getEl(b.id);if(el){el.focus();ce(el);}},
|
||||
onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}},
|
||||
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='divider'||b.type==='image'||b.type==='embed')continue;const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el)b.content=el.textContent||'';}},
|
||||
render(){const ct=document.getElementById('_blocksCt');if(!ct)return;let html='';for(let i=0;i<this.blocks.length;i++)html+=renderBlock(this.blocks[i],i);ct.innerHTML=html;
|
||||
ct.querySelectorAll('.block-wrapper').forEach(w=>{w.onmouseenter=()=>{w.querySelector('.block-handle')?.classList.add('visible');w.querySelector('.block-actions')?.classList.add('visible');};w.onmouseleave=()=>{w.querySelector('.block-handle')?.classList.remove('visible');w.querySelector('.block-actions')?.classList.remove('visible');};});
|
||||
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
|
||||
},
|
||||
replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);},
|
||||
addAt(idx,type,content){this.sync();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;},
|
||||
addAfter(idx,type){return this.addAt(idx+1,type);},
|
||||
removeBlock(idx){this.sync();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
|
||||
|
||||
toggleFavorite(){
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const m=this.favorited?'DELETE':'POST';
|
||||
fetch(`/board/api/favorites/${this.pid}`,{method:m,headers:{'X-CSRF-Token':csrf?csrf[1]:''}})
|
||||
.then(r=>r.json()).then(()=>{this.favorited=!this.favorited;this.showToast(this.favorited?'Added to favorites':'Removed from favorites');
|
||||
if(window.appState&&window.appState.refreshFavorites)window.appState.refreshFavorites();
|
||||
}).catch(()=>{this.showToast('Failed to toggle favorite');});
|
||||
},
|
||||
toggleActivityOpen(){
|
||||
if(!this.activityOpen){this.activityOpen=true;this._justOpened=true;setTimeout(()=>this._justOpened=false,200);}
|
||||
else{this.activityOpen=false;}
|
||||
},
|
||||
toggleShareOpen(){
|
||||
if(!this.shareOpen){this.shareOpen=true;this._justOpened=true;setTimeout(()=>this._justOpened=false,200);}
|
||||
else{this.shareOpen=false;}
|
||||
},
|
||||
toggleMoreOpen(){
|
||||
if(!this.moreOpen){this.moreOpen=true;this._justOpened=true;setTimeout(()=>this._justOpened=false,200);}
|
||||
else{this.moreOpen=false;}
|
||||
},
|
||||
_justOpened:false,
|
||||
togglePublish(){this.pagePublished=!this.pagePublished;this.saveShare();},
|
||||
saveShare(){
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/board/api/share/${this.pid}`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({mode:this.generalAccess,published:this.pagePublished})}).catch(()=>{});
|
||||
},
|
||||
async copyPageLink(){
|
||||
console.log('copyPageLink invoked');
|
||||
const url = this.pageUrl;
|
||||
const fallback = () => {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = url; ta.style.position='fixed'; ta.style.opacity='0';
|
||||
document.body.appendChild(ta); ta.select();
|
||||
document.execCommand('copy'); document.body.removeChild(ta);
|
||||
};
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
this.showToast('Link copied to clipboard');
|
||||
} catch(e) { fallback(); this.showToast('Link copied to clipboard'); }
|
||||
},
|
||||
showToast(msg){
|
||||
this.toastMsg = msg; this.toastVisible = true;
|
||||
clearTimeout(this._toastTimer);
|
||||
this._toastTimer = setTimeout(() => { this.toastVisible = false; }, 2500);
|
||||
},
|
||||
publishPage() {
|
||||
var self = this;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/api/pages/' + this.pid + '/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (d.is_published) {
|
||||
self.pagePublished = true;
|
||||
self.publishedUrl = window.location.origin + '/p/' + d.publish_slug;
|
||||
self.showToast('Published to web — ' + self.publishedUrl);
|
||||
} else {
|
||||
self.showToast(d.detail || 'Publish failed');
|
||||
}
|
||||
}).catch(function(){ self.showToast('Publish failed'); });
|
||||
},
|
||||
unpublishPage() {
|
||||
var self = this;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/api/pages/' + this.pid + '/publish', {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (!d.is_published) {
|
||||
self.pagePublished = false;
|
||||
self.publishedUrl = '';
|
||||
self.showToast('Unpublished');
|
||||
} else {
|
||||
self.showToast(d.detail || 'Unpublish failed');
|
||||
}
|
||||
}).catch(function(){ self.showToast('Unpublish failed'); });
|
||||
},
|
||||
shareInvite() {
|
||||
var self = this;
|
||||
if (!this.inviteEmail.trim()) return;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/api/pages/' + this.pid + '/share', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||
body: JSON.stringify({ email: this.inviteEmail.trim(), permission: this.invitePermission })
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (d.status === 'shared') { self.inviteEmail = ''; self.showToast('Invitation sent'); self.loadShares(); }
|
||||
else { self.showToast(d.detail || 'Share failed'); }
|
||||
}).catch(function(){ self.showToast('Share failed'); });
|
||||
},
|
||||
loadShares() {
|
||||
var self = this;
|
||||
fetch('/api/pages/' + this.pid + '/shares').then(function(r){ return r.json(); }).then(function(d){
|
||||
self.accessList = d.shares || [];
|
||||
}).catch(function(){ self.accessList = []; });
|
||||
},
|
||||
removeShare(sid) {
|
||||
var self = this;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/api/pages/' + this.pid + '/share/' + sid, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (d.status === 'removed') { self.showToast('Share removed'); self.loadShares(); }
|
||||
else { self.showToast(d.detail || 'Remove failed'); }
|
||||
}).catch(function(){ self.showToast('Remove failed'); });
|
||||
},
|
||||
invitePerson(){if(!this.inviteEmail.trim())return;this.accessList.push({email:this.inviteEmail,permission:this.invitePermission,permOpen:false});this.inviteEmail='';},
|
||||
removeAccess(email){this.accessList=this.accessList.filter(a=>a.email!==email);},
|
||||
exportPage(){
|
||||
this.moreOpen=false;
|
||||
// Export blocks as Markdown
|
||||
var md = '# ' + (this.pageTitle || 'Untitled') + '\n\n';
|
||||
for (var i = 0; i < this.blocks.length; i++) {
|
||||
var b = this.blocks[i];
|
||||
md += blocksToMarkdown(b) + '\n\n';
|
||||
}
|
||||
var blob = new Blob([md], {type: 'text/markdown'});
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url; a.download = (this.pageTitle || 'export') + '.md';
|
||||
a.click(); URL.revokeObjectURL(url);
|
||||
this.showToast('Exported as Markdown');
|
||||
},
|
||||
duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}§ion=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{window.showToast('Duplicate failed','error');});},
|
||||
movePage(){
|
||||
this.moreOpen=false;
|
||||
this.moveOpen = true;
|
||||
this.moveLoading = true;
|
||||
var self = this;
|
||||
fetch('/api/workspaces')
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
self.moveWorkspaces = d.workspaces || [];
|
||||
self.moveLoading = false;
|
||||
})
|
||||
.catch(function(){ self.moveLoading = false; });
|
||||
},
|
||||
doMove(wsId) {
|
||||
this.moveOpen = false;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var self = this;
|
||||
fetch('/api/pages/' + this.pid + '/move', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||
body: JSON.stringify({ workspace_id: wsId })
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
if (d.status === 'ok') {
|
||||
self.showToast('Moved to workspace');
|
||||
setTimeout(function(){ window.location.href = '/local-workspace'; }, 800);
|
||||
} else {
|
||||
self.showToast(d.detail || 'Move failed');
|
||||
}
|
||||
})
|
||||
.catch(function(){ self.showToast('Move failed'); });
|
||||
},
|
||||
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
|
||||
|
||||
onKd(e){
|
||||
const ab=this.getActiveBlock();if(!ab)return;
|
||||
const{el,bid,idx}=ab;const block=this.blocks[idx];if(!block)return;
|
||||
if(SM.isOpen){
|
||||
if(e.key==='Escape'){e.preventDefault();SM.close();return;}
|
||||
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){e.preventDefault();SM._in.dispatchEvent(new KeyboardEvent('keydown',{key:e.key,bubbles:true}));return;}
|
||||
if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;}
|
||||
return;
|
||||
}
|
||||
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen)SM.open(i2,e2);},15);}
|
||||
if(e.key===' '){const i2=idx;setTimeout(()=>this.checkMd(i2),15);}
|
||||
if(e.key==='Enter'){
|
||||
if(e.shiftKey){this.dirty=true;this.autoSave();return;}
|
||||
e.preventDefault();
|
||||
const text=el.textContent||'',pos=cp(el),before=text.substring(0,pos),after=text.substring(pos);
|
||||
if(!before.trim()&&!after.trim()){
|
||||
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
|
||||
this.sync();this.blocks.splice(idx+1,0,this.mkB(nt,''));this.render();
|
||||
setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();ce(ne);}},60);
|
||||
this.dirty=true;this.autoSave();return;
|
||||
}
|
||||
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
|
||||
this.sync();block.content=before;this.blocks.splice(idx+1,0,this.mkB(nt,after));this.render();
|
||||
setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();cs(ne);}},60);
|
||||
this.dirty=true;this.autoSave();return;
|
||||
}
|
||||
if(e.key==='ArrowUp'){if(cp(el)===0&&idx>0){e.preventDefault();const prev=this.getEl(this.blocks[idx-1]?.id);if(prev){prev.focus();ce(prev);}}return;}
|
||||
if(e.key==='ArrowDown'){if(cp(el)>=(el.textContent||'').length&&idx<this.blocks.length-1){e.preventDefault();const next=this.getEl(this.blocks[idx+1]?.id);if(next){next.focus();cs(next);}}return;}
|
||||
if(e.key==='Backspace'){if(!(el.textContent||'').trim()){this.sync();if(block.type!=='paragraph'){e.preventDefault();block.type='paragraph';block.content='';this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;}
|
||||
if(e.key===' '){const b2=bid;setTimeout(()=>this.checkMd(b2),15);}
|
||||
if((e.ctrlKey||e.metaKey)&&!e.altKey){const m={b:'bold',i:'italic',u:'underline'};if(m[e.key]){e.preventDefault();document.execCommand(m[e.key]);this.dirty=true;this.autoSave();}}
|
||||
if(e.key==='Tab'&&block.type==='code'){e.preventDefault();document.execCommand('insertText',false,' ');}
|
||||
this.dirty=true;this.autoSave();
|
||||
},
|
||||
checkMd(idx){
|
||||
if(idx<0||idx>=this.blocks.length)return;const block=this.blocks[idx];const el=this.getEl(block.id);if(!el)return;const text=el.textContent||'';
|
||||
const sc={'# ':'heading_1','## ':'heading_2','### ':'heading_3','#### ':'heading_4','- ':'bulleted_list','* ':'bulleted_list','+ ':'bulleted_list','1. ':'numbered_list','[] ':'to_do','[ ] ':'to_do','> ':'quote'};
|
||||
this.sync();
|
||||
for(const[pfx,type] of Object.entries(sc)){if(text===pfx||text.startsWith(pfx)){block.type=type;block.content='';if(type==='toggle')block.expanded=true;if(type==='to_do')block.checked=false;this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne&&type!=='divider')ne.focus();},60);return;}}
|
||||
if(text==='---'){block.type='divider';block.content='';this.addAfter(idx,'paragraph');}
|
||||
if(text==='```'){block.type='code';block.content='';block.language='Plain Text';this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}
|
||||
},
|
||||
applySlash(id){
|
||||
const idx=SM._i;if(idx<0||idx>=this.blocks.length)return;
|
||||
const block=this.blocks[idx];this.sync();block.content='';
|
||||
if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');}
|
||||
else{block.type=id;if(id==='toggle')block.expanded=true;if(id==='to_do')block.checked=false;if(id==='code')block.language='Plain Text';}
|
||||
this.render();this.dirty=true;this.autoSave();
|
||||
setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);
|
||||
},
|
||||
_doToggle(bid,v){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].checked=v;this.dirty=true;this.autoSave();}},
|
||||
_doToggleExpand(bid){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].expanded=!this.blocks[idx].expanded;this.dirty=true;this.autoSave();}},
|
||||
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
|
||||
fmtApply(f){this.fmt.open=false;if(!this._sel)return;const s=window.getSelection();s.removeAllRanges();s.addRange(this._sel.range);document.execCommand(f==='strikeThrough'?'strikeThrough':f);this._sel=null;this.dirty=true;this.autoSave();},
|
||||
fmtLink(){this.fmt.open=false;const u=prompt('URL:');if(u){document.execCommand('createLink',false,u);this.dirty=true;this.autoSave();}},
|
||||
pastePlain(e){e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();},
|
||||
autoSave(){if(this.fileData)return;clearTimeout(this.st);this.st=setTimeout(()=>this.save(),1500);},
|
||||
save(){
|
||||
if(this.fileData)return;
|
||||
if(this.saving)return;
|
||||
this.sync();
|
||||
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'New page';
|
||||
this.saving=true;
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
|
||||
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
|
||||
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
|
||||
}).catch(()=>{this.saving=false;});
|
||||
},
|
||||
md2b(md){const bl=[];let cb=null;for(const line of md.split('\n')){if(cb){if(line.trim()==='```'){bl.push(cb);cb=null;}else cb.content+=(cb.content?'\n':'')+line;continue;}const t=line.trim();if(!t)continue;if(t.startsWith('```')){cb=this.mkB('code','',{language:t.substring(3).trim()||'Plain Text'});continue;}if(t.startsWith('# ')&&!t.startsWith('## '))bl.push(this.mkB('heading_1',t.substring(2)));else if(t.startsWith('## ')&&!t.startsWith('### '))bl.push(this.mkB('heading_2',t.substring(3)));else if(t.startsWith('### ')&&!t.startsWith('#### '))bl.push(this.mkB('heading_3',t.substring(4)));else if(t.startsWith('#### '))bl.push(this.mkB('heading_4',t.substring(5)));else if(t==='---'||t==='***')bl.push(this.mkB('divider',''));else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));else if(/^\d+\. /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+\. /,'')));else if(t.startsWith('- [ ] ')||t.startsWith('* [ ] '))bl.push(this.mkB('to_do',t.substring(6)));else if(t.startsWith('- [x] ')||t.startsWith('* [x] '))bl.push(this.mkB('to_do',t.substring(6),{checked:true}));else if(t.startsWith('> '))bl.push(this.mkB('quote',t.substring(2)));else bl.push(this.mkB('paragraph',t));}if(cb)bl.push(cb);return bl;},
|
||||
};
|
||||
}
|
||||
document.addEventListener('mouseup',()=>{setTimeout(()=>{const s=window.getSelection();if(!s||s.isCollapsed)return;const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;const d=ed.__x.$data;const ct=document.getElementById('_blocksCt');if(!ct?.contains(s.anchorNode))return;const bel=s.anchorNode.parentElement?.closest('[data-bid]');if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);}},80);});
|
||||
function blocksToMarkdown(b) {
|
||||
var c = b.content || '';
|
||||
switch(b.type) {
|
||||
case 'heading_1': return '# ' + c;
|
||||
case 'heading_2': return '## ' + c;
|
||||
case 'heading_3': return '### ' + c;
|
||||
case 'heading_4': return '#### ' + c;
|
||||
case 'bulleted_list': return '- ' + c;
|
||||
case 'numbered_list': return '1. ' + c;
|
||||
case 'to_do': return (b.checked ? '- [x] ' : '- [ ] ') + c;
|
||||
case 'quote': return '> ' + c;
|
||||
case 'divider': return '---';
|
||||
case 'code': return '```' + (b.language || '') + '\n' + c + '\n```';
|
||||
case 'toggle': return '## ' + c;
|
||||
default: return c;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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 -->
|
||||
<div id="view-content" x-data="tableView()">
|
||||
<div class="table-container">
|
||||
@@ -9,23 +10,23 @@
|
||||
<span x-show="sortField === 'name'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||
</th>
|
||||
<th @click="sortBy('status')">
|
||||
<span class="th-icon">⚙</span> Status
|
||||
<span class="th-icon">{{ fd_icon("settings",14) }}</span> Status
|
||||
<span x-show="sortField === 'status'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||
</th>
|
||||
<th @click="sortBy('assignee')">
|
||||
<span class="th-icon">👤</span> Assign
|
||||
<span class="th-icon">{{ fd_icon("user",14) }}</span> Assign
|
||||
<span x-show="sortField === 'assignee'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||
</th>
|
||||
<th @click="sortBy('deadline')">
|
||||
<span class="th-icon">📅</span> Deadline
|
||||
<span class="th-icon">{{ fd_icon("calendar",14) }}</span> Deadline
|
||||
<span x-show="sortField === 'deadline'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||
</th>
|
||||
<th @click="sortBy('team')">
|
||||
<span class="th-icon">👥</span> Team
|
||||
<span class="th-icon">{{ fd_icon("users",14) }}</span> Team
|
||||
<span x-show="sortField === 'team'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||
</th>
|
||||
<th @click="sortBy('keywords')">
|
||||
<span class="th-icon">🏷</span> AI keywords
|
||||
<span class="th-icon">{{ fd_icon("tag",14) }}</span> AI keywords
|
||||
<span x-show="sortField === 'keywords'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
<!-- Team Load — Notion-style stacked bar chart -->
|
||||
<div id="view-content" x-data="teamLoad()" class="team-load">
|
||||
{% for member in team_data %}
|
||||
@@ -29,7 +30,7 @@
|
||||
|
||||
{% if not team_data %}
|
||||
<div style="text-align:center; padding:48px; width:100%; color:var(--text-dim);">
|
||||
<div style="font-size:48px; margin-bottom:16px;">👥</div>
|
||||
<div style="margin-bottom:16px;">{{ fd_icon("users",48) }}</div>
|
||||
<h3>No team data</h3>
|
||||
<p style="margin-top:8px;">Assign issues to team members to see their workload.</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}{{ fd_icon("trash",18) }}{% endblock %}
|
||||
{% block page_title %}Trash{% endblock %}
|
||||
{% block title_prefix %}Trash{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">{{ fd_icon("trash",24) }}</span>
|
||||
<h1>Trash</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
|
||||
<!-- Search -->
|
||||
<div style="position:relative; margin-bottom:12px;">
|
||||
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
|
||||
<input type="text" placeholder="Search pages in Trash" x-model="search"
|
||||
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div style="display:flex; gap:8px; margin-bottom:16px;">
|
||||
<button class="trash-filter active">
|
||||
<span style="color:var(--accent);">{{ fd_icon("user",14) }}</span> Last edited by ▾
|
||||
</button>
|
||||
<button class="trash-filter">
|
||||
<span>{{ fd_icon("folder",14) }}</span> In ▾
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Items -->
|
||||
<div style="min-height:200px;">
|
||||
<template x-for="item in filteredItems" :key="item.id">
|
||||
<div class="trash-item">
|
||||
<span class="trash-item-icon" x-text="item.icon"></span>
|
||||
<div class="trash-item-info">
|
||||
<span class="trash-item-name" x-text="item.name"></span>
|
||||
<span class="trash-item-path" x-text="item.path"></span>
|
||||
</div>
|
||||
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">
|
||||
↩️
|
||||
</button>
|
||||
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
|
||||
{{ fd_icon("trash",14) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="filteredItems.length === 0" class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",14) }}</div>
|
||||
<h3>Trash is empty</h3>
|
||||
<p>Deleted pages will appear here for 30 days.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div style="padding:12px 16px; margin-top:16px; background:var(--bg-tertiary); border-radius:8px; font-size:13px; color:var(--text-dim); display:flex; align-items:center; gap:8px;">
|
||||
<span>Once a page has been in Trash for 30 days, it will be automatically deleted</span>
|
||||
<span style="font-size:16px;">ⓘ</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function trashData() {
|
||||
return {
|
||||
search: '',
|
||||
items: [],
|
||||
async init() {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const token = csrf ? csrf[1] : '';
|
||||
try {
|
||||
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': token } });
|
||||
this.items = await r.json();
|
||||
} catch(e) { this.items = []; }
|
||||
},
|
||||
get filteredItems() {
|
||||
const q = this.search.toLowerCase();
|
||||
return this.items.filter(i => !q || i.name.toLowerCase().includes(q) || (i.path||'').toLowerCase().includes(q));
|
||||
},
|
||||
async restore(id) {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const r = await fetch(`/board/api/trash/${id}/restore`, { method: 'POST', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
||||
},
|
||||
async deleteForever(id) {
|
||||
if (!confirm('Permanently delete this page? This cannot be undone.')) return;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const r = await fetch(`/board/api/trash/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,772 @@
|
||||
# Guide Technique — Databases, Views & Tasks Notion → FlowDeck
|
||||
|
||||
> **Version:** 2.0 · **Date:** 2026-07-20
|
||||
> **Auteur:** Hermes-Deepin · **Cible:** FlowDeck v4.0.x+
|
||||
> **Sources:** [Notion Help — Databases](https://www.notion.com/help/category/databases), [Database Views](https://www.notion.com/help/category/database-views), [Tasks & Dependencies](https://www.notion.com/help/tasks-and-dependencies), [Sprints](https://www.notion.com/help/sprints), [Data Sources](https://www.notion.com/help/data-sources-and-linked-databases), [docs/Guide_Complet_Notion_database.md](Guide_Complet_Notion_database.md)
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Introduction : les 3 piliers Notion](#1-introduction)
|
||||
2. [Anatomy d'une Database Notion](#2-anatomy-database)
|
||||
3. [Les propriétés (Database Properties)](#3-propriétés)
|
||||
4. [Les relations et rollups](#4-relations-rollups)
|
||||
5. [Les formules (Formulas)](#5-formules)
|
||||
6. [Templates de database](#6-templates)
|
||||
7. [Les Database Views (10 types)](#7-views)
|
||||
8. [Filtres, tris & regroupements](#8-filtres-tris-groupes)
|
||||
9. [Data Sources & Linked Databases](#9-data-sources)
|
||||
10. [Tasks, Sub-items, Dependencies & Sprints](#10-tasks)
|
||||
11. [My Tasks — vue unifiée](#11-my-tasks)
|
||||
12. [Dashboards](#12-dashboards)
|
||||
13. [Modèle de données FlowDeck](#13-modèle-de-données)
|
||||
14. [Plan d'implémentation (5 phases)](#14-plan-implémentation)
|
||||
15. [Limites Notion à respecter](#15-limites)
|
||||
16. [Références](#16-références)
|
||||
|
||||
---
|
||||
|
||||
## 1. Introduction : les 3 piliers Notion {#1-introduction}
|
||||
|
||||
Notion repose sur 3 concepts fondamentaux interconnectés :
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ NOTION │
|
||||
│ │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ DATABASES │ │ DATABASE VIEWS │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ • Collections de │ │ • 10 types de vues │ │
|
||||
│ │ pages │ │ • Filtres/Tris/ │ │
|
||||
│ │ • Propriétés typées │ │ Groupes │ │
|
||||
│ │ • Relations/Rollups │ │ • Layouts & │ │
|
||||
│ │ • Formules │ │ Dashboards │ │
|
||||
│ │ • Templates │ │ • Charts/Graphiques │ │
|
||||
│ └──────────┬──────────┘ └──────────┬──────────┘ │
|
||||
│ │ │ │
|
||||
│ └────────┬───────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ TASKS │ │
|
||||
│ │ • Sub-items (parent_id auto-référencé) │ │
|
||||
│ │ • Dependencies (bloque/bloqué par) │ │
|
||||
│ │ • Sprints (planification agile) │ │
|
||||
│ │ • My Tasks (vue unifiée cross-databases) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### État actuel FlowDeck (v4.0.x)
|
||||
|
||||
```
|
||||
✅ Workspaces → workspaces, workspace_members
|
||||
✅ Pages + Arbre → pages (parent_id, sort_order)
|
||||
✅ Block Editor → content_format='blocks', JSON blocks
|
||||
✅ Sidebar Notion → Recents, Favorites, Shared, Published, Private
|
||||
✅ Library multi-onglets → library.html + /api/library/*
|
||||
✅ Share / Publish → page_shares, share_mode, publish_slug
|
||||
✅ Recents tracking → recents table
|
||||
✅ Tags → tags, page_tags
|
||||
✅ Gitea / GitHub OAuth → auth_method, OAuth tokens
|
||||
⚠️ Collections (ébauche) → collections, collection_pages
|
||||
⚠️ Collection Properties (ébauche) → collection_properties (21 types définis)
|
||||
⚠️ Collection Views (ébauche) → collection_views (structure partielle)
|
||||
❌ Database multi-sources → ABSENT (data_sources concept)
|
||||
❌ Full-page vs Inline databases → ABSENT
|
||||
❌ Charts, Forms, Maps, Feed → ABSENT
|
||||
❌ Dashboards → ABSENT (combinaison de vues)
|
||||
❌ Templates de database → ABSENT (database_templates ébauche)
|
||||
❌ Tasks / Sub-items → ABSENT
|
||||
❌ Dependencies / Sprints → ABSENT
|
||||
❌ My Tasks (cross-DB unifié) → ABSENT (/my-tasks ébauche statique)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Anatomy d'une Database Notion {#2-anatomy-database}
|
||||
|
||||
### 2.1 Concept fondamental
|
||||
|
||||
Une **Database Notion** est une **collection de pages**, chaque ligne/élément étant une **page Notion complète** — ouvrable et éditable comme n'importe quelle page (texte, images, sous-pages, embeds…).
|
||||
|
||||
| Concept | Définition |
|
||||
|---------|-----------|
|
||||
| **Database** | Conteneur global : 1+ data sources + 1+ vues |
|
||||
| **Data source** | Un ensemble de pages liées. Chaque DB a ≥ 1 source |
|
||||
| **Page / Item** | Une ligne = une page Notion complète, éditable |
|
||||
| **Property** | Champ de métadonnées (date, statut, personne…) |
|
||||
| **View** | Fenêtre d'affichage sur les données (table, board, etc.) |
|
||||
|
||||
### 2.2 Full-page vs Inline
|
||||
|
||||
| Type | Comportement |
|
||||
|------|-------------|
|
||||
| **Full-page** | Apparaît comme une page dans la sidebar, peut être verrouillée |
|
||||
| **Inline** | Intégrée dans une page existante, contrôles masqués au survol, peut être étendue |
|
||||
|
||||
**Transformations :**
|
||||
- Inline → Full-page : cliquer sur **⤡** (étendre) ou glisser vers la sidebar
|
||||
- Full-page → Inline : glisser dans une autre page → `⋮⋮` → Turn into inline
|
||||
|
||||
### 2.3 Création d'une database
|
||||
|
||||
1. **New page** → sous *Get started with*, sélectionner **Table** (ou `•••` → Database)
|
||||
2. **Slash command** : `/database` dans une page existante
|
||||
3. **Options** : New empty / Link to existing data / Build with AI / Template
|
||||
|
||||
**Premières étapes :**
|
||||
1. Créer une page → bouton **New page**
|
||||
2. Ajouter une propriété → **Add property**
|
||||
3. Ajouter une vue → par défaut Table view, puis autant que souhaité
|
||||
4. Éditer la vue → nom, layout, propriétés visibles, filtres, tris, groupes
|
||||
|
||||
---
|
||||
|
||||
## 3. Les propriétés (Database Properties) {#3-propriétés}
|
||||
|
||||
### 3.1 Types disponibles (20 types)
|
||||
|
||||
| # | Propriété | Usage | Value format |
|
||||
|---|-----------|-------|-------------|
|
||||
| 1 | **Text** | Texte formaté (notes, descriptions) | String |
|
||||
| 2 | **Number** | Nombre, format devise ou barre de progression | Float + format |
|
||||
| 3 | **Select** | Une option parmi une liste de tags | String |
|
||||
| 4 | **Status** | Progression : To-do, In Progress, Complete | String |
|
||||
| 5 | **Multi-Select** | Plusieurs tags/catégories | Array[String] |
|
||||
| 6 | **Date** | Date ou plage de dates (heure optionnelle) | ISO 8601 / range |
|
||||
| 7 | **Formula** | Calculs basés sur d'autres propriétés | Expression → Dynamic |
|
||||
| 8 | **Relation** | Connexion entre databases | FK référence |
|
||||
| 9 | **Rollup** | Agrégation d'infos via une relation | Dynamic (count/sum/avg…) |
|
||||
| 10 | **Person** | Assigner une personne/groupe | User ref |
|
||||
| 11 | **File** | Upload de fichiers et images | File metadata |
|
||||
| 12 | **Checkbox** | Booléen | Boolean |
|
||||
| 13 | **URL** | Lien web cliquable | String (URL) |
|
||||
| 14 | **Email** | Adresse email (ouvre client mail) | String (email) |
|
||||
| 15 | **Phone** | Numéro de téléphone | String (phone) |
|
||||
| 16 | **Created time** | Auto-généré, non éditable | Timestamp |
|
||||
| 17 | **Created by** | Auto-généré, non éditable | User ref |
|
||||
| 18 | **Last edited time** | Auto-mis à jour | Timestamp |
|
||||
| 19 | **Last edited by** | Auto-mis à jour | User ref |
|
||||
| 20 | **Button** | Automatiser des actions (v5.0) | Action config |
|
||||
| 21 | **ID** | Identifiant unique, immuable | Auto-incrément |
|
||||
| 22 | **Place** | Localisation (services, nom, adresse) | Geo + text |
|
||||
|
||||
> ⚠️ Limite : 500 propriétés max par database.
|
||||
|
||||
### 3.2 Gestion des propriétés
|
||||
|
||||
- **Créer** : `Add property` ou `+` à droite → nom + type
|
||||
- **Éditer** : menu ⚙️ → Edit properties
|
||||
- **Masquer** : cliquer l'icône 👁️
|
||||
- **Réordonner** : drag & drop
|
||||
- **Dupliquer** : `•••` → Duplicate property (ne duplique pas les valeurs)
|
||||
- **Supprimer** : supprime définitivement la propriété et toutes ses valeurs
|
||||
|
||||
---
|
||||
|
||||
## 4. Relations & Rollups {#4-relations-rollups}
|
||||
|
||||
### 4.1 Relations
|
||||
|
||||
Une **relation** connecte les pages de deux databases (ou d'une database à elle-même).
|
||||
|
||||
```
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ Customers │ │ Deals │
|
||||
│ │────────▶│ │
|
||||
│ id, name, email │ related │ id, amount, stage │
|
||||
│ │◀────────│ │
|
||||
└──────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
**Config :**
|
||||
- **1-way** (par défaut) ou **2-way** (bidirectionnelle)
|
||||
- **Limit** : nombre de pages liées (1 ou illimité)
|
||||
- **Property visibility** : Always show / Hide when empty / Always hide
|
||||
- **Relation vers soi-même** : ex. tâches liées entre elles (dependencies)
|
||||
|
||||
### 4.2 Rollups
|
||||
|
||||
Un **rollup** agrège les données issues d'une relation selon une fonction :
|
||||
|
||||
| Fonction | Description |
|
||||
|----------|-------------|
|
||||
| `count` | Nombre d'éléments liés |
|
||||
| `count values` | Nombre de valeurs non vides |
|
||||
| `count unique values` | Nombre de valeurs uniques |
|
||||
| `count empty` | Nombre de valeurs vides |
|
||||
| `percent empty` | % de valeurs vides |
|
||||
| `percent not empty` | % de valeurs non vides |
|
||||
| `sum` | Somme |
|
||||
| `average` | Moyenne |
|
||||
| `min` / `max` | Valeur min / max |
|
||||
| `range` | Intervalle (max - min) |
|
||||
| `earliest date` / `latest date` | Date la plus ancienne / récente |
|
||||
| `show original` | Affiche la valeur brute |
|
||||
|
||||
> ⚠️ **Pas de rollup d'un rollup** (anti-boucle).
|
||||
|
||||
---
|
||||
|
||||
## 5. Formules (Formulas) {#5-formules}
|
||||
|
||||
Une propriété **Formula** exécute des calculs via le langage de formules Notion.
|
||||
|
||||
### 5.1 Éléments du langage
|
||||
|
||||
```
|
||||
Properties : prop("Nom")
|
||||
Math : + - * / % ^
|
||||
Booléens : true, false
|
||||
Comparaison : ==, !=, <, <=, >, >=
|
||||
Logiques : and, or, not
|
||||
Ternaire : X ? Y : Z (équivaut à if(X, Y, Z))
|
||||
```
|
||||
|
||||
### 5.2 Fonctions courantes
|
||||
|
||||
`if()`, `ifs()`, `empty()`, `length()`, `substring()`, `contains()`, `replace()`, `lower()`, `upper()`, `style()`, `format()`, `formatDate()`, `now()`, `today()`, `dateAdd()`, `dateBetween()`, `.first()`, `.last()`, `.every()`, `.filter()`, `.map()`, `.at()`, `round()`, `ceil()`, `floor()`, `min()`, `max()`, `sum()`, `mean()`, `median()`, `join()`, `toNumber()`, `toString()`, `concat()`, `id()`, `let()`…
|
||||
|
||||
### 5.3 Exemples
|
||||
|
||||
```notion
|
||||
// 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()
|
||||
|
||||
// Priorité calculée à partir de l'impact et l'effort
|
||||
if(and(Impact == "High", Effort == "Low"), "🔥 Quick win", "Normal")
|
||||
|
||||
// Calcul du CA journalier
|
||||
prop("Prix unitaire") * prop("Quantité")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Templates de database {#6-templates}
|
||||
|
||||
Permettent de répliquer une structure de page en un clic.
|
||||
|
||||
**Créer :**
|
||||
1. Flèche déroulante à côté de **New** → **+ New template**
|
||||
2. Nommer le template
|
||||
3. Prédéfinir propriétés (ex. Priority = P1) et contenu (texte, images, embeds)
|
||||
|
||||
**Fonctionnalités :**
|
||||
- **Récurrence** : quotidienne, hebdomadaire, mensuelle, annuelle
|
||||
- **Nesting max** : 3 niveaux (sauf template quotidien → pas de nesting)
|
||||
|
||||
> ⚠️ Templates propres à chaque database. Ne pas pré-remplir les relations dans un template (sinon toutes les pages pointeront sur la même cible).
|
||||
|
||||
---
|
||||
|
||||
## 7. Les Database Views {#7-views}
|
||||
|
||||
Une **vue** est une fenêtre d'affichage sur les données. Chaque vue possède ses propres filtres, tris, groupes et propriétés visibles — **indépendants** de la database source.
|
||||
|
||||
### 7.1 Les 10 types de vues
|
||||
|
||||
| # | Vue | Idéal pour | Caractéristique |
|
||||
|---|-----|-----------|-----------------|
|
||||
| 1 | **Table** | Données structurées, aperçu complet | Lignes × colonnes, calculs en bas de colonne |
|
||||
| 2 | **Board (Kanban)** | Workflows, gestion de tâches | Colonnes regroupées par propriété, cartes |
|
||||
| 3 | **Timeline** | Planification, dépendances | Chronologie horizontale type Gantt |
|
||||
| 4 | **Calendar** | Événements, deadlines | Basé sur propriété Date, vues jour/semaine/mois |
|
||||
| 5 | **List** | Notes, docs, lecture simple | Minimaliste et épurée |
|
||||
| 6 | **Gallery** | Moodboards, portfolios | Cartes visuelles avec aperçu image |
|
||||
| 7 | **Chart** | Analyse, reporting | Barres, courbes, camemberts, donuts, scatter |
|
||||
| 8 | **Form** | Collecte de données, sondages | Formulaire de saisie → crée pages |
|
||||
| 9 | **Map** | Lieux, adresses | Géolocalisation (propriété Place) |
|
||||
| 10 | **Feed** | Mises à jour, annonces | Flux de contenu type actualité |
|
||||
|
||||
### 7.2 Ajouter une vue
|
||||
|
||||
1. Cliquer **+** à côté des onglets de vues existants
|
||||
2. Choisir le type de layout
|
||||
3. Donner un nom
|
||||
4. Configurer : propriétés visibles, filtres, tris, groupes, layout
|
||||
|
||||
### 7.3 Layouts personnalisables
|
||||
|
||||
Chaque vue possède des options de **layout** :
|
||||
- **Card size** : small / medium / large (board, gallery)
|
||||
- **Card preview** : page cover / page content / none
|
||||
- **Properties** : choisir lesquelles afficher sur les cartes
|
||||
- **Open pages in** : center peek / side peek / full page
|
||||
|
||||
---
|
||||
|
||||
## 8. Filtres, tris & regroupements {#8-filtres-tris-groupes}
|
||||
|
||||
### 8.1 Filtres
|
||||
|
||||
Affichent uniquement les éléments correspondant à des critères.
|
||||
|
||||
**Structure :**
|
||||
```
|
||||
┌─ Filter group (AND)
|
||||
│ ├─ Status = "In Progress"
|
||||
│ ├─ Due Date is within this week
|
||||
│ └─ Filter group (OR)
|
||||
│ ├─ Priority = "P1"
|
||||
│ └─ Priority = "P2"
|
||||
```
|
||||
|
||||
**Opérateurs disponibles :** `is`, `is not`, `contains`, `does not contain`, `starts with`, `ends with`, `is empty`, `is not empty`, `is before`, `is after`, `is within`, `is today`, `is this week`
|
||||
|
||||
### 8.2 Tris
|
||||
|
||||
Ordonnent les éléments. Multi-niveaux (ex. trier par Status ASC puis Due Date DESC).
|
||||
|
||||
### 8.3 Regroupements (Groups)
|
||||
|
||||
Regroupent visuellement les éléments par propriété :
|
||||
- **Group** (principal), **Sub-group** (secondaire, surtout en board)
|
||||
- Exemples : par Status, par Assignee, par Date (mois/semaine)
|
||||
|
||||
---
|
||||
|
||||
## 9. Data Sources & Linked Databases {#9-data-sources}
|
||||
|
||||
### 9.1 Concept
|
||||
|
||||
Chaque database a ≥ 1 **data source**. Une database peut en regrouper **plusieurs**.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ DATABASE (CRM tout-en-un) │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Contacts │ │Entreprises│ │ Deals │ │Activités │ │
|
||||
│ │ (source) │ │ (source) │ │ (source) │ │ (linked) │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 9.2 Databases liées
|
||||
|
||||
Une **linked database** est une copie d'une database existante qui :
|
||||
- Peut avoir ses propres vues/filtres/tris/**sans affecter l'originale**
|
||||
- Mais l'édition du contenu/titres/propriétés **est répercutée** sur la source
|
||||
- L'accès respecte celui de la database originale
|
||||
|
||||
---
|
||||
|
||||
## 10. Tasks, Sub-Items, Dependencies & Sprints {#10-tasks}
|
||||
|
||||
### 10.1 Task Database
|
||||
|
||||
Une database standard avec des **propriétés obligatoires** :
|
||||
- **Status** (To-do, In Progress, Done)
|
||||
- **Assignee** (Person)
|
||||
- **Due Date** (Date)
|
||||
|
||||
**Activation :** ⚙️ → More settings → **Turn into Tasks**
|
||||
|
||||
Effets :
|
||||
- Les tâches assignées apparaissent dans **My Tasks** (widget Home)
|
||||
- Propriété **ID** auto-générée (format `TASK-123`)
|
||||
- URL directe : `notion.so/TASK-123`
|
||||
- Fonctionnalités Sprints débloquées
|
||||
|
||||
### 10.2 Sub-items
|
||||
|
||||
Permettent de découper une tâche en sous-tâches, visibles dans toutes les vues.
|
||||
|
||||
**Activation :** ⚙️ → More settings → Sub-items → Turn on
|
||||
|
||||
**Modes d'affichage :**
|
||||
- **Nested in toggle** — arborescence pliable (défaut)
|
||||
- **Flattened list** — liste plate indentée
|
||||
- **Card property** — affichée sur la carte parente
|
||||
|
||||
**Filtres :** Parents only / Parents and sub-items / Sub-items only
|
||||
|
||||
### 10.3 Dependencies
|
||||
|
||||
Relient les tâches de façon linéaire (`A bloque B`).
|
||||
|
||||
**Calendrier automatique :**
|
||||
- Shift only when dates overlap
|
||||
- Shift & maintain time between items
|
||||
- Do not automatically shift
|
||||
|
||||
**Éviction des week-ends** : configurable.
|
||||
|
||||
### 10.4 Sprints
|
||||
|
||||
Nécessitent une **Task database** préalable.
|
||||
|
||||
**Activation :** ⚙️ → More settings → Sprints → Turn on sprints
|
||||
|
||||
**Éléments :**
|
||||
- **Sprint board** : 3 colonnes (Current Sprint / Sprint planning / Backlog)
|
||||
- **Sprints database** : vue globale + Timeline
|
||||
- **Réglages** : durée (1-8 semaines), jour de début, gestion incomplètes, complétion auto
|
||||
- **Vélocité** : suivi de la capacité de l'équipe
|
||||
|
||||
---
|
||||
|
||||
## 11. My Tasks — vue unifiée {#11-my-tasks}
|
||||
|
||||
### 11.1 Qu'est-ce que My Tasks ?
|
||||
|
||||
**My Tasks** agrège **toutes les tâches assignées à l'utilisateur** à travers **toutes les databases** du workspace, dans une vue unique.
|
||||
|
||||
Pas besoin de naviguer dans chaque database pour trouver ses tâches — tout est consolidé.
|
||||
|
||||
### 11.2 Fonctionnement
|
||||
|
||||
1. Une database est marquée comme **Task database**
|
||||
2. L'utilisateur est assigné à certaines pages (propriété **Person**)
|
||||
3. Ces pages apparaissent automatiquement dans **My Tasks**
|
||||
4. Filtrage possible : aujourd'hui, overdue, next 7 days, par collection
|
||||
|
||||
### 11.3 Interface
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 📋 My Tasks │
|
||||
│ │
|
||||
│ [All] [Today] [Overdue] [Next 7 days] │
|
||||
│ │
|
||||
│ ── Collection A (3) ── │
|
||||
│ ┌──────────────────────────────────────────┐ │
|
||||
│ │ 📋 Tâche 1 ⏳ In Progress 15 juil │ │
|
||||
│ │ 📋 Tâche 2 ✅ Done 10 juil │ │
|
||||
│ │ 📋 Tâche 3 📝 Todo — │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ── Collection B (1) ── │
|
||||
│ ┌──────────────────────────────────────────┐ │
|
||||
│ │ 📋 Tâche 4 🚀 P1 20 juil │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 4 tasks across 2 collections │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 11.4 API nécessaire
|
||||
|
||||
```
|
||||
GET /my-tasks?view=all|today|overdue|upcoming&days=7
|
||||
→ Retourne toutes les pages de type tâche assignées à l'utilisateur
|
||||
→ Groupées par collection
|
||||
→ Avec statut, due date, priorité
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Dashboards {#12-dashboards}
|
||||
|
||||
Transforment une database en **tableau de bord** centralisé en combinant plusieurs vues/widgets sur une même page.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ DASHBOARD — Suivi Projet │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ 📊 Chart │ │ 📊 Chart │ │ 📋 Board (Kanban) │ │
|
||||
│ │ Burn-down │ │ Status │ │ Current Sprint │ │
|
||||
│ │ │ │ répartition │ │ │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 📅 Calendar — Échéances du mois │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Modèle de données FlowDeck {#13-modèle-de-données}
|
||||
|
||||
### 13.1 Tables existantes (v4.0) — à conserver
|
||||
|
||||
```sql
|
||||
-- Collections (existantes, à enrichir)
|
||||
CREATE TABLE collections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
icon TEXT DEFAULT '📋',
|
||||
schema_json TEXT NOT NULL DEFAULT '[]', -- propriétés
|
||||
gitea_owner TEXT,
|
||||
gitea_repo TEXT,
|
||||
is_locked BOOLEAN NOT NULL DEFAULT 0,
|
||||
is_inline BOOLEAN NOT NULL DEFAULT 0,
|
||||
parent_page_id INTEGER REFERENCES collection_pages(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
UNIQUE(workspace_id, name)
|
||||
);
|
||||
|
||||
-- Pages dans une collection
|
||||
CREATE TABLE collection_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT DEFAULT '📄',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
parent_id INTEGER REFERENCES collection_pages(id), -- sub-items
|
||||
gitea_issue_id INTEGER,
|
||||
gitea_issue_number INTEGER,
|
||||
property_values_json TEXT NOT NULL DEFAULT '{}', -- valeurs des propriétés
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
updated_by INTEGER REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- Vues d'une collection
|
||||
CREATE TABLE collection_views (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT 'Default View',
|
||||
view_type TEXT NOT NULL DEFAULT 'table',
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Propriétés d'une collection
|
||||
CREATE TABLE collection_properties (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
prop_type TEXT NOT NULL DEFAULT 'text',
|
||||
options_json TEXT DEFAULT '[]',
|
||||
number_format TEXT DEFAULT 'number',
|
||||
related_collection_id INTEGER REFERENCES collections(id),
|
||||
reverse_name TEXT,
|
||||
relation_property_id INTEGER REFERENCES collection_properties(id),
|
||||
target_property_id INTEGER REFERENCES collection_properties(id),
|
||||
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)
|
||||
);
|
||||
```
|
||||
|
||||
### 13.2 Nouvelles tables — Database Views enrichies
|
||||
|
||||
```sql
|
||||
-- Dashboard : combinaison de vues/widgets sur une page
|
||||
CREATE TABLE 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":[]}',
|
||||
-- [{view_id, x, y, width, height}, ...]
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Templates de collection
|
||||
CREATE TABLE collection_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT 'Default',
|
||||
description TEXT DEFAULT '',
|
||||
property_defaults_json TEXT NOT NULL DEFAULT '{}',
|
||||
content_json TEXT DEFAULT '[]',
|
||||
is_recurring BOOLEAN NOT NULL DEFAULT 0,
|
||||
recurrence_rule TEXT, -- 'daily', 'weekly', 'monthly', 'yearly'
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Data sources multiples par collection
|
||||
CREATE TABLE 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, -- nom d'affichage dans la DB composite
|
||||
is_linked BOOLEAN NOT NULL DEFAULT 0, -- 1 = linked (provient d'ailleurs)
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(collection_id, source_collection_id)
|
||||
);
|
||||
|
||||
-- Dependencies entre pages (tâches)
|
||||
CREATE TABLE page_dependencies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||
dependency_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||
dependency_type TEXT NOT NULL DEFAULT 'blocks', -- blocks | blocked_by | related
|
||||
auto_shift TEXT DEFAULT 'overlap', -- overlap | maintain_time | never
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(page_id, dependency_id)
|
||||
);
|
||||
|
||||
-- Sprints
|
||||
CREATE TABLE sprints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
goal TEXT DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'planning', -- planning | active | completed
|
||||
auto_complete BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE sprint_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sprint_id INTEGER NOT NULL REFERENCES sprints(id) ON DELETE CASCADE,
|
||||
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||
status_at_start TEXT, -- snapshot du statut au début du sprint
|
||||
velocity_points INTEGER DEFAULT 1,
|
||||
UNIQUE(sprint_id, page_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 13.3 Diagramme des relations clés
|
||||
|
||||
```
|
||||
collections ──1:N── collection_pages ── self-ref ── parent_id (sub-items)
|
||||
│ │
|
||||
│1:N │N:N
|
||||
▼ ▼
|
||||
collection_views page_dependencies (bloque/bloqué par)
|
||||
│
|
||||
│1:N
|
||||
▼
|
||||
collection_properties
|
||||
│
|
||||
│ self-ref
|
||||
├── related_collection_id (relation)
|
||||
├── relation_property_id (rollup)
|
||||
└── target_property_id (rollup)
|
||||
|
||||
collections ──1:N── collection_data_sources (multi-sources)
|
||||
collections ──1:N── collection_templates
|
||||
collections ──1:N── sprints ──1:N── sprint_pages
|
||||
collections ──1:N── collection_dashboards
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Plan d'implémentation (5 phases) {#14-plan-implémentation}
|
||||
|
||||
### Phase 1 — Data Sources & Linked Databases (v4.1.0)
|
||||
|
||||
**Objectif :** Permettre à une collection d'avoir plusieurs sources de données et des vues liées.
|
||||
|
||||
```
|
||||
✅ Table collection_data_sources
|
||||
✅ API: POST /db/{id}/sources (add)
|
||||
✅ API: DELETE /db/{id}/sources/{sid} (remove)
|
||||
✅ API: POST /db/{id}/linked (créer une linked DB)
|
||||
✅ UI: Data source manager dans settings database
|
||||
✅ Règle: les linked DB héritent des permissions de la source
|
||||
```
|
||||
|
||||
### Phase 2 — Templates & Dashboard (v4.2.0)
|
||||
|
||||
**Objectif :** Templates réutilisables + combinaison de vues en dashboard.
|
||||
|
||||
```
|
||||
✅ Table collection_templates (avec récurrence)
|
||||
✅ Table collection_dashboards (layout widgets)
|
||||
✅ API: templates CRUD
|
||||
✅ API: POST /db/{id}/templates/{tid}/apply
|
||||
✅ API: dashboards CRUD
|
||||
✅ UI: Dashboard view (grille configurable de widgets)
|
||||
✅ UI: Template picker dans le dropdown New
|
||||
```
|
||||
|
||||
### Phase 3 — Database Views complètes (v4.3.0)
|
||||
|
||||
**Objectif :** Compléter les 10 types de vues avec leurs layouts.
|
||||
|
||||
```
|
||||
✅ Charts: barres, courbes, camemberts, donuts, scatter (morris.js / chart.js)
|
||||
✅ Form: formulaire HTML → POST création page
|
||||
✅ Map: intégration Leaflet pour propriété Place
|
||||
✅ Feed: vue chronologique type fil d'actualité
|
||||
✅ Timeline/Gantt: barres horizontales avec dépendances
|
||||
✅ Layout options: card size, card preview, open in peek/side/full
|
||||
✅ View tabs: navigation fluide entre vues
|
||||
```
|
||||
|
||||
### Phase 4 — Tasks, Sub-items & Dependencies (v4.4.0)
|
||||
|
||||
**Objectif :** Implémenter le système complet de gestion de tâches Notion-style.
|
||||
|
||||
```
|
||||
✅ Flag is_task sur collections (Turn into Tasks)
|
||||
✅ Sub-items: parent_id dans collection_pages (existant, à enrichir)
|
||||
✅ Propriété ID auto-générée (TASK-XXX)
|
||||
✅ Table page_dependencies (bloque/bloqué par)
|
||||
✅ API: POST/GET/DELETE /db/{c}/pages/{p}/dependencies
|
||||
✅ Auto-shift des dates (overlap / maintain_time / never)
|
||||
✅ UI: vue dépendances (graphe ou liste), toggle sub-items
|
||||
✅ Filtres sub-items: parents only / all / sub-items only
|
||||
```
|
||||
|
||||
### Phase 5 — Sprints & My Tasks (v4.5.0)
|
||||
|
||||
**Objectif :** Sprints agiles + vue My Tasks cross-databases.
|
||||
|
||||
```
|
||||
✅ Tables sprints, sprint_pages
|
||||
✅ API: sprints CRUD + assignation pages aux sprints
|
||||
✅ Sprint board: Current Sprint / Planning / Backlog
|
||||
✅ Vélocité: calcul points/burndown chart
|
||||
✅ My Tasks: agrégation cross-databases
|
||||
✅ My Tasks API: /my-tasks?view=all|today|overdue|upcoming
|
||||
✅ My Tasks UI: groupes par collection, status badges
|
||||
✅ Assignee property sync avec users FlowDeck
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. Limites Notion à respecter {#15-limites}
|
||||
|
||||
| Limite | Valeur |
|
||||
|--------|--------|
|
||||
| Propriétés max par database | 500 |
|
||||
| Pas de rollup d'un rollup | ✅ À respecter |
|
||||
| Pages avant indexation non linéaire | ~1 000 |
|
||||
| Nesting templates | max 3 niveaux |
|
||||
| Nesting template quotidien | interdit |
|
||||
| Templates | propres à chaque database |
|
||||
| Database liée : partage | nécessite accès à l'originale |
|
||||
|
||||
---
|
||||
|
||||
## 16. Références {#16-références}
|
||||
|
||||
**Documentation Notion officielle :**
|
||||
- [Intro to databases](https://www.notion.com/help/intro-to-databases)
|
||||
- [Database properties](https://www.notion.com/help/database-properties)
|
||||
- [Relations & rollups](https://www.notion.com/help/relations-and-rollups)
|
||||
- [Formulas](https://www.notion.com/help/formulas)
|
||||
- [Formula syntax](https://www.notion.com/help/formula-syntax)
|
||||
- [Database templates](https://www.notion.com/help/database-templates)
|
||||
- [Tasks & dependencies](https://www.notion.com/help/tasks-and-dependencies)
|
||||
- [Sprints](https://www.notion.com/help/sprints)
|
||||
- [Data sources & linked databases](https://www.notion.com/help/data-sources-and-linked-databases)
|
||||
- [Views, filters & sorts](https://www.notion.com/help/views-filters-and-sorts)
|
||||
- [Tables](https://www.notion.com/help/tables), [Boards](https://www.notion.com/help/boards), [Timelines](https://www.notion.com/help/timelines)
|
||||
- [Calendars](https://www.notion.com/help/calendars), [Lists](https://www.notion.com/help/lists), [Galleries](https://www.notion.com/help/galleries)
|
||||
- [Charts](https://www.notion.com/help/charts), [Forms](https://www.notion.com/help/forms), [Maps](https://www.notion.com/help/maps)
|
||||
- [Feeds](https://www.notion.com/help/feeds), [Layouts](https://www.notion.com/help/layouts), [Dashboards](https://www.notion.com/help/dashboards)
|
||||
- [My Tasks with Task databases](https://www.notion.com/help/guides/give-your-to-dos-a-home-with-task-databases)
|
||||
|
||||
**Documents FlowDeck :**
|
||||
- [Guide Complet Notion Database](Guide_Complet_Notion_database.md)
|
||||
- [Architecture FlowDeck v4.0](../ARCHITECTURE.md)
|
||||
- [Roadmap](../ROADMAP.md)
|
||||
- [FlowDeck Agent Integration](Flowdeck_Agent_integration.md)
|
||||
|
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 |
|
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 |