settings-site: phase 4 client refactor (split app.js into focused modules, shared dropdown helper, strict-CSP-ready)
This commit is contained in:
@@ -428,6 +428,15 @@ body::before {
|
|||||||
.ss-placeholder { color: var(--text-dim); }
|
.ss-placeholder { color: var(--text-dim); }
|
||||||
.ss-avatar { width: 20px; height: 20px; border-radius: 50%; }
|
.ss-avatar { width: 20px; height: 20px; border-radius: 50%; }
|
||||||
.ss-dot { width: 10px; height: 10px; border-radius: 0; flex-shrink: 0; }
|
.ss-dot { width: 10px; height: 10px; border-radius: 0; flex-shrink: 0; }
|
||||||
|
.ss-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; }
|
||||||
|
.ss-option.ss-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: 2px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* Save bar */
|
/* Save bar */
|
||||||
.save-bar {
|
.save-bar {
|
||||||
|
|||||||
@@ -382,7 +382,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/discord.js"></script>
|
<script defer src="/js/util.js"></script>
|
||||||
<script src="/js/app.js"></script>
|
<script defer src="/js/router.js"></script>
|
||||||
|
<script defer src="/js/fields.js"></script>
|
||||||
|
<script defer src="/js/notifications.js"></script>
|
||||||
|
<script defer src="/js/discord.js"></script>
|
||||||
|
<script defer src="/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,618 +1,162 @@
|
|||||||
let savedConfig = {};
|
(function () {
|
||||||
let pendingChanges = {};
|
'use strict';
|
||||||
let notificationThresholdsState = {};
|
|
||||||
let csrfToken = '';
|
|
||||||
|
|
||||||
async function fetchCsrfToken() {
|
async function init() {
|
||||||
const res = await fetch('/api/csrf-token', { credentials: 'same-origin' });
|
document.getElementById('loading').classList.remove('hidden');
|
||||||
if (!res.ok) throw new Error('Failed to fetch CSRF token');
|
try {
|
||||||
const data = await res.json();
|
await Util.fetchCsrfToken();
|
||||||
csrfToken = data.csrfToken;
|
const [config] = await Promise.all([
|
||||||
return csrfToken;
|
fetch('/api/config', { credentials: 'same-origin' }).then(r => r.json()),
|
||||||
}
|
DiscordFields.fetchGuildData()
|
||||||
|
]);
|
||||||
function csrfHeaders(base = {}) {
|
Fields.setSavedConfig(config);
|
||||||
return { ...base, 'x-csrf-token': csrfToken };
|
document.getElementById('bot-status-dot').className = 'dot online';
|
||||||
}
|
document.getElementById('bot-status-text').textContent = 'Connected';
|
||||||
|
Fields.populateFields(config);
|
||||||
const NOTIFICATION_PRESETS = ['15m', '30m', '1h', '2h', '4h', '8h', '1d'];
|
Notifications.initNotificationsEditor(config);
|
||||||
const NOTIFICATION_TAB_KEYS = {
|
Fields.initSmartSelects(config);
|
||||||
surge: [
|
} catch (e) {
|
||||||
'surge_tickets',
|
document.getElementById('bot-status-dot').className = 'dot offline';
|
||||||
'surge_game',
|
document.getElementById('bot-status-text').textContent = 'Unreachable';
|
||||||
'surge_stale',
|
|
||||||
'surge_needs_response',
|
|
||||||
'surge_unclaimed',
|
|
||||||
'surge_tier3_unclaimed',
|
|
||||||
'surge_no_staff'
|
|
||||||
],
|
|
||||||
patterns: [
|
|
||||||
'user_tickets',
|
|
||||||
'user_reopen',
|
|
||||||
'user_crossgame',
|
|
||||||
'game_surge',
|
|
||||||
'game_backlog',
|
|
||||||
'game_resolution',
|
|
||||||
'game_spike',
|
|
||||||
'tag_top',
|
|
||||||
'tag_escalation',
|
|
||||||
'untagged_closes',
|
|
||||||
'tag_game_corr',
|
|
||||||
'user_esc',
|
|
||||||
'game_esc_rate',
|
|
||||||
'rapid_t2_t3',
|
|
||||||
'staff_no_close',
|
|
||||||
'staff_overloaded',
|
|
||||||
'staff_stale',
|
|
||||||
'staff_transfer_rate',
|
|
||||||
'staff_esc',
|
|
||||||
'staff_game_esc',
|
|
||||||
'game_tag_spike',
|
|
||||||
'overnight_gap',
|
|
||||||
'staff_always_esc'
|
|
||||||
],
|
|
||||||
unclaimed: ['unclaimed_reminder'],
|
|
||||||
chat: ['chat_messages', 'chat_time']
|
|
||||||
};
|
|
||||||
const NOTIFICATION_ALERT_DESCRIPTIONS = {
|
|
||||||
surge_tickets: 'Fires when total active ticket volume exceeds configured surge thresholds, signaling broad queue pressure that needs staffing attention.',
|
|
||||||
surge_game: 'Fires when one game accumulates tickets unusually fast within the configured window, indicating a localized incident that should be triaged.',
|
|
||||||
surge_stale: 'Fires when too many tickets stay unresolved past the stale-time threshold, prompting staff to clear aging backlog.',
|
|
||||||
surge_needs_response: 'Fires when tickets needing a staff reply exceed count and age limits, indicating response latency is building.',
|
|
||||||
surge_unclaimed: 'Fires when the unclaimed queue crosses configured count/age thresholds, signaling ownership gaps that need pickup.',
|
|
||||||
surge_tier3_unclaimed: "Fires when Tier 3 tickets have been sitting unclaimed past each threshold. Escalating intervals prevent spam while ensuring critical tickets don't go unnoticed.",
|
|
||||||
surge_no_staff: 'Fires when open-ticket load is high while no staff are detected as available, prompting immediate coverage.',
|
|
||||||
user_tickets: 'Detects users opening unusually high ticket counts in the active window, suggesting repeat-issue or abuse patterns.',
|
|
||||||
user_reopen: 'Detects users who repeatedly reopen or recreate issues after closure, signaling unresolved root-cause patterns.',
|
|
||||||
user_crossgame: 'Detects users reporting similar issues across multiple games in a short period, indicating broader account-level impact.',
|
|
||||||
game_surge: 'Detects game-specific ticket spikes crossing thresholds in the pattern window, signaling service instability for that title.',
|
|
||||||
game_backlog: 'Detects games accumulating unresolved backlog above threshold, implying triage capacity is lagging for that queue.',
|
|
||||||
game_resolution: 'Detects unusual drops in resolution rate for a game, indicating tickets are staying open longer than expected.',
|
|
||||||
game_spike: 'Detects abrupt short-window jumps in ticket volume for a game, flagging incidents that may need escalation.',
|
|
||||||
tag_top: 'Detects tag frequency leaders above threshold so recurring issue types can be prioritized for fixes or macros.',
|
|
||||||
tag_escalation: 'Detects tags with unusually high escalation rates, indicating categories that routinely require higher-tier handling.',
|
|
||||||
untagged_closes: 'Detects elevated counts of closed tickets without tags, prompting cleanup to preserve reporting quality.',
|
|
||||||
tag_game_corr: 'Detects strong tag-to-game concentration patterns, highlighting issue types tightly linked to specific games.',
|
|
||||||
user_esc: 'Detects users whose tickets escalate unusually often, implying complex cases that may need proactive follow-up.',
|
|
||||||
game_esc_rate: 'Detects games with escalating ticket-rate thresholds exceeded, signaling deeper technical issues for that title.',
|
|
||||||
rapid_t2_t3: 'Fires at ticket count milestones (e.g. 3, 5, 10) when tickets have reached Tier 3 this week. Each milestone fires once per week.',
|
|
||||||
staff_no_close: 'Detects staff with prolonged periods of claims but few closes, suggesting overloaded ownership or stuck work.',
|
|
||||||
staff_overloaded: 'Detects staff carrying ticket loads beyond threshold, indicating balancing or reassignment may be needed.',
|
|
||||||
staff_stale: 'Detects staff-owned tickets aging beyond stale limits, prompting review and unblock actions.',
|
|
||||||
staff_transfer_rate: 'Detects unusually high transfer/reassignment rates by staff, signaling ownership churn that may hurt throughput.',
|
|
||||||
staff_esc: 'Detects staff escalation counts above threshold, highlighting where extra support or training may be needed.',
|
|
||||||
staff_game_esc: 'Detects high escalation concentration for specific staff/game combinations, indicating targeted expertise gaps.',
|
|
||||||
game_tag_spike: 'Detects sudden spikes of specific tags within a game, flagging focused incident signatures.',
|
|
||||||
overnight_gap: 'Detects recurring unattended overnight windows with active demand, suggesting staffing coverage gaps.',
|
|
||||||
staff_always_esc: 'Detects staff whose handled tickets escalate at consistently high rates, implying sustained tier-fit issues.',
|
|
||||||
unclaimed_reminder: 'Reminds all staff notification channels about unclaimed tickets. Thresholds are per-ticket age — each threshold fires once per ticket and resets on escalation.',
|
|
||||||
chat_messages: 'Fires when pending user message volume in monitored chat channels crosses configured count thresholds without staff replies.',
|
|
||||||
chat_time: 'Fires when a monitored chat channel has had no staff response for the given duration with pending user messages. Resets when staff responds.'
|
|
||||||
};
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
document.getElementById('loading').classList.remove('hidden');
|
|
||||||
try {
|
|
||||||
await fetchCsrfToken();
|
|
||||||
const [config] = await Promise.all([
|
|
||||||
fetch('/api/config', { credentials: 'same-origin' }).then(r => r.json()),
|
|
||||||
DiscordFields.fetchGuildData()
|
|
||||||
]);
|
|
||||||
savedConfig = config;
|
|
||||||
document.getElementById('bot-status-dot').className = 'dot online';
|
|
||||||
document.getElementById('bot-status-text').textContent = 'Connected';
|
|
||||||
populateFields(config);
|
|
||||||
initNotificationsEditor(config);
|
|
||||||
initSmartSelects(config);
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('bot-status-dot').className = 'dot offline';
|
|
||||||
document.getElementById('bot-status-text').textContent = 'Unreachable';
|
|
||||||
}
|
|
||||||
document.getElementById('loading').classList.add('hidden');
|
|
||||||
setupSectionToggles();
|
|
||||||
setupSaveBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateFields(config) {
|
|
||||||
document.querySelectorAll('[data-key]').forEach(el => {
|
|
||||||
const key = el.dataset.key;
|
|
||||||
const value = config[key] || '';
|
|
||||||
if (el.type === 'checkbox') {
|
|
||||||
el.checked = value === 'true' || value === true;
|
|
||||||
} else if (el.type === 'color') {
|
|
||||||
// Convert 0xRRGGBB to #RRGGBB
|
|
||||||
const num = parseInt(value) || 0;
|
|
||||||
el.value = '#' + num.toString(16).padStart(6, '0');
|
|
||||||
} else {
|
|
||||||
el.value = value;
|
|
||||||
}
|
}
|
||||||
el.addEventListener('change', () => handleFieldChange(el, key));
|
document.getElementById('loading').classList.add('hidden');
|
||||||
el.addEventListener('input', () => {
|
setupSectionToggles();
|
||||||
if (el.type === 'text' || el.type === 'number' || el.type === 'password' || el.tagName === 'TEXTAREA') {
|
Fields.setupSaveBar();
|
||||||
handleFieldChange(el, key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFieldChange(el, key) {
|
|
||||||
let value;
|
|
||||||
if (el.type === 'checkbox') {
|
|
||||||
value = el.checked ? 'true' : 'false';
|
|
||||||
} else if (el.type === 'color') {
|
|
||||||
value = '0x' + el.value.slice(1).toUpperCase();
|
|
||||||
} else {
|
|
||||||
value = el.value;
|
|
||||||
}
|
}
|
||||||
markChanged(key, value);
|
|
||||||
el.classList.toggle('changed', key in pendingChanges);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initSmartSelects(config) {
|
function setupSectionToggles() {
|
||||||
document.querySelectorAll('[data-smart]').forEach(el => {
|
document.querySelectorAll('.section-header').forEach(header => {
|
||||||
const key = el.dataset.key;
|
header.addEventListener('click', () => {
|
||||||
const type = el.dataset.smart;
|
header.closest('.section').classList.toggle('collapsed');
|
||||||
const value = config[key] || '';
|
});
|
||||||
if (type === 'channel') DiscordFields.renderChannelSelect(el, value);
|
|
||||||
else if (type === 'category') DiscordFields.renderCategorySelect(el, value);
|
|
||||||
else if (type === 'role') DiscordFields.renderRoleSelect(el, value);
|
|
||||||
else if (type === 'member') DiscordFields.renderMemberSelect(el, value);
|
|
||||||
else if (type === 'multi-member') DiscordFields.renderMultiMemberSelect(el, value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupSectionToggles() {
|
|
||||||
document.querySelectorAll('.section-header').forEach(header => {
|
|
||||||
header.addEventListener('click', () => {
|
|
||||||
header.closest('.section').classList.toggle('collapsed');
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function markChanged(key, value) {
|
|
||||||
if (String(value) === String(savedConfig[key] || '')) {
|
|
||||||
delete pendingChanges[key];
|
|
||||||
} else {
|
|
||||||
pendingChanges[key] = value;
|
|
||||||
}
|
}
|
||||||
updateSaveBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupSaveBar() {
|
function openScheduleModal() {
|
||||||
updateSaveBar();
|
const modal = document.getElementById('schedule-modal');
|
||||||
}
|
modal.classList.remove('hidden');
|
||||||
|
const dt = document.getElementById('schedule-datetime');
|
||||||
|
const min = Util.formatLocalDateTime(new Date(Date.now() + 60000));
|
||||||
|
dt.min = min;
|
||||||
|
dt.value = min;
|
||||||
|
}
|
||||||
|
|
||||||
function updateSaveBar() {
|
async function confirmScheduledRestart() {
|
||||||
const bar = document.getElementById('save-bar');
|
const dt = document.getElementById('schedule-datetime').value;
|
||||||
const count = Object.keys(pendingChanges).length;
|
if (!dt) return;
|
||||||
bar.classList.toggle('visible', count > 0);
|
await fetch('/api/restart', {
|
||||||
document.getElementById('change-count').textContent =
|
|
||||||
`${count} unsaved change${count !== 1 ? 's' : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveConfig(mode) {
|
|
||||||
const buttons = document.querySelectorAll('#save-bar button');
|
|
||||||
buttons.forEach(b => b.disabled = true);
|
|
||||||
try {
|
|
||||||
if (mode === 'restart' && !confirm('Save changes and restart the bot now?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await fetch('/api/config', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: csrfHeaders({ 'Content-Type': 'application/json' }),
|
headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }),
|
||||||
body: JSON.stringify(pendingChanges)
|
body: JSON.stringify({ mode: 'scheduled', scheduledFor: new Date(dt).toISOString() })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
document.getElementById('schedule-modal').classList.add('hidden');
|
||||||
if (data.applied) {
|
Util.showToast(`Restart scheduled for ${new Date(dt).toLocaleString()}`, 'warning');
|
||||||
for (const key of data.applied) savedConfig[key] = pendingChanges[key];
|
}
|
||||||
pendingChanges = {};
|
|
||||||
updateSaveBar();
|
async function doLogout() {
|
||||||
document.querySelectorAll('.changed').forEach(el => el.classList.remove('changed'));
|
try {
|
||||||
showToast(`${data.applied.length} settings saved.`, 'success');
|
await fetch('/logout', {
|
||||||
}
|
|
||||||
const hasErrors = data.errors && data.errors.length > 0;
|
|
||||||
if (hasErrors) {
|
|
||||||
showToast(`Errors: ${data.errors.join(', ')}`, 'error');
|
|
||||||
}
|
|
||||||
if (mode === 'restart' && !hasErrors) {
|
|
||||||
await fetch('/api/restart', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: csrfHeaders({ 'Content-Type': 'application/json' }),
|
headers: Util.csrfHeaders()
|
||||||
body: JSON.stringify({ mode: 'immediate' })
|
|
||||||
});
|
});
|
||||||
showToast('Restart initiated.', 'warning');
|
} catch (e) { /* ignore */ }
|
||||||
} else if (mode === 'restart' && hasErrors) {
|
window.location.href = '/login';
|
||||||
showToast(`Restart cancelled — save returned errors: ${data.errors.join(', ')}`, 'warning');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Failed to save. Bot may be unreachable.', 'error');
|
|
||||||
} finally {
|
|
||||||
buttons.forEach(b => b.disabled = false);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function formatLocalDateTime(d) {
|
function setupActionButtons() {
|
||||||
const pad = n => String(n).padStart(2, '0');
|
document.getElementById('save-btn')?.addEventListener('click', () => Fields.saveConfig('save'));
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
document.getElementById('save-restart-btn')?.addEventListener('click', () => Fields.saveConfig('restart'));
|
||||||
}
|
document.getElementById('schedule-restart-btn')?.addEventListener('click', openScheduleModal);
|
||||||
|
document.getElementById('schedule-confirm-btn')?.addEventListener('click', confirmScheduledRestart);
|
||||||
function openScheduleModal() {
|
document.getElementById('schedule-cancel-btn')?.addEventListener('click', () => {
|
||||||
const modal = document.getElementById('schedule-modal');
|
document.getElementById('schedule-modal').classList.add('hidden');
|
||||||
modal.classList.remove('hidden');
|
|
||||||
const dt = document.getElementById('schedule-datetime');
|
|
||||||
const min = formatLocalDateTime(new Date(Date.now() + 60000));
|
|
||||||
dt.min = min;
|
|
||||||
dt.value = min;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmScheduledRestart() {
|
|
||||||
const dt = document.getElementById('schedule-datetime').value;
|
|
||||||
if (!dt) return;
|
|
||||||
await fetch('/api/restart', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: csrfHeaders({ 'Content-Type': 'application/json' }),
|
|
||||||
body: JSON.stringify({ mode: 'scheduled', scheduledFor: new Date(dt).toISOString() })
|
|
||||||
});
|
|
||||||
document.getElementById('schedule-modal').classList.add('hidden');
|
|
||||||
showToast(`Restart scheduled for ${new Date(dt).toLocaleString()}`, 'warning');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doLogout() {
|
|
||||||
try {
|
|
||||||
await fetch('/logout', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: csrfHeaders()
|
|
||||||
});
|
});
|
||||||
} catch (e) { /* ignore */ }
|
document.getElementById('logout-btn')?.addEventListener('click', doLogout);
|
||||||
window.location.href = '/login';
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function setupActionButtons() {
|
function setupMobileNav() {
|
||||||
document.getElementById('save-btn')?.addEventListener('click', () => saveConfig('save'));
|
const toggle = document.getElementById('menu-toggle');
|
||||||
document.getElementById('save-restart-btn')?.addEventListener('click', () => saveConfig('restart'));
|
const backdrop = document.getElementById('sidebar-backdrop');
|
||||||
document.getElementById('schedule-restart-btn')?.addEventListener('click', openScheduleModal);
|
|
||||||
document.getElementById('schedule-confirm-btn')?.addEventListener('click', confirmScheduledRestart);
|
|
||||||
document.getElementById('schedule-cancel-btn')?.addEventListener('click', () => {
|
|
||||||
document.getElementById('schedule-modal').classList.add('hidden');
|
|
||||||
});
|
|
||||||
document.getElementById('logout-btn')?.addEventListener('click', doLogout);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(message, type = 'success') {
|
toggle?.addEventListener('click', () => {
|
||||||
const toast = document.createElement('div');
|
Util.setSidebarOpen(!document.body.classList.contains('sidebar-open'));
|
||||||
toast.className = `toast toast-${type}`;
|
|
||||||
toast.textContent = message;
|
|
||||||
document.getElementById('toast-container').appendChild(toast);
|
|
||||||
setTimeout(() => toast.remove(), 3500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initNotificationsEditor(config) {
|
|
||||||
const section = document.getElementById('s-notifications');
|
|
||||||
if (!section) return;
|
|
||||||
|
|
||||||
const hiddenField = section.querySelector('[data-key="NOTIFICATION_THRESHOLDS_JSON"]');
|
|
||||||
if (!hiddenField) return;
|
|
||||||
|
|
||||||
notificationThresholdsState = parseNotificationThresholdsConfig(config);
|
|
||||||
hiddenField.value = serializeNotificationThresholds(notificationThresholdsState);
|
|
||||||
|
|
||||||
section.querySelectorAll('.notif-tab-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => setNotificationTab(btn.dataset.notifTab));
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.entries(NOTIFICATION_TAB_KEYS).forEach(([category, keys]) => {
|
|
||||||
const select = section.querySelector(`[data-notif-category="${category}"]`);
|
|
||||||
const chipsWrap = section.querySelector(`[data-notif-chips="${category}"]`);
|
|
||||||
const input = section.querySelector(`[data-notif-input="${category}"]`);
|
|
||||||
const addBtn = section.querySelector(`[data-notif-add="${category}"]`);
|
|
||||||
const presetsWrap = section.querySelector(`[data-notif-presets="${category}"]`);
|
|
||||||
if (!select || !chipsWrap || !input || !addBtn || !presetsWrap) return;
|
|
||||||
|
|
||||||
keys.forEach(key => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = key;
|
|
||||||
option.textContent = toHumanLabel(key);
|
|
||||||
select.appendChild(option);
|
|
||||||
});
|
});
|
||||||
|
backdrop?.addEventListener('click', () => Util.setSidebarOpen(false));
|
||||||
if (keys.length) select.value = keys[0];
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && document.body.classList.contains('sidebar-open')) {
|
||||||
select.addEventListener('change', () => {
|
Util.setSidebarOpen(false);
|
||||||
renderThresholdChips(category);
|
|
||||||
renderAlertDescription(category);
|
|
||||||
});
|
|
||||||
addBtn.addEventListener('click', () => addThresholdFromInput(category));
|
|
||||||
input.addEventListener('keydown', (evt) => {
|
|
||||||
if (evt.key === 'Enter') {
|
|
||||||
evt.preventDefault();
|
|
||||||
addThresholdFromInput(category);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
NOTIFICATION_PRESETS.forEach(preset => {
|
if (!Util.isMobileViewport() && document.body.classList.contains('sidebar-open')) {
|
||||||
const btn = document.createElement('button');
|
Util.setSidebarOpen(false);
|
||||||
btn.type = 'button';
|
}
|
||||||
btn.textContent = preset;
|
|
||||||
btn.addEventListener('click', () => addThresholdValue(category, preset));
|
|
||||||
presetsWrap.appendChild(btn);
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
renderThresholdChips(category);
|
let healthPollHandle = null;
|
||||||
renderAlertDescription(category);
|
|
||||||
});
|
|
||||||
|
|
||||||
setNotificationTab('surge');
|
function setBotStatus(online) {
|
||||||
}
|
const dot = document.getElementById('bot-status-dot');
|
||||||
|
const text = document.getElementById('bot-status-text');
|
||||||
|
if (!dot || !text) return;
|
||||||
|
dot.className = online ? 'dot online' : 'dot offline';
|
||||||
|
text.textContent = online ? 'Connected' : 'Unreachable';
|
||||||
|
}
|
||||||
|
|
||||||
function parseNotificationThresholdsConfig(config) {
|
async function pollHealth() {
|
||||||
const rawJson = config.NOTIFICATION_THRESHOLDS_JSON;
|
|
||||||
if (rawJson && String(rawJson).trim()) {
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(rawJson);
|
const res = await fetch('/healthz', { credentials: 'same-origin' });
|
||||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
if (res.ok) {
|
||||||
} catch (_) {}
|
const data = await res.json();
|
||||||
}
|
setBotStatus(Boolean(data.bot));
|
||||||
if (config.NOTIFICATION_THRESHOLDS && typeof config.NOTIFICATION_THRESHOLDS === 'object' && !Array.isArray(config.NOTIFICATION_THRESHOLDS)) {
|
} else {
|
||||||
return config.NOTIFICATION_THRESHOLDS;
|
setBotStatus(false);
|
||||||
}
|
}
|
||||||
return {};
|
} catch (_) {
|
||||||
}
|
|
||||||
|
|
||||||
function serializeNotificationThresholds(obj) {
|
|
||||||
const ordered = {};
|
|
||||||
Object.keys(obj).sort().forEach(key => {
|
|
||||||
const arr = Array.isArray(obj[key]) ? obj[key].map(v => String(v).trim()).filter(Boolean) : [];
|
|
||||||
ordered[key] = arr;
|
|
||||||
});
|
|
||||||
return JSON.stringify(ordered);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setNotificationTab(category) {
|
|
||||||
document.querySelectorAll('#s-notifications .notif-tab-btn').forEach(btn => {
|
|
||||||
btn.classList.toggle('active', btn.dataset.notifTab === category);
|
|
||||||
});
|
|
||||||
document.querySelectorAll('#s-notifications .notif-panel').forEach(panel => {
|
|
||||||
panel.classList.toggle('hidden', panel.dataset.notifPanel !== category);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addThresholdFromInput(category) {
|
|
||||||
const input = document.querySelector(`#s-notifications [data-notif-input="${category}"]`);
|
|
||||||
if (!input) return;
|
|
||||||
const value = input.value.trim().toLowerCase();
|
|
||||||
if (addThresholdValue(category, value)) input.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function addThresholdValue(category, rawValue) {
|
|
||||||
const value = String(rawValue || '').trim().toLowerCase();
|
|
||||||
if (!isValidThresholdValue(value)) {
|
|
||||||
showToast('Invalid threshold format. Use 15m, 1h, 1d6h, or whole numbers.', 'error');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const alertKey = getSelectedAlertKey(category);
|
|
||||||
if (!alertKey) return false;
|
|
||||||
|
|
||||||
const current = Array.isArray(notificationThresholdsState[alertKey]) ? [...notificationThresholdsState[alertKey]] : [];
|
|
||||||
if (current.includes(value)) return false;
|
|
||||||
current.push(value);
|
|
||||||
notificationThresholdsState[alertKey] = current;
|
|
||||||
syncNotificationThresholdsField();
|
|
||||||
renderThresholdChips(category);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeThresholdValue(category, valueToRemove) {
|
|
||||||
const alertKey = getSelectedAlertKey(category);
|
|
||||||
if (!alertKey) return;
|
|
||||||
const current = Array.isArray(notificationThresholdsState[alertKey]) ? [...notificationThresholdsState[alertKey]] : [];
|
|
||||||
notificationThresholdsState[alertKey] = current.filter(v => String(v) !== String(valueToRemove));
|
|
||||||
syncNotificationThresholdsField();
|
|
||||||
renderThresholdChips(category);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderThresholdChips(category) {
|
|
||||||
const chipsWrap = document.querySelector(`#s-notifications [data-notif-chips="${category}"]`);
|
|
||||||
if (!chipsWrap) return;
|
|
||||||
const alertKey = getSelectedAlertKey(category);
|
|
||||||
const thresholds = alertKey && Array.isArray(notificationThresholdsState[alertKey])
|
|
||||||
? notificationThresholdsState[alertKey]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
chipsWrap.innerHTML = '';
|
|
||||||
thresholds.forEach(value => {
|
|
||||||
const chip = document.createElement('span');
|
|
||||||
chip.className = 'notif-chip';
|
|
||||||
chip.textContent = value;
|
|
||||||
|
|
||||||
const remove = document.createElement('button');
|
|
||||||
remove.type = 'button';
|
|
||||||
remove.title = `Remove ${value}`;
|
|
||||||
remove.textContent = '×';
|
|
||||||
remove.addEventListener('click', () => removeThresholdValue(category, value));
|
|
||||||
|
|
||||||
chip.appendChild(remove);
|
|
||||||
chipsWrap.appendChild(chip);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAlertDescription(category) {
|
|
||||||
const descriptionEl = document.querySelector(`#s-notifications [data-notif-description="${category}"]`);
|
|
||||||
if (!descriptionEl) return;
|
|
||||||
const alertKey = getSelectedAlertKey(category);
|
|
||||||
descriptionEl.textContent = NOTIFICATION_ALERT_DESCRIPTIONS[alertKey] || 'No description available for this alert key yet.';
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncNotificationThresholdsField() {
|
|
||||||
const hiddenField = document.querySelector('#s-notifications [data-key="NOTIFICATION_THRESHOLDS_JSON"]');
|
|
||||||
if (!hiddenField) return;
|
|
||||||
const serialized = serializeNotificationThresholds(notificationThresholdsState);
|
|
||||||
hiddenField.value = serialized;
|
|
||||||
markChanged('NOTIFICATION_THRESHOLDS_JSON', serialized);
|
|
||||||
hiddenField.classList.toggle('changed', 'NOTIFICATION_THRESHOLDS_JSON' in pendingChanges);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSelectedAlertKey(category) {
|
|
||||||
const select = document.querySelector(`#s-notifications [data-notif-category="${category}"]`);
|
|
||||||
return select ? select.value : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidThresholdValue(value) {
|
|
||||||
if (!value) return false;
|
|
||||||
if (/^\d+$/.test(value)) return true;
|
|
||||||
return /^(\d+[mhd])+$/.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toHumanLabel(key) {
|
|
||||||
return String(key)
|
|
||||||
.split('_')
|
|
||||||
.map(part => part.toUpperCase() === 'T2' || part.toUpperCase() === 'T3'
|
|
||||||
? part.toUpperCase()
|
|
||||||
: part.charAt(0).toUpperCase() + part.slice(1))
|
|
||||||
.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROUTES = {
|
|
||||||
'/': 's-core',
|
|
||||||
'/channels': 's-channels',
|
|
||||||
'/categories': 's-categories',
|
|
||||||
'/gmail': 's-gmail',
|
|
||||||
'/behavior': 's-behavior',
|
|
||||||
'/threads': 's-threads',
|
|
||||||
'/pins': 's-pins',
|
|
||||||
'/notifications': 's-notifications',
|
|
||||||
'/logging': 's-logging',
|
|
||||||
'/automation': 's-automation',
|
|
||||||
'/appearance': 's-appearance',
|
|
||||||
'/staff': 's-staff',
|
|
||||||
'/advanced': 's-advanced'
|
|
||||||
};
|
|
||||||
|
|
||||||
function navigate(path, updateHistory = true) {
|
|
||||||
const sectionId = ROUTES[path] || ROUTES['/'];
|
|
||||||
const normalizedPath = ROUTES[path] ? path : '/';
|
|
||||||
if (updateHistory) history.pushState({}, '', normalizedPath);
|
|
||||||
|
|
||||||
document.querySelectorAll('.section').forEach(section => {
|
|
||||||
section.classList.toggle('hidden', section.id !== sectionId);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('.sidebar a').forEach(link => {
|
|
||||||
link.classList.toggle('active', link.getAttribute('href') === normalizedPath);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupSidebarRouting() {
|
|
||||||
const sidebar = document.querySelector('.sidebar');
|
|
||||||
if (!sidebar) return;
|
|
||||||
|
|
||||||
sidebar.addEventListener('click', e => {
|
|
||||||
const a = e.target.closest('a');
|
|
||||||
if (!a) return;
|
|
||||||
e.preventDefault();
|
|
||||||
navigate(a.getAttribute('href'));
|
|
||||||
if (isMobileViewport()) setSidebarOpen(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('popstate', () => {
|
|
||||||
navigate(location.pathname, false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 900;
|
|
||||||
|
|
||||||
function isMobileViewport() {
|
|
||||||
return window.innerWidth <= MOBILE_BREAKPOINT;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSidebarOpen(open) {
|
|
||||||
document.body.classList.toggle('sidebar-open', open);
|
|
||||||
const toggle = document.getElementById('menu-toggle');
|
|
||||||
if (toggle) toggle.setAttribute('aria-expanded', String(open));
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupMobileNav() {
|
|
||||||
const toggle = document.getElementById('menu-toggle');
|
|
||||||
const backdrop = document.getElementById('sidebar-backdrop');
|
|
||||||
|
|
||||||
toggle?.addEventListener('click', () => {
|
|
||||||
setSidebarOpen(!document.body.classList.contains('sidebar-open'));
|
|
||||||
});
|
|
||||||
backdrop?.addEventListener('click', () => setSidebarOpen(false));
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape' && document.body.classList.contains('sidebar-open')) {
|
|
||||||
setSidebarOpen(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
if (!isMobileViewport() && document.body.classList.contains('sidebar-open')) {
|
|
||||||
setSidebarOpen(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let healthPollHandle = null;
|
|
||||||
|
|
||||||
function setBotStatus(online) {
|
|
||||||
const dot = document.getElementById('bot-status-dot');
|
|
||||||
const text = document.getElementById('bot-status-text');
|
|
||||||
if (!dot || !text) return;
|
|
||||||
dot.className = online ? 'dot online' : 'dot offline';
|
|
||||||
text.textContent = online ? 'Connected' : 'Unreachable';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollHealth() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/healthz', { credentials: 'same-origin' });
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setBotStatus(Boolean(data.bot));
|
|
||||||
} else {
|
|
||||||
setBotStatus(false);
|
setBotStatus(false);
|
||||||
}
|
}
|
||||||
} catch (_) {
|
|
||||||
setBotStatus(false);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleNextHealthPoll() {
|
function scheduleNextHealthPoll() {
|
||||||
if (document.hidden) return;
|
if (document.hidden) return;
|
||||||
healthPollHandle = setTimeout(async () => {
|
healthPollHandle = setTimeout(async () => {
|
||||||
await pollHealth();
|
await pollHealth();
|
||||||
|
scheduleNextHealthPoll();
|
||||||
|
}, 20000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHealthPolling() {
|
||||||
|
if (healthPollHandle) clearTimeout(healthPollHandle);
|
||||||
scheduleNextHealthPoll();
|
scheduleNextHealthPoll();
|
||||||
}, 20000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function startHealthPolling() {
|
|
||||||
if (healthPollHandle) clearTimeout(healthPollHandle);
|
|
||||||
scheduleNextHealthPoll();
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopHealthPolling() {
|
|
||||||
if (healthPollHandle) {
|
|
||||||
clearTimeout(healthPollHandle);
|
|
||||||
healthPollHandle = null;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function setupHealthPolling() {
|
function stopHealthPolling() {
|
||||||
document.addEventListener('visibilitychange', () => {
|
if (healthPollHandle) {
|
||||||
if (document.hidden) stopHealthPolling();
|
clearTimeout(healthPollHandle);
|
||||||
else startHealthPolling();
|
healthPollHandle = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupHealthPolling() {
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) stopHealthPolling();
|
||||||
|
else startHealthPolling();
|
||||||
|
});
|
||||||
|
window.addEventListener('pagehide', stopHealthPolling);
|
||||||
|
startHealthPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
Router.setupSidebarRouting();
|
||||||
|
setupActionButtons();
|
||||||
|
setupMobileNav();
|
||||||
|
await init();
|
||||||
|
Router.navigate(location.pathname, false);
|
||||||
|
setupHealthPolling();
|
||||||
});
|
});
|
||||||
window.addEventListener('pagehide', stopHealthPolling);
|
|
||||||
startHealthPolling();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
window.App = { init };
|
||||||
setupSidebarRouting();
|
})();
|
||||||
setupActionButtons();
|
|
||||||
setupMobileNav();
|
|
||||||
await init();
|
|
||||||
navigate(location.pathname, false);
|
|
||||||
setupHealthPolling();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,195 +1,253 @@
|
|||||||
// Discord guild data cache
|
(function () {
|
||||||
let guildData = null;
|
'use strict';
|
||||||
let guildDataPromise = null;
|
|
||||||
|
|
||||||
async function fetchGuildData() {
|
let guildData = null;
|
||||||
if (guildData) return guildData;
|
let guildDataPromise = null;
|
||||||
if (guildDataPromise) return guildDataPromise;
|
|
||||||
guildDataPromise = fetch('/api/discord/guild')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => { guildData = data; return data; })
|
|
||||||
.catch(() => ({ channels: [], roles: [], members: [], categories: [] }));
|
|
||||||
return guildDataPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderChannelSelect(el, currentValue, filter) {
|
async function fetchGuildData() {
|
||||||
const data = await fetchGuildData();
|
if (guildData) return guildData;
|
||||||
const channels = filter ? data.channels.filter(filter) : data.channels;
|
if (guildDataPromise) return guildDataPromise;
|
||||||
renderSmartSelect(el, channels.map(c => ({
|
guildDataPromise = fetch('/api/discord/guild')
|
||||||
id: c.id,
|
.then(r => r.json())
|
||||||
label: `#${c.name}`,
|
.then(data => { guildData = data; return data; })
|
||||||
sub: c.parentId ? (data.categories.find(cat => cat.id === c.parentId)?.name || null) : null
|
.catch(() => ({ channels: [], roles: [], members: [], categories: [] }));
|
||||||
})), currentValue);
|
return guildDataPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderCategorySelect(el, currentValue) {
|
async function renderChannelSelect(el, currentValue, filter) {
|
||||||
const data = await fetchGuildData();
|
const data = await fetchGuildData();
|
||||||
renderSmartSelect(el, data.categories.map(c => ({ id: c.id, label: c.name })), currentValue);
|
const channels = filter ? data.channels.filter(filter) : data.channels;
|
||||||
}
|
renderSmartSelect(el, channels.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
label: `#${c.name}`,
|
||||||
|
sub: c.parentId ? (data.categories.find(cat => cat.id === c.parentId)?.name || null) : null
|
||||||
|
})), currentValue);
|
||||||
|
}
|
||||||
|
|
||||||
async function renderRoleSelect(el, currentValue) {
|
async function renderCategorySelect(el, currentValue) {
|
||||||
const data = await fetchGuildData();
|
const data = await fetchGuildData();
|
||||||
renderSmartSelect(el, data.roles.map(r => ({ id: r.id, label: `@${r.name}`, color: r.color })), currentValue);
|
renderSmartSelect(el, data.categories.map(c => ({ id: c.id, label: c.name })), currentValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderMemberSelect(el, currentValue) {
|
async function renderRoleSelect(el, currentValue) {
|
||||||
const data = await fetchGuildData();
|
const data = await fetchGuildData();
|
||||||
renderSmartSelect(el, data.members.map(m => ({
|
renderSmartSelect(el, data.roles.map(r => ({ id: r.id, label: `@${r.name}`, color: r.color })), currentValue);
|
||||||
id: m.id, label: m.displayName, sub: `@${m.username}`, avatar: m.avatar
|
}
|
||||||
})), currentValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderMultiMemberSelect(el, currentValue) {
|
async function renderMemberSelect(el, currentValue) {
|
||||||
const data = await fetchGuildData();
|
const data = await fetchGuildData();
|
||||||
const currentIds = (currentValue || '').split(',').map(s => s.trim()).filter(Boolean);
|
renderSmartSelect(el, data.members.map(m => ({
|
||||||
renderMultiSelect(el, data.members.map(m => ({
|
id: m.id, label: m.displayName, sub: `@${m.username}`, avatar: m.avatar
|
||||||
id: m.id, label: m.displayName, sub: `@${m.username}`, avatar: m.avatar
|
})), currentValue);
|
||||||
})), currentIds);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function renderSmartSelect(inputEl, options, currentValue) {
|
async function renderMultiMemberSelect(el, currentValue) {
|
||||||
const wrapper = document.createElement('div');
|
const data = await fetchGuildData();
|
||||||
wrapper.className = 'smart-select';
|
const currentIds = (currentValue || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
renderMultiSelect(el, data.members.map(m => ({
|
||||||
|
id: m.id, label: m.displayName, sub: `@${m.username}`, avatar: m.avatar
|
||||||
|
})), currentIds);
|
||||||
|
}
|
||||||
|
|
||||||
const current = options.find(o => o.id === currentValue);
|
function buildOptionRow(opt, { selected = false } = {}) {
|
||||||
const display = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
display.className = 'smart-select-display';
|
item.className = 'ss-option' + (selected ? ' selected' : '');
|
||||||
display.innerHTML = current
|
if (opt.avatar) {
|
||||||
? `<span class="ss-label">${current.label}</span><span class="ss-id">${current.id}</span>`
|
const img = document.createElement('img');
|
||||||
: `<span class="ss-placeholder">Not set</span>`;
|
img.className = 'ss-avatar';
|
||||||
|
img.src = opt.avatar;
|
||||||
|
img.alt = '';
|
||||||
|
item.appendChild(img);
|
||||||
|
}
|
||||||
|
if (opt.color && opt.color !== '#000000') {
|
||||||
|
const dot = document.createElement('span');
|
||||||
|
dot.className = 'ss-dot';
|
||||||
|
dot.style.background = opt.color;
|
||||||
|
item.appendChild(dot);
|
||||||
|
}
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'ss-label';
|
||||||
|
label.textContent = opt.label;
|
||||||
|
item.appendChild(label);
|
||||||
|
if (opt.sub) {
|
||||||
|
const sub = document.createElement('span');
|
||||||
|
sub.className = 'ss-sub';
|
||||||
|
sub.textContent = opt.sub;
|
||||||
|
item.appendChild(sub);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
const dropdown = document.createElement('div');
|
function setDisplayValue(displayEl, opt) {
|
||||||
dropdown.className = 'smart-select-dropdown hidden';
|
displayEl.replaceChildren();
|
||||||
|
const labelSpan = document.createElement('span');
|
||||||
|
labelSpan.className = 'ss-label';
|
||||||
|
labelSpan.textContent = opt.label;
|
||||||
|
const idSpan = document.createElement('span');
|
||||||
|
idSpan.className = 'ss-id';
|
||||||
|
idSpan.textContent = opt.id;
|
||||||
|
displayEl.appendChild(labelSpan);
|
||||||
|
displayEl.appendChild(idSpan);
|
||||||
|
}
|
||||||
|
|
||||||
const search = document.createElement('input');
|
function setDisplayPlaceholder(displayEl, text) {
|
||||||
search.type = 'text';
|
displayEl.replaceChildren();
|
||||||
search.placeholder = 'Search...';
|
const placeholder = document.createElement('span');
|
||||||
search.className = 'ss-search';
|
placeholder.className = 'ss-placeholder';
|
||||||
|
placeholder.textContent = text;
|
||||||
|
displayEl.appendChild(placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
const list = document.createElement('div');
|
function createDropdown(options, opts) {
|
||||||
list.className = 'ss-list';
|
const { multi = false, getCurrentId = () => null, isExcluded = () => false, onChoose, onClear } = opts;
|
||||||
|
|
||||||
const clearOpt = document.createElement('div');
|
const dropdown = document.createElement('div');
|
||||||
clearOpt.className = 'ss-option ss-clear';
|
dropdown.className = 'smart-select-dropdown hidden';
|
||||||
clearOpt.textContent = 'Clear (not set)';
|
|
||||||
clearOpt.addEventListener('click', () => {
|
|
||||||
inputEl.value = '';
|
|
||||||
display.innerHTML = `<span class="ss-placeholder">Not set</span>`;
|
|
||||||
dropdown.classList.add('hidden');
|
|
||||||
inputEl.dispatchEvent(new Event('change'));
|
|
||||||
});
|
|
||||||
list.appendChild(clearOpt);
|
|
||||||
|
|
||||||
function renderOptions(filter = '') {
|
const search = document.createElement('input');
|
||||||
while (list.children.length > 1) list.removeChild(list.lastChild);
|
search.type = 'text';
|
||||||
const filtered = filter
|
search.placeholder = 'Search...';
|
||||||
? options.filter(o => o.label.toLowerCase().includes(filter.toLowerCase()) || (o.sub || '').toLowerCase().includes(filter.toLowerCase()) || o.id.includes(filter))
|
search.className = 'ss-search';
|
||||||
: options;
|
|
||||||
for (const opt of filtered.slice(0, 50)) {
|
const list = document.createElement('div');
|
||||||
const item = document.createElement('div');
|
list.className = 'ss-list';
|
||||||
item.className = 'ss-option' + (opt.id === inputEl.value ? ' selected' : '');
|
|
||||||
let inner = '';
|
function renderOptions(filter = '') {
|
||||||
if (opt.avatar) inner += `<img class="ss-avatar" src="${opt.avatar}" alt="">`;
|
list.replaceChildren();
|
||||||
if (opt.color && opt.color !== '#000000') inner += `<span class="ss-dot" style="background:${opt.color}"></span>`;
|
if (!multi && onClear) {
|
||||||
inner += `<span class="ss-label">${opt.label}</span>`;
|
const clearOpt = document.createElement('div');
|
||||||
if (opt.sub) inner += `<span class="ss-sub">${opt.sub}</span>`;
|
clearOpt.className = 'ss-option ss-clear';
|
||||||
item.innerHTML = inner;
|
clearOpt.textContent = 'Clear (not set)';
|
||||||
item.addEventListener('click', () => {
|
clearOpt.addEventListener('click', onClear);
|
||||||
|
list.appendChild(clearOpt);
|
||||||
|
}
|
||||||
|
const lq = filter.toLowerCase();
|
||||||
|
const filtered = options.filter(o => {
|
||||||
|
if (isExcluded(o.id)) return false;
|
||||||
|
if (!filter) return true;
|
||||||
|
if (multi) {
|
||||||
|
return o.label.toLowerCase().includes(lq) || o.id.includes(filter);
|
||||||
|
}
|
||||||
|
return o.label.toLowerCase().includes(lq)
|
||||||
|
|| (o.sub || '').toLowerCase().includes(lq)
|
||||||
|
|| o.id.includes(filter);
|
||||||
|
});
|
||||||
|
const currentId = getCurrentId();
|
||||||
|
for (const opt of filtered.slice(0, 50)) {
|
||||||
|
const item = buildOptionRow(opt, { selected: !multi && opt.id === currentId });
|
||||||
|
item.addEventListener('click', () => onChoose(opt));
|
||||||
|
list.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
search.addEventListener('input', () => renderOptions(search.value));
|
||||||
|
|
||||||
|
dropdown.appendChild(search);
|
||||||
|
dropdown.appendChild(list);
|
||||||
|
|
||||||
|
renderOptions();
|
||||||
|
|
||||||
|
return { dropdown, search, list, renderOptions };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSmartSelect(inputEl, options, currentValue) {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'smart-select';
|
||||||
|
|
||||||
|
const display = document.createElement('div');
|
||||||
|
display.className = 'smart-select-display';
|
||||||
|
const current = options.find(o => o.id === currentValue);
|
||||||
|
if (current) setDisplayValue(display, current);
|
||||||
|
else setDisplayPlaceholder(display, 'Not set');
|
||||||
|
|
||||||
|
const { dropdown, search } = createDropdown(options, {
|
||||||
|
multi: false,
|
||||||
|
getCurrentId: () => inputEl.value,
|
||||||
|
onChoose: (opt) => {
|
||||||
inputEl.value = opt.id;
|
inputEl.value = opt.id;
|
||||||
display.innerHTML = `<span class="ss-label">${opt.label}</span><span class="ss-id">${opt.id}</span>`;
|
setDisplayValue(display, opt);
|
||||||
dropdown.classList.add('hidden');
|
dropdown.classList.add('hidden');
|
||||||
inputEl.dispatchEvent(new Event('change'));
|
inputEl.dispatchEvent(new Event('change'));
|
||||||
});
|
},
|
||||||
list.appendChild(item);
|
onClear: () => {
|
||||||
|
inputEl.value = '';
|
||||||
|
setDisplayPlaceholder(display, 'Not set');
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
inputEl.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
display.addEventListener('click', () => {
|
||||||
|
dropdown.classList.toggle('hidden');
|
||||||
|
if (!dropdown.classList.contains('hidden')) search.focus();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!wrapper.contains(e.target)) dropdown.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.appendChild(display);
|
||||||
|
wrapper.appendChild(dropdown);
|
||||||
|
inputEl.style.display = 'none';
|
||||||
|
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMultiSelect(inputEl, options, currentIds) {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'smart-select';
|
||||||
|
const selected = new Set(currentIds);
|
||||||
|
|
||||||
|
function updateInput() {
|
||||||
|
inputEl.value = [...selected].join(',');
|
||||||
|
inputEl.dispatchEvent(new Event('change'));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
renderOptions();
|
const chipsEl = document.createElement('div');
|
||||||
search.addEventListener('input', () => renderOptions(search.value));
|
chipsEl.className = 'ss-chips';
|
||||||
display.addEventListener('click', () => {
|
|
||||||
dropdown.classList.toggle('hidden');
|
|
||||||
if (!dropdown.classList.contains('hidden')) search.focus();
|
|
||||||
});
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (!wrapper.contains(e.target)) dropdown.classList.add('hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
dropdown.appendChild(search);
|
function renderChips() {
|
||||||
dropdown.appendChild(list);
|
chipsEl.replaceChildren();
|
||||||
wrapper.appendChild(display);
|
for (const id of selected) {
|
||||||
wrapper.appendChild(dropdown);
|
const opt = options.find(o => o.id === id);
|
||||||
inputEl.style.display = 'none';
|
const chip = document.createElement('span');
|
||||||
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
|
chip.className = 'ss-option ss-chip selected';
|
||||||
}
|
chip.textContent = opt ? opt.label : id;
|
||||||
|
chip.title = 'Click to remove';
|
||||||
function renderMultiSelect(inputEl, options, currentIds) {
|
chip.addEventListener('click', () => { selected.delete(id); renderChips(); updateInput(); });
|
||||||
const wrapper = document.createElement('div');
|
chipsEl.appendChild(chip);
|
||||||
wrapper.className = 'smart-select';
|
}
|
||||||
const selected = new Set(currentIds);
|
|
||||||
|
|
||||||
function updateInput() {
|
|
||||||
inputEl.value = [...selected].join(',');
|
|
||||||
inputEl.dispatchEvent(new Event('change'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderChips() {
|
|
||||||
chipsEl.innerHTML = '';
|
|
||||||
for (const id of selected) {
|
|
||||||
const opt = options.find(o => o.id === id);
|
|
||||||
const chip = document.createElement('span');
|
|
||||||
chip.className = 'ss-option selected';
|
|
||||||
chip.style.cssText = 'display:inline-flex;padding:4px 8px;margin:2px;border-radius:12px;font-size:12px;cursor:pointer;';
|
|
||||||
chip.textContent = opt ? opt.label : id;
|
|
||||||
chip.title = 'Click to remove';
|
|
||||||
chip.addEventListener('click', () => { selected.delete(id); renderChips(); updateInput(); });
|
|
||||||
chipsEl.appendChild(chip);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addBtn = document.createElement('div');
|
||||||
|
addBtn.className = 'smart-select-display';
|
||||||
|
setDisplayPlaceholder(addBtn, '+ Add');
|
||||||
|
|
||||||
|
const { dropdown, search, renderOptions } = createDropdown(options, {
|
||||||
|
multi: true,
|
||||||
|
isExcluded: (id) => selected.has(id),
|
||||||
|
onChoose: (opt) => {
|
||||||
|
selected.add(opt.id);
|
||||||
|
renderChips();
|
||||||
|
renderOptions(search.value);
|
||||||
|
updateInput();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
renderChips();
|
||||||
|
|
||||||
|
addBtn.addEventListener('click', () => {
|
||||||
|
dropdown.classList.toggle('hidden');
|
||||||
|
if (!dropdown.classList.contains('hidden')) search.focus();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!wrapper.contains(e.target)) dropdown.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.appendChild(chipsEl);
|
||||||
|
wrapper.appendChild(addBtn);
|
||||||
|
wrapper.appendChild(dropdown);
|
||||||
|
inputEl.style.display = 'none';
|
||||||
|
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
|
||||||
}
|
}
|
||||||
|
|
||||||
const chipsEl = document.createElement('div');
|
window.DiscordFields = { fetchGuildData, renderChannelSelect, renderCategorySelect, renderRoleSelect, renderMemberSelect, renderMultiMemberSelect };
|
||||||
chipsEl.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;';
|
})();
|
||||||
renderChips();
|
|
||||||
|
|
||||||
const addBtn = document.createElement('div');
|
|
||||||
addBtn.className = 'smart-select-display';
|
|
||||||
addBtn.innerHTML = '<span class="ss-placeholder">+ Add</span>';
|
|
||||||
|
|
||||||
const dropdown = document.createElement('div');
|
|
||||||
dropdown.className = 'smart-select-dropdown hidden';
|
|
||||||
const search = document.createElement('input');
|
|
||||||
search.type = 'text'; search.placeholder = 'Search...'; search.className = 'ss-search';
|
|
||||||
const list = document.createElement('div');
|
|
||||||
list.className = 'ss-list';
|
|
||||||
|
|
||||||
function renderOptions(filter = '') {
|
|
||||||
list.innerHTML = '';
|
|
||||||
const filtered = filter
|
|
||||||
? options.filter(o => !selected.has(o.id) && (o.label.toLowerCase().includes(filter.toLowerCase()) || o.id.includes(filter)))
|
|
||||||
: options.filter(o => !selected.has(o.id));
|
|
||||||
for (const opt of filtered.slice(0, 50)) {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'ss-option';
|
|
||||||
let inner = '';
|
|
||||||
if (opt.avatar) inner += `<img class="ss-avatar" src="${opt.avatar}" alt="">`;
|
|
||||||
inner += `<span class="ss-label">${opt.label}</span>`;
|
|
||||||
if (opt.sub) inner += `<span class="ss-sub">${opt.sub}</span>`;
|
|
||||||
item.innerHTML = inner;
|
|
||||||
item.addEventListener('click', () => { selected.add(opt.id); renderChips(); renderOptions(search.value); updateInput(); });
|
|
||||||
list.appendChild(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
renderOptions();
|
|
||||||
search.addEventListener('input', () => renderOptions(search.value));
|
|
||||||
addBtn.addEventListener('click', () => { dropdown.classList.toggle('hidden'); if (!dropdown.classList.contains('hidden')) search.focus(); });
|
|
||||||
document.addEventListener('click', (e) => { if (!wrapper.contains(e.target)) dropdown.classList.add('hidden'); });
|
|
||||||
|
|
||||||
dropdown.appendChild(search);
|
|
||||||
dropdown.appendChild(list);
|
|
||||||
wrapper.appendChild(chipsEl);
|
|
||||||
wrapper.appendChild(addBtn);
|
|
||||||
wrapper.appendChild(dropdown);
|
|
||||||
inputEl.style.display = 'none';
|
|
||||||
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.DiscordFields = { fetchGuildData, renderChannelSelect, renderCategorySelect, renderRoleSelect, renderMemberSelect, renderMultiMemberSelect };
|
|
||||||
|
|||||||
137
settings-site/public/js/fields.js
Normal file
137
settings-site/public/js/fields.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let savedConfig = {};
|
||||||
|
let pendingChanges = {};
|
||||||
|
|
||||||
|
function setSavedConfig(config) {
|
||||||
|
savedConfig = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateFields(config) {
|
||||||
|
document.querySelectorAll('[data-key]').forEach(el => {
|
||||||
|
const key = el.dataset.key;
|
||||||
|
const value = config[key] || '';
|
||||||
|
if (el.type === 'checkbox') {
|
||||||
|
el.checked = value === 'true' || value === true;
|
||||||
|
} else if (el.type === 'color') {
|
||||||
|
const num = parseInt(value) || 0;
|
||||||
|
el.value = '#' + num.toString(16).padStart(6, '0');
|
||||||
|
} else {
|
||||||
|
el.value = value;
|
||||||
|
}
|
||||||
|
el.addEventListener('change', () => handleFieldChange(el, key));
|
||||||
|
el.addEventListener('input', () => {
|
||||||
|
if (el.type === 'text' || el.type === 'number' || el.type === 'password' || el.tagName === 'TEXTAREA') {
|
||||||
|
handleFieldChange(el, key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFieldChange(el, key) {
|
||||||
|
let value;
|
||||||
|
if (el.type === 'checkbox') {
|
||||||
|
value = el.checked ? 'true' : 'false';
|
||||||
|
} else if (el.type === 'color') {
|
||||||
|
value = '0x' + el.value.slice(1).toUpperCase();
|
||||||
|
} else {
|
||||||
|
value = el.value;
|
||||||
|
}
|
||||||
|
markChanged(key, value);
|
||||||
|
el.classList.toggle('changed', key in pendingChanges);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSmartSelects(config) {
|
||||||
|
document.querySelectorAll('[data-smart]').forEach(el => {
|
||||||
|
const key = el.dataset.key;
|
||||||
|
const type = el.dataset.smart;
|
||||||
|
const value = config[key] || '';
|
||||||
|
if (type === 'channel') DiscordFields.renderChannelSelect(el, value);
|
||||||
|
else if (type === 'category') DiscordFields.renderCategorySelect(el, value);
|
||||||
|
else if (type === 'role') DiscordFields.renderRoleSelect(el, value);
|
||||||
|
else if (type === 'member') DiscordFields.renderMemberSelect(el, value);
|
||||||
|
else if (type === 'multi-member') DiscordFields.renderMultiMemberSelect(el, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markChanged(key, value) {
|
||||||
|
if (String(value) === String(savedConfig[key] || '')) {
|
||||||
|
delete pendingChanges[key];
|
||||||
|
} else {
|
||||||
|
pendingChanges[key] = value;
|
||||||
|
}
|
||||||
|
updateSaveBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChanged(key) {
|
||||||
|
return key in pendingChanges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSaveBar() {
|
||||||
|
const bar = document.getElementById('save-bar');
|
||||||
|
const count = Object.keys(pendingChanges).length;
|
||||||
|
bar.classList.toggle('visible', count > 0);
|
||||||
|
document.getElementById('change-count').textContent =
|
||||||
|
`${count} unsaved change${count !== 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSaveBar() {
|
||||||
|
updateSaveBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig(mode) {
|
||||||
|
const buttons = document.querySelectorAll('#save-bar button');
|
||||||
|
buttons.forEach(b => b.disabled = true);
|
||||||
|
try {
|
||||||
|
if (mode === 'restart' && !confirm('Save changes and restart the bot now?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch('/api/config', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify(pendingChanges)
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.applied) {
|
||||||
|
for (const key of data.applied) savedConfig[key] = pendingChanges[key];
|
||||||
|
pendingChanges = {};
|
||||||
|
updateSaveBar();
|
||||||
|
document.querySelectorAll('.changed').forEach(el => el.classList.remove('changed'));
|
||||||
|
Util.showToast(`${data.applied.length} settings saved.`, 'success');
|
||||||
|
}
|
||||||
|
const hasErrors = data.errors && data.errors.length > 0;
|
||||||
|
if (hasErrors) {
|
||||||
|
Util.showToast(`Errors: ${data.errors.join(', ')}`, 'error');
|
||||||
|
}
|
||||||
|
if (mode === 'restart' && !hasErrors) {
|
||||||
|
await fetch('/api/restart', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({ mode: 'immediate' })
|
||||||
|
});
|
||||||
|
Util.showToast('Restart initiated.', 'warning');
|
||||||
|
} else if (mode === 'restart' && hasErrors) {
|
||||||
|
Util.showToast(`Restart cancelled — save returned errors: ${data.errors.join(', ')}`, 'warning');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Util.showToast('Failed to save. Bot may be unreachable.', 'error');
|
||||||
|
} finally {
|
||||||
|
buttons.forEach(b => b.disabled = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Fields = {
|
||||||
|
setSavedConfig,
|
||||||
|
populateFields,
|
||||||
|
handleFieldChange,
|
||||||
|
initSmartSelects,
|
||||||
|
markChanged,
|
||||||
|
isChanged,
|
||||||
|
updateSaveBar,
|
||||||
|
setupSaveBar,
|
||||||
|
saveConfig
|
||||||
|
};
|
||||||
|
})();
|
||||||
277
settings-site/public/js/notifications.js
Normal file
277
settings-site/public/js/notifications.js
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const NOTIFICATION_PRESETS = ['15m', '30m', '1h', '2h', '4h', '8h', '1d'];
|
||||||
|
|
||||||
|
const NOTIFICATION_TAB_KEYS = {
|
||||||
|
surge: [
|
||||||
|
'surge_tickets',
|
||||||
|
'surge_game',
|
||||||
|
'surge_stale',
|
||||||
|
'surge_needs_response',
|
||||||
|
'surge_unclaimed',
|
||||||
|
'surge_tier3_unclaimed',
|
||||||
|
'surge_no_staff'
|
||||||
|
],
|
||||||
|
patterns: [
|
||||||
|
'user_tickets',
|
||||||
|
'user_reopen',
|
||||||
|
'user_crossgame',
|
||||||
|
'game_surge',
|
||||||
|
'game_backlog',
|
||||||
|
'game_resolution',
|
||||||
|
'game_spike',
|
||||||
|
'tag_top',
|
||||||
|
'tag_escalation',
|
||||||
|
'untagged_closes',
|
||||||
|
'tag_game_corr',
|
||||||
|
'user_esc',
|
||||||
|
'game_esc_rate',
|
||||||
|
'rapid_t2_t3',
|
||||||
|
'staff_no_close',
|
||||||
|
'staff_overloaded',
|
||||||
|
'staff_stale',
|
||||||
|
'staff_transfer_rate',
|
||||||
|
'staff_esc',
|
||||||
|
'staff_game_esc',
|
||||||
|
'game_tag_spike',
|
||||||
|
'overnight_gap',
|
||||||
|
'staff_always_esc'
|
||||||
|
],
|
||||||
|
unclaimed: ['unclaimed_reminder'],
|
||||||
|
chat: ['chat_messages', 'chat_time']
|
||||||
|
};
|
||||||
|
|
||||||
|
const NOTIFICATION_ALERT_DESCRIPTIONS = {
|
||||||
|
surge_tickets: 'Fires when total active ticket volume exceeds configured surge thresholds, signaling broad queue pressure that needs staffing attention.',
|
||||||
|
surge_game: 'Fires when one game accumulates tickets unusually fast within the configured window, indicating a localized incident that should be triaged.',
|
||||||
|
surge_stale: 'Fires when too many tickets stay unresolved past the stale-time threshold, prompting staff to clear aging backlog.',
|
||||||
|
surge_needs_response: 'Fires when tickets needing a staff reply exceed count and age limits, indicating response latency is building.',
|
||||||
|
surge_unclaimed: 'Fires when the unclaimed queue crosses configured count/age thresholds, signaling ownership gaps that need pickup.',
|
||||||
|
surge_tier3_unclaimed: "Fires when Tier 3 tickets have been sitting unclaimed past each threshold. Escalating intervals prevent spam while ensuring critical tickets don't go unnoticed.",
|
||||||
|
surge_no_staff: 'Fires when open-ticket load is high while no staff are detected as available, prompting immediate coverage.',
|
||||||
|
user_tickets: 'Detects users opening unusually high ticket counts in the active window, suggesting repeat-issue or abuse patterns.',
|
||||||
|
user_reopen: 'Detects users who repeatedly reopen or recreate issues after closure, signaling unresolved root-cause patterns.',
|
||||||
|
user_crossgame: 'Detects users reporting similar issues across multiple games in a short period, indicating broader account-level impact.',
|
||||||
|
game_surge: 'Detects game-specific ticket spikes crossing thresholds in the pattern window, signaling service instability for that title.',
|
||||||
|
game_backlog: 'Detects games accumulating unresolved backlog above threshold, implying triage capacity is lagging for that queue.',
|
||||||
|
game_resolution: 'Detects unusual drops in resolution rate for a game, indicating tickets are staying open longer than expected.',
|
||||||
|
game_spike: 'Detects abrupt short-window jumps in ticket volume for a game, flagging incidents that may need escalation.',
|
||||||
|
tag_top: 'Detects tag frequency leaders above threshold so recurring issue types can be prioritized for fixes or macros.',
|
||||||
|
tag_escalation: 'Detects tags with unusually high escalation rates, indicating categories that routinely require higher-tier handling.',
|
||||||
|
untagged_closes: 'Detects elevated counts of closed tickets without tags, prompting cleanup to preserve reporting quality.',
|
||||||
|
tag_game_corr: 'Detects strong tag-to-game concentration patterns, highlighting issue types tightly linked to specific games.',
|
||||||
|
user_esc: 'Detects users whose tickets escalate unusually often, implying complex cases that may need proactive follow-up.',
|
||||||
|
game_esc_rate: 'Detects games with escalating ticket-rate thresholds exceeded, signaling deeper technical issues for that title.',
|
||||||
|
rapid_t2_t3: 'Fires at ticket count milestones (e.g. 3, 5, 10) when tickets have reached Tier 3 this week. Each milestone fires once per week.',
|
||||||
|
staff_no_close: 'Detects staff with prolonged periods of claims but few closes, suggesting overloaded ownership or stuck work.',
|
||||||
|
staff_overloaded: 'Detects staff carrying ticket loads beyond threshold, indicating balancing or reassignment may be needed.',
|
||||||
|
staff_stale: 'Detects staff-owned tickets aging beyond stale limits, prompting review and unblock actions.',
|
||||||
|
staff_transfer_rate: 'Detects unusually high transfer/reassignment rates by staff, signaling ownership churn that may hurt throughput.',
|
||||||
|
staff_esc: 'Detects staff escalation counts above threshold, highlighting where extra support or training may be needed.',
|
||||||
|
staff_game_esc: 'Detects high escalation concentration for specific staff/game combinations, indicating targeted expertise gaps.',
|
||||||
|
game_tag_spike: 'Detects sudden spikes of specific tags within a game, flagging focused incident signatures.',
|
||||||
|
overnight_gap: 'Detects recurring unattended overnight windows with active demand, suggesting staffing coverage gaps.',
|
||||||
|
staff_always_esc: 'Detects staff whose handled tickets escalate at consistently high rates, implying sustained tier-fit issues.',
|
||||||
|
unclaimed_reminder: 'Reminds all staff notification channels about unclaimed tickets. Thresholds are per-ticket age — each threshold fires once per ticket and resets on escalation.',
|
||||||
|
chat_messages: 'Fires when pending user message volume in monitored chat channels crosses configured count thresholds without staff replies.',
|
||||||
|
chat_time: 'Fires when a monitored chat channel has had no staff response for the given duration with pending user messages. Resets when staff responds.'
|
||||||
|
};
|
||||||
|
|
||||||
|
let notificationThresholdsState = {};
|
||||||
|
|
||||||
|
function initNotificationsEditor(config) {
|
||||||
|
const section = document.getElementById('s-notifications');
|
||||||
|
if (!section) return;
|
||||||
|
|
||||||
|
const hiddenField = section.querySelector('[data-key="NOTIFICATION_THRESHOLDS_JSON"]');
|
||||||
|
if (!hiddenField) return;
|
||||||
|
|
||||||
|
notificationThresholdsState = parseNotificationThresholdsConfig(config);
|
||||||
|
hiddenField.value = serializeNotificationThresholds(notificationThresholdsState);
|
||||||
|
|
||||||
|
section.querySelectorAll('.notif-tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => setNotificationTab(btn.dataset.notifTab));
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.entries(NOTIFICATION_TAB_KEYS).forEach(([category, keys]) => {
|
||||||
|
const select = section.querySelector(`[data-notif-category="${category}"]`);
|
||||||
|
const chipsWrap = section.querySelector(`[data-notif-chips="${category}"]`);
|
||||||
|
const input = section.querySelector(`[data-notif-input="${category}"]`);
|
||||||
|
const addBtn = section.querySelector(`[data-notif-add="${category}"]`);
|
||||||
|
const presetsWrap = section.querySelector(`[data-notif-presets="${category}"]`);
|
||||||
|
if (!select || !chipsWrap || !input || !addBtn || !presetsWrap) return;
|
||||||
|
|
||||||
|
keys.forEach(key => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = key;
|
||||||
|
option.textContent = toHumanLabel(key);
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (keys.length) select.value = keys[0];
|
||||||
|
|
||||||
|
select.addEventListener('change', () => {
|
||||||
|
renderThresholdChips(category);
|
||||||
|
renderAlertDescription(category);
|
||||||
|
});
|
||||||
|
addBtn.addEventListener('click', () => addThresholdFromInput(category));
|
||||||
|
input.addEventListener('keydown', (evt) => {
|
||||||
|
if (evt.key === 'Enter') {
|
||||||
|
evt.preventDefault();
|
||||||
|
addThresholdFromInput(category);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
NOTIFICATION_PRESETS.forEach(preset => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.textContent = preset;
|
||||||
|
btn.addEventListener('click', () => addThresholdValue(category, preset));
|
||||||
|
presetsWrap.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderThresholdChips(category);
|
||||||
|
renderAlertDescription(category);
|
||||||
|
});
|
||||||
|
|
||||||
|
setNotificationTab('surge');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNotificationThresholdsConfig(config) {
|
||||||
|
const rawJson = config.NOTIFICATION_THRESHOLDS_JSON;
|
||||||
|
if (rawJson && String(rawJson).trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawJson);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (config.NOTIFICATION_THRESHOLDS && typeof config.NOTIFICATION_THRESHOLDS === 'object' && !Array.isArray(config.NOTIFICATION_THRESHOLDS)) {
|
||||||
|
return config.NOTIFICATION_THRESHOLDS;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeNotificationThresholds(obj) {
|
||||||
|
const ordered = {};
|
||||||
|
Object.keys(obj).sort().forEach(key => {
|
||||||
|
const arr = Array.isArray(obj[key]) ? obj[key].map(v => String(v).trim()).filter(Boolean) : [];
|
||||||
|
ordered[key] = arr;
|
||||||
|
});
|
||||||
|
return JSON.stringify(ordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNotificationTab(category) {
|
||||||
|
document.querySelectorAll('#s-notifications .notif-tab-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.notifTab === category);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('#s-notifications .notif-panel').forEach(panel => {
|
||||||
|
panel.classList.toggle('hidden', panel.dataset.notifPanel !== category);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addThresholdFromInput(category) {
|
||||||
|
const input = document.querySelector(`#s-notifications [data-notif-input="${category}"]`);
|
||||||
|
if (!input) return;
|
||||||
|
const value = input.value.trim().toLowerCase();
|
||||||
|
if (addThresholdValue(category, value)) input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addThresholdValue(category, rawValue) {
|
||||||
|
const value = String(rawValue || '').trim().toLowerCase();
|
||||||
|
if (!isValidThresholdValue(value)) {
|
||||||
|
Util.showToast('Invalid threshold format. Use 15m, 1h, 1d6h, or whole numbers.', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertKey = getSelectedAlertKey(category);
|
||||||
|
if (!alertKey) return false;
|
||||||
|
|
||||||
|
const current = Array.isArray(notificationThresholdsState[alertKey]) ? [...notificationThresholdsState[alertKey]] : [];
|
||||||
|
if (current.includes(value)) return false;
|
||||||
|
current.push(value);
|
||||||
|
notificationThresholdsState[alertKey] = current;
|
||||||
|
syncNotificationThresholdsField();
|
||||||
|
renderThresholdChips(category);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeThresholdValue(category, valueToRemove) {
|
||||||
|
const alertKey = getSelectedAlertKey(category);
|
||||||
|
if (!alertKey) return;
|
||||||
|
const current = Array.isArray(notificationThresholdsState[alertKey]) ? [...notificationThresholdsState[alertKey]] : [];
|
||||||
|
notificationThresholdsState[alertKey] = current.filter(v => String(v) !== String(valueToRemove));
|
||||||
|
syncNotificationThresholdsField();
|
||||||
|
renderThresholdChips(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderThresholdChips(category) {
|
||||||
|
const chipsWrap = document.querySelector(`#s-notifications [data-notif-chips="${category}"]`);
|
||||||
|
if (!chipsWrap) return;
|
||||||
|
const alertKey = getSelectedAlertKey(category);
|
||||||
|
const thresholds = alertKey && Array.isArray(notificationThresholdsState[alertKey])
|
||||||
|
? notificationThresholdsState[alertKey]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
chipsWrap.replaceChildren();
|
||||||
|
thresholds.forEach(value => {
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.className = 'notif-chip';
|
||||||
|
chip.textContent = value;
|
||||||
|
|
||||||
|
const remove = document.createElement('button');
|
||||||
|
remove.type = 'button';
|
||||||
|
remove.title = `Remove ${value}`;
|
||||||
|
remove.textContent = '×';
|
||||||
|
remove.addEventListener('click', () => removeThresholdValue(category, value));
|
||||||
|
|
||||||
|
chip.appendChild(remove);
|
||||||
|
chipsWrap.appendChild(chip);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlertDescription(category) {
|
||||||
|
const descriptionEl = document.querySelector(`#s-notifications [data-notif-description="${category}"]`);
|
||||||
|
if (!descriptionEl) return;
|
||||||
|
const alertKey = getSelectedAlertKey(category);
|
||||||
|
descriptionEl.textContent = NOTIFICATION_ALERT_DESCRIPTIONS[alertKey] || 'No description available for this alert key yet.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncNotificationThresholdsField() {
|
||||||
|
const hiddenField = document.querySelector('#s-notifications [data-key="NOTIFICATION_THRESHOLDS_JSON"]');
|
||||||
|
if (!hiddenField) return;
|
||||||
|
const serialized = serializeNotificationThresholds(notificationThresholdsState);
|
||||||
|
hiddenField.value = serialized;
|
||||||
|
Fields.markChanged('NOTIFICATION_THRESHOLDS_JSON', serialized);
|
||||||
|
hiddenField.classList.toggle('changed', Fields.isChanged('NOTIFICATION_THRESHOLDS_JSON'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedAlertKey(category) {
|
||||||
|
const select = document.querySelector(`#s-notifications [data-notif-category="${category}"]`);
|
||||||
|
return select ? select.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidThresholdValue(value) {
|
||||||
|
if (!value) return false;
|
||||||
|
if (/^\d+$/.test(value)) return true;
|
||||||
|
return /^(\d+[mhd])+$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHumanLabel(key) {
|
||||||
|
return String(key)
|
||||||
|
.split('_')
|
||||||
|
.map(part => part.toUpperCase() === 'T2' || part.toUpperCase() === 'T3'
|
||||||
|
? part.toUpperCase()
|
||||||
|
: part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Notifications = {
|
||||||
|
initNotificationsEditor,
|
||||||
|
isValidThresholdValue,
|
||||||
|
toHumanLabel,
|
||||||
|
NOTIFICATION_PRESETS,
|
||||||
|
NOTIFICATION_TAB_KEYS,
|
||||||
|
NOTIFICATION_ALERT_DESCRIPTIONS
|
||||||
|
};
|
||||||
|
})();
|
||||||
52
settings-site/public/js/router.js
Normal file
52
settings-site/public/js/router.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const ROUTES = {
|
||||||
|
'/': 's-core',
|
||||||
|
'/channels': 's-channels',
|
||||||
|
'/categories': 's-categories',
|
||||||
|
'/gmail': 's-gmail',
|
||||||
|
'/behavior': 's-behavior',
|
||||||
|
'/threads': 's-threads',
|
||||||
|
'/pins': 's-pins',
|
||||||
|
'/notifications': 's-notifications',
|
||||||
|
'/logging': 's-logging',
|
||||||
|
'/automation': 's-automation',
|
||||||
|
'/appearance': 's-appearance',
|
||||||
|
'/staff': 's-staff',
|
||||||
|
'/advanced': 's-advanced'
|
||||||
|
};
|
||||||
|
|
||||||
|
function navigate(path, updateHistory = true) {
|
||||||
|
const sectionId = ROUTES[path] || ROUTES['/'];
|
||||||
|
const normalizedPath = ROUTES[path] ? path : '/';
|
||||||
|
if (updateHistory) history.pushState({}, '', normalizedPath);
|
||||||
|
|
||||||
|
document.querySelectorAll('.section').forEach(section => {
|
||||||
|
section.classList.toggle('hidden', section.id !== sectionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.sidebar a').forEach(link => {
|
||||||
|
link.classList.toggle('active', link.getAttribute('href') === normalizedPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSidebarRouting() {
|
||||||
|
const sidebar = document.querySelector('.sidebar');
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
sidebar.addEventListener('click', e => {
|
||||||
|
const a = e.target.closest('a');
|
||||||
|
if (!a) return;
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(a.getAttribute('href'));
|
||||||
|
if (Util.isMobileViewport()) Util.setSidebarOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
navigate(location.pathname, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Router = { ROUTES, navigate, setupSidebarRouting };
|
||||||
|
})();
|
||||||
52
settings-site/public/js/util.js
Normal file
52
settings-site/public/js/util.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let csrfToken = '';
|
||||||
|
|
||||||
|
async function fetchCsrfToken() {
|
||||||
|
const res = await fetch('/api/csrf-token', { credentials: 'same-origin' });
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch CSRF token');
|
||||||
|
const data = await res.json();
|
||||||
|
csrfToken = data.csrfToken;
|
||||||
|
return csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrfHeaders(base = {}) {
|
||||||
|
return { ...base, 'x-csrf-token': csrfToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast-${type}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
document.getElementById('toast-container').appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocalDateTime(d) {
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 900;
|
||||||
|
|
||||||
|
function isMobileViewport() {
|
||||||
|
return window.innerWidth <= MOBILE_BREAKPOINT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSidebarOpen(open) {
|
||||||
|
document.body.classList.toggle('sidebar-open', open);
|
||||||
|
const toggle = document.getElementById('menu-toggle');
|
||||||
|
if (toggle) toggle.setAttribute('aria-expanded', String(open));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Util = {
|
||||||
|
fetchCsrfToken,
|
||||||
|
csrfHeaders,
|
||||||
|
showToast,
|
||||||
|
formatLocalDateTime,
|
||||||
|
isMobileViewport,
|
||||||
|
setSidebarOpen,
|
||||||
|
MOBILE_BREAKPOINT
|
||||||
|
};
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user