Merge branch 'main' of https://git.dracodev.net/bruno/flowdeck
This commit is contained in:
+254
-3
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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:
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""GitHub OAuth — status and disconnect routes."""
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["github"], prefix="/api/github")
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def github_status(request: Request):
|
||||
"""Check if the current user has GitHub linked."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return {"linked": False}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='github' AND access_token IS NOT NULL AND access_token != ''",
|
||||
(user["id"],)
|
||||
).fetchone()
|
||||
return {"linked": row is not None}
|
||||
|
||||
|
||||
@router.delete("/disconnect")
|
||||
async def disconnect_github(request: Request):
|
||||
"""Remove all GitHub OAuth tokens for the current user."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=? AND provider='github'", (user["id"],))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,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}
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,46 @@
|
||||
{% macro render_workspace_tree(pages, depth=0) %}
|
||||
{% for page in pages %}
|
||||
{% set safe_name = page.name | replace("'", "\\'") %}
|
||||
<li class="sidebar-item ws-tree-item"
|
||||
data-id="{{ page.db_id }}"
|
||||
data-is-folder="{{ 'true' if page.is_folder else 'false' }}"
|
||||
data-depth="{{ depth }}"
|
||||
data-name="{{ safe_name }}"
|
||||
:class="{ active: activeNodeId === {{ page.db_id }} }"
|
||||
draggable="true"
|
||||
@dragstart="dragStart($event, {{ page.db_id }})"
|
||||
@dragover.prevent="dragOver($event, {{ page.db_id }}, {{ 'true' if page.is_folder else 'false' }})"
|
||||
@dragleave="dragLeave($event)"
|
||||
@drop.prevent="drop($event, {{ page.db_id }})"
|
||||
style="padding-left:{{ 4 + depth * 12 }}px;"
|
||||
@contextmenu.prevent="showWsContext($event, {{ page.db_id }}, '{{ safe_name }}', {{ 'true' if page.is_folder else 'false' }})"
|
||||
@touchstart="wsTouchStart($event)"
|
||||
@touchend="wsTouchEnd($event, {{ page.db_id }}, '{{ safe_name }}', {{ 'true' if page.is_folder else 'false' }})"
|
||||
@touchmove="wsTouchMove($event)">
|
||||
{% if page.is_folder %}
|
||||
<button class="tree-chevron"
|
||||
@click.stop="toggleTreeFolder({{ page.db_id }})"
|
||||
:class="{ open: expandedFolders[{{ page.db_id }}] }"
|
||||
title="Toggle folder">▶</button>
|
||||
{% else %}
|
||||
<span style="width:12px;flex-shrink:0;"></span>
|
||||
{% endif %}
|
||||
<span class="page-icon">{{ page.icon }}</span>
|
||||
{% if page.is_folder %}
|
||||
<span class="page-name tree-folder-link"
|
||||
@click.stop="navigateToFolder({{ page.db_id }})"
|
||||
draggable="false"
|
||||
title="Open folder">{{ page.name }}{% if page.child_count %} <span class="child-count">({{ page.child_count }})</span>{% endif %}</span>
|
||||
{% else %}
|
||||
<a href="/pages/{{ page.db_id }}" class="page-name" draggable="false">{{ page.name }}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% if page.children %}
|
||||
<li x-show="expandedFolders[{{ page.db_id }}]" x-transition style="list-style:none;margin:0;padding:0;">
|
||||
<ul style="list-style:none;margin:0;padding:0;">
|
||||
{{ render_workspace_tree(page.children, depth + 1) }}
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}👤{% endblock %}
|
||||
{% block page_title %}Accounts{% endblock %}
|
||||
{% block title_prefix %}Accounts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-title-area">
|
||||
|
||||
+225
-103
@@ -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,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,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
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -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')}§ion=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{window.showToast('Duplicate failed','error');});},
|
||||
movePage(){this.moreOpen=false;},
|
||||
movePage(){
|
||||
this.moreOpen=false;
|
||||
this.moveOpen = true;
|
||||
this.moveLoading = true;
|
||||
var self = this;
|
||||
fetch('/api/workspaces')
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
self.moveWorkspaces = d.workspaces || [];
|
||||
self.moveLoading = false;
|
||||
})
|
||||
.catch(function(){ self.moveLoading = false; });
|
||||
},
|
||||
doMove(wsId) {
|
||||
this.moveOpen = false;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var self = this;
|
||||
fetch('/api/pages/' + this.pid + '/move', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||
body: JSON.stringify({ workspace_id: wsId })
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
if (d.status === 'ok') {
|
||||
self.showToast('Moved to workspace');
|
||||
setTimeout(function(){ window.location.href = '/local-workspace'; }, 800);
|
||||
} else {
|
||||
self.showToast(d.detail || 'Move failed');
|
||||
}
|
||||
})
|
||||
.catch(function(){ self.showToast('Move failed'); });
|
||||
},
|
||||
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
|
||||
|
||||
onKd(e){
|
||||
@@ -736,5 +855,22 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
};
|
||||
}
|
||||
document.addEventListener('mouseup',()=>{setTimeout(()=>{const s=window.getSelection();if(!s||s.isCollapsed)return;const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;const d=ed.__x.$data;const ct=document.getElementById('_blocksCt');if(!ct?.contains(s.anchorNode))return;const bel=s.anchorNode.parentElement?.closest('[data-bid]');if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);}},80);});
|
||||
function blocksToMarkdown(b) {
|
||||
var c = b.content || '';
|
||||
switch(b.type) {
|
||||
case 'heading_1': return '# ' + c;
|
||||
case 'heading_2': return '## ' + c;
|
||||
case 'heading_3': return '### ' + c;
|
||||
case 'heading_4': return '#### ' + c;
|
||||
case 'bulleted_list': return '- ' + c;
|
||||
case 'numbered_list': return '1. ' + c;
|
||||
case 'to_do': return (b.checked ? '- [x] ' : '- [ ] ') + c;
|
||||
case 'quote': return '> ' + c;
|
||||
case 'divider': return '---';
|
||||
case 'code': return '```' + (b.language || '') + '\n' + c + '\n```';
|
||||
case 'toggle': return '## ' + c;
|
||||
default: return c;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+60
-15
@@ -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,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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user