Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed465333e4 | ||
|
|
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 |
@@ -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
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# Architecture FlowDeck — Document Complet v2.0
|
||||
# Architecture FlowDeck — Document Complet v3.0
|
||||
|
||||
> **Version:** 2.0 · **Date:** 2026-07-10 · **Auteur:** Hermes-Deepin
|
||||
> **Cible:** Clone Notion intégré à Gitea avec support multi-utilisateurs
|
||||
> **Version:** 3.0 · **Date:** 2026-07-14 · **Auteur:** Hermes-Deepin
|
||||
> **Cible:** Clone Notion intégré à Gitea/GitHub — multi-comptes, multi-intégrations
|
||||
|
||||
---
|
||||
|
||||
@@ -800,6 +800,257 @@ User authorize ──→ GET /auth/callback?code=xxx
|
||||
|
||||
---
|
||||
|
||||
## 6.5 Modèle de comptes & intégrations (v4.0)
|
||||
|
||||
### 6.5.1 Types de comptes
|
||||
|
||||
FlowDeck supporte 3 types de comptes, déterminés par `auth_method` dans la table `users` :
|
||||
|
||||
```
|
||||
┌──────────────┬──────────────────┬─────────────────────────────────────┐
|
||||
│ auth_method │ Login via │ Identité dans le sidebar │
|
||||
├──────────────┼──────────────────┼─────────────────────────────────────┤
|
||||
│ local │ email + password │ Nom local (ex: "Draco") │
|
||||
│ gitea │ OAuth2 Gitea │ Nom Gitea + badge 🦎 Gitea │
|
||||
│ github │ OAuth2 GitHub │ Nom GitHub + badge 🐙 GitHub │
|
||||
└──────────────┴──────────────────┴─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Règle fondamentale** : Un compte local avec intégration(s) reste un compte LOCAL.
|
||||
Le badge OAuth (🦎/🐙) n'apparaît QUE pour les comptes dont `auth_method` est le provider.
|
||||
|
||||
```
|
||||
Compte LOCAL avec intégrations :
|
||||
┌──────────────────────────────────┐
|
||||
│ [D] Draco │ ← Nom local, PAS de badge
|
||||
│ [email protected] │
|
||||
└──────────────────────────────────┘
|
||||
|
||||
Compte GITEA (auth_method=gitea) :
|
||||
┌──────────────────────────────────┐
|
||||
│ [B] bruno [🦎 Gitea] │ ← Badge visible
|
||||
│ [email protected] │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.5.2 Matrice des intégrations
|
||||
|
||||
```
|
||||
Compte principal Peut ajouter Déconnexion possible
|
||||
─────────────────────────────────────────────────────────
|
||||
local Gitea ✅ Oui
|
||||
local GitHub ✅ Oui
|
||||
local Gitea + GitHub ✅ Les deux
|
||||
─────────────────────────────────────────────────────────
|
||||
gitea GitHub ✅ GitHub ❌ Gitea
|
||||
github Gitea ✅ Gitea ❌ GitHub
|
||||
```
|
||||
|
||||
**Settings → Integrations** : Affiche toutes les intégrations avec bouton
|
||||
Disconnect. Le provider principal (auth_method) n'a PAS de bouton Disconnect.
|
||||
|
||||
### 6.5.3 Scénarios de workspace
|
||||
|
||||
```
|
||||
SCÉNARIO A : Compte local pur
|
||||
─────────────────────────────
|
||||
· Login email+password
|
||||
· Workspaces locaux uniquement
|
||||
· Section Private : CACHÉE (tout est déjà local)
|
||||
· Trash : ✅ (restauration locale)
|
||||
|
||||
SCÉNARIO B : Compte local + intégration(s)
|
||||
──────────────────────────────────────────
|
||||
· Login email+password
|
||||
· Workspaces : locaux + Gitea + GitHub
|
||||
· Section Private : VISIBLE si workspace distant actif
|
||||
· Private : stockage DB locale, associé user+repo
|
||||
· Trash : ✅ (local uniquement)
|
||||
|
||||
SCÉNARIO C : Compte OAuth pur (gitea ou github)
|
||||
────────────────────────────────────────────────
|
||||
· Login via OAuth
|
||||
· Workspaces : distants du provider principal
|
||||
· Section Private : VISIBLE (toujours)
|
||||
· Trash : ❌ (pas de contenu local)
|
||||
· Intégration secondaire : possible
|
||||
· Déconnexion provider principal : impossible
|
||||
```
|
||||
|
||||
### 6.5.4 Sidebar — règles d'affichage par section
|
||||
|
||||
```
|
||||
Section Scénario A Scénario B Scénario C
|
||||
───────────── ────────────── ────────────────────── ──────────────
|
||||
Recents ✅ (local) ✅ (tout) ✅ (tout)
|
||||
Favorites ✅ (local) ✅ (tout) ✅ (tout)
|
||||
Shared ✅ (local) ✅ (tout) ✅ (tout)
|
||||
Published ✅ (local) ✅ (tout) ✅ (tout)
|
||||
Agents ✅ ✅ ✅
|
||||
Private ❌ CACHÉ ✅ si ws distant actif ✅
|
||||
Workspace ✅ ✅ ✅
|
||||
Trash ✅ (local) ✅ (local) ❌
|
||||
```
|
||||
|
||||
Chaque section a un bouton `→ Library` qui ouvre :
|
||||
```
|
||||
/library?tab=recents|favorites|shared|published|private|workspace
|
||||
```
|
||||
|
||||
### 6.5.5 Système Share & Publish
|
||||
|
||||
Le bouton [Share] dans la topbar d'une page ouvre une modale à 2 tabs :
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ [ Share ] [ Publish ] │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ Share tab : │
|
||||
│ · Invite people : [email] [Invite] │
|
||||
│ · General access : [🔒 Restricted] Copy link │
|
||||
│ · Liste des personnes/groupes avec accès │
|
||||
│ → Document listé dans sidebar "Shared" │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ Publish tab : │
|
||||
│ · [Publish to web] → URL publique générée │
|
||||
│ · URL : https://flowdeck.draco.dev/p/<slug> │
|
||||
│ · [Unpublish] │
|
||||
│ → Document listé dans sidebar "Published" │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.5.6 Stockage des fichiers Private
|
||||
|
||||
Quand un workspace distant est actif, les fichiers créés dans la section
|
||||
Private sont stockés dans la **base de données locale** (table `pages` avec
|
||||
`parent_section='Private'` et `workspace=gitea:<owner>/<repo>`).
|
||||
|
||||
Cela permet de :
|
||||
- Prendre des notes personnelles liées à un projet sans les committer
|
||||
- Garder des brouillons avant de les pousser
|
||||
- Avoir un espace de travail privé même sur un repo partagé
|
||||
|
||||
### 6.5.7 Trash — règles
|
||||
|
||||
```
|
||||
Contenu Trash ? Comportement
|
||||
─────────────────────── ──────── ──────────────────────────
|
||||
Workspace local ✅ Supprimé → restaurable
|
||||
Dossier/fichier local ✅ Supprimé → restaurable
|
||||
Fichier Gitea/GitHub ❌ Commit "delete" → pas trash
|
||||
Page distante ❌ Commit "delete" → pas trash
|
||||
```
|
||||
|
||||
### 6.5.8 Édition de fichiers — UI unifiée
|
||||
|
||||
```
|
||||
Fichier LOCAL Fichier DISTANT (Gitea/GitHub)
|
||||
┌─────────────────────────┐ ┌─────────────────────────────┐
|
||||
│ Même UI d'édition │ │ Même UI d'édition │
|
||||
│ Même rendu visuel │ │ Même rendu visuel │
|
||||
│ Sauvegarde auto DB │ │ + Bouton [Commit] │
|
||||
│ Pas de commit │ │ + Champ message commit │
|
||||
└─────────────────────────┘ └─────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.5.9 Page Library — `/library?tab=<tab>`
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Library │
|
||||
│ [Recents] [Favorites] [Shared] [Published] │
|
||||
│ [Private] [Workspace] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Filtres : [All] [Local] [Gitea] [GitHub] │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 📄 Document X Local · modifié il y a 2h │ │
|
||||
│ │ 📄 README.md Gitea · bruno/flowdeck │ │
|
||||
│ │ 📄 Notes Local · Workspace Projet A │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.5.10 Schéma DB — mises à jour pour v4.0
|
||||
|
||||
```sql
|
||||
-- users : ajout auth_method
|
||||
ALTER TABLE users ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'local';
|
||||
-- 'local' | 'gitea' | 'github'
|
||||
|
||||
-- users : colonnes OAuth existantes
|
||||
-- gitea_id INTEGER (NULL si pas Gitea)
|
||||
-- github_id INTEGER (NULL si pas GitHub)
|
||||
-- gitea_token TEXT (token intégration, NULL si pas connecté)
|
||||
-- github_token TEXT (token intégration, NULL si pas connecté)
|
||||
|
||||
-- pages : ajout published
|
||||
ALTER TABLE pages ADD COLUMN is_published BOOLEAN NOT NULL DEFAULT 0;
|
||||
ALTER TABLE pages ADD COLUMN publish_slug TEXT;
|
||||
ALTER TABLE pages ADD COLUMN is_shared BOOLEAN NOT NULL DEFAULT 0;
|
||||
ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'restricted';
|
||||
-- 'restricted' | 'link' | 'public'
|
||||
|
||||
-- page_shares : qui a accès à quel document
|
||||
CREATE TABLE page_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
shared_with_user_id INTEGER REFERENCES users(id),
|
||||
shared_with_email TEXT,
|
||||
permission TEXT NOT NULL DEFAULT 'view',
|
||||
-- 'view' | 'comment' | 'edit'
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- recents : historique de consultation
|
||||
CREATE TABLE recents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id),
|
||||
workspace TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL DEFAULT 'local',
|
||||
-- 'local' | 'gitea' | 'github'
|
||||
accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, page_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 6.5.11 Plan de migration
|
||||
|
||||
```
|
||||
Phase 1 — DB & Modèles
|
||||
1. Ajouter auth_method aux users existants (auto-détecter)
|
||||
2. Ajouter is_published, is_shared, share_mode aux pages
|
||||
3. Créer table page_shares
|
||||
4. Créer table recents
|
||||
|
||||
Phase 2 — Sidebar
|
||||
1. Badge OAuth (🦎/🐙) basé sur auth_method
|
||||
2. Afficher/cacher Private selon type de compte + workspace actif
|
||||
3. Boutons Library par section
|
||||
4. Trash filtré local-only
|
||||
|
||||
Phase 3 — Share & Publish
|
||||
1. Modale Share à 2 tabs fonctionnelle
|
||||
2. Share via email/user → table page_shares
|
||||
3. Share via lien → share_mode='link'
|
||||
4. Publish → is_published + publish_slug
|
||||
5. Page publique servable à /p/<slug>
|
||||
|
||||
Phase 4 — Library
|
||||
1. Page /library avec tabs
|
||||
2. Filtrage par source (local/gitea/github)
|
||||
3. Recents alimentés automatiquement
|
||||
|
||||
Phase 5 — Intégrations
|
||||
1. GitHub OAuth déjà fait (providers.py)
|
||||
2. Matrice de déconnexion respectée
|
||||
3. Compte local peut connecter/déconnecter Gitea+GitHub
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Intégration Gitea
|
||||
|
||||
### 7.1 Flux de données
|
||||
|
||||
@@ -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,85 +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
|
||||
- **Type `relation`**: lien bidirectionnel entre collections avec reverse_name auto
|
||||
- API: `POST /db/{id}/properties/relation`, `POST /db/{id}/properties/relation/link`
|
||||
- **FormulaEngine**: 19 fonctions (prop, if, concat, round, now, today, dateAdd, replace, ...)
|
||||
- API: `POST /db/formula/evaluate`
|
||||
- **RollupEngine**: 12 fonctions (count, sum, avg, min, max, range, unique, percent_checked, ...)
|
||||
- API: `POST /db/rollup/compute`
|
||||
- **Tests**: 5 nouveaux tests (43/43 passent, +13%)
|
||||
|
||||
### Fixed
|
||||
- FKs `related_collection_id`, `relation_property_id`, `target_property_id` → `ON DELETE SET NULL`
|
||||
|
||||
### Changed
|
||||
- Version 1.4.0 → 1.5.0
|
||||
- **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`**: remplace `project_properties`, liée aux collections
|
||||
- Types: title, text, number, select, multi_select, status, date, person, checkbox, url, email, phone, files, unique_id
|
||||
- Auto-propriétés: created_time, created_by, last_edited_time, last_edited_by
|
||||
- Relations/Rollups/Formulas: schéma prêt (colonnes dispo, implémentation v1.5)
|
||||
- **Service `property_types.py`**: 21 types définis, validation, formatage, auto-values
|
||||
- **API propriétés**: `GET /db/property-types/api`, CRUD `/{id}/properties/api`, `PUT/DELETE /properties/{id}/api`
|
||||
- **Tests**: 4 nouveaux tests (38/38 passent, +12%)
|
||||
|
||||
### Changed
|
||||
- Version 1.3.0 → 1.4.0
|
||||
- **Table `collection_properties`**: 21 types Notion
|
||||
- **Service `property_types.py`**: validation, formatage, auto-values
|
||||
|
||||
## v1.3.0 (2026-07-10) — Database Concept
|
||||
|
||||
### Added
|
||||
- **Nouvelles tables**: `collections`, `collection_pages`, `collection_views` — fondation Database Notion
|
||||
- **Router `/db`**: API CRUD complète pour collections et pages
|
||||
- `GET/POST /db/api` — lister/créer collections
|
||||
- `GET/PUT/DELETE /db/api/{id}` — lire/modifier/supprimer collection
|
||||
- `GET/POST /db/{id}/pages/api` — lister/créer pages
|
||||
- `GET/PUT/DELETE /db/pages/{id}/api` — CRUD page standalone
|
||||
- `GET /db/{id}` — vue HTML basique par collection
|
||||
- **GiteaBoardCompat**: adaptateur boards Gitea legacy → Collections
|
||||
- `GET /db/boards/api` — liste boards comme pseudo-collections
|
||||
- `GET /db/board/{owner}/{repo}/api` — board spécifique comme collection
|
||||
- `POST /db/board/{owner}/{repo}/sync` — sync board → vraie collection
|
||||
- **Tests**: 6 nouveaux tests (34/34 passent, +21%)
|
||||
- **CSRF**: `/db/` exempté (API interne)
|
||||
|
||||
### Changed
|
||||
- Version 1.0.0 → 1.3.0 (main.py, api.py, health)
|
||||
- `db.py` init: 3 nouvelles tables + index
|
||||
- Architecture DB: `collection_pages` utilise `property_values_json` (JSON flexible)
|
||||
- **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** — Databases, Pages, Kanban, Table, multi-vues.
|
||||
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
|
||||
|
||||
> **v1.3.0** — Database Concept (collections, pages, vues, GiteaBoardCompat)
|
||||
> **v2.1.0** — API publique, Webhooks sortants, PWA
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -15,45 +15,62 @@ docker compose up -d
|
||||
|
||||
## Features
|
||||
|
||||
### Database Concept (v1.3.0)
|
||||
- **Collections**: databases indépendantes de Gitea, schéma JSON, icon, description
|
||||
- **Pages**: propriétés JSON, position, parent_id (sub-items ready), lien Gitea optionnel
|
||||
- **Vues**: configurables (type, filters, sorts, visible_properties)
|
||||
### 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
|
||||
- **API REST**: CRUD complet collections + pages (12 endpoints)
|
||||
|
||||
### 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
|
||||
|
||||
### 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 (Recents, Private, Library, Trash)
|
||||
- **Topbar** avec breadcrumbs (workspace > projet > page)
|
||||
- **Dark mode** Notion: `#191919` fond, `#222222` sidebar, `#333333` actif
|
||||
- **Éditeur de blocs** Notion-style: slash menu (/), navigation clavier, placeholders
|
||||
- **Drag & drop** pages dans la sidebar (SortableJS)
|
||||
|
||||
### Vues Multiples (v0.5–v0.8)
|
||||
- **Kanban board**: colonnes avec sous-groupes, drag & drop cartes
|
||||
- **Table view**: colonnes triables, groupes rétractables
|
||||
- **Status overview**: Donut chart SVG avec segments, compteurs
|
||||
- **Team Load**: Barres empilées par membre
|
||||
- **Detailed board**: cartes avec propriétés visibles
|
||||
|
||||
### Filtres & Tri (v0.7)
|
||||
- **Filtres cumulables** (logique AND) avec pastilles
|
||||
- **Tri multi-critères** hiérarchique
|
||||
- **Filtre par statut**: checkboxes multiples
|
||||
- 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 | Jinja2 + HTMX + Alpine.js + SortableJS + CSS 31KB |
|
||||
| Frontend | Jinja2 + HTMX + Alpine.js + SortableJS + CSS |
|
||||
| Backend | Python 3.12 + FastAPI + httpx |
|
||||
| BDD | SQLite (WAL mode) — `/data/flowdeck.db` |
|
||||
| BDD | SQLite (WAL, 21 tables) — `/data/flowdeck.db` |
|
||||
| Déploiement | Docker (python:3.12-slim), docker-compose |
|
||||
|
||||
## Configuration
|
||||
@@ -65,25 +82,16 @@ APP_PORT=8080
|
||||
DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
Voir [ROADMAP.md](ROADMAP.md) — v1.4.0 Propriétés Avancées → v2.0.0 Multi-Users.
|
||||
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --port 8080
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/ -v # 34/34 passent
|
||||
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,7 +1,40 @@
|
||||
# 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).
|
||||
|
||||
## 🏗️ 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
|
||||
|
||||
@@ -54,172 +87,203 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
||||
- [x] Heading 4 support
|
||||
- [x] Breadcrumbs contextuels (workspace > projet > page)
|
||||
|
||||
### v1.3.0 ✅ — Database Concept (Bloc 1 du gap analysis)
|
||||
### (2026-07-10, commit c574d2c, tag v1.3.0)
|
||||
### 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
|
||||
|
||||
- [x] **Table `collections`** — base de données abstraite, indépendante de Gitea
|
||||
- `id`, `name`, `description`, `icon`, `schema_json`
|
||||
- Optionnellement liée à Gitea (`gitea_owner`, `gitea_repo`)
|
||||
- [x] **Table `collection_pages`** — remplace `cards`, pages génériques
|
||||
- `collection_id`, `title`, `position`, `parent_id`, `property_values_json`
|
||||
- `gitea_issue_id` optionnel, auto-propriétés (`created_at`, `updated_at`)
|
||||
- [x] **Table `collection_views`** — vues configurables par collection
|
||||
- `name`, `view_type`, `config_json`, `position`
|
||||
- [x] **Rétrocompatibilité Gitea** — adaptateur `GiteaBoardCompat`
|
||||
- Les boards Gitea existants deviennent des collections
|
||||
- Migration transparente `cards` → `collection_pages`
|
||||
- Routes `/db/boards/api`, `/db/board/{o}/{r}/api`, `/db/board/{o}/{r}/sync`
|
||||
- [x] **Router `/db`** — API CRUD collections + pages (12 endpoints)
|
||||
- [x] **34/34 tests** passent (+6 nouveaux tests v1.3)
|
||||
- [x] **Database lock** — mode lecture seule (`collections.is_locked`)
|
||||
- [x] **Database description** — champ `description` sur les collections
|
||||
### v1.4.0 ✅ — Propriétés Avancées
|
||||
- [x] Table `collection_properties` — 21 types Notion
|
||||
- [x] Service `property_types.py` — validation, formatage, auto-values
|
||||
- [x] 38/38 tests passent
|
||||
|
||||
### v1.5.0 ✅ — Relations & Rollups
|
||||
- [x] Type `relation` — lien bidirectionnel entre collections
|
||||
- [x] Type `rollup` — aggregation via relation (COUNT, SUM, AVG, etc.)
|
||||
- [x] Type `formula` — moteur d'expressions (19 fonctions)
|
||||
- [x] Formula engine + Rollup engine
|
||||
- [x] 43/43 tests passent
|
||||
|
||||
### v1.6.0 ✅ — Vues Manquantes
|
||||
- [x] Calendar, Gallery, List, Timeline, Table views
|
||||
- [x] View tabs navigation
|
||||
- [x] 49/49 tests passent
|
||||
|
||||
### v1.7.0 ✅ — Vues Améliorées
|
||||
### v1.8.0 ✅ — Sub-items & Dépendances
|
||||
- [x] View config API, Save view as
|
||||
- [x] Sub-items (parent_id auto-référence)
|
||||
- [x] Status aggregate, Dependencies (blocking constraint)
|
||||
- [x] 56/56 tests passent
|
||||
|
||||
### v1.9.0 ✅ — My Tasks & Dashboard Unifié
|
||||
- [x] Vue `/my-tasks` — agrège pages assignées à l'utilisateur
|
||||
- [x] Groupement par collection, filtres globaux
|
||||
- [x] 60/60 tests passent
|
||||
|
||||
### v2.0.0 ✅ — Éditeur Complet & Multi-Utilisateurs
|
||||
- [x] Workspaces, Comments, Page History, Favorites
|
||||
- [x] Database Templates, Page Templates, CSV Import/Export
|
||||
- [x] Public Sharing
|
||||
- [x] 67/67 tests passent
|
||||
|
||||
### v2.1.0 ✅ — Workspace Local & Arborescence
|
||||
### v2.2.0 ✅ — Tags & Recherche Avancée
|
||||
### v2.3.0 ✅ — Multi-Vues Workspace & Preview Panel
|
||||
### v2.4.0 ✅ — Tags Personnalisés par Utilisateur
|
||||
### v2.5.0 ✅ — Administration Avancée
|
||||
### v2.6.0 ✅ — My Account & Auth Locale
|
||||
### v2.7.0 ✅ — Intégration Gitea Phase 1
|
||||
### v2.7.1 ✅ — Private Pages Gitea
|
||||
|
||||
### v2.8.0 ✅ — Gitea Phase 2
|
||||
- [x] Créer fichiers dans le repo (New file UI + API PUT)
|
||||
- [x] Upload fichiers binaires (POST /upload, drag-drop)
|
||||
- [x] Syntax highlighting via Prism.js CDN
|
||||
- [x] Labels Gitea → tags FlowDeck (POST /sync-labels, 16 labels importés)
|
||||
- [x] Historique commits par fichier (GET /commits?path=)
|
||||
|
||||
### v2.9.0 ✅ — GitHub OAuth Provider
|
||||
- [x] GitHubProvider dans `app/auth/providers.py`
|
||||
- [x] GitHubAdapter dans `app/services/github_adapter.py`
|
||||
- [x] Config GitHub dans `.env.example`
|
||||
|
||||
### v3.0.0 ✅ — UX Professionnelle
|
||||
- [x] Multi-sélection (checkboxes + Shift/Ctrl-click dans le sidebar)
|
||||
- [x] Raccourcis clavier (Ctrl+N, F2, Delete, Escape, Ctrl+S)
|
||||
- [x] Inline rename (double-clic → champ inline)
|
||||
- [x] Loading skeletons (animations pulse dans workspaces + file viewer)
|
||||
- [x] Grid/card view (mode grille/vignettes)
|
||||
- [x] Table sorting (tri par colonnes)
|
||||
- [x] Sélecteur de vue: Tree, List, Details, Title, Content (5 modes)
|
||||
- [x] Preview Panel latéral (contenu, métadonnées, tags, actions)
|
||||
- [x] Tri par date (Oldest/Newest)
|
||||
|
||||
### v4.0.0 ✅ — Accounts, Integrations & Sharing (MVP)
|
||||
- [x] `auth_method` sur users (local/gitea/github)
|
||||
- [x] Badge OAuth dans sidebar
|
||||
- [x] Settings page avec Connect/Disconnect Gitea + GitHub
|
||||
- [x] Sidebar rules (Private = distant seulement, Trash local-only)
|
||||
- [x] Library page avec tabs + filtres source (All/Local/Gitea/GitHub)
|
||||
- [x] Modale Share (2 tabs: Share + Publish)
|
||||
- [x] Page publique `/p/<slug>` (no auth, rendu HTML)
|
||||
- [x] Table `page_shares`, colonnes `is_published`, `is_shared`, `share_mode`
|
||||
- [x] Table `recents` — tracking automatique
|
||||
- [x] Édition unifiée local/distant (même UI, bouton Commit pour Gitea)
|
||||
- [x] 133 tests passent
|
||||
|
||||
---
|
||||
|
||||
## Upcoming
|
||||
## 🔜 Prochaines versions
|
||||
|
||||
### v1.4.0 ✅ — Propriétés Avancées (Bloc 2)
|
||||
### (2026-07-10)
|
||||
### v4.0.1 — Onboarding & Polish 🎯 (priorité MAX)
|
||||
> **Objectif** : rendre l'app utilisable dès la première visite, sans friction.
|
||||
|
||||
- [x] **Table `collection_properties`** — remplace `project_properties`
|
||||
- Liée à `collections` (pas à `project_owner/name`)
|
||||
- Supporte tous les types Notion (21 types)
|
||||
- [x] **Type `number`** — avec formatage (nombre, %, €, $, £, ¥)
|
||||
- [x] **Type `checkbox`** — booléen natif
|
||||
- [x] **Type `url`** — lien cliquable
|
||||
- [x] **Type `email`** — email cliquable
|
||||
- [x] **Type `phone`** — téléphone cliquable
|
||||
- [x] **Type `status`** — select avec couleurs forcées (9 couleurs Notion)
|
||||
- [x] **Type `files`** — uploads/images attachés aux pages
|
||||
- [x] **Type `unique_id`** — ID auto-incrémenté par collection
|
||||
- [x] **Type `created_time` / `created_by`** — auto-propriétés
|
||||
- [x] **Type `last_edited_time` / `last_edited_by`** — auto-propriétés
|
||||
- [x] **Service `property_types.py`** — validation, formatage, auto-values
|
||||
- [x] **API CRUD** — `/db/property-types/api` + CRUD `/db/{id}/properties/api`
|
||||
- [x] **38/38 tests** passent (+4 nouveaux tests v1.4)
|
||||
- [ ] **Migration** `project_properties` → `collection_properties` (v1.5)
|
||||
- [ ] **Landing page `/` pour visiteurs non-auth** — page d'accueil (pas de redirect loop)
|
||||
- [ ] **Page `/auth/register` GET** — formulaire d'inscription dédié (pas juste un tab)
|
||||
- [ ] **Redirection `/` intelligente** — si pas connecté → login, si connecté sans Gitea → local workspace
|
||||
- [ ] **Empty states cohérents** — onboarding dans le local workspace vide
|
||||
- [ ] **Page 404 stylisée** — thème Notion sombre (actuellement HTML brut)
|
||||
- [ ] **Unifier les routes API** — `/board/api/pages` ↔ `/api/pages` (cohérence)
|
||||
- [ ] **Page Settings accessible** → lien dans le menu utilisateur déjà présent, vérifier fonctionnel
|
||||
|
||||
### v1.5.0 ✅ — Relations & Rollups (Bloc 2 suite)
|
||||
### (2026-07-10)
|
||||
**Effort estimé** : 2-3 sessions | **Impact** : 🔴 Critique pour adoption
|
||||
|
||||
- [x] **Type `relation`** — lien bidirectionnel entre collections
|
||||
- `related_collection_id`, `reverse_name` auto-généré
|
||||
- Stockage : JSON array de page IDs dans `property_values_json`
|
||||
- API: `POST /db/{id}/properties/relation` (création avec reverse)
|
||||
- API: `POST /db/{id}/properties/relation/link` (lier 2 pages)
|
||||
- [x] **Type `rollup`** — agrégation via relation
|
||||
- Functions : COUNT, SUM, AVG, MIN, MAX, RANGE, UNIQUE, PERCENT_CHECKED
|
||||
- API: `POST /db/rollup/compute`
|
||||
- [x] **Type `formula`** — propriétés calculées
|
||||
- Moteur d'expressions JavaScript-like (19 fonctions)
|
||||
- API: `POST /db/formula/evaluate`
|
||||
- [x] **Formula engine** — `services/formula_engine.py`
|
||||
- [x] **Rollup engine** — `services/rollup_engine.py`
|
||||
- [x] **43/43 tests** passent (+5 nouveaux tests v1.5)
|
||||
- [x] **FKs fix**: `related_collection_id`, `relation_property_id`, `target_property_id` → `ON DELETE SET NULL`
|
||||
### v4.0.2 — Qualité & Robustesse
|
||||
> **Objectif** : zéro crash, zéro regression, DX impeccable.
|
||||
|
||||
### v1.6.0 ✅ — Vues Manquantes (Bloc 3)
|
||||
### (2026-07-10)
|
||||
- [ ] **Gestion d'erreurs** — try/catch manquants, messages user-friendly
|
||||
- [ ] **Validation inputs** — formulaires login/register, titre de page vide
|
||||
- [ ] **Rate limiting effectif** — tester + ajuster les seuils
|
||||
- [ ] **Session expiry UX** — message clair au lieu de redirect silencieux
|
||||
- [ ] **CSRF token refresh** — après expiration session
|
||||
- [ ] **Mobile responsive** — sidebar, editor, modals sur petit écran
|
||||
- [ ] **Tests de non-régression** — scénarios critiques (login, create page, publish)
|
||||
|
||||
- [x] **Calendar view** — grid mensuel, navigation mois, événements par date
|
||||
- Route: `GET /db/{id}/view/calendar`
|
||||
- [x] **Gallery view** — cartes visuelles avec cover image optionnelle
|
||||
- `card_size`: small/medium/large, `cover_property` configurable
|
||||
- [x] **List view** — liste compacte avec preview propriétés
|
||||
- [x] **Timeline/Gantt view** — barres horizontales sur axe date
|
||||
- [x] **Table view** — rendu SSR, colonnes properties
|
||||
- [x] **View tabs** — navigation entre vues (Table/Board/Calendar/Gallery/List/Timeline)
|
||||
- [x] **49/49 tests** passent (+6 nouveaux tests v1.6)
|
||||
**Effort estimé** : 2-3 sessions | **Impact** : 🟡 Élevé
|
||||
|
||||
### v1.7.0 — Vues Améliorées (Bloc 3 suite)
|
||||
---
|
||||
|
||||
- [ ] **Group_by configurable** — Kanban groupé par n'importe quelle propriété Select/Status
|
||||
- Plus seulement les labels Gitea
|
||||
- UI de sélection du group_by dans la barre d'outils
|
||||
- [ ] **Visible properties par vue** — toggle colonnes dans chaque vue
|
||||
- [ ] **Card size** — small/medium/large pour board et gallery
|
||||
- [ ] **Cover image property** — choisir la propriété de cover
|
||||
- [ ] **Groupes de filtres** — (A OR B) AND C, UI de construction de filtres
|
||||
- [ ] **Save view as** — sauvegarder état courant comme nouvelle vue
|
||||
### v4.1.0 — Content Blocks enrichis (Notion Parity Tier 2)
|
||||
> **Objectif** : parité d'édition avec Notion.
|
||||
|
||||
### v1.8.0 — Sub-items & Dépendances (Bloc 4)
|
||||
- [ ] **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)
|
||||
|
||||
- [ ] **Sub-items sur cartes Kanban** — `parent_id` dans `collection_pages`
|
||||
- Auto-référence : une page peut être enfant d'une autre dans la même collection
|
||||
- [ ] **Affichage imbriqué** — cartes indentées sous le parent dans le board
|
||||
- CSS `.sub-item` avec indentation visuelle
|
||||
- Compteur de sous-tâches sur la carte parent
|
||||
- [ ] **Bouton "+ Sub-item"** — création inline de sous-tâche
|
||||
- [ ] **État parent = agrégation enfants** — "Done" seulement si tous enfants Done
|
||||
- [ ] **Dependencies (Blocking ↔ Blocked by)**
|
||||
- 2e relation auto-référencée
|
||||
- Propriétés `Blocks` et `Blocked by` créées automatiquement
|
||||
- [ ] **Contrainte de dépendance** — empêcher "Done" si bloque des tâches non terminées
|
||||
- [ ] **Flèches de dépendance** — visuel dans Timeline et Kanban
|
||||
### v4.2.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
|
||||
|
||||
### v1.9.0 — My Tasks & Dashboard Unifié (Bloc 5)
|
||||
### v4.3.0 — Collaboration
|
||||
- [ ] **Inline comments** — commentaires sur sélection de texte
|
||||
- [ ] **@mentions** — notifier un utilisateur → page/commentaire
|
||||
- [ ] **Email notifications** — changements, mentions
|
||||
|
||||
- [ ] **Vue `/my-tasks`** — agrège toutes les pages assignées à l'utilisateur
|
||||
- Scan cross-collection (toutes les databases du workspace)
|
||||
- Filtre automatique : `Assignee = current_user` ET `Status ≠ Done`
|
||||
- [ ] **Groupement par collection** — sections par database d'origine
|
||||
- [ ] **Filtres globaux** — Status, Due Date, Collection
|
||||
- [ ] **Calendrier intégré** — My Tasks en vue calendrier
|
||||
- [ ] **Vue Today** — ce qui est dû aujourd'hui
|
||||
- [ ] **Vue Overdue** — tâches en retard
|
||||
- [ ] **Vue Upcoming** — 7 prochains jours
|
||||
### v4.4.0 — Embeds & Rich Media
|
||||
- [ ] **Embeds** — YouTube, Figma, Google Maps, Twitter, etc.
|
||||
- [ ] **Image lightbox** — clic pour agrandir, navigation
|
||||
- [ ] **Bookmark cards** — aperçu riche des liens (OG metadata)
|
||||
|
||||
### v2.0.0 — Éditeur Complet & Multi-Utilisateurs (Blocs 6-7)
|
||||
### v4.5.0 — Kanban Adaptable
|
||||
- [ ] Colonnes configurables par utilisateur
|
||||
- [ ] Cartes configurables (choisir propriétés affichées)
|
||||
- [ ] Couverture de carte (image, icône, couleur)
|
||||
- [ ] WIP limits par colonne
|
||||
|
||||
- [ ] **Drag blocks to reorder** — réorganisation des blocs dans l'éditeur
|
||||
- [ ] **Colonnes (2, 3, 4, 5)** — layout multi-colonnes dans l'éditeur
|
||||
- [ ] **Callout blocks** — bloc avec icône et fond coloré
|
||||
- [ ] **Toggle blocks** — bloc repliable
|
||||
- [ ] **Code blocks avec syntax highlighting** — highlight.js
|
||||
- [ ] **Image resize/alignment** — redimensionnement et alignement
|
||||
- [ ] **Embeds** — vidéo, fichier, bookmark web
|
||||
- [ ] **Database templates** — structures de database réutilisables
|
||||
- [ ] **Page templates** — pages modèles dans une database
|
||||
- [ ] **Inline databases** — databases embarquées dans une page
|
||||
- [ ] **Linked databases** — même database affichée dans plusieurs pages
|
||||
- [ ] **Multi-user workspaces** — collaboration temps réel
|
||||
- Workspaces partagés avec permissions (read/write/admin)
|
||||
- User presence (qui est en ligne, qui édite quelle page)
|
||||
- Historique des modifications par page
|
||||
- Commentaires sur les pages (pas juste les issues Gitea)
|
||||
- [ ] **Favorites** — système de favoris fonctionnel
|
||||
- [ ] **CSV import/export** pour databases
|
||||
- [ ] **Public sharing** — lien de partage public pour une vue
|
||||
### v4.6.0 — Calendar & Meetings
|
||||
- [ ] Vue Calendrier drag & drop
|
||||
- [ ] Récurrence d'événements
|
||||
- [ ] Template Meeting Notes
|
||||
|
||||
### v2.1.0 — Intégrations Avancées (futur)
|
||||
### v4.7.0 — AI Assistant
|
||||
- [ ] Intégration LLM pour écriture/résumé/traduction
|
||||
- [ ] Slash AI commands (`/ai write`, `/ai summarize`)
|
||||
- [ ] Auto-complétion
|
||||
|
||||
- [ ] **API publique** — REST API documentée pour usage externe
|
||||
- [ ] **Webhooks sortants** — notifier des services externes
|
||||
- [ ] **n8n integration** — nœud FlowDeck pour workflows
|
||||
- [ ] **Slack/Discord integration** — notifications dans les channels
|
||||
- [ ] **Google Calendar sync** — synchronisation bidirectionnelle
|
||||
- [ ] **Mobile PWA** — Progressive Web App pour mobile
|
||||
- [ ] **PostgreSQL migration** — si passage multi-tenant
|
||||
### v4.8.0 — Command Palette & Recherche
|
||||
- [ ] **Command palette** — Ctrl+K / Ctrl+P recherche universelle
|
||||
- [ ] **Quick actions** — navigation, création, commandes
|
||||
|
||||
### v4.9.0 — Automations
|
||||
- [ ] **Database automations** — if-this-then-that
|
||||
- [ ] **Buttons** — cliquables déclenchant actions
|
||||
|
||||
### v4.10.0 — Database Avancée
|
||||
- [ ] Inline databases dans n'importe quelle page
|
||||
- [ ] Templates de database (Project tracker, CRM…)
|
||||
- [ ] Validation des propriétés (required, unique, min/max)
|
||||
|
||||
---
|
||||
|
||||
## v5.0.0 — Pro (futur)
|
||||
|
||||
- [ ] **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
|
||||
|
||||
```
|
||||
v1.0.0 ✅ v1.1.0 ✅ v1.2.0 ✅ v1.3.0 ✅ v1.4.0 ✅ v1.5.0 ✅ v1.6.0 ✅ v1.7.0 ⬜
|
||||
Production Pages Editor DB Concept Properties Relations Views View+
|
||||
──────────────────────────────────────────────
|
||||
52 fonctionnalités identifiées dans le gap analysis
|
||||
│
|
||||
├─ Bloc 1: Database Concept (v1.3) — 6 items ✅
|
||||
├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items ✅ (complété)
|
||||
├─ Bloc 3: Vues manquantes (v1.6-1.7) — 10 items (6/10 ✅)
|
||||
├─ Bloc 4: Sub-items & Deps (v1.8) — 7 items
|
||||
├─ Bloc 5: My Tasks (v1.9) — 7 items
|
||||
├─ Bloc 6: Éditeur complet (v2.0) — 8 items
|
||||
└─ Bloc 7: Fonctions transversales — +multi-user
|
||||
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 PRIORITÉ
|
||||
+ GitHub OAuth + Library MAX
|
||||
|
||||
v1.8.0 ⬜ v1.9.0 ⬜ v2.0.0 ⬜ v2.1.0 ⬜
|
||||
Sub-items My Tasks Editor+ Future
|
||||
Multi-user
|
||||
v4.0.2 ⬜ v4.1.0 ⬜ v4.2.0 ⬜ v4.3.0 ⬜ v4.4–4.10 ⬜ v5.0.0 ⬜
|
||||
Quality Blocks Export Collab Notion Pro
|
||||
& Tests enrichis PDF/MD @mentions Parity (futur)
|
||||
```
|
||||
|
||||
*Dernière mise à jour: 2026-07-18 — Roadmap restructuré, focus MVP, stratégie de branches Gitea*
|
||||
|
||||
@@ -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,71 +1,62 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version actuelle**: v1.3.0 | **Prochaine**: v1.4.0
|
||||
> **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 | Tests |
|
||||
|-------|-------------|--------|-------|
|
||||
| v0.2 – v0.9 | Base → Backend | ✅ | — |
|
||||
| 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 (14 types) | ⬜ | — |
|
||||
| v1.5 | Relations & Rollups | ⬜ | — |
|
||||
| v1.6 | Vues Calendrier/Timeline/Gallery/List | ⬜ | — |
|
||||
| v1.7 | Vues Améliorées (group_by, card_size) | ⬜ | — |
|
||||
| v1.8 | Sub-items & Dépendances | ⬜ | — |
|
||||
| v1.9 | My Tasks & Dashboard Unifié | ⬜ | — |
|
||||
| v2.0 | Éditeur Complet & Multi-Users | ⬜ | — |
|
||||
| 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
|
||||
|
||||
## v1.3.0 — Database Concept (complété 2026-07-10)
|
||||
| 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 | ✅ |
|
||||
|
||||
### Tables
|
||||
- [x] `collections` — base de données abstraite, schéma JSON, lien Gitea optionnel
|
||||
- [x] `collection_pages` — pages avec `parent_id`, `property_values_json`, `position`
|
||||
- [x] `collection_views` — vues configurables (`view_type`, `config_json`)
|
||||
- [x] Index: `idx_cp_collection`, `idx_cp_parent`, `idx_cp_gitea`
|
||||
**Total: 52/52 features (100%)**
|
||||
|
||||
### API
|
||||
- [x] `GET/POST /db/api` — lister/créer collections
|
||||
- [x] `GET/PUT/DELETE /db/api/{id}` — CRUD collection
|
||||
- [x] `GET/POST /db/{id}/pages/api` — pages dans une collection
|
||||
- [x] `GET/PUT/DELETE /db/pages/{id}/api` — CRUD page standalone
|
||||
- [x] `GET /db/{id}` — vue HTML collection
|
||||
- [x] `GET /db/boards/api` — boards Gitea comme collections
|
||||
- [x] `GET /db/board/{o}/{r}/api` — board spécifique
|
||||
- [x] `POST /db/board/{o}/{r}/sync` — sync board → collection
|
||||
## Architecture
|
||||
|
||||
### Adapter
|
||||
- [x] `GiteaBoardCompat` — `from_board()`, `from_card()`, `list_boards_as_collections()`, `sync_to_collection()`
|
||||
### 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`
|
||||
|
||||
### Infra
|
||||
- [x] CSRF exempté pour `/db/`
|
||||
- [x] Version 1.0.0 → 1.3.0
|
||||
- [x] 34/34 tests passent
|
||||
- [x] Tag git v1.3.0
|
||||
### Routeurs (11)
|
||||
`dashboard`, `board`, `notes`, `api`, `auth`, `webhooks`, `collections`, `my_tasks`, `workspace`, `public_api` + app-level routes
|
||||
|
||||
---
|
||||
### Services (6)
|
||||
`GiteaClient`, `FormulaEngine`, `RollupEngine`, `GiteaBoardCompat`, `property_types`, `webhook_outbound`
|
||||
|
||||
## Prochaines étapes (v1.4.0 — Propriétés Avancées)
|
||||
### 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
|
||||
|
||||
### Table `collection_properties`
|
||||
- [ ] Remplacer `project_properties` (liée à `project_owner/name`) par `collection_properties` (liée à `collections`)
|
||||
- [ ] Types: number, checkbox, url, email, phone, status (9 couleurs), files, unique_id
|
||||
- [ ] Auto-propriétés: created_time, created_by, last_edited_time, last_edited_by
|
||||
- [ ] Format number: nombre, %, €, $, £, ¥
|
||||
## Stack
|
||||
|
||||
### Intégration UI
|
||||
- [ ] Property editor inline par type (select dropdown, date picker, checkbox...)
|
||||
- [ ] Affichage des propriétés dans la modale de détail page
|
||||
- [ ] Migration project_properties → collection_properties
|
||||
|
||||
## Notes
|
||||
|
||||
- **Stack**: FastAPI + Jinja2 + HTMX + Alpine.js + SortableJS + SQLite (WAL)
|
||||
- **Docker**: python:3.12-slim, port 8080, volume /data
|
||||
- **Tests**: pytest, 34 tests, client TestClient avec SQLite temporaire
|
||||
- **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)
|
||||
@@ -134,10 +161,13 @@ def init_db():
|
||||
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
|
||||
);
|
||||
@@ -205,12 +235,103 @@ def init_db():
|
||||
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")
|
||||
@@ -227,6 +348,118 @@ def init_db():
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -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, collections
|
||||
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware
|
||||
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing
|
||||
from app.routers.gitea import router as gitea_router
|
||||
from app.routers.github_routes import router as github_router
|
||||
from app.services.gitea_client import gitea
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
||||
@@ -25,19 +29,20 @@ logger = logging.getLogger(__name__)
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
init_db()
|
||||
init_webhook_tables()
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)"
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("FlowDeck v1.6.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.6.0",
|
||||
version="4.0.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
@@ -45,6 +50,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)
|
||||
@@ -54,5 +61,84 @@ 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/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):
|
||||
"""Styled 404 page matching the FlowDeck dark theme."""
|
||||
from fastapi.responses import HTMLResponse
|
||||
# API routes should get JSON, not HTML
|
||||
if request.url.path.startswith("/api/") or request.url.path.startswith("/board/api/"):
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": "Not found"}, status_code=404)
|
||||
return HTMLResponse(NOT_FOUND_HTML, status_code=404)
|
||||
|
||||
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/auth/callback", "/board/api/pages", "/db/"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents"}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
@@ -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]}
|
||||
@@ -39,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
|
||||
@@ -57,7 +57,7 @@ async def health():
|
||||
|
||||
return {
|
||||
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
||||
"version": "1.6.0",
|
||||
"version": request.app.version,
|
||||
"db": db_ok,
|
||||
"gitea": gitea_ok,
|
||||
}
|
||||
@@ -623,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,7 +5,7 @@ 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
|
||||
@@ -140,10 +140,131 @@ def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, m
|
||||
return items
|
||||
|
||||
|
||||
def _file_icon(name: str, content_format: str = "") -> str:
|
||||
"""Map file extension to emoji icon."""
|
||||
import re
|
||||
# FlowDeck internal pages (no extension)
|
||||
if content_format and content_format != 'file':
|
||||
return '📝'
|
||||
n = name.lower()
|
||||
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): return '🖼️'
|
||||
if n.endswith('.pdf'): return '📕'
|
||||
if re.search(r'\.(md|markdown)$', n): return '📝'
|
||||
if n.endswith('.py'): return '🐍'
|
||||
if re.search(r'\.(js|jsx|ts|tsx)$', n): return '📜'
|
||||
if re.search(r'\.(html?|xml)$', n): return '🌐'
|
||||
if n.endswith('.css'): return '🎨'
|
||||
if n.endswith('.json'): return '📋'
|
||||
if n.endswith('.sql'): return '🗃️'
|
||||
if re.search(r'\.(sh|bash|zsh)$', n): return '💻'
|
||||
if n.endswith('.ps1'): return '⚡'
|
||||
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n): return '📜'
|
||||
if re.search(r'\.(txt|log)$', n): return '📄'
|
||||
if re.search(r'\.(zip|tar|gz|rar|7z)$', n): return '📦'
|
||||
return '📄'
|
||||
|
||||
|
||||
def _load_workspace_pages(ws_cookie: str) -> list:
|
||||
"""Load top-level pages with children for the active workspace."""
|
||||
if not ws_cookie:
|
||||
return []
|
||||
try:
|
||||
ws_id = int(ws_cookie)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format FROM pages WHERE workspace_id=? AND parent_id IS NULL 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 _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws_name = user.get("login", "Bruno") if user else "Bruno"
|
||||
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 = ""
|
||||
|
||||
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
|
||||
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)
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
local_ws_id = 0
|
||||
recent = []
|
||||
if owner and repo:
|
||||
view_map = {
|
||||
@@ -169,11 +290,93 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
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": [], "user": user,
|
||||
"workspace_key": ws_key}
|
||||
"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}
|
||||
|
||||
|
||||
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
||||
@@ -275,19 +478,108 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
||||
# ═══════════ Library page ═══════════
|
||||
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
async def library_page(request: Request):
|
||||
"""Library page avec onglets Recents, Favorites, Shared, Private."""
|
||||
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)
|
||||
# Enrich with timestamps
|
||||
for p in sidebar["recent_pages"]:
|
||||
p["last_edited"] = "now"
|
||||
for p in sidebar["private_pages"]:
|
||||
p["last_edited"] = "now"
|
||||
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")
|
||||
@@ -499,23 +791,29 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
"""Create a new Markdown page, optionally as a sub-page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
||||
with get_conn() as conn:
|
||||
# Compute next sort_order for this parent
|
||||
next_order = 0
|
||||
parent_val = parent_id if parent_id > 0 else None
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?",
|
||||
(ws_key, parent_val),
|
||||
).fetchone()
|
||||
if row:
|
||||
next_order = row[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)",
|
||||
(ws_key, title, section, parent_val, next_order),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
return {"status": "ok", "id": page_id, "title": title, "workspace": ws_key, "parent_id": parent_id}
|
||||
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}")
|
||||
@@ -565,42 +863,49 @@ async def save_page_blocks(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}/move")
|
||||
async def move_page(request: Request, page_id: int,
|
||||
new_parent_id: int = Query(default=0),
|
||||
new_order: int = Query(default=0)):
|
||||
"""Move/reorder a page in the tree (drag & drop).
|
||||
new_parent_id=0 means move to root level.
|
||||
new_order=0 means append to end (or set explicit position)."""
|
||||
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:
|
||||
# Verify page exists
|
||||
row = conn.execute("SELECT id, workspace FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
# Update parent (None for root level)
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_parent_id if new_parent_id > 0 else None, page_id),
|
||||
)
|
||||
# Update sort order
|
||||
conn.execute(
|
||||
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_order, page_id),
|
||||
)
|
||||
# Re-index siblings to ensure no gaps
|
||||
siblings = conn.execute(
|
||||
"SELECT id, sort_order FROM pages WHERE workspace=? AND parent_id IS ? AND id != ? ORDER BY sort_order ASC",
|
||||
(row["workspace"], new_parent_id if new_parent_id > 0 else None, page_id),
|
||||
).fetchall()
|
||||
# Recompute sort_order for all siblings after the insertion point
|
||||
insert_point = new_order
|
||||
reorder = []
|
||||
for sib in siblings:
|
||||
if sib["sort_order"] >= insert_point:
|
||||
reorder.append(sib["id"])
|
||||
for i, sid in enumerate(sorted(reorder, key=lambda x: next(s["sort_order"] for s in siblings if s["id"] == x))):
|
||||
conn.execute("UPDATE pages SET sort_order=? WHERE id=?", (insert_point + i + 1, sid))
|
||||
|
||||
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, "parent_id": new_parent_id, "order": new_order}
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}")
|
||||
@@ -618,17 +923,68 @@ async def delete_page(request: Request, page_id: int):
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
async def view_page(request: Request, page_id: int):
|
||||
"""Render a page as HTML."""
|
||||
"""Render a page as HTML, or a file viewer for uploaded files."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
||||
sidebar = _sidebar_data(request)
|
||||
ctx = {**sidebar, "page": dict(row)}
|
||||
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")
|
||||
return template.render(**ctx)
|
||||
response = template.render(**ctx)
|
||||
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
||||
|
||||
|
||||
@router.post("/api/sync/{owner}/{repo}")
|
||||
async def sync_project(owner: str, repo: str):
|
||||
|
||||
@@ -537,6 +537,229 @@ async def evaluate_formula(request: Request):
|
||||
return {"result": result, "expression": expression}
|
||||
|
||||
|
||||
# ── v1.7.0 View Management ──
|
||||
|
||||
|
||||
@router.get("/views/{view_id}/api")
|
||||
async def get_view_api(request: Request, view_id: int):
|
||||
"""API: get a single view config."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.put("/views/{view_id}/config")
|
||||
async def update_view_config(request: Request, view_id: int):
|
||||
"""API: update view configuration (group_by, card_size, visible_properties, etc.)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
|
||||
config = json.loads(existing["config_json"])
|
||||
for key in ("group_by", "card_size", "cover_property", "visible_properties",
|
||||
"filters", "sorts", "filter_conjunction", "date_property", "date_range_property"):
|
||||
if key in body:
|
||||
config[key] = body[key]
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name) WHERE id=?",
|
||||
(json.dumps(config), body.get("name"), view_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": view_id, "status": "updated", "config": config}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/views/save-as")
|
||||
async def save_view_as(request: Request, collection_id: int):
|
||||
"""API: save current view state as a new named view."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "New View")
|
||||
config = body.get("config", {})
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)",
|
||||
(collection_id, name, body.get("view_type", "table"), json.dumps(config), max_pos),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": cur.lastrowid, "name": name, "status": "saved"}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/api")
|
||||
async def list_views_api(request: Request, collection_id: int):
|
||||
"""API: list all views for a collection."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return {"views": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ── v1.8.0 Sub-items & Dependencies ──
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/sub-items")
|
||||
async def list_sub_items(request: Request, collection_id: int, page_id: int):
|
||||
"""API: list sub-items of a page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE parent_id=? ORDER BY position",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return {"sub_items": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/sub-items")
|
||||
async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
||||
"""API: create a sub-item under a page."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
title = body.get("title", "New sub-item").strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
|
||||
with get_conn() as conn:
|
||||
parent = conn.execute("SELECT id FROM collection_pages WHERE id=? AND collection_id=?", (page_id, collection_id)).fetchone()
|
||||
if not parent:
|
||||
raise HTTPException(status_code=404, detail="Parent page not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE parent_id=?",
|
||||
(page_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)",
|
||||
(collection_id, title, page_id, max_pos, json.dumps(body.get("properties", {}))),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": cur.lastrowid, "title": title, "parent_id": page_id, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
|
||||
async def aggregate_child_status(request: Request, collection_id: int, page_id: int):
|
||||
"""API: compute aggregate status from children."""
|
||||
with get_conn() as conn:
|
||||
children = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE parent_id=?",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
|
||||
statuses = []
|
||||
for c in children:
|
||||
props = json.loads(c["property_values_json"])
|
||||
for v in props.values():
|
||||
if isinstance(v, str) and v:
|
||||
statuses.append(v)
|
||||
|
||||
total = len(statuses)
|
||||
if total == 0:
|
||||
return {"total": 0, "done": 0, "all_done": False}
|
||||
|
||||
done = sum(1 for s in statuses if s.lower() in ("done", "complete", "completed", "terminé"))
|
||||
return {"total": total, "done": done, "all_done": done == total}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/dependencies")
|
||||
async def set_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
"""API: set blocking dependencies for a page (stored as 'blocks' property)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
blocks_ids = body.get("blocks", [])
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
props = json.loads(page["property_values_json"])
|
||||
props["blocks"] = blocks_ids
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
|
||||
(json.dumps(props), page_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"page_id": page_id, "blocks": blocks_ids, "status": "updated"}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/check-deps")
|
||||
async def check_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
"""API: check if a page can transition to a new status."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
new_status = body.get("new_status", "Done")
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
props = json.loads(page["property_values_json"])
|
||||
blocks_ids = props.get("blocks", [])
|
||||
|
||||
if not blocks_ids:
|
||||
return {"can_transition": True, "blocked_by": []}
|
||||
|
||||
# Check blocked pages status
|
||||
placeholders = ",".join("?" for _ in blocks_ids)
|
||||
blocked = conn.execute(
|
||||
f"SELECT id, title, property_values_json FROM collection_pages WHERE id IN ({placeholders})",
|
||||
blocks_ids,
|
||||
).fetchall()
|
||||
|
||||
blockers = []
|
||||
for b in blocked:
|
||||
bprops = json.loads(b["property_values_json"])
|
||||
bstatus = None
|
||||
for v in bprops.values():
|
||||
if isinstance(v, str) and v:
|
||||
bstatus = v
|
||||
break
|
||||
if bstatus and bstatus.lower() not in ("done", "complete", "completed", "terminé"):
|
||||
blockers.append({"id": b["id"], "title": b["title"], "status": bstatus})
|
||||
|
||||
return {
|
||||
"can_transition": len(blockers) == 0,
|
||||
"blocked_by": blockers,
|
||||
}
|
||||
|
||||
|
||||
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
||||
|
||||
|
||||
|
||||
@@ -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,178 @@
|
||||
"""FlowDeck — My Tasks router (v1.9.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
|
||||
|
||||
|
||||
def _get_current_user(request: Request) -> dict | None:
|
||||
session = request.cookies.get("flowdeck_session", "")
|
||||
return SessionManager.decode_session(session)
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
|
||||
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
|
||||
user = _get_current_user(request)
|
||||
user_login = user.get("login", "admin") if user else "admin"
|
||||
|
||||
with get_conn() as conn:
|
||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||
grouped: dict[str, list[dict]] = {}
|
||||
|
||||
for col in collections:
|
||||
col_id = col["id"]
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
(col_id,),
|
||||
).fetchall()
|
||||
|
||||
collection_tasks = []
|
||||
for p in pages:
|
||||
props = json.loads(p["property_values_json"])
|
||||
# Check if assigned to current user
|
||||
assigned = False
|
||||
for v in props.values():
|
||||
if isinstance(v, list):
|
||||
for item in v:
|
||||
if isinstance(item, dict) and item.get("login") == user_login:
|
||||
assigned = True
|
||||
break
|
||||
elif isinstance(v, str) and user_login.lower() in v.lower():
|
||||
assigned = True
|
||||
if assigned:
|
||||
break
|
||||
|
||||
if not assigned and user_login == "admin":
|
||||
assigned = True # admin sees all
|
||||
|
||||
if not assigned:
|
||||
continue
|
||||
|
||||
# Extract status and due date
|
||||
status = None
|
||||
due_date = None
|
||||
for v in props.values():
|
||||
if isinstance(v, str) and v in ("Todo", "In Progress", "Done", "Complete"):
|
||||
status = v
|
||||
elif isinstance(v, str) and v.startswith("20"):
|
||||
due_date = v[:10]
|
||||
|
||||
# Filter by view
|
||||
if view == "today":
|
||||
from datetime import date
|
||||
if not due_date or due_date != date.today().isoformat():
|
||||
continue
|
||||
elif view == "overdue":
|
||||
from datetime import date
|
||||
if not due_date or due_date >= date.today().isoformat():
|
||||
continue
|
||||
elif view == "upcoming":
|
||||
from datetime import date, timedelta
|
||||
if not due_date:
|
||||
continue
|
||||
today = date.today()
|
||||
limit = today + timedelta(days=days)
|
||||
d = date.fromisoformat(due_date)
|
||||
if d < today or d > limit:
|
||||
continue
|
||||
|
||||
collection_tasks.append({
|
||||
"id": p["id"], "title": p["title"], "icon": p.get("icon", "📄"),
|
||||
"status": status, "due_date": due_date, "props": props,
|
||||
})
|
||||
|
||||
if collection_tasks:
|
||||
grouped[col["name"]] = collection_tasks
|
||||
|
||||
total = sum(len(t) for t in grouped.values())
|
||||
|
||||
# Render
|
||||
sections = ""
|
||||
for col_name, tasks in grouped.items():
|
||||
items = ""
|
||||
for t in tasks:
|
||||
due_badge = f"<span class='mt-due'>{t['due_date']}</span>" if t.get("due_date") else ""
|
||||
status_badge = f"<span class='mt-status mt-{t['status'].lower().replace(' ','-') if t.get('status') else 'none'}'>{t.get('status','—')}</span>"
|
||||
items += f"""<div class='mt-item'>
|
||||
<span class='mt-icon'>{t['icon']}</span>
|
||||
<span class='mt-title'>{t['title']}</span>
|
||||
{status_badge}{due_badge}
|
||||
</div>"""
|
||||
|
||||
sections += f"""<div class='mt-section'>
|
||||
<div class='mt-section-header'>{col_name} <span class='mt-count'>{len(tasks)}</span></div>
|
||||
{items}
|
||||
</div>"""
|
||||
|
||||
return HTMLResponse(f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>My Tasks — FlowDeck</title>
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;background:#191919;color:#fff;margin:0;padding:20px}}
|
||||
h1{{font-size:24px;margin:0 0 16px}}
|
||||
.view-tabs{{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid #333;padding-bottom:8px}}
|
||||
.tab{{padding:6px 14px;border-radius:6px;cursor:pointer;color:#A0A0A0;font-size:13px;background:none;border:none;text-decoration:none}}
|
||||
.tab:hover,.tab.active{{background:#333;color:#fff}}
|
||||
.mt-section{{margin-bottom:24px}}
|
||||
.mt-section-header{{font-size:14px;font-weight:600;color:#A0A0A0;margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px}}
|
||||
.mt-count{{color:#6B6B6B;font-weight:400}}
|
||||
.mt-item{{display:flex;align-items:center;gap:10px;padding:8px 12px;background:#1a1a1a;border-radius:6px;margin-bottom:3px;border:1px solid #222}}
|
||||
.mt-item:hover{{border-color:#444}}
|
||||
.mt-icon{{font-size:16px}}
|
||||
.mt-title{{flex:1;font-size:14px}}
|
||||
.mt-status{{font-size:11px;padding:2px 8px;border-radius:4px}}
|
||||
.mt-due{{font-size:11px;color:#A0A0A0}}
|
||||
.mt-todo{{background:#333;color:#A0A0A0}}
|
||||
.mt-in-progress{{background:#1a2744;color:#3366CC}}
|
||||
.mt-done,.mt-complete{{background:#1a332a;color:#00CC66}}
|
||||
.p_desc{{color:#A0A0A0;margin-top:8px}}
|
||||
</style></head><body>
|
||||
<h1>📋 My Tasks</h1>
|
||||
<div class="view-tabs">
|
||||
<a class="tab{' active' if view=='all' else ''}" href="?view=all">All</a>
|
||||
<a class="tab{' active' if view=='today' else ''}" href="?view=today">Today</a>
|
||||
<a class="tab{' active' if view=='overdue' else ''}" href="?view=overdue">Overdue</a>
|
||||
<a class="tab{' active' if view=='upcoming' else ''}" href="?view=upcoming&days=7">Next 7 days</a>
|
||||
</div>
|
||||
{sections}
|
||||
<p class="p_desc">{total} tasks across {len(grouped)} collections</p>
|
||||
</body></html>""")
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
async def my_tasks_api(request: Request, view: str = "all", days: int = 7):
|
||||
"""API: return my tasks as JSON."""
|
||||
user = _get_current_user(request)
|
||||
user_login = user.get("login", "admin") if user else "admin"
|
||||
|
||||
with get_conn() as conn:
|
||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||
result = {}
|
||||
|
||||
for col in collections:
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
(col["id"],),
|
||||
).fetchall()
|
||||
|
||||
tasks = []
|
||||
for p in pages:
|
||||
props = json.loads(p["property_values_json"])
|
||||
tasks.append({
|
||||
"id": p["id"], "title": p["title"], "icon": p["icon"],
|
||||
"properties": props,
|
||||
})
|
||||
|
||||
if tasks:
|
||||
result[col["name"]] = tasks
|
||||
|
||||
return {"tasks": result, "total": sum(len(t) for t in result.values())}
|
||||
@@ -0,0 +1,89 @@
|
||||
"""FlowDeck — Public API router (v2.1.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Header, Depends
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["public-api"], prefix="/api/v1")
|
||||
DEFAULT_TOKEN = "fd-public-key"
|
||||
|
||||
|
||||
def verify_token(authorization: str | None = Header(None)):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(401, "API token required. Generate one via POST /api/v1/token.")
|
||||
token = authorization[7:] # strip "Bearer "
|
||||
if token == DEFAULT_TOKEN:
|
||||
return token
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT 1 FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(403, "Invalid API token")
|
||||
return token
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
async def generate_token(request: Request):
|
||||
"""Generate a public API access token."""
|
||||
token = f"fd_{token_urlsafe(24)}"
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
(0, token),
|
||||
)
|
||||
conn.commit()
|
||||
return {"token": token, "note": "Use as: Authorization: Bearer <token>"}
|
||||
|
||||
|
||||
@router.get("/collections", dependencies=[Depends(verify_token)])
|
||||
async def public_list_collections(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, description, icon, created_at FROM collections ORDER BY name").fetchall()
|
||||
return {"collections": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}", dependencies=[Depends(verify_token)])
|
||||
async def public_get_collection(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(404, "Not found")
|
||||
pages = conn.execute(
|
||||
"SELECT id, title, icon, position, property_values_json, created_at, updated_at FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return {"collection": dict(coll), "pages": [dict(p) for p in pages]}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/pages", dependencies=[Depends(verify_token)])
|
||||
async def public_list_pages(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
"SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return {"pages": [dict(p) for p in pages]}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", dependencies=[Depends(verify_token)])
|
||||
async def public_get_page(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
p = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not p:
|
||||
raise HTTPException(404, "Not found")
|
||||
return dict(p)
|
||||
|
||||
|
||||
@router.get("/my-tasks", dependencies=[Depends(verify_token)])
|
||||
async def public_my_tasks(request: Request):
|
||||
"""Public API: list tasks (requires valid token)."""
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE parent_id IS NULL ORDER BY created_at DESC LIMIT 50"
|
||||
).fetchall()
|
||||
return {"tasks": [dict(p) for p in pages]}
|
||||
@@ -0,0 +1,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,411 @@
|
||||
"""FlowDeck — Workspace, Comments, Favorites, History, Templates (v2.0.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
||||
|
||||
ROLES = ["admin", "editor", "commenter", "viewer"]
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
s = request.cookies.get("flowdeck_session", "")
|
||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||
|
||||
|
||||
# ── Workspaces ──
|
||||
|
||||
@router.get("")
|
||||
async def list_workspaces(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM workspaces ORDER BY name").fetchall()
|
||||
return {"workspaces": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_workspace(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "Default Workspace")
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
|
||||
with get_conn() as conn:
|
||||
uid_ensured = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not uid_ensured:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||
(uid, user.get("login", "admin"), user.get("full_name", "Admin")))
|
||||
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?,?)", (name, uid))
|
||||
ws_id = cur.lastrowid
|
||||
conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
|
||||
(ws_id, uid, "admin"))
|
||||
conn.commit()
|
||||
return {"id": ws_id, "name": name, "status": "created"}
|
||||
|
||||
|
||||
# ── Members ──
|
||||
|
||||
@router.get("/{ws_id}/members")
|
||||
async def list_members(request: Request, ws_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT wm.*, u.login, u.full_name, u.avatar_url FROM workspace_members wm JOIN users u ON wm.user_id=u.id WHERE wm.workspace_id=?",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
return {"members": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/{ws_id}/members")
|
||||
async def add_member(request: Request, ws_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = body.get("user_id")
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
raise HTTPException(400, f"Invalid role: {role}")
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||
(user_id, f"user_{user_id}", f"User {user_id}"))
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
|
||||
(ws_id, user_id, role))
|
||||
conn.commit()
|
||||
return {"status": "added"}
|
||||
|
||||
|
||||
@router.put("/{ws_id}/members/{user_id}")
|
||||
async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
raise HTTPException(400, f"Invalid role: {role}")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?",
|
||||
(role, ws_id, user_id))
|
||||
conn.commit()
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/{ws_id}/members/{user_id}")
|
||||
async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
|
||||
conn.commit()
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
# ── Comments ──
|
||||
|
||||
@router.get("/pages/{page_id}/comments")
|
||||
async def list_comments(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT c.*, u.login, u.avatar_url FROM comments c JOIN users u ON c.user_id=u.id WHERE c.page_id=? ORDER BY c.created_at",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return {"comments": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/comments")
|
||||
async def add_comment(request: Request, page_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
b = body.get("body", "").strip()
|
||||
if not b:
|
||||
raise HTTPException(400, "body required")
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
parent_id = body.get("parent_id")
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||
(uid, user.get("login", "admin"), user.get("full_name", "Admin")))
|
||||
cur = conn.execute("INSERT INTO comments (page_id, user_id, body, parent_id) VALUES (?,?,?,?)",
|
||||
(page_id, uid, b, parent_id))
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/comments/{comment_id}")
|
||||
async def update_comment(request: Request, comment_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
b = body.get("body")
|
||||
resolved = body.get("resolved")
|
||||
with get_conn() as conn:
|
||||
if b is not None:
|
||||
conn.execute("UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (b, comment_id))
|
||||
if resolved is not None:
|
||||
conn.execute("UPDATE comments SET resolved=? WHERE id=?", (int(resolved), comment_id))
|
||||
conn.commit()
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
# ── Page History ──
|
||||
|
||||
@router.get("/pages/{page_id}/history")
|
||||
async def page_history(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return {"history": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/history")
|
||||
async def record_history(request: Request, page_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
|
||||
conn.execute(
|
||||
"INSERT INTO page_history (page_id, user_id, change_type, snapshot_json) VALUES (?,?,?,?)",
|
||||
(page_id, uid, body.get("change_type", "updated"), json.dumps(body.get("snapshot", {}))),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "recorded"}
|
||||
|
||||
|
||||
# ── Favorites ──
|
||||
|
||||
@router.get("/favorites")
|
||||
async def list_favorites(request: Request):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT f.*, cp.title as page_title, c.name as collection_name
|
||||
FROM favorites f
|
||||
LEFT JOIN collection_pages cp ON f.page_id=cp.id
|
||||
LEFT JOIN collections c ON f.collection_id=c.id
|
||||
WHERE f.user_id=? ORDER BY f.position""",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
return {"favorites": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/favorites")
|
||||
async def add_favorite(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
page_id = body.get("page_id")
|
||||
collection_id = body.get("collection_id")
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO favorites (user_id, page_id, collection_id) VALUES (?,?,?)",
|
||||
(uid, page_id, collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "favorited"}
|
||||
|
||||
|
||||
@router.delete("/favorites/{fav_id}")
|
||||
async def remove_favorite(request: Request, fav_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
|
||||
conn.commit()
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
# ── Templates ──
|
||||
|
||||
@router.get("/templates/database")
|
||||
async def list_db_templates(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
|
||||
return {"templates": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/templates/database")
|
||||
async def create_db_template(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
cur = None
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO database_templates (name, description, schema_json) VALUES (?,?,?)",
|
||||
(body.get("name", "Template"), body.get("description", ""), json.dumps(body.get("schema", []))),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@router.post("/templates/database/{tid}/apply")
|
||||
async def apply_db_template(request: Request, tid: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "New Database")
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (tid,)).fetchone()
|
||||
if not tmpl:
|
||||
raise HTTPException(404, "Template not found")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collections (name, description, icon, schema_json) VALUES (?,?,?,?)",
|
||||
(name, tmpl["description"], "📋", tmpl["schema_json"]),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)",
|
||||
(cur.lastrowid, "Default View", "table", "{}"),
|
||||
)
|
||||
conn.commit()
|
||||
return {"collection_id": cur.lastrowid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/templates/page")
|
||||
async def list_page_templates(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,)
|
||||
).fetchall()
|
||||
return {"templates": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/templates/page")
|
||||
async def create_page_template(request: Request, collection_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO page_templates (collection_id, name, property_values_json) VALUES (?,?,?)",
|
||||
(collection_id, body.get("name", "Default"), json.dumps(body.get("properties", {}))),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/templates/page/{tid}/apply")
|
||||
async def apply_page_template(request: Request, collection_id: int, tid: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)).fetchone()
|
||||
if not tmpl:
|
||||
raise HTTPException(404, "Template not found")
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (collection_id,)
|
||||
).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
|
||||
(collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
# ── CSV Import/Export ──
|
||||
|
||||
@router.post("/collections/{collection_id}/import/csv")
|
||||
async def import_csv(request: Request, collection_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
csv_data = body.get("csv", "")
|
||||
if not csv_data:
|
||||
raise HTTPException(400, "csv field required")
|
||||
|
||||
reader = csv.DictReader(io.StringIO(csv_data))
|
||||
rows_imported = 0
|
||||
with get_conn() as conn:
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) FROM collection_pages WHERE collection_id=?", (collection_id,)
|
||||
).fetchone()[0]
|
||||
for row in reader:
|
||||
title = row.get("title", row.get("Title", row.get("Name", "Imported")))
|
||||
props = {k: v for k, v in row.items() if k.lower() != "title"}
|
||||
max_pos += 1
|
||||
conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
|
||||
(collection_id, title, max_pos, json.dumps(props)),
|
||||
)
|
||||
rows_imported += 1
|
||||
conn.commit()
|
||||
return {"imported": rows_imported}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/export/csv")
|
||||
async def export_csv(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)
|
||||
).fetchall()
|
||||
|
||||
# Collect all property keys
|
||||
all_keys = set()
|
||||
rows = []
|
||||
for p in pages:
|
||||
props = json.loads(p["property_values_json"])
|
||||
all_keys.update(props.keys())
|
||||
rows.append({"title": p["title"], **props})
|
||||
|
||||
output = io.StringIO()
|
||||
fieldnames = ["title"] + sorted(all_keys)
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=export.csv"},
|
||||
)
|
||||
|
||||
|
||||
# ── Webhooks Outbound Management ──
|
||||
|
||||
@router.get("/webhooks")
|
||||
async def list_webhooks(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
|
||||
return {"webhooks": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/webhooks")
|
||||
async def create_webhook(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
url = body.get("url", "").strip()
|
||||
event = body.get("event", "page.created")
|
||||
secret = body.get("secret", "")
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?,?,?)",
|
||||
(url, event, secret),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "url": url, "event": event, "status": "registered"}
|
||||
|
||||
|
||||
@router.delete("/webhooks/{wh_id}")
|
||||
async def delete_webhook(request: Request, wh_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
|
||||
conn.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
# ── Public Sharing ──
|
||||
|
||||
@router.get("/public/{collection_id}")
|
||||
async def public_view(request: Request, collection_id: int):
|
||||
"""Simple public read-only view — no auth required."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
pages = conn.execute(
|
||||
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
items = "".join(
|
||||
f"<li>{p['icon']} <b>{p['title']}</b></li>"
|
||||
for p in pages
|
||||
)
|
||||
return HTMLResponse(f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>{coll['name']} — FlowDeck Public</title>
|
||||
<style>body{{font-family:system-ui;background:#191919;color:#fff;padding:20px}}
|
||||
h1{{font-size:24px}} li{{padding:8px;border-bottom:1px solid #333}}</style></head>
|
||||
<body><h1>{coll['icon']} {coll['name']}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
|
||||
@@ -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,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": "🏠", "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">{{ page_icon }}</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" x-text="h.icon || '📄'"></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" x-text="item.icon || '📄'"></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" x-text="s.icon || '📄'"></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,46 @@
|
||||
{% 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">{{ page.icon }}</span>
|
||||
{% if page.is_folder %}
|
||||
<span class="page-name tree-folder-link"
|
||||
@click.stop="navigateToFolder({{ page.db_id }})"
|
||||
draggable="false"
|
||||
title="Open folder">{{ page.name }}{% if page.child_count %} <span class="child-count">({{ page.child_count }})</span>{% endif %}</span>
|
||||
{% else %}
|
||||
<a href="/pages/{{ page.db_id }}" class="page-name" draggable="false">{{ page.name }}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% if page.children %}
|
||||
<li x-show="expandedFolders[{{ page.db_id }}]" x-transition style="list-style:none;margin:0;padding:0;">
|
||||
<ul style="list-style:none;margin:0;padding:0;">
|
||||
{{ render_workspace_tree(page.children, depth + 1) }}
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}👤{% endblock %}
|
||||
{% block page_title %}Accounts{% endblock %}
|
||||
{% block title_prefix %}Accounts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-title-area">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}📁{% endblock %}
|
||||
{% block page_title %}Kanban board new{% endblock %}
|
||||
{% block title_prefix %}Kanban board new{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Cover image -->
|
||||
@@ -244,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 = '';
|
||||
@@ -277,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();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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_title %}Home{% endblock %}
|
||||
{% block title_prefix %}Home{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-title-area" style="padding-top: 24px;">
|
||||
|
||||
@@ -0,0 +1,819 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}{{ workspace_name }}{% endblock %}
|
||||
{% block title_prefix %}{{ workspace_name }}{% endblock %}
|
||||
{% block page_icon %}🔗{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
|
||||
{% set page_icon = "🔗" %}
|
||||
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">📄+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">📤</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">🔄</button><a href="/accounts/settings" class="topbar-btn" title="Settings">⚙</a>' %}
|
||||
{% 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">🔗</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' ? '📁' : '📄';
|
||||
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">🔒</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' ? '📁' : '📄';
|
||||
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()">✏️ Edit</button>
|
||||
<button class="btn btn-danger" @click="deleteCurrentFile()">🗑 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()">💾 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>📁 Empty</h2>
|
||||
<p>No files in this folder</p>
|
||||
<button class="btn btn-primary" @click="showNewFile=!showNewFile">📄+ 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-text="item.type==='folder' ? '📁' : '📄'" 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()">✏️ Rename</div>
|
||||
<div class="ctx-item ctx-danger" @click="gwDelete()">🗑 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()">💾 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>🔒 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)">🗑</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()">💾 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,204 @@
|
||||
<!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>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>📚</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">📝</div>
|
||||
<h3>Block Editor</h3>
|
||||
<p>Notion-style editor with slash commands, markdown shortcuts, headings, code blocks, to-dos, and more.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📊</div>
|
||||
<h3>Kanban & Tables</h3>
|
||||
<p>Visual project management with drag-drop boards, table views, and database collections.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🦎</div>
|
||||
<h3>Gitea & GitHub</h3>
|
||||
<p>Browse repos, edit files, commit changes — all from your workspace sidebar.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🌐</div>
|
||||
<h3>Publish to Web</h3>
|
||||
<p>One-click publish any page to a public URL. Share with anyone, no account needed.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔒</div>
|
||||
<h3>Self-Hosted</h3>
|
||||
<p>Your data stays on your server. No vendor lock-in, full control, Docker single-container deploy.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">⚡</div>
|
||||
<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>
|
||||
@@ -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>📚</span> FlowDeck
|
||||
</a>
|
||||
<span class="pub-badge">🌐 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 %}⚙️{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set page_icon = "⚙" %}
|
||||
{% 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'">👤 My account</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'">🔔 Notifications</div>
|
||||
<div class="settings-nav-header">Workspace</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">📋 General</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">🏷️ Tags</div>
|
||||
<div class="settings-nav-header">Features</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">🧩 Integrations</div>
|
||||
<!-- Admin nav: only visible to admins -->
|
||||
<template x-if="userIsAdmin">
|
||||
<div>
|
||||
<div class="settings-nav-header">Admin</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">👥 Users & Roles</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">📋 Audit Log</div>
|
||||
</div>
|
||||
</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">
|
||||
Upload photo
|
||||
<input type="file" class="avatar-upload-input" accept="image/*" @change="uploadAvatar($event)">
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--text-dim);margin-top:4px;">JPG, PNG or GIF — max 2MB</div>
|
||||
</div>
|
||||
</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">💾 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 ? '🙈' : '👁'" style="top:50%;"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:12px;">
|
||||
<button class="btn btn-primary" @click="saveAccount()" :disabled="accountSaving || !accountForm.password" style="width:auto;padding:8px 20px;font-size:13px;">
|
||||
<span x-show="!accountSaving">🔒 Update password</span>
|
||||
<span x-show="accountSaving">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">🌳 Tree</option>
|
||||
<option value="list">📋 List</option>
|
||||
<option value="details">📊 Details</option>
|
||||
<option value="title">🏷️ Title</option>
|
||||
<option value="content">📝 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;">
|
||||
⚠️ 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);">🔗 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);">🔗 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">✏️</button>
|
||||
<button class="btn-sm btn-sm-danger" @click="adminDeleteUser(u.id)" title="Delete" x-show="adminUsers.length > 1">🗑</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,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}🗑️{% endblock %}
|
||||
{% block page_title %}Trash{% endblock %}
|
||||
{% block title_prefix %}Trash{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-title-area">
|
||||
@@ -21,7 +22,7 @@
|
||||
<!-- Filters -->
|
||||
<div style="display:flex; gap:8px; margin-bottom:16px;">
|
||||
<button class="trash-filter active">
|
||||
<span style="color:#5b9bd5;">👤</span> Last edited by ▾
|
||||
<span style="color:var(--accent);">👤</span> Last edited by ▾
|
||||
</button>
|
||||
<button class="trash-filter">
|
||||
<span>📁</span> In ▾
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}Workspace{% endblock %}
|
||||
{% block title_prefix %}Workspace{% endblock %}
|
||||
{% block page_icon %}🏠{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set page_icon = "🏠" %}
|
||||
{% set page_title = "Workspace — Projects" %}
|
||||
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">🔑</a><a href="/accounts/settings" class="topbar-btn" title="Settings">⚙</a>' %}
|
||||
{% 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>🏠 Workspace</h1>
|
||||
<p>All your projects in one place — built-in, Gitea, and GitHub</p>
|
||||
</div>
|
||||
|
||||
<!-- Built-in Projects -->
|
||||
<div class="forge-section">
|
||||
<h2><span class="forge-badge builtin">Built-in</span> My Projects</h2>
|
||||
<div class="project-grid">
|
||||
<template x-for="p in builtinProjects" :key="p.id">
|
||||
<div class="project-card" @click="openProject(p)">
|
||||
<div class="project-card-top">
|
||||
<span class="project-card-icon" x-text="p.icon || '📁'"></span>
|
||||
<span class="forge-badge builtin">Built-in</span>
|
||||
</div>
|
||||
<div class="project-card-name" x-text="p.name"></div>
|
||||
<div class="project-card-desc" x-text="p.description || 'No description'"></div>
|
||||
<div class="project-card-meta">
|
||||
<span x-text="p.pageCount + ' pages'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="project-card" style="border:1px dashed 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">🔗 Gitea</span> Repositories</h2>
|
||||
<div class="project-grid">
|
||||
<template x-for="p in giteaProjects" :key="p.id">
|
||||
<div class="project-card" @click="openProject(p)">
|
||||
<div class="project-card-top">
|
||||
<span class="project-card-icon">📂</span>
|
||||
<span class="forge-badge gitea">Gitea</span>
|
||||
</div>
|
||||
<div class="project-card-name" x-text="p.name"></div>
|
||||
<div class="project-card-desc" x-text="p.description || 'No description'"></div>
|
||||
<div class="project-card-meta">
|
||||
<span x-text="p.full_name"></span>
|
||||
<span x-show="p.language" x-text="'· ' + p.language"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GitHub Projects -->
|
||||
<div class="forge-section" x-show="githubProjects.length">
|
||||
<h2><span class="forge-badge github">🐙 GitHub</span> Repositories</h2>
|
||||
<div class="project-grid">
|
||||
<template x-for="p in githubProjects" :key="p.id">
|
||||
<div class="project-card" @click="openProject(p)">
|
||||
<div class="project-card-top">
|
||||
<span class="project-card-icon">📂</span>
|
||||
<span class="forge-badge github">GitHub</span>
|
||||
</div>
|
||||
<div class="project-card-name" x-text="p.name"></div>
|
||||
<div class="project-card-desc" x-text="p.description || 'No description'"></div>
|
||||
<div class="project-card-meta">
|
||||
<span x-text="p.full_name"></span>
|
||||
<span x-show="p.language" x-text="'· ' + p.language"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div class="empty-state" x-show="!builtinProjects.length && !giteaProjects.length && !githubProjects.length">
|
||||
<p>No projects yet. Create a new project or connect a forge.</p>
|
||||
<a href="/auth/login?provider=local" class="btn">Get Started</a>
|
||||
</div>
|
||||
|
||||
<!-- Create Modal -->
|
||||
<div class="modal-overlay" x-show="showCreateModal" @click.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: '📁', 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 %}🏠{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %}
|
||||
{% set page_icon = "🏠" %}
|
||||
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">🔑</a><a href="/accounts/settings" class="topbar-btn" title="Settings">⚙</a>' %}
|
||||
{% 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>🏠 Workspaces</h1>
|
||||
</div>
|
||||
|
||||
<!-- ── Local Workspaces ── -->
|
||||
<div class="ws-section">
|
||||
<div class="ws-section-title">📁 My Workspaces <span class="badge" x-text="workspaces.length"></span></div>
|
||||
<div class="ws-grid">
|
||||
<template x-for="ws in workspaces" :key="'local-'+ws.id">
|
||||
<div class="ws-card" :class="{active:ws.id===activeId}" @click="selectLocal(ws)">
|
||||
<div class="ws-card-name" x-text="ws.name"></div>
|
||||
<div class="ws-card-count" x-text="ws.page_count+' page(s)'"></div>
|
||||
<div class="ws-card-actions" @click.stop>
|
||||
<button class="btn-sm" @click="renameWs(ws)">✏️</button>
|
||||
<button class="btn-sm" style="color:var(--danger);border-color:rgba(255,80,80,.3)" @click="deleteWs(ws)">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="ws-card btn-new" @click="showCreate=true">
|
||||
<span style="font-size:32px">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Gitea Projects ── -->
|
||||
<div class="ws-section">
|
||||
<div class="ws-section-title">🔗 Gitea Projects <span class="badge" x-text="giteaTotal"></span></div>
|
||||
|
||||
<!-- Not linked -->
|
||||
<div class="gitea-empty" x-show="giteaStatus==='not_linked'">
|
||||
<div>Connect your Gitea account to see your projects.</div>
|
||||
<a class="gitea-connect" href="/auth/login?provider=gitea&mode=link">🔗 Connect Gitea</a>
|
||||
<span style="margin:0 8px;color:var(--text-dim);">or</span>
|
||||
<a class="gitea-connect" href="/auth/login?provider=gitea">📝 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>⚠ 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">🔒</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,210 @@
|
||||
# ROADMAP — FlowDeck v3.0 : Multi-User, Multi-Forge, Standalone
|
||||
|
||||
> **Début**: 2026-07-10 | **Cible**: v3.0 | **Auteur**: Bruno + Hermes-Deepin
|
||||
> **Objectif**: Transformer FlowDeck d'un outil personnel lié à Gitea en une plateforme collaborative multi-utilisateur, multi-forge (Gitea/GitHub), fonctionnant avec ou sans forge Git.
|
||||
|
||||
---
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FLOWDECK v3.0 │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌────────────────────────┐ │
|
||||
│ │ Comptes │ │ Forges │ │ Workspaces & Projets │ │
|
||||
│ │ locaux │ │ Gitea │ │ (built-in, Gitea, │ │
|
||||
│ │ + OAuth │ │ GitHub │ │ GitHub) │ │
|
||||
│ └──────────┘ └──────────┘ └────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Navigation: Topbar avec arborescence projet │ │
|
||||
│ │ [Workspace] [Projet ▾] [Meetings] [Shared] │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Authentification Multi-Mode
|
||||
|
||||
### 1.1 Comptes locaux avec mot de passe
|
||||
- [x] **DB**: Ajouter `password_hash` (hash) à la table `users`
|
||||
- [x] **DB**: Ajouter `is_active`, `is_admin`, `last_login`, `login_attempts`, `locked_until`
|
||||
- [x] **API**: `POST /auth/register` — création de compte local (email + password)
|
||||
- [x] **API**: `POST /auth/login` — login local (email + password → session)
|
||||
- [x] **Page**: `/login` — formulaire login/register
|
||||
- [x] **Sécurité**: Rate limiting (5 tentatives → lock 15 min)
|
||||
- [x] **Sécurité**: CSRF sur toutes les routes auth
|
||||
- [x] **Session**: Détachée de Gitea — session valide sans OAuth
|
||||
|
||||
### 1.2 Page de configuration du compte
|
||||
- [x] **Page**: `/accounts/settings` — config du profil
|
||||
- [x] **Section**: Profil (nom, email, avatar, mot de passe)
|
||||
- [x] **Section**: Connexions forges (Gitea, GitHub) — lier/délier
|
||||
- [x] **Section**: Préférences (langue, thème, notifs)
|
||||
- [ ] **Section**: Tokens API (générer/révoquer des clés API)
|
||||
- [ ] **Section**: Sessions actives (voir et révoquer)
|
||||
|
||||
### 1.3 OAuth multi-forge
|
||||
- [x] **Refactor**: `GiteaOAuth` → `OAuthProvider` (classe abstraite)
|
||||
- [ ] **Implement**: `GitHubOAuthProvider` (OAuth2 GitHub)
|
||||
- [x] **Implement**: `GiteaOAuthProvider` (existant, refactoré)
|
||||
- [x] **DB**: Table `user_oauth_tokens` (user_id, provider, access_token, refresh_token, expires_at)
|
||||
- [x] **API**: `GET /auth/{provider}/login` — redirige vers OAuth
|
||||
- [x] **API**: `GET /auth/{provider}/callback` — callback OAuth
|
||||
- [x] **Page**: `/accounts/settings` → boutons "Connect GitHub" / "Connect Gitea"
|
||||
- [x] **Fallback**: L'utilisateur peut utiliser FlowDeck SANS lier aucune forge
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Abstraction Multi-Forge
|
||||
|
||||
### 2.1 Adapter pattern pour les forges
|
||||
- [x] **Interface**: `ForgeAdapter` (classe abstraite)
|
||||
- `list_repos(token) → List[Repo]`
|
||||
- `get_repo(token, owner, repo) → RepoDetail`
|
||||
- `list_issues(token, owner, repo) → List[Issue]`
|
||||
- `get_file_tree(token, owner, repo, path) → TreeNode`
|
||||
- `get_file_content(token, owner, repo, path) → str`
|
||||
- `create_webhook(token, owner, repo, url) → Webhook`
|
||||
- [x] **Implement**: `GiteaAdapter` (API Gitea existante → interface unifiée)
|
||||
- [ ] **Implement**: `GitHubAdapter` (API GitHub v3 → interface unifiée)
|
||||
- [x] **DB**: Table `forge_connections` (user_id, provider, token_id, instance_url)
|
||||
- [x] **Config**: Support GitHub Enterprise (custom URL) et Gitea self-hosted
|
||||
|
||||
### 2.2 Synchronisation des projets
|
||||
- [x] **Service**: `ProjectSyncService` — sync projets depuis les forges liées
|
||||
- [ ] **DB**: Table `projects` avec type (`builtin`, `gitea`, `github`)
|
||||
- [ ] **DB**: `projects.forge_id` — référence externe (repo ID)
|
||||
- [ ] **DB**: `projects.clone_url`, `projects.default_branch`, `projects.language`
|
||||
- [ ] **Cron**: Sync périodique des projets (configurable, défaut: chaque heure)
|
||||
- [x] **Page**: `/workspace` — liste TOUS les projets (built-in + Gitea + GitHub)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Workspace & Navigation
|
||||
|
||||
### 3.1 Nouvelle page Workspace
|
||||
- [x] **Page**: `/workspace` — dashboard central
|
||||
- Section "My Projects" (built-in)
|
||||
- Section "Gitea Projects" (si connecté)
|
||||
- Section "GitHub Projects" (si connecté)
|
||||
- Bouton "Create Project" (built-in)
|
||||
- Bouton "Import from Gitea/GitHub"
|
||||
- [x] **Filtres**: Par forge, par statut, recherche
|
||||
- [x] **Carte projet**: Nom, description, forge badge, dernière activité, nombre de pages
|
||||
|
||||
### 3.2 Barre de navigation projet
|
||||
- [x] **Topbar**: Section entre "Meetings" et la sidebar
|
||||
- Nom du projet actif (dropdown pour switcher)
|
||||
- Badge forge (icône Gitea/GitHub/built-in)
|
||||
- [x] **Arborescence**: Sous le projet dans la sidebar gauche
|
||||
- Dossiers et fichiers du repo Git (lecture seule si forge externe)
|
||||
- Pages FlowDeck liées au projet
|
||||
- Bouton "+" pour créer nouvelle page dans le projet
|
||||
- [x] **Service**: `FileTreeService` — construit l'arborescence depuis l'API forge
|
||||
- [x] **Cache**: Arborescence en cache (TTL 5 min) pour éviter les appels API à chaque page
|
||||
|
||||
### 3.3 Navigation projet
|
||||
- [x] **Sidebar**: Les projets apparaissent sous une section dédiée
|
||||
- [x] **Breadcrumb**: Workspace > Projet > Dossier > Page
|
||||
- [x] **Contexte projet**: Toute nouvelle page créée depuis un projet y est liée
|
||||
- [ ] **Quick switch**: Ctrl+K → chercher et switcher de projet
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Fonctionnement sans forge
|
||||
|
||||
### 4.1 Mode standalone
|
||||
- [x] **Config**: `FLOWDECK_STANDALONE=true` — démarre sans aucune forge
|
||||
- [x] **UI**: Pas de sections Gitea/GitHub si non configuré
|
||||
- [x] **Workspace**: Uniquement projets built-in
|
||||
- [x] **Auth**: Login local uniquement (pas de boutons OAuth)
|
||||
- [x] **Pages**: Création/édition 100% locale, pas de synchro externe
|
||||
|
||||
### 4.2 Mode hybride
|
||||
- [x] Un utilisateur peut avoir Gitea lié, un autre GitHub, un troisième rien
|
||||
- [x] Chaque utilisateur voit SES projets de forge dans SA workspace
|
||||
- [x] Les projets built-in sont partagés selon les permissions workspace
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Permissions & Collaboratif
|
||||
|
||||
### 5.1 Rôles workspace
|
||||
- [x] **DB**: Table `workspace_members` avec rôles: owner, admin, editor, viewer
|
||||
- [x] **API**: `POST /workspace/{id}/members` — inviter un utilisateur
|
||||
- [x] **API**: `DELETE /workspace/{id}/members/{user_id}` — retirer
|
||||
- [x] **API**: `PUT /workspace/{id}/members/{user_id}` — changer rôle
|
||||
- [x] **Middleware**: `PermissionMiddleware` — vérifie les droits avant chaque action
|
||||
|
||||
### 5.2 Partage de pages
|
||||
- [x] Étendre le système Share existant pour supporter les permissions workspace
|
||||
- [x] Une page dans un workspace est visible par tous les membres
|
||||
- [x] "Anyone with the link" crée un lien public indépendant du workspace
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — UI/UX Polishing
|
||||
|
||||
### 6.1 Design system
|
||||
- [ ] Unifier les couleurs, espacements, typographie dans un fichier `design-tokens.css`
|
||||
- [ ] Composants réutilisables: boutons, inputs, modales, dropdowns, toasts
|
||||
- [ ] Responsive: adapter la sidebar et topbar pour écrans < 1024px
|
||||
|
||||
### 6.2 Onboarding
|
||||
- [ ] Page `/welcome` au premier lancement
|
||||
- [ ] Wizard: créer compte → lier forges (optionnel) → créer premier projet
|
||||
- [ ] Templates de projets (vide, kanban, wiki, documentation)
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Infrastructure
|
||||
|
||||
### 7.1 Base de données
|
||||
- [ ] Migrations versionnées (Alembic ou script maison avec table `schema_version`)
|
||||
- [ ] Backup automatique (cron daily → fichier daté)
|
||||
- [ ] Index manquants (users.email, projects.forge_id, forge_connections.user_id)
|
||||
|
||||
### 7.2 Tests
|
||||
- [ ] Tests d'intégration auth (login local, OAuth mock)
|
||||
- [ ] Tests des adapters forge (mock HTTP responses)
|
||||
- [ ] Tests multi-user (2 utilisateurs, permissions croisées)
|
||||
- [ ] Tests standalone (sans forge configurée)
|
||||
- [ ] Cible: 100+ tests
|
||||
|
||||
### 7.3 CI/CD
|
||||
- [ ] Linting (ruff, eslint)
|
||||
- [ ] Tests parallèles (pytest-xdist)
|
||||
- [ ] Build Docker multi-stage (optimiser la taille d'image)
|
||||
|
||||
---
|
||||
|
||||
## Dépendances et points critiques
|
||||
|
||||
| Dépendance | Impact | Bloque |
|
||||
|-----------|--------|--------|
|
||||
| **Auth locale** | Fondation — tout le reste en dépend | Phases 2-7 |
|
||||
| **Abstraction forge** | Permet GitHub + standalone | Phases 3-4 |
|
||||
| **Permission system** | Nécessaire pour le multi-user réel | Phase 5 |
|
||||
| **Migration DB** | Les données existantes doivent survivre | Phase 1 |
|
||||
| **Session refactor** | Actuellement couplée à Gitea OAuth | Phase 1 |
|
||||
|
||||
---
|
||||
|
||||
## Chronologie estimée
|
||||
|
||||
| Phase | Effort | Priorité |
|
||||
|-------|--------|----------|
|
||||
| Phase 1 — Auth | 🔴🔴🔴 | Critique |
|
||||
| Phase 2 — Multi-forge | 🔴🔴 | Haute |
|
||||
| Phase 3 — Workspace/Nav | 🔴🔴 | Haute |
|
||||
| Phase 4 — Standalone | 🔴 | Moyenne |
|
||||
| Phase 5 — Permissions | 🔴🔴 | Haute |
|
||||
| Phase 6 — UI/UX | 🔴 | Moyenne |
|
||||
| Phase 7 — Infra | 🔴 | Continue |
|
||||
|
||||
---
|
||||
|
||||
*Dernière mise à jour: 2026-07-13 — phases 1-5 majoritairement complétées*
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 33 KiB |