feat: sidebar user menu Notion-style + settings page + tag colors + preview images/PDF
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- Sidebar header: user menu dropdown (Settings, Switch workspace, Log out)
  avec avatar + nom + email, chevron animé, fond opaque #1E1E1E
- Nouvelle page /settings avec gestion globale des tags:
  créer tag avec couleur, changer couleur, supprimer, liste avec compteurs
- API tag management: POST/PUT/DELETE /api/settings/tags, GET /api/settings/tags/all
- Preview panel: affichage images (img tag), PDF (lien viewer), détection format
- workspace-chevron CSS: rotation 180° quand menu ouvert
This commit is contained in:
2026-07-12 12:35:17 -04:00
parent 17f158ca18
commit 004c47df34
5 changed files with 292 additions and 168 deletions
+74
View File
@@ -1080,6 +1080,80 @@ async def select_workspace(request: Request, ws_id: int):
return response
# ═══════════ Settings Page ═══════════
@router.get("/settings", response_class=HTMLResponse)
async def app_settings_page(request: Request):
"""Settings & configuration page."""
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}
template = env.get_template("settings.html")
return template.render(**ctx)
# ═══════════ Tag Management API (global) ═══════════
@router.post("/api/settings/tags")
async def create_tag_global(request: Request):
"""Create a tag with optional color."""
import json
body = await request.json()
tag_name = body.get("name", "").strip().lower()
color = body.get("color", "#787774")
if not tag_name:
return {"error": "Tag name required"}, 400
with get_conn() as conn:
tag = conn.execute("SELECT id FROM tags WHERE name = ?", (tag_name,)).fetchone()
if tag:
conn.execute("UPDATE tags SET color = ? WHERE id = ?", (color, tag["id"]))
conn.commit()
return {"tag": {"id": tag["id"], "name": tag_name, "color": color}}
cursor = conn.execute("INSERT INTO tags (name, color) VALUES (?, ?)", (tag_name, color))
conn.commit()
return {"tag": {"id": cursor.lastrowid, "name": tag_name, "color": color}}
@router.put("/api/settings/tags/{tag_id:int}")
async def update_tag_global(tag_id: int, request: Request):
"""Update a tag (name or color)."""
import json
body = await request.json()
with get_conn() as conn:
if "name" in body:
conn.execute("UPDATE tags SET name = ? WHERE id = ?", (body["name"].strip().lower(), tag_id))
if "color" in body:
conn.execute("UPDATE tags SET color = ? WHERE id = ?", (body["color"], tag_id))
conn.commit()
return {"status": "ok"}
@router.delete("/api/settings/tags/{tag_id:int}")
async def delete_tag_global(tag_id: int):
"""Delete a tag and its page associations."""
with get_conn() as conn:
conn.execute("DELETE FROM page_tags WHERE tag_id = ?", (tag_id,))
conn.execute("DELETE FROM tags WHERE id = ?", (tag_id,))
conn.commit()
return {"status": "ok"}
@router.get("/api/settings/tags/all")
async def list_all_tags_global():
"""List all tags with counts."""
with get_conn() as conn:
rows = conn.execute(
"SELECT t.id, t.name, t.color, COUNT(pt.page_id) as count "
"FROM tags t LEFT JOIN page_tags pt ON pt.tag_id = t.id "
"GROUP BY t.id ORDER BY t.name"
).fetchall()
return {"tags": [dict(r) for r in rows]}
# ═══════════ Tags API ═══════════
@router.get("/api/local-workspace/tags")
+19 -8
View File
@@ -61,7 +61,7 @@
x-data x-init="$nextTick(() => { if(typeof initTreeSortable==='function') initTreeSortable(); })">
<!-- Workspace header + mobile back -->
<div style="display:flex;align-items:center;gap:4px;">
<button class="sidebar-back-btn" @click="mobileSidebarOpen = false; sidebarCollapsed = true;" title="Fermer le menu">
<button class="sidebar-back-btn" @click="mobileSidebarOpen = false; sidebarCollapsed = true;" x-show="mobileSidebarOpen" title="Fermer le menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<div class="sidebar-workspace" @click="toggleWorkspaceMenu" style="flex:1;">
@@ -69,7 +69,22 @@
<div class="workspace-avatar">{{ workspaceInitial }}</div>
<span class="workspace-name">{{ workspaceName }}</span>
</div>
<span class="workspace-chevron">▾</span>
<span class="workspace-chevron" :class="{ open: workspaceMenuOpen }">▾</span>
</div>
<!-- User menu dropdown -->
<div class="user-menu-dropdown" x-show="workspaceMenuOpen" @click.outside="workspaceMenuOpen=false" x-transition>
<div class="um-user-info">
<div class="um-avatar">{{ workspaceInitial }}</div>
<div>
<div class="um-name">{{ user.full_name or user.login }}</div>
<div class="um-plan">{{ user.email or '' }}</div>
</div>
</div>
<div class="um-sep"></div>
<a href="/settings" class="um-item" @click="workspaceMenuOpen=false">⚙️ Settings</a>
<a href="/workspaces" class="um-item" @click="workspaceMenuOpen=false">🔄 Switch workspace</a>
<div class="um-sep"></div>
<a href="/auth/logout" class="um-item um-danger" @click="workspaceMenuOpen=false">🚪 Log out</a>
</div>
</div>
@@ -599,13 +614,9 @@
try { localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen)); }
catch(e) {}
},
workspaceMenuOpen: false,
toggleWorkspaceMenu() {
if (window.innerWidth <= 768) {
this.mobileSidebarOpen = !this.mobileSidebarOpen;
this.sidebarCollapsed = !this.mobileSidebarOpen;
} else {
this.sidebarCollapsed = !this.sidebarCollapsed;
}
this.workspaceMenuOpen = !this.workspaceMenuOpen;
},
navigateTo(url) {
this.mobileSidebarOpen = false;
+32 -2
View File
@@ -1155,19 +1155,49 @@ document.addEventListener('alpine:init', function() {
this.previewPanelContent = '<div style="text-align:center;padding:40px;color:var(--text-secondary)">'
+ '<span style="font-size:48px;display:block;margin-bottom:12px">📁</span>'
+ '<div style="font-size:14px">' + (node.child_count||0) + ' item(s)</div>'
+ '<button class="btn btn-secondary" style="margin-top:16px" onclick="window.location=\'/local-workspace?folder=' + node.id + '\'">Open folder</button>'
+ '<button class="btn btn-secondary" style="margin-top:16px" onclick="window.location=\\'/local-workspace?folder=' + node.id + '\\'">Open folder</button>'
+ '</div>';
return;
}
try {
var name = (node.name || '').toLowerCase();
// Image preview
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(name)) {
var wsId = 1; // from workspace
var imgUrl = '/api/files/' + wsId + '/' + encodeURIComponent(node.name);
this.previewPanelContent = '<div style="text-align:center;padding:12px;">'
+ '<img src="' + imgUrl + '" style="max-width:100%;max-height:50vh;border-radius:8px;" onerror="this.parentElement.innerHTML=\\'<div style=padding:20px;color:var(--text-tertiary)>Image not available</div>\\'">'
+ '</div>';
return;
}
// PDF preview
if (/\.pdf$/.test(name)) {
this.previewPanelContent = '<div style="text-align:center;padding:24px;color:var(--text-secondary);">'
+ '<span style="font-size:48px;display:block;margin-bottom:8px;">📕</span>'
+ '<div style="font-size:14px;margin-bottom:4px;">' + this._escHtml(node.name) + '</div>'
+ '<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:16px;">' + (node.size_display || '') + '</div>'
+ '<a href="/pages/' + node.id + '" class="btn btn-secondary" style="display:inline-block;text-decoration:none;">Open in viewer</a>'
+ '</div>';
return;
}
var r = await this._fetchRetry('/api/local-workspace/page-content/' + node.id, true);
var content = r.content || '';
var format = r.format || '';
var preview = '';
if (format === 'file') {
// Try to detect image/pdf from content metadata
try {
var meta = JSON.parse(content);
if (meta.file_path && /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(meta.file_path)) {
var wsId2 = 1;
var imgUrl2 = '/api/files/' + wsId2 + '/' + encodeURIComponent(meta.file_path);
this.previewPanelContent = '<img src="' + imgUrl2 + '" style="max-width:100%;max-height:50vh;border-radius:8px;">';
return;
}
} catch(e) {}
preview = '<div style="text-align:center;padding:20px;color:var(--text-secondary)">'
+ '<span style="font-size:48px;display:block;margin-bottom:8px">📄</span>'
+ 'Uploaded file — open to view</div>';
+ (node.size_display || '') + ' — open to view</div>';
} else if (format === 'blocks') {
try {
var blocks = typeof content === 'string' ? JSON.parse(content) : content;
+117 -158
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block page_title %}Account Settings{% endblock %}
{% block page_title %}Settings{% endblock %}
{% block page_icon %}⚙️{% endblock %}
{% block topbar %}
@@ -8,185 +8,144 @@
<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)">
Settings
</span>
<span class="page-title-topbar">Account Settings</span>
<span class="page-title-topbar">⚙️ Settings</span>
</div>
<div class="topbar-right"></div>
</header>
{% endblock %}
{% block content %}
<style>
.settings-layout{max-width:700px;margin:0 auto;padding:40px 24px;}
.settings-layout{max-width:720px;margin:0 auto;padding:32px 24px;}
.settings-section{margin-bottom:32px;}
.settings-section h2{font-size:14px;color:rgba(255,255,255,.5);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px;font-weight:500;}
.settings-card{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden;}
.settings-row{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid rgba(255,255,255,.06);}
.settings-row:last-child{border-bottom:none;}
.settings-row-left{flex:1;}
.settings-row-label{font-size:14px;color:#fff;}
.settings-row-desc{font-size:12px;color:rgba(255,255,255,.45);margin-top:2px;}
.settings-row-value{font-size:13px;color:rgba(255,255,255,.5);margin-right:12px;}
.settings-input{background:#2A2A2A;border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:8px 12px;color:#fff;font-size:13px;width:220px;outline:none;}
.settings-input:focus{border-color:#2383E2;}
.settings-btn{padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
.settings-btn-primary{background:#2383E2;color:#fff;}
.settings-btn-primary:hover{background:#2C8CEB;}
.settings-btn-danger{background:rgba(255,80,80,.15);color:#ff5050;}
.settings-btn-danger:hover{background:rgba(255,80,80,.25);}
.settings-btn-secondary{background:#333;color:#fff;}
.settings-btn-secondary:hover{background:#444;}
.inline-form{display:flex;align-items:center;gap:8px;}
.forge-badge{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:#2A2A2A;border-radius:6px;font-size:12px;}
.connected{color:#50ff50;}
.disconnected{color:rgba(255,255,255,.3);}
.settings-section h2{font-size:13px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-tertiary);margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid var(--border);}
.setting-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);}
.setting-row:last-child{border-bottom:none;}
.setting-label{font-size:14px;color:var(--text);}
.setting-desc{font-size:12px;color:var(--text-tertiary);margin-top:2px;}
.setting-control{flex-shrink:0;margin-left:16px;}
/* Tags management */
.tag-mgmt-list{display:flex;flex-direction:column;gap:4px;}
.tag-mgmt-item{display:flex;align-items:center;gap:10px;padding:8px 10px;background:var(--bg-tertiary);border-radius:8px;}
.tag-mgmt-item .tag-color-dot{width:14px;height:14px;border-radius:50%;flex-shrink:0;}
.tag-mgmt-item .tag-name{flex:1;font-size:13px;color:var(--text);}
.tag-mgmt-item .tag-count{font-size:12px;color:var(--text-tertiary);}
.tag-mgmt-item .tag-actions{display:flex;gap:4px;}
.tag-mgmt-item .tag-del-btn{background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:14px;padding:2px 6px;border-radius:4px;}
.tag-mgmt-item .tag-del-btn:hover{color:var(--danger);background:rgba(255,80,80,.1);}
/* Color picker grid */
.color-picker{display:flex;gap:4px;flex-wrap:wrap;}
.color-swatch{width:22px;height:22px;border-radius:50%;cursor:pointer;border:2px solid transparent;transition:all 120ms;}
.color-swatch:hover{transform:scale(1.15);}
.color-swatch.selected{border-color:var(--text);box-shadow:0 0 0 2px var(--accent);}
/* New tag form */
.new-tag-form{display:flex;align-items:center;gap:10px;margin-top:8px;padding:8px 10px;background:var(--bg-tertiary);border:1px dashed var(--border);border-radius:8px;}
.new-tag-form input{flex:1;background:none;border:none;color:var(--text);font-size:13px;outline:none;}
.new-tag-form input::placeholder{color:var(--text-tertiary);}
.new-tag-form .new-tag-color{margin-right:6px;}
</style>
<div class="settings-layout" x-data="settingsPage()">
<!-- Profile -->
<div class="settings-layout" x-data="settingsInit()">
<!-- Tags Management -->
<div class="settings-section">
<h2>Profile</h2>
<div class="settings-card">
<div class="settings-row">
<div class="settings-row-left">
<div class="settings-row-label">Email</div>
<div class="settings-row-desc">Used for login and notifications</div>
</div>
<div class="settings-row-value">{{ user.email or user.login or '' }}</div>
</div>
<div class="settings-row">
<div class="settings-row-left">
<div class="settings-row-label">Name</div>
<div class="settings-row-desc">Display name</div>
</div>
<div class="inline-form">
<input class="settings-input" x-model="name" :value="name" placeholder="Your name">
<button class="settings-btn settings-btn-primary" @click="saveName">Save</button>
</div>
</div>
<div class="settings-row">
<div class="settings-row-left">
<div class="settings-row-label">Password</div>
<div class="settings-row-desc">Change your password</div>
</div>
<div class="inline-form">
<input class="settings-input" type="password" x-model="newPassword" placeholder="New password">
<button class="settings-btn settings-btn-primary" @click="savePassword">Update</button>
</div>
</div>
</div>
</div>
<!-- Connected Forges -->
<div class="settings-section">
<h2>Connected Forges</h2>
<div class="settings-card">
<div class="settings-row">
<div class="settings-row-left">
<div class="settings-row-label">🔗 Gitea</div>
<div class="settings-row-desc">Connect to your Gitea instance</div>
</div>
{% if gitea_connected %}
<span class="forge-badge connected">✅ Connected</span>
<button class="settings-btn settings-btn-danger" @click="disconnect('gitea')">Disconnect</button>
{% else %}
<button class="settings-btn settings-btn-primary" onclick="window.location='/auth/login?provider=gitea'">Connect</button>
{% endif %}
</div>
<div class="settings-row">
<div class="settings-row-left">
<div class="settings-row-label">🐙 GitHub</div>
<div class="settings-row-desc">Connect to GitHub</div>
</div>
{% if github_connected %}
<span class="forge-badge connected">✅ Connected</span>
<button class="settings-btn settings-btn-danger" @click="disconnect('github')">Disconnect</button>
{% else %}
<button class="settings-btn settings-btn-secondary">
Coming soon
</button>
{% endif %}
</div>
</div>
</div>
<!-- API Tokens -->
<div class="settings-section">
<h2>API Tokens</h2>
<div class="settings-card">
<div class="settings-row">
<div class="settings-row-left">
<div class="settings-row-label">Generate API Token</div>
<div class="settings-row-desc">For programmatic access to FlowDeck API</div>
</div>
<button class="settings-btn settings-btn-primary" @click="generateToken">Generate</button>
</div>
<template x-if="token">
<div class="settings-row" style="background:#1a2a1a">
<div class="settings-row-left">
<div class="settings-row-label" style="font-family:monospace;font-size:12px;word-break:break-all" x-text="token"></div>
<div class="settings-row-desc" style="color:#50ff50">Copy this token now — it won't be shown again</div>
<h2>🏷️ Tags Management</h2>
<p class="setting-desc" style="margin-bottom:12px;">Manage all tags across your workspace. Changes apply globally.</p>
<div class="tag-mgmt-list">
<template x-for="tag in allTags" :key="tag.id">
<div class="tag-mgmt-item">
<div class="tag-color-dot" :style="{background: tag.color}"></div>
<span class="tag-name" x-text="tag.name"></span>
<span class="tag-count" x-text="tag.count + ' items'"></span>
<div class="tag-actions">
<input type="color" :value="tag.color" @input="updateTagColor(tag.id, $event.target.value)" style="width:24px;height:24px;border:none;cursor:pointer;background:none;padding:0;" title="Change color">
<button class="tag-del-btn" @click="deleteTag(tag.id)" title="Delete tag">×</button>
</div>
<button class="settings-btn settings-btn-primary" @click="copyToken">Copy</button>
</div>
</template>
<div x-show="allTags.length === 0" style="text-align:center;padding:16px;color:var(--text-tertiary);">No tags yet</div>
</div>
<!-- New tag form -->
<div class="new-tag-form">
<div class="color-picker new-tag-color" style="margin-right:8px;">
<template x-for="c in presetColors" :key="c">
<div class="color-swatch" :class="{ selected: newTagColor === c }" :style="{background: c}" @click="newTagColor=c"></div>
</template>
</div>
<input type="text" placeholder="New tag name..." x-model="newTagName" @keydown.enter="createTag()">
<button class="btn btn-primary" @click="createTag()" :disabled="!newTagName.trim()" style="padding:4px 12px;font-size:12px;">Add</button>
</div>
</div>
<!-- Active Sessions -->
<!-- Workspace Info -->
<div class="settings-section">
<h2>Active Sessions</h2>
<div class="settings-card">
<div class="settings-row">
<div class="settings-row-left">
<div class="settings-row-label">Current session</div>
<div class="settings-row-desc">You are logged in</div>
</div>
<button class="settings-btn settings-btn-danger" onclick="window.location='/auth/logout'">Log out</button>
<h2>📋 Workspace</h2>
<div class="setting-row">
<div>
<div class="setting-label">Workspace name</div>
<div class="setting-desc">Current: {{ workspaceName }}</div>
</div>
</div>
</div>
</div>
<script>
function settingsPage() {
return {
name: '{{ user.full_name or '' }}',
newPassword: '',
token: '',
async saveName() {
await fetch('/api/user/profile', {method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({full_name:this.name})});
alert('Name saved');
},
async savePassword() {
if (!this.newPassword || this.newPassword.length < 6) { alert('Password must be at least 6 characters'); return; }
await fetch('/api/user/password', {method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:this.newPassword})});
this.newPassword = '';
alert('Password updated');
},
async generateToken() {
const r = await fetch('/api/user/token', {method:'POST'});
const d = await r.json();
this.token = d.token;
},
copyToken() {
navigator.clipboard?.writeText(this.token);
const ta = document.createElement('textarea');
ta.value = this.token; ta.style.position='fixed'; ta.style.opacity='0';
document.body.appendChild(ta); ta.select(); document.execCommand('copy');
document.body.removeChild(ta);
alert('Token copied to clipboard');
},
async disconnect(provider) {
if (!confirm('Disconnect ' + provider + '?')) return;
await fetch('/api/user/forge/' + provider, {method:'DELETE'});
window.location.reload();
}
};
}
document.addEventListener('alpine:init', function() {
Alpine.data('settingsInit', function() {
return {
allTags: [],
newTagName: '',
newTagColor: '#787774',
presetColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
async init() {
await this.loadTags();
},
async loadTags() {
try {
var r = await fetch('/api/settings/tags/all');
var d = await r.json();
this.allTags = d.tags || [];
} catch(e) { this.allTags = []; }
},
async createTag() {
var n = this.newTagName.trim();
if (!n) return;
try {
var r = await fetch('/api/settings/tags', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({name: n, color: this.newTagColor})
});
if (r.ok) {
this.newTagName = '';
this.newTagColor = '#787774';
await this.loadTags();
}
} catch(e) {}
},
async updateTagColor(id, color) {
try {
await fetch('/api/settings/tags/' + id, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({color: color})
});
await this.loadTags();
} catch(e) {}
},
async deleteTag(id) {
if (!confirm('Delete this tag?')) return;
try {
await fetch('/api/settings/tags/' + id, { method: 'DELETE' });
await this.loadTags();
} catch(e) {}
}
};
});
});
</script>
{% endblock %}
+50
View File
@@ -162,6 +162,56 @@ input, select, textarea { font-family: inherit; color: inherit; }
color: var(--text-primary);
}
.workspace-chevron {
font-size: 10px;
color: var(--text-dim);
transition: transform 0.2s ease;
}
.workspace-chevron.open {
transform: rotate(180deg);
}
/* User menu dropdown */
.user-menu-dropdown {
position: absolute;
top: 100%;
left: 8px;
right: 8px;
margin-top: 4px;
background: #1E1E1E;
border: 1px solid var(--border-strong);
border-radius: 10px;
box-shadow: 0 12px 32px rgba(0,0,0,.6);
z-index: 400;
padding: 4px 0;
overflow: hidden;
}
.um-user-info {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
}
.um-avatar {
width: 28px; height: 28px;
background: var(--bg-tertiary);
border-radius: 6px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 600; color: var(--text);
flex-shrink: 0;
}
.um-name { font-size: 14px; font-weight: 600; color: var(--text); }
.um-plan { font-size: 12px; color: var(--text-dim); margin-top: 1px; }
.um-sep { height: 1px; background: var(--border); margin: 3px 0; }
.um-item {
display: flex; align-items: center; gap: 8px;
padding: 7px 14px; font-size: 13px; color: var(--text);
text-decoration: none; cursor: pointer; transition: background 100ms;
}
.um-item:hover { background: var(--bg-hover); }
.um-danger { color: var(--danger); }
.um-danger:hover { background: rgba(255,80,80,.1); }
.sidebar-actions {
display: flex;
align-items: center;