diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 20f2168..83d6e1b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,7 +1,7 @@ -# Architecture FlowDeck — Document Complet v2.0 +# Architecture FlowDeck — Document Complet v3.0 -> **Version:** 2.0 · **Date:** 2026-07-10 · **Auteur:** Hermes-Deepin -> **Cible:** Clone Notion intégré à Gitea avec support multi-utilisateurs +> **Version:** 3.0 · **Date:** 2026-07-14 · **Auteur:** Hermes-Deepin +> **Cible:** Clone Notion intégré à Gitea/GitHub — multi-comptes, multi-intégrations --- @@ -800,6 +800,257 @@ User authorize ──→ GET /auth/callback?code=xxx --- +## 6.5 Modèle de comptes & intégrations (v4.0) + +### 6.5.1 Types de comptes + +FlowDeck supporte 3 types de comptes, déterminés par `auth_method` dans la table `users` : + +``` +┌──────────────┬──────────────────┬─────────────────────────────────────┐ +│ auth_method │ Login via │ Identité dans le sidebar │ +├──────────────┼──────────────────┼─────────────────────────────────────┤ +│ local │ email + password │ Nom local (ex: "Draco") │ +│ gitea │ OAuth2 Gitea │ Nom Gitea + badge 🦎 Gitea │ +│ github │ OAuth2 GitHub │ Nom GitHub + badge 🐙 GitHub │ +└──────────────┴──────────────────┴─────────────────────────────────────┘ +``` + +**Règle fondamentale** : Un compte local avec intégration(s) reste un compte LOCAL. +Le badge OAuth (🦎/🐙) n'apparaît QUE pour les comptes dont `auth_method` est le provider. + +``` +Compte LOCAL avec intégrations : +┌──────────────────────────────────┐ +│ [D] Draco │ ← Nom local, PAS de badge +│ bruno.charest@gmail.com │ +└──────────────────────────────────┘ + +Compte GITEA (auth_method=gitea) : +┌──────────────────────────────────┐ +│ [B] bruno [🦎 Gitea] │ ← Badge visible +│ bruno@dracodev.net │ +└──────────────────────────────────┘ +``` + +### 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/ │ +│ · [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:/`). + +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=` + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 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/ + +Phase 4 — Library + 1. Page /library avec tabs + 2. Filtrage par source (local/gitea/github) + 3. Recents alimentés automatiquement + +Phase 5 — Intégrations + 1. GitHub OAuth déjà fait (providers.py) + 2. Matrice de déconnexion respectée + 3. Compte local peut connecter/déconnecter Gitea+GitHub +``` + +--- + ## 7. Intégration Gitea ### 7.1 Flux de données diff --git a/ROADMAP.md b/ROADMAP.md index e98f6a0..b26daea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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/` 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) > **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. diff --git a/app/db.py b/app/db.py index ca466ec..17612b1 100644 --- a/app/db.py +++ b/app/db.py @@ -410,6 +410,58 @@ def init_db(): """) 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 def get_conn(): diff --git a/app/main.py b/app/main.py index c59782d..16db288 100644 --- a/app/main.py +++ b/app/main.py @@ -13,8 +13,9 @@ from app.config import settings from app.db import init_db from app.middleware.csrf import CSRFMiddleware 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.github_routes import router as github_router from app.services.gitea_client import gitea from app.services.webhook_outbound import init_webhook_tables @@ -41,7 +42,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="2.4.6", + version="2.5.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, @@ -62,9 +63,12 @@ app.include_router(webhooks.router) app.include_router(collections.router) app.include_router(my_tasks.router) app.include_router(workspace.router) +app.include_router(library.router) app.include_router(admin.router) app.include_router(gitea_router) +app.include_router(github_router) app.include_router(public_api.router) +app.include_router(sharing.router) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index ea280cd..13b22e5 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware): """ 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): # Webhook receiver, OAuth callback, and internal API are exempt diff --git a/app/routers/auth.py b/app/routers/auth.py index efe77fa..2019c4f 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -79,7 +79,7 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont, let mode='login'; function switchTab(t){mode=t;document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));document.getElementById('tab-'+t).classList.add('active');document.getElementById('name-group').style.display=t==='register'?'block':'none';document.getElementById('submit-btn').textContent=t==='register'?'Register':'Login';document.getElementById('error-msg').style.display='none';document.getElementById('success-msg').style.display='none';var lbl=t==='register'?'Register':'Login';var e1=document.getElementById('gitea-btn-label');var e2=document.getElementById('github-btn-label');if(e1)e1.textContent=lbl;if(e2)e2.textContent=lbl;} function togglePassword(){var pw=document.getElementById('password');var btn=pw.parentElement.querySelector('.pw-toggle');if(pw.type==='password'){pw.type='text';btn.textContent='🙈';}else{pw.type='password';btn.textContent='👁';}} -async function handleLogin(e){e.preventDefault();const email=document.getElementById('email').value;const password=document.getElementById('password').value;const name=document.getElementById('name').value;const url=mode==='register'?'/auth/register':'/auth/local-login';const body={email,password};if(mode==='register')body.name=name;try{const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok){document.getElementById('error-msg').textContent=d.error||'Error';document.getElementById('error-msg').style.display='block';return;}if(d.status==='ok'){window.location='/';}}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';}} """ @@ -117,11 +117,13 @@ async def login(request: Request, provider: str = Query("gitea")): request.session["oauth_provider"] = provider # Link mode: connect OAuth to current local account instead of creating new user mode = request.query_params.get("mode", "") + # Encode auth mode in state to survive session loss during OAuth redirect + signed_state = f"{state}:{mode}" if mode else state request.session["oauth_mode"] = mode # Dynamic redirect URI based on incoming Host header host = request.headers.get("host", "localhost:8080") dynamic_redirect_uri = f"http://{host}/auth/callback" - auth_url = oauth_provider.get_authorize_url(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) @@ -232,11 +234,22 @@ async def callback( state: str = Query(...), ): """Handle OAuth2 callback from Gitea.""" - # Validate state + # Recover mode from state suffix (state:mode format), then validate expected_state = request.session.get("oauth_state", "") 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("

Invalid state

", status_code=400) + if not expected_state and not auth_mode: + return HTMLResponse("

Session expired — please try connecting again

", status_code=400) from app.auth.providers import get_provider oauth_provider = get_provider(provider_name) @@ -282,11 +295,11 @@ async def callback( login_id = f"{provider_name}_{oauth_user['login']}" with get_conn() as conn: conn.execute( - """INSERT INTO users (login, full_name, email, avatar_url) - VALUES (?, ?, ?, ?) + """INSERT INTO users (login, full_name, email, avatar_url, auth_method) + VALUES (?, ?, ?, ?, ?) ON CONFLICT(login) - DO UPDATE SET full_name=excluded.full_name, email=excluded.email, avatar_url=excluded.avatar_url""", - (login_id, oauth_user.get("full_name", ""), oauth_user.get("email", ""), oauth_user.get("avatar_url", "")), + DO UPDATE SET full_name=excluded.full_name, email=excluded.email, avatar_url=excluded.avatar_url, auth_method=excluded.auth_method""", + (login_id, oauth_user.get("full_name", ""), oauth_user.get("email", ""), oauth_user.get("avatar_url", ""), provider_name), ) conn.commit() # Store OAuth token diff --git a/app/routers/board.py b/app/routers/board.py index 3ce6e3c..9b629ae 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -5,7 +5,7 @@ import json import logging from fastapi import APIRouter, Request, HTTPException, Query -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from app.db import get_conn from app.services.gitea_client import gitea @@ -172,7 +172,7 @@ def _load_workspace_pages(ws_cookie: str) -> list: ws_id = int(ws_cookie) with get_conn() as conn: rows = conn.execute( - "SELECT id, title, parent_section, content_format FROM pages WHERE workspace_id=? AND parent_id IS NULL 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,), ).fetchall() items = [] @@ -197,7 +197,7 @@ def _load_children(parent_id: int) -> list: """Recursively load children of a page.""" with get_conn() as conn: rows = conn.execute( - "SELECT id, title, parent_section, content_format FROM pages WHERE parent_id=? 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,), ).fetchall() children = [] @@ -225,14 +225,46 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: from app.routers.dashboard import WORKSPACE_COOKIE ws_cookie = request.cookies.get(WORKSPACE_COOKIE, "") 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: + wsi = int(ws_cookie) with get_conn() as conn: - ws = conn.execute("SELECT name FROM workspaces WHERE id=?", (int(ws_cookie),)).fetchone() - if ws: - active_ws_name = ws["name"] + row = conn.execute( + "SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?", + (wsi, user["id"]) + ).fetchone() + if row: + active_ws_name = row["name"] + workspace_pages = _load_workspace_pages(ws_cookie) except (ValueError, Exception): pass + local_ws_id = 0 recent = [] if owner and repo: view_map = { @@ -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" ).fetchall() shared_pages = [] + published_pages = [] for r in shared_rows: - shared_pages.append({ + page_entry = { "id": f"page/{r['id']}", "db_id": r["id"], "name": r["title"] or "New page", @@ -301,15 +334,49 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: "depth": 0, "has_children": False, "children": [], - }) + } + if r["published"]: + published_pages.append(page_entry) + else: + shared_pages.append(page_entry) + + # Auth method & OAuth badge data + auth_method = "local" + gitea_linked = False + github_linked = False + if user and user.get("id"): + try: + with get_conn() as conn: + am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone() + if am_row and am_row["auth_method"]: + auth_method = am_row["auth_method"] + tokens = conn.execute( + "SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],) + ).fetchall() + for t in tokens: + if t["provider"] == "gitea": + gitea_linked = True + elif t["provider"] == "github": + github_linked = True + except Exception: + pass return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B", "active_ws_name": active_ws_name, - "workspace_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", "recent_pages": recent, "private_pages": private_items, "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 = ""): @@ -790,42 +857,49 @@ async def save_page_blocks(request: Request, page_id: int): @router.put("/api/pages/{page_id}/move") -async def move_page(request: Request, page_id: int, - new_parent_id: int = Query(default=0), - new_order: int = Query(default=0)): - """Move/reorder a page in the tree (drag & drop). - new_parent_id=0 means move to root level. - new_order=0 means append to end (or set explicit position).""" +async def move_page(request: Request, page_id: int): + """Move a page to another workspace or reorder within tree. + + Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int } + - workspace_id: move page to a different workspace + - parent_id: change parent (0 = root level) + - new_order: position among siblings (0 = append) + """ + try: + body = await request.json() + except Exception: + body = {} + new_ws_id = body.get("workspace_id") + new_parent_id = body.get("parent_id", 0) + new_order = body.get("new_order", 0) + with get_conn() as conn: - # Verify page exists - row = conn.execute("SELECT id, workspace FROM pages WHERE id=?", (page_id,)).fetchone() + row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone() if not row: raise HTTPException(404, "Page not found") - # Update parent (None for root level) - conn.execute( - "UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", - (new_parent_id if new_parent_id > 0 else None, page_id), - ) - # Update sort order - conn.execute( - "UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", - (new_order, page_id), - ) - # Re-index siblings to ensure no gaps - siblings = conn.execute( - "SELECT id, sort_order FROM pages WHERE workspace=? AND parent_id IS ? AND id != ? ORDER BY sort_order ASC", - (row["workspace"], new_parent_id if new_parent_id > 0 else None, page_id), - ).fetchall() - # Recompute sort_order for all siblings after the insertion point - insert_point = new_order - reorder = [] - for sib in siblings: - if sib["sort_order"] >= insert_point: - reorder.append(sib["id"]) - for i, sid in enumerate(sorted(reorder, key=lambda x: next(s["sort_order"] for s in siblings if s["id"] == x))): - conn.execute("UPDATE pages SET sort_order=? WHERE id=?", (insert_point + i + 1, sid)) + + if new_ws_id: + # Move to a different workspace: get the workspace name + ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone() + if not ws_row: + return JSONResponse({"status": "error", "detail": "Workspace not found"}, status_code=404) + conn.execute( + "UPDATE pages SET workspace_id=?, workspace=?, parent_id=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (new_ws_id, ws_row["name"], page_id), + ) + else: + # Reorder within same workspace + conn.execute( + "UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (new_parent_id if new_parent_id > 0 else None, page_id), + ) + conn.execute( + "UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (new_order, page_id), + ) + conn.commit() - return {"status": "ok", "id": page_id, "parent_id": new_parent_id, "order": new_order} + return {"status": "ok", "id": page_id} @router.delete("/api/pages/{page_id}") diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 4181c35..5529d27 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -6,7 +6,7 @@ import logging from fastapi import APIRouter, Request, Query 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.db import get_conn @@ -31,7 +31,7 @@ def _get_user_or_redirect(request: Request): 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", "")) ws = user.get("login", "Bruno") if user else "Bruno" initial = ws[0].upper() if ws else "B" @@ -64,46 +64,111 @@ def _sidebar_data(request: Request, repos: list[dict]) -> dict: "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 ws_cookie = request.cookies.get("flowdeck_workspace", "") active_ws_name = "Workspace" workspace_pages = [] 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 + # BUT keep local workspace pages — they go in the "Private" section 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 - elif ws_cookie: - workspace_pages = _load_workspace_pages(ws_cookie) + # workspace_pages stays as-is (local tree, loaded above) + elif include_workspace and ws_cookie and user: + 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: with get_conn() as conn: - ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (int(ws_cookie),)).fetchone() - if ws_row: - active_ws_name = ws_row["name"] - except (ValueError, Exception): + 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 + + # 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 return { "workspace_name": ws, "workspace_initial": initial, "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_owner": gitea_owner, + "gitea_repo": gitea_repo, + "local_ws_id": local_ws_id, + "workspace_pages": workspace_pages, "current_page": "Dashboard", "last_edited": "now", "recent_pages": recent_pages, - "private_pages": [], + "private_pages": private_pages, "favorite_pages": [], "shared_pages": [], + "published_pages": [], "user": user, "avatar_url": avatar_url, "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) 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. - - La sidebar garde exactement la même arborescence que dans le workspace. - Seul le contenu principal change pour afficher les pages triées par catégorie. + """Library page — tabbed view (recents, favorites, shared, published, private, workspace). + + Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints. """ from jinja2 import Environment, FileSystemLoader from app.routers.board import _sidebar_data as board_sidebar env = Environment(loader=FileSystemLoader("app/templates")) - sidebar = board_sidebar(request, owner, repo) # Sidebar intacte, inchangée - - 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 + sidebar = board_sidebar(request, owner, repo) template = env.get_template("library.html") return template.render(**sidebar) @@ -367,6 +371,16 @@ async def dashboard( except Exception: pass 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) try: 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) async def gitea_workspace_page(request: Request): """Gitea workspace — browse repo files.""" + import json from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, []) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: 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") - 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") 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 = [] with get_conn() as conn: 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"}) gitea_repos = [] - try: - repos = await gitea.get_user_repos(page=1, limit=50) - for repo in repos: - gitea_repos.append({ - "id": str(repo.get("id", "")), - "name": repo.get("name", ""), - "full_name": repo.get("full_name", ""), - "description": repo.get("description", ""), - "html_url": repo.get("html_url", ""), - "language": repo.get("language", ""), - "forge": "gitea", - }) - except Exception: - pass + # Use per-user token if available, otherwise return empty + user_gitea = get_user_gitea_client(request) if user else None + if user_gitea: + try: + repos = await user_gitea.get_user_repos(page=1, limit=50) + for repo in repos: + gitea_repos.append({ + "id": str(repo.get("id", "")), + "name": repo.get("name", ""), + "full_name": repo.get("full_name", ""), + "description": repo.get("description", ""), + "html_url": repo.get("html_url", ""), + "language": repo.get("language", ""), + "forge": "gitea", + }) + except Exception: + pass return {"builtin": builtin, "gitea": gitea_repos, "github": []} @@ -549,9 +609,13 @@ async def local_workspace_page(request: Request, folder: int = None): if not user: 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 + # 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 breadcrumb = [] 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. 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 if not ws_id: return {"tree": [], "breadcrumb": []} @@ -744,14 +808,16 @@ async def create_local_workspace_item(request: Request): name = body.get("name", "Untitled").strip() item_type = body.get("type", "page") parent_id = body.get("parent_id") - ws = _get_active_workspace(request) - ws_id = ws["id"] if ws else None + explicit_ws_id = body.get("workspace_id") + 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' with get_conn() as conn: cursor = conn.execute( "INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) " - "VALUES ('', ?, ?, '', 'blocks', ?, ?)", - (ws_id, name, section, parent_id), + "VALUES (?, ?, ?, '', 'blocks', ?, ?)", + (ws_key, ws_id, name, section, parent_id) ) conn.commit() 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}") async def delete_local_workspace_item(request: Request, item_id: int): - """Delete a file (and its children). Also removes uploaded file from disk.""" - import json - from pathlib import Path + """Soft-delete a file/folder (sets deleted_at).""" + from datetime import datetime with get_conn() as conn: - # Check if it's a file-type page with a disk file - row = conn.execute("SELECT content, content_format FROM pages WHERE id=?", (item_id,)).fetchone() - if row and row["content_format"] == "file": - try: - meta = json.loads(row["content"]) - fp = Path(f"/data/{meta['file_path']}") - if fp.exists(): - fp.unlink() - except Exception: - pass - conn.execute("DELETE FROM pages WHERE id=? OR parent_id=?", (item_id, item_id)) + conn.execute( + "UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL", + (datetime.utcnow().isoformat(), item_id), + ) + conn.commit() + return {"status": "ok"} + + +@router.post("/api/local-workspace/items/{item_id:int}/restore") +async def restore_local_workspace_item(request: Request, item_id: int): + """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() return {"status": "ok"} @@ -833,7 +903,7 @@ async def upload_local_workspace_file(request: Request): import json 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 if not ws_id: return JSONResponse({"error": "No active workspace"}, status_code=400) @@ -906,7 +976,7 @@ async def upload_local_workspace_folder(request: Request): import json 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 if not ws_id: 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" -def _get_active_workspace(request: Request) -> dict | None: - """Get the active workspace ID from the cookie, or first workspace, or None.""" +def _get_active_workspace(request: Request, user_id: int = None) -> dict | 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) if ws_id: try: 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: - 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): pass - # Fallback: first workspace - with get_conn() as conn: - ws = conn.execute("SELECT * FROM workspaces ORDER BY id LIMIT 1").fetchone() - if ws: - return dict(ws) + # Fallback: first workspace owned by this user + if user_id: + with get_conn() as conn: + ws = conn.execute( + "SELECT * FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1", + (user_id,) + ).fetchone() + if ws: + return dict(ws) return None @@ -1033,7 +1111,7 @@ async def workspaces_page(request: Request): """Workspaces list page.""" from jinja2 import Environment, FileSystemLoader 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", "")) if not user: return RedirectResponse("/auth/login?provider=local", status_code=302) @@ -1057,7 +1135,7 @@ async def list_workspaces(request: Request): for r in rows: d = dict(r) 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} @@ -1276,7 +1354,7 @@ async def list_all_tags_global(request: Request): @router.get("/api/local-workspace/tags") async def list_tags(request: Request): """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 uid = _get_user_id(request) 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") async def search_by_tags(request: Request, tags: str = ""): """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 if not ws_id: 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.set_cookie("flowdeck_session", new_session, httponly=True, max_age=86400 * 7, samesite="lax", path="/") 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('') + + # Gitea workspace — no server-side tree, loaded client-side + if ws_cookie.startswith("gitea:"): + return HTMLResponse('') + + 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( + '' + ) + + pages = _load_workspace_pages(ws_cookie) + if not pages: + return HTMLResponse( + '' + ) + + # 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( + '' + ) diff --git a/app/routers/gitea.py b/app/routers/gitea.py index 764f1b3..86c30a9 100644 --- a/app/routers/gitea.py +++ b/app/routers/gitea.py @@ -6,7 +6,12 @@ router = APIRouter(tags=["gitea"], prefix="/api/gitea") 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 client = get_user_gitea_client(request) if not client: diff --git a/app/routers/github_routes.py b/app/routers/github_routes.py new file mode 100644 index 0000000..2b3bc28 --- /dev/null +++ b/app/routers/github_routes.py @@ -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"} diff --git a/app/routers/library.py b/app/routers/library.py new file mode 100644 index 0000000..8ac8b6b --- /dev/null +++ b/app/routers/library.py @@ -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} diff --git a/app/routers/sharing.py b/app/routers/sharing.py new file mode 100644 index 0000000..c545a18 --- /dev/null +++ b/app/routers/sharing.py @@ -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(), + } diff --git a/app/services/gitea_client.py b/app/services/gitea_client.py index 71c6c73..7e5f7b9 100644 --- a/app/services/gitea_client.py +++ b/app/services/gitea_client.py @@ -420,7 +420,7 @@ def get_user_gitea_client(request): from app.db import get_conn with get_conn() as conn: 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"],), ).fetchone() if not row: diff --git a/app/templates/_header.html b/app/templates/_header.html new file mode 100644 index 0000000..2ac9cc5 --- /dev/null +++ b/app/templates/_header.html @@ -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 +#} +
+ {% if not hide_hamburger %} + + {% endif %} + +
+ {% if page_icon %} + {{ page_icon }} + {% endif %} + + {% if breadcrumb_items %} + {% for item in breadcrumb_items %} + {% if item.url and not loop.last %} + {{ item.label }} + / + {% elif loop.last %} + {{ item.label }} + {% else %} + {{ item.label }} + / + {% endif %} + {% endfor %} + {% else %} + {{ page_title|default('FlowDeck') }} + {% endif %} +
+ +
+ {{ right_actions|safe if right_actions else '' }} +
+
diff --git a/app/templates/_workspace_tree_macro.html b/app/templates/_workspace_tree_macro.html new file mode 100644 index 0000000..d49c463 --- /dev/null +++ b/app/templates/_workspace_tree_macro.html @@ -0,0 +1,46 @@ +{% macro render_workspace_tree(pages, depth=0) %} + {% for page in pages %} + {% set safe_name = page.name | replace("'", "\\'") %} + + {% if page.children %} +
  • +
      + {{ render_workspace_tree(page.children, depth + 1) }} +
    +
  • + {% endif %} + {% endfor %} +{% endmacro %} diff --git a/app/templates/accounts.html b/app/templates/accounts.html index b76fd1b..f8650c6 100644 --- a/app/templates/accounts.html +++ b/app/templates/accounts.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block page_icon %}👤{% endblock %} {% block page_title %}Accounts{% endblock %} +{% block title_prefix %}Accounts{% endblock %} {% block content %}
    diff --git a/app/templates/base.html b/app/templates/base.html index 326c932..13590f9 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -3,7 +3,7 @@ - FlowDeck + FlowDeck — {% block title_prefix %}Home{% endblock %} @@ -71,6 +71,11 @@
    {% if not avatar_url %}{{ user.full_name[:1].upper() if user and user.get('full_name') else workspaceInitial }}{% endif %}
    {{ user.full_name or user.login or workspaceName }} + {% if auth_method != 'local' %} + + {% if auth_method == 'gitea' %}🦎 Gitea{% elif auth_method == 'github' %}🐙 GitHub{% else %}🔑 {{ auth_method|title }}{% endif %} + + {% endif %}
    ▾
    + + + + {% if auth_method != 'local' or '/' in workspace_key %} + {% endif %} @@ -339,47 +349,7 @@ {% block topbar %} -
    - - -
    -
    - - / - -
    -
    - -
    -
    - - -
    -
    - 🔒 Private - ▾ -
    -
    - -
    - Edited - - - - - - -
    -
    + {% include '_header.html' %} {% endblock %} @@ -521,6 +491,9 @@ function appState() { return { + init() { + window.appState = this; + }, sidebarCollapsed: false, mobileSidebarOpen: false, showInvite: true, @@ -543,6 +516,11 @@ pageTitle: '{% block page_title %}FlowDeck{% endblock %}', lastEdited: '{{ last_edited }}', 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', contextMenu: { visible: false, x: 0, y: 0, pageId: null, pageName: '' }, contextActions: {}, @@ -598,17 +576,26 @@ case 'rename': var newName = prompt('Rename:', name); if (newName && newName.trim()) { + var self = this; fetch('/api/local-workspace/items/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() }, 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; case 'delete': if (!id) break; if (!confirm('Delete "' + name + '"?')) break; - this.deleteWorkspacePage(id); + this.deleteWorkspacePage(id, true); break; } }, @@ -618,7 +605,7 @@ // ── Sections collapsible ── 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 { var saved = JSON.parse(localStorage.getItem('fd_sections') || '{}'); return Object.assign(def, saved); @@ -838,9 +825,68 @@ .then(data => { window.location.href = `/pages/${data.id}`; }) .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 = ''; + return; + } + this.giteaTree.forEach(function(item) { + var li = document.createElement('li'); + li.className = 'sidebar-item'; + var icon = item.type === 'dir' ? '📁' : '📄'; + li.innerHTML = '' + icon + '' + item.name + ''; + 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 = ''; + } + }); + }, newPageInWorkspace() { const name = prompt('File name:'); 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', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() }, @@ -853,26 +899,55 @@ newFolderInWorkspace() { const name = prompt('Folder name:'); 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', { - method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() }, body: JSON.stringify({ name: name.trim(), type: 'folder' }) }) .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'); }); }, - deleteWorkspacePage(pageId) { - if (!confirm('Delete this page?')) return; + deleteWorkspacePage(pageId, skipConfirm) { + if (!skipConfirm && !confirm('Delete this page?')) return; fetch(`/api/local-workspace/items/${pageId}`, { method: 'DELETE', headers: { 'X-CSRF-Token': this.getCsrfToken() } }) .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'); }); }, + 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
  • ) + 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 ── dragOverId: null, dragStart(ev, id) { @@ -898,7 +973,15 @@ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() }, 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'); }); }, @@ -1019,6 +1102,14 @@ setTimeout(function() { var active = document.querySelector('.sidebar-item.active'); 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); }); @@ -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) { var tag = (document.activeElement && document.activeElement.tagName) || ''; var editable = tag === 'INPUT' || tag === 'TEXTAREA' || (document.activeElement && document.activeElement.isContentEditable); @@ -1124,5 +1226,25 @@ {% block scripts %}{% endblock %} + + + diff --git a/app/templates/board.html b/app/templates/board.html index 52d2246..803ed44 100644 --- a/app/templates/board.html +++ b/app/templates/board.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block page_icon %}📁{% endblock %} {% block page_title %}Kanban board new{% endblock %} +{% block title_prefix %}Kanban board new{% endblock %} {% block content %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 090cfaf..28b0884 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block page_icon %}🏠{% endblock %} {% block page_title %}Home{% endblock %} +{% block title_prefix %}Home{% endblock %} {% block content %}
    diff --git a/app/templates/gitea_workspace.html b/app/templates/gitea_workspace.html index 8c56aff..f4cac8c 100644 --- a/app/templates/gitea_workspace.html +++ b/app/templates/gitea_workspace.html @@ -1,24 +1,13 @@ {% 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 topbar %} -
    - -
    - Home - {{ owner }}/{{ repo }} -
    -
    - - - - - -
    -
    +{% set breadcrumb_items = [{"label": "Home", "url": "/workspaces"}, {"label": owner ~ "/" ~ repo, "url": None}] %} +{% set page_icon = "🔗" %} +{% set right_actions = '⚙' %} +{% include '_header.html' %} {% endblock %} {% 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;} -
    - -
    -
    - - - - - -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    - - - - - - - - - - - - -
    - +
    + +
    +
    + + + + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    + + + + + + + + + + + + +
    + {% endblock %} diff --git a/app/templates/library.html b/app/templates/library.html index 1707528..3d4b224 100644 --- a/app/templates/library.html +++ b/app/templates/library.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block page_icon %}📚{% endblock %} {% block page_title %}Library{% endblock %} +{% block title_prefix %}Library{% endblock %} {% block content %}
    @@ -10,122 +11,341 @@
    - -
    +
    -
    - - {% if lib_has_favorites %} - - {% endif %} - {% if lib_has_shared %} - - {% endif %} - -
    - - -
    -
    - - - - -
    - - -
    - -
    - 📄 Page name - 👤 Created by - ↗ Source - 🕒 Last edited time - 🕒 Last visited time + +
    + + + + + +
    - {% macro page_row(page) %} - - - {{ page.icon }} - {{ page.name }} - - 👤 {{ page.created_by or 'You' }} - {{ page.source or '🔒 Private' }} - {{ page.last_edited }} - {{ page.last_visited or page.last_edited }} - - {% endmacro %} - - -
    - {% for page in lib_recent %}{{ page_row(page) }}{% endfor %} - {% if not lib_recent %} -
    -
    🕒
    -

    No recent pages

    + +
    +
    + + + +
    - {% endif %} +
    + +
    - -
    - {% for page in lib_favorites %}{{ page_row(page) }}{% endfor %} - {% if not lib_favorites %} -
    -
    ⭐
    -

    No favorites

    -

    Right-click a page in the sidebar and select "Add to Favorites".

    -
    - {% endif %} + +
    +
    +

    Loading…

    - -
    - {% for page in lib_shared %}{{ page_row(page) }}{% endfor %} - {% if not lib_shared %} -
    -
    👥
    -

    No shared pages

    -

    Shared pages from your team will appear here.

    -
    - {% endif %} + +
    +
    - -
    - {% for page in lib_private %}{{ page_row(page) }}{% endfor %} - {% if not lib_private %} -
    -
    🔒
    -

    No private pages

    -

    Create a new page with the + button in the sidebar.

    -
    - {% endif %} + +
    +
    +

    +

    -
    +
    {% endblock %} {% block scripts %} + + {% endblock %} diff --git a/app/templates/local_workspace.html b/app/templates/local_workspace.html index 109b0d5..b281808 100644 --- a/app/templates/local_workspace.html +++ b/app/templates/local_workspace.html @@ -1,22 +1,12 @@ {% extends "base.html" %} {% block page_title %}{{ workspace_name }}{% endblock %} +{% block title_prefix %}{{ workspace_name }}{% endblock %} {% block page_icon %}📁{% endblock %} {% block topbar %} -
    - -
    - - {% if breadcrumb %} - {% for b in breadcrumb %} - / {{ b.name }} - {% endfor %} - {% endif %} - -
    -
    +{% set page_icon = "📁" %} +{% set page_title = workspace_name %} +{% include '_header.html' %} {% endblock %} {% block content %} @@ -566,11 +556,20 @@ window._wsInitData = (function() { if (ids.length === 0) return; if (!confirm('Delete ' + ids.length + ' selected items?')) return; var self = this; + // Soft-delete all selected items for (var i=0; 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) { for (var i=0; i diff --git a/app/templates/page_editor.html b/app/templates/page_editor.html index cce88c9..5d02c08 100644 --- a/app/templates/page_editor.html +++ b/app/templates/page_editor.html @@ -1,41 +1,12 @@ {% extends "base.html" %} {% block page_icon %}📄{% endblock %} {% block page_title %}{{ page.title }}{% endblock %} {% block topbar %} -
    - -
    - {{ page.title }} -
    -
    +{% set page_icon = "📄" %} +{% set page_title = page.title %} +{% set right_actions = 'Edited ▾' %} +{% include '_header.html' %} {% endblock %} {% block content %}
    - -
    -
    - Edited just now - - - - +
    -
    @@ -268,7 +239,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}

    @@ -354,7 +325,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %} @@ -362,6 +333,18 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
    + +
    +
    Edited
    + +
    +
    -
    -
    + + +
    +

    Move to workspace

    +
    Loading...
    +
    + +
    + No workspaces available +
    +
    +
    @@ -588,8 +593,11 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %} function editorState(){ return { pid:null,pageTitle:'',blocks:[],dirty:false,saving:false,lastSaved:'',st:null, + updatedAt:'{{ page.get("updated_at", "") }}', fmt:{open:false,top:0,left:0,idx:-1}, shareOpen:false,moreOpen:false,gsMoreOpen:false,accessMenuOpen:false, + moveOpen:false,moveWorkspaces:[],moveLoading:false, + activityOpen:false,pageCreated:'{{ page.get("created_at", "") }}', shareTab:'share',favorited:false, pagePublished:{{ page_published | tojson }}, generalAccess:'{{ page_share_mode }}',publicPerm:'viewer', @@ -597,6 +605,20 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %} pageUrl:window.location.href,publishedUrl:'', inviteEmail:'',invitePermission:'editor',accessList:[], toastVisible:false,toastMsg:'', + get timeAgo() { + if (!this.updatedAt) return 'just now'; + var diff = Math.floor((Date.now() - new Date(this.updatedAt + 'Z').getTime()) / 1000); + if (diff < 60) return 'just now'; + if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; + if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; + return Math.floor(diff / 86400) + 'd ago'; + }, + formatDate(d) { + if (!d) return ''; + var dt = new Date(d + 'Z'); + return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + + ' at ' + dt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); + }, init(){ _bid=Math.floor(Math.random()*10000); const dataEl=document.getElementById('page-data'); @@ -657,11 +679,108 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %} clearTimeout(this._toastTimer); this._toastTimer = setTimeout(() => { this.toastVisible = false; }, 2500); }, + publishPage() { + var self = this; + var csrf = document.cookie.match(/csrf_token=([^;]+)/); + fetch('/api/pages/' + this.pid + '/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' } + }).then(function(r){ return r.json(); }).then(function(d){ + if (d.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='';}, 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')}§ion=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{window.showToast('Duplicate failed','error');});}, - movePage(){this.moreOpen=false;}, + movePage(){ + this.moreOpen=false; + this.moveOpen = true; + this.moveLoading = true; + var self = this; + fetch('/api/workspaces') + .then(function(r){ return r.json(); }) + .then(function(d){ + self.moveWorkspaces = d.workspaces || []; + self.moveLoading = false; + }) + .catch(function(){ self.moveLoading = false; }); + }, + doMove(wsId) { + this.moveOpen = false; + var csrf = document.cookie.match(/csrf_token=([^;]+)/); + var self = this; + fetch('/api/pages/' + this.pid + '/move', { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' }, + body: JSON.stringify({ workspace_id: wsId }) + }).then(function(r){ return r.json(); }) + .then(function(d){ + if (d.status === 'ok') { + self.showToast('Moved to workspace'); + setTimeout(function(){ window.location.href = '/local-workspace'; }, 800); + } else { + self.showToast(d.detail || 'Move failed'); + } + }) + .catch(function(){ self.showToast('Move failed'); }); + }, deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});}, onKd(e){ @@ -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);}); + 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; + } + } {% endblock %} diff --git a/app/templates/settings.html b/app/templates/settings.html index 545c2a0..ae19065 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -1,16 +1,12 @@ {% extends "base.html" %} {% block page_title %}Settings{% endblock %} +{% block title_prefix %}Settings{% endblock %} {% block page_icon %}⚙️{% endblock %} {% block topbar %} -
    - -
    - ⚙️ Settings -
    -
    +{% set page_icon = "⚙" %} +{% set page_title = "Settings" %} +{% include '_header.html' %} {% endblock %} {% block content %} @@ -80,7 +76,7 @@
    - +
    @@ -318,9 +314,10 @@

    Integrations and feature toggles.

    Integrations

    +
    -
    Gitea
    +
    🦎 Gitea Primary
    Connected to git.dracodev.net Connect your Gitea account to access projects @@ -328,16 +325,24 @@
    ✅ Active - + 🔗 Connect
    +
    -
    GitHub
    -
    Connect your GitHub account
    +
    🐙 GitHub Primary
    +
    + Connected to github.com + Connect your GitHub account to access repositories +
    +
    +
    + ✅ Active + + 🔗 Connect
    -
    Coming soon
    @@ -512,8 +517,10 @@ document.addEventListener('alpine:init', function() { defaultView: localStorage.getItem('fd_default_view') || 'tree', userInfo: {full_name: '{{ user.full_name or "" }}', login: '{{ user.login or "" }}', email: '{{ user.email or "" }}'}, userIsAdmin: {{ 'true' if user.get('is_admin') else 'false' }}, - // Gitea + // Auth & Integrations + authMethod: '{{ auth_method }}', giteaLinked: false, + githubLinked: false, // Theme theme: localStorage.getItem('fd_theme') || 'dark', // Account form @@ -533,6 +540,7 @@ document.addEventListener('alpine:init', function() { async init() { await this.loadTags(); await this.loadGiteaStatus(); + await this.loadGithubStatus(); }, async loadTags() { @@ -771,6 +779,43 @@ document.addEventListener('alpine:init', function() { if (r.ok) { this.giteaLinked = false; } } catch(e) {} }, + async loadGithubStatus() { + try { + var r = await fetch('/api/github/status'); + var d = await r.json(); + this.githubLinked = d.linked || false; + } catch(e) { this.githubLinked = false; } + }, + async disconnectGithub() { + if (!confirm('Disconnect GitHub? You can reconnect anytime.')) return; + try { + var r = await fetch('/api/github/disconnect', { method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken()} }); + if (r.ok) { this.githubLinked = false; } + } catch(e) {} + }, + closeSettings() { + // Refresh parent page after settings changes, then navigate back + if (window.opener && !window.opener.closed) { + window.opener.location.reload(); + window.close(); + return; + } + // If navigated directly to settings, redirect to workspaces (forces refresh) + if (!document.referrer || document.referrer === window.location.href) { + window.location.href = '/workspaces?ts=' + Date.now(); + return; + } + // Go back and force refresh + var prev = document.referrer; + if (prev && prev.includes('/settings')) { + // Came from another settings tab — go back two steps then refresh + history.go(-2); + setTimeout(function() { window.location.reload(); }, 100); + } else { + history.back(); + setTimeout(function() { window.location.reload(); }, 100); + } + }, }; }); }); diff --git a/app/templates/trash.html b/app/templates/trash.html index 0b20ad4..c74d217 100644 --- a/app/templates/trash.html +++ b/app/templates/trash.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block page_icon %}🗑️{% endblock %} {% block page_title %}Trash{% endblock %} +{% block title_prefix %}Trash{% endblock %} {% block content %}
    diff --git a/app/templates/workspace.html b/app/templates/workspace.html index 3ad1773..f4579d7 100644 --- a/app/templates/workspace.html +++ b/app/templates/workspace.html @@ -1,21 +1,13 @@ {% extends "base.html" %} {% block page_title %}Workspace{% endblock %} +{% block title_prefix %}Workspace{% endblock %} {% block page_icon %}🏠{% endblock %} {% block topbar %} -
    - -
    - Workspace - Projects -
    -
    - - -
    -
    +{% set page_icon = "🏠" %} +{% set page_title = "Workspace — Projects" %} +{% set right_actions = '🔑⚙' %} +{% include '_header.html' %} {% endblock %} {% block content %} diff --git a/app/templates/workspaces.html b/app/templates/workspaces.html index 00f5f2e..f18541a 100644 --- a/app/templates/workspaces.html +++ b/app/templates/workspaces.html @@ -1,20 +1,13 @@ {% extends "base.html" %} {% block page_title %}Workspaces{% endblock %} +{% block title_prefix %}Workspaces{% endblock %} {% block page_icon %}🏠{% endblock %} {% block topbar %} -
    - -
    - Home - Workspaces -
    -
    - -
    -
    +{% set breadcrumb_items = [{"label": "Home", "url": "/workspaces"}, {"label": "Workspaces", "url": None}] %} +{% set page_icon = "🏠" %} +{% set right_actions = '🔑⚙' %} +{% include '_header.html' %} {% endblock %} {% block content %} @@ -87,7 +80,7 @@
    Connect your Gitea account to see your projects.
    - 🔗 Connect Gitea + 🔗 Connect Gitea or 📝 Register with Gitea
    diff --git a/static/css/app.css b/static/css/app.css index 7d1eec9..d40e5f6 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -807,6 +807,104 @@ input, select, textarea { font-family: inherit; color: inherit; } 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 { color: var(--text-primary); } diff --git a/tests/test_app.py b/tests/test_app.py index c388aeb..d487117 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -209,7 +209,8 @@ def test_db_tables_exist(client): "collections", "collection_pages", "collection_views", "collection_properties", "workspaces", "workspace_members", "comments", "page_history", "favorites", - "database_templates", "page_templates"} + "database_templates", "page_templates", + "page_shares", "recents"} assert required <= names @@ -1013,10 +1014,9 @@ def test_pwa_manifest(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") - assert resp.status_code == 401 - assert "not linked" in resp.json()["detail"].lower() or "gitea" in resp.json()["detail"].lower() + assert resp.status_code == 401 # no Gitea token → 401 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): - """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") - assert resp.status_code == 401 + assert resp.status_code == 401 # no session → 401 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", cookies={"flowdeck_session": session}, ) - # 401 — requires Gitea OAuth token - assert resp.status_code in (401, 502) + # No Gitea OAuth token → 401 + assert resp.status_code == 401 with get_conn() as conn: conn.execute("DELETE FROM users WHERE id=?", (uid,)) conn.commit() @@ -1134,9 +1134,9 @@ def test_sync_labels_with_gitea_token(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") - assert resp.status_code == 401 + assert resp.status_code == 401 # no Gitea token → 401 def test_commits_with_path(client): @@ -1164,15 +1164,15 @@ def test_commits_with_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=") - assert resp.status_code == 401 # no auth + assert resp.status_code == 401 # no Gitea token → 401 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") - 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): - """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={ "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): @@ -1795,39 +1795,39 @@ def test_auth_user_unauthenticated(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") - assert resp.status_code == 401 + assert resp.status_code == 401 # no Gitea token → 401 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") - assert resp.status_code == 401 + assert resp.status_code == 401 # no Gitea token → 401 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") - assert resp.status_code == 401 + assert resp.status_code == 401 # no Gitea token → 401 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") - assert resp.status_code == 401 + assert resp.status_code == 401 # no Gitea token → 401 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") - assert resp.status_code == 401 + assert resp.status_code == 401 # no Gitea token → 401 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") - assert resp.status_code in (400, 401) + assert resp.status_code == 401 # no Gitea token → 401 def test_gitea_file_delete_missing_params(client):