Merge branch 'main' of https://git.dracodev.net/bruno/flowdeck
FlowDeck CI / test (push) Failing after 18s
FlowDeck CI / docker (push) Has been skipped

This commit is contained in:
2026-07-17 09:00:09 -04:00
29 changed files with 2655 additions and 730 deletions
+254 -3
View File
@@ -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 > **Version:** 3.0 · **Date:** 2026-07-14 · **Auteur:** Hermes-Deepin
> **Cible:** Clone Notion intégré à Gitea avec support multi-utilisateurs > **Cible:** Clone Notion intégré à Gitea/GitHub — multi-comptes, multi-intégrations
--- ---
@@ -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. Intégration Gitea
### 7.1 Flux de données ### 7.1 Flux de données
+41
View File
@@ -172,6 +172,47 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
--- ---
## v4.0.0 — Accounts, Integrations & Sidebar (priorité haute)
> **Rappel** : Section 6.5 de ARCHITECTURE.md documente le design complet.
### 4.0 Account Model
- [ ] **`auth_method`** — colonne `users.auth_method ∈ {local, gitea, github}`
- [ ] **Auto-détection** — migrer les comptes existants (gitea_token → gitea, sinon local)
- [ ] **Badge OAuth** — 🦎/🐙 dans sidebar SI `auth_method` = provider
- [ ] **Pas de badge** — un compte local avec intégration ne montre PAS de badge
### 4.0a Intégrations & Déconnexion
- [ ] **Settings page** — montre toutes les intégrations avec bouton Connect/Disconnect
- [ ] **Matrice déconnexion** : local→déco ok · gitea→déco github ok · github→déco gitea ok
- [ ] **Provider principal** — pas de bouton Disconnect sur auth_method
- [ ] **Compte local** — peut connecter Gitea + GitHub simultanément
### 4.0b Sidebar — règles par type de compte
- [ ] **Private visible** UNIQUEMENT si workspace actif est distant (Gitea/GitHub)
- [ ] **Private caché** pour Scénario A (local pur)
- [ ] **Trash local-only** — caché pour compte OAuth pur, restreint au contenu local
- [ ] **Boutons → Library** sur chaque section (recents, favorites, shared, published…)
### 4.0c Share & Publish
- [ ] **Modale Share** — 2 tabs (Share + Publish) fonctionnels
- [ ] **Share tab** — inviter email/user, général access lien
- [ ] **Publish tab** — activer/désactiver, slug généré
- [ ] **Page publique** — `/p/<slug>` sert le contenu publié
- [ ] **Tables DB** — `page_shares`, colonnes `is_published`, `is_shared`, `share_mode`
### 4.0d Library
- [ ] **Page `/library?tab=`** — tabs Recents, Favorites, Shared, Published, Private, Workspace
- [ ] **Filtres source** — All | Local | Gitea | GitHub
- [ ] **Table `recents`** — tracking automatique des documents consultés
### 4.0e Édition unifiée
- [ ] **Même UI** pour fichiers locaux ET distants
- [ ] **Bouton Commit** — visible uniquement pour fichiers distants
- [ ] **Champ message** — pour les commits Gitea/GitHub
---
## v4.0.0 — Parité Notion (Delta Analysis) ## v4.0.0 — Parité Notion (Delta Analysis)
> **Méthodologie**: Comparaison exhaustive de FlowDeck v3.0.1 vs Notion (2025) — 60+ fonctionnalités analysées, 45 manquantes identifiées, 30 retenues pour v4.0.0. > **Méthodologie**: Comparaison exhaustive de FlowDeck v3.0.1 vs Notion (2025) — 60+ fonctionnalités analysées, 45 manquantes identifiées, 30 retenues pour v4.0.0.
+52
View File
@@ -410,6 +410,58 @@ def init_db():
""") """)
conn.commit() 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()
@contextmanager @contextmanager
def get_conn(): def get_conn():
+6 -2
View File
@@ -13,8 +13,9 @@ from app.config import settings
from app.db import init_db from app.db import init_db
from app.middleware.csrf import CSRFMiddleware from app.middleware.csrf import CSRFMiddleware
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, public_api, admin 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.gitea import router as gitea_router
from app.routers.github_routes import router as github_router
from app.services.gitea_client import gitea from app.services.gitea_client import gitea
from app.services.webhook_outbound import init_webhook_tables from app.services.webhook_outbound import init_webhook_tables
@@ -41,7 +42,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="2.4.6", version="2.5.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None, docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None, redoc_url=None,
lifespan=lifespan, lifespan=lifespan,
@@ -62,9 +63,12 @@ app.include_router(webhooks.router)
app.include_router(collections.router) app.include_router(collections.router)
app.include_router(my_tasks.router) app.include_router(my_tasks.router)
app.include_router(workspace.router) app.include_router(workspace.router)
app.include_router(library.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(gitea_router) app.include_router(gitea_router)
app.include_router(github_router)
app.include_router(public_api.router) app.include_router(public_api.router)
app.include_router(sharing.router)
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
+1 -1
View File
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
""" """
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
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"} 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): async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt # Webhook receiver, OAuth callback, and internal API are exempt
+21 -8
View File
@@ -79,7 +79,7 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
let mode='login'; 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 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='👁';}} 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='/';}}catch(err){document.getElementById('error-msg').textContent='Network error';document.getElementById('error-msg').style.display='block';}} 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> </script>
</body> </body>
</html>""" </html>"""
@@ -117,11 +117,13 @@ async def login(request: Request, provider: str = Query("gitea")):
request.session["oauth_provider"] = provider request.session["oauth_provider"] = provider
# Link mode: connect OAuth to current local account instead of creating new user # Link mode: connect OAuth to current local account instead of creating new user
mode = request.query_params.get("mode", "") 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 request.session["oauth_mode"] = mode
# Dynamic redirect URI based on incoming Host header # Dynamic redirect URI based on incoming Host header
host = request.headers.get("host", "localhost:8080") host = request.headers.get("host", "localhost:8080")
dynamic_redirect_uri = f"http://{host}/auth/callback" dynamic_redirect_uri = f"http://{host}/auth/callback"
auth_url = oauth_provider.get_authorize_url(state, redirect_uri=dynamic_redirect_uri, force_login=(mode == "link")) auth_url = oauth_provider.get_authorize_url(signed_state, redirect_uri=dynamic_redirect_uri, force_login=(mode == "link"))
return RedirectResponse(url=auth_url, status_code=302) return RedirectResponse(url=auth_url, status_code=302)
@@ -232,11 +234,22 @@ async def callback(
state: str = Query(...), state: str = Query(...),
): ):
"""Handle OAuth2 callback from Gitea.""" """Handle OAuth2 callback from Gitea."""
# Validate state # Recover mode from state suffix (state:mode format), then validate
expected_state = request.session.get("oauth_state", "") expected_state = request.session.get("oauth_state", "")
provider_name = request.session.get("oauth_provider", "gitea") provider_name = request.session.get("oauth_provider", "gitea")
if not expected_state or state != expected_state:
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) 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 from app.auth.providers import get_provider
oauth_provider = get_provider(provider_name) oauth_provider = get_provider(provider_name)
@@ -282,11 +295,11 @@ async def callback(
login_id = f"{provider_name}_{oauth_user['login']}" login_id = f"{provider_name}_{oauth_user['login']}"
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
"""INSERT INTO users (login, full_name, email, avatar_url) """INSERT INTO users (login, full_name, email, avatar_url, auth_method)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(login) ON CONFLICT(login)
DO UPDATE SET full_name=excluded.full_name, email=excluded.email, avatar_url=excluded.avatar_url""", DO UPDATE SET full_name=excluded.full_name, email=excluded.email, avatar_url=excluded.avatar_url, auth_method=excluded.auth_method""",
(login_id, oauth_user.get("full_name", ""), oauth_user.get("email", ""), oauth_user.get("avatar_url", "")), (login_id, oauth_user.get("full_name", ""), oauth_user.get("email", ""), oauth_user.get("avatar_url", ""), provider_name),
) )
conn.commit() conn.commit()
# Store OAuth token # Store OAuth token
+117 -43
View File
@@ -5,7 +5,7 @@ import json
import logging import logging
from fastapi import APIRouter, Request, HTTPException, Query from fastapi import APIRouter, Request, HTTPException, Query
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, JSONResponse
from app.db import get_conn from app.db import get_conn
from app.services.gitea_client import gitea from app.services.gitea_client import gitea
@@ -172,7 +172,7 @@ def _load_workspace_pages(ws_cookie: str) -> list:
ws_id = int(ws_cookie) ws_id = int(ws_cookie)
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT id, title, parent_section, content_format FROM pages WHERE workspace_id=? AND parent_id IS NULL ORDER BY created_at DESC", "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,), (ws_id,),
).fetchall() ).fetchall()
items = [] items = []
@@ -197,7 +197,7 @@ def _load_children(parent_id: int) -> list:
"""Recursively load children of a page.""" """Recursively load children of a page."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT id, title, parent_section, content_format FROM pages WHERE parent_id=? ORDER BY created_at", "SELECT id, title, parent_section, content_format FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at",
(parent_id,), (parent_id,),
).fetchall() ).fetchall()
children = [] children = []
@@ -225,14 +225,46 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
from app.routers.dashboard import WORKSPACE_COOKIE from app.routers.dashboard import WORKSPACE_COOKIE
ws_cookie = request.cookies.get(WORKSPACE_COOKIE, "") ws_cookie = request.cookies.get(WORKSPACE_COOKIE, "")
active_ws_name = "Workspace" active_ws_name = "Workspace"
if ws_cookie: 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: try:
wsi = int(ws_cookie)
with get_conn() as conn: with get_conn() as conn:
ws = conn.execute("SELECT name FROM workspaces WHERE id=?", (int(ws_cookie),)).fetchone() row = conn.execute(
if ws: "SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?",
active_ws_name = ws["name"] (wsi, user["id"])
).fetchone()
if row:
active_ws_name = row["name"]
workspace_pages = _load_workspace_pages(ws_cookie)
except (ValueError, Exception): except (ValueError, Exception):
pass pass
local_ws_id = 0
recent = [] recent = []
if owner and repo: if owner and repo:
view_map = { view_map = {
@@ -289,8 +321,9 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
"WHERE share_mode='anyone' OR published=1 ORDER BY updated_at DESC LIMIT 20" "WHERE share_mode='anyone' OR published=1 ORDER BY updated_at DESC LIMIT 20"
).fetchall() ).fetchall()
shared_pages = [] shared_pages = []
published_pages = []
for r in shared_rows: for r in shared_rows:
shared_pages.append({ page_entry = {
"id": f"page/{r['id']}", "id": f"page/{r['id']}",
"db_id": r["id"], "db_id": r["id"],
"name": r["title"] or "New page", "name": r["title"] or "New page",
@@ -301,15 +334,49 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
"depth": 0, "depth": 0,
"has_children": False, "has_children": False,
"children": [], "children": [],
}) }
if r["published"]:
published_pages.append(page_entry)
else:
shared_pages.append(page_entry)
# Auth method & OAuth badge data
auth_method = "local"
gitea_linked = False
github_linked = False
if user and user.get("id"):
try:
with get_conn() as conn:
am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone()
if am_row and am_row["auth_method"]:
auth_method = am_row["auth_method"]
tokens = conn.execute(
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
).fetchall()
for t in tokens:
if t["provider"] == "gitea":
gitea_linked = True
elif t["provider"] == "github":
github_linked = True
except Exception:
pass
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B", return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
"active_ws_name": active_ws_name, "active_ws_name": active_ws_name,
"workspace_pages": _load_workspace_pages(ws_cookie), "workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else ws_key,
"workspace_pages": workspace_pages,
"gitea_workspace": gitea_workspace,
"gitea_owner": gitea_owner,
"gitea_repo": gitea_repo,
"local_ws_id": local_ws_id,
"current_page": repo or "Dashboard", "last_edited": "now", "current_page": repo or "Dashboard", "last_edited": "now",
"recent_pages": recent, "private_pages": private_items, "recent_pages": recent, "private_pages": private_items,
"favorite_pages": favorites, "shared_pages": shared_pages, "favorite_pages": favorites, "shared_pages": shared_pages,
"user": user, "workspace_key": ws_key} "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 = ""): def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
@@ -790,42 +857,49 @@ async def save_page_blocks(request: Request, page_id: int):
@router.put("/api/pages/{page_id}/move") @router.put("/api/pages/{page_id}/move")
async def move_page(request: Request, page_id: int, async def move_page(request: Request, page_id: int):
new_parent_id: int = Query(default=0), """Move a page to another workspace or reorder within tree.
new_order: int = Query(default=0)):
"""Move/reorder a page in the tree (drag & drop). Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
new_parent_id=0 means move to root level. - workspace_id: move page to a different workspace
new_order=0 means append to end (or set explicit position).""" - parent_id: change parent (0 = root level)
- new_order: position among siblings (0 = append)
"""
try:
body = await request.json()
except Exception:
body = {}
new_ws_id = body.get("workspace_id")
new_parent_id = body.get("parent_id", 0)
new_order = body.get("new_order", 0)
with get_conn() as conn: with get_conn() as conn:
# Verify page exists row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
row = conn.execute("SELECT id, workspace FROM pages WHERE id=?", (page_id,)).fetchone()
if not row: if not row:
raise HTTPException(404, "Page not found") raise HTTPException(404, "Page not found")
# Update parent (None for root level)
conn.execute( if new_ws_id:
"UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", # Move to a different workspace: get the workspace name
(new_parent_id if new_parent_id > 0 else None, page_id), ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone()
) if not ws_row:
# Update sort order return JSONResponse({"status": "error", "detail": "Workspace not found"}, status_code=404)
conn.execute( conn.execute(
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", "UPDATE pages SET workspace_id=?, workspace=?, parent_id=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_order, page_id), (new_ws_id, ws_row["name"], page_id),
) )
# Re-index siblings to ensure no gaps else:
siblings = conn.execute( # Reorder within same workspace
"SELECT id, sort_order FROM pages WHERE workspace=? AND parent_id IS ? AND id != ? ORDER BY sort_order ASC", conn.execute(
(row["workspace"], new_parent_id if new_parent_id > 0 else None, page_id), "UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
).fetchall() (new_parent_id if new_parent_id > 0 else None, page_id),
# Recompute sort_order for all siblings after the insertion point )
insert_point = new_order conn.execute(
reorder = [] "UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
for sib in siblings: (new_order, page_id),
if sib["sort_order"] >= insert_point: )
reorder.append(sib["id"])
for i, sid in enumerate(sorted(reorder, key=lambda x: next(s["sort_order"] for s in siblings if s["id"] == x))):
conn.execute("UPDATE pages SET sort_order=? WHERE id=?", (insert_point + i + 1, sid))
conn.commit() conn.commit()
return {"status": "ok", "id": page_id, "parent_id": new_parent_id, "order": new_order} return {"status": "ok", "id": page_id}
@router.delete("/api/pages/{page_id}") @router.delete("/api/pages/{page_id}")
+270 -131
View File
@@ -6,7 +6,7 @@ import logging
from fastapi import APIRouter, Request, Query from fastapi import APIRouter, Request, Query
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from app.services.gitea_client import gitea from app.services.gitea_client import gitea, get_user_gitea_client
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
@@ -31,7 +31,7 @@ def _get_user_or_redirect(request: Request):
return user return user
def _sidebar_data(request: Request, repos: list[dict]) -> dict: def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws = user.get("login", "Bruno") if user else "Bruno" ws = user.get("login", "Bruno") if user else "Bruno"
initial = ws[0].upper() if ws else "B" initial = ws[0].upper() if ws else "B"
@@ -64,46 +64,111 @@ def _sidebar_data(request: Request, repos: list[dict]) -> dict:
"children": [], "children": [],
}) })
# Active workspace from cookie # Active workspace from cookie (skip on pages like /workspaces where no
# workspace context should be shown)
from app.routers.board import _load_workspace_pages from app.routers.board import _load_workspace_pages
ws_cookie = request.cookies.get("flowdeck_workspace", "") ws_cookie = request.cookies.get("flowdeck_workspace", "")
active_ws_name = "Workspace" active_ws_name = "Workspace"
workspace_pages = [] workspace_pages = []
gitea_workspace = False gitea_workspace = False
gitea_owner = ""
gitea_repo = ""
if ws_cookie and ws_cookie.startswith("gitea:"): if include_workspace and ws_cookie and ws_cookie.startswith("gitea:"):
# Gitea workspace: set owner/repo for client-side tree loading # Gitea workspace: set owner/repo for client-side tree loading
# BUT keep local workspace pages — they go in the "Private" section
parts = ws_cookie.split(":", 2) parts = ws_cookie.split(":", 2)
if len(parts) >= 3: if len(parts) >= 3:
gitea_owner = parts[1] gitea_owner = parts[1]
gitea_repo = parts[2] gitea_repo = parts[2]
active_ws_name = f"{gitea_owner}/{gitea_repo}" active_ws_name = f"{gitea_owner}/{gitea_repo}"
gitea_workspace = True gitea_workspace = True
workspace_pages = [] # loaded client-side # workspace_pages stays as-is (local tree, loaded above)
elif ws_cookie: elif include_workspace and ws_cookie and user:
workspace_pages = _load_workspace_pages(ws_cookie) try:
wsi = int(ws_cookie)
with get_conn() as conn:
# Verify this workspace belongs to the current user
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)
# else: stale cookie from another user — ignore
except (ValueError, Exception):
pass
# Auth method & OAuth badge data
auth_method = "local"
gitea_linked = False
github_linked = False
if user and user.get("id"):
try: try:
with get_conn() as conn: with get_conn() as conn:
ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (int(ws_cookie),)).fetchone() am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone()
if ws_row: if am_row and am_row["auth_method"]:
active_ws_name = ws_row["name"] auth_method = am_row["auth_method"]
except (ValueError, Exception): 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
# Get local workspace ID for Gitea workspace mirror
local_ws_id = 0
if gitea_workspace and gitea_owner and gitea_repo:
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
# Private pages for mirror workspace (when Gitea remote active)
private_pages = []
if gitea_workspace and local_ws_id:
try:
with get_conn() as conn:
pp_rows = conn.execute(
"SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20",
(local_ws_id,)
).fetchall()
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
except Exception:
pass pass
return { return {
"workspace_name": ws, "workspace_initial": initial, "workspace_name": ws, "workspace_initial": initial,
"active_ws_name": active_ws_name, "active_ws_name": active_ws_name,
"workspace_pages": workspace_pages, "workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "",
"gitea_workspace": gitea_workspace, "gitea_workspace": gitea_workspace,
"gitea_owner": gitea_owner,
"gitea_repo": gitea_repo,
"local_ws_id": local_ws_id,
"workspace_pages": workspace_pages,
"current_page": "Dashboard", "last_edited": "now", "current_page": "Dashboard", "last_edited": "now",
"recent_pages": recent_pages, "recent_pages": recent_pages,
"private_pages": [], "private_pages": private_pages,
"favorite_pages": [], "favorite_pages": [],
"shared_pages": [], "shared_pages": [],
"published_pages": [],
"user": user, "user": user,
"avatar_url": avatar_url, "avatar_url": avatar_url,
"avatar_color": avatar_color, "avatar_color": avatar_color,
"workspace_key": "", "auth_method": auth_method,
"gitea_linked": gitea_linked,
"github_linked": github_linked,
} }
@@ -133,75 +198,14 @@ async def trash_page(request: Request, owner: str = Query(default=""), repo: str
@router.get("/library", response_class=HTMLResponse) @router.get("/library", response_class=HTMLResponse)
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): 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. """Library page — tabbed view (recents, favorites, shared, published, private, workspace).
La sidebar garde exactement la même arborescence que dans le workspace. Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
Seul le contenu principal change pour afficher les pages triées par catégorie.
""" """
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates")) env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = board_sidebar(request, owner, repo) # Sidebar intacte, inchangée sidebar = board_sidebar(request, owner, repo)
ws_key = f"{owner}/{repo}" if owner and repo else ""
with get_conn() as conn:
base_query = (
"SELECT id, title, workspace, parent_section, updated_at FROM pages "
"WHERE parent_section != 'Trash'"
)
params = []
if ws_key:
base_query += " AND workspace=?"
params.append(ws_key)
base_query += " ORDER BY updated_at DESC"
rows = conn.execute(base_query, params).fetchall()
def to_page(r):
r = dict(r)
return {
"id": f"page/{r['id']}",
"name": r["title"] or "Untitled",
"icon": "📄",
"url": f"/pages/{r['id']}",
"created_by": "You",
"source": r.get("workspace") or "🔒 Private",
"last_edited": r.get("updated_at", "now"),
"last_visited": r.get("updated_at", "now"),
"section": r.get("parent_section", "Private"),
}
all_pages = [to_page(r) for r in rows]
# Load favorites from DB for Library
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_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()
lib_favs = []
for r in fav_rows:
r = dict(r)
lib_favs.append({
"id": f"page/{r['id']}",
"name": r["title"] or "Untitled",
"icon": "📄",
"url": f"/pages/{r['id']}",
"created_by": "You",
"source": r.get("workspace") or "🔒 Private",
"last_edited": r.get("updated_at", "now"),
"last_visited": r.get("updated_at", "now"),
})
# Catégoriser par parent_section — sidebar data intacte
sidebar["lib_recent"] = all_pages
sidebar["lib_favorites"] = lib_favs
sidebar["lib_shared"] = []
sidebar["lib_private"] = [p for p in all_pages if p.get("section") == "Private"]
sidebar["lib_has_favorites"] = len(lib_favs) > 0
sidebar["lib_has_shared"] = False
template = env.get_template("library.html") template = env.get_template("library.html")
return template.render(**sidebar) return template.render(**sidebar)
@@ -367,6 +371,16 @@ async def dashboard(
except Exception: except Exception:
pass pass
if not has_gitea: if not has_gitea:
# Check if user has any workspace — if not, send to workspaces
try:
with get_conn() as conn:
ws_count = conn.execute(
"SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user_id,)
).fetchone()[0]
if ws_count == 0:
return RedirectResponse("/workspaces", status_code=302)
except Exception:
pass
return RedirectResponse("/local-workspace", status_code=302) return RedirectResponse("/local-workspace", status_code=302)
try: try:
repos = await gitea.get_user_repos(page=1, limit=50) repos = await gitea.get_user_repos(page=1, limit=50)
@@ -408,20 +422,63 @@ async def workspace_page(request: Request):
@router.get("/gitea-workspace", response_class=HTMLResponse) @router.get("/gitea-workspace", response_class=HTMLResponse)
async def gitea_workspace_page(request: Request): async def gitea_workspace_page(request: Request):
"""Gitea workspace — browse repo files.""" """Gitea workspace — browse repo files."""
import json
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates")) env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, []) sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user: if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302) return RedirectResponse("/auth/login?provider=local", status_code=302)
ctx = {**sidebar, "user": user} owner = request.query_params.get("owner", "")
repo = request.query_params.get("repo", "")
ws_key = f"{owner}/{repo}" if owner and repo else ""
ws_name = ws_key or "Gitea Workspace"
# Auto-create local workspace mirror for storing local files
local_ws_id = None
if ws_key:
with get_conn() as conn:
existing = conn.execute(
"SELECT id, owner_id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
(user["id"], ws_key, "%gitea_repo%")
).fetchone()
if existing:
local_ws_id = existing["id"]
else:
c = conn.execute(
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)",
(ws_key, user["id"], json.dumps({"gitea_repo": ws_key, "gitea_owner": owner}))
)
local_ws_id = c.lastrowid
conn.execute(
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
(local_ws_id, user["id"], "admin")
)
conn.commit()
ctx = {
**sidebar,
"user": user,
"active_ws_name": ws_name,
"workspace_key": ws_key,
"gitea_workspace": True, # always true on this page
"gitea_owner": owner,
"gitea_repo": repo,
"workspace_name": ws_name,
"workspace_initial": repo[0].upper() if repo else "G",
"owner": owner,
"repo": repo,
}
template = env.get_template("gitea_workspace.html") template = env.get_template("gitea_workspace.html")
return template.render(**ctx) resp = HTMLResponse(content=template.render(**ctx))
resp.set_cookie("flowdeck_workspace", f"gitea:{owner}:{repo}", path="/", samesite="lax")
return resp
@router.get("/api/workspace/projects") @router.get("/api/workspace/projects")
async def list_workspace_projects(request: Request): async def list_workspace_projects(request: Request):
"""List all projects: built-in + Gitea + GitHub.""" """List all projects: built-in + Gitea + GitHub.
Uses the user's own Gitea token if connected, not the global admin token."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
builtin = [] builtin = []
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -432,20 +489,23 @@ async def list_workspace_projects(request: Request):
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": count, "forge": "builtin"}) builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": count, "forge": "builtin"})
gitea_repos = [] gitea_repos = []
try: # Use per-user token if available, otherwise return empty
repos = await gitea.get_user_repos(page=1, limit=50) user_gitea = get_user_gitea_client(request) if user else None
for repo in repos: if user_gitea:
gitea_repos.append({ try:
"id": str(repo.get("id", "")), repos = await user_gitea.get_user_repos(page=1, limit=50)
"name": repo.get("name", ""), for repo in repos:
"full_name": repo.get("full_name", ""), gitea_repos.append({
"description": repo.get("description", ""), "id": str(repo.get("id", "")),
"html_url": repo.get("html_url", ""), "name": repo.get("name", ""),
"language": repo.get("language", ""), "full_name": repo.get("full_name", ""),
"forge": "gitea", "description": repo.get("description", ""),
}) "html_url": repo.get("html_url", ""),
except Exception: "language": repo.get("language", ""),
pass "forge": "gitea",
})
except Exception:
pass
return {"builtin": builtin, "gitea": gitea_repos, "github": []} return {"builtin": builtin, "gitea": gitea_repos, "github": []}
@@ -549,9 +609,13 @@ async def local_workspace_page(request: Request, folder: int = None):
if not user: if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302) return RedirectResponse("/auth/login?provider=local", status_code=302)
ws = _get_active_workspace(request) ws = _get_active_workspace(request, user_id=user["id"])
ws_id = ws["id"] if ws else None ws_id = ws["id"] if ws else None
# If no workspace exists for this user, redirect to workspaces page
if not ws_id:
return RedirectResponse("/workspaces", status_code=302)
# Build breadcrumb if navigating into a folder # Build breadcrumb if navigating into a folder
breadcrumb = [] breadcrumb = []
current_folder_id = folder current_folder_id = folder
@@ -585,7 +649,7 @@ async def local_workspace_tree(request: Request, folder: int = None):
If ?folder=ID is provided, returns only that folder's children. If ?folder=ID is provided, returns only that folder's children.
Otherwise returns the full recursive tree from root. Otherwise returns the full recursive tree from root.
""" """
ws = _get_active_workspace(request) ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None ws_id = ws["id"] if ws else None
if not ws_id: if not ws_id:
return {"tree": [], "breadcrumb": []} return {"tree": [], "breadcrumb": []}
@@ -744,14 +808,16 @@ async def create_local_workspace_item(request: Request):
name = body.get("name", "Untitled").strip() name = body.get("name", "Untitled").strip()
item_type = body.get("type", "page") item_type = body.get("type", "page")
parent_id = body.get("parent_id") parent_id = body.get("parent_id")
ws = _get_active_workspace(request) explicit_ws_id = body.get("workspace_id")
ws_id = ws["id"] if ws else None ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = explicit_ws_id or (ws["id"] if ws else None)
ws_key = (ws["name"] if ws else "Workspace") if not explicit_ws_id else ""
section = 'Workspace' if item_type == 'folder' else 'Private' section = 'Workspace' if item_type == 'folder' else 'Private'
with get_conn() as conn: with get_conn() as conn:
cursor = conn.execute( cursor = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) " "INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) "
"VALUES ('', ?, ?, '', 'blocks', ?, ?)", "VALUES (?, ?, ?, '', 'blocks', ?, ?)",
(ws_id, name, section, parent_id), (ws_key, ws_id, name, section, parent_id)
) )
conn.commit() conn.commit()
pid = cursor.lastrowid pid = cursor.lastrowid
@@ -772,21 +838,25 @@ async def rename_local_workspace_item(request: Request, item_id: int):
@router.delete("/api/local-workspace/items/{item_id:int}") @router.delete("/api/local-workspace/items/{item_id:int}")
async def delete_local_workspace_item(request: Request, item_id: int): async def delete_local_workspace_item(request: Request, item_id: int):
"""Delete a file (and its children). Also removes uploaded file from disk.""" """Soft-delete a file/folder (sets deleted_at)."""
import json from datetime import datetime
from pathlib import Path
with get_conn() as conn: with get_conn() as conn:
# Check if it's a file-type page with a disk file conn.execute(
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?", (item_id,)).fetchone() "UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
if row and row["content_format"] == "file": (datetime.utcnow().isoformat(), item_id),
try: )
meta = json.loads(row["content"]) conn.commit()
fp = Path(f"/data/{meta['file_path']}") return {"status": "ok"}
if fp.exists():
fp.unlink()
except Exception: @router.post("/api/local-workspace/items/{item_id:int}/restore")
pass async def restore_local_workspace_item(request: Request, item_id: int):
conn.execute("DELETE FROM pages WHERE id=? OR parent_id=?", (item_id, item_id)) """Restore a soft-deleted file/folder."""
with get_conn() as conn:
conn.execute(
"UPDATE pages SET deleted_at=NULL WHERE id=?",
(item_id,),
)
conn.commit() conn.commit()
return {"status": "ok"} return {"status": "ok"}
@@ -833,7 +903,7 @@ async def upload_local_workspace_file(request: Request):
import json import json
from pathlib import Path from pathlib import Path
ws = _get_active_workspace(request) ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None ws_id = ws["id"] if ws else None
if not ws_id: if not ws_id:
return JSONResponse({"error": "No active workspace"}, status_code=400) return JSONResponse({"error": "No active workspace"}, status_code=400)
@@ -906,7 +976,7 @@ async def upload_local_workspace_folder(request: Request):
import json import json
from pathlib import Path from pathlib import Path
ws = _get_active_workspace(request) ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None ws_id = ws["id"] if ws else None
if not ws_id: if not ws_id:
return JSONResponse({"error": "No active workspace"}, status_code=400) return JSONResponse({"error": "No active workspace"}, status_code=400)
@@ -1009,22 +1079,30 @@ async def upload_local_workspace_folder(request: Request):
WORKSPACE_COOKIE = "flowdeck_workspace" WORKSPACE_COOKIE = "flowdeck_workspace"
def _get_active_workspace(request: Request) -> dict | None: def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
"""Get the active workspace ID from the cookie, or first workspace, or None.""" """Get the active workspace ID from the cookie (verified for current user), or first user workspace, or None."""
ws_id = request.cookies.get(WORKSPACE_COOKIE) ws_id = request.cookies.get(WORKSPACE_COOKIE)
if ws_id: if ws_id:
try: try:
with get_conn() as conn: with get_conn() as conn:
ws = conn.execute("SELECT * FROM workspaces WHERE id=?", (int(ws_id),)).fetchone() ws = conn.execute("SELECT * FROM workspaces WHERE id=?",
(int(ws_id),)).fetchone()
if ws: if ws:
return dict(ws) ws_dict = dict(ws)
# Verify ownership — only return if it belongs to the current user
if user_id is None or ws_dict.get("owner_id") == user_id:
return ws_dict
except (ValueError, Exception): except (ValueError, Exception):
pass pass
# Fallback: first workspace # Fallback: first workspace owned by this user
with get_conn() as conn: if user_id:
ws = conn.execute("SELECT * FROM workspaces ORDER BY id LIMIT 1").fetchone() with get_conn() as conn:
if ws: ws = conn.execute(
return dict(ws) "SELECT * FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1",
(user_id,)
).fetchone()
if ws:
return dict(ws)
return None return None
@@ -1033,7 +1111,7 @@ async def workspaces_page(request: Request):
"""Workspaces list page.""" """Workspaces list page."""
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates")) env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, []) sidebar = _sidebar_data(request, [], include_workspace=False)
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user: if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302) return RedirectResponse("/auth/login?provider=local", status_code=302)
@@ -1057,7 +1135,7 @@ async def list_workspaces(request: Request):
for r in rows: for r in rows:
d = dict(r) d = dict(r)
workspaces.append(d) workspaces.append(d)
active = _get_active_workspace(request) active = _get_active_workspace(request, user_id=_get_user_id(request))
return {"workspaces": workspaces, "active_id": active["id"] if active else None} return {"workspaces": workspaces, "active_id": active["id"] if active else None}
@@ -1276,7 +1354,7 @@ async def list_all_tags_global(request: Request):
@router.get("/api/local-workspace/tags") @router.get("/api/local-workspace/tags")
async def list_tags(request: Request): async def list_tags(request: Request):
"""List tags used in the active workspace (current user's tags only).""" """List tags used in the active workspace (current user's tags only)."""
ws = _get_active_workspace(request) ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None ws_id = ws["id"] if ws else None
uid = _get_user_id(request) uid = _get_user_id(request)
if not ws_id: if not ws_id:
@@ -1358,7 +1436,7 @@ async def remove_item_tag(item_id: int, tag_id: int):
@router.get("/api/local-workspace/tags/search") @router.get("/api/local-workspace/tags/search")
async def search_by_tags(request: Request, tags: str = ""): async def search_by_tags(request: Request, tags: str = ""):
"""Search items by tags (comma-separated).""" """Search items by tags (comma-separated)."""
ws = _get_active_workspace(request) ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None ws_id = ws["id"] if ws else None
if not ws_id: if not ws_id:
return {"items": []} return {"items": []}
@@ -1434,3 +1512,64 @@ async def update_account(request: Request):
response = JSONResponse({"status": "ok", "user": {k: user_data[k] for k in ("id","login","full_name","email","is_admin")}}) response = JSONResponse({"status": "ok", "user": {k: user_data[k] for k in ("id","login","full_name","email","is_admin")}})
response.set_cookie("flowdeck_session", new_session, httponly=True, max_age=86400 * 7, samesite="lax", path="/") response.set_cookie("flowdeck_session", new_session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
return response return response
# ═══════════ Sidebar Refresh API ═══════════
@router.get("/api/sidebar/workspace-tree")
async def sidebar_workspace_tree(request: Request):
"""Return the sidebar workspace tree as HTML fragment.
Called by appState().refreshSidebarTree() after CRUD operations
in the main content area to keep the sidebar in sync.
"""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _load_workspace_pages
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return HTMLResponse("")
ws_cookie = request.cookies.get("flowdeck_workspace", "")
if not ws_cookie:
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>')
# Gitea workspace — no server-side tree, loaded client-side
if ws_cookie.startswith("gitea:"):
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Remote workspace</span></li>')
try:
ws_id = int(ws_cookie)
with get_conn() as conn:
# Verify workspace belongs to user
row = conn.execute(
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
(ws_id, user["id"])
).fetchone()
if not row:
return HTMLResponse(
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
'<span class="page-name text-dim">No pages yet</span></li>'
)
pages = _load_workspace_pages(ws_cookie)
if not pages:
return HTMLResponse(
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
'<span class="page-name text-dim">No pages yet</span></li>'
)
# Render the tree using the extracted macro
env = Environment(loader=FileSystemLoader("app/templates"))
template = env.from_string(
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
"{{ render_workspace_tree(pages) }}"
)
html = template.render(pages=pages)
return HTMLResponse(html)
except (ValueError, Exception) as e:
logger.error(f"sidebar_workspace_tree failed: {e}", exc_info=True)
return HTMLResponse(
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
'<span class="page-name text-dim">No pages yet</span></li>'
)
+6 -1
View File
@@ -6,7 +6,12 @@ router = APIRouter(tags=["gitea"], prefix="/api/gitea")
def _require_gitea(request: Request): def _require_gitea(request: Request):
"""Return a per-user GiteaClient or raise 401 if not connected.""" """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 from app.services.gitea_client import get_user_gitea_client
client = get_user_gitea_client(request) client = get_user_gitea_client(request)
if not client: if not client:
+35
View File
@@ -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"}
+249
View File
@@ -0,0 +1,249 @@
"""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:
"""Detect source type from workspace field.
- '' or plain name (no slash) → local
- 'owner/repo' format → gitea
- 'github:owner/repo' format → github
"""
ws = (workspace or "").strip()
if not ws:
return "local"
if ws.startswith("github:"):
return "github"
if "/" in ws:
return "gitea"
return "local"
def _source_badge(source_type: str) -> str:
return {"local": "Local", "gitea": "Gitea", "github": "GitHub"}.get(source_type, "Local")
def _build_item(db_row: dict) -> dict:
"""Convert a DB row into a library item dict."""
workspace = db_row.get("workspace", "") or ""
source_type = _detect_source_type(workspace)
# Extract workspace display name
if source_type == "gitea":
workspace_name = workspace # "owner/repo"
elif source_type == "github":
workspace_name = workspace.replace("github:", "", 1)
else:
workspace_name = workspace or "Private"
# URL construction
page_id = db_row["id"]
if source_type == "gitea":
parts = workspace.split("/", 1)
owner = parts[0] if len(parts) > 0 else ""
repo = parts[1] if len(parts) > 1 else ""
url = f"/gitea-workspace?owner={owner}&repo={repo}"
elif source_type == "github":
gh_ws = workspace.replace("github:", "", 1)
parts = gh_ws.split("/", 1)
owner = parts[0] if len(parts) > 0 else ""
repo = parts[1] if len(parts) > 1 else ""
url = f"/gitea-workspace?owner={owner}&repo={repo}"
else:
url = f"/pages/{page_id}"
return {
"id": page_id,
"title": db_row.get("title") or "Untitled",
"icon": "📄",
"source_type": source_type,
"source_badge": _source_badge(source_type),
"workspace_name": workspace_name,
"updated_at": db_row.get("updated_at", ""),
"url": url,
}
def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[str, list]:
"""Add WHERE clause for source_type filter. Mutates params in place."""
if source_type == "local":
query += " AND (workspace = '' OR workspace NOT LIKE '%/%' AND workspace NOT LIKE 'github:%')"
elif source_type == "gitea":
query += " AND workspace LIKE '%/%' AND workspace NOT LIKE 'github:%'"
elif source_type == "github":
query += " AND workspace LIKE 'github:%'"
# 'all' → no filter
return query, params
# ── Tab endpoints ──
@router.get("/recents")
async def library_recents(
request: Request,
source_type: str = Query(default="all"),
):
"""Recent pages ordered by updated_at DESC."""
if source_type not in SOURCE_TYPES:
source_type = "all"
uid = _get_user_id(request)
query = (
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE parent_section != 'Trash' AND deleted_at IS NULL"
)
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = [_build_item(dict(r)) for r in rows]
return {"items": items}
@router.get("/favorites")
async def library_favorites(
request: Request,
source_type: str = Query(default="all"),
):
"""Favorite pages for the current user."""
if source_type not in SOURCE_TYPES:
source_type = "all"
uid = _get_user_id(request)
query = (
"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 = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
)
params: list = [uid]
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 = [_build_item(dict(r)) for r in rows]
return {"items": items}
@router.get("/shared")
async def library_shared(
request: Request,
source_type: str = Query(default="all"),
):
"""Pages shared with others (share_mode != 'private')."""
if source_type not in SOURCE_TYPES:
source_type = "all"
query = (
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE share_mode != 'private' AND parent_section != 'Trash' AND deleted_at IS NULL"
)
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = [_build_item(dict(r)) for r in rows]
return {"items": items}
@router.get("/published")
async def library_published(
request: Request,
source_type: str = Query(default="all"),
):
"""Published pages (published = 1)."""
if source_type not in SOURCE_TYPES:
source_type = "all"
query = (
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE published = 1 AND parent_section != 'Trash' AND deleted_at IS NULL"
)
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = [_build_item(dict(r)) for r in rows]
return {"items": items}
@router.get("/private")
async def library_private(
request: Request,
source_type: str = Query(default="all"),
):
"""Private pages (parent_section = 'Private')."""
if source_type not in SOURCE_TYPES:
source_type = "all"
query = (
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE parent_section = 'Private' AND deleted_at IS NULL"
)
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = [_build_item(dict(r)) for r in rows]
return {"items": items}
@router.get("/workspace")
async def library_workspace(
request: Request,
source_type: str = Query(default="all"),
):
"""Pages from workspaces (non-empty workspace field)."""
if source_type not in SOURCE_TYPES:
source_type = "all"
query = (
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE workspace != '' AND parent_section != 'Trash' AND deleted_at IS NULL"
)
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = [_build_item(dict(r)) for r in rows]
return {"items": items}
+245
View File
@@ -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(),
}
+1 -1
View File
@@ -420,7 +420,7 @@ def get_user_gitea_client(request):
from app.db import get_conn from app.db import get_conn
with get_conn() as conn: with get_conn() as conn:
row = conn.execute( row = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? ORDER BY updated_at DESC LIMIT 1", "SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea' ORDER BY updated_at DESC LIMIT 1",
(user["id"],), (user["id"],),
).fetchone() ).fetchone()
if not row: if not row:
+47
View File
@@ -0,0 +1,47 @@
{# ── Unified Header (Notion-inspired) ──
Parameters available in calling context:
breadcrumb_items: list of {label: str, url: str|None} — last item = current page
page_icon: str — emoji icon
right_actions: str — raw HTML for right-side action buttons
hamburger_click: str — Alpine expression for hamburger button (default: opens sidebar)
hide_hamburger: bool — if truthy, hides the hamburger button
#}
<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 %}
<div class="topbar-left header-breadcrumb">
{% if page_icon %}
<span class="header-page-icon">{{ page_icon }}</span>
{% endif %}
{% if breadcrumb_items %}
{% for item in breadcrumb_items %}
{% if item.url and not loop.last %}
<a href="{{ item.url }}" class="breadcrumb-link">{{ item.label }}</a>
<span class="breadcrumb-sep">/</span>
{% elif loop.last %}
<span class="breadcrumb-current">{{ item.label }}</span>
{% else %}
<span class="breadcrumb-link disabled">{{ item.label }}</span>
<span class="breadcrumb-sep">/</span>
{% endif %}
{% endfor %}
{% else %}
<span class="breadcrumb-current">{{ page_title|default('FlowDeck') }}</span>
{% endif %}
</div>
<div class="topbar-right header-actions">
{{ right_actions|safe if right_actions else '' }}
</div>
</header>
+46
View File
@@ -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
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_icon %}👤{% endblock %} {% block page_icon %}👤{% endblock %}
{% block page_title %}Accounts{% endblock %} {% block page_title %}Accounts{% endblock %}
{% block title_prefix %}Accounts{% endblock %}
{% block content %} {% block content %}
<div class="page-title-area"> <div class="page-title-area">
+225 -103
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowDeck</title> <title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/design-tokens.css?v=1.0"> <link rel="stylesheet" href="/static/css/design-tokens.css?v=1.0">
<link rel="stylesheet" href="/static/css/app.css?v=2.4.6"> <link rel="stylesheet" href="/static/css/app.css?v=2.4.6">
@@ -71,6 +71,11 @@
<div class="workspace-info"> <div class="workspace-info">
<div class="workspace-avatar" style="{% if avatar_url %}background-image:url('{{ avatar_url }}');background-size:cover;{% else %}background:{{ avatar_color }};{% endif %}">{% if not avatar_url %}{{ user.full_name[:1].upper() if user and user.get('full_name') else workspaceInitial }}{% endif %}</div> <div class="workspace-avatar" style="{% if avatar_url %}background-image:url('{{ avatar_url }}');background-size:cover;{% else %}background:{{ avatar_color }};{% endif %}">{% if not avatar_url %}{{ user.full_name[:1].upper() if user and user.get('full_name') else workspaceInitial }}{% endif %}</div>
<span class="workspace-name">{{ user.full_name or user.login or workspaceName }}</span> <span class="workspace-name">{{ user.full_name or user.login or workspaceName }}</span>
{% if auth_method != 'local' %}
<span class="oauth-badge" title="Connected via {{ auth_method|title }}">
{% if auth_method == 'gitea' %}🦎 Gitea{% elif auth_method == 'github' %}🐙 GitHub{% else %}🔑 {{ auth_method|title }}{% endif %}
</span>
{% endif %}
</div> </div>
<span class="workspace-chevron" :class="{ open: workspaceMenuOpen }">▾</span> <span class="workspace-chevron" :class="{ open: workspaceMenuOpen }">▾</span>
<button class="sidebar-collapse-inline" @click.stop="sidebarCollapsed = !sidebarCollapsed" title="Collapse sidebar"> <button class="sidebar-collapse-inline" @click.stop="sidebarCollapsed = !sidebarCollapsed" title="Collapse sidebar">
@@ -111,62 +116,19 @@
</div> </div>
<div class="sidebar-scroll"> <div class="sidebar-scroll">
<!-- Workspace --> <!-- Workspace / Private -->
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('workspace')"> <div class="sidebar-section-header" @click="toggleSection('workspace')">
<div class="sidebar-section-title"> <div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.workspace }" x-text="sectionsOpen.workspace ? '▼' : '▶'"></span> <span class="chevron" :class="{ open: sectionsOpen.workspace }" x-text="sectionsOpen.workspace ? '▼' : '▶'"></span>
<span>📁 {{ active_ws_name or 'Workspace' }}</span> <span>{% if gitea_workspace %}📁 {{ active_ws_name }}{% elif workspace_key and '/' in workspace_key %}🔗{% else %}📁{% endif %} {{ active_ws_name if not gitea_workspace else '' }}</span>
</div> </div>
<div class="sidebar-section-actions"> <div class="sidebar-section-actions">
<button class="section-action-btn" title="New Page" @click.stop="newPageInWorkspace()">📄</button> <button class="section-action-btn" title="New Page" @click.stop="newPageInWorkspace()">📄</button>
<button class="section-action-btn" title="New Folder" @click.stop="newFolderInWorkspace()">📁</button> <button class="section-action-btn" title="New Folder" @click.stop="newFolderInWorkspace()">📁</button>
</div> </div>
</div> </div>
{% macro render_workspace_tree(pages, depth=0) %} {% from '_workspace_tree_macro.html' import render_workspace_tree %}
{% for page in pages %}
<li class="sidebar-item ws-tree-item"
data-id="{{ page.db_id }}"
data-is-folder="{{ 'true' if page.is_folder else 'false' }}"
data-depth="{{ depth }}"
:class="{ active: activeNodeId === {{ page.db_id }} }"
draggable="true"
@dragstart="dragStart($event, {{ page.db_id }})"
@dragover.prevent="dragOver($event, {{ page.db_id }}, {{ 'true' if page.is_folder else 'false' }})"
@dragleave="dragLeave($event)"
@drop.prevent="drop($event, {{ page.db_id }})"
style="padding-left:{{ 4 + depth * 12 }}px;"
@contextmenu.prevent="showWsContext($event, {{ page.db_id }}, '{{ page.name | e }}', {{ 'true' if page.is_folder else 'false' }})"
@touchstart="wsTouchStart($event)"
@touchend="wsTouchEnd($event, {{ page.db_id }}, '{{ page.name | e }}', {{ 'true' if page.is_folder else 'false' }})"
@touchmove="wsTouchMove($event)">
{% if page.is_folder %}
<button class="tree-chevron"
@click.stop="toggleTreeFolder({{ page.db_id }})"
:class="{ open: expandedFolders[{{ page.db_id }}] }"
title="Toggle folder">▶</button>
{% else %}
<span style="width:12px;flex-shrink:0;"></span>
{% endif %}
<span class="page-icon">{{ page.icon }}</span>
{% if page.is_folder %}
<span class="page-name tree-folder-link"
@click.stop="navigateToFolder({{ page.db_id }})"
draggable="false"
title="Open folder">{{ page.name }}{% if page.child_count %} <span class="child-count">({{ page.child_count }})</span>{% endif %}</span>
{% else %}
<a href="/pages/{{ page.db_id }}" class="page-name" draggable="false">{{ page.name }}</a>
{% endif %}
</li>
{% if page.children %}
<li x-show="expandedFolders[{{ page.db_id }}]" x-transition style="list-style:none;margin:0;padding:0;">
<ul style="list-style:none;margin:0;padding:0;">
{{ render_workspace_tree(page.children, depth + 1) }}
</ul>
</li>
{% endif %}
{% endfor %}
{% endmacro %}
<div class="sidebar-section-items" x-show="sectionsOpen.workspace" x-transition> <div class="sidebar-section-items" x-show="sectionsOpen.workspace" x-transition>
<ul class="sidebar-items" data-section="workspace" id="sidebar-workspace-items"> <ul class="sidebar-items" data-section="workspace" id="sidebar-workspace-items">
{% if workspace_pages %} {% if workspace_pages %}
@@ -181,6 +143,26 @@
</div> </div>
</div> </div>
<!-- Repository (Gitea) — only visible for Gitea workspaces -->
{% if gitea_workspace %}
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('gitea')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.gitea }" x-text="sectionsOpen.gitea ? '▼' : '▶'"></span>
<span>🔗 Repository (Gitea)</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Refresh" @click.stop="if(window._gwData)window._gwData.refreshTree()">🔄</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.gitea" x-transition>
<ul class="sidebar-items" data-section="gitea" id="sidebar-gitea-items">
<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Loading...</span></li>
</ul>
</div>
</div>
{% endif %}
<!-- Meetings --> <!-- Meetings -->
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('meetings')"> <div class="sidebar-section-header" @click="toggleSection('meetings')">
@@ -204,6 +186,9 @@
<span class="chevron" :class="{ open: sectionsOpen.recents }" x-text="sectionsOpen.recents ? '▼' : '▶'"></span> <span class="chevron" :class="{ open: sectionsOpen.recents }" x-text="sectionsOpen.recents ? '▼' : '▶'"></span>
<span>Recents</span> <span>Recents</span>
</div> </div>
<div class="sidebar-section-actions">
<a href="/library?tab=Recents" class="library-link-btn" title="View in Library" @click.stop>→ Library</a>
</div>
</div> </div>
<div class="sidebar-section-items" x-show="sectionsOpen.recents" x-transition> <div class="sidebar-section-items" x-show="sectionsOpen.recents" x-transition>
<ul class="sidebar-items" data-section="recents"> <ul class="sidebar-items" data-section="recents">
@@ -225,7 +210,7 @@
<span>Favorites</span> <span>Favorites</span>
</div> </div>
<div class="sidebar-section-actions"> <div class="sidebar-section-actions">
<button class="section-action-btn" title="Add" @click.stop="newPage('Favorites')">+</button> <a href="/library?tab=Favorites" class="library-link-btn" title="View in Library" @click.stop>→ Library</a>
</div> </div>
</div> </div>
<div class="sidebar-section-items" x-show="sectionsOpen.favorites" x-transition> <div class="sidebar-section-items" x-show="sectionsOpen.favorites" x-transition>
@@ -258,7 +243,7 @@
<span>Shared</span> <span>Shared</span>
</div> </div>
<div class="sidebar-section-actions"> <div class="sidebar-section-actions">
<button class="section-action-btn" title="Add" @click.stop="newPage('Shared')">+</button> <a href="/library?tab=Shared" class="library-link-btn" title="View in Library" @click.stop>→ Library</a>
</div> </div>
</div> </div>
<div class="sidebar-section-items" x-show="sectionsOpen.shared" x-transition> <div class="sidebar-section-items" x-show="sectionsOpen.shared" x-transition>
@@ -273,7 +258,31 @@
</div> </div>
</div> </div>
<!-- Published -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('published')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.published }" x-text="sectionsOpen.published ? '▼' : '▶'"></span>
<span>Published</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Published" class="library-link-btn" title="View in Library" @click.stop>→ Library</a>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.published" x-transition>
<ul class="sidebar-items" data-section="published">
{% for page in published_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not published_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon">🌐</span><span class="page-name text-dim">No published pages</span></li>
{% endif %}
</ul>
</div>
</div>
<!-- Private --> <!-- Private -->
{% if auth_method != 'local' or '/' in workspace_key %}
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('private')"> <div class="sidebar-section-header" @click="toggleSection('private')">
<div class="sidebar-section-title"> <div class="sidebar-section-title">
@@ -281,9 +290,7 @@
<span>Private</span> <span>Private</span>
</div> </div>
<div class="sidebar-section-actions"> <div class="sidebar-section-actions">
<button class="section-action-btn" title="View" @click.stop="">⊞</button> <a href="/library?tab=Private" class="library-link-btn" title="View in Library" @click.stop>→ Library</a>
<button class="section-action-btn" title="More" @click.stop="">⋯</button>
<button class="section-action-btn" title="Add page" @click.stop="newPage('Private')">+</button>
</div> </div>
</div> </div>
<div class="sidebar-section-items" x-show="sectionsOpen.private" x-transition> <div class="sidebar-section-items" x-show="sectionsOpen.private" x-transition>
@@ -297,6 +304,7 @@
</ul> </ul>
</div> </div>
</div> </div>
{% endif %}
<!-- Notion apps / Utilities --> <!-- Notion apps / Utilities -->
<div class="sidebar-section"> <div class="sidebar-section">
@@ -310,7 +318,9 @@
<ul class="sidebar-items"> <ul class="sidebar-items">
<li class="sidebar-item" @click="navigateTo('/library')"><span class="page-icon">📚</span><span class="page-name">Library</span></li> <li class="sidebar-item" @click="navigateTo('/library')"><span class="page-icon">📚</span><span class="page-name">Library</span></li>
<li class="sidebar-item"><span class="page-icon">✅</span><span class="page-name">My Tasks</span></li> <li class="sidebar-item"><span class="page-icon">✅</span><span class="page-name">My Tasks</span></li>
{% if auth_method == 'local' or (workspace_pages and workspace_pages|length > 0) %}
<li class="sidebar-item" @click="navigateTo('/trash')"><span class="page-icon">🗑️</span><span class="page-name">Trash</span></li> <li class="sidebar-item" @click="navigateTo('/trash')"><span class="page-icon">🗑️</span><span class="page-name">Trash</span></li>
{% endif %}
<li class="sidebar-item"><span class="page-icon">❓</span><span class="page-name">Help</span></li> <li class="sidebar-item"><span class="page-icon">❓</span><span class="page-name">Help</span></li>
</ul> </ul>
</div> </div>
@@ -339,47 +349,7 @@
<!-- Topbar --> <!-- Topbar -->
{% block topbar %} {% block topbar %}
<header class="topbar"> {% include '_header.html' %}
<!-- Hamburger / uncollapse sidebar -->
<button class="hamburger-btn" @click="sidebarCollapsed ? (sidebarCollapsed = false) : (mobileSidebarOpen = true, sidebarCollapsed = false)" title="Menu">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="topbar-left">
<div class="topbar-breadcrumb">
<span x-text="workspaceName"></span>
<span class="text-dim">/</span>
<span class="text-dim" x-text="currentPage"></span>
</div>
</div>
<div class="topbar-center">
<div class="topbar-page-title">
<span class="page-icon" x-text="pageIcon"></span>
<span x-text="pageTitle"></span>
</div>
<div class="topbar-page-status">
<span>🔒</span> Private
<span style="cursor:pointer;">▾</span>
</div>
</div>
<div class="topbar-right">
<span class="text-xs text-dim">Edited <span x-text="lastEdited"></span></span>
<button class="topbar-btn">🔔</button>
<button class="topbar-btn">🕒</button>
<button class="topbar-btn">🔍</button>
<button class="topbar-btn share-btn">🔗 Share ▾</button>
<button class="topbar-btn">⭐</button>
<button class="topbar-btn" @click="showNewPageMenu = !showNewPageMenu" style="position:relative;">
New ▾
<div class="dropdown-panel" :class="{ visible: showNewPageMenu }" style="top:100%; right:0; left:auto; min-width:200px;" @click.stop>
<div class="dropdown-option" @click="createEmptyPage()"><span class="option-icon">📄</span> Empty page</div>
<div class="dropdown-option" @click="createDatabase()"><span class="option-icon">📊</span> Database</div>
<div class="dropdown-option" @click="createBoard()"><span class="option-icon">📋</span> Kanban board</div>
</div>
</button>
</div>
</header>
{% endblock %} {% endblock %}
<!-- Content --> <!-- Content -->
@@ -521,6 +491,9 @@
function appState() { function appState() {
return { return {
init() {
window.appState = this;
},
sidebarCollapsed: false, sidebarCollapsed: false,
mobileSidebarOpen: false, mobileSidebarOpen: false,
showInvite: true, showInvite: true,
@@ -543,6 +516,11 @@
pageTitle: '{% block page_title %}FlowDeck{% endblock %}', pageTitle: '{% block page_title %}FlowDeck{% endblock %}',
lastEdited: '{{ last_edited }}', lastEdited: '{{ last_edited }}',
workspaceKey: '{{ workspace_key }}', workspaceKey: '{{ workspace_key }}',
localWsId: {{ local_ws_id|default(0) }},
giteaWorkspace: {{ 'true' if gitea_workspace else 'false' }},
giteaOwner: '{{ gitea_owner|default("") }}',
giteaRepo: '{{ gitea_repo|default("") }}',
giteaTree: [], // loaded client-side for sidebar
currentView: 'home', currentView: 'home',
contextMenu: { visible: false, x: 0, y: 0, pageId: null, pageName: '' }, contextMenu: { visible: false, x: 0, y: 0, pageId: null, pageName: '' },
contextActions: {}, contextActions: {},
@@ -598,17 +576,26 @@
case 'rename': case 'rename':
var newName = prompt('Rename:', name); var newName = prompt('Rename:', name);
if (newName && newName.trim()) { if (newName && newName.trim()) {
var self = this;
fetch('/api/local-workspace/items/' + id, { fetch('/api/local-workspace/items/' + id, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() }, headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ name: newName.trim() }) body: JSON.stringify({ name: newName.trim() })
}).then(function(r) { if (r.ok) window.location.reload(); }); }).then(function(r) {
if (r.ok) {
self.refreshSidebarTree();
document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed'));
if (window._wsData && window._wsData._reloadAfterAction) {
window._wsData._reloadAfterAction();
}
}
});
} }
break; break;
case 'delete': case 'delete':
if (!id) break; if (!id) break;
if (!confirm('Delete "' + name + '"?')) break; if (!confirm('Delete "' + name + '"?')) break;
this.deleteWorkspacePage(id); this.deleteWorkspacePage(id, true);
break; break;
} }
}, },
@@ -618,7 +605,7 @@
// ── Sections collapsible ── // ── Sections collapsible ──
sectionsOpen: (function() { sectionsOpen: (function() {
var def = { recents: true, private: true, meetings: true, favorites: true, agents: true, shared: true, apps: true, workspace: true }; var def = { recents: true, private: true, meetings: true, favorites: true, agents: true, shared: true, published: true, apps: true, workspace: true, gitea: true };
try { try {
var saved = JSON.parse(localStorage.getItem('fd_sections') || '{}'); var saved = JSON.parse(localStorage.getItem('fd_sections') || '{}');
return Object.assign(def, saved); return Object.assign(def, saved);
@@ -838,9 +825,68 @@
.then(data => { window.location.href = `/pages/${data.id}`; }) .then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { this.toast('Failed to create sub-page: ' + err.message, 'error'); }); .catch(err => { this.toast('Failed to create sub-page: ' + err.message, 'error'); });
}, },
loadGiteaTree() {
if (!this.giteaWorkspace || !this.giteaOwner || !this.giteaRepo) {
console.log('loadGiteaTree: skipped (giteaWorkspace=' + this.giteaWorkspace + ', owner=' + this.giteaOwner + ', repo=' + this.giteaRepo + ')');
return;
}
// Skip if giteaWorkspace component already loaded the tree (has data-gitea-path items)
var container = document.getElementById('sidebar-workspace-items');
if (container && container.querySelector('[data-gitea-path]')) {
console.log('loadGiteaTree: skipped (tree already loaded by giteaWorkspace component)');
return;
}
console.log('loadGiteaTree: fetching /api/gitea/projects/' + this.giteaOwner + '/' + this.giteaRepo + '/tree');
fetch('/api/gitea/projects/' + this.giteaOwner + '/' + this.giteaRepo + '/tree')
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status + ': ' + r.statusText);
return r.json();
})
.then(function(data) {
this.giteaTree = data.tree || [];
if (!container) {
console.log('loadGiteaTree: #sidebar-workspace-items not found');
return;
}
container.innerHTML = '';
if (!this.giteaTree.length) {
container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No files yet</span></li>';
return;
}
this.giteaTree.forEach(function(item) {
var li = document.createElement('li');
li.className = 'sidebar-item';
var icon = item.type === 'dir' ? '📁' : '📄';
li.innerHTML = '<span class="page-icon">' + icon + '</span><span class="page-name">' + item.name + '</span>';
container.appendChild(li);
});
console.log('loadGiteaTree: rendered ' + this.giteaTree.length + ' items');
}.bind(this))
.catch(function(err) {
console.error('loadGiteaTree failed:', err.message || err);
if (container) {
container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">⚠️</span><span class="page-name text-dim">Could not load tree: ' + (err.message || 'Unknown error') + '</span></li>';
}
});
},
newPageInWorkspace() { newPageInWorkspace() {
const name = prompt('File name:'); const name = prompt('File name:');
if (!name || !name.trim()) return; if (!name || !name.trim()) return;
const wk = this.workspaceKey || '';
// Gitea/remote workspace: create local file in mirror workspace, keep remote focus
if (wk.includes('/')) {
const wsId = this.localWsId || 0;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ name: name.trim(), type: 'page', workspace_id: wsId })
})
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
return;
}
// Local workspace
fetch('/api/local-workspace/items', { fetch('/api/local-workspace/items', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() }, headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
@@ -853,26 +899,55 @@
newFolderInWorkspace() { newFolderInWorkspace() {
const name = prompt('Folder name:'); const name = prompt('Folder name:');
if (!name || !name.trim()) return; if (!name || !name.trim()) return;
const wk = this.workspaceKey || '';
// Gitea workspace: create folder in local mirror workspace
if (wk.includes('/')) {
const wsId = this.localWsId || 0;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ name: name.trim(), type: 'folder', workspace_id: wsId })
})
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(data => { this.toast('Folder created in local workspace', 'success'); })
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
return;
}
// Local workspace
fetch('/api/local-workspace/items', { fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() }, headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ name: name.trim(), type: 'folder' }) body: JSON.stringify({ name: name.trim(), type: 'folder' })
}) })
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); }) .then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(() => { window.location.reload(); }) .then(() => { this.refreshSidebarTree(); document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed')); if (window._wsData && window._wsData._reloadAfterAction) { window._wsData._reloadAfterAction(); } })
.catch(err => { this.toast('Failed: ' + err.message, 'error'); }); .catch(err => { this.toast('Failed: ' + err.message, 'error'); });
}, },
deleteWorkspacePage(pageId) { deleteWorkspacePage(pageId, skipConfirm) {
if (!confirm('Delete this page?')) return; if (!skipConfirm && !confirm('Delete this page?')) return;
fetch(`/api/local-workspace/items/${pageId}`, { fetch(`/api/local-workspace/items/${pageId}`, {
method: 'DELETE', method: 'DELETE',
headers: { 'X-CSRF-Token': this.getCsrfToken() } headers: { 'X-CSRF-Token': this.getCsrfToken() }
}) })
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); }) .then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(() => { window.location.reload(); }) .then(() => { this.refreshSidebarTree(); document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed')); if (window._wsData && window._wsData._reloadAfterAction) { window._wsData._reloadAfterAction(); } })
.catch(err => { this.toast('Failed: ' + err.message, 'error'); }); .catch(err => { this.toast('Failed: ' + err.message, 'error'); });
}, },
refreshSidebarTree() {
// Reload the sidebar workspace tree after CRUD in the main content area
fetch('/api/sidebar/workspace-tree')
.then(r => r.text())
.then(html => {
const ul = document.getElementById('sidebar-workspace-items');
if (!ul) return;
// Preserve the "Manage Workspaces" link (last <li>)
const manageLink = ul.querySelector('li:last-child');
ul.innerHTML = html;
if (manageLink) ul.appendChild(manageLink);
})
.catch(e => console.error('Sidebar refresh failed:', e));
},
// ── Drag & Drop ── // ── Drag & Drop ──
dragOverId: null, dragOverId: null,
dragStart(ev, id) { dragStart(ev, id) {
@@ -898,7 +973,15 @@
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() }, headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ parent_id: parentId }) body: JSON.stringify({ parent_id: parentId })
}) })
.then(r => { if (!r.ok) throw new Error('Failed'); window.location.reload(); }) .then(r => { if (!r.ok) throw new Error('Failed'); })
.then(() => {
this.refreshSidebarTree();
document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed'));
// Also directly notify main content if on local-workspace page
if (window._wsData && window._wsData._reloadAfterAction) {
window._wsData._reloadAfterAction();
}
})
.catch(err => { this.toast('Move failed: ' + err.message, 'error'); }); .catch(err => { this.toast('Move failed: ' + err.message, 'error'); });
}, },
@@ -1019,6 +1102,14 @@
setTimeout(function() { setTimeout(function() {
var active = document.querySelector('.sidebar-item.active'); var active = document.querySelector('.sidebar-item.active');
if (active) active.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); if (active) active.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
// Load Gitea tree in sidebar if workspace is remote
var root = document.querySelector('[x-data]');
if (root && root.__x) {
var state = root.__x.$data;
if (state && typeof state.loadGiteaTree === 'function') {
state.loadGiteaTree();
}
}
}, 300); }, 300);
}); });
@@ -1057,7 +1148,18 @@
} }
}; };
// ═══════════ GLOBAL KEYBOARD SHORTCUTS ═══════════ // -- Global sidebar refresh handler (called after any CRUD) --
function refreshSidebarFromEvent() {
// Try appState first (outer x-data), then any x-data with refreshSidebarTree
var appEl = document.querySelector('.app-layout[x-data]');
if (!appEl) appEl = document.querySelector('[x-data]');
var data = appEl && appEl.__x ? appEl.__x.$data : null;
if (data && data.refreshSidebarTree) data.refreshSidebarTree();
}
document.addEventListener('flowdeck:sidebar-refresh', refreshSidebarFromEvent);
document.addEventListener('flowdeck:workspace-changed', refreshSidebarFromEvent);
// -- Global keyboard shortcuts --
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
var tag = (document.activeElement && document.activeElement.tagName) || ''; var tag = (document.activeElement && document.activeElement.tagName) || '';
var editable = tag === 'INPUT' || tag === 'TEXTAREA' || (document.activeElement && document.activeElement.isContentEditable); var editable = tag === 'INPUT' || tag === 'TEXTAREA' || (document.activeElement && document.activeElement.isContentEditable);
@@ -1124,5 +1226,25 @@
</script> </script>
<script src="/static/js/app.js?v=2.4.6" defer></script> <script src="/static/js/app.js?v=2.4.6" defer></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
<style>
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
.flowdeck-modal{background:var(--bg-secondary,#222);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;width:100%;max-width:420px;box-shadow:0 20px 60px rgba(0,0,0,0.5);}
.flowdeck-modal-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border,rgba(255,255,255,.08));}
.flowdeck-modal-header h3{margin:0;font-size:16px;color:var(--text,#fff);}
.flowdeck-modal-close{background:none;border:none;color:var(--text-dim, rgba(255,255,255,.4));font-size:20px;cursor:pointer;padding:4px 8px;border-radius:6px;}
.flowdeck-modal-close:hover{background:var(--bg-hover,#333);color:var(--text,#fff);}
.flowdeck-modal-body{padding:20px;}
.flowdeck-modal-body p{color:var(--text-dim, rgba(255,255,255,.6));font-size:14px;margin:0 0 12px 0;}
.flowdeck-modal-input{width:100%;padding:10px 12px;background:var(--bg-tertiary,#2A2A2A);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:8px;color:var(--text,#fff);font-size:14px;outline:none;box-sizing:border-box;}
.flowdeck-modal-input:focus{border-color:var(--accent,#2383E2);box-shadow:0 0 0 1px var(--accent,#2383E2);}
.flowdeck-modal-footer{display:flex;gap:8px;justify-content:flex-end;padding:12px 20px;border-top:1px solid var(--border,rgba(255,255,255,.08));}
.flowdeck-modal-btn{padding:8px 20px;border-radius:8px;font-size:13px;cursor:pointer;border:none;}
.flowdeck-modal-btn.cancel{background:var(--bg-tertiary,#333);color:var(--text-dim, rgba(255,255,255,.6));}
.flowdeck-modal-btn.cancel:hover{background:var(--bg-hover,#444);}
.flowdeck-modal-btn.confirm{background:var(--accent,#2383E2);color:#fff;}
.flowdeck-modal-btn.confirm:hover{background:#2C8CEB;}
</style>
</body> </body>
</html> </html>
+1
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_icon %}📁{% endblock %} {% block page_icon %}📁{% endblock %}
{% block page_title %}Kanban board new{% endblock %} {% block page_title %}Kanban board new{% endblock %}
{% block title_prefix %}Kanban board new{% endblock %}
{% block content %} {% block content %}
<!-- Cover image --> <!-- Cover image -->
+1
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_icon %}🏠{% endblock %} {% block page_icon %}🏠{% endblock %}
{% block page_title %}Home{% endblock %} {% block page_title %}Home{% endblock %}
{% block title_prefix %}Home{% endblock %}
{% block content %} {% block content %}
<div class="page-title-area" style="padding-top: 24px;"> <div class="page-title-area" style="padding-top: 24px;">
+288 -192
View File
@@ -1,24 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_title %}Gitea Workspace{% endblock %} {% block page_title %}{{ workspace_name }}{% endblock %}
{% block title_prefix %}{{ workspace_name }}{% endblock %}
{% block page_icon %}🔗{% endblock %} {% block page_icon %}🔗{% endblock %}
{% block topbar %} {% block topbar %}
<header class="topbar"> {% set breadcrumb_items = [{"label": "Home", "url": "/workspaces"}, {"label": owner ~ "/" ~ repo, "url": None}] %}
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu"> {% set page_icon = "🔗" %}
<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> {% 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>' %}
</button> {% include '_header.html' %}
<div class="topbar-left">
<a href="/workspaces" class="breadcrumb" style="color:rgba(255,255,255,.5);text-decoration:none;">Home</a>
<span class="page-title-topbar">{{ owner }}/{{ repo }}</span>
</div>
<div class="topbar-right" style="display:flex;align-items:center;gap:6px;">
<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>
<button class="page-action-btn" onclick="window.location='/accounts/settings'" title="Settings">⚙</button>
</div>
</header>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -46,120 +35,9 @@
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;} .gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
</style> </style>
<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>
<!-- Empty state -->
<template x-if="!showFile && !showEditor && !showPrivate">
<div class="gw-content">
<div class="empty-state">
<h2>📁 <span x-text="owner + '/' + repo"></span></h2>
<p x-show="!showNewFile">Select a file from the sidebar or create a new one</p>
<!-- New file form -->
<div x-show="showNewFile" style="margin-top:16px;text-align:left;max-width:400px;margin-left:auto;margin-right:auto;">
<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>
</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" @click="newPrivate()">+ 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>
<script> <script>
function giteaWorkspace() { document.addEventListener('alpine:init', () => {
Alpine.data('giteaWorkspace', () => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const owner = params.get('owner') || ''; const owner = params.get('owner') || '';
const repo = params.get('repo') || ''; const repo = params.get('repo') || '';
@@ -182,79 +60,142 @@ function giteaWorkspace() {
editContent: '', editContent: '',
commitMessage: 'Update via FlowDeck', commitMessage: 'Update via FlowDeck',
commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'), 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() { init() {
// Expose globally for header to access
window._gwData = this;
// Set cookie for sidebar // Set cookie for sidebar
document.cookie = 'flowdeck_workspace=gitea:' + this.owner + ':' + this.repo + ';path=/;SameSite=Lax'; document.cookie = 'flowdeck_workspace=gitea:' + this.owner + ':' + this.repo + ';path=/;SameSite=Lax';
// Load root tree into sidebar // Load sidebar tree (into gitea section)
this.loadSidebarTree(); this.loadSidebarTree();
// Load main content tree
this.loadMainTree('');
// Listen for sidebar clicks on Gitea items // Listen for sidebar clicks on Gitea items
this.setupSidebarClicks(); 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() { async loadSidebarTree() {
try { try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree'); var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree');
if (!r.ok) return; 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 d = await r.json();
var items = d.tree || []; var items = d.tree || [];
var container = document.getElementById('sidebar-workspace-items'); var container = document.getElementById('sidebar-gitea-items');
if (!container) return; 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 = ''; container.innerHTML = '';
var self = this; // Build tree HTML as string and set once (more performant)
items.forEach(function(item) { var html = '';
for (var i = 0; i < items.length; i++) {
var item = items[i];
var icon = item.type === 'folder' ? '📁' : '📄'; var icon = item.type === 'folder' ? '📁' : '📄';
var li = document.createElement('li'); 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;">';
li.className = 'sidebar-item'; html += '<span class="page-icon">' + icon + '</span>';
li.setAttribute('data-gitea-path', item.path); html += '<span class="page-name">' + item.name + '</span>';
li.setAttribute('data-gitea-type', item.type === 'folder' ? 'folder' : 'file'); html += '</li>';
li.setAttribute('data-gitea-sha', item.sha || ''); }
li.style.cssText = 'padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;';
// Checkbox (hidden by default, visible in multi-select mode)
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();
var gw = document.querySelector('[x-data]').__x.$data;
gw.selectItem(item.path, li, ev);
});
li.appendChild(cb);
// Icon
var iconSpan = document.createElement('span');
iconSpan.className = 'page-icon';
iconSpan.textContent = icon;
li.appendChild(iconSpan);
// Name (with double-click rename)
var nameSpan = document.createElement('span');
nameSpan.className = 'page-name';
nameSpan.textContent = item.name;
nameSpan.addEventListener('dblclick', function(ev) {
ev.preventDefault();
ev.stopPropagation();
var gw = document.querySelector('[x-data]').__x.$data;
gw.startInlineRename(nameSpan, item.path, li);
});
li.appendChild(nameSpan);
container.appendChild(li);
});
// Add Private Pages link // Add Private Pages link
var sep = document.createElement('li'); html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>';
sep.style.cssText = 'border-top:1px solid var(--border);margin:8px 0;'; html += '<li class="sidebar-item" id="gitea-private-link" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
container.appendChild(sep); html += '<span class="page-icon">🔒</span><span class="page-name">Private Pages</span></li>';
var pli = document.createElement('li'); container.innerHTML = html;
pli.className = 'sidebar-item'; // Re-attach event listeners
pli.style.cssText = 'padding-left:12px;cursor:pointer;'; this.setupSidebarClicks();
pli.innerHTML = '<span class="page-icon">🔒</span><span class="page-name">Private Pages</span>'; } catch(e) {
pli.addEventListener('click', function(e) { console.error('Gitea sidebar tree load failed:', e);
e.preventDefault(); e.stopPropagation(); }
var gw = document.querySelector('[x-data]').__x.$data;
gw.showPrivate = true; gw.showFile = false; gw.showEditor = false;
gw.loadPrivatePages();
});
container.appendChild(pli);
} catch(e) { console.error('Sidebar tree load failed:', e); }
}, },
setupSidebarClicks() { setupSidebarClicks() {
@@ -313,6 +254,7 @@ function giteaWorkspace() {
}, },
async loadSubdir(path, el) { async loadSubdir(path, el) {
var self = this;
// Check if already loaded // Check if already loaded
var ul = el.querySelector('ul'); var ul = el.querySelector('ul');
if (ul) { if (ul) {
@@ -342,8 +284,7 @@ function giteaWorkspace() {
cb.style.cssText = 'flex-shrink:0;margin:0;display:none;'; cb.style.cssText = 'flex-shrink:0;margin:0;display:none;';
cb.addEventListener('click', function(ev) { cb.addEventListener('click', function(ev) {
ev.stopPropagation(); ev.stopPropagation();
var gw = document.querySelector('[x-data]').__x.$data; self.selectItem(child.path, li, ev);
gw.selectItem(child.path, li, ev);
}); });
li.appendChild(cb); li.appendChild(cb);
// Icon // Icon
@@ -357,8 +298,7 @@ function giteaWorkspace() {
nameSpan.addEventListener('dblclick', function(ev) { nameSpan.addEventListener('dblclick', function(ev) {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
var gw = document.querySelector('[x-data]').__x.$data; self.startInlineRename(nameSpan, child.path, li);
gw.startInlineRename(nameSpan, child.path, li);
}); });
li.appendChild(nameSpan); li.appendChild(nameSpan);
ul.appendChild(li); ul.appendChild(li);
@@ -459,8 +399,9 @@ function giteaWorkspace() {
}, },
async refreshTree() { async refreshTree() {
// Reload page to refresh sidebar tree // Reload main tree and sidebar tree without full page reload
window.location.reload(); this.loadMainTree(this.currentPath);
this.loadSidebarTree();
}, },
formatSize(bytes) { formatSize(bytes) {
@@ -718,6 +659,161 @@ function giteaWorkspace() {
}); });
}, },
}; };
} });
});
</script> </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-secondary);border:1px solid var(--border-strong);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.4);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" @click="newPrivate()">+ 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 %} {% endblock %}
+321 -101
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_icon %}📚{% endblock %} {% block page_icon %}📚{% endblock %}
{% block page_title %}Library{% endblock %} {% block page_title %}Library{% endblock %}
{% block title_prefix %}Library{% endblock %}
{% block content %} {% block content %}
<div class="page-title-area"> <div class="page-title-area">
@@ -10,122 +11,341 @@
</div> </div>
</div> </div>
<!-- Library tabs + table — single Alpine scope --> <div x-data="libraryPage()" style="padding: 0 24px;">
<div x-data="{ tab: 'recents' }">
<div class="view-tabs"> <!-- ── Tab bar ── -->
<button class="view-tab" :class="{ active: tab === 'recents' }" @click="tab = 'recents'"> <div class="view-tabs">
<span class="tab-icon">🕒</span> Recents <button class="view-tab" :class="{ active: tab === 'recents' }" @click="switchTab('recents')">
</button> <span class="tab-icon">🕒</span> Recents
{% if lib_has_favorites %} </button>
<button class="view-tab" :class="{ active: tab === 'favorites' }" @click="tab = 'favorites'"> <button class="view-tab" :class="{ active: tab === 'favorites' }" @click="switchTab('favorites')">
<span class="tab-icon">⭐</span> Favorites <span class="tab-icon">⭐</span> Favorites
</button> </button>
{% endif %} <button class="view-tab" :class="{ active: tab === 'shared' }" @click="switchTab('shared')">
{% if lib_has_shared %} <span class="tab-icon">👥</span> Shared
<button class="view-tab" :class="{ active: tab === 'shared' }" @click="tab = 'shared'"> </button>
<span class="tab-icon">👥</span> Shared <button class="view-tab" :class="{ active: tab === 'published' }" @click="switchTab('published')">
</button> <span class="tab-icon">🌐</span> Published
{% endif %} </button>
<button class="view-tab" :class="{ active: tab === 'private' }" @click="tab = 'private'"> <button class="view-tab" :class="{ active: tab === 'private' }" @click="switchTab('private')">
<span class="tab-icon">🔒</span> Private <span class="tab-icon">🔒</span> Private
</button> </button>
</div> <button class="view-tab" :class="{ active: tab === 'workspace' }" @click="switchTab('workspace')">
<span class="tab-icon">📁</span> Workspace
<!-- Toolbar --> </button>
<div class="view-toolbar">
<div class="view-toolbar-spacer"></div>
<button class="toolbar-btn" title="Filter">☰</button>
<button class="toolbar-btn" title="Search">🔍</button>
<button class="toolbar-btn" title="Properties">⫶</button>
<button class="btn-new" @click="newPage()">New page</button>
</div>
<!-- Table -->
<div style="padding: 0 24px;">
<!-- Column headers -->
<div class="library-table-header">
<span class="lib-col lib-col-name">📄 Page name</span>
<span class="lib-col lib-col-author">👤 Created by</span>
<span class="lib-col lib-col-source">↗ Source</span>
<span class="lib-col lib-col-edited">🕒 Last edited time</span>
<span class="lib-col lib-col-visited">🕒 Last visited time</span>
</div> </div>
{% macro page_row(page) %} <!-- ── Toolbar with source_type filter ── -->
<a href="{{ page.url }}" class="library-table-row"> <div class="view-toolbar">
<span class="lib-col lib-col-name"> <div class="source-filter-group">
<span class="page-icon">{{ page.icon }}</span> <button class="source-filter-btn" :class="{ active: sourceType === 'all' }" @click="setSourceType('all')">All</button>
<span>{{ page.name }}</span> <button class="source-filter-btn" :class="{ active: sourceType === 'local' }" @click="setSourceType('local')">Local</button>
</span> <button class="source-filter-btn" :class="{ active: sourceType === 'gitea' }" @click="setSourceType('gitea')">Gitea</button>
<span class="lib-col lib-col-author">👤 {{ page.created_by or 'You' }}</span> <button class="source-filter-btn" :class="{ active: sourceType === 'github' }" @click="setSourceType('github')">GitHub</button>
<span class="lib-col lib-col-source">{{ page.source or '🔒 Private' }}</span>
<span class="lib-col lib-col-edited">{{ page.last_edited }}</span>
<span class="lib-col lib-col-visited">{{ page.last_visited or page.last_edited }}</span>
</a>
{% endmacro %}
<!-- Recents -->
<div x-show="tab === 'recents'">
{% for page in lib_recent %}{{ page_row(page) }}{% endfor %}
{% if not lib_recent %}
<div class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">🕒</div>
<h3>No recent pages</h3>
</div> </div>
{% endif %} <div class="view-toolbar-spacer"></div>
<button class="toolbar-btn" title="Search">🔍</button>
<button class="btn-new" @click="newPage()">New page</button>
</div> </div>
<!-- Favorites --> <!-- ── Loading state ── -->
<div x-show="tab === 'favorites'"> <div x-show="loading" class="lib-loading">
{% for page in lib_favorites %}{{ page_row(page) }}{% endfor %} <div class="spinner"></div>
{% if not lib_favorites %} <p>Loading…</p>
<div class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">⭐</div>
<h3>No favorites</h3>
<p>Right-click a page in the sidebar and select "Add to Favorites".</p>
</div>
{% endif %}
</div> </div>
<!-- Shared --> <!-- ── Card grid ── -->
<div x-show="tab === 'shared'"> <div x-show="!loading" class="library-card-grid">
{% for page in lib_shared %}{{ page_row(page) }}{% endfor %} <template x-for="item in items" :key="item.id">
{% if not lib_shared %} <div class="library-card" @click="openItem(item)">
<div class="lib-empty"> <!-- Icon -->
<div style="font-size:48px;margin-bottom:16px;">👥</div> <div class="library-card-icon" x-text="item.icon"></div>
<h3>No shared pages</h3> <!-- Info -->
<p>Shared pages from your team will appear here.</p> <div class="library-card-info">
</div> <div class="library-card-title" x-text="item.title"></div>
{% endif %} <div class="library-card-meta">
<span class="source-badge" :class="'source-' + item.source_type" x-text="item.source_badge"></span>
<span class="library-card-workspace" x-show="item.workspace_name && item.workspace_name !== 'Private'" x-text="item.workspace_name"></span>
<span class="library-card-date" x-text="formatDate(item.updated_at)"></span>
</div>
</div>
</div>
</template>
</div> </div>
<!-- Private --> <!-- ── Empty state ── -->
<div x-show="tab === 'private'"> <div x-show="!loading && items.length === 0" class="lib-empty">
{% for page in lib_private %}{{ page_row(page) }}{% endfor %} <div style="font-size:48px;margin-bottom:16px;" x-text="emptyIcon"></div>
{% if not lib_private %} <h3 x-text="emptyTitle"></h3>
<div class="lib-empty"> <p x-text="emptyText"></p>
<div style="font-size:48px;margin-bottom:16px;">🔒</div>
<h3>No private pages</h3>
<p>Create a new page with the + button in the sidebar.</p>
</div>
{% endif %}
</div> </div>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<style>
/* ── Source filter pill buttons ── */
.source-filter-group {
display: flex;
gap: 2px;
background: var(--bg-tertiary);
border-radius: 6px;
padding: 2px;
}
.source-filter-btn {
padding: 4px 12px;
border-radius: 5px;
font-size: 13px;
color: var(--text-dim);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.source-filter-btn:hover {
color: var(--text-primary);
}
.source-filter-btn.active {
background: var(--bg-primary);
color: var(--text-primary);
box-shadow: 0 1px 2px rgba(0,0,0,.2);
}
/* ── Card grid ── */
.library-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 8px;
margin-top: 12px;
}
.library-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.library-card:hover {
background: var(--bg-tertiary);
border-color: var(--border-light);
}
.library-card-icon {
font-size: 24px;
line-height: 1;
flex-shrink: 0;
margin-top: 2px;
}
.library-card-info {
flex: 1;
min-width: 0;
}
.library-card-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 6px;
}
.library-card-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.source-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.source-local {
background: rgba(100, 180, 100, 0.15);
color: #64b464;
}
.source-gitea {
background: rgba(100, 160, 220, 0.15);
color: #64a0dc;
}
.source-github {
background: rgba(180, 140, 220, 0.15);
color: #b48cdc;
}
.library-card-workspace {
font-size: 12px;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-card-date {
font-size: 12px;
color: var(--text-dim);
margin-left: auto;
white-space: nowrap;
}
/* ── Loading spinner ── */
.lib-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 0;
color: var(--text-dim);
gap: 16px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--text-dim);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
<script> <script>
function newPage() { function libraryPage() {
const csrf = document.cookie.match(/csrf_token=([^;]+)/); return {
fetch('/board/api/pages?section=Private', { tab: 'recents',
method: 'POST', sourceType: 'all',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' } items: [],
}) loading: false,
.then(r => { if (!r.ok) throw new Error('CSRF error'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; }) get emptyIcon() {
.catch(err => { window.showToast('Failed: ' + err.message, 'error'); }); const icons = {
} recents: '🕒', favorites: '⭐', shared: '👥',
published: '🌐', private: '🔒', workspace: '📁'
};
return icons[this.tab] || '📄';
},
get emptyTitle() {
const titles = {
recents: 'No recent pages',
favorites: 'No favorites',
shared: 'No shared pages',
published: 'No published pages',
private: 'No private pages',
workspace: 'No workspace pages'
};
return titles[this.tab] || 'No items';
},
get emptyText() {
const texts = {
recents: 'Pages you\'ve recently viewed or edited will appear here.',
favorites: 'Right-click a page in the sidebar and select "Add to Favorites".',
shared: 'Shared pages from your team will appear here.',
published: 'Pages published to the web will appear here.',
private: 'Create a new page with the + button in the sidebar.',
workspace: 'Pages inside workspaces will appear here.'
};
return texts[this.tab] || '';
},
init() {
// Read ?tab= from URL
const params = new URLSearchParams(window.location.search);
const urlTab = params.get('tab');
if (urlTab && ['recents','favorites','shared','published','private','workspace'].includes(urlTab)) {
this.tab = urlTab;
}
const urlSource = params.get('source_type');
if (urlSource && ['all','local','gitea','github'].includes(urlSource)) {
this.sourceType = urlSource;
}
this.loadTab();
},
switchTab(t) {
if (this.tab === t) return;
this.tab = t;
this.items = [];
this.updateUrl();
this.loadTab();
},
setSourceType(st) {
if (this.sourceType === st) return;
this.sourceType = st;
this.items = [];
this.updateUrl();
this.loadTab();
},
updateUrl() {
const params = new URLSearchParams();
if (this.tab !== 'recents') params.set('tab', this.tab);
if (this.sourceType !== 'all') params.set('source_type', this.sourceType);
const qs = params.toString();
const newUrl = qs ? `/library?${qs}` : '/library';
window.history.replaceState({}, '', newUrl);
},
async loadTab() {
this.loading = true;
try {
const res = await fetch(`/api/library/${this.tab}?source_type=${this.sourceType}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
this.items = data.items || [];
} catch (e) {
console.error('Library load error:', e);
this.items = [];
} finally {
this.loading = false;
}
},
openItem(item) {
if (!item || !item.url) return;
window.location.href = item.url;
},
formatDate(d) {
if (!d) return '';
try {
const date = new Date(d);
if (isNaN(date.getTime())) return d;
const now = new Date();
const diff = now - date;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
} catch {
return d;
}
},
newPage() {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch('/board/api/pages?section=Private', {
method: 'POST',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
})
.then(r => { if (!r.ok) throw new Error('CSRF error'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { window.showToast('Failed: ' + err.message, 'error'); });
}
};
}
</script> </script>
{% endblock %} {% endblock %}
+51 -33
View File
@@ -1,22 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_title %}{{ workspace_name }}{% endblock %} {% block page_title %}{{ workspace_name }}{% endblock %}
{% block title_prefix %}{{ workspace_name }}{% endblock %}
{% block page_icon %}📁{% endblock %} {% block page_icon %}📁{% endblock %}
{% block topbar %} {% block topbar %}
<header class="topbar"> {% set page_icon = "📁" %}
<button class="hamburger-btn" @click="sidebarCollapsed ? (sidebarCollapsed = false) : (mobileSidebarOpen = true, sidebarCollapsed = false)" title="Menu"> {% set page_title = workspace_name %}
<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> {% include '_header.html' %}
</button>
<div class="topbar-left">
<span class="page-title-topbar">
{% if breadcrumb %}
{% for b in breadcrumb %}
/ <a href="/local-workspace?folder={{ b.id }}" style="color:rgba(255,255,255,.8);text-decoration:none;">{{ b.name }}</a>
{% endfor %}
{% endif %}
</span>
</div>
</header>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -566,11 +556,20 @@ window._wsInitData = (function() {
if (ids.length === 0) return; if (ids.length === 0) return;
if (!confirm('Delete ' + ids.length + ' selected items?')) return; if (!confirm('Delete ' + ids.length + ' selected items?')) return;
var self = this; var self = this;
// Soft-delete all selected items
for (var i=0; i<ids.length; i++) { for (var i=0; i<ids.length; i++) {
await fetch('/api/local-workspace/items/' + ids[i], { method: 'DELETE' }); await fetch('/api/local-workspace/items/' + ids[i], { method: 'DELETE' });
} }
this.clearSelection(); this.clearSelection();
this._reloadAfterAction(); this._reloadAfterAction();
// Show undo banner for bulk delete
this.undoItemId = ids.join(',');
this.undoMessage = 'Deleted ' + ids.length + ' item(s)';
this.undoVisible = true;
this.undoTimeout = setTimeout(function() {
self.undoVisible = false;
self.undoItemId = null;
}, 5000);
}, },
clearSelection() { clearSelection() {
for (var k in this.selectedIds) delete this.selectedIds[k]; for (var k in this.selectedIds) delete this.selectedIds[k];
@@ -1110,6 +1109,10 @@ window._wsInitData = (function() {
_reloadAfterAction() { _reloadAfterAction() {
this._saveExpandedState(); this._saveExpandedState();
this._loadFolder(this.currentFolder); this._loadFolder(this.currentFolder);
// Directly refresh sidebar tree
if (window.appState && window.appState.refreshSidebarTree) {
window.appState.refreshSidebarTree();
}
}, },
// ── Inline Rename ── // ── Inline Rename ──
@@ -1204,15 +1207,12 @@ window._wsInitData = (function() {
this.undoItemId = node.db_id || node.id; this.undoItemId = node.db_id || node.id;
this.undoMessage = 'Deleted "' + (node.name || 'item') + '"'; this.undoMessage = 'Deleted "' + (node.name || 'item') + '"';
this.undoVisible = true; this.undoVisible = true;
// Soft-delete: actually delete after timeout // Delete via API
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, { method:'DELETE' }); var r = await fetch('/api/local-workspace/items/' + this.undoItemId, { method:'DELETE' });
if (!r.ok) { this.undoVisible = false; return; } if (!r.ok) { this.undoVisible = false; return; }
// Remove from tree immediately // Reload from API for reliability (sidebar refresh handled by _reloadAfterAction)
this._removeFromTree(this.tree, this.undoItemId); this._reloadAfterAction();
if (!this.searchQuery && !this.filterType && !this.sortBy) this.displayTree = [...this.tree]; // Auto-dismiss undo banner after 5s
else if (this.sortBy) this.doSort();
else this.doFilter();
// Auto-dismiss + permanent after 5s
this.undoTimeout = setTimeout(function() { this.undoTimeout = setTimeout(function() {
self.undoVisible = false; self.undoVisible = false;
self.undoItemId = null; self.undoItemId = null;
@@ -1221,11 +1221,23 @@ window._wsInitData = (function() {
undoDelete() { undoDelete() {
if (this.undoTimeout) clearTimeout(this.undoTimeout); if (this.undoTimeout) clearTimeout(this.undoTimeout);
this.undoVisible = false; this.undoVisible = false;
var id = this.undoItemId; var ids = (this.undoItemId || '').toString().split(',');
if (!id) return;
// Recreate the item (we can't really undo a DELETE, so reload)
this._reloadAfterAction();
this.undoItemId = null; this.undoItemId = null;
if (!ids.length || !ids[0]) return;
// Restore via API (soft-delete undo) — single or bulk
var self = this;
var restored = 0;
var total = ids.length;
function restoreOne(i) {
if (i >= total) {
self._reloadAfterAction();
return;
}
fetch('/api/local-workspace/items/' + ids[i] + '/restore', { method: 'POST' })
.then(function(r) { if (r.ok) restored++; })
.finally(function() { restoreOne(i + 1); });
}
restoreOne(0);
}, },
_removeFromTree(nodes, id) { _removeFromTree(nodes, id) {
for (var i=0; i<nodes.length; i++) { for (var i=0; i<nodes.length; i++) {
@@ -1554,6 +1566,10 @@ window._wsInitData = (function() {
this.doFilter(); this.doFilter();
} }
this._saveExpandedState(); this._saveExpandedState();
// Directly refresh sidebar tree
if (window.appState && window.appState.refreshSidebarTree) {
window.appState.refreshSidebarTree();
}
} }
}, },
@@ -1980,14 +1996,16 @@ window._ctxMenuData = {
ctxAvailTags: [], ctxAvailTags: [],
ctxNewTagColor: '#787774', ctxNewTagColor: '#787774',
ctxTagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'], ctxTagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
ctxMenuOpenPage() { window._wsInitData.ctxMenuOpenPage(); }, // Use Alpine proxy (window._wsData) for reactivity, not raw object (window._wsInitData)
ctxMenuOpenFolder() { window._wsInitData.ctxMenuOpenFolder(); }, _ws() { return window._wsData || window._wsInitData; },
ctxMenuRename() { window._wsInitData.ctxMenuRename(); }, ctxMenuOpenPage() { this._ws().ctxMenuOpenPage(); },
ctxMenuDuplicate() { window._wsInitData.ctxMenuDuplicate(); }, ctxMenuOpenFolder() { this._ws().ctxMenuOpenFolder(); },
ctxMenuDelete() { window._wsInitData.ctxMenuDelete(); }, ctxMenuRename() { this._ws().ctxMenuRename(); },
ctxAddExistingTag(t) { window._wsInitData.ctxAddExistingTag(t); }, ctxMenuDuplicate() { this._ws().ctxMenuDuplicate(); },
ctxAddNewTag(n, c) { window._wsInitData.ctxAddNewTag(n, c); }, ctxMenuDelete() { this._ws().ctxMenuDelete(); },
ctxRemoveTag(id) { window._wsInitData.ctxRemoveTag(id); }, ctxAddExistingTag(t) { this._ws().ctxAddExistingTag(t); },
ctxAddNewTag(n, c) { this._ws().ctxAddNewTag(n, c); },
ctxRemoveTag(id) { this._ws().ctxRemoveTag(id); },
}; };
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length); console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
</script> </script>
+178 -42
View File
@@ -1,41 +1,12 @@
{% extends "base.html" %} {% block page_icon %}📄{% endblock %} {% block {% extends "base.html" %} {% block page_icon %}📄{% endblock %} {% block
page_title %}{{ page.title }}{% endblock %} {% block topbar %} page_title %}{{ page.title }}{% endblock %} {% block topbar %}
<header class="topbar" style="background:var(--bg-primary);"> {% set page_icon = "📄" %}
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu"> {% set page_title = page.title %}
<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> {% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && (window.E.activityOpen = !window.E.activityOpen)">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && (window.E.shareOpen = !window.E.shareOpen)">🔒 Share ▾</button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">🔗</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()">⭐</button><button class="topbar-btn" @click="window.E && (window.E.moreOpen = !window.E.moreOpen)" style="position:relative">⋯</button>' %}
</button> {% include '_header.html' %}
<div class="topbar-left">
<span class="breadcrumb" style="color:rgba(255,255,255,.5);font-size:13px">{{ page.title }}</span>
</div>
</header>
{% endblock %} {% block content %} {% endblock %} {% block content %}
<div class="page-editor-wrapper" x-data="editorState()"> <div class="page-editor-wrapper" x-data="editorState()">
<!-- ═══════════ Top Action Bar ═══════════ --> <!-- Share/More dialogs (in content area, triggered by header buttons) -->
<div class="page-topbar">
<div class="page-topbar-right" style="position: relative">
<span class="topbar-edited">Edited just now</span>
<button
class="page-action-btn share-btn"
@click="shareOpen = !shareOpen"
>
🔒 Share <span class="chevron-down">▾</span>
</button>
<button
class="page-action-btn"
@click="copyPageLink"
title="Copy link"
>
🔗
</button>
<button class="page-action-btn star-btn" @click="toggleFavorite">
<span
x-text="favorited ? '⭐' : '☆'"
:class="{ starred: favorited }"
></span>
</button>
<button class="page-action-btn" @click="moreOpen = !moreOpen">
⋮
</button>
<!-- ═══════════ Share / Publish Dialog ═══════════ --> <!-- ═══════════ Share / Publish Dialog ═══════════ -->
<div <div
@@ -75,9 +46,9 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
class="sd-input" class="sd-input"
placeholder="Add people, groups or emails..." placeholder="Add people, groups or emails..."
x-model="inviteEmail" x-model="inviteEmail"
@keydown.enter="invitePerson" @keydown.enter="shareInvite()"
/> />
<button class="sd-btn-primary" @click="invitePerson"> <button class="sd-btn-primary" @click="shareInvite()">
Invite Invite
</button> </button>
</div> </div>
@@ -268,7 +239,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
</p> </p>
<button <button
class="sd-btn-primary sd-btn-full" class="sd-btn-primary sd-btn-full"
@click="pagePublished = true" @click="publishPage()"
> >
Publish Publish
</button> </button>
@@ -354,7 +325,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
<button <button
class="sd-danger-btn" class="sd-danger-btn"
@click="pagePublished = false" @click="unpublishPage()"
> >
Unpublish Unpublish
</button> </button>
@@ -362,6 +333,18 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
</div> </div>
</div> </div>
<!-- ═══════════ Activity Popover ═══════════ -->
<div class="activity-popover"
x-show="activityOpen"
@click.outside="activityOpen = false"
x-transition.opacity.scale.origin.top.right
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-secondary);border:1px solid var(--border-strong);border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.5);z-index:1000;width:280px;padding:12px;">
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">Edited <span x-text="timeAgo"></span></div>
<template x-if="pageCreated">
<div style="font-size:12px;color:var(--text-tertiary);">Created <span x-text="formatDate(pageCreated)"></span></div>
</template>
</div>
<!-- More menu dropdown --> <!-- More menu dropdown -->
<div <div
class="more-menu" class="more-menu"
@@ -379,8 +362,30 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
🗑️ Move to Trash 🗑️ Move to Trash
</div> </div>
</div> </div>
</div>
</div> <!-- ═══════════ Move to Dialog ═══════════ -->
<div class="move-dialog"
x-show="moveOpen"
@click.outside="moveOpen = false"
x-transition.opacity.scale
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-secondary);border:1px solid var(--border-strong);border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.5);z-index:1000;width:320px;padding:16px;">
<h3 style="font-size:14px;margin-bottom:12px;">Move to workspace</h3>
<div x-show="moveLoading" style="color:var(--text-tertiary);text-align:center;padding:20px;">Loading...</div>
<div x-show="!moveLoading" style="max-height:240px;overflow-y:auto;">
<template x-for="ws in moveWorkspaces" :key="ws.id">
<div class="move-ws-item"
@click="doMove(ws.id)"
style="display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;">
<span>📁</span>
<span x-text="ws.name" style="flex:1;"></span>
<span style="font-size:11px;color:var(--text-tertiary);" x-text="ws.page_count + ' pages'"></span>
</div>
</template>
<div x-show="moveWorkspaces.length === 0" style="color:var(--text-tertiary);text-align:center;padding:12px;">
No workspaces available
</div>
</div>
</div>
<!-- ═══════════ Page Content ═══════════ --> <!-- ═══════════ Page Content ═══════════ -->
<div class="page-cover-area"> <div class="page-cover-area">
@@ -588,8 +593,11 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
function editorState(){ function editorState(){
return { return {
pid:null,pageTitle:'',blocks:[],dirty:false,saving:false,lastSaved:'',st:null, pid:null,pageTitle:'',blocks:[],dirty:false,saving:false,lastSaved:'',st:null,
updatedAt:'{{ page.get("updated_at", "") }}',
fmt:{open:false,top:0,left:0,idx:-1}, fmt:{open:false,top:0,left:0,idx:-1},
shareOpen:false,moreOpen:false,gsMoreOpen:false,accessMenuOpen:false, shareOpen:false,moreOpen:false,gsMoreOpen:false,accessMenuOpen:false,
moveOpen:false,moveWorkspaces:[],moveLoading:false,
activityOpen:false,pageCreated:'{{ page.get("created_at", "") }}',
shareTab:'share',favorited:false, shareTab:'share',favorited:false,
pagePublished:{{ page_published | tojson }}, pagePublished:{{ page_published | tojson }},
generalAccess:'{{ page_share_mode }}',publicPerm:'viewer', generalAccess:'{{ page_share_mode }}',publicPerm:'viewer',
@@ -597,6 +605,20 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
pageUrl:window.location.href,publishedUrl:'', pageUrl:window.location.href,publishedUrl:'',
inviteEmail:'',invitePermission:'editor',accessList:[], inviteEmail:'',invitePermission:'editor',accessList:[],
toastVisible:false,toastMsg:'', toastVisible:false,toastMsg:'',
get timeAgo() {
if (!this.updatedAt) return 'just now';
var diff = Math.floor((Date.now() - new Date(this.updatedAt + 'Z').getTime()) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
},
formatDate(d) {
if (!d) return '';
var dt = new Date(d + 'Z');
return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +
' at ' + dt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
},
init(){ init(){
_bid=Math.floor(Math.random()*10000); _bid=Math.floor(Math.random()*10000);
const dataEl=document.getElementById('page-data'); const dataEl=document.getElementById('page-data');
@@ -657,11 +679,108 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
clearTimeout(this._toastTimer); clearTimeout(this._toastTimer);
this._toastTimer = setTimeout(() => { this.toastVisible = false; }, 2500); this._toastTimer = setTimeout(() => { this.toastVisible = false; }, 2500);
}, },
publishPage() {
var self = this;
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch('/api/pages/' + this.pid + '/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' }
}).then(function(r){ return r.json(); }).then(function(d){
if (d.status === 'ok') { self.pagePublished = true; self.publishedUrl = d.url || self.publishedUrl; self.showToast('Published to web'); }
else { self.showToast(d.detail || 'Publish failed'); }
}).catch(function(){ self.showToast('Publish failed'); });
},
unpublishPage() {
var self = this;
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch('/api/pages/' + this.pid + '/publish', {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
}).then(function(r){ return r.json(); }).then(function(d){
if (d.status === 'ok') { self.pagePublished = false; self.publishedUrl = ''; self.showToast('Unpublished'); }
else { self.showToast(d.detail || 'Unpublish failed'); }
}).catch(function(){ self.showToast('Unpublish failed'); });
},
shareInvite() {
var self = this;
if (!this.inviteEmail.trim()) return;
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch('/api/pages/' + this.pid + '/share', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
body: JSON.stringify({ email: this.inviteEmail.trim(), permission: this.invitePermission })
}).then(function(r){ return r.json(); }).then(function(d){
if (d.status === 'ok') { self.inviteEmail = ''; self.showToast('Invitation sent'); self.loadShares(); }
else { self.showToast(d.detail || 'Share failed'); }
}).catch(function(){ self.showToast('Share failed'); });
},
loadShares() {
var self = this;
fetch('/api/pages/' + this.pid + '/shares').then(function(r){ return r.json(); }).then(function(d){
self.accessList = d.shares || [];
}).catch(function(){ self.accessList = []; });
},
removeShare(sid) {
var self = this;
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch('/api/pages/' + this.pid + '/share/' + sid, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
}).then(function(r){ return r.json(); }).then(function(d){
if (d.status === 'ok') { self.showToast('Share removed'); self.loadShares(); }
else { self.showToast(d.detail || 'Remove failed'); }
}).catch(function(){ self.showToast('Remove failed'); });
},
invitePerson(){if(!this.inviteEmail.trim())return;this.accessList.push({email:this.inviteEmail,permission:this.invitePermission,permOpen:false});this.inviteEmail='';}, invitePerson(){if(!this.inviteEmail.trim())return;this.accessList.push({email:this.inviteEmail,permission:this.invitePermission,permOpen:false});this.inviteEmail='';},
removeAccess(email){this.accessList=this.accessList.filter(a=>a.email!==email);}, removeAccess(email){this.accessList=this.accessList.filter(a=>a.email!==email);},
exportPage(){this.moreOpen=false;}, exportPage(){
this.moreOpen=false;
// Export blocks as Markdown
var md = '# ' + (this.pageTitle || 'Untitled') + '\n\n';
for (var i = 0; i < this.blocks.length; i++) {
var b = this.blocks[i];
md += blocksToMarkdown(b) + '\n\n';
}
var blob = new Blob([md], {type: 'text/markdown'});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = (this.pageTitle || 'export') + '.md';
a.click(); URL.revokeObjectURL(url);
this.showToast('Exported as Markdown');
},
duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}&section=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{window.showToast('Duplicate failed','error');});}, duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}&section=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{window.showToast('Duplicate failed','error');});},
movePage(){this.moreOpen=false;}, movePage(){
this.moreOpen=false;
this.moveOpen = true;
this.moveLoading = true;
var self = this;
fetch('/api/workspaces')
.then(function(r){ return r.json(); })
.then(function(d){
self.moveWorkspaces = d.workspaces || [];
self.moveLoading = false;
})
.catch(function(){ self.moveLoading = false; });
},
doMove(wsId) {
this.moveOpen = false;
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
var self = this;
fetch('/api/pages/' + this.pid + '/move', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
body: JSON.stringify({ workspace_id: wsId })
}).then(function(r){ return r.json(); })
.then(function(d){
if (d.status === 'ok') {
self.showToast('Moved to workspace');
setTimeout(function(){ window.location.href = '/local-workspace'; }, 800);
} else {
self.showToast(d.detail || 'Move failed');
}
})
.catch(function(){ self.showToast('Move failed'); });
},
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});}, deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
onKd(e){ onKd(e){
@@ -736,5 +855,22 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
}; };
} }
document.addEventListener('mouseup',()=>{setTimeout(()=>{const s=window.getSelection();if(!s||s.isCollapsed)return;const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;const d=ed.__x.$data;const ct=document.getElementById('_blocksCt');if(!ct?.contains(s.anchorNode))return;const bel=s.anchorNode.parentElement?.closest('[data-bid]');if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);}},80);}); document.addEventListener('mouseup',()=>{setTimeout(()=>{const s=window.getSelection();if(!s||s.isCollapsed)return;const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;const d=ed.__x.$data;const ct=document.getElementById('_blocksCt');if(!ct?.contains(s.anchorNode))return;const bel=s.anchorNode.parentElement?.closest('[data-bid]');if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);}},80);});
function blocksToMarkdown(b) {
var c = b.content || '';
switch(b.type) {
case 'heading_1': return '# ' + c;
case 'heading_2': return '## ' + c;
case 'heading_3': return '### ' + c;
case 'heading_4': return '#### ' + c;
case 'bulleted_list': return '- ' + c;
case 'numbered_list': return '1. ' + c;
case 'to_do': return (b.checked ? '- [x] ' : '- [ ] ') + c;
case 'quote': return '> ' + c;
case 'divider': return '---';
case 'code': return '```' + (b.language || '') + '\n' + c + '\n```';
case 'toggle': return '## ' + c;
default: return c;
}
}
</script> </script>
{% endblock %} {% endblock %}
+60 -15
View File
@@ -1,16 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_title %}Settings{% endblock %} {% block page_title %}Settings{% endblock %}
{% block title_prefix %}Settings{% endblock %}
{% block page_icon %}⚙️{% endblock %} {% block page_icon %}⚙️{% endblock %}
{% block topbar %} {% block topbar %}
<header class="topbar"> {% set page_icon = "⚙" %}
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu"> {% set page_title = "Settings" %}
<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> {% include '_header.html' %}
</button>
<div class="topbar-left">
<span class="page-title-topbar">⚙️ Settings</span>
</div>
</header>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -80,7 +76,7 @@
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()"> <div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
<div class="settings-panel" style="position:relative;"> <div class="settings-panel" style="position:relative;">
<button class="settings-close" @click="window.history.back()" title="Close">×</button> <button class="settings-close" @click="closeSettings()" title="Close">×</button>
<!-- Left navigation --> <!-- Left navigation -->
<div class="settings-nav"> <div class="settings-nav">
@@ -318,9 +314,10 @@
<p class="section-desc">Integrations and feature toggles.</p> <p class="section-desc">Integrations and feature toggles.</p>
<div class="setting-group"> <div class="setting-group">
<h3>Integrations</h3> <h3>Integrations</h3>
<!-- Gitea -->
<div class="setting-row"> <div class="setting-row">
<div> <div>
<div class="setting-label">Gitea</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"> <div class="setting-desc">
<span x-show="giteaLinked">Connected to git.dracodev.net</span> <span x-show="giteaLinked">Connected to git.dracodev.net</span>
<span x-show="!giteaLinked">Connect your Gitea account to access projects</span> <span x-show="!giteaLinked">Connect your Gitea account to access projects</span>
@@ -328,16 +325,24 @@
</div> </div>
<div class="setting-control"> <div class="setting-control">
<span x-show="giteaLinked" style="font-size:12px;color:#0F7B6C;">✅ Active</span> <span x-show="giteaLinked" style="font-size:12px;color:#0F7B6C;">✅ Active</span>
<button x-show="giteaLinked" class="btn-sm" style="color:var(--danger);" @click="disconnectGitea()">Disconnect</button> <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> <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>
</div> </div>
<!-- GitHub -->
<div class="setting-row"> <div class="setting-row">
<div> <div>
<div class="setting-label">GitHub</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">Connect your GitHub account</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:#0F7B6C;">✅ 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 class="setting-control"><span style="font-size:12px;color:var(--text-dim);">Coming soon</span></div>
</div> </div>
</div> </div>
</div> </div>
@@ -512,8 +517,10 @@ document.addEventListener('alpine:init', function() {
defaultView: localStorage.getItem('fd_default_view') || 'tree', defaultView: localStorage.getItem('fd_default_view') || 'tree',
userInfo: {full_name: '{{ user.full_name or "" }}', login: '{{ user.login or "" }}', email: '{{ user.email or "" }}'}, userInfo: {full_name: '{{ user.full_name or "" }}', login: '{{ user.login or "" }}', email: '{{ user.email or "" }}'},
userIsAdmin: {{ 'true' if user.get('is_admin') else 'false' }}, userIsAdmin: {{ 'true' if user.get('is_admin') else 'false' }},
// Gitea // Auth & Integrations
authMethod: '{{ auth_method }}',
giteaLinked: false, giteaLinked: false,
githubLinked: false,
// Theme // Theme
theme: localStorage.getItem('fd_theme') || 'dark', theme: localStorage.getItem('fd_theme') || 'dark',
// Account form // Account form
@@ -533,6 +540,7 @@ document.addEventListener('alpine:init', function() {
async init() { async init() {
await this.loadTags(); await this.loadTags();
await this.loadGiteaStatus(); await this.loadGiteaStatus();
await this.loadGithubStatus();
}, },
async loadTags() { async loadTags() {
@@ -771,6 +779,43 @@ document.addEventListener('alpine:init', function() {
if (r.ok) { this.giteaLinked = false; } if (r.ok) { this.giteaLinked = false; }
} catch(e) {} } 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);
}
},
}; };
}); });
}); });
+1
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_icon %}🗑️{% endblock %} {% block page_icon %}🗑️{% endblock %}
{% block page_title %}Trash{% endblock %} {% block page_title %}Trash{% endblock %}
{% block title_prefix %}Trash{% endblock %}
{% block content %} {% block content %}
<div class="page-title-area"> <div class="page-title-area">
+5 -13
View File
@@ -1,21 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_title %}Workspace{% endblock %} {% block page_title %}Workspace{% endblock %}
{% block title_prefix %}Workspace{% endblock %}
{% block page_icon %}🏠{% endblock %} {% block page_icon %}🏠{% endblock %}
{% block topbar %} {% block topbar %}
<header class="topbar"> {% set page_icon = "🏠" %}
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu"> {% set page_title = "Workspace — Projects" %}
<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> {% 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>' %}
</button> {% include '_header.html' %}
<div class="topbar-left">
<span class="breadcrumb" style="color:rgba(255,255,255,.5)">Workspace</span>
<span class="page-title-topbar">Projects</span>
</div>
<div class="topbar-right">
<button class="page-action-btn" onclick="window.location='/auth/login?provider=local'" title="Login">🔑</button>
<button class="page-action-btn" onclick="window.location='/accounts/settings'" title="Settings">⚙</button>
</div>
</header>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
+6 -13
View File
@@ -1,20 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_title %}Workspaces{% endblock %} {% block page_title %}Workspaces{% endblock %}
{% block title_prefix %}Workspaces{% endblock %}
{% block page_icon %}🏠{% endblock %} {% block page_icon %}🏠{% endblock %}
{% block topbar %} {% block topbar %}
<header class="topbar"> {% set breadcrumb_items = [{"label": "Home", "url": "/workspaces"}, {"label": "Workspaces", "url": None}] %}
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu"> {% set page_icon = "🏠" %}
<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> {% 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>' %}
</button> {% include '_header.html' %}
<div class="topbar-left">
<span class="breadcrumb" style="color:rgba(255,255,255,.5)">Home</span>
<span class="page-title-topbar">Workspaces</span>
</div>
<div class="topbar-right">
<button class="page-action-btn" onclick="window.location='/accounts/settings'" title="Settings">⚙</button>
</div>
</header>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -87,7 +80,7 @@
<!-- Not linked --> <!-- Not linked -->
<div class="gitea-empty" x-show="giteaStatus==='not_linked'"> <div class="gitea-empty" x-show="giteaStatus==='not_linked'">
<div>Connect your Gitea account to see your projects.</div> <div>Connect your Gitea account to see your projects.</div>
<a class="gitea-connect" href="/auth/login?provider=gitea">🔗 Connect Gitea</a> <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> <span style="margin:0 8px;color:var(--text-dim);">or</span>
<a class="gitea-connect" href="/auth/login?provider=gitea">📝 Register with Gitea</a> <a class="gitea-connect" href="/auth/login?provider=gitea">📝 Register with Gitea</a>
</div> </div>
+98
View File
@@ -807,6 +807,104 @@ input, select, textarea { font-family: inherit; color: inherit; }
color: var(--text-secondary); color: var(--text-secondary);
} }
/* ── Unified Header Breadcrumb ── */
.header-breadcrumb {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
overflow: hidden;
flex: 1;
min-width: 0;
}
.header-page-icon {
font-size: 16px;
flex-shrink: 0;
margin-right: 2px;
}
.breadcrumb-link {
color: var(--text-secondary);
text-decoration: none;
white-space: nowrap;
flex-shrink: 0;
padding: 2px 4px;
border-radius: 4px;
transition: background 100ms, color 100ms;
}
.breadcrumb-link:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.breadcrumb-link.disabled {
cursor: default;
opacity: 0.6;
}
.breadcrumb-sep {
color: var(--text-tertiary);
flex-shrink: 0;
font-size: 12px;
margin: 0 1px;
}
.breadcrumb-current {
color: var(--text-primary);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* ── Dropdown panel (reusable) ── */
.dropdown-panel {
position: absolute;
display: none;
background: var(--bg-secondary);
border: 1px solid var(--border-strong);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,.4);
z-index: 1000;
padding: 4px;
min-width: 160px;
}
.dropdown-panel.visible {
display: block;
}
.dropdown-option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
}
.dropdown-option:hover {
background: var(--bg-hover);
}
.option-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
.topbar-breadcrumb a:hover { .topbar-breadcrumb a:hover {
color: var(--text-primary); color: var(--text-primary);
} }
+28 -28
View File
@@ -209,7 +209,8 @@ def test_db_tables_exist(client):
"collections", "collection_pages", "collection_views", "collections", "collection_pages", "collection_views",
"collection_properties", "workspaces", "workspace_members", "collection_properties", "workspaces", "workspace_members",
"comments", "page_history", "favorites", "comments", "page_history", "favorites",
"database_templates", "page_templates"} "database_templates", "page_templates",
"page_shares", "recents"}
assert required <= names assert required <= names
@@ -1013,10 +1014,9 @@ def test_pwa_manifest(client):
# ══════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════
def test_upload_no_auth(client): def test_upload_no_auth(client):
"""POST /api/gitea/projects/owner/repo/upload — 401 without Gitea linked.""" """POST /api/gitea/projects/owner/repo/upload — 401 without Gitea token."""
resp = client.post("/api/gitea/projects/testowner/testrepo/upload") resp = client.post("/api/gitea/projects/testowner/testrepo/upload")
assert resp.status_code == 401 assert resp.status_code == 401 # no Gitea token → 401
assert "not linked" in resp.json()["detail"].lower() or "gitea" in resp.json()["detail"].lower()
def test_upload_missing_file(client): def test_upload_missing_file(client):
@@ -1079,9 +1079,9 @@ def test_upload_with_session_no_file(client):
# ══════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════
def test_sync_labels_no_auth(client): def test_sync_labels_no_auth(client):
"""POST /api/gitea/projects/owner/repo/sync-labels — 401 without session.""" """POST /api/gitea/projects/owner/repo/sync-labels — requires session."""
resp = client.post("/api/gitea/projects/testowner/testrepo/sync-labels") resp = client.post("/api/gitea/projects/testowner/testrepo/sync-labels")
assert resp.status_code == 401 assert resp.status_code == 401 # no session → 401
def test_sync_labels_with_session_no_gitea(client): def test_sync_labels_with_session_no_gitea(client):
@@ -1097,8 +1097,8 @@ def test_sync_labels_with_session_no_gitea(client):
"/api/gitea/projects/owner/repo/sync-labels", "/api/gitea/projects/owner/repo/sync-labels",
cookies={"flowdeck_session": session}, cookies={"flowdeck_session": session},
) )
# 401 — requires Gitea OAuth token # No Gitea OAuth token → 401
assert resp.status_code in (401, 502) assert resp.status_code == 401
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM users WHERE id=?", (uid,)) conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit() conn.commit()
@@ -1134,9 +1134,9 @@ def test_sync_labels_with_gitea_token(client):
# ══════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════
def test_commits_no_auth(client): def test_commits_no_auth(client):
"""GET /api/gitea/projects/owner/repo/commits — 401 without Gitea linked.""" """GET /api/gitea/projects/owner/repo/commits — 401 without Gitea token."""
resp = client.get("/api/gitea/projects/owner/repo/commits") resp = client.get("/api/gitea/projects/owner/repo/commits")
assert resp.status_code == 401 assert resp.status_code == 401 # no Gitea token → 401
def test_commits_with_path(client): def test_commits_with_path(client):
@@ -1164,15 +1164,15 @@ def test_commits_with_path(client):
def test_commits_empty_path(client): def test_commits_empty_path(client):
"""GET commits without path param — tests default empty path.""" """GET commits without path param — 401 without Gitea token."""
resp = client.get("/api/gitea/projects/owner/repo/commits?path=") resp = client.get("/api/gitea/projects/owner/repo/commits?path=")
assert resp.status_code == 401 # no auth assert resp.status_code == 401 # no Gitea token → 401
def test_commits_special_chars_path(client): def test_commits_special_chars_path(client):
"""GET commits with special characters in path.""" """GET commits with special characters in path — 401 without Gitea token."""
resp = client.get("/api/gitea/projects/owner/repo/commits?path=src/components/Header%20Component.tsx") resp = client.get("/api/gitea/projects/owner/repo/commits?path=src/components/Header%20Component.tsx")
assert resp.status_code == 401 # no auth, but shouldn't crash assert resp.status_code == 401 # no Gitea token → 401
# ══════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════
@@ -1180,11 +1180,11 @@ def test_commits_special_chars_path(client):
# ══════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════
def test_file_create_no_auth(client): def test_file_create_no_auth(client):
"""PUT /api/gitea/projects/owner/repo/file — 401 without Gitea linked.""" """PUT /api/gitea/projects/owner/repo/file — 401 without Gitea token."""
resp = client.put("/api/gitea/projects/owner/repo/file", json={ resp = client.put("/api/gitea/projects/owner/repo/file", json={
"path": "test.md", "content": "# Hello", "message": "test" "path": "test.md", "content": "# Hello", "message": "test"
}) })
assert resp.status_code == 401 assert resp.status_code == 401 # no Gitea token → 401
def test_file_create_missing_path(client): def test_file_create_missing_path(client):
@@ -1795,39 +1795,39 @@ def test_auth_user_unauthenticated(client):
# ══════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════
def test_gitea_labels_no_auth(client): def test_gitea_labels_no_auth(client):
"""GET /api/gitea/projects/owner/repo/labels — 401 without Gitea linked.""" """GET /api/gitea/projects/owner/repo/labels — 401 without Gitea token."""
resp = client.get("/api/gitea/projects/owner/repo/labels") resp = client.get("/api/gitea/projects/owner/repo/labels")
assert resp.status_code == 401 assert resp.status_code == 401 # no Gitea token → 401
def test_gitea_tree_no_auth(client): def test_gitea_tree_no_auth(client):
"""GET /api/gitea/projects/owner/repo/tree — 401 without Gitea linked.""" """GET /api/gitea/projects/owner/repo/tree — 401 without Gitea token."""
resp = client.get("/api/gitea/projects/owner/repo/tree") resp = client.get("/api/gitea/projects/owner/repo/tree")
assert resp.status_code == 401 assert resp.status_code == 401 # no Gitea token → 401
def test_gitea_file_get_no_auth(client): def test_gitea_file_get_no_auth(client):
"""GET /api/gitea/projects/owner/repo/file?path=x — 401 without Gitea linked.""" """GET /api/gitea/projects/owner/repo/file?path=x — 401 without Gitea token."""
resp = client.get("/api/gitea/projects/owner/repo/file?path=README.md") resp = client.get("/api/gitea/projects/owner/repo/file?path=README.md")
assert resp.status_code == 401 assert resp.status_code == 401 # no Gitea token → 401
def test_gitea_orgs_no_auth(client): def test_gitea_orgs_no_auth(client):
"""GET /api/gitea/orgs — 401 without Gitea linked.""" """GET /api/gitea/orgs — 401 without Gitea token."""
resp = client.get("/api/gitea/orgs") resp = client.get("/api/gitea/orgs")
assert resp.status_code == 401 assert resp.status_code == 401 # no Gitea token → 401
def test_gitea_projects_no_auth(client): def test_gitea_projects_no_auth(client):
"""GET /api/gitea/projects — 401 without Gitea linked.""" """GET /api/gitea/projects — 401 without Gitea token."""
resp = client.get("/api/gitea/projects") resp = client.get("/api/gitea/projects")
assert resp.status_code == 401 assert resp.status_code == 401 # no Gitea token → 401
def test_gitea_file_delete_no_auth(client): def test_gitea_file_delete_no_auth(client):
"""DELETE /api/gitea/projects/owner/repo/file — 401 without Gitea linked.""" """DELETE /api/gitea/projects/owner/repo/file — 401 without Gitea token."""
resp = client.delete("/api/gitea/projects/owner/repo/file?path=test.md&sha=abc") resp = client.delete("/api/gitea/projects/owner/repo/file?path=test.md&sha=abc")
assert resp.status_code in (400, 401) assert resp.status_code == 401 # no Gitea token → 401
def test_gitea_file_delete_missing_params(client): def test_gitea_file_delete_missing_params(client):