Files
flowdeck/app/templates/settings.html
T
bruno 057ff9f524
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
ui: settings panel 1050px (était 820px) pour afficher tous les champs admin
2026-07-13 13:14:26 -04:00

652 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block page_title %}Settings{% endblock %}
{% block page_icon %}⚙️{% endblock %}
{% block topbar %}
<header class="topbar">
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu">
<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="page-title-topbar">⚙️ Settings</span>
</div>
</header>
{% endblock %}
{% block content %}
<style>
.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:1050px;max-width:98vw;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-dim);margin-top:2px;}
.setting-control{flex-shrink:0;margin-left:16px;}
/* 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;}
/* Avatar color swatches */
.avatar-color-swatch{width:40px;height:40px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:#fff;cursor:pointer;border:2px solid transparent;transition:all 120ms;}
.avatar-color-swatch:hover{transform:scale(1.1);}
.avatar-color-swatch.selected{border-color:var(--accent);box-shadow:0 0 0 2px rgba(35,131,226,.4);}
/* 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:6px;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);}
/* Modal (for tag delete/rename) */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;z-index:999;}
.modal-box{background:#1E1E1E;border:1px solid #444;border-radius:12px;padding:24px;min-width:320px;box-shadow:0 12px 40px rgba(0,0,0,.8);}
.modal-box h3{font-size:16px;font-weight:600;color:#fff;}
.settings-input{background:#2A2A2A;border:1px solid #555;border-radius:6px;color:#ddd;font-size:13px;padding:6px 10px;outline:none;width:100%;}
.settings-input:focus{border-color:var(--accent,#2383E2);box-shadow:0 0 0 2px rgba(35,131,226,.25);}
.btn-danger{background:#E03E3E;color:#fff;border:none;padding:6px 16px;border-radius:6px;cursor:pointer;font-size:13px;}
.btn-danger:hover{background:#C93535;}
/* Admin tables */
.table-wrap{overflow-x:auto;border-radius:8px;border:1px solid #333;}
.admin-table{width:100%;border-collapse:collapse;font-size:13px;}
.admin-table th{background:#1A1A1A;color:var(--text-dim);font-weight:500;padding:8px 12px;text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap;}
.admin-table td{padding:8px 12px;border-top:1px solid #2A2A2A;color:var(--text);}
.admin-table tr:hover td{background:#1E1E1E;}
.stat-card{background:#1A1A1A;border:1px solid #333;border-radius:8px;padding:14px;text-align:center;}
.stat-value{font-size:22px;font-weight:700;color:var(--accent,#2383E2);}
.stat-label{font-size:11px;color:var(--text-dim);margin-top:2px;text-transform:uppercase;letter-spacing:.5px;}
.role-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:500;}
.role-admin{background:rgba(35,131,226,.2);color:#4DA3F5;}
.role-user{background:rgba(120,119,116,.2);color:var(--text-dim);}
.status-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:5px;vertical-align:middle;}
.status-dot.active{background:#0F7B6C;}
.status-dot.inactive{background:#E03E3E;}
.btn-sm{background:transparent;border:1px solid #444;color:var(--text-dim);padding:2px 6px;border-radius:4px;cursor:pointer;font-size:12px;}
.btn-sm:hover{background:#333;color:var(--text);}
.btn-sm-danger{color:#E03E3E;border-color:#542;}
/* 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);}
.settings-select{background:var(--bg-tertiary);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 10px;font-size:13px;outline:none;cursor:pointer;min-width:140px;}
.settings-select:focus{border-color:var(--accent);}
</style>
<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>
<!-- Admin nav: only visible to admins -->
<template x-if="userIsAdmin">
<div>
<div class="settings-nav-header">Admin</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">👥 Users &amp; Roles</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">📋 Audit Log</div>
</div>
</template>
</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" :style="{background: avatarColor || '#3A3A3A'}">
<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 style="margin-top:8px;">
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px;">Or pick a color:</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<template x-for="c in avatarColors" :key="c">
<div class="avatar-color-swatch" :style="{background: c}"
:class="{ selected: avatarColor === c }"
@click="selectAvatarColor(c)">
<span>{{ user.get('full_name', user.get('login',''))[:1].upper() }}</span>
</div>
</template>
</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>
</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="settings-select" x-model="defaultView" @change="saveDefaultView()">
<option value="tree">🌳 Tree</option>
<option value="list">📋 List</option>
<option value="details">📊 Details</option>
<option value="title">🏷️ Cards</option>
<option value="content">📝 Content</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"
@dblclick="renameTag(tag)"
title="Double-click to rename"
style="cursor:pointer;"></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="deletingTag=tag" 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>
<!-- Delete confirmation modal -->
<div class="modal-overlay" x-show="deletingTag" @click.self="deletingTag=null" @keydown.escape="deletingTag=null" style="z-index:1000;">
<div class="modal-box" style="max-width:360px;">
<h3 style="margin:0 0 8px;">Delete tag</h3>
<p style="color:var(--text-dim);margin:0 0 16px;">
Are you sure you want to delete the tag "<strong x-text="deletingTag?.name"></strong>"?
<span x-show="deletingTag?.count > 0" style="color:#E03E3E;display:block;margin-top:4px;">
⚠️ This tag is used on <span x-text="deletingTag?.count"></span> item(s).
</span>
</p>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button class="btn" @click="deletingTag=null">Cancel</button>
<button class="btn btn-danger" @click="confirmDeleteTag()">Delete</button>
</div>
</div>
</div>
<!-- Rename modal -->
<div class="modal-overlay" x-show="renamingTag" @click.self="renamingTag=null" @keydown.escape="renamingTag=null" style="z-index:1000;">
<div class="modal-box" style="max-width:360px;">
<h3 style="margin:0 0 12px;">Rename tag</h3>
<div style="display:flex;gap:8px;align-items:center;">
<div class="tag-color-dot" :style="{background: renamingTag?.color}" style="width:16px;height:16px;"></div>
<input type="text" class="settings-input" x-model="renameValue"
@keydown.enter="confirmRenameTag()" @keydown.escape="renamingTag=null"
style="flex:1;" autofocus>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px;">
<button class="btn" @click="renamingTag=null">Cancel</button>
<button class="btn btn-primary" @click="confirmRenameTag()" :disabled="!renameValue.trim()">Rename</button>
</div>
</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: Users & Roles -->
<div x-show="activeSection==='admin-users'">
<h2>Users &amp; Roles</h2>
<p class="section-desc">Manage user accounts, roles, and permissions.</p>
<!-- Quick Stats -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px;">
<div class="stat-card">
<div class="stat-value" x-text="adminStats.total_users">—</div>
<div class="stat-label">Total Users</div>
</div>
<div class="stat-card">
<div class="stat-value" x-text="adminStats.total_workspaces">—</div>
<div class="stat-label">Workspaces</div>
</div>
<div class="stat-card">
<div class="stat-value" x-text="adminStats.total_files">—</div>
<div class="stat-label">Files</div>
</div>
<div class="stat-card">
<div class="stat-value" x-text="adminStats.total_mb + ' MB'">— MB</div>
<div class="stat-label">Storage</div>
</div>
</div>
<!-- Create User Button -->
<div style="margin-bottom:16px;">
<button class="btn btn-primary" @click="showCreateUser=!showCreateUser" style="font-size:13px;">+ New User</button>
</div>
<!-- Create User Form -->
<div x-show="showCreateUser" class="new-tag-form" style="margin-bottom:16px;">
<input type="text" placeholder="Login" x-model="newUser.login" style="width:120px;">
<input type="text" placeholder="Full name" x-model="newUser.name" style="width:140px;">
<input type="email" placeholder="Email" x-model="newUser.email" style="width:160px;">
<input type="password" placeholder="Password" x-model="newUser.password" style="width:130px;">
<label style="font-size:11px;color:var(--text-dim);white-space:nowrap;">
<input type="checkbox" x-model="newUser.is_admin"> Admin
</label>
<button class="btn btn-primary" @click="adminCreateUser()" :disabled="!newUser.login || !newUser.password" style="padding:4px 12px;font-size:12px;">Create</button>
</div>
<!-- Users Table -->
<div class="table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Status</th>
<th>Workspaces</th>
<th>Files</th>
<th>Folders</th>
<th>Storage</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template x-for="u in adminUsers" :key="u.id">
<tr>
<td>
<div style="font-weight:500;" x-text="u.full_name || u.login"></div>
<div style="font-size:11px;color:var(--text-dim);" x-text="u.login"></div>
</td>
<td>
<span class="role-badge" :class="u.is_admin ? 'role-admin' : 'role-user'" x-text="u.is_admin ? 'Admin' : 'User'"></span>
</td>
<td>
<span class="status-dot" :class="u.is_active ? 'active' : 'inactive'"></span>
<span x-text="u.is_active ? 'Active' : 'Inactive'" style="font-size:12px;color:var(--text-dim);"></span>
</td>
<td x-text="u.ws_count" style="text-align:center;"></td>
<td x-text="u.file_count" style="text-align:center;"></td>
<td x-text="u.folder_count" style="text-align:center;"></td>
<td x-text="u.total_mb + ' MB'" style="text-align:right;"></td>
<td>
<span style="font-size:11px;color:var(--text-dim);" x-text="u.last_login ? new Date(u.last_login*1000).toLocaleDateString() : 'Never'"></span>
</td>
<td>
<div style="display:flex;gap:4px;">
<button class="btn-sm" @click="editingUser=u; editUserForm={login:u.login,name:u.full_name,email:u.email,is_admin:u.is_admin,is_active:u.is_active}" title="Edit">✏️</button>
<button class="btn-sm btn-sm-danger" @click="adminDeleteUser(u.id)" title="Delete" x-show="adminUsers.length > 1">🗑</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Edit User Modal -->
<div class="modal-overlay" x-show="editingUser" @click.self="editingUser=null" @keydown.escape="editingUser=null" style="z-index:1001;">
<div class="modal-box" style="max-width:400px;">
<h3 style="margin:0 0 12px;">Edit User: <span x-text="editUserForm.login"></span></h3>
<div style="display:flex;flex-direction:column;gap:10px;">
<input type="text" class="settings-input" placeholder="Full name" x-model="editUserForm.name">
<input type="email" class="settings-input" placeholder="Email" x-model="editUserForm.email">
<input type="password" class="settings-input" placeholder="New password (leave blank to keep)" x-model="editUserForm.password">
<label style="font-size:13px;color:var(--text);display:flex;align-items:center;gap:8px;">
<input type="checkbox" x-model="editUserForm.is_admin"> Admin role
</label>
<label style="font-size:13px;color:var(--text);display:flex;align-items:center;gap:8px;">
<input type="checkbox" x-model="editUserForm.is_active"> Active account
</label>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px;">
<button class="btn" @click="editingUser=null">Cancel</button>
<button class="btn btn-primary" @click="adminUpdateUser()">Save</button>
</div>
</div>
</div>
</div>
<!-- Admin: Audit Log -->
<div x-show="activeSection==='admin-audit'">
<h2>Audit Log</h2>
<p class="section-desc">Login history and security events.</p>
<div class="table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Date</th>
<th>User</th>
<th>IP Address</th>
<th>User Agent</th>
</tr>
</thead>
<tbody>
<template x-for="e in auditEntries" :key="e.id">
<tr>
<td><span style="font-size:12px;" x-text="new Date(e.logged_at+'Z').toLocaleString()"></span></td>
<td>
<span style="font-weight:500;" x-text="e.full_name || e.login"></span>
<span style="font-size:11px;color:var(--text-dim);display:block;" x-text="e.login"></span>
</td>
<td><code style="font-size:11px;" x-text="e.ip_address || '—'"></code></td>
<td><span style="font-size:11px;color:var(--text-dim);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;" x-text="e.user_agent || '—'"></span></td>
</tr>
</template>
<tr x-show="auditEntries.length === 0">
<td colspan="4" style="text-align:center;color:var(--text-dim);padding:20px;">No login events yet</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('alpine:init', function() {
Alpine.data('settingsInit', function() {
return {
activeSection: 'account',
allTags: [],
newTagName: '',
newTagColor: '#787774',
deletingTag: null,
renamingTag: null,
renameValue: '',
presetColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
avatarUrl: '{{ avatar_url or "" }}',
avatarColor: '{{ avatar_color or "#3A3A3A" }}',
avatarColors: ['#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#448361','#337EA9','#9065B0','#C94D8B','#787774','#3A3A3A'],
defaultView: localStorage.getItem('fd_default_view') || 'tree',
userInfo: {full_name: '{{ user.full_name or "" }}', login: '{{ user.login or "" }}', email: '{{ user.email or "" }}'},
userIsAdmin: {{ 'true' if user.get('is_admin') else 'false' }},
// Admin data
adminUsers: [],
adminStats: {},
auditEntries: [],
showCreateUser: false,
newUser: {login:'',name:'',email:'',password:'',is_admin:false},
editingUser: null,
editUserForm: {login:'',name:'',email:'',password:'',is_admin:false,is_active:true},
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;
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) {
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;
await fetch('/api/settings/tags/' + id, { method: 'DELETE' });
await this.loadTags();
},
async renameTag(tag) {
this.renamingTag = tag;
this.renameValue = tag.name;
// Focus the input after Alpine renders the modal
var self = this;
this.$nextTick(function() {
var input = document.querySelector('.modal-box input[type=text]');
if (input) { input.focus(); input.select(); }
});
},
async confirmRenameTag() {
var tag = this.renamingTag;
var newName = this.renameValue.trim();
if (!tag || !newName || newName.toLowerCase() === tag.name.toLowerCase()) {
this.renamingTag = null; return;
}
await fetch('/api/settings/tags/' + tag.id, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({name: newName})
});
this.renamingTag = null;
await this.loadTags();
},
async confirmDeleteTag() {
var tag = this.deletingTag;
if (!tag) return;
await fetch('/api/settings/tags/' + tag.id, { method: 'DELETE' });
this.deletingTag = null;
await this.loadTags();
},
// ── Admin ──
async loadAdminUsers() {
try {
var r = await fetch('/api/admin/users');
var d = await r.json();
this.adminUsers = d.users || [];
} catch(e) { this.adminUsers = []; }
await this.loadAdminStats();
},
async loadAdminStats() {
try {
var r = await fetch('/api/admin/stats');
this.adminStats = await r.json();
} catch(e) { this.adminStats = {}; }
},
async loadAdminAudit() {
try {
var r = await fetch('/api/admin/audit?limit=200');
var d = await r.json();
this.auditEntries = d.entries || [];
} catch(e) { this.auditEntries = []; }
},
async adminCreateUser() {
var u = this.newUser;
if (!u.login || !u.password) return;
try {
var r = await fetch('/api/admin/users', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({login:u.login, name:u.name, email:u.email, password:u.password, is_admin:u.is_admin?1:0})
});
if (r.ok) {
this.newUser = {login:'',name:'',email:'',password:'',is_admin:false};
this.showCreateUser = false;
await this.loadAdminUsers();
} else {
var d = await r.json();
alert(d.error || 'Failed to create user');
}
} catch(e) { alert('Network error'); }
},
async adminUpdateUser() {
var f = this.editUserForm;
if (!f.login) return;
var body = {name: f.name, email: f.email, is_admin: f.is_admin?1:0, is_active: f.is_active?1:0};
if (f.password) body.password = f.password;
try {
var r = await fetch('/api/admin/users/' + this.editingUser.id, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify(body)
});
if (r.ok) {
this.editingUser = null;
await this.loadAdminUsers();
}
} catch(e) {}
},
async adminDeleteUser(id) {
if (!confirm('Permanently delete this user and all their data?')) return;
try {
await fetch('/api/admin/users/' + id, { method: 'DELETE' });
await this.loadAdminUsers();
} catch(e) {}
},
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();
this.avatarColor = '';
}
},
async selectAvatarColor(color) {
this.avatarColor = color;
var r = await fetch('/api/settings/avatar-color', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({color: color})
});
if (r.ok) { this.avatarUrl = ''; }
},
saveDefaultView() {
localStorage.setItem('fd_default_view', this.defaultView);
}
};
});
});
</script>
{% endblock %}