fa317a3b78
Theme preference stored in localStorage (auto follows the OS setting). The chosen data-theme attribute is applied synchronously in <head> to avoid any flash of unstyled content. CSS custom properties handle all surface, text, border and input colours across every page. The Appearance section on the profile page lets each user switch modes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
843 lines
34 KiB
HTML
843 lines
34 KiB
HTML
<!DOCTYPE html>
|
||
<html>
|
||
{% include 'head.html' %}
|
||
|
||
<style>
|
||
html, body { overflow: visible; }
|
||
|
||
.container {
|
||
max-width: 900px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
h1 {
|
||
color: #333;
|
||
margin-bottom: 4px;
|
||
font-size: 1.5em;
|
||
}
|
||
|
||
.subtitle {
|
||
color: #666;
|
||
margin-bottom: 24px;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
/* ---- Profile card ---- */
|
||
.profile-card {
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 1px 6px rgba(0,0,0,0.1);
|
||
padding: 28px 32px;
|
||
margin-bottom: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 28px;
|
||
}
|
||
|
||
.avatar-large {
|
||
width: 80px;
|
||
height: 80px;
|
||
border-radius: 50%;
|
||
object-fit: cover;
|
||
flex-shrink: 0;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||
}
|
||
|
||
.avatar-initials-large {
|
||
width: 80px;
|
||
height: 80px;
|
||
border-radius: 50%;
|
||
background: #0066cc;
|
||
color: #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 2em;
|
||
font-weight: 700;
|
||
flex-shrink: 0;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||
}
|
||
|
||
.profile-info { flex: 1; }
|
||
|
||
.profile-name {
|
||
font-size: 1.4em;
|
||
font-weight: 700;
|
||
color: #222;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.profile-username {
|
||
font-size: 0.9em;
|
||
color: #666;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 2px 10px;
|
||
border-radius: 12px;
|
||
font-size: 0.78em;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.4px;
|
||
}
|
||
|
||
.badge-admin { background: #e8f0fe; color: #1a73e8; }
|
||
.badge-user { background: #f1f3f4; color: #555; }
|
||
|
||
.profile-logout {
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.btn-logout {
|
||
display: inline-block;
|
||
padding: 6px 16px;
|
||
border-radius: 4px;
|
||
background: #f44336;
|
||
color: #fff;
|
||
font-size: 1.00em;
|
||
font-weight: 500;
|
||
text-decoration: none;
|
||
transition: background 0.15s;
|
||
}
|
||
.btn-logout:hover { background: #d32f2f; text-decoration: none; }
|
||
|
||
/* ---- Section cards ---- */
|
||
.section {
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 1px 6px rgba(0,0,0,0.1);
|
||
padding: 20px 24px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.section h2 {
|
||
font-size: 1em;
|
||
font-weight: 700;
|
||
color: #333;
|
||
margin: 0 0 16px;
|
||
padding-bottom: 10px;
|
||
border-bottom: 1px solid #eee;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
/* ---- Settings rows ---- */
|
||
.settings-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
padding: 8px 0;
|
||
border-bottom: 1px solid #f5f5f5;
|
||
font-size: 0.9em;
|
||
}
|
||
.settings-row:last-child { border-bottom: none; }
|
||
|
||
.settings-label {
|
||
width: 180px;
|
||
flex-shrink: 0;
|
||
color: #666;
|
||
font-size: 0.88em;
|
||
}
|
||
|
||
.settings-value { color: #222; }
|
||
|
||
.settings-empty { color: #aaa; font-style: italic; }
|
||
|
||
/* ---- Host lists ---- */
|
||
.host-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.host-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 4px 12px;
|
||
border-radius: 16px;
|
||
font-size: 1.00em;
|
||
font-weight: 500;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.host-chip.owner { background: #e8f5e9; color: #2e7d32; }
|
||
.host-chip.manager { background: #e3f2fd; color: #1565c0; }
|
||
.host-chip.monitor { background: #f3e5f5; color: #6a1b9a; }
|
||
|
||
.host-chip-dot {
|
||
width: 7px; height: 7px; border-radius: 50%;
|
||
}
|
||
.owner .host-chip-dot { background: #2e7d32; }
|
||
.manager .host-chip-dot { background: #1565c0; }
|
||
.monitor .host-chip-dot { background: #6a1b9a; }
|
||
|
||
.no-hosts {
|
||
color: #aaa;
|
||
font-size: 0.9em;
|
||
font-style: italic;
|
||
}
|
||
|
||
/* ---- Notification channels ---- */
|
||
.channel-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 6px 0;
|
||
border-bottom: 1px solid #f5f5f5;
|
||
font-size: 0.9em;
|
||
}
|
||
.channel-row:last-child { border-bottom: none; }
|
||
|
||
.channel-type {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
font-size: 0.78em;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
background: #f1f3f4;
|
||
color: #555;
|
||
min-width: 70px;
|
||
text-align: center;
|
||
}
|
||
|
||
.channel-name { color: #333; }
|
||
|
||
.edit-section { margin-top: 20px; }
|
||
.edit-section h4 { font-size: .88em; font-weight: 600; color: #333; margin: 0 0 10px; text-transform: uppercase; letter-spacing: .04em; border-bottom: 1px solid #eee; padding-bottom: 6px; }
|
||
.edit-field { margin-bottom: 10px; }
|
||
.edit-field label { display: block; font-size: .82em; color: #666; margin-bottom: 3px; }
|
||
.edit-input { width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 5px 8px; font-size: .88em; box-sizing: border-box; }
|
||
.edit-input:focus { border-color: #0066cc; outline: none; }
|
||
.status-msg { font-size: .82em; margin-left: 8px; }
|
||
.save-row { display: flex; align-items: center; margin-top: 8px; }
|
||
.btn-save { background: #0066cc; color: #fff; border: none; border-radius: 4px; padding: 5px 14px; font-size: .85em; cursor: pointer; }
|
||
.btn-save:hover { background: #0055aa; }
|
||
/* ---- Channel chip picker ---- */
|
||
.ch-picker { }
|
||
.ch-picker-label { font-size: .8em; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 6px; }
|
||
.ch-chips { display: flex; flex-wrap: wrap; gap: 6px; min-height: 32px; margin-bottom: 10px; }
|
||
.ch-chip {
|
||
display: inline-flex; align-items: center; gap: 5px;
|
||
padding: 4px 10px; border-radius: 14px; font-size: .85em; font-weight: 500; cursor: pointer;
|
||
border: none; font-family: inherit;
|
||
}
|
||
.ch-chip.selected { background: #e3f2fd; color: #1565c0; }
|
||
.ch-chip.selected:hover { background: #bbdefb; }
|
||
.ch-chip.available { background: #f1f3f4; color: #555; }
|
||
.ch-chip.available:hover { background: #e8eaf6; color: #283593; }
|
||
.ch-chip-x { font-size: .9em; line-height: 1; color: inherit; opacity: .7; }
|
||
|
||
/* ---- My Channels card list ---- */
|
||
.my-ch-card {
|
||
border: 1px solid #e8eaf6; border-radius: 6px; margin-bottom: 8px; overflow: hidden;
|
||
}
|
||
.my-ch-header {
|
||
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
||
background: #f8f9ff; border-bottom: 1px solid #e8eaf6;
|
||
}
|
||
.my-ch-name { font-weight: 600; font-size: .9em; color: #222; }
|
||
.my-ch-type { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #e8eaf6; color: #3949ab; }
|
||
.my-ch-private { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fce4ec; color: #c62828; }
|
||
.my-ch-actions { margin-left: auto; display: flex; gap: 5px; }
|
||
.btn-sm-edit { background: #888; color: #fff; border: none; border-radius: 4px; padding: 2px 8px; font-size: .78em; cursor: pointer; }
|
||
.btn-sm-edit:hover { background: #666; }
|
||
.btn-sm-del { background: transparent; color: #c62828; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 7px; font-size: .78em; cursor: pointer; }
|
||
.btn-sm-del:hover { background: #fce4ec; }
|
||
|
||
/* ---- Theme picker ---- */
|
||
.theme-btns { display: flex; gap: 6px; }
|
||
.theme-btn {
|
||
padding: 5px 14px;
|
||
border: 1px solid var(--border, #e0e0e0);
|
||
border-radius: 4px;
|
||
background: var(--surface-3, #f5f5f5);
|
||
color: var(--text-sec, #666);
|
||
cursor: pointer;
|
||
font-size: .88em;
|
||
font-family: inherit;
|
||
}
|
||
.theme-btn:hover { border-color: var(--link, #0066cc); color: var(--link, #0066cc); }
|
||
.theme-btn.active { background: var(--link, #0066cc); color: #fff; border-color: var(--link, #0066cc); }
|
||
|
||
/* ── Dark mode ── */
|
||
html[data-theme="dark"] h1 { color: var(--text); }
|
||
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
|
||
html[data-theme="dark"] .profile-card { background: var(--surface); box-shadow: 0 1px 6px var(--shadow); }
|
||
html[data-theme="dark"] .profile-name { color: var(--text); }
|
||
html[data-theme="dark"] .profile-username { color: var(--text-sec); }
|
||
html[data-theme="dark"] .badge-admin { background: #1a3255; color: #7aa8f0; }
|
||
html[data-theme="dark"] .badge-user { background: var(--surface-3); color: var(--text-sec); }
|
||
html[data-theme="dark"] .section { background: var(--surface); box-shadow: 0 1px 6px var(--shadow); }
|
||
html[data-theme="dark"] .section h2 { color: var(--text); border-bottom-color: var(--border); }
|
||
html[data-theme="dark"] .settings-row { border-bottom-color: var(--border-4); }
|
||
html[data-theme="dark"] .settings-label { color: var(--text-sec); }
|
||
html[data-theme="dark"] .settings-value { color: var(--text); }
|
||
html[data-theme="dark"] .settings-empty { color: var(--text-dim); }
|
||
html[data-theme="dark"] .edit-section h4 { color: var(--text); border-bottom-color: var(--border); }
|
||
html[data-theme="dark"] .edit-field label { color: var(--text-sec); }
|
||
html[data-theme="dark"] .edit-input { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
|
||
html[data-theme="dark"] .channel-row { border-bottom-color: var(--border-4); }
|
||
html[data-theme="dark"] .channel-name { color: var(--text); }
|
||
html[data-theme="dark"] .ch-picker-label { color: var(--text-sec); }
|
||
html[data-theme="dark"] .ch-chip.selected { background: #1a3255; color: #60a5fa; }
|
||
html[data-theme="dark"] .ch-chip.available { background: var(--surface-3); color: var(--text-sec); }
|
||
html[data-theme="dark"] .ch-chip.available:hover { background: var(--border); color: var(--link); }
|
||
html[data-theme="dark"] .my-ch-card { border-color: var(--border); }
|
||
html[data-theme="dark"] .my-ch-header { background: var(--surface-2); border-bottom-color: var(--border); }
|
||
html[data-theme="dark"] .my-ch-name { color: var(--text); }
|
||
html[data-theme="dark"] .host-chip.owner { background: #0d2e17; color: #66bb6a; }
|
||
html[data-theme="dark"] .host-chip.manager { background: #0d1f40; color: #64b5f6; }
|
||
html[data-theme="dark"] .host-chip.monitor { background: #1e0d30; color: #ba68c8; }
|
||
html[data-theme="dark"] .no-hosts { color: var(--text-dim); }
|
||
html[data-theme="dark"] .ch-modal-box { background: var(--surface); color: var(--text); }
|
||
html[data-theme="dark"] .ch-modal-box h3 { color: var(--text); }
|
||
html[data-theme="dark"] .ch-form-row label { color: var(--text-sec); }
|
||
html[data-theme="dark"] .ch-form-divider { color: var(--text-muted); border-top-color: var(--border); }
|
||
|
||
/* ---- Channel modal (for My Channels CRUD) ---- */
|
||
.ch-modal-overlay {
|
||
position: fixed; inset: 0; background: rgba(0,0,0,.4);
|
||
display: flex; align-items: center; justify-content: center; z-index: 1001;
|
||
}
|
||
.ch-modal-box {
|
||
background: #fff; border-radius: 8px; padding: 24px;
|
||
min-width: 360px; max-width: 520px; width: 95%;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,.2);
|
||
}
|
||
.ch-modal-box h3 { margin: 0 0 16px; font-size: 1em; }
|
||
.ch-form-row { margin-bottom: 12px; }
|
||
.ch-form-row label { display: block; font-size: .83em; font-weight: 600; color: #555; margin-bottom: 3px; }
|
||
.ch-form-row input[type=text], .ch-form-row input[type=password], .ch-form-row select {
|
||
width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 5px 8px;
|
||
font-size: .88em; box-sizing: border-box; font-family: inherit;
|
||
}
|
||
.ch-form-row input:focus, .ch-form-row select:focus { border-color: #0066cc; outline: none; }
|
||
.ch-form-divider { font-size: .78em; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #888; margin: 14px 0 8px; border-top: 1px solid #eee; padding-top: 10px; }
|
||
.ch-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
|
||
.ch-modal-status { font-size: .83em; margin-top: 8px; }
|
||
</style>
|
||
|
||
<body>
|
||
{% include 'nav.html' %}
|
||
|
||
<div class="container">
|
||
<h1>{{ header }}</h1>
|
||
<p class="subtitle">Your account settings and host access</p>
|
||
|
||
<!-- Profile card -->
|
||
<div class="profile-card">
|
||
{% if current_user and current_user.avatar %}
|
||
<img class="avatar-large" src="{{ current_user.avatar_url }}" alt="">
|
||
{% else %}
|
||
<div class="avatar-initials-large">
|
||
{{ ((current_user.full_name if current_user else '') or (current_user.username if current_user else '?'))[:1] | upper }}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="profile-info">
|
||
<div class="profile-name">{{ current_user.full_name if current_user and current_user.full_name else (current_user.username if current_user else '—') }}</div>
|
||
<div class="profile-username">@{{ current_user.username if current_user else '—' }}</div>
|
||
{% if current_user and current_user.admin %}
|
||
<span class="badge badge-admin">Admin</span>
|
||
{% else %}
|
||
<span class="badge badge-user">User</span>
|
||
{% endif %}
|
||
<div class="profile-logout">
|
||
<a href="/logout" class="btn-logout">Sign out</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Account settings -->
|
||
<div class="section">
|
||
<h2>Account</h2>
|
||
<div class="settings-row">
|
||
<span class="settings-label">Username</span>
|
||
<span class="settings-value">{{ current_user.username if current_user else '—' }}</span>
|
||
</div>
|
||
<div class="settings-row">
|
||
<span class="settings-label">Full name</span>
|
||
{% if current_user and current_user.full_name %}
|
||
<span class="settings-value">{{ current_user.full_name }}</span>
|
||
{% else %}
|
||
<span class="settings-empty">Not set</span>
|
||
{% endif %}
|
||
</div>
|
||
<div class="settings-row">
|
||
<span class="settings-label">Role</span>
|
||
<span class="settings-value">{{ 'Administrator' if current_user and current_user.admin else 'User' }}</span>
|
||
</div>
|
||
<div class="settings-row">
|
||
<span class="settings-label">Avatar</span>
|
||
{% if current_user and current_user.avatar %}
|
||
<span class="settings-value" style="word-break:break-all;">{{ current_user.avatar }}</span>
|
||
{% else %}
|
||
<span class="settings-empty">Not set (initials used)</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
{% if current_user %}
|
||
<!-- ---- Editable identity ---- -->
|
||
<div class="section edit-section">
|
||
<h4>Identity</h4>
|
||
<div class="edit-field">
|
||
<label for="profile-fullname">Display name</label>
|
||
<input id="profile-fullname" class="edit-input" type="text" value="{{ current_user.full_name | e }}" placeholder="Full name">
|
||
</div>
|
||
<div class="edit-field">
|
||
<label for="profile-avatar">Avatar URL or path</label>
|
||
<input id="profile-avatar" class="edit-input" type="text" value="{{ current_user.avatar | e }}" placeholder="/path/to/avatar.png or https://…">
|
||
</div>
|
||
<div class="save-row">
|
||
<button class="btn-save" onclick="saveIdentity()">Save</button>
|
||
<span id="identity-status" class="status-msg"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ---- Change password ---- -->
|
||
<div class="section edit-section">
|
||
<h4>Change password</h4>
|
||
<div class="edit-field">
|
||
<label for="profile-current-pw">Current password</label>
|
||
<input id="profile-current-pw" class="edit-input" type="password" autocomplete="current-password">
|
||
</div>
|
||
<div class="edit-field">
|
||
<label for="profile-new-pw">New password</label>
|
||
<input id="profile-new-pw" class="edit-input" type="password" autocomplete="new-password">
|
||
</div>
|
||
<div class="save-row">
|
||
<button class="btn-save" onclick="changePassword()">Change password</button>
|
||
<span id="password-status" class="status-msg"></span>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- Notification channels — chip picker -->
|
||
<div class="section">
|
||
<h2>Notification Channels</h2>
|
||
{% if current_user %}
|
||
<p style="font-size:.82em;color:#888;margin:0 0 12px">Click a channel to add or remove it from your alert list.</p>
|
||
{% if all_channels %}
|
||
<div class="ch-picker">
|
||
<div class="ch-picker-label">Selected</div>
|
||
<div id="selected-chips" class="ch-chips">
|
||
{% for ch in all_channels %}
|
||
{% if ch.name in (current_user.notification_channels or []) %}
|
||
<button class="ch-chip selected" data-ch="{{ ch.name | e }}" onclick="toggleChip(this)">
|
||
{{ ch.name | e }} <span class="ch-chip-x">×</span>
|
||
</button>
|
||
{% endif %}
|
||
{% endfor %}
|
||
{% set selected_set = current_user.notification_channels or [] %}
|
||
{% set has_selected = selected_set | length > 0 %}
|
||
{% if not has_selected %}
|
||
<span style="font-size:.83em;color:#bbb;font-style:italic;align-self:center">None selected</span>
|
||
{% endif %}
|
||
</div>
|
||
<div class="ch-picker-label">Available</div>
|
||
<div id="available-chips" class="ch-chips">
|
||
{% for ch in all_channels %}
|
||
{% if ch.name not in (current_user.notification_channels or []) %}
|
||
<button class="ch-chip available" data-ch="{{ ch.name | e }}" onclick="toggleChip(this)">
|
||
+ {{ ch.name | e }}
|
||
</button>
|
||
{% endif %}
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% else %}
|
||
<p style="font-size:.83em;color:#bbb;font-style:italic">No notification channels available. You can create your own below.</p>
|
||
{% endif %}
|
||
<div class="save-row">
|
||
<button class="btn-save" onclick="saveChannels()">Save channels</button>
|
||
<span id="channels-status" class="status-msg"></span>
|
||
</div>
|
||
{% else %}
|
||
<span class="no-hosts">Log in to manage notification channels.</span>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- My Channels — create/edit/delete own channels -->
|
||
{% if current_user %}
|
||
<div class="section">
|
||
<h2>My Channels</h2>
|
||
<p style="font-size:.82em;color:#888;margin:0 0 12px">Channels you own. Public channels are available to all users; private channels are visible only to you.</p>
|
||
<div id="my-channels-list">
|
||
{% set my_channels = all_channels | selectattr('owner', 'equalto', current_user.username) | list %}
|
||
{% for ch in my_channels %}
|
||
<div class="my-ch-card" id="mychcard-{{ ch.name | e }}">
|
||
<div class="my-ch-header">
|
||
<span class="my-ch-name">{{ ch.name | e }}</span>
|
||
<span class="my-ch-type">{{ ch.type | e }}</span>
|
||
{% if ch.private %}<span class="my-ch-private">private</span>{% endif %}
|
||
<span class="my-ch-actions">
|
||
<button class="btn-sm-edit" onclick="openMyChModal('{{ ch.name | e }}')">Edit</button>
|
||
<button class="btn-sm-del" onclick="deleteMyChannel('{{ ch.name | e }}')">✕</button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
{% if not my_channels %}
|
||
<p id="my-channels-empty" style="font-size:.83em;color:#bbb;font-style:italic">No channels yet.</p>
|
||
{% endif %}
|
||
</div>
|
||
<div class="save-row" style="margin-top:8px">
|
||
<button class="btn-save" onclick="openMyChModal()">+ New channel</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- My Channels modal -->
|
||
<div id="my-ch-modal" class="ch-modal-overlay" style="display:none" onclick="if(event.target===this)closeMyChModal()">
|
||
<div class="ch-modal-box">
|
||
<h3 id="my-ch-modal-title">New Channel</h3>
|
||
<div class="ch-form-row">
|
||
<label>Channel name</label>
|
||
<input type="text" id="my-ch-name" placeholder="e.g. my_pushover" autocomplete="off">
|
||
</div>
|
||
<div class="ch-form-row">
|
||
<label>Type</label>
|
||
<select id="my-ch-type" onchange="onMyChTypeChange()">
|
||
<option value="">— select —</option>
|
||
</select>
|
||
</div>
|
||
<div id="my-ch-type-fields"></div>
|
||
<div class="ch-form-divider">Options</div>
|
||
<div class="ch-form-row">
|
||
<label>Minimum alert level</label>
|
||
<select id="my-ch-min-level">
|
||
<option value="WARNING">WARNING (and above)</option>
|
||
<option value="CRITICAL">CRITICAL only</option>
|
||
</select>
|
||
</div>
|
||
<div class="ch-form-row">
|
||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||
<input type="checkbox" id="my-ch-private"> Private — visible only to you
|
||
</label>
|
||
</div>
|
||
<div id="my-ch-modal-status" class="ch-modal-status"></div>
|
||
<div class="ch-modal-footer">
|
||
<button class="btn-save" style="background:#888" onclick="closeMyChModal()">Cancel</button>
|
||
<button class="btn-save" onclick="saveMyChannel()">Save</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- Appearance -->
|
||
<div class="section">
|
||
<h2>Appearance</h2>
|
||
<div class="settings-row">
|
||
<span class="settings-label">Theme</span>
|
||
<div class="theme-btns">
|
||
<button class="theme-btn" data-theme-val="auto" onclick="setTheme('auto')">Auto</button>
|
||
<button class="theme-btn" data-theme-val="light" onclick="setTheme('light')">Light</button>
|
||
<button class="theme-btn" data-theme-val="dark" onclick="setTheme('dark')">Dark</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Host access -->
|
||
<div class="section">
|
||
<h2>Host Access</h2>
|
||
|
||
<div class="settings-row" style="align-items: flex-start; padding-bottom: 14px;">
|
||
<span class="settings-label" style="padding-top: 2px;">Owner</span>
|
||
<div class="host-grid">
|
||
{% if owned_hosts %}
|
||
{% for h in owned_hosts %}
|
||
<span class="host-chip owner"><span class="host-chip-dot"></span>{{ h }}</span>
|
||
{% endfor %}
|
||
{% else %}
|
||
<span class="no-hosts">None</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-row" style="align-items: flex-start; padding-bottom: 14px;">
|
||
<span class="settings-label" style="padding-top: 2px;">Manager</span>
|
||
<div class="host-grid">
|
||
{% if managed_hosts %}
|
||
{% for h in managed_hosts %}
|
||
<span class="host-chip manager"><span class="host-chip-dot"></span>{{ h }}</span>
|
||
{% endfor %}
|
||
{% else %}
|
||
<span class="no-hosts">None</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-row" style="align-items: flex-start; padding-bottom: 4px;">
|
||
<span class="settings-label" style="padding-top: 2px;">Monitor</span>
|
||
<div class="host-grid">
|
||
{% if monitored_hosts %}
|
||
{% for h in monitored_hosts %}
|
||
<span class="host-chip monitor"><span class="host-chip-dot"></span>{{ h }}</span>
|
||
{% endfor %}
|
||
{% else %}
|
||
<span class="no-hosts">None</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
<script>
|
||
// ---- Theme ----
|
||
function applyTheme(pref) {
|
||
var dark = pref === 'dark' ||
|
||
(pref === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||
if (dark) { document.documentElement.setAttribute('data-theme', 'dark'); }
|
||
else { document.documentElement.removeAttribute('data-theme'); }
|
||
}
|
||
function setTheme(pref) {
|
||
try { localStorage.setItem('hbd_theme', pref); } catch(e) {}
|
||
applyTheme(pref);
|
||
document.querySelectorAll('.theme-btn').forEach(function(b) {
|
||
b.classList.toggle('active', b.dataset.themeVal === pref);
|
||
});
|
||
}
|
||
(function() {
|
||
var pref = 'auto';
|
||
try { pref = localStorage.getItem('hbd_theme') || 'auto'; } catch(e) {}
|
||
document.querySelectorAll('.theme-btn').forEach(function(b) {
|
||
b.classList.toggle('active', b.dataset.themeVal === pref);
|
||
});
|
||
})();
|
||
|
||
// ---- Identity ----
|
||
async function saveIdentity() {
|
||
const full_name = document.getElementById('profile-fullname').value;
|
||
const avatar = document.getElementById('profile-avatar').value;
|
||
const resp = await fetch('/api/0/users/me', {
|
||
method: 'PUT',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({full_name, avatar}),
|
||
});
|
||
if (resp.ok) {
|
||
showStatus('identity-status', 'Saved', '#2e7d32');
|
||
} else {
|
||
const err = await resp.json().catch(() => ({}));
|
||
showStatus('identity-status', err.error || 'Error saving', '#c62828');
|
||
}
|
||
}
|
||
|
||
// ---- Password ----
|
||
async function changePassword() {
|
||
const current = document.getElementById('profile-current-pw').value;
|
||
const newpw = document.getElementById('profile-new-pw').value;
|
||
if (!current || !newpw) {
|
||
showStatus('password-status', 'Both fields are required', '#c62828');
|
||
return;
|
||
}
|
||
const resp = await fetch('/api/0/users/me', {
|
||
method: 'PUT',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({password: {current, new: newpw}}),
|
||
});
|
||
if (resp.ok) {
|
||
document.getElementById('profile-current-pw').value = '';
|
||
document.getElementById('profile-new-pw').value = '';
|
||
showStatus('password-status', 'Password changed', '#2e7d32');
|
||
} else {
|
||
const err = await resp.json().catch(() => ({}));
|
||
showStatus('password-status', err.error || 'Error', '#c62828');
|
||
}
|
||
}
|
||
|
||
// ---- Channel chip picker ----
|
||
function toggleChip(btn) {
|
||
const name = btn.dataset.ch;
|
||
const isSelected = btn.classList.contains('selected');
|
||
if (isSelected) {
|
||
// Move to available
|
||
btn.classList.remove('selected');
|
||
btn.classList.add('available');
|
||
btn.innerHTML = '+ ' + escHtml(name);
|
||
btn.onclick = function() { toggleChip(this); };
|
||
document.getElementById('available-chips').appendChild(btn);
|
||
// Remove "None selected" placeholder if it exists
|
||
} else {
|
||
// Move to selected
|
||
btn.classList.remove('available');
|
||
btn.classList.add('selected');
|
||
btn.innerHTML = escHtml(name) + ' <span class="ch-chip-x">×</span>';
|
||
btn.onclick = function() { toggleChip(this); };
|
||
document.getElementById('selected-chips').appendChild(btn);
|
||
}
|
||
// Update placeholder visibility
|
||
const sel = document.getElementById('selected-chips');
|
||
const placeholder = sel.querySelector('span[style]');
|
||
const hasChips = sel.querySelectorAll('.ch-chip.selected').length > 0;
|
||
if (placeholder) placeholder.style.display = hasChips ? 'none' : '';
|
||
}
|
||
|
||
async function saveChannels() {
|
||
const notification_channels = [
|
||
...document.querySelectorAll('#selected-chips .ch-chip.selected')
|
||
].map(b => b.dataset.ch);
|
||
const resp = await fetch('/api/0/users/me', {
|
||
method: 'PUT',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({notification_channels}),
|
||
});
|
||
if (resp.ok) {
|
||
showStatus('channels-status', 'Saved', '#2e7d32');
|
||
} else {
|
||
const err = await resp.json().catch(() => ({}));
|
||
showStatus('channels-status', err.error || 'Error saving', '#c62828');
|
||
}
|
||
}
|
||
|
||
// ---- My Channels CRUD ----
|
||
let _myChSchemas = {};
|
||
let _myChEditName = null;
|
||
|
||
async function _loadMyChSchemas() {
|
||
try {
|
||
const r = await fetch('/api/0/notification_channel_types');
|
||
_myChSchemas = await r.json();
|
||
const sel = document.getElementById('my-ch-type');
|
||
if (!sel) return;
|
||
Object.entries(_myChSchemas).forEach(([k, v]) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = k; opt.textContent = v.label;
|
||
sel.appendChild(opt);
|
||
});
|
||
} catch(e) { console.warn('Could not load channel schemas', e); }
|
||
}
|
||
|
||
function onMyChTypeChange() {
|
||
const type = document.getElementById('my-ch-type').value;
|
||
const container = document.getElementById('my-ch-type-fields');
|
||
container.innerHTML = '';
|
||
if (!type || !_myChSchemas[type]) return;
|
||
const divider = document.createElement('div');
|
||
divider.className = 'ch-form-divider';
|
||
divider.textContent = _myChSchemas[type].label + ' settings';
|
||
container.appendChild(divider);
|
||
(_myChSchemas[type].fields || []).forEach(sf => {
|
||
const row = document.createElement('div');
|
||
row.className = 'ch-form-row';
|
||
const lbl = document.createElement('label');
|
||
lbl.textContent = sf.label + (sf.required ? ' *' : '');
|
||
const inp = document.createElement('input');
|
||
inp.type = sf.type === 'secret' ? 'password' : 'text';
|
||
inp.id = 'mychf-' + sf.key;
|
||
inp.placeholder = sf.required ? '(required)' : '(optional)';
|
||
inp.autocomplete = 'off';
|
||
row.appendChild(lbl);
|
||
row.appendChild(inp);
|
||
container.appendChild(row);
|
||
});
|
||
}
|
||
|
||
async function openMyChModal(name) {
|
||
_myChEditName = name || null;
|
||
document.getElementById('my-ch-modal-status').textContent = '';
|
||
document.getElementById('my-ch-modal-title').textContent = name ? 'Edit Channel' : 'New Channel';
|
||
document.getElementById('my-ch-name').value = name || '';
|
||
document.getElementById('my-ch-name').disabled = !!name;
|
||
document.getElementById('my-ch-type').value = '';
|
||
document.getElementById('my-ch-type-fields').innerHTML = '';
|
||
document.getElementById('my-ch-min-level').value = 'WARNING';
|
||
document.getElementById('my-ch-private').checked = false;
|
||
|
||
if (name) {
|
||
try {
|
||
const r = await fetch('/api/0/notification_channels');
|
||
const channels = await r.json();
|
||
const ch = channels.find(c => c.name === name);
|
||
if (ch) {
|
||
document.getElementById('my-ch-type').value = ch.type;
|
||
onMyChTypeChange();
|
||
document.getElementById('my-ch-min-level').value = ch.min_level || 'WARNING';
|
||
document.getElementById('my-ch-private').checked = ch.private || false;
|
||
(ch.fields || []).forEach(f => {
|
||
const inp = document.getElementById('mychf-' + f.key);
|
||
if (inp) inp.value = f.value || '';
|
||
});
|
||
}
|
||
} catch(e) { console.warn('Failed to load channel', e); }
|
||
}
|
||
document.getElementById('my-ch-modal').style.display = 'flex';
|
||
}
|
||
|
||
function closeMyChModal() {
|
||
document.getElementById('my-ch-modal').style.display = 'none';
|
||
}
|
||
|
||
async function saveMyChannel() {
|
||
const name = document.getElementById('my-ch-name').value.trim();
|
||
const type = document.getElementById('my-ch-type').value;
|
||
const minLevel = document.getElementById('my-ch-min-level').value;
|
||
const isPrivate = document.getElementById('my-ch-private').checked;
|
||
const statusEl = document.getElementById('my-ch-modal-status');
|
||
statusEl.textContent = '';
|
||
|
||
if (!name) { statusEl.textContent = 'Name is required.'; statusEl.style.color = '#c62828'; return; }
|
||
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
|
||
|
||
const body = { name, type, min_level: minLevel, private: isPrivate };
|
||
if (_myChSchemas[type]) {
|
||
(_myChSchemas[type].fields || []).forEach(sf => {
|
||
const inp = document.getElementById('mychf-' + sf.key);
|
||
if (inp) body[sf.key] = inp.value;
|
||
});
|
||
}
|
||
|
||
const isEdit = !!_myChEditName;
|
||
const url = isEdit
|
||
? '/api/0/notification_channels/' + encodeURIComponent(_myChEditName)
|
||
: '/api/0/notification_channels';
|
||
const method = isEdit ? 'PUT' : 'POST';
|
||
try {
|
||
const r = await fetch(url, { method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
|
||
if (r.ok) {
|
||
closeMyChModal();
|
||
window.location.reload();
|
||
} else {
|
||
const err = await r.json().catch(() => ({}));
|
||
statusEl.textContent = err.error || 'Error saving.';
|
||
statusEl.style.color = '#c62828';
|
||
}
|
||
} catch(e) {
|
||
statusEl.textContent = 'Network error: ' + e.message;
|
||
statusEl.style.color = '#c62828';
|
||
}
|
||
}
|
||
|
||
async function deleteMyChannel(name) {
|
||
if (!confirm('Delete channel "' + name + '"?')) return;
|
||
try {
|
||
const r = await fetch('/api/0/notification_channels/' + encodeURIComponent(name), { method: 'DELETE' });
|
||
if (r.ok) {
|
||
window.location.reload();
|
||
} else {
|
||
const err = await r.json().catch(() => ({}));
|
||
alert('Error: ' + (err.error || 'Could not delete.'));
|
||
}
|
||
} catch(e) { alert('Network error: ' + e.message); }
|
||
}
|
||
|
||
// ---- Utilities ----
|
||
function showStatus(id, msg, color) {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.textContent = msg;
|
||
el.style.color = color;
|
||
setTimeout(() => { el.textContent = ''; }, 3000);
|
||
}
|
||
|
||
function escHtml(s) {
|
||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', _loadMyChSchemas);
|
||
</script>
|
||
</body>
|
||
</html>
|