diff --git a/settings-site/public/css/main.css b/settings-site/public/css/main.css index 851ba8c..969445e 100644 --- a/settings-site/public/css/main.css +++ b/settings-site/public/css/main.css @@ -428,6 +428,15 @@ body::before { .ss-placeholder { color: var(--text-dim); } .ss-avatar { width: 20px; height: 20px; border-radius: 50%; } .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 { diff --git a/settings-site/public/index.html b/settings-site/public/index.html index 50b3e5a..b4b50d5 100644 --- a/settings-site/public/index.html +++ b/settings-site/public/index.html @@ -382,7 +382,11 @@ - - + + + + + + diff --git a/settings-site/public/js/app.js b/settings-site/public/js/app.js index c0c6a14..3e52e33 100644 --- a/settings-site/public/js/app.js +++ b/settings-site/public/js/app.js @@ -1,618 +1,162 @@ -let savedConfig = {}; -let pendingChanges = {}; -let notificationThresholdsState = {}; -let csrfToken = ''; +(function () { + 'use strict'; -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 }; -} - -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.' -}; - -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; + async function init() { + document.getElementById('loading').classList.remove('hidden'); + try { + await Util.fetchCsrfToken(); + const [config] = await Promise.all([ + fetch('/api/config', { credentials: 'same-origin' }).then(r => r.json()), + DiscordFields.fetchGuildData() + ]); + Fields.setSavedConfig(config); + document.getElementById('bot-status-dot').className = 'dot online'; + document.getElementById('bot-status-text').textContent = 'Connected'; + Fields.populateFields(config); + Notifications.initNotificationsEditor(config); + Fields.initSmartSelects(config); + } catch (e) { + document.getElementById('bot-status-dot').className = 'dot offline'; + document.getElementById('bot-status-text').textContent = 'Unreachable'; } - 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; + document.getElementById('loading').classList.add('hidden'); + setupSectionToggles(); + Fields.setupSaveBar(); } - 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 setupSectionToggles() { - document.querySelectorAll('.section-header').forEach(header => { - header.addEventListener('click', () => { - header.closest('.section').classList.toggle('collapsed'); + 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() { - updateSaveBar(); -} + function openScheduleModal() { + 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() { - 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' : ''}`; -} - -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', { + 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(pendingChanges) + headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ mode: 'scheduled', scheduledFor: new Date(dt).toISOString() }) }); - 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')); - showToast(`${data.applied.length} settings saved.`, 'success'); - } - const hasErrors = data.errors && data.errors.length > 0; - if (hasErrors) { - showToast(`Errors: ${data.errors.join(', ')}`, 'error'); - } - if (mode === 'restart' && !hasErrors) { - await fetch('/api/restart', { + document.getElementById('schedule-modal').classList.add('hidden'); + Util.showToast(`Restart scheduled for ${new Date(dt).toLocaleString()}`, 'warning'); + } + + async function doLogout() { + try { + await fetch('/logout', { method: 'POST', credentials: 'same-origin', - headers: csrfHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ mode: 'immediate' }) + headers: Util.csrfHeaders() }); - showToast('Restart initiated.', 'warning'); - } else if (mode === 'restart' && hasErrors) { - 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); + } catch (e) { /* ignore */ } + window.location.href = '/login'; } -} -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())}`; -} - -function openScheduleModal() { - const modal = document.getElementById('schedule-modal'); - 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() + function setupActionButtons() { + document.getElementById('save-btn')?.addEventListener('click', () => Fields.saveConfig('save')); + 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); + document.getElementById('schedule-cancel-btn')?.addEventListener('click', () => { + document.getElementById('schedule-modal').classList.add('hidden'); }); - } catch (e) { /* ignore */ } - window.location.href = '/login'; -} + document.getElementById('logout-btn')?.addEventListener('click', doLogout); + } -function setupActionButtons() { - document.getElementById('save-btn')?.addEventListener('click', () => saveConfig('save')); - document.getElementById('save-restart-btn')?.addEventListener('click', () => saveConfig('restart')); - 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 setupMobileNav() { + const toggle = document.getElementById('menu-toggle'); + const backdrop = document.getElementById('sidebar-backdrop'); -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 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); + toggle?.addEventListener('click', () => { + Util.setSidebarOpen(!document.body.classList.contains('sidebar-open')); }); - - 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); + backdrop?.addEventListener('click', () => Util.setSidebarOpen(false)); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && document.body.classList.contains('sidebar-open')) { + Util.setSidebarOpen(false); } }); - - NOTIFICATION_PRESETS.forEach(preset => { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.textContent = preset; - btn.addEventListener('click', () => addThresholdValue(category, preset)); - presetsWrap.appendChild(btn); + window.addEventListener('resize', () => { + if (!Util.isMobileViewport() && document.body.classList.contains('sidebar-open')) { + Util.setSidebarOpen(false); + } }); + } - renderThresholdChips(category); - renderAlertDescription(category); - }); + let healthPollHandle = null; - 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) { - const rawJson = config.NOTIFICATION_THRESHOLDS_JSON; - if (rawJson && String(rawJson).trim()) { + async function pollHealth() { 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)) { - 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 { + const res = await fetch('/healthz', { credentials: 'same-origin' }); + if (res.ok) { + const data = await res.json(); + setBotStatus(Boolean(data.bot)); + } else { + setBotStatus(false); + } + } catch (_) { setBotStatus(false); } - } catch (_) { - setBotStatus(false); } -} -function scheduleNextHealthPoll() { - if (document.hidden) return; - healthPollHandle = setTimeout(async () => { - await pollHealth(); + function scheduleNextHealthPoll() { + if (document.hidden) return; + healthPollHandle = setTimeout(async () => { + await pollHealth(); + scheduleNextHealthPoll(); + }, 20000); + } + + function startHealthPolling() { + if (healthPollHandle) clearTimeout(healthPollHandle); scheduleNextHealthPoll(); - }, 20000); -} - -function startHealthPolling() { - if (healthPollHandle) clearTimeout(healthPollHandle); - scheduleNextHealthPoll(); -} - -function stopHealthPolling() { - if (healthPollHandle) { - clearTimeout(healthPollHandle); - healthPollHandle = null; } -} -function setupHealthPolling() { - document.addEventListener('visibilitychange', () => { - if (document.hidden) stopHealthPolling(); - else startHealthPolling(); + function stopHealthPolling() { + if (healthPollHandle) { + clearTimeout(healthPollHandle); + 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 () => { - setupSidebarRouting(); - setupActionButtons(); - setupMobileNav(); - await init(); - navigate(location.pathname, false); - setupHealthPolling(); -}); + window.App = { init }; +})(); diff --git a/settings-site/public/js/discord.js b/settings-site/public/js/discord.js index 9622ff9..fe3bf5c 100644 --- a/settings-site/public/js/discord.js +++ b/settings-site/public/js/discord.js @@ -1,195 +1,253 @@ -// Discord guild data cache -let guildData = null; -let guildDataPromise = null; +(function () { + 'use strict'; -async function fetchGuildData() { - if (guildData) return guildData; - 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; -} + let guildData = null; + let guildDataPromise = null; -async function renderChannelSelect(el, currentValue, filter) { - const data = await fetchGuildData(); - 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 fetchGuildData() { + if (guildData) return guildData; + 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 renderCategorySelect(el, currentValue) { - const data = await fetchGuildData(); - renderSmartSelect(el, data.categories.map(c => ({ id: c.id, label: c.name })), currentValue); -} + async function renderChannelSelect(el, currentValue, filter) { + const data = await fetchGuildData(); + 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) { - const data = await fetchGuildData(); - renderSmartSelect(el, data.roles.map(r => ({ id: r.id, label: `@${r.name}`, color: r.color })), currentValue); -} + async function renderCategorySelect(el, currentValue) { + const data = await fetchGuildData(); + renderSmartSelect(el, data.categories.map(c => ({ id: c.id, label: c.name })), currentValue); + } -async function renderMemberSelect(el, currentValue) { - const data = await fetchGuildData(); - renderSmartSelect(el, data.members.map(m => ({ - id: m.id, label: m.displayName, sub: `@${m.username}`, avatar: m.avatar - })), currentValue); -} + async function renderRoleSelect(el, currentValue) { + const data = await fetchGuildData(); + renderSmartSelect(el, data.roles.map(r => ({ id: r.id, label: `@${r.name}`, color: r.color })), currentValue); + } -async function renderMultiMemberSelect(el, currentValue) { - const data = await fetchGuildData(); - 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); -} + async function renderMemberSelect(el, currentValue) { + const data = await fetchGuildData(); + renderSmartSelect(el, data.members.map(m => ({ + id: m.id, label: m.displayName, sub: `@${m.username}`, avatar: m.avatar + })), currentValue); + } -function renderSmartSelect(inputEl, options, currentValue) { - const wrapper = document.createElement('div'); - wrapper.className = 'smart-select'; + async function renderMultiMemberSelect(el, currentValue) { + const data = await fetchGuildData(); + 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); - const display = document.createElement('div'); - display.className = 'smart-select-display'; - display.innerHTML = current - ? `${current.label}${current.id}` - : `Not set`; + function buildOptionRow(opt, { selected = false } = {}) { + const item = document.createElement('div'); + item.className = 'ss-option' + (selected ? ' selected' : ''); + if (opt.avatar) { + const img = document.createElement('img'); + 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'); - dropdown.className = 'smart-select-dropdown hidden'; + function setDisplayValue(displayEl, opt) { + 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'); - search.type = 'text'; - search.placeholder = 'Search...'; - search.className = 'ss-search'; + function setDisplayPlaceholder(displayEl, text) { + displayEl.replaceChildren(); + const placeholder = document.createElement('span'); + placeholder.className = 'ss-placeholder'; + placeholder.textContent = text; + displayEl.appendChild(placeholder); + } - const list = document.createElement('div'); - list.className = 'ss-list'; + function createDropdown(options, opts) { + const { multi = false, getCurrentId = () => null, isExcluded = () => false, onChoose, onClear } = opts; - const clearOpt = document.createElement('div'); - clearOpt.className = 'ss-option ss-clear'; - clearOpt.textContent = 'Clear (not set)'; - clearOpt.addEventListener('click', () => { - inputEl.value = ''; - display.innerHTML = `Not set`; - dropdown.classList.add('hidden'); - inputEl.dispatchEvent(new Event('change')); - }); - list.appendChild(clearOpt); + const dropdown = document.createElement('div'); + dropdown.className = 'smart-select-dropdown hidden'; - function renderOptions(filter = '') { - while (list.children.length > 1) list.removeChild(list.lastChild); - const filtered = filter - ? options.filter(o => o.label.toLowerCase().includes(filter.toLowerCase()) || (o.sub || '').toLowerCase().includes(filter.toLowerCase()) || o.id.includes(filter)) - : options; - for (const opt of filtered.slice(0, 50)) { - const item = document.createElement('div'); - item.className = 'ss-option' + (opt.id === inputEl.value ? ' selected' : ''); - let inner = ''; - if (opt.avatar) inner += ``; - if (opt.color && opt.color !== '#000000') inner += ``; - inner += `${opt.label}`; - if (opt.sub) inner += `${opt.sub}`; - item.innerHTML = inner; - item.addEventListener('click', () => { + 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.replaceChildren(); + if (!multi && onClear) { + const clearOpt = document.createElement('div'); + clearOpt.className = 'ss-option ss-clear'; + clearOpt.textContent = 'Clear (not set)'; + 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; - display.innerHTML = `${opt.label}${opt.id}`; + setDisplayValue(display, opt); dropdown.classList.add('hidden'); 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(); - search.addEventListener('input', () => renderOptions(search.value)); - 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'); - }); + const chipsEl = document.createElement('div'); + chipsEl.className = 'ss-chips'; - dropdown.appendChild(search); - dropdown.appendChild(list); - 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')); - } - - 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); + function renderChips() { + chipsEl.replaceChildren(); + for (const id of selected) { + const opt = options.find(o => o.id === id); + const chip = document.createElement('span'); + chip.className = 'ss-option ss-chip selected'; + 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'); - 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 = '+ Add'; - - 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 += ``; - inner += `${opt.label}`; - if (opt.sub) inner += `${opt.sub}`; - 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 }; + window.DiscordFields = { fetchGuildData, renderChannelSelect, renderCategorySelect, renderRoleSelect, renderMemberSelect, renderMultiMemberSelect }; +})(); diff --git a/settings-site/public/js/fields.js b/settings-site/public/js/fields.js new file mode 100644 index 0000000..f3b8ddf --- /dev/null +++ b/settings-site/public/js/fields.js @@ -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 + }; +})(); diff --git a/settings-site/public/js/notifications.js b/settings-site/public/js/notifications.js new file mode 100644 index 0000000..90765fd --- /dev/null +++ b/settings-site/public/js/notifications.js @@ -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 + }; +})(); diff --git a/settings-site/public/js/router.js b/settings-site/public/js/router.js new file mode 100644 index 0000000..9435014 --- /dev/null +++ b/settings-site/public/js/router.js @@ -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 }; +})(); diff --git a/settings-site/public/js/util.js b/settings-site/public/js/util.js new file mode 100644 index 0000000..553d0f3 --- /dev/null +++ b/settings-site/public/js/util.js @@ -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 + }; +})();