feat: sidebar Kanban board + pages Markdown + boutons + et 📚 fonctionnels
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- Sidebar: 'Kanban board' affiché avant les 4 sous-vues
- DB: nouvelle table 'pages' (workspace, title, content, parent_section)
- API: POST /board/api/pages, GET/PUT /board/api/pages/{id}
- UI: page_editor.html — éditeur Markdown avec preview live
- UI: boutons + créent une page Markdown, bouton 📚 ouvre la page
- Dashboard: sidebar_data utilise BDD pages + workspace
- board.py: _sidebar_data enrichi (Kanban board + pages DB)
This commit is contained in:
2026-07-08 15:02:15 -04:00
parent a5e91cf8c7
commit b10118eddc
5 changed files with 295 additions and 48 deletions
+10
View File
@@ -130,6 +130,16 @@ def init_db():
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project_owner, project_name, keyword)
);
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'New page',
content TEXT NOT NULL DEFAULT '',
parent_section TEXT DEFAULT 'Private',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
conn.commit()
+79 -10
View File
@@ -115,18 +115,34 @@ def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict:
}
def _sidebar_data(request: Request, owner: str, repo: str) -> dict:
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws = user.get("login", "Bruno") if user else "Bruno"
recent = [{"id": f"{owner}/{repo}", "name": repo, "icon": "📁",
"url": f"/board/{owner}/{repo}", "active": True, "indent": 0}]
for sv in ["Detailed board", "Table view", "Status overview", "Team Load"]:
recent.append({"id": f"{owner}/{repo}/{sv.lower().replace(' ', '-')}",
"name": sv, "icon": "·", "url": f"/board/{owner}/{repo}",
"active": False, "indent": 1})
recent = []
if owner and repo:
recent = [
{"id": f"{owner}/{repo}", "name": "Kanban board", "icon": "📁",
"url": f"/board/{owner}/{repo}", "active": True, "indent": 0},
]
for sv in ["Detailed board", "Table view", "Status overview", "Team Load"]:
recent.append({"id": f"{owner}/{repo}/{sv.lower().replace(' ', '-')}",
"name": sv, "icon": "·", "url": f"/board/{owner}/{repo}",
"active": False, "indent": 1})
# Load user-created pages from DB
with get_conn() as conn:
pages = conn.execute(
"SELECT id, title, updated_at FROM pages WHERE workspace=? ORDER BY updated_at DESC LIMIT 10",
(ws,),
).fetchall()
for p in pages:
recent.append({
"id": f"page/{p['id']}", "name": p["title"], "icon": "📄",
"url": f"/pages/{p['id']}", "active": False, "indent": 0,
})
return {"workspace_name": ws, "workspace_initial": ws[0].upper() if ws else "B",
"current_page": repo, "last_edited": "2m ago",
"recent_pages": recent, "private_pages": recent[:1], "user": user}
"current_page": repo or "Dashboard", "last_edited": "now",
"recent_pages": recent, "private_pages": [r for r in recent if r.get("active")],
"user": user}
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
@@ -391,7 +407,60 @@ async def extract_ai_keywords(owner: str, repo: str):
return {"status": "ok", "issues_scanned": len(issues)}
# ═══════════ v0.9.0: Sync endpoint ═══════════
# ═══════════ Pages Markdown ═══════════
@router.post("/api/pages")
async def create_page(request: Request, title: str = Query(default="New page"),
section: str = Query(default="Private")):
"""Create a new Markdown page."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws = user.get("login", "Bruno") if user else "Bruno"
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, title, parent_section) VALUES (?,?,?)",
(ws, title, section),
)
conn.commit()
page_id = cur.lastrowid
return {"status": "ok", "id": page_id, "title": title}
@router.get("/api/pages/{page_id}")
async def get_page(page_id: int):
"""Get a Markdown page."""
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
return dict(row)
@router.put("/api/pages/{page_id}")
async def update_page(page_id: int, title: str = Query(default=""),
content: str = Query(default="")):
"""Update a page's title and/or content."""
with get_conn() as conn:
if title:
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
if content:
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id))
conn.commit()
return {"status": "ok"}
@router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page(request: Request, page_id: int):
"""Render a page as HTML."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
sidebar = _sidebar_data(request)
ctx = {**sidebar, "page": dict(row)}
template = env.get_template("page_editor.html")
return template.render(**ctx)
@router.post("/api/sync/{owner}/{repo}")
async def sync_project(owner: str, repo: str):
+22 -28
View File
@@ -8,41 +8,46 @@ from fastapi.responses import HTMLResponse
from app.services.gitea_client import gitea
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
def _sidebar_data(request: Request, repos: list[dict]) -> dict:
"""Build sidebar context from Gitea repos."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
workspace_name = user.get("login", "Bruno") if user else "Bruno"
workspace_initial = workspace_name[0].upper() if workspace_name else "B"
ws = user.get("login", "Bruno") if user else "Bruno"
initial = ws[0].upper() if ws else "B"
# Build recent pages from repos
recent_pages = []
private_pages = []
for repo in repos[:10]:
full_name = repo.get("full_name", "")
parts = full_name.split("/") if "/" in full_name else [workspace_name, full_name]
page = {
recent_pages.append({
"id": full_name,
"name": repo.get("name", full_name),
"icon": "📁",
"url": f"/board/{full_name}",
"active": False,
"indent": 0,
}
recent_pages.append(page)
private_pages.append({**page})
})
# Load user-created pages
with get_conn() as conn:
pages = conn.execute(
"SELECT id, title FROM pages WHERE workspace=? ORDER BY updated_at DESC LIMIT 10",
(ws,),
).fetchall()
for p in pages:
recent_pages.append({
"id": f"page/{p['id']}", "name": p["title"], "icon": "📄",
"url": f"/pages/{p['id']}", "active": False, "indent": 0,
})
return {
"workspace_name": workspace_name,
"workspace_initial": workspace_initial,
"current_page": "Dashboard",
"last_edited": "now",
"workspace_name": ws, "workspace_initial": initial,
"current_page": "Dashboard", "last_edited": "now",
"recent_pages": recent_pages,
"private_pages": private_pages,
"private_pages": [p for p in recent_pages if p["id"].startswith("page/")],
"user": user,
}
@@ -53,33 +58,22 @@ async def dashboard(
search: str = Query(default=""),
show_archived: bool = Query(default=False),
):
"""Dashboard: liste tous les projets Gitea (user + orgs)."""
try:
repos = await gitea.get_user_repos(page=1, limit=50)
if search:
q = search.lower()
repos = [r for r in repos if q in r.get("full_name", "").lower()
or q in (r.get("description") or "").lower()]
if not show_archived:
repos = [r for r in repos if not r.get("archived", False)]
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
except Exception as e:
logger.error("Dashboard error: %s", e)
repos = []
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, repos)
template = env.get_template("dashboard.html")
return template.render(
request=request,
repos=repos,
search=search,
show_archived=show_archived,
**sidebar,
)
return template.render(request=request, repos=repos, search=search,
show_archived=show_archived, **sidebar)
+32 -10
View File
@@ -48,8 +48,8 @@
<span class="chevron open">▶</span> Recents
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Open in Library">📚</button>
<button class="section-action-btn" title="More">⋯</button>
<button class="section-action-btn" title="Open in Library" @click="openLibrary('Recents')">📚</button>
<button class="section-action-btn" title="Add page" @click="newPage('Recents')">+</button>
</div>
</div>
<ul class="sidebar-items">
@@ -60,8 +60,8 @@
<span class="page-icon">{{ page.icon }}</span>
<span class="page-name">{{ page.name }}</span>
<span class="item-actions">
<button class="item-action-btn" @click.stop="openInLibrary('{{ page.id }}')">📚</button>
<button class="item-action-btn" @click.stop="showMore('{{ page.id }}')">⋯</button>
<button class="item-action-btn" @click.stop="openLibrary('{{ page.id }}')">📚</button>
<button class="item-action-btn" @click.stop="newSubPage('{{ page.id }}')">+</button>
</span>
</li>
{% endfor %}
@@ -81,7 +81,8 @@
<span class="chevron open">▶</span> Private
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" @click="addPage('private')" title="Add page">+</button>
<button class="section-action-btn" title="Open in Library" @click="openLibrary('Private')">📚</button>
<button class="section-action-btn" title="Add page" @click="newPage('Private')">+</button>
</div>
</div>
<ul class="sidebar-items">
@@ -92,11 +93,17 @@
<span class="page-icon">{{ page.icon }}</span>
<span class="page-name">{{ page.name }}</span>
<span class="item-actions">
<button class="item-action-btn" @click.stop="showMore('{{ page.id }}')">⋯</button>
<button class="item-action-btn" @click.stop="addSubPage('{{ page.id }}')">+</button>
<button class="item-action-btn" @click.stop="openLibrary('{{ page.id }}')">📚</button>
<button class="item-action-btn" @click.stop="newSubPage('{{ page.id }}')">+</button>
</span>
</li>
{% endfor %}
{% if not private_pages %}
<li class="sidebar-item">
<span class="page-icon">📄</span>
<span class="page-name text-dim">No pages yet</span>
</li>
{% endif %}
</ul>
</div>
@@ -318,10 +325,25 @@
this.contextMenu.visible = false;
},
openInLibrary(id) { console.log('Open in library:', id); },
openLibrary(id) {
// Open the library view for this section/page
console.log('Open library for:', id);
window.location.href = '/pages/' + id.replace('page/', '');
},
openInLibrary(id) { this.openLibrary(id); },
showMore(id) { console.log('Show more:', id); },
addPage(section) { console.log('Add page to:', section); },
addSubPage(id) { console.log('Add sub-page to:', id); },
addPage(section) { this.newPage(section); },
addSubPage(id) { this.newSubPage(id); },
newPage(section) {
fetch(`/board/api/pages?title=New%20page&section=${encodeURIComponent(section)}`, { method: 'POST' })
.then(r => r.json())
.then(data => { window.location.href = `/pages/${data.id}`; });
},
newSubPage(parentId) {
fetch(`/board/api/pages?title=New%20sub-page&section=Private`, { method: 'POST' })
.then(r => r.json())
.then(data => { window.location.href = `/pages/${data.id}`; });
},
toggleTheme() {
const html = document.documentElement;
+152
View File
@@ -0,0 +1,152 @@
{% extends "base.html" %}
{% block page_icon %}📄{% endblock %}
{% block page_title %}{{ page.title }}{% endblock %}
{% block content %}
<div class="page-title-area">
<div class="page-title">
<span class="page-icon-lg">📄</span>
<h1 contenteditable="true"
@blur="saveTitle($event.target.textContent)"
x-data>{{ page.title }}</h1>
</div>
</div>
<div x-data="pageEditor('{{ page.content|e }}')" style="padding: 0 24px 48px; max-width: 900px;">
<!-- Toolbar -->
<div style="display:flex; gap:4px; margin-bottom:12px; flex-wrap:wrap;">
<button class="toolbar-btn" @click="insertMd('**', '**')" title="Bold">B</button>
<button class="toolbar-btn" @click="insertMd('*', '*')" title="Italic"><i>I</i></button>
<button class="toolbar-btn" @click="insertMd('# ', '')" title="Heading 1">H1</button>
<button class="toolbar-btn" @click="insertMd('## ', '')" title="Heading 2">H2</button>
<button class="toolbar-btn" @click="insertMd('- ', '')" title="Bullet list">•</button>
<button class="toolbar-btn" @click="insertMd('1. ', '')" title="Numbered list">1.</button>
<button class="toolbar-btn" @click="insertMd('> ', '')" title="Quote">"</button>
<button class="toolbar-btn" @click="insertMd('`', '`')" title="Code">&lt;/&gt;</button>
<button class="toolbar-btn" @click="insertMd('```\n', '\n```')" title="Code block">```</button>
<button class="toolbar-btn" @click="insertMd('---\n', '')" title="Divider">—</button>
<button class="toolbar-btn" @click="insertMd('[', '](url)')" title="Link">🔗</button>
<button class="toolbar-btn" @click="insertMd('- [ ] ', '')" title="Todo">☐</button>
</div>
<!-- Editor -->
<div style="display:flex; gap:0; min-height:400px; border:1px solid var(--border); border-radius:8px; overflow:hidden;">
<!-- Markdown source -->
<div style="flex:1; display:flex; flex-direction:column;">
<div style="padding:4px 12px; background:var(--bg-tertiary); font-size:12px; color:var(--text-dim);">Markdown</div>
<textarea x-ref="editor" x-model="content"
@input="autoSave()"
style="flex:1; width:100%; min-height:400px; background:var(--bg-secondary);
border:none; padding:12px; color:var(--text-primary); font-family:var(--font-mono);
font-size:13px; line-height:1.6; resize:none; outline:none; tab-size:2;"
placeholder="Start writing in Markdown..."></textarea>
</div>
<!-- Preview -->
<div style="flex:1; border-left:1px solid var(--border); display:flex; flex-direction:column;">
<div style="padding:4px 12px; background:var(--bg-tertiary); font-size:12px; color:var(--text-dim);">Preview</div>
<div style="flex:1; padding:12px; overflow-y:auto; font-size:14px; line-height:1.7; color:var(--text-primary);"
x-html="renderedContent()"></div>
</div>
</div>
<!-- Status -->
<div style="display:flex; align-items:center; gap:8px; margin-top:8px; font-size:12px; color:var(--text-dim);">
<span x-show="saved">✓ Saved</span>
<span x-show="dirty && !saving">● Unsaved</span>
<span x-text="wordCount + ' words'"></span>
<span style="margin-left:auto;" x-text="'Last edited: ' + lastSaved"></span>
</div>
<!-- Get started block (shown for empty pages) -->
<div x-show="!content.trim()" style="margin-top:48px; text-align:center; color:var(--text-dim);">
<div style="font-size:48px; margin-bottom:16px;">📝</div>
<h3 style="color:var(--text-secondary); margin-bottom:8px;">Get started with</h3>
<div style="display:flex; gap:12px; justify-content:center; flex-wrap:wrap;">
<button class="sidebar-new-chat" style="width:auto; padding:8px 16px;" @click="insertMd('# ', '')">📄 Empty page</button>
<button class="sidebar-new-chat" style="width:auto; padding:8px 16px;" @click="insertMd('- [ ] Task 1\n- [ ] Task 2\n', '')">✅ To-do list</button>
<button class="sidebar-new-chat" style="width:auto; padding:8px 16px;" @click="insertMd('| Col 1 | Col 2 |\n|-------|-------|\n| | |\n', '')">📊 Table</button>
<button class="sidebar-new-chat" style="width:auto; padding:8px 16px;" @click="insertMd('# Meeting Notes\n\n**Date:** \n**Attendees:** \n\n## Agenda\n\n- \n\n## Notes\n\n- \n\n## Action Items\n\n- [ ] \n', '')">🎤 Meeting Notes</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Simple Markdown-to-HTML renderer
function renderMarkdown(md) {
let html = md
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// Headers
.replace(/^### (.+)$/gm, '<h3 style="font-size:16px; margin:12px 0 4px;">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 style="font-size:18px; margin:16px 0 4px;">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 style="font-size:22px; margin:20px 0 6px;">$1</h1>')
// Bold/italic
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Inline code
.replace(/`([^`]+)`/g, '<code style="background:var(--bg-tertiary); padding:1px 4px; border-radius:3px; font-size:12px;">$1</code>')
// Code blocks
.replace(/```[\s\S]*?```/g, (m) => '<pre style="background:var(--bg-tertiary); padding:12px; border-radius:4px; overflow-x:auto; font-size:12px;"><code>' + m.slice(3, -3).trim() + '</code></pre>')
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color:var(--blue);">$1</a>')
// Horizontal rules
.replace(/^---$/gm, '<hr style="border:none; border-top:1px solid var(--border); margin:16px 0;">')
// Blockquotes
.replace(/^> (.+)$/gm, '<blockquote style="border-left:2px solid var(--blue); padding-left:12px; margin:8px 0; color:var(--text-secondary);">$1</blockquote>')
// Lists
.replace(/^- (.+)$/gm, '<li style="margin-left:20px;">$1</li>')
.replace(/^\d+\. (.+)$/gm, '<li style="margin-left:20px;">$1</li>')
// Checkboxes
.replace(/^- \[ \] (.+)$/gm, '<div style="display:flex; align-items:center; gap:8px; margin:2px 0;"><input type="checkbox" style="accent-color:var(--blue);">$1</div>')
.replace(/^- \[x\] (.+)$/gm, '<div style="display:flex; align-items:center; gap:8px; margin:2px 0;"><input type="checkbox" checked style="accent-color:var(--blue);"><span style="text-decoration:line-through; color:var(--text-dim);">$1</span></div>')
// Paragraphs
.replace(/\n\n/g, '<br><br>');
return html;
}
function pageEditor(initialContent) {
return {
content: initialContent || '',
saved: true,
dirty: false,
saving: false,
lastSaved: 'now',
saveTimeout: null,
renderedContent() { return renderMarkdown(this.content); },
get wordCount() { return this.content.trim() ? this.content.trim().split(/\s+/).length : 0; },
insertMd(before, after) {
const el = this.$refs.editor;
const start = el.selectionStart;
const end = el.selectionEnd;
const selected = this.content.substring(start, end);
const replacement = before + selected + after;
this.content = this.content.substring(0, start) + replacement + this.content.substring(end);
this.autoSave();
el.focus();
el.setSelectionRange(start + before.length, start + before.length + selected.length);
},
autoSave() {
this.dirty = true;
this.saved = false;
clearTimeout(this.saveTimeout);
this.saveTimeout = setTimeout(() => this.save(), 1500);
},
save() {
this.saving = true;
const title = document.querySelector('.page-title h1')?.textContent || 'New page';
fetch(`/board/api/pages/{{ page.id }}?title=${encodeURIComponent(title)}&content=${encodeURIComponent(this.content)}`, { method: 'PUT' })
.then(() => {
this.saved = true;
this.dirty = false;
this.saving = false;
this.lastSaved = new Date().toLocaleTimeString();
});
},
saveTitle(newTitle) {
fetch(`/board/api/pages/{{ page.id }}?title=${encodeURIComponent(newTitle)}`, { method: 'PUT' });
}
};
}
</script>
{% endblock %}