feat: settings panel overlay Notion-style + avatar upload + sections
- Settings page refaite en panneau overlay (position:fixed + backdrop-blur)
- Layout 2 panes: nav sidebar gauche + contenu droit
- Sections: Account (profile + avatar upload), Notifications, Workspace,
Tags management, Features (integrations), Admin (security)
- Avatar: upload image (POST /api/settings/avatar), preview instantané,
stockage /data/avatars/, mise à jour users.avatar_url
- GET /api/avatar/{user_id} redirige vers l'image avatar
- Tags: palette couleurs, création, suppression, changement couleur
- Fermeture: bouton × ou Escape (window.history.back)
This commit is contained in:
+227
-76
@@ -15,77 +15,223 @@
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.settings-layout{max-width:720px;margin:0 auto;padding:32px 24px;}
|
||||
.settings-section{margin-bottom:32px;}
|
||||
.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);}
|
||||
.settings-overlay{position:fixed;inset:0;z-index:500;display:flex;background:rgba(0,0,0,.5);backdrop-filter:blur(2px);}
|
||||
.settings-panel{display:flex;width:820px;max-width:95vw;height:85vh;margin:auto;background:#1E1E1E;border:1px solid var(--border-strong);border-radius:14px;box-shadow:0 16px 48px rgba(0,0,0,.6);overflow:hidden;}
|
||||
.settings-nav{width:200px;min-width:200px;background:var(--bg-sidebar);border-right:1px solid var(--border);padding:8px 0;overflow-y:auto;}
|
||||
.settings-nav-header{padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;}
|
||||
.settings-nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;border-radius:0;}
|
||||
.settings-nav-item:hover{background:var(--bg-hover);}
|
||||
.settings-nav-item.active{background:rgba(35,131,226,.12);color:var(--text);font-weight:500;}
|
||||
.settings-content{flex:1;overflow-y:auto;padding:24px 32px;}
|
||||
.settings-content h2{font-size:18px;font-weight:600;margin-bottom:4px;}
|
||||
.settings-content .section-desc{font-size:12px;color:var(--text-dim);margin-bottom:24px;}
|
||||
.setting-group{margin-bottom:28px;}
|
||||
.setting-group h3{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin-bottom:8px;padding-bottom:4px;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-desc{font-size:12px;color:var(--text-dim);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);}
|
||||
/* Avatar */
|
||||
.avatar-section{display:flex;align-items:center;gap:16px;padding:12px 0;}
|
||||
.avatar-preview{width:64px;height:64px;border-radius:50%;background:var(--bg-tertiary);display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:700;color:var(--text);overflow:hidden;flex-shrink:0;}
|
||||
.avatar-preview img{width:100%;height:100%;object-fit:cover;}
|
||||
.avatar-upload-label{cursor:pointer;font-size:13px;color:var(--accent);padding:6px 12px;border:1px solid var(--accent);border-radius:6px;transition:all 150ms;}
|
||||
.avatar-upload-label:hover{background:rgba(35,131,226,.1);}
|
||||
.avatar-upload-input{display:none;}
|
||||
|
||||
/* Color picker grid */
|
||||
.color-picker{display:flex;gap:4px;flex-wrap:wrap;}
|
||||
/* Tags */
|
||||
.tag-mgmt-item{display:flex;align-items:center;gap:10px;padding:8px 10px;background:var(--bg-tertiary);border-radius:8px;margin-bottom:4px;}
|
||||
.tag-mgmt-item .tag-color-dot{width:14px;height:14px;border-radius:50%;flex-shrink:0;}
|
||||
.tag-name{flex:1;font-size:13px;color:var(--text);}
|
||||
.tag-count{font-size:12px;color:var(--text-dim);}
|
||||
.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;}
|
||||
.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;}
|
||||
/* Close button */
|
||||
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;}
|
||||
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
|
||||
</style>
|
||||
|
||||
<div class="settings-layout" x-data="settingsInit()">
|
||||
<!-- Tags Management -->
|
||||
<div class="settings-section">
|
||||
<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 class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
|
||||
<div class="settings-panel" style="position:relative;">
|
||||
<button class="settings-close" @click="window.history.back()" title="Close">×</button>
|
||||
|
||||
<!-- Left navigation -->
|
||||
<div class="settings-nav">
|
||||
<div class="settings-nav-header">Account</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'">👤 My account</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'">🔔 Notifications</div>
|
||||
<div class="settings-nav-header">Workspace</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">📋 General</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">🏷️ Tags</div>
|
||||
<div class="settings-nav-header">Features</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">🧩 Integrations</div>
|
||||
<div class="settings-nav-header">Admin</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin' }" @click="activeSection='admin'">🔧 Administration</div>
|
||||
</div>
|
||||
|
||||
<!-- Right content -->
|
||||
<div class="settings-content">
|
||||
|
||||
<!-- Account -->
|
||||
<div x-show="activeSection==='account'">
|
||||
<h2>My account</h2>
|
||||
<p class="section-desc">Manage your profile and preferences.</p>
|
||||
<div class="setting-group">
|
||||
<h3>Profile</h3>
|
||||
<div class="avatar-section">
|
||||
<div class="avatar-preview">
|
||||
<img x-show="avatarUrl" :src="avatarUrl" alt="Avatar">
|
||||
<span x-show="!avatarUrl">{{ user.get('full_name', user.get('login',''))[:1].upper() }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="avatar-upload-label">
|
||||
Upload photo
|
||||
<input type="file" class="avatar-upload-input" accept="image/*" @change="uploadAvatar($event)">
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--text-dim);margin-top:4px;">JPG, PNG or GIF — max 2MB</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Full name</div>
|
||||
<div class="setting-desc" x-text="userInfo.full_name || userInfo.login"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Email</div>
|
||||
<div class="setting-desc" x-text="userInfo.email || '—'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- Workspace Info -->
|
||||
<div class="settings-section">
|
||||
<h2>📋 Workspace</h2>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Workspace name</div>
|
||||
<div class="setting-desc">Current: {{ workspaceName }}</div>
|
||||
<!-- Notifications -->
|
||||
<div x-show="activeSection==='notifications'">
|
||||
<h2>Notifications</h2>
|
||||
<p class="section-desc">Choose when and how you want to be notified.</p>
|
||||
<div class="setting-group">
|
||||
<h3>Email notifications</h3>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Comments</div>
|
||||
<div class="setting-desc">When someone comments on your pages</div>
|
||||
</div>
|
||||
<div class="setting-control"><span style="font-size:12px;color:var(--text-dim);">Coming soon</span></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Mentions</div>
|
||||
<div class="setting-desc">When someone @mentions you</div>
|
||||
</div>
|
||||
<div class="setting-control"><span style="font-size:12px;color:var(--text-dim);">Coming soon</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workspace -->
|
||||
<div x-show="activeSection==='workspace'">
|
||||
<h2>Workspace</h2>
|
||||
<p class="section-desc">General workspace settings.</p>
|
||||
<div class="setting-group">
|
||||
<h3>General</h3>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Workspace name</div>
|
||||
<div class="setting-desc" x-text="'{{ workspace_name }}'"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Default view</div>
|
||||
<div class="setting-desc">Choose the default view for new pages</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select class="sort-select">
|
||||
<option>Tree</option><option>List</option><option>Details</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div x-show="activeSection==='tags'">
|
||||
<h2>Tags</h2>
|
||||
<p class="section-desc">Manage all tags across your workspace.</p>
|
||||
<div class="setting-group">
|
||||
<h3>All tags</h3>
|
||||
<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>
|
||||
<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-chip rm-btn" @click="deleteTag(tag.id)" title="Delete">×</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="new-tag-form">
|
||||
<template x-for="c in presetColors" :key="c">
|
||||
<div class="color-swatch" :class="{ selected: newTagColor === c }" :style="{background: c}" @click="newTagColor=c"></div>
|
||||
</template>
|
||||
<input type="text" placeholder="New tag..." 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>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div x-show="activeSection==='features'">
|
||||
<h2>Features</h2>
|
||||
<p class="section-desc">Integrations and feature toggles.</p>
|
||||
<div class="setting-group">
|
||||
<h3>Integrations</h3>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Gitea</div>
|
||||
<div class="setting-desc">Connected to git.dracodev.net</div>
|
||||
</div>
|
||||
<div class="setting-control"><span style="font-size:12px;color:#0F7B6C;">✅ Active</span></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">GitHub</div>
|
||||
<div class="setting-desc">Connect your GitHub account</div>
|
||||
</div>
|
||||
<div class="setting-control"><span style="font-size:12px;color:var(--text-dim);">Coming soon</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin -->
|
||||
<div x-show="activeSection==='admin'">
|
||||
<h2>Administration</h2>
|
||||
<p class="section-desc">Workspace administration and security.</p>
|
||||
<div class="setting-group">
|
||||
<h3>Security</h3>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Session timeout</div>
|
||||
<div class="setting-desc">Automatically log out after inactivity</div>
|
||||
</div>
|
||||
<div class="setting-control"><span style="font-size:12px;color:var(--text-dim);">30 days</span></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Two-factor authentication</div>
|
||||
<div class="setting-desc">Add an extra layer of security</div>
|
||||
</div>
|
||||
<div class="setting-control"><span style="font-size:12px;color:var(--text-dim);">Coming soon</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,10 +240,13 @@
|
||||
document.addEventListener('alpine:init', function() {
|
||||
Alpine.data('settingsInit', function() {
|
||||
return {
|
||||
activeSection: 'account',
|
||||
allTags: [],
|
||||
newTagName: '',
|
||||
newTagColor: '#787774',
|
||||
presetColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
|
||||
avatarUrl: '{{ user.avatar_url or "" }}',
|
||||
userInfo: {full_name: '{{ user.full_name or "" }}', login: '{{ user.login or "" }}', email: '{{ user.email or "" }}'},
|
||||
|
||||
async init() {
|
||||
await this.loadTags();
|
||||
@@ -114,35 +263,37 @@ document.addEventListener('alpine:init', function() {
|
||||
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) {}
|
||||
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(); }
|
||||
},
|
||||
|
||||
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) {}
|
||||
await fetch('/api/settings/tags/' + id, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({color: color})
|
||||
});
|
||||
await this.loadTags();
|
||||
},
|
||||
|
||||
async deleteTag(id) {
|
||||
if (!confirm('Delete this tag?')) return;
|
||||
try {
|
||||
await fetch('/api/settings/tags/' + id, { method: 'DELETE' });
|
||||
await this.loadTags();
|
||||
} catch(e) {}
|
||||
await fetch('/api/settings/tags/' + id, { method: 'DELETE' });
|
||||
await this.loadTags();
|
||||
},
|
||||
|
||||
async uploadAvatar(ev) {
|
||||
var file = ev.target.files[0];
|
||||
if (!file) return;
|
||||
var form = new FormData();
|
||||
form.append('file', file);
|
||||
var r = await fetch('/api/settings/avatar', { method: 'POST', body: form });
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
this.avatarUrl = d.avatar_url + '?t=' + Date.now();
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user