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
+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 %}