feat: sidebar user menu Notion-style + settings page + tag colors + preview images/PDF
- 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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user