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

This commit is contained in:
2026-07-17 09:00:09 -04:00
29 changed files with 2655 additions and 730 deletions
+254 -3
View File
@@ -1,7 +1,7 @@
# Architecture FlowDeck — Document Complet v2.0
# Architecture FlowDeck — Document Complet v3.0
> **Version:** 2.0 · **Date:** 2026-07-10 · **Auteur:** Hermes-Deepin
> **Cible:** Clone Notion intégré à Gitea avec support multi-utilisateurs
> **Version:** 3.0 · **Date:** 2026-07-14 · **Auteur:** Hermes-Deepin
> **Cible:** Clone Notion intégré à Gitea/GitHub — multi-comptes, multi-intégrations
---
@@ -800,6 +800,257 @@ User authorize ──→ GET /auth/callback?code=xxx
---
## 6.5 Modèle de comptes & intégrations (v4.0)
### 6.5.1 Types de comptes
FlowDeck supporte 3 types de comptes, déterminés par `auth_method` dans la table `users` :
```
┌──────────────┬──────────────────┬─────────────────────────────────────┐
│ auth_method │ Login via │ Identité dans le sidebar │
├──────────────┼──────────────────┼─────────────────────────────────────┤
│ local │ email + password │ Nom local (ex: "Draco") │
│ gitea │ OAuth2 Gitea │ Nom Gitea + badge 🦎 Gitea │
│ github │ OAuth2 GitHub │ Nom GitHub + badge 🐙 GitHub │
└──────────────┴──────────────────┴─────────────────────────────────────┘
```
**Règle fondamentale** : Un compte local avec intégration(s) reste un compte LOCAL.
Le badge OAuth (🦎/🐙) n'apparaît QUE pour les comptes dont `auth_method` est le provider.
```
Compte LOCAL avec intégrations :
┌──────────────────────────────────┐
│ [D] Draco │ ← Nom local, PAS de badge
│ [email protected] │
└──────────────────────────────────┘
Compte GITEA (auth_method=gitea) :
┌──────────────────────────────────┐
│ [B] bruno [🦎 Gitea] │ ← Badge visible
│ [email protected] │
└──────────────────────────────────┘
```
### 6.5.2 Matrice des intégrations
```
Compte principal Peut ajouter Déconnexion possible
─────────────────────────────────────────────────────────
local Gitea ✅ Oui
local GitHub ✅ Oui
local Gitea + GitHub ✅ Les deux
─────────────────────────────────────────────────────────
gitea GitHub ✅ GitHub ❌ Gitea
github Gitea ✅ Gitea ❌ GitHub
```
**Settings → Integrations** : Affiche toutes les intégrations avec bouton
Disconnect. Le provider principal (auth_method) n'a PAS de bouton Disconnect.
### 6.5.3 Scénarios de workspace
```
SCÉNARIO A : Compte local pur
─────────────────────────────
· Login email+password
· Workspaces locaux uniquement
· Section Private : CACHÉE (tout est déjà local)
· Trash : ✅ (restauration locale)
SCÉNARIO B : Compte local + intégration(s)
──────────────────────────────────────────
· Login email+password
· Workspaces : locaux + Gitea + GitHub
· Section Private : VISIBLE si workspace distant actif
· Private : stockage DB locale, associé user+repo
· Trash : ✅ (local uniquement)
SCÉNARIO C : Compte OAuth pur (gitea ou github)
────────────────────────────────────────────────
· Login via OAuth
· Workspaces : distants du provider principal
· Section Private : VISIBLE (toujours)
· Trash : ❌ (pas de contenu local)
· Intégration secondaire : possible
· Déconnexion provider principal : impossible
```
### 6.5.4 Sidebar — règles d'affichage par section
```
Section Scénario A Scénario B Scénario C
───────────── ────────────── ────────────────────── ──────────────
Recents ✅ (local) ✅ (tout) ✅ (tout)
Favorites ✅ (local) ✅ (tout) ✅ (tout)
Shared ✅ (local) ✅ (tout) ✅ (tout)
Published ✅ (local) ✅ (tout) ✅ (tout)
Agents ✅ ✅ ✅
Private ❌ CACHÉ ✅ si ws distant actif ✅
Workspace ✅ ✅ ✅
Trash ✅ (local) ✅ (local) ❌
```
Chaque section a un bouton `→ Library` qui ouvre :
```
/library?tab=recents|favorites|shared|published|private|workspace
```
### 6.5.5 Système Share & Publish
Le bouton [Share] dans la topbar d'une page ouvre une modale à 2 tabs :
```
┌──────────────────────────────────────────────────────┐
│ [ Share ] [ Publish ] │
├──────────────────────────────────────────────────────┤
│ Share tab : │
│ · Invite people : [email] [Invite] │
│ · General access : [🔒 Restricted] Copy link │
│ · Liste des personnes/groupes avec accès │
│ → Document listé dans sidebar "Shared" │
├──────────────────────────────────────────────────────┤
│ Publish tab : │
│ · [Publish to web] → URL publique générée │
│ · URL : https://flowdeck.draco.dev/p/<slug> │
│ · [Unpublish] │
│ → Document listé dans sidebar "Published" │
└──────────────────────────────────────────────────────┘
```
### 6.5.6 Stockage des fichiers Private
Quand un workspace distant est actif, les fichiers créés dans la section
Private sont stockés dans la **base de données locale** (table `pages` avec
`parent_section='Private'` et `workspace=gitea:<owner>/<repo>`).
Cela permet de :
- Prendre des notes personnelles liées à un projet sans les committer
- Garder des brouillons avant de les pousser
- Avoir un espace de travail privé même sur un repo partagé
### 6.5.7 Trash — règles
```
Contenu Trash ? Comportement
─────────────────────── ──────── ──────────────────────────
Workspace local ✅ Supprimé → restaurable
Dossier/fichier local ✅ Supprimé → restaurable
Fichier Gitea/GitHub ❌ Commit "delete" → pas trash
Page distante ❌ Commit "delete" → pas trash
```
### 6.5.8 Édition de fichiers — UI unifiée
```
Fichier LOCAL Fichier DISTANT (Gitea/GitHub)
┌─────────────────────────┐ ┌─────────────────────────────┐
│ Même UI d'édition │ │ Même UI d'édition │
│ Même rendu visuel │ │ Même rendu visuel │
│ Sauvegarde auto DB │ │ + Bouton [Commit] │
│ Pas de commit │ │ + Champ message commit │
└─────────────────────────┘ └─────────────────────────────┘
```
### 6.5.9 Page Library — `/library?tab=<tab>`
```
┌──────────────────────────────────────────────────────────────┐
│ Library │
│ [Recents] [Favorites] [Shared] [Published] │
│ [Private] [Workspace] │
├──────────────────────────────────────────────────────────────┤
│ Filtres : [All] [Local] [Gitea] [GitHub] │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 📄 Document X Local · modifié il y a 2h │ │
│ │ 📄 README.md Gitea · bruno/flowdeck │ │
│ │ 📄 Notes Local · Workspace Projet A │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
### 6.5.10 Schéma DB — mises à jour pour v4.0
```sql
-- users : ajout auth_method
ALTER TABLE users ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'local';
-- 'local' | 'gitea' | 'github'
-- users : colonnes OAuth existantes
-- gitea_id INTEGER (NULL si pas Gitea)
-- github_id INTEGER (NULL si pas GitHub)
-- gitea_token TEXT (token intégration, NULL si pas connecté)
-- github_token TEXT (token intégration, NULL si pas connecté)
-- pages : ajout published
ALTER TABLE pages ADD COLUMN is_published BOOLEAN NOT NULL DEFAULT 0;
ALTER TABLE pages ADD COLUMN publish_slug TEXT;
ALTER TABLE pages ADD COLUMN is_shared BOOLEAN NOT NULL DEFAULT 0;
ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'restricted';
-- 'restricted' | 'link' | 'public'
-- page_shares : qui a accès à quel document
CREATE TABLE page_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
shared_with_user_id INTEGER REFERENCES users(id),
shared_with_email TEXT,
permission TEXT NOT NULL DEFAULT 'view',
-- 'view' | 'comment' | 'edit'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
);
-- recents : historique de consultation
CREATE TABLE recents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
page_id INTEGER NOT NULL REFERENCES pages(id),
workspace TEXT NOT NULL,
source_type TEXT NOT NULL DEFAULT 'local',
-- 'local' | 'gitea' | 'github'
accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, page_id)
);
```
### 6.5.11 Plan de migration
```
Phase 1 — DB & Modèles
1. Ajouter auth_method aux users existants (auto-détecter)
2. Ajouter is_published, is_shared, share_mode aux pages
3. Créer table page_shares
4. Créer table recents
Phase 2 — Sidebar
1. Badge OAuth (🦎/🐙) basé sur auth_method
2. Afficher/cacher Private selon type de compte + workspace actif
3. Boutons Library par section
4. Trash filtré local-only
Phase 3 — Share & Publish
1. Modale Share à 2 tabs fonctionnelle
2. Share via email/user → table page_shares
3. Share via lien → share_mode='link'
4. Publish → is_published + publish_slug
5. Page publique servable à /p/<slug>
Phase 4 — Library
1. Page /library avec tabs
2. Filtrage par source (local/gitea/github)
3. Recents alimentés automatiquement
Phase 5 — Intégrations
1. GitHub OAuth déjà fait (providers.py)
2. Matrice de déconnexion respectée
3. Compte local peut connecter/déconnecter Gitea+GitHub
```
---
## 7. Intégration Gitea
### 7.1 Flux de données
+41
View File
@@ -172,6 +172,47 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
---
## v4.0.0 — Accounts, Integrations & Sidebar (priorité haute)
> **Rappel** : Section 6.5 de ARCHITECTURE.md documente le design complet.
### 4.0 Account Model
- [ ] **`auth_method`** — colonne `users.auth_method ∈ {local, gitea, github}`
- [ ] **Auto-détection** — migrer les comptes existants (gitea_token → gitea, sinon local)
- [ ] **Badge OAuth** — 🦎/🐙 dans sidebar SI `auth_method` = provider
- [ ] **Pas de badge** — un compte local avec intégration ne montre PAS de badge
### 4.0a Intégrations & Déconnexion
- [ ] **Settings page** — montre toutes les intégrations avec bouton Connect/Disconnect
- [ ] **Matrice déconnexion** : local→déco ok · gitea→déco github ok · github→déco gitea ok
- [ ] **Provider principal** — pas de bouton Disconnect sur auth_method
- [ ] **Compte local** — peut connecter Gitea + GitHub simultanément
### 4.0b Sidebar — règles par type de compte
- [ ] **Private visible** UNIQUEMENT si workspace actif est distant (Gitea/GitHub)
- [ ] **Private caché** pour Scénario A (local pur)
- [ ] **Trash local-only** — caché pour compte OAuth pur, restreint au contenu local
- [ ] **Boutons → Library** sur chaque section (recents, favorites, shared, published…)
### 4.0c Share & Publish
- [ ] **Modale Share** — 2 tabs (Share + Publish) fonctionnels
- [ ] **Share tab** — inviter email/user, général access lien
- [ ] **Publish tab** — activer/désactiver, slug généré
- [ ] **Page publique** — `/p/<slug>` sert le contenu publié
- [ ] **Tables DB** — `page_shares`, colonnes `is_published`, `is_shared`, `share_mode`
### 4.0d Library
- [ ] **Page `/library?tab=`** — tabs Recents, Favorites, Shared, Published, Private, Workspace
- [ ] **Filtres source** — All | Local | Gitea | GitHub
- [ ] **Table `recents`** — tracking automatique des documents consultés
### 4.0e Édition unifiée
- [ ] **Même UI** pour fichiers locaux ET distants
- [ ] **Bouton Commit** — visible uniquement pour fichiers distants
- [ ] **Champ message** — pour les commits Gitea/GitHub
---
## v4.0.0 — Parité Notion (Delta Analysis)
> **Méthodologie**: Comparaison exhaustive de FlowDeck v3.0.1 vs Notion (2025) — 60+ fonctionnalités analysées, 45 manquantes identifiées, 30 retenues pour v4.0.0.
+52
View File
@@ -410,6 +410,58 @@ def init_db():
""")
conn.commit()
# ── 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():
+6 -2
View File
@@ -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")
+1 -1
View File
@@ -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
+21 -8
View File
@@ -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';}}
</script>
</body>
</html>"""
@@ -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("<h1>Invalid state</h1>", status_code=400)
if not expected_state and not auth_mode:
return HTMLResponse("<h1>Session expired — please try connecting again</h1>", status_code=400)
from app.auth.providers import get_provider
oauth_provider = get_provider(provider_name)
@@ -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
+117 -43
View File
@@ -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}")
+270 -131
View File
@@ -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('<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>')
# Gitea workspace — no server-side tree, loaded client-side
if ws_cookie.startswith("gitea:"):
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Remote workspace</span></li>')
try:
ws_id = int(ws_cookie)
with get_conn() as conn:
# Verify workspace belongs to user
row = conn.execute(
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
(ws_id, user["id"])
).fetchone()
if not row:
return HTMLResponse(
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
'<span class="page-name text-dim">No pages yet</span></li>'
)
pages = _load_workspace_pages(ws_cookie)
if not pages:
return HTMLResponse(
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
'<span class="page-name text-dim">No pages yet</span></li>'
)
# Render the tree using the extracted macro
env = Environment(loader=FileSystemLoader("app/templates"))
template = env.from_string(
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
"{{ render_workspace_tree(pages) }}"
)
html = template.render(pages=pages)
return HTMLResponse(html)
except (ValueError, Exception) as e:
logger.error(f"sidebar_workspace_tree failed: {e}", exc_info=True)
return HTMLResponse(
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
'<span class="page-name text-dim">No pages yet</span></li>'
)
+6 -1
View File
@@ -6,7 +6,12 @@ router = APIRouter(tags=["gitea"], prefix="/api/gitea")
def _require_gitea(request: Request):
"""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:
+35
View File
@@ -0,0 +1,35 @@
"""GitHub OAuth — status and disconnect routes."""
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
router = APIRouter(tags=["github"], prefix="/api/github")
@router.get("/status")
async def github_status(request: Request):
"""Check if the current user has GitHub linked."""
from app.auth.session import SessionManager
from app.db import get_conn
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return {"linked": False}
with get_conn() as conn:
row = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='github' AND access_token IS NOT NULL AND access_token != ''",
(user["id"],)
).fetchone()
return {"linked": row is not None}
@router.delete("/disconnect")
async def disconnect_github(request: Request):
"""Remove all GitHub OAuth tokens for the current user."""
from app.auth.session import SessionManager
from app.db import get_conn
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
with get_conn() as conn:
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=? AND provider='github'", (user["id"],))
conn.commit()
return {"status": "ok"}
+249
View File
@@ -0,0 +1,249 @@
"""FlowDeck — Library API: recents, favorites, shared, published, private, workspace."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request, Query
from fastapi.responses import JSONResponse
from app.db import get_conn
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["library"], prefix="/api/library")
SOURCE_TYPES = {"all", "local", "gitea", "github"}
def _get_user_id(request: Request) -> int:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
return user["id"] if user and user.get("id") else 1
def _detect_source_type(workspace: str) -> str:
"""Detect source type from workspace field.
- '' or plain name (no slash) → local
- 'owner/repo' format → gitea
- 'github:owner/repo' format → github
"""
ws = (workspace or "").strip()
if not ws:
return "local"
if ws.startswith("github:"):
return "github"
if "/" in ws:
return "gitea"
return "local"
def _source_badge(source_type: str) -> str:
return {"local": "Local", "gitea": "Gitea", "github": "GitHub"}.get(source_type, "Local")
def _build_item(db_row: dict) -> dict:
"""Convert a DB row into a library item dict."""
workspace = db_row.get("workspace", "") or ""
source_type = _detect_source_type(workspace)
# Extract workspace display name
if source_type == "gitea":
workspace_name = workspace # "owner/repo"
elif source_type == "github":
workspace_name = workspace.replace("github:", "", 1)
else:
workspace_name = workspace or "Private"
# URL construction
page_id = db_row["id"]
if source_type == "gitea":
parts = workspace.split("/", 1)
owner = parts[0] if len(parts) > 0 else ""
repo = parts[1] if len(parts) > 1 else ""
url = f"/gitea-workspace?owner={owner}&repo={repo}"
elif source_type == "github":
gh_ws = workspace.replace("github:", "", 1)
parts = gh_ws.split("/", 1)
owner = parts[0] if len(parts) > 0 else ""
repo = parts[1] if len(parts) > 1 else ""
url = f"/gitea-workspace?owner={owner}&repo={repo}"
else:
url = f"/pages/{page_id}"
return {
"id": page_id,
"title": db_row.get("title") or "Untitled",
"icon": "📄",
"source_type": source_type,
"source_badge": _source_badge(source_type),
"workspace_name": workspace_name,
"updated_at": db_row.get("updated_at", ""),
"url": url,
}
def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[str, list]:
"""Add WHERE clause for source_type filter. Mutates params in place."""
if source_type == "local":
query += " AND (workspace = '' OR workspace NOT LIKE '%/%' AND workspace NOT LIKE 'github:%')"
elif source_type == "gitea":
query += " AND workspace LIKE '%/%' AND workspace NOT LIKE 'github:%'"
elif source_type == "github":
query += " AND workspace LIKE 'github:%'"
# 'all' → no filter
return query, params
# ── Tab endpoints ──
@router.get("/recents")
async def library_recents(
request: Request,
source_type: str = Query(default="all"),
):
"""Recent pages ordered by updated_at DESC."""
if source_type not in SOURCE_TYPES:
source_type = "all"
uid = _get_user_id(request)
query = (
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE parent_section != 'Trash' AND deleted_at IS NULL"
)
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = [_build_item(dict(r)) for r in rows]
return {"items": items}
@router.get("/favorites")
async def library_favorites(
request: Request,
source_type: str = Query(default="all"),
):
"""Favorite pages for the current user."""
if source_type not in SOURCE_TYPES:
source_type = "all"
uid = _get_user_id(request)
query = (
"SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f "
"JOIN pages p ON p.id = f.page_id "
"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
)
params: list = [uid]
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY f.position"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = [_build_item(dict(r)) for r in rows]
return {"items": items}
@router.get("/shared")
async def library_shared(
request: Request,
source_type: str = Query(default="all"),
):
"""Pages shared with others (share_mode != 'private')."""
if source_type not in SOURCE_TYPES:
source_type = "all"
query = (
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE share_mode != 'private' AND parent_section != 'Trash' AND deleted_at IS NULL"
)
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = [_build_item(dict(r)) for r in rows]
return {"items": items}
@router.get("/published")
async def library_published(
request: Request,
source_type: str = Query(default="all"),
):
"""Published pages (published = 1)."""
if source_type not in SOURCE_TYPES:
source_type = "all"
query = (
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE published = 1 AND parent_section != 'Trash' AND deleted_at IS NULL"
)
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = [_build_item(dict(r)) for r in rows]
return {"items": items}
@router.get("/private")
async def library_private(
request: Request,
source_type: str = Query(default="all"),
):
"""Private pages (parent_section = 'Private')."""
if source_type not in SOURCE_TYPES:
source_type = "all"
query = (
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE parent_section = 'Private' AND deleted_at IS NULL"
)
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = [_build_item(dict(r)) for r in rows]
return {"items": items}
@router.get("/workspace")
async def library_workspace(
request: Request,
source_type: str = Query(default="all"),
):
"""Pages from workspaces (non-empty workspace field)."""
if source_type not in SOURCE_TYPES:
source_type = "all"
query = (
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE workspace != '' AND parent_section != 'Trash' AND deleted_at IS NULL"
)
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = [_build_item(dict(r)) for r in rows]
return {"items": items}
+245
View File
@@ -0,0 +1,245 @@
"""FlowDeck — Sharing, Publishing & Recents API (v4.0)."""
from __future__ import annotations
import logging
import re
import unicodedata
from datetime import datetime
from fastapi import APIRouter, Request, HTTPException
from app.db import get_conn
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sharing"], prefix="/api")
def _require_auth(request: Request) -> dict:
"""Require an authenticated session. Returns user dict or raises 401."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
return user
def _slugify(title: str) -> str:
"""Generate a URL-safe slug from a page title."""
slug = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^\w\s-]", "", slug.lower())
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
return slug or "untitled"
# ── Page Sharing ──
@router.post("/pages/{page_id}/share")
async def share_page(page_id: int, request: Request):
"""Invite a user or email to a page."""
user = _require_auth(request)
body = await request.json() if request.headers.get("content-type") else {}
target_user_id = body.get("user_id")
email = body.get("email", "")
permission = body.get("permission", "view")
if permission not in ("view", "comment", "edit"):
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
if not target_user_id and not email:
raise HTTPException(400, "Provide user_id or email to share with.")
with get_conn() as conn:
# Verify page exists
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(404, "Page not found")
# Verify target user exists if user_id given
if target_user_id:
target = conn.execute("SELECT id FROM users WHERE id=?", (target_user_id,)).fetchone()
if not target:
raise HTTPException(404, "Target user not found")
cur = conn.execute(
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
VALUES (?, ?, ?, ?, ?)""",
(page_id, target_user_id, email, permission, user["id"]),
)
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
conn.commit()
return {
"id": cur.lastrowid,
"page_id": page_id,
"shared_with_user_id": target_user_id,
"shared_with_email": email,
"permission": permission,
"status": "shared",
}
@router.delete("/pages/{page_id}/share/{share_id}")
async def remove_share(page_id: int, share_id: int, request: Request):
"""Remove a share invitation."""
_require_auth(request)
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM page_shares WHERE id=? AND page_id=?",
(share_id, page_id),
).fetchone()
if not row:
raise HTTPException(404, "Share entry not found")
conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,))
# If no more shares, unset is_shared
remaining = conn.execute(
"SELECT COUNT(*) AS c FROM page_shares WHERE page_id=?", (page_id,)
).fetchone()["c"]
if remaining == 0:
conn.execute("UPDATE pages SET is_shared=0 WHERE id=?", (page_id,))
conn.commit()
return {"status": "removed", "share_id": share_id}
@router.get("/pages/{page_id}/shares")
async def list_shares(page_id: int, request: Request):
"""Get all shares for a page."""
_require_auth(request)
with get_conn() as conn:
page = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(404, "Page not found")
rows = conn.execute(
"""SELECT s.*, u.login, u.full_name, u.avatar_url
FROM page_shares s
LEFT JOIN users u ON s.shared_with_user_id = u.id
WHERE s.page_id=?
ORDER BY s.created_at DESC""",
(page_id,),
).fetchall()
return {
"page_id": page_id,
"shares": [
{
"id": r["id"],
"shared_with_user_id": r["shared_with_user_id"],
"shared_with_email": r["shared_with_email"],
"permission": r["permission"],
"created_at": r["created_at"],
"created_by": r["created_by"],
"user_login": r["login"],
"user_full_name": r["full_name"],
"user_avatar_url": r["avatar_url"],
}
for r in rows
],
}
# ── Page Publishing ──
@router.post("/pages/{page_id}/publish")
async def publish_page(page_id: int, request: Request):
"""Publish a page (is_published=1) with a URL slug."""
user = _require_auth(request)
with get_conn() as conn:
page = conn.execute(
"SELECT id, title, is_published FROM pages WHERE id=?", (page_id,)
).fetchone()
if not page:
raise HTTPException(404, "Page not found")
slug = _slugify(page["title"])
# Ensure uniqueness by appending suffix if needed
base_slug = slug
counter = 1
while conn.execute(
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
).fetchone():
slug = f"{base_slug}-{counter}"
counter += 1
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?",
(slug, page_id),
)
conn.commit()
return {
"page_id": page_id,
"is_published": True,
"publish_slug": slug,
"status": "published",
}
@router.delete("/pages/{page_id}/publish")
async def unpublish_page(page_id: int, request: Request):
"""Unpublish a page."""
_require_auth(request)
with get_conn() as conn:
page = conn.execute(
"SELECT id, is_published FROM pages WHERE id=?", (page_id,)
).fetchone()
if not page:
raise HTTPException(404, "Page not found")
conn.execute(
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
(page_id,),
)
conn.commit()
return {
"page_id": page_id,
"is_published": False,
"status": "unpublished",
}
# ── Recents ──
@router.post("/recents/track")
async def track_recent(request: Request):
"""Record a page access in recents."""
user = _require_auth(request)
body = await request.json() if request.headers.get("content-type") else {}
page_id = body.get("page_id")
workspace = body.get("workspace", "")
source_type = body.get("source_type", "local")
if not page_id:
raise HTTPException(400, "page_id is required")
with get_conn() as conn:
page = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(404, "Page not found")
conn.execute(
"""INSERT INTO recents (user_id, page_id, workspace, source_type, accessed_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, page_id)
DO UPDATE SET workspace=excluded.workspace,
source_type=excluded.source_type,
accessed_at=excluded.accessed_at""",
(user["id"], page_id, workspace, source_type, datetime.utcnow().isoformat()),
)
conn.commit()
return {
"status": "tracked",
"user_id": user["id"],
"page_id": page_id,
"accessed_at": datetime.utcnow().isoformat(),
}
+1 -1
View File
@@ -420,7 +420,7 @@ def get_user_gitea_client(request):
from app.db import get_conn
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:
+47
View File
@@ -0,0 +1,47 @@
{# ── Unified Header (Notion-inspired) ──
Parameters available in calling context:
breadcrumb_items: list of {label: str, url: str|None} — last item = current page
page_icon: str — emoji icon
right_actions: str — raw HTML for right-side action buttons
hamburger_click: str — Alpine expression for hamburger button (default: opens sidebar)
hide_hamburger: bool — if truthy, hides the hamburger button
#}
<header class="topbar unified-header">
{% if not hide_hamburger %}
<button class="hamburger-btn"
@click="{{ hamburger_click|default('sidebarCollapsed ? (sidebarCollapsed = false) : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
title="Toggle sidebar">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
{% endif %}
<div class="topbar-left header-breadcrumb">
{% if page_icon %}
<span class="header-page-icon">{{ page_icon }}</span>
{% endif %}
{% if breadcrumb_items %}
{% for item in breadcrumb_items %}
{% if item.url and not loop.last %}
<a href="{{ item.url }}" class="breadcrumb-link">{{ item.label }}</a>
<span class="breadcrumb-sep">/</span>
{% elif loop.last %}
<span class="breadcrumb-current">{{ item.label }}</span>
{% else %}
<span class="breadcrumb-link disabled">{{ item.label }}</span>
<span class="breadcrumb-sep">/</span>
{% endif %}
{% endfor %}
{% else %}
<span class="breadcrumb-current">{{ page_title|default('FlowDeck') }}</span>
{% endif %}
</div>
<div class="topbar-right header-actions">
{{ right_actions|safe if right_actions else '' }}
</div>
</header>
+46
View File
@@ -0,0 +1,46 @@
{% macro render_workspace_tree(pages, depth=0) %}
{% for page in pages %}
{% set safe_name = page.name | replace("'", "\\'") %}
<li class="sidebar-item ws-tree-item"
data-id="{{ page.db_id }}"
data-is-folder="{{ 'true' if page.is_folder else 'false' }}"
data-depth="{{ depth }}"
data-name="{{ safe_name }}"
:class="{ active: activeNodeId === {{ page.db_id }} }"
draggable="true"
@dragstart="dragStart($event, {{ page.db_id }})"
@dragover.prevent="dragOver($event, {{ page.db_id }}, {{ 'true' if page.is_folder else 'false' }})"
@dragleave="dragLeave($event)"
@drop.prevent="drop($event, {{ page.db_id }})"
style="padding-left:{{ 4 + depth * 12 }}px;"
@contextmenu.prevent="showWsContext($event, {{ page.db_id }}, '{{ safe_name }}', {{ 'true' if page.is_folder else 'false' }})"
@touchstart="wsTouchStart($event)"
@touchend="wsTouchEnd($event, {{ page.db_id }}, '{{ safe_name }}', {{ 'true' if page.is_folder else 'false' }})"
@touchmove="wsTouchMove($event)">
{% if page.is_folder %}
<button class="tree-chevron"
@click.stop="toggleTreeFolder({{ page.db_id }})"
:class="{ open: expandedFolders[{{ page.db_id }}] }"
title="Toggle folder">▶</button>
{% else %}
<span style="width:12px;flex-shrink:0;"></span>
{% endif %}
<span class="page-icon">{{ page.icon }}</span>
{% if page.is_folder %}
<span class="page-name tree-folder-link"
@click.stop="navigateToFolder({{ page.db_id }})"
draggable="false"
title="Open folder">{{ page.name }}{% if page.child_count %} <span class="child-count">({{ page.child_count }})</span>{% endif %}</span>
{% else %}
<a href="/pages/{{ page.db_id }}" class="page-name" draggable="false">{{ page.name }}</a>
{% endif %}
</li>
{% if page.children %}
<li x-show="expandedFolders[{{ page.db_id }}]" x-transition style="list-style:none;margin:0;padding:0;">
<ul style="list-style:none;margin:0;padding:0;">
{{ render_workspace_tree(page.children, depth + 1) }}
</ul>
</li>
{% endif %}
{% endfor %}
{% endmacro %}
+1
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block page_icon %}👤{% endblock %}
{% block page_title %}Accounts{% endblock %}
{% block title_prefix %}Accounts{% endblock %}
{% block content %}
<div class="page-title-area">
+225 -103
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowDeck</title>
<title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/design-tokens.css?v=1.0">
<link rel="stylesheet" href="/static/css/app.css?v=2.4.6">
@@ -71,6 +71,11 @@
<div class="workspace-info">
<div class="workspace-avatar" style="{% if avatar_url %}background-image:url('{{ avatar_url }}');background-size:cover;{% else %}background:{{ avatar_color }};{% endif %}">{% if not avatar_url %}{{ user.full_name[:1].upper() if user and user.get('full_name') else workspaceInitial }}{% endif %}</div>
<span class="workspace-name">{{ user.full_name or user.login or workspaceName }}</span>
{% if auth_method != 'local' %}
<span class="oauth-badge" title="Connected via {{ auth_method|title }}">
{% if auth_method == 'gitea' %}🦎 Gitea{% elif auth_method == 'github' %}🐙 GitHub{% else %}🔑 {{ auth_method|title }}{% endif %}
</span>
{% endif %}
</div>
<span class="workspace-chevron" :class="{ open: workspaceMenuOpen }">▾</span>
<button class="sidebar-collapse-inline" @click.stop="sidebarCollapsed = !sidebarCollapsed" title="Collapse sidebar">
@@ -111,62 +116,19 @@
</div>
<div class="sidebar-scroll">
<!-- Workspace -->
<!-- Workspace / Private -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('workspace')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.workspace }" x-text="sectionsOpen.workspace ? '▼' : '▶'"></span>
<span>📁 {{ active_ws_name or 'Workspace' }}</span>
<span>{% if gitea_workspace %}📁 {{ active_ws_name }}{% elif workspace_key and '/' in workspace_key %}🔗{% else %}📁{% endif %} {{ active_ws_name if not gitea_workspace else '' }}</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="New Page" @click.stop="newPageInWorkspace()">📄</button>
<button class="section-action-btn" title="New Folder" @click.stop="newFolderInWorkspace()">📁</button>
</div>
</div>
{% macro render_workspace_tree(pages, depth=0) %}
{% for page in pages %}
<li class="sidebar-item ws-tree-item"
data-id="{{ page.db_id }}"
data-is-folder="{{ 'true' if page.is_folder else 'false' }}"
data-depth="{{ depth }}"
:class="{ active: activeNodeId === {{ page.db_id }} }"
draggable="true"
@dragstart="dragStart($event, {{ page.db_id }})"
@dragover.prevent="dragOver($event, {{ page.db_id }}, {{ 'true' if page.is_folder else 'false' }})"
@dragleave="dragLeave($event)"
@drop.prevent="drop($event, {{ page.db_id }})"
style="padding-left:{{ 4 + depth * 12 }}px;"
@contextmenu.prevent="showWsContext($event, {{ page.db_id }}, '{{ page.name | e }}', {{ 'true' if page.is_folder else 'false' }})"
@touchstart="wsTouchStart($event)"
@touchend="wsTouchEnd($event, {{ page.db_id }}, '{{ page.name | e }}', {{ 'true' if page.is_folder else 'false' }})"
@touchmove="wsTouchMove($event)">
{% if page.is_folder %}
<button class="tree-chevron"
@click.stop="toggleTreeFolder({{ page.db_id }})"
:class="{ open: expandedFolders[{{ page.db_id }}] }"
title="Toggle folder">▶</button>
{% else %}
<span style="width:12px;flex-shrink:0;"></span>
{% endif %}
<span class="page-icon">{{ page.icon }}</span>
{% if page.is_folder %}
<span class="page-name tree-folder-link"
@click.stop="navigateToFolder({{ page.db_id }})"
draggable="false"
title="Open folder">{{ page.name }}{% if page.child_count %} <span class="child-count">({{ page.child_count }})</span>{% endif %}</span>
{% else %}
<a href="/pages/{{ page.db_id }}" class="page-name" draggable="false">{{ page.name }}</a>
{% endif %}
</li>
{% if page.children %}
<li x-show="expandedFolders[{{ page.db_id }}]" x-transition style="list-style:none;margin:0;padding:0;">
<ul style="list-style:none;margin:0;padding:0;">
{{ render_workspace_tree(page.children, depth + 1) }}
</ul>
</li>
{% endif %}
{% endfor %}
{% endmacro %}
{% from '_workspace_tree_macro.html' import render_workspace_tree %}
<div class="sidebar-section-items" x-show="sectionsOpen.workspace" x-transition>
<ul class="sidebar-items" data-section="workspace" id="sidebar-workspace-items">
{% if workspace_pages %}
@@ -181,6 +143,26 @@
</div>
</div>
<!-- Repository (Gitea) — only visible for Gitea workspaces -->
{% if gitea_workspace %}
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('gitea')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.gitea }" x-text="sectionsOpen.gitea ? '▼' : '▶'"></span>
<span>🔗 Repository (Gitea)</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Refresh" @click.stop="if(window._gwData)window._gwData.refreshTree()">🔄</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.gitea" x-transition>
<ul class="sidebar-items" data-section="gitea" id="sidebar-gitea-items">
<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Loading...</span></li>
</ul>
</div>
</div>
{% endif %}
<!-- Meetings -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('meetings')">
@@ -204,6 +186,9 @@
<span class="chevron" :class="{ open: sectionsOpen.recents }" x-text="sectionsOpen.recents ? '▼' : '▶'"></span>
<span>Recents</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Recents" class="library-link-btn" title="View in Library" @click.stop>→ Library</a>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.recents" x-transition>
<ul class="sidebar-items" data-section="recents">
@@ -225,7 +210,7 @@
<span>Favorites</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Add" @click.stop="newPage('Favorites')">+</button>
<a href="/library?tab=Favorites" class="library-link-btn" title="View in Library" @click.stop>→ Library</a>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.favorites" x-transition>
@@ -258,7 +243,7 @@
<span>Shared</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Add" @click.stop="newPage('Shared')">+</button>
<a href="/library?tab=Shared" class="library-link-btn" title="View in Library" @click.stop>→ Library</a>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.shared" x-transition>
@@ -273,7 +258,31 @@
</div>
</div>
<!-- Published -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('published')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.published }" x-text="sectionsOpen.published ? '▼' : '▶'"></span>
<span>Published</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Published" class="library-link-btn" title="View in Library" @click.stop>→ Library</a>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.published" x-transition>
<ul class="sidebar-items" data-section="published">
{% for page in published_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not published_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon">🌐</span><span class="page-name text-dim">No published pages</span></li>
{% endif %}
</ul>
</div>
</div>
<!-- Private -->
{% if auth_method != 'local' or '/' in workspace_key %}
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('private')">
<div class="sidebar-section-title">
@@ -281,9 +290,7 @@
<span>Private</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="View" @click.stop="">⊞</button>
<button class="section-action-btn" title="More" @click.stop="">⋯</button>
<button class="section-action-btn" title="Add page" @click.stop="newPage('Private')">+</button>
<a href="/library?tab=Private" class="library-link-btn" title="View in Library" @click.stop>→ Library</a>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.private" x-transition>
@@ -297,6 +304,7 @@
</ul>
</div>
</div>
{% endif %}
<!-- Notion apps / Utilities -->
<div class="sidebar-section">
@@ -310,7 +318,9 @@
<ul class="sidebar-items">
<li class="sidebar-item" @click="navigateTo('/library')"><span class="page-icon">📚</span><span class="page-name">Library</span></li>
<li class="sidebar-item"><span class="page-icon">✅</span><span class="page-name">My Tasks</span></li>
{% if auth_method == 'local' or (workspace_pages and workspace_pages|length > 0) %}
<li class="sidebar-item" @click="navigateTo('/trash')"><span class="page-icon">🗑️</span><span class="page-name">Trash</span></li>
{% endif %}
<li class="sidebar-item"><span class="page-icon">❓</span><span class="page-name">Help</span></li>
</ul>
</div>
@@ -339,47 +349,7 @@
<!-- Topbar -->
{% block topbar %}
<header class="topbar">
<!-- Hamburger / uncollapse sidebar -->
<button class="hamburger-btn" @click="sidebarCollapsed ? (sidebarCollapsed = false) : (mobileSidebarOpen = true, sidebarCollapsed = false)" title="Menu">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="topbar-left">
<div class="topbar-breadcrumb">
<span x-text="workspaceName"></span>
<span class="text-dim">/</span>
<span class="text-dim" x-text="currentPage"></span>
</div>
</div>
<div class="topbar-center">
<div class="topbar-page-title">
<span class="page-icon" x-text="pageIcon"></span>
<span x-text="pageTitle"></span>
</div>
<div class="topbar-page-status">
<span>🔒</span> Private
<span style="cursor:pointer;">▾</span>
</div>
</div>
<div class="topbar-right">
<span class="text-xs text-dim">Edited <span x-text="lastEdited"></span></span>
<button class="topbar-btn">🔔</button>
<button class="topbar-btn">🕒</button>
<button class="topbar-btn">🔍</button>
<button class="topbar-btn share-btn">🔗 Share ▾</button>
<button class="topbar-btn">⭐</button>
<button class="topbar-btn" @click="showNewPageMenu = !showNewPageMenu" style="position:relative;">
New ▾
<div class="dropdown-panel" :class="{ visible: showNewPageMenu }" style="top:100%; right:0; left:auto; min-width:200px;" @click.stop>
<div class="dropdown-option" @click="createEmptyPage()"><span class="option-icon">📄</span> Empty page</div>
<div class="dropdown-option" @click="createDatabase()"><span class="option-icon">📊</span> Database</div>
<div class="dropdown-option" @click="createBoard()"><span class="option-icon">📋</span> Kanban board</div>
</div>
</button>
</div>
</header>
{% include '_header.html' %}
{% endblock %}
<!-- Content -->
@@ -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 = '<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No files yet</span></li>';
return;
}
this.giteaTree.forEach(function(item) {
var li = document.createElement('li');
li.className = 'sidebar-item';
var icon = item.type === 'dir' ? '📁' : '📄';
li.innerHTML = '<span class="page-icon">' + icon + '</span><span class="page-name">' + item.name + '</span>';
container.appendChild(li);
});
console.log('loadGiteaTree: rendered ' + this.giteaTree.length + ' items');
}.bind(this))
.catch(function(err) {
console.error('loadGiteaTree failed:', err.message || err);
if (container) {
container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">⚠️</span><span class="page-name text-dim">Could not load tree: ' + (err.message || 'Unknown error') + '</span></li>';
}
});
},
newPageInWorkspace() {
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 <li>)
const manageLink = ul.querySelector('li:last-child');
ul.innerHTML = html;
if (manageLink) ul.appendChild(manageLink);
})
.catch(e => console.error('Sidebar refresh failed:', e));
},
// ── Drag & Drop ──
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 @@
</script>
<script src="/static/js/app.js?v=2.4.6" defer></script>
{% block scripts %}{% endblock %}
<style>
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
.flowdeck-modal{background:var(--bg-secondary,#222);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;width:100%;max-width:420px;box-shadow:0 20px 60px rgba(0,0,0,0.5);}
.flowdeck-modal-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border,rgba(255,255,255,.08));}
.flowdeck-modal-header h3{margin:0;font-size:16px;color:var(--text,#fff);}
.flowdeck-modal-close{background:none;border:none;color:var(--text-dim, rgba(255,255,255,.4));font-size:20px;cursor:pointer;padding:4px 8px;border-radius:6px;}
.flowdeck-modal-close:hover{background:var(--bg-hover,#333);color:var(--text,#fff);}
.flowdeck-modal-body{padding:20px;}
.flowdeck-modal-body p{color:var(--text-dim, rgba(255,255,255,.6));font-size:14px;margin:0 0 12px 0;}
.flowdeck-modal-input{width:100%;padding:10px 12px;background:var(--bg-tertiary,#2A2A2A);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:8px;color:var(--text,#fff);font-size:14px;outline:none;box-sizing:border-box;}
.flowdeck-modal-input:focus{border-color:var(--accent,#2383E2);box-shadow:0 0 0 1px var(--accent,#2383E2);}
.flowdeck-modal-footer{display:flex;gap:8px;justify-content:flex-end;padding:12px 20px;border-top:1px solid var(--border,rgba(255,255,255,.08));}
.flowdeck-modal-btn{padding:8px 20px;border-radius:8px;font-size:13px;cursor:pointer;border:none;}
.flowdeck-modal-btn.cancel{background:var(--bg-tertiary,#333);color:var(--text-dim, rgba(255,255,255,.6));}
.flowdeck-modal-btn.cancel:hover{background:var(--bg-hover,#444);}
.flowdeck-modal-btn.confirm{background:var(--accent,#2383E2);color:#fff;}
.flowdeck-modal-btn.confirm:hover{background:#2C8CEB;}
</style>
</body>
</html>
+1
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block page_icon %}📁{% endblock %}
{% block page_title %}Kanban board new{% endblock %}
{% block title_prefix %}Kanban board new{% endblock %}
{% block content %}
<!-- Cover image -->
+1
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block page_icon %}🏠{% endblock %}
{% block page_title %}Home{% endblock %}
{% block title_prefix %}Home{% endblock %}
{% block content %}
<div class="page-title-area" style="padding-top: 24px;">
+288 -192
View File
@@ -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 %}
<header class="topbar">
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="topbar-left">
<a href="/workspaces" class="breadcrumb" style="color:rgba(255,255,255,.5);text-decoration:none;">Home</a>
<span class="page-title-topbar">{{ owner }}/{{ repo }}</span>
</div>
<div class="topbar-right" style="display:flex;align-items:center;gap:6px;">
<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">📄+</button>
<button class="page-action-btn" @click="triggerUpload()" title="Upload file">📤</button>
<input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple>
<button class="page-action-btn" @click="refreshTree()" title="Refresh">🔄</button>
<button class="page-action-btn" onclick="window.location='/accounts/settings'" title="Settings">⚙</button>
</div>
</header>
{% set breadcrumb_items = [{"label": "Home", "url": "/workspaces"}, {"label": owner ~ "/" ~ repo, "url": None}] %}
{% set page_icon = "🔗" %}
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">📄+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">📤</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">🔄</button><a href="/accounts/settings" class="topbar-btn" title="Settings">⚙</a>' %}
{% include '_header.html' %}
{% endblock %}
{% block content %}
@@ -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;}
</style>
<div class="gw-main" x-data="giteaWorkspace()">
<!-- File view -->
<div x-show="showFile && !showEditor" x-transition>
<div class="gw-file-toolbar">
<strong x-text="filePath || ''"></strong>
<span x-text="formatSize(fileSize)"></span>
<span style="flex:1;"></span>
<button class="btn" @click="openEditor()">✏️ Edit</button>
<button class="btn btn-danger" @click="deleteCurrentFile()">🗑 Delete</button>
</div>
<div class="gw-content">
<!-- Skeleton loading -->
<div x-show="fileLoading" class="skeleton-file">
<div class="skeleton" style="height:15px;width:60%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:80%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:40%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:75%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:55%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:90%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:35%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:65%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:45%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:70%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:50%;"></div>
</div>
<!-- Syntax-highlighted code -->
<pre x-show="!fileLoading"><code :class="'language-'+fileLanguage" x-ref="codeBlock" x-text="fileContent"></code></pre>
</div>
</div>
<!-- Editor view -->
<template x-if="showEditor">
<div>
<div class="gw-file-toolbar">
<strong>Editing: <span x-text="filePath || 'new file'"></span></strong>
<span style="flex:1;"></span>
<button class="btn" @click="showEditor=false">Cancel</button>
</div>
<div class="gw-content">
<textarea x-model="editContent"></textarea>
</div>
<div class="gw-commit-bar">
<input type="text" x-model="commitMessage" placeholder="Commit message" list="commit-history">
<datalist id="commit-history">
<template x-for="msg in commitHistory" :key="msg">
<option :value="msg">
</template>
</datalist>
<button class="btn-primary" @click="saveFile()">💾 Commit</button>
</div>
</div>
</template>
<!-- Empty state -->
<template x-if="!showFile && !showEditor && !showPrivate">
<div class="gw-content">
<div class="empty-state">
<h2>📁 <span x-text="owner + '/' + repo"></span></h2>
<p x-show="!showNewFile">Select a file from the sidebar or create a new one</p>
<!-- New file form -->
<div x-show="showNewFile" style="margin-top:16px;text-align:left;max-width:400px;margin-left:auto;margin-right:auto;">
<input type="text" x-model="newFilePath" placeholder="path/to/file.md" class="settings-input" style="margin-bottom:8px;">
<textarea x-model="newFileContent" placeholder="File content..." class="settings-input" style="height:120px;margin-bottom:8px;"></textarea>
<input type="text" x-model="newFileMsg" placeholder="Commit message" class="settings-input" style="margin-bottom:8px;">
<button class="btn btn-primary" @click="createNewFile()">💾 Create file</button>
</div>
</div>
</div>
</template>
<!-- Private pages view -->
<template x-if="showPrivate && !editingPrivate">
<div class="gw-content">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3>🔒 Private Pages</h3>
<button class="btn btn-primary" @click="newPrivate()">+ New Page</button>
</div>
<template x-if="privatePages.length === 0">
<div class="empty-state" style="padding:40px 20px;">
<p>No private pages yet. Create one to store notes linked to this project.</p>
</div>
</template>
<template x-for="pp in privatePages" :key="pp.id">
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:8px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;"
@click="openPrivate(pp.id)">
<div>
<div style="font-weight:500;" x-text="pp.title"></div>
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
</div>
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">🗑</button>
</div>
</template>
</div>
</template>
<!-- Private page editor -->
<template x-if="editingPrivate">
<div>
<div class="gw-file-toolbar">
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
<span style="flex:1;"></span>
<button class="btn" @click="savePrivate()">💾 Save</button>
<button class="btn" @click="editingPrivate=null;editingPrivateTitle='';editingPrivateContent='';">Cancel</button>
</div>
<div class="gw-content">
<input x-model="editingPrivateTitle" placeholder="Page title" style="width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:16px;margin-bottom:12px;outline:none;">
<textarea x-model="editingPrivateContent" placeholder="Start writing…" style="min-height:60vh;"></textarea>
</div>
</div>
</template>
</div>
<script>
function giteaWorkspace() {
document.addEventListener('alpine:init', () => {
Alpine.data('giteaWorkspace', () => {
const params = new URLSearchParams(window.location.search);
const owner = params.get('owner') || '';
const repo = params.get('repo') || '';
@@ -182,79 +60,142 @@ function giteaWorkspace() {
editContent: '',
commitMessage: 'Update via FlowDeck',
commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'),
// File tree browser
treeItems: [],
sortedTreeItems: [],
treeLoading: false,
folderStack: [],
currentPath: '',
// Context menu
gwCtx: { visible: false, x: 0, y: 0, item: null },
_sortTree() {
this.sortedTreeItems = [...this.treeItems].sort(function(a, b) {
if (a.type === b.type) return a.name.localeCompare(b.name);
return a.type === 'folder' ? -1 : 1;
});
},
init() {
// Expose globally for header to access
window._gwData = this;
// Set cookie for sidebar
document.cookie = 'flowdeck_workspace=gitea:' + this.owner + ':' + this.repo + ';path=/;SameSite=Lax';
// Load root tree into sidebar
// Load sidebar tree (into gitea section)
this.loadSidebarTree();
// Load main content tree
this.loadMainTree('');
// Listen for sidebar clicks on Gitea items
this.setupSidebarClicks();
},
async loadMainTree(path) {
this.treeLoading = true;
this.currentPath = path;
try {
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree';
if (path) url += '?path=' + encodeURIComponent(path);
var r = await fetch(url);
if (!r.ok) { this.treeItems = []; this.sortedTreeItems = []; return; }
var d = await r.json();
this.treeItems = d.tree || [];
this._sortTree();
} catch(e) { this.treeItems = []; this.sortedTreeItems = []; }
finally { this.treeLoading = false; }
},
drillDown(path) {
this.folderStack.push(path.split('/').pop());
this.loadMainTree(path);
},
navigateToFolder(idx) {
// Truncate stack and rebuild path
this.folderStack = this.folderStack.slice(0, idx + 1);
var path = this.folderStack.join('/');
this.loadMainTree(path);
},
navigateToRoot() {
this.folderStack = [];
this.loadMainTree('');
},
openGwContext(ev, item) {
this.gwCtx = { visible: true, x: ev.clientX, y: ev.clientY, item: item };
},
gwRename() {
this.gwCtx.visible = false;
var item = this.gwCtx.item;
if (!item) return;
var newName = prompt('Rename:', item.name);
if (!newName || !newName.trim() || newName.trim() === item.name) return;
var self = this;
var oldPath = item.path;
var newPath = oldPath.replace(/[^/]+$/, newName.trim());
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath })
}).then(function(r){ return r.json(); })
.then(function(d){
if (d.status === 'ok') { self.refreshTree(); }
else { window.showToast('Rename failed', 'error'); }
})
.catch(function(){ window.showToast('Rename failed', 'error'); });
},
gwDelete() {
this.gwCtx.visible = false;
var item = this.gwCtx.item;
if (!item) return;
if (!confirm('Delete ' + item.name + '?')) return;
var self = this;
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {
method: 'DELETE'
}).then(function(r){
if (r.ok) { self.refreshTree(); }
else { window.showToast('Delete failed', 'error'); }
}).catch(function(){ window.showToast('Delete failed', 'error'); });
},
async loadSidebarTree() {
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree');
if (!r.ok) return;
if (!r.ok) {
// If API fails (e.g. no Gitea token), set a message
var container = document.getElementById('sidebar-gitea-items');
if (container) container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Connect Gitea to browse files</span></li>';
return;
}
var d = await r.json();
var items = d.tree || [];
var container = document.getElementById('sidebar-workspace-items');
var container = document.getElementById('sidebar-gitea-items');
if (!container) return;
// Ensure gitea section is visible
if (window.appState && window.appState.sectionsOpen && !window.appState.sectionsOpen.gitea) {
window.appState.sectionsOpen.gitea = true;
}
container.innerHTML = '';
var self = this;
items.forEach(function(item) {
// Build tree HTML as string and set once (more performant)
var html = '';
for (var i = 0; i < items.length; i++) {
var item = items[i];
var icon = item.type === 'folder' ? '📁' : '📄';
var li = document.createElement('li');
li.className = 'sidebar-item';
li.setAttribute('data-gitea-path', item.path);
li.setAttribute('data-gitea-type', item.type === 'folder' ? 'folder' : 'file');
li.setAttribute('data-gitea-sha', item.sha || '');
li.style.cssText = 'padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;';
// Checkbox (hidden by default, visible in multi-select mode)
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'gitea-checkbox';
cb.style.cssText = 'flex-shrink:0;margin:0;display:none;';
cb.addEventListener('click', function(ev) {
ev.stopPropagation();
var gw = document.querySelector('[x-data]').__x.$data;
gw.selectItem(item.path, li, ev);
});
li.appendChild(cb);
// Icon
var iconSpan = document.createElement('span');
iconSpan.className = 'page-icon';
iconSpan.textContent = icon;
li.appendChild(iconSpan);
// Name (with double-click rename)
var nameSpan = document.createElement('span');
nameSpan.className = 'page-name';
nameSpan.textContent = item.name;
nameSpan.addEventListener('dblclick', function(ev) {
ev.preventDefault();
ev.stopPropagation();
var gw = document.querySelector('[x-data]').__x.$data;
gw.startInlineRename(nameSpan, item.path, li);
});
li.appendChild(nameSpan);
container.appendChild(li);
});
html += '<li class="sidebar-item" data-gitea-path="' + item.path + '" data-gitea-type="' + (item.type === 'folder' ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '') + '" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
html += '<span class="page-icon">' + icon + '</span>';
html += '<span class="page-name">' + item.name + '</span>';
html += '</li>';
}
// Add Private Pages link
var sep = document.createElement('li');
sep.style.cssText = 'border-top:1px solid var(--border);margin:8px 0;';
container.appendChild(sep);
var pli = document.createElement('li');
pli.className = 'sidebar-item';
pli.style.cssText = 'padding-left:12px;cursor:pointer;';
pli.innerHTML = '<span class="page-icon">🔒</span><span class="page-name">Private Pages</span>';
pli.addEventListener('click', function(e) {
e.preventDefault(); e.stopPropagation();
var gw = document.querySelector('[x-data]').__x.$data;
gw.showPrivate = true; gw.showFile = false; gw.showEditor = false;
gw.loadPrivatePages();
});
container.appendChild(pli);
} catch(e) { console.error('Sidebar tree load failed:', e); }
html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>';
html += '<li class="sidebar-item" id="gitea-private-link" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
html += '<span class="page-icon">🔒</span><span class="page-name">Private Pages</span></li>';
container.innerHTML = html;
// Re-attach event listeners
this.setupSidebarClicks();
} catch(e) {
console.error('Gitea sidebar tree load failed:', e);
}
},
setupSidebarClicks() {
@@ -313,6 +254,7 @@ function giteaWorkspace() {
},
async loadSubdir(path, el) {
var self = this;
// Check if already loaded
var ul = el.querySelector('ul');
if (ul) {
@@ -342,8 +284,7 @@ function giteaWorkspace() {
cb.style.cssText = 'flex-shrink:0;margin:0;display:none;';
cb.addEventListener('click', function(ev) {
ev.stopPropagation();
var gw = document.querySelector('[x-data]').__x.$data;
gw.selectItem(child.path, li, ev);
self.selectItem(child.path, li, ev);
});
li.appendChild(cb);
// Icon
@@ -357,8 +298,7 @@ function giteaWorkspace() {
nameSpan.addEventListener('dblclick', function(ev) {
ev.preventDefault();
ev.stopPropagation();
var gw = document.querySelector('[x-data]').__x.$data;
gw.startInlineRename(nameSpan, child.path, li);
self.startInlineRename(nameSpan, child.path, li);
});
li.appendChild(nameSpan);
ul.appendChild(li);
@@ -459,8 +399,9 @@ function giteaWorkspace() {
},
async refreshTree() {
// Reload page to refresh sidebar tree
window.location.reload();
// Reload main tree and sidebar tree without full page reload
this.loadMainTree(this.currentPath);
this.loadSidebarTree();
},
formatSize(bytes) {
@@ -718,6 +659,161 @@ function giteaWorkspace() {
});
},
};
}
});
});
</script>
<div class="gw-main" x-data="giteaWorkspace">
<!-- File view -->
<div x-show="showFile && !showEditor" x-transition>
<div class="gw-file-toolbar">
<strong x-text="filePath || ''"></strong>
<span x-text="formatSize(fileSize)"></span>
<span style="flex:1;"></span>
<button class="btn" @click="openEditor()">✏️ Edit</button>
<button class="btn btn-danger" @click="deleteCurrentFile()">🗑 Delete</button>
</div>
<div class="gw-content">
<!-- Skeleton loading -->
<div x-show="fileLoading" class="skeleton-file">
<div class="skeleton" style="height:15px;width:60%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:80%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:40%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:75%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:55%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:90%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:35%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:65%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:45%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:70%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:50%;"></div>
</div>
<!-- Syntax-highlighted code -->
<pre x-show="!fileLoading"><code :class="'language-'+fileLanguage" x-ref="codeBlock" x-text="fileContent"></code></pre>
</div>
</div>
<!-- Editor view -->
<template x-if="showEditor">
<div>
<div class="gw-file-toolbar">
<strong>Editing: <span x-text="filePath || 'new file'"></span></strong>
<span style="flex:1;"></span>
<button class="btn" @click="showEditor=false">Cancel</button>
</div>
<div class="gw-content">
<textarea x-model="editContent"></textarea>
</div>
<div class="gw-commit-bar">
<input type="text" x-model="commitMessage" placeholder="Commit message" list="commit-history">
<datalist id="commit-history">
<template x-for="msg in commitHistory" :key="msg">
<option :value="msg">
</template>
</datalist>
<button class="btn-primary" @click="saveFile()">💾 Commit</button>
</div>
</div>
</template>
<!-- File tree browser (like local-workspace) -->
<template x-if="!showFile && !showEditor && !showPrivate">
<div class="gw-content">
<!-- Breadcrumb -->
<div class="gw-breadcrumb" style="display:flex;align-items:center;gap:4px;margin-bottom:16px;font-size:14px;color:var(--text-secondary);">
<a href="#" @click.prevent="navigateToRoot()" style="color:var(--text-secondary);text-decoration:none;" x-text="owner + '/' + repo"></a>
<template x-for="(b, i) in folderStack" :key="i">
<span style="display:contents;">
<span style="color:var(--text-tertiary);">/</span>
<a href="#" @click.prevent="navigateToFolder(i)" style="color:var(--text-secondary);text-decoration:none;" x-text="b"></a>
</span>
</template>
<span x-show="treeLoading" style="color:var(--text-tertiary);">Loading...</span>
</div>
<!-- Tree items -->
<div x-show="!treeLoading">
<template x-if="treeItems.length === 0">
<div class="empty-state">
<h2>📁 Empty</h2>
<p>No files in this folder</p>
<button class="btn btn-primary" @click="showNewFile=!showNewFile">📄+ New file</button>
</div>
</template>
<div class="ws-tree">
<template x-for="item in sortedTreeItems" :key="item.path">
<div class="ws-tree-item"
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
@contextmenu.prevent="openGwContext($event, item)"
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
<span x-text="item.type==='folder' ? '📁' : '📄'" style="font-size:16px;"></span>
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
</div>
</template>
</div>
<!-- Context menu -->
<div class="gw-context-menu"
x-show="gwCtx.visible"
:style="'left:' + gwCtx.x + 'px; top:' + gwCtx.y + 'px'"
@click.outside="gwCtx.visible = false"
style="display:none;position:fixed;background:var(--bg-secondary);border:1px solid var(--border-strong);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:1100;min-width:160px;padding:4px;">
<div class="ctx-item" @click="gwRename()">✏️ Rename</div>
<div class="ctx-item ctx-danger" @click="gwDelete()">🗑 Delete</div>
</div>
</div>
<!-- New file form -->
<div x-show="showNewFile" style="margin-top:16px;max-width:400px;">
<input type="text" x-model="newFilePath" placeholder="path/to/file.md" class="settings-input" style="margin-bottom:8px;">
<textarea x-model="newFileContent" placeholder="File content..." class="settings-input" style="height:120px;margin-bottom:8px;"></textarea>
<input type="text" x-model="newFileMsg" placeholder="Commit message" class="settings-input" style="margin-bottom:8px;">
<button class="btn btn-primary" @click="createNewFile()">💾 Create file</button>
</div>
</div>
</template>
<!-- Private pages view -->
<template x-if="showPrivate && !editingPrivate">
<div class="gw-content">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3>🔒 Private Pages</h3>
<button class="btn btn-primary" @click="newPrivate()">+ New Page</button>
</div>
<template x-if="privatePages.length === 0">
<div class="empty-state" style="padding:40px 20px;">
<p>No private pages yet. Create one to store notes linked to this project.</p>
</div>
</template>
<template x-for="pp in privatePages" :key="pp.id">
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:8px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;"
@click="openPrivate(pp.id)">
<div>
<div style="font-weight:500;" x-text="pp.title"></div>
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
</div>
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">🗑</button>
</div>
</template>
</div>
</template>
<!-- Private page editor -->
<template x-if="editingPrivate">
<div>
<div class="gw-file-toolbar">
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
<span style="flex:1;"></span>
<button class="btn" @click="savePrivate()">💾 Save</button>
<button class="btn" @click="editingPrivate=null;editingPrivateTitle='';editingPrivateContent='';">Cancel</button>
</div>
<div class="gw-content">
<input x-model="editingPrivateTitle" placeholder="Page title" style="width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:16px;margin-bottom:12px;outline:none;">
<textarea x-model="editingPrivateContent" placeholder="Start writing…" style="min-height:60vh;"></textarea>
</div>
</div>
</template>
</div>
{% endblock %}
+321 -101
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block page_icon %}📚{% endblock %}
{% block page_title %}Library{% endblock %}
{% block title_prefix %}Library{% endblock %}
{% block content %}
<div class="page-title-area">
@@ -10,122 +11,341 @@
</div>
</div>
<!-- Library tabs + table — single Alpine scope -->
<div x-data="{ tab: 'recents' }">
<div x-data="libraryPage()" style="padding: 0 24px;">
<div class="view-tabs">
<button class="view-tab" :class="{ active: tab === 'recents' }" @click="tab = 'recents'">
<span class="tab-icon">🕒</span> Recents
</button>
{% if lib_has_favorites %}
<button class="view-tab" :class="{ active: tab === 'favorites' }" @click="tab = 'favorites'">
<span class="tab-icon">⭐</span> Favorites
</button>
{% endif %}
{% if lib_has_shared %}
<button class="view-tab" :class="{ active: tab === 'shared' }" @click="tab = 'shared'">
<span class="tab-icon">👥</span> Shared
</button>
{% endif %}
<button class="view-tab" :class="{ active: tab === 'private' }" @click="tab = 'private'">
<span class="tab-icon">🔒</span> Private
</button>
</div>
<!-- Toolbar -->
<div class="view-toolbar">
<div class="view-toolbar-spacer"></div>
<button class="toolbar-btn" title="Filter">☰</button>
<button class="toolbar-btn" title="Search">🔍</button>
<button class="toolbar-btn" title="Properties">⫶</button>
<button class="btn-new" @click="newPage()">New page</button>
</div>
<!-- Table -->
<div style="padding: 0 24px;">
<!-- Column headers -->
<div class="library-table-header">
<span class="lib-col lib-col-name">📄 Page name</span>
<span class="lib-col lib-col-author">👤 Created by</span>
<span class="lib-col lib-col-source">↗ Source</span>
<span class="lib-col lib-col-edited">🕒 Last edited time</span>
<span class="lib-col lib-col-visited">🕒 Last visited time</span>
<!-- ── Tab bar ── -->
<div class="view-tabs">
<button class="view-tab" :class="{ active: tab === 'recents' }" @click="switchTab('recents')">
<span class="tab-icon">🕒</span> Recents
</button>
<button class="view-tab" :class="{ active: tab === 'favorites' }" @click="switchTab('favorites')">
<span class="tab-icon">⭐</span> Favorites
</button>
<button class="view-tab" :class="{ active: tab === 'shared' }" @click="switchTab('shared')">
<span class="tab-icon">👥</span> Shared
</button>
<button class="view-tab" :class="{ active: tab === 'published' }" @click="switchTab('published')">
<span class="tab-icon">🌐</span> Published
</button>
<button class="view-tab" :class="{ active: tab === 'private' }" @click="switchTab('private')">
<span class="tab-icon">🔒</span> Private
</button>
<button class="view-tab" :class="{ active: tab === 'workspace' }" @click="switchTab('workspace')">
<span class="tab-icon">📁</span> Workspace
</button>
</div>
{% macro page_row(page) %}
<a href="{{ page.url }}" class="library-table-row">
<span class="lib-col lib-col-name">
<span class="page-icon">{{ page.icon }}</span>
<span>{{ page.name }}</span>
</span>
<span class="lib-col lib-col-author">👤 {{ page.created_by or 'You' }}</span>
<span class="lib-col lib-col-source">{{ page.source or '🔒 Private' }}</span>
<span class="lib-col lib-col-edited">{{ page.last_edited }}</span>
<span class="lib-col lib-col-visited">{{ page.last_visited or page.last_edited }}</span>
</a>
{% endmacro %}
<!-- Recents -->
<div x-show="tab === 'recents'">
{% for page in lib_recent %}{{ page_row(page) }}{% endfor %}
{% if not lib_recent %}
<div class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">🕒</div>
<h3>No recent pages</h3>
<!-- ── Toolbar with source_type filter ── -->
<div class="view-toolbar">
<div class="source-filter-group">
<button class="source-filter-btn" :class="{ active: sourceType === 'all' }" @click="setSourceType('all')">All</button>
<button class="source-filter-btn" :class="{ active: sourceType === 'local' }" @click="setSourceType('local')">Local</button>
<button class="source-filter-btn" :class="{ active: sourceType === 'gitea' }" @click="setSourceType('gitea')">Gitea</button>
<button class="source-filter-btn" :class="{ active: sourceType === 'github' }" @click="setSourceType('github')">GitHub</button>
</div>
{% endif %}
<div class="view-toolbar-spacer"></div>
<button class="toolbar-btn" title="Search">🔍</button>
<button class="btn-new" @click="newPage()">New page</button>
</div>
<!-- Favorites -->
<div x-show="tab === 'favorites'">
{% for page in lib_favorites %}{{ page_row(page) }}{% endfor %}
{% if not lib_favorites %}
<div class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">⭐</div>
<h3>No favorites</h3>
<p>Right-click a page in the sidebar and select "Add to Favorites".</p>
</div>
{% endif %}
<!-- ── Loading state ── -->
<div x-show="loading" class="lib-loading">
<div class="spinner"></div>
<p>Loading…</p>
</div>
<!-- Shared -->
<div x-show="tab === 'shared'">
{% for page in lib_shared %}{{ page_row(page) }}{% endfor %}
{% if not lib_shared %}
<div class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">👥</div>
<h3>No shared pages</h3>
<p>Shared pages from your team will appear here.</p>
</div>
{% endif %}
<!-- ── Card grid ── -->
<div x-show="!loading" class="library-card-grid">
<template x-for="item in items" :key="item.id">
<div class="library-card" @click="openItem(item)">
<!-- Icon -->
<div class="library-card-icon" x-text="item.icon"></div>
<!-- Info -->
<div class="library-card-info">
<div class="library-card-title" x-text="item.title"></div>
<div class="library-card-meta">
<span class="source-badge" :class="'source-' + item.source_type" x-text="item.source_badge"></span>
<span class="library-card-workspace" x-show="item.workspace_name && item.workspace_name !== 'Private'" x-text="item.workspace_name"></span>
<span class="library-card-date" x-text="formatDate(item.updated_at)"></span>
</div>
</div>
</div>
</template>
</div>
<!-- Private -->
<div x-show="tab === 'private'">
{% for page in lib_private %}{{ page_row(page) }}{% endfor %}
{% if not lib_private %}
<div class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">🔒</div>
<h3>No private pages</h3>
<p>Create a new page with the + button in the sidebar.</p>
</div>
{% endif %}
<!-- ── Empty state ── -->
<div x-show="!loading && items.length === 0" class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;" x-text="emptyIcon"></div>
<h3 x-text="emptyTitle"></h3>
<p x-text="emptyText"></p>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<style>
/* ── Source filter pill buttons ── */
.source-filter-group {
display: flex;
gap: 2px;
background: var(--bg-tertiary);
border-radius: 6px;
padding: 2px;
}
.source-filter-btn {
padding: 4px 12px;
border-radius: 5px;
font-size: 13px;
color: var(--text-dim);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.source-filter-btn:hover {
color: var(--text-primary);
}
.source-filter-btn.active {
background: var(--bg-primary);
color: var(--text-primary);
box-shadow: 0 1px 2px rgba(0,0,0,.2);
}
/* ── Card grid ── */
.library-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 8px;
margin-top: 12px;
}
.library-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.library-card:hover {
background: var(--bg-tertiary);
border-color: var(--border-light);
}
.library-card-icon {
font-size: 24px;
line-height: 1;
flex-shrink: 0;
margin-top: 2px;
}
.library-card-info {
flex: 1;
min-width: 0;
}
.library-card-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 6px;
}
.library-card-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.source-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.source-local {
background: rgba(100, 180, 100, 0.15);
color: #64b464;
}
.source-gitea {
background: rgba(100, 160, 220, 0.15);
color: #64a0dc;
}
.source-github {
background: rgba(180, 140, 220, 0.15);
color: #b48cdc;
}
.library-card-workspace {
font-size: 12px;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-card-date {
font-size: 12px;
color: var(--text-dim);
margin-left: auto;
white-space: nowrap;
}
/* ── Loading spinner ── */
.lib-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 0;
color: var(--text-dim);
gap: 16px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--text-dim);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
<script>
function newPage() {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch('/board/api/pages?section=Private', {
method: 'POST',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
})
.then(r => { if (!r.ok) throw new Error('CSRF error'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { window.showToast('Failed: ' + err.message, 'error'); });
}
function libraryPage() {
return {
tab: 'recents',
sourceType: 'all',
items: [],
loading: false,
get emptyIcon() {
const icons = {
recents: '🕒', favorites: '⭐', shared: '👥',
published: '🌐', private: '🔒', workspace: '📁'
};
return icons[this.tab] || '📄';
},
get emptyTitle() {
const titles = {
recents: 'No recent pages',
favorites: 'No favorites',
shared: 'No shared pages',
published: 'No published pages',
private: 'No private pages',
workspace: 'No workspace pages'
};
return titles[this.tab] || 'No items';
},
get emptyText() {
const texts = {
recents: 'Pages you\'ve recently viewed or edited will appear here.',
favorites: 'Right-click a page in the sidebar and select "Add to Favorites".',
shared: 'Shared pages from your team will appear here.',
published: 'Pages published to the web will appear here.',
private: 'Create a new page with the + button in the sidebar.',
workspace: 'Pages inside workspaces will appear here.'
};
return texts[this.tab] || '';
},
init() {
// Read ?tab= from URL
const params = new URLSearchParams(window.location.search);
const urlTab = params.get('tab');
if (urlTab && ['recents','favorites','shared','published','private','workspace'].includes(urlTab)) {
this.tab = urlTab;
}
const urlSource = params.get('source_type');
if (urlSource && ['all','local','gitea','github'].includes(urlSource)) {
this.sourceType = urlSource;
}
this.loadTab();
},
switchTab(t) {
if (this.tab === t) return;
this.tab = t;
this.items = [];
this.updateUrl();
this.loadTab();
},
setSourceType(st) {
if (this.sourceType === st) return;
this.sourceType = st;
this.items = [];
this.updateUrl();
this.loadTab();
},
updateUrl() {
const params = new URLSearchParams();
if (this.tab !== 'recents') params.set('tab', this.tab);
if (this.sourceType !== 'all') params.set('source_type', this.sourceType);
const qs = params.toString();
const newUrl = qs ? `/library?${qs}` : '/library';
window.history.replaceState({}, '', newUrl);
},
async loadTab() {
this.loading = true;
try {
const res = await fetch(`/api/library/${this.tab}?source_type=${this.sourceType}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
this.items = data.items || [];
} catch (e) {
console.error('Library load error:', e);
this.items = [];
} finally {
this.loading = false;
}
},
openItem(item) {
if (!item || !item.url) return;
window.location.href = item.url;
},
formatDate(d) {
if (!d) return '';
try {
const date = new Date(d);
if (isNaN(date.getTime())) return d;
const now = new Date();
const diff = now - date;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
} catch {
return d;
}
},
newPage() {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch('/board/api/pages?section=Private', {
method: 'POST',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
})
.then(r => { if (!r.ok) throw new Error('CSRF error'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { window.showToast('Failed: ' + err.message, 'error'); });
}
};
}
</script>
{% endblock %}
+51 -33
View File
@@ -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 %}
<header class="topbar">
<button class="hamburger-btn" @click="sidebarCollapsed ? (sidebarCollapsed = false) : (mobileSidebarOpen = true, sidebarCollapsed = false)" title="Menu">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="topbar-left">
<span class="page-title-topbar">
{% if breadcrumb %}
{% for b in breadcrumb %}
/ <a href="/local-workspace?folder={{ b.id }}" style="color:rgba(255,255,255,.8);text-decoration:none;">{{ b.name }}</a>
{% endfor %}
{% endif %}
</span>
</div>
</header>
{% 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<ids.length; i++) {
await fetch('/api/local-workspace/items/' + ids[i], { method: 'DELETE' });
}
this.clearSelection();
this._reloadAfterAction();
// Show undo banner for bulk delete
this.undoItemId = ids.join(',');
this.undoMessage = 'Deleted ' + ids.length + ' item(s)';
this.undoVisible = true;
this.undoTimeout = setTimeout(function() {
self.undoVisible = false;
self.undoItemId = null;
}, 5000);
},
clearSelection() {
for (var k in this.selectedIds) delete this.selectedIds[k];
@@ -1110,6 +1109,10 @@ window._wsInitData = (function() {
_reloadAfterAction() {
this._saveExpandedState();
this._loadFolder(this.currentFolder);
// Directly refresh sidebar tree
if (window.appState && window.appState.refreshSidebarTree) {
window.appState.refreshSidebarTree();
}
},
// ── Inline Rename ──
@@ -1204,15 +1207,12 @@ window._wsInitData = (function() {
this.undoItemId = node.db_id || node.id;
this.undoMessage = 'Deleted "' + (node.name || 'item') + '"';
this.undoVisible = true;
// Soft-delete: actually delete after timeout
// Delete via API
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, { method:'DELETE' });
if (!r.ok) { this.undoVisible = false; return; }
// Remove from tree immediately
this._removeFromTree(this.tree, this.undoItemId);
if (!this.searchQuery && !this.filterType && !this.sortBy) this.displayTree = [...this.tree];
else if (this.sortBy) this.doSort();
else this.doFilter();
// Auto-dismiss + permanent after 5s
// Reload from API for reliability (sidebar refresh handled by _reloadAfterAction)
this._reloadAfterAction();
// Auto-dismiss undo banner after 5s
this.undoTimeout = setTimeout(function() {
self.undoVisible = false;
self.undoItemId = null;
@@ -1221,11 +1221,23 @@ window._wsInitData = (function() {
undoDelete() {
if (this.undoTimeout) clearTimeout(this.undoTimeout);
this.undoVisible = false;
var id = this.undoItemId;
if (!id) return;
// Recreate the item (we can't really undo a DELETE, so reload)
this._reloadAfterAction();
var ids = (this.undoItemId || '').toString().split(',');
this.undoItemId = null;
if (!ids.length || !ids[0]) return;
// Restore via API (soft-delete undo) — single or bulk
var self = this;
var restored = 0;
var total = ids.length;
function restoreOne(i) {
if (i >= total) {
self._reloadAfterAction();
return;
}
fetch('/api/local-workspace/items/' + ids[i] + '/restore', { method: 'POST' })
.then(function(r) { if (r.ok) restored++; })
.finally(function() { restoreOne(i + 1); });
}
restoreOne(0);
},
_removeFromTree(nodes, id) {
for (var i=0; i<nodes.length; i++) {
@@ -1554,6 +1566,10 @@ window._wsInitData = (function() {
this.doFilter();
}
this._saveExpandedState();
// Directly refresh sidebar tree
if (window.appState && window.appState.refreshSidebarTree) {
window.appState.refreshSidebarTree();
}
}
},
@@ -1980,14 +1996,16 @@ window._ctxMenuData = {
ctxAvailTags: [],
ctxNewTagColor: '#787774',
ctxTagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
ctxMenuOpenPage() { window._wsInitData.ctxMenuOpenPage(); },
ctxMenuOpenFolder() { window._wsInitData.ctxMenuOpenFolder(); },
ctxMenuRename() { window._wsInitData.ctxMenuRename(); },
ctxMenuDuplicate() { window._wsInitData.ctxMenuDuplicate(); },
ctxMenuDelete() { window._wsInitData.ctxMenuDelete(); },
ctxAddExistingTag(t) { window._wsInitData.ctxAddExistingTag(t); },
ctxAddNewTag(n, c) { window._wsInitData.ctxAddNewTag(n, c); },
ctxRemoveTag(id) { window._wsInitData.ctxRemoveTag(id); },
// Use Alpine proxy (window._wsData) for reactivity, not raw object (window._wsInitData)
_ws() { return window._wsData || window._wsInitData; },
ctxMenuOpenPage() { this._ws().ctxMenuOpenPage(); },
ctxMenuOpenFolder() { this._ws().ctxMenuOpenFolder(); },
ctxMenuRename() { this._ws().ctxMenuRename(); },
ctxMenuDuplicate() { this._ws().ctxMenuDuplicate(); },
ctxMenuDelete() { this._ws().ctxMenuDelete(); },
ctxAddExistingTag(t) { this._ws().ctxAddExistingTag(t); },
ctxAddNewTag(n, c) { this._ws().ctxAddNewTag(n, c); },
ctxRemoveTag(id) { this._ws().ctxRemoveTag(id); },
};
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
</script>
+178 -42
View File
@@ -1,41 +1,12 @@
{% extends "base.html" %} {% block page_icon %}📄{% endblock %} {% block
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
<header class="topbar" style="background:var(--bg-primary);">
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="topbar-left">
<span class="breadcrumb" style="color:rgba(255,255,255,.5);font-size:13px">{{ page.title }}</span>
</div>
</header>
{% set page_icon = "📄" %}
{% set page_title = page.title %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && (window.E.activityOpen = !window.E.activityOpen)">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && (window.E.shareOpen = !window.E.shareOpen)">🔒 Share ▾</button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">🔗</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()">⭐</button><button class="topbar-btn" @click="window.E && (window.E.moreOpen = !window.E.moreOpen)" style="position:relative">⋯</button>' %}
{% include '_header.html' %}
{% endblock %} {% block content %}
<div class="page-editor-wrapper" x-data="editorState()">
<!-- ═══════════ Top Action Bar ═══════════ -->
<div class="page-topbar">
<div class="page-topbar-right" style="position: relative">
<span class="topbar-edited">Edited just now</span>
<button
class="page-action-btn share-btn"
@click="shareOpen = !shareOpen"
>
🔒 Share <span class="chevron-down">▾</span>
</button>
<button
class="page-action-btn"
@click="copyPageLink"
title="Copy link"
>
🔗
</button>
<button class="page-action-btn star-btn" @click="toggleFavorite">
<span
x-text="favorited ? '⭐' : '☆'"
:class="{ starred: favorited }"
></span>
</button>
<button class="page-action-btn" @click="moreOpen = !moreOpen">
⋮
</button>
<!-- Share/More dialogs (in content area, triggered by header buttons) -->
<!-- ═══════════ Share / Publish Dialog ═══════════ -->
<div
@@ -75,9 +46,9 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
class="sd-input"
placeholder="Add people, groups or emails..."
x-model="inviteEmail"
@keydown.enter="invitePerson"
@keydown.enter="shareInvite()"
/>
<button class="sd-btn-primary" @click="invitePerson">
<button class="sd-btn-primary" @click="shareInvite()">
Invite
</button>
</div>
@@ -268,7 +239,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
</p>
<button
class="sd-btn-primary sd-btn-full"
@click="pagePublished = true"
@click="publishPage()"
>
Publish
</button>
@@ -354,7 +325,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
<button
class="sd-danger-btn"
@click="pagePublished = false"
@click="unpublishPage()"
>
Unpublish
</button>
@@ -362,6 +333,18 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
</div>
</div>
<!-- ═══════════ Activity Popover ═══════════ -->
<div class="activity-popover"
x-show="activityOpen"
@click.outside="activityOpen = false"
x-transition.opacity.scale.origin.top.right
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-secondary);border:1px solid var(--border-strong);border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.5);z-index:1000;width:280px;padding:12px;">
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">Edited <span x-text="timeAgo"></span></div>
<template x-if="pageCreated">
<div style="font-size:12px;color:var(--text-tertiary);">Created <span x-text="formatDate(pageCreated)"></span></div>
</template>
</div>
<!-- More menu dropdown -->
<div
class="more-menu"
@@ -379,8 +362,30 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
🗑️ Move to Trash
</div>
</div>
</div>
</div>
<!-- ═══════════ Move to Dialog ═══════════ -->
<div class="move-dialog"
x-show="moveOpen"
@click.outside="moveOpen = false"
x-transition.opacity.scale
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-secondary);border:1px solid var(--border-strong);border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.5);z-index:1000;width:320px;padding:16px;">
<h3 style="font-size:14px;margin-bottom:12px;">Move to workspace</h3>
<div x-show="moveLoading" style="color:var(--text-tertiary);text-align:center;padding:20px;">Loading...</div>
<div x-show="!moveLoading" style="max-height:240px;overflow-y:auto;">
<template x-for="ws in moveWorkspaces" :key="ws.id">
<div class="move-ws-item"
@click="doMove(ws.id)"
style="display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;">
<span>📁</span>
<span x-text="ws.name" style="flex:1;"></span>
<span style="font-size:11px;color:var(--text-tertiary);" x-text="ws.page_count + ' pages'"></span>
</div>
</template>
<div x-show="moveWorkspaces.length === 0" style="color:var(--text-tertiary);text-align:center;padding:12px;">
No workspaces available
</div>
</div>
</div>
<!-- ═══════════ Page Content ═══════════ -->
<div class="page-cover-area">
@@ -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')}&section=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{window.showToast('Duplicate failed','error');});},
movePage(){this.moreOpen=false;},
movePage(){
this.moreOpen=false;
this.moveOpen = true;
this.moveLoading = true;
var self = this;
fetch('/api/workspaces')
.then(function(r){ return r.json(); })
.then(function(d){
self.moveWorkspaces = d.workspaces || [];
self.moveLoading = false;
})
.catch(function(){ self.moveLoading = false; });
},
doMove(wsId) {
this.moveOpen = false;
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
var self = this;
fetch('/api/pages/' + this.pid + '/move', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
body: JSON.stringify({ workspace_id: wsId })
}).then(function(r){ return r.json(); })
.then(function(d){
if (d.status === 'ok') {
self.showToast('Moved to workspace');
setTimeout(function(){ window.location.href = '/local-workspace'; }, 800);
} else {
self.showToast(d.detail || 'Move failed');
}
})
.catch(function(){ self.showToast('Move failed'); });
},
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
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;
}
}
</script>
{% endblock %}
+60 -15
View File
@@ -1,16 +1,12 @@
{% extends "base.html" %}
{% block page_title %}Settings{% endblock %}
{% block title_prefix %}Settings{% endblock %}
{% block page_icon %}⚙️{% endblock %}
{% block topbar %}
<header class="topbar">
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="topbar-left">
<span class="page-title-topbar">⚙️ Settings</span>
</div>
</header>
{% set page_icon = "⚙" %}
{% set page_title = "Settings" %}
{% include '_header.html' %}
{% endblock %}
{% block content %}
@@ -80,7 +76,7 @@
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
<div class="settings-panel" style="position:relative;">
<button class="settings-close" @click="window.history.back()" title="Close">×</button>
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
<!-- Left navigation -->
<div class="settings-nav">
@@ -318,9 +314,10 @@
<p class="section-desc">Integrations and feature toggles.</p>
<div class="setting-group">
<h3>Integrations</h3>
<!-- Gitea -->
<div class="setting-row">
<div>
<div class="setting-label">Gitea</div>
<div class="setting-label">🦎 Gitea <span x-show="authMethod==='gitea'" style="font-size:10px;background:var(--accent);color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">Primary</span></div>
<div class="setting-desc">
<span x-show="giteaLinked">Connected to git.dracodev.net</span>
<span x-show="!giteaLinked">Connect your Gitea account to access projects</span>
@@ -328,16 +325,24 @@
</div>
<div class="setting-control">
<span x-show="giteaLinked" style="font-size:12px;color:#0F7B6C;">✅ Active</span>
<button x-show="giteaLinked" class="btn-sm" style="color:var(--danger);" @click="disconnectGitea()">Disconnect</button>
<button x-show="giteaLinked && authMethod!=='gitea'" class="btn-sm" style="color:var(--danger);" @click="disconnectGitea()">Disconnect</button>
<a x-show="!giteaLinked" class="btn-sm" href="/auth/login?provider=gitea&mode=link" style="text-decoration:none;color:var(--accent);">🔗 Connect</a>
</div>
</div>
<!-- GitHub -->
<div class="setting-row">
<div>
<div class="setting-label">GitHub</div>
<div class="setting-desc">Connect your GitHub account</div>
<div class="setting-label">🐙 GitHub <span x-show="authMethod==='github'" style="font-size:10px;background:var(--accent);color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">Primary</span></div>
<div class="setting-desc">
<span x-show="githubLinked">Connected to github.com</span>
<span x-show="!githubLinked">Connect your GitHub account to access repositories</span>
</div>
</div>
<div class="setting-control">
<span x-show="githubLinked" style="font-size:12px;color:#0F7B6C;">✅ Active</span>
<button x-show="githubLinked && authMethod!=='github'" class="btn-sm" style="color:var(--danger);" @click="disconnectGithub()">Disconnect</button>
<a x-show="!githubLinked" class="btn-sm" href="/auth/login?provider=github&mode=link" style="text-decoration:none;color:var(--accent);">🔗 Connect</a>
</div>
<div class="setting-control"><span style="font-size:12px;color:var(--text-dim);">Coming soon</span></div>
</div>
</div>
</div>
@@ -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);
}
},
};
});
});
+1
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block page_icon %}🗑️{% endblock %}
{% block page_title %}Trash{% endblock %}
{% block title_prefix %}Trash{% endblock %}
{% block content %}
<div class="page-title-area">
+5 -13
View File
@@ -1,21 +1,13 @@
{% extends "base.html" %}
{% block page_title %}Workspace{% endblock %}
{% block title_prefix %}Workspace{% endblock %}
{% block page_icon %}🏠{% endblock %}
{% block topbar %}
<header class="topbar">
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="topbar-left">
<span class="breadcrumb" style="color:rgba(255,255,255,.5)">Workspace</span>
<span class="page-title-topbar">Projects</span>
</div>
<div class="topbar-right">
<button class="page-action-btn" onclick="window.location='/auth/login?provider=local'" title="Login">🔑</button>
<button class="page-action-btn" onclick="window.location='/accounts/settings'" title="Settings">⚙</button>
</div>
</header>
{% set page_icon = "🏠" %}
{% set page_title = "Workspace — Projects" %}
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">🔑</a><a href="/accounts/settings" class="topbar-btn" title="Settings">⚙</a>' %}
{% include '_header.html' %}
{% endblock %}
{% block content %}
+6 -13
View File
@@ -1,20 +1,13 @@
{% extends "base.html" %}
{% block page_title %}Workspaces{% endblock %}
{% block title_prefix %}Workspaces{% endblock %}
{% block page_icon %}🏠{% endblock %}
{% block topbar %}
<header class="topbar">
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="topbar-left">
<span class="breadcrumb" style="color:rgba(255,255,255,.5)">Home</span>
<span class="page-title-topbar">Workspaces</span>
</div>
<div class="topbar-right">
<button class="page-action-btn" onclick="window.location='/accounts/settings'" title="Settings">⚙</button>
</div>
</header>
{% set breadcrumb_items = [{"label": "Home", "url": "/workspaces"}, {"label": "Workspaces", "url": None}] %}
{% set page_icon = "🏠" %}
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">🔑</a><a href="/accounts/settings" class="topbar-btn" title="Settings">⚙</a>' %}
{% include '_header.html' %}
{% endblock %}
{% block content %}
@@ -87,7 +80,7 @@
<!-- Not linked -->
<div class="gitea-empty" x-show="giteaStatus==='not_linked'">
<div>Connect your Gitea account to see your projects.</div>
<a class="gitea-connect" href="/auth/login?provider=gitea">🔗 Connect Gitea</a>
<a class="gitea-connect" href="/auth/login?provider=gitea&mode=link">🔗 Connect Gitea</a>
<span style="margin:0 8px;color:var(--text-dim);">or</span>
<a class="gitea-connect" href="/auth/login?provider=gitea">📝 Register with Gitea</a>
</div>
+98
View File
@@ -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);
}
+28 -28
View File
@@ -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):