merge: feat/onboarding → develop (v4.0.1)
This commit is contained in:
+59
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
@@ -84,3 +84,61 @@ async def pwa_manifest():
|
|||||||
"theme_color": "#191919",
|
"theme_color": "#191919",
|
||||||
"icons": [{"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"}],
|
"icons": [{"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"}],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════ API aliases (v4.0.1) ═══════════
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/pages")
|
||||||
|
async def api_pages_alias(request: Request):
|
||||||
|
"""Alias /api/pages → /board/api/pages for API path consistency."""
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
qs = str(request.url.query)
|
||||||
|
target = f"/board/api/pages{'?' + qs if qs else ''}"
|
||||||
|
return RedirectResponse(url=target, status_code=307)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/pages")
|
||||||
|
async def api_pages_post_alias(request: Request):
|
||||||
|
"""Alias POST /api/pages → /board/api/pages for API path consistency."""
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
return RedirectResponse(url="/board/api/pages", status_code=307)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════ Styled 404 handler ═══════════
|
||||||
|
|
||||||
|
|
||||||
|
NOT_FOUND_HTML = """<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>404 — FlowDeck</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#191919;--text:#e0e0e0;--text-dim:#999;--accent:#2383E2}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center}
|
||||||
|
.box h1{font-size:6rem;font-weight:800;opacity:.08;line-height:1}
|
||||||
|
.box p{font-size:18px;color:var(--text-dim);margin:8px 0 24px}
|
||||||
|
.box a{color:var(--accent);text-decoration:none;font-size:14px}
|
||||||
|
.box a:hover{text-decoration:underline}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="box">
|
||||||
|
<h1>404</h1>
|
||||||
|
<p>This page doesn't exist or has been moved.</p>
|
||||||
|
<a href="/">← Back to FlowDeck</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(404)
|
||||||
|
async def not_found_handler(request: Request, exc):
|
||||||
|
"""Styled 404 page matching the FlowDeck dark theme."""
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
# API routes should get JSON, not HTML
|
||||||
|
if request.url.path.startswith("/api/") or request.url.path.startswith("/board/api/"):
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse({"detail": "Not found"}, status_code=404)
|
||||||
|
return HTMLResponse(NOT_FOUND_HTML, status_code=404)
|
||||||
|
|||||||
@@ -85,6 +85,27 @@ async function handleLogin(e){e.preventDefault();const email=document.getElement
|
|||||||
</html>"""
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/register")
|
||||||
|
async def register_page(request: Request):
|
||||||
|
"""Show the registration page (local login page with register tab active)."""
|
||||||
|
return HTMLResponse(LOCAL_LOGIN_HTML.replace(
|
||||||
|
'class="tab active" onclick="switchTab(\'login\')"',
|
||||||
|
'class="tab" onclick="switchTab(\'login\')"'
|
||||||
|
).replace(
|
||||||
|
'class="tab" onclick="switchTab(\'register\')"',
|
||||||
|
'class="tab active" onclick="switchTab(\'register\')"'
|
||||||
|
).replace(
|
||||||
|
'let mode=\'login\';',
|
||||||
|
'let mode=\'register\';'
|
||||||
|
).replace(
|
||||||
|
'id="name-group" style="display:none"',
|
||||||
|
'id="name-group" style="display:block"'
|
||||||
|
).replace(
|
||||||
|
'id="submit-btn">Login<',
|
||||||
|
'id="submit-btn">Register<'
|
||||||
|
), status_code=200)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login")
|
@router.get("/login")
|
||||||
async def login(request: Request, provider: str = Query("gitea")):
|
async def login(request: Request, provider: str = Query("gitea")):
|
||||||
"""Redirect to OAuth2 authorize page or show local login page."""
|
"""Redirect to OAuth2 authorize page or show local login page."""
|
||||||
|
|||||||
@@ -404,11 +404,29 @@ async def dashboard(
|
|||||||
search: str = Query(default=""),
|
search: str = Query(default=""),
|
||||||
show_archived: bool = Query(default=False),
|
show_archived: bool = Query(default=False),
|
||||||
):
|
):
|
||||||
auth = _get_user_or_redirect(request)
|
"""Smart root route: landing for visitors, local workspace for new users, dashboard for Gitea users."""
|
||||||
if isinstance(auth, RedirectResponse):
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
return auth
|
|
||||||
# Check if user has Gitea connected — if not, show local workspace
|
# ── Not authenticated → show landing page ──
|
||||||
user_id = auth.get("id") if isinstance(auth, dict) else 1
|
if not user:
|
||||||
|
# Allow through if DB is empty (fresh install)
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
|
if count == 0:
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||||
|
template = env.get_template("landing.html")
|
||||||
|
return template.render()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||||
|
template = env.get_template("landing.html")
|
||||||
|
return template.render()
|
||||||
|
|
||||||
|
# ── Authenticated ──
|
||||||
|
user_id = user.get("id", 1)
|
||||||
has_gitea = False
|
has_gitea = False
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -419,8 +437,9 @@ async def dashboard(
|
|||||||
has_gitea = bool(tok)
|
has_gitea = bool(tok)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not has_gitea:
|
if not has_gitea:
|
||||||
# Check if user has any workspace — if not, send to workspaces
|
# Check if user has any workspace
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
ws_count = conn.execute(
|
ws_count = conn.execute(
|
||||||
@@ -431,6 +450,8 @@ async def dashboard(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return RedirectResponse("/local-workspace", status_code=302)
|
return RedirectResponse("/local-workspace", status_code=302)
|
||||||
|
|
||||||
|
# ── Gitea user → full dashboard ──
|
||||||
try:
|
try:
|
||||||
repos = await gitea.get_user_repos(page=1, limit=50)
|
repos = await gitea.get_user_repos(page=1, limit=50)
|
||||||
if search:
|
if search:
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FlowDeck — All-in-one workspace</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #191919;
|
||||||
|
--bg-card: #1e1e1e;
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--text-dim: #999;
|
||||||
|
--accent: #2383E2;
|
||||||
|
--accent-hover: #2C8CEB;
|
||||||
|
--border: rgba(255,255,255,.06);
|
||||||
|
--radius: 12px;
|
||||||
|
}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box;}
|
||||||
|
body{
|
||||||
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
||||||
|
background:var(--bg);
|
||||||
|
color:var(--text);
|
||||||
|
min-height:100vh;
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
}
|
||||||
|
/* Nav */
|
||||||
|
.landing-nav{
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:space-between;
|
||||||
|
padding:16px 32px;
|
||||||
|
border-bottom:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.landing-logo{
|
||||||
|
font-size:20px;
|
||||||
|
font-weight:700;
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
gap:8px;
|
||||||
|
text-decoration:none;
|
||||||
|
color:var(--text);
|
||||||
|
}
|
||||||
|
.landing-nav-actions{
|
||||||
|
display:flex;
|
||||||
|
gap:12px;
|
||||||
|
align-items:center;
|
||||||
|
}
|
||||||
|
.btn{
|
||||||
|
padding:10px 20px;
|
||||||
|
border-radius:8px;
|
||||||
|
font-size:14px;
|
||||||
|
font-weight:500;
|
||||||
|
cursor:pointer;
|
||||||
|
text-decoration:none;
|
||||||
|
border:none;
|
||||||
|
transition:all .15s;
|
||||||
|
}
|
||||||
|
.btn-primary{
|
||||||
|
background:var(--accent);
|
||||||
|
color:#fff;
|
||||||
|
}
|
||||||
|
.btn-primary:hover{background:var(--accent-hover);}
|
||||||
|
.btn-secondary{
|
||||||
|
background:transparent;
|
||||||
|
color:var(--text);
|
||||||
|
border:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover{background:rgba(255,255,255,.05);}
|
||||||
|
|
||||||
|
/* Hero */
|
||||||
|
.hero{
|
||||||
|
text-align:center;
|
||||||
|
padding:80px 32px 60px;
|
||||||
|
max-width:720px;
|
||||||
|
margin:0 auto;
|
||||||
|
}
|
||||||
|
.hero h1{
|
||||||
|
font-size:clamp(2rem,5vw,3.2rem);
|
||||||
|
font-weight:800;
|
||||||
|
letter-spacing:-.5px;
|
||||||
|
margin-bottom:16px;
|
||||||
|
line-height:1.2;
|
||||||
|
}
|
||||||
|
.hero .gradient-text{
|
||||||
|
background:linear-gradient(135deg,#2383E2,#6C5CE7);
|
||||||
|
-webkit-background-clip:text;
|
||||||
|
-webkit-text-fill-color:transparent;
|
||||||
|
background-clip:text;
|
||||||
|
}
|
||||||
|
.hero p{
|
||||||
|
font-size:18px;
|
||||||
|
color:var(--text-dim);
|
||||||
|
line-height:1.6;
|
||||||
|
margin-bottom:32px;
|
||||||
|
}
|
||||||
|
.hero-actions{
|
||||||
|
display:flex;
|
||||||
|
gap:12px;
|
||||||
|
justify-content:center;
|
||||||
|
flex-wrap:wrap;
|
||||||
|
}
|
||||||
|
.hero-actions .btn{
|
||||||
|
font-size:15px;
|
||||||
|
padding:12px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Features grid */
|
||||||
|
.features{
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:repeat(auto-fit,minmax(260px,1fr));
|
||||||
|
gap:16px;
|
||||||
|
max-width:1000px;
|
||||||
|
margin:0 auto;
|
||||||
|
padding:0 32px 60px;
|
||||||
|
}
|
||||||
|
.feature-card{
|
||||||
|
background:var(--bg-card);
|
||||||
|
border:1px solid var(--border);
|
||||||
|
border-radius:var(--radius);
|
||||||
|
padding:28px 24px;
|
||||||
|
transition:border-color .15s;
|
||||||
|
}
|
||||||
|
.feature-card:hover{border-color:rgba(255,255,255,.12);}
|
||||||
|
.feature-icon{font-size:28px;margin-bottom:12px;}
|
||||||
|
.feature-card h3{font-size:16px;font-weight:600;margin-bottom:8px;}
|
||||||
|
.feature-card p{font-size:14px;color:var(--text-dim);line-height:1.5;}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.landing-footer{
|
||||||
|
margin-top:auto;
|
||||||
|
text-align:center;
|
||||||
|
padding:32px;
|
||||||
|
border-top:1px solid var(--border);
|
||||||
|
font-size:13px;
|
||||||
|
color:var(--text-dim);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="landing-nav">
|
||||||
|
<a href="/" class="landing-logo">
|
||||||
|
<span>📚</span> FlowDeck
|
||||||
|
</a>
|
||||||
|
<div class="landing-nav-actions">
|
||||||
|
<a href="/auth/login?provider=local" class="btn btn-secondary">Log in</a>
|
||||||
|
<a href="/auth/register" class="btn btn-primary">Get started free</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<h1>Your <span class="gradient-text">Notion-style</span> workspace,<br>self-hosted & integrated</h1>
|
||||||
|
<p>
|
||||||
|
Write, organize, and collaborate — with block-based editing,
|
||||||
|
Kanban boards, databases, and deep Gitea/GitHub integration.
|
||||||
|
All on your own server.
|
||||||
|
</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a href="/auth/register" class="btn btn-primary">🚀 Get started — it's free</a>
|
||||||
|
<a href="/auth/login?provider=local" class="btn btn-secondary">I already have an account</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="features">
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📝</div>
|
||||||
|
<h3>Block Editor</h3>
|
||||||
|
<p>Notion-style editor with slash commands, markdown shortcuts, headings, code blocks, to-dos, and more.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📊</div>
|
||||||
|
<h3>Kanban & Tables</h3>
|
||||||
|
<p>Visual project management with drag-drop boards, table views, and database collections.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🦎</div>
|
||||||
|
<h3>Gitea & GitHub</h3>
|
||||||
|
<p>Browse repos, edit files, commit changes — all from your workspace sidebar.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🌐</div>
|
||||||
|
<h3>Publish to Web</h3>
|
||||||
|
<p>One-click publish any page to a public URL. Share with anyone, no account needed.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🔒</div>
|
||||||
|
<h3>Self-Hosted</h3>
|
||||||
|
<p>Your data stays on your server. No vendor lock-in, full control, Docker single-container deploy.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">⚡</div>
|
||||||
|
<h3>Fast & Light</h3>
|
||||||
|
<p>Alpine.js + FastAPI + SQLite. No React bloat, no Node.js needed. Sub-100ms page loads.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="landing-footer">
|
||||||
|
FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user