settings site changes

This commit is contained in:
2026-04-21 15:30:40 +00:00
parent 74d7f49c8d
commit 298cf13d5c
16 changed files with 1908 additions and 40 deletions

View File

@@ -194,6 +194,7 @@ body::before {
/* Sections */
.section { margin-bottom: 20px; }
.section.hidden { display: none; }
.section-header {
background: var(--surface);
border: 1px solid var(--border);
@@ -1023,4 +1024,49 @@ button.ss-chip {
line-height: 1.4;
}
/* Landing / home card grid */
.landing-section { margin-bottom: 20px; }
.landing-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.landing-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 20px 22px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
text-decoration: none;
color: var(--text);
transition: border-color 150ms ease, background 150ms ease;
}
.landing-card:hover {
border-color: var(--border-strong);
background: var(--surface-2);
}
.landing-card-body { flex: 1; min-width: 0; }
.landing-card h3 {
font-family: var(--font-title);
font-size: 16px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
}
.landing-card p {
font-family: var(--font-body);
font-size: 13px;
color: var(--text-muted);
line-height: 1.4;
}
.landing-card .chevron {
color: var(--primary);
font-size: 14px;
transition: transform 150ms ease;
}
.landing-card:hover .chevron { transform: translateX(3px); }

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,8 @@
<div class="sidebar-backdrop" id="sidebar-backdrop" aria-hidden="true"></div>
<nav class="sidebar" id="sidebar">
<div class="logo">Broccolini Settings</div>
<a href="/" class="active">Core</a>
<a href="/" class="active">Home</a>
<a href="/core">Core</a>
<a href="/channels">Channels</a>
<a href="/categories">Categories</a>
<a href="/gmail">Gmail</a>
@@ -47,6 +48,25 @@
<!-- Main content -->
<div class="main">
<!-- 0. Landing -->
<div class="section landing-section" id="s-landing">
<div class="landing-grid">
<a class="landing-card" href="/core"><div class="landing-card-body"><h3>Core</h3><p>Discord credentials and guild</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/channels"><div class="landing-card-body"><h3>Channels</h3><p>Log, transcript, and alert channels</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/categories"><div class="landing-card-body"><h3>Categories</h3><p>Ticket categories and escalation</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/gmail"><div class="landing-card-body"><h3>Gmail</h3><p>Google OAuth and email settings</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/behavior"><div class="landing-card-body"><h3>Ticket Behavior</h3><p>Automation, limits, and messages</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/threads"><div class="landing-card-body"><h3>Staff Threads</h3><p>Private staff discussion threads</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/pins"><div class="landing-card-body"><h3>Pin Messages</h3><p>Auto-pin welcome and escalations</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/notifications"><div class="landing-card-body"><h3>Notifications</h3><p>Surge, patterns, unclaimed, chat</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/logging"><div class="landing-card-body"><h3>Logging</h3><p>Log channel configuration</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/automation"><div class="landing-card-body"><h3>Automation</h3><p>Polling intervals and timers</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/appearance"><div class="landing-card-body"><h3>Appearance</h3><p>Colors, labels, emojis</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/staff"><div class="landing-card-body"><h3>Staff</h3><p>Staff IDs, emojis, admin</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/advanced"><div class="landing-card-body"><h3>Advanced</h3><p>Ports, URLs, game list, branding</p></div><span class="chevron">&#8250;</span></a>
</div>
</div>
<!-- 1. Core -->
<div class="section" id="s-core">
<div class="section-header"><h2>Core</h2><p>Discord bot credentials and guild</p><span class="chevron">&#9660;</span></div>

View File

@@ -0,0 +1,484 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Broccolini Settings</title>
<link rel="stylesheet" href="/css/main.css">
</head>
<body>
<div id="loading" class="loading"><div class="spinner"></div></div>
<div id="toast-container"></div>
<!-- Sidebar -->
<div class="sidebar-backdrop" id="sidebar-backdrop" aria-hidden="true"></div>
<nav class="sidebar" id="sidebar">
<div class="logo">Broccolini Settings</div>
<a href="/" class="active">Core</a>
<a href="/channels">Channels</a>
<a href="/categories">Categories</a>
<a href="/gmail">Gmail</a>
<a href="/behavior">Ticket Behavior</a>
<a href="/threads">Staff Threads</a>
<a href="/pins">Pin Messages</a>
<a href="/notifications">Notifications</a>
<a href="/logging">Logging</a>
<a href="/automation">Automation</a>
<a href="/appearance">Appearance</a>
<a href="/staff">Staff</a>
<a href="/advanced">Advanced</a>
</nav>
<!-- Top bar -->
<div class="topbar">
<button type="button" class="menu-toggle" id="menu-toggle" aria-label="Toggle navigation" aria-expanded="false" aria-controls="sidebar">
<span class="menu-toggle-bars" aria-hidden="true"></span>
</button>
<h1>Settings</h1>
<div class="status">
<span class="dot" id="bot-status-dot"></span>
<span id="bot-status-text">Checking...</span>
</div>
<div class="actions">
<button type="button" id="logout-btn">Logout</button>
</div>
</div>
<!-- Main content -->
<div class="main">
<!-- 1. Core -->
<div class="section" id="s-core">
<div class="section-header"><h2>Core</h2><p>Discord bot credentials and guild</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Discord Token</label><input type="password" data-key="DISCORD_TOKEN" placeholder="Bot token"></div>
<div class="field"><label>Application ID</label><input type="text" data-key="DISCORD_APPLICATION_ID"></div>
<div class="field"><label>Guild ID</label><input type="text" data-key="DISCORD_GUILD_ID"></div>
</div></div>
</div>
<!-- 2. Channels -->
<div class="section" id="s-channels">
<div class="section-header"><h2>Channels</h2><p>Channel assignments for logging, transcripts, and alerts</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Transcript Channel</label><input type="text" data-key="TRANSCRIPT_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Logging Channel</label><input type="text" data-key="LOGGING_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Debugging Channel</label><input type="text" data-key="DEBUGGING_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Backup/Export Channel</label><input type="text" data-key="BACKUP_EXPORT_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Account Info Channel</label><input type="text" data-key="ACCOUNT_INFO_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Gmail Log Channel</label><input type="text" data-key="GMAIL_LOG_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Automation Log Channel</label><input type="text" data-key="AUTOMATION_LOG_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Rename Log Channel</label><input type="text" data-key="RENAME_LOG_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Security Log Channel</label><input type="text" data-key="SECURITY_LOG_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>System Log Channel</label><input type="text" data-key="SYSTEM_LOG_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>All Staff Channel</label><input type="text" data-key="ALL_STAFF_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Chat Alert Channel</label><input type="text" data-key="ALL_STAFF_CHAT_ALERT_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>User Patterns Channel</label><input type="text" data-key="USER_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Game Patterns Channel</label><input type="text" data-key="GAME_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Tag Patterns Channel</label><input type="text" data-key="TAG_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Escalation Patterns Channel</label><input type="text" data-key="ESCALATION_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Staff Patterns Channel</label><input type="text" data-key="STAFF_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Combined Patterns Channel</label><input type="text" data-key="COMBINED_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
</div></div>
</div>
<!-- 3. Categories -->
<div class="section" id="s-categories">
<div class="section-header"><h2>Categories</h2><p>Ticket category assignments and escalation targets</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Email Ticket Category</label><input type="text" data-key="TICKET_CATEGORY_ID" data-smart="category"></div>
<div class="field"><label>Discord Ticket Category</label><input type="text" data-key="DISCORD_TICKET_CATEGORY_ID" data-smart="category"></div>
<div class="field"><label>Email T2 Category</label><input type="text" data-key="EMAIL_ESCALATED2_CHANNEL_ID" data-smart="category"></div>
<div class="field"><label>Discord T2 Category</label><input type="text" data-key="DISCORD_ESCALATED2_CHANNEL_ID" data-smart="category"></div>
<div class="field"><label>Email T3 Category</label><input type="text" data-key="EMAIL_ESCALATED3_CHANNEL_ID" data-smart="category"></div>
<div class="field"><label>Discord T3 Category</label><input type="text" data-key="DISCORD_ESCALATED3_CHANNEL_ID" data-smart="category"></div>
<div class="field"><label>Staff Notification Category</label><input type="text" data-key="STAFF_NOTIFICATION_CATEGORY_ID" data-smart="category"></div>
<div class="field"><label>Category Name</label><input type="text" data-key="TICKET_CATEGORY_NAME"></div>
<div class="field"><label>T2 Category Name</label><input type="text" data-key="TICKET_T2_CATEGORY_NAME"></div>
<div class="field"><label>T3 Category Name</label><input type="text" data-key="TICKET_T3_CATEGORY_NAME"></div>
<div class="field"><label>Discord Thread Channel</label><input type="text" data-key="DISCORD_THREAD_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Email Thread Channel</label><input type="text" data-key="EMAIL_THREAD_CHANNEL_ID" data-smart="channel"></div>
</div></div>
</div>
<!-- 4. Gmail -->
<div class="section" id="s-gmail">
<div class="section-header"><h2>Gmail</h2><p>Google OAuth credentials and email settings</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Google Client ID</label><input type="text" data-key="GOOGLE_CLIENT_ID"></div>
<div class="field"><label>Google Client Secret</label><input type="password" data-key="GOOGLE_CLIENT_SECRET"></div>
<div class="field"><label>Refresh Token</label><input type="password" data-key="REFRESH_TOKEN"></div>
<div class="field"><label>Support Email</label><input type="email" data-key="MY_EMAIL"></div>
</div></div>
</div>
<!-- 5. Ticket Behavior -->
<div class="section" id="s-behavior">
<div class="section-header"><h2>Ticket Behavior</h2><p>Automation, limits, and messages</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Auto-Close</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_CLOSE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Auto-Close Hours</label><input type="number" data-key="AUTO_CLOSE_AFTER_HOURS"></div>
<div class="field"><label>Reminders</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="REMINDER_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Reminder Hours</label><input type="number" data-key="REMINDER_AFTER_HOURS"></div>
<div class="field"><label>Priority System</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PRIORITY_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Claim Timeout</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="CLAIM_TIMEOUT_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Claim Timeout Hours</label><input type="number" data-key="CLAIM_TIMEOUT_HOURS"></div>
<div class="field"><label>Auto-Unclaim</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_UNCLAIM_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Auto-Unclaim Hours</label><input type="number" data-key="AUTO_UNCLAIM_AFTER_HOURS"></div>
<div class="field"><label>Allow Claim Overwrite</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="ALLOW_CLAIM_OVERWRITE"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Global Ticket Limit</label><input type="number" data-key="GLOBAL_TICKET_LIMIT"></div>
<div class="field"><label>Rate Limit (per user)</label><input type="number" data-key="RATE_LIMIT_TICKETS_PER_USER"></div>
<div class="field"><label>Rate Limit Window (min)</label><input type="number" data-key="RATE_LIMIT_WINDOW_MINUTES"></div>
<div class="field"><label>Role to Ping</label><input type="text" data-key="ROLE_ID_TO_PING" data-smart="role"></div>
<div class="field full-width"><label>Welcome Message</label><textarea data-key="TICKET_WELCOME_MESSAGE" rows="3"></textarea></div>
<div class="field full-width"><label>Claimed Message</label><textarea data-key="TICKET_CLAIMED_MESSAGE" rows="2"></textarea><div class="hint">Variables: {staff_mention}, {staff_name}</div></div>
<div class="field full-width"><label>Unclaimed Message</label><textarea data-key="TICKET_UNCLAIMED_MESSAGE" rows="2"></textarea></div>
<div class="field full-width"><label>Escalation Message</label><textarea data-key="ESCALATION_MESSAGE" rows="3"></textarea><div class="hint">Variables: {support_name}</div></div>
<div class="field full-width"><label>Reminder Message</label><textarea data-key="REMINDER_MESSAGE" rows="2"></textarea><div class="hint">Variables: {ping}, {hours}</div></div>
</div></div>
</div>
<!-- 6. Staff Threads -->
<div class="section" id="s-threads">
<div class="section-header"><h2>Staff Threads</h2><p>Private staff discussion threads on ticket channels</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Enabled</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_THREAD_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Thread Name</label><input type="text" data-key="STAFF_THREAD_NAME"></div>
<div class="field"><label>Auto-Add Role</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_THREAD_AUTO_ADD_ROLE"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Staff Thread Role</label><input type="text" data-key="STAFF_THREAD_ROLE_ID" data-smart="role"></div>
</div></div>
</div>
<!-- 7. Pin Messages -->
<div class="section" id="s-pins">
<div class="section-header"><h2>Pin Messages</h2><p>Auto-pin welcome and escalation messages</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Pin Initial Message</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PIN_INITIAL_MESSAGE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Pin Escalation Message</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PIN_ESCALATION_MESSAGE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Suppress Pin Notice</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PIN_SUPPRESS_SYSTEM_MESSAGE"><span class="slider"></span></label><span>Enabled</span></div></div>
</div></div>
</div>
<!-- 8. Notifications -->
<div class="section" id="s-notifications">
<div class="section-header"><h2>Notifications</h2><p>Threshold milestones and trigger conditions by alert category</p><span class="chevron">&#9660;</span></div>
<div class="section-body">
<input type="hidden" data-key="NOTIFICATION_THRESHOLDS_JSON">
<div class="notif-tabs" role="tablist" aria-label="Notification categories">
<button type="button" class="notif-tab-btn active" data-notif-tab="surge">Surge</button>
<button type="button" class="notif-tab-btn" data-notif-tab="patterns">Patterns</button>
<button type="button" class="notif-tab-btn" data-notif-tab="unclaimed">Unclaimed</button>
<button type="button" class="notif-tab-btn" data-notif-tab="chat">Chat</button>
</div>
<div class="notif-panel" data-notif-panel="surge">
<div class="notif-toggle-row">
<div class="notif-toggle-group">
<label class="toggle">
<input type="checkbox" data-notif-master>
<span class="slider"></span>
</label>
<span class="notif-toggle-label">Master (all categories)</span>
</div>
<div class="notif-toggle-group">
<label class="toggle">
<input type="checkbox" data-notif-category-toggle="surge">
<span class="slider"></span>
</label>
<span class="notif-toggle-label">All in category</span>
</div>
</div>
<p class="hint">Surge alerts fire when active ticket conditions cross thresholds — high volume, unclaimed queues, no staff online. Each alert escalates through its threshold list, spacing out pings as the condition persists. The counter resets when the condition clears.</p>
<div class="notif-editor">
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="surge"></select></div>
<div class="hint notif-alert-description" data-notif-description="surge"></div>
<div class="notif-per-alert-row">
<label class="toggle">
<input type="checkbox" data-notif-alert>
<span class="slider"></span>
</label>
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
</div>
<div class="notif-chips" data-notif-chips="surge"></div>
<div class="notif-input-row">
<input type="text" class="notif-threshold-input" data-notif-input="surge" placeholder="15m, 1h, 1d6h, 2d6h, 5">
<button type="button" class="notif-add-btn" data-notif-add="surge">Add</button>
</div>
<div class="notif-presets" data-notif-presets="surge"></div>
</div>
<details class="notif-trigger">
<summary>Trigger conditions</summary>
<div class="field-grid">
<div class="field"><label>Surge Role</label><input type="text" data-key="SURGE_ROLE_ID" data-smart="role"></div>
<div class="field"><label>Ticket Surge Count</label><input type="number" data-key="SURGE_TICKET_COUNT"></div>
<div class="field"><label>Ticket Window (min)</label><input type="number" data-key="SURGE_TICKET_WINDOW_MINUTES"></div>
<div class="field"><label>Game Surge Count</label><input type="number" data-key="SURGE_GAME_TICKET_COUNT"></div>
<div class="field"><label>Game Window (min)</label><input type="number" data-key="SURGE_GAME_TICKET_WINDOW_MINUTES"></div>
<div class="field"><label>Stale Count</label><input type="number" data-key="SURGE_STALE_COUNT"></div>
<div class="field"><label>Stale Hours</label><input type="number" data-key="SURGE_STALE_HOURS"></div>
<div class="field"><label>Needs Response Count</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_COUNT"></div>
<div class="field"><label>Needs Response Hours</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_HOURS"></div>
<div class="field"><label>Unclaimed Count</label><input type="number" data-key="SURGE_UNCLAIMED_COUNT"></div>
<div class="field"><label>Unclaimed Minutes</label><input type="number" data-key="SURGE_UNCLAIMED_MINUTES"></div>
<div class="field"><label>Tier 3 Unclaimed (min)</label><input type="number" data-key="SURGE_TIER3_UNCLAIMED_MINUTES"></div>
<div class="field"><label>Cooldown (min)</label><input type="number" data-key="SURGE_COOLDOWN_MINUTES"></div>
<div class="field"><label>DND = Available</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_DND_COUNTS_AS_AVAILABLE"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>No-Staff Cooldown (min)</label><input type="number" data-key="SURGE_NO_STAFF_COOLDOWN_MINUTES"></div>
<div class="field"><label>No-Staff Ticket Threshold</label><input type="number" data-key="SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD"></div>
</div>
</details>
</div>
<div class="notif-panel hidden" data-notif-panel="patterns">
<div class="notif-toggle-row">
<div class="notif-toggle-group">
<label class="toggle">
<input type="checkbox" data-notif-master>
<span class="slider"></span>
</label>
<span class="notif-toggle-label">Master (all categories)</span>
</div>
<div class="notif-toggle-group">
<label class="toggle">
<input type="checkbox" data-notif-category-toggle="patterns">
<span class="slider"></span>
</label>
<span class="notif-toggle-label">All in category</span>
</div>
</div>
<p class="hint">Pattern alerts detect trends over time — surges by game, escalation rates, staff behavior. Each alert fires once per threshold crossed within its window (daily/weekly/monthly) and won't repeat until the next window resets.</p>
<div class="notif-editor">
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="patterns"></select></div>
<div class="hint notif-alert-description" data-notif-description="patterns"></div>
<div class="notif-per-alert-row">
<label class="toggle">
<input type="checkbox" data-notif-alert>
<span class="slider"></span>
</label>
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
</div>
<div class="notif-chips" data-notif-chips="patterns"></div>
<div class="notif-input-row">
<input type="text" class="notif-threshold-input" data-notif-input="patterns" placeholder="15m, 1h, 1d6h, 2d6h, 5">
<button type="button" class="notif-add-btn" data-notif-add="patterns">Add</button>
</div>
<div class="notif-presets" data-notif-presets="patterns"></div>
</div>
<details class="notif-trigger">
<summary>Trigger conditions</summary>
<div class="field-grid">
<div class="field"><label>Check Interval (min)</label><input type="number" data-key="PATTERN_CHECK_INTERVAL_MINUTES"></div>
<div class="field"><label>User Ticket Threshold</label><input type="number" data-key="PATTERN_USER_TICKET_THRESHOLD"></div>
<div class="field"><label>Game Ticket Threshold</label><input type="number" data-key="PATTERN_GAME_TICKET_THRESHOLD"></div>
<div class="field"><label>Staff Stale Ping Threshold</label><input type="number" data-key="PATTERN_STAFF_STALE_PING_THRESHOLD"></div>
<div class="field"><label>Escalation Threshold</label><input type="number" data-key="PATTERN_ESCALATION_THRESHOLD"></div>
<div class="field"><label>Rapid Close Seconds</label><input type="number" data-key="PATTERN_RAPID_CLOSE_SECONDS"></div>
<div class="field"><label>Unclaimed Hours</label><input type="number" data-key="PATTERN_UNCLAIMED_HOURS"></div>
</div>
</details>
</div>
<div class="notif-panel hidden" data-notif-panel="unclaimed">
<div class="notif-toggle-row">
<div class="notif-toggle-group">
<label class="toggle">
<input type="checkbox" data-notif-master>
<span class="slider"></span>
</label>
<span class="notif-toggle-label">Master (all categories)</span>
</div>
<div class="notif-toggle-group">
<label class="toggle">
<input type="checkbox" data-notif-category-toggle="unclaimed">
<span class="slider"></span>
</label>
<span class="notif-toggle-label">All in category</span>
</div>
</div>
<p class="hint">Per-ticket reminders sent to staff notification channels when a ticket remains unclaimed. Each threshold fires once per ticket. Escalating a ticket resets the threshold list so reminders restart for the new tier.</p>
<div class="notif-editor">
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="unclaimed"></select></div>
<div class="hint notif-alert-description" data-notif-description="unclaimed"></div>
<div class="notif-per-alert-row">
<label class="toggle">
<input type="checkbox" data-notif-alert>
<span class="slider"></span>
</label>
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
</div>
<div class="notif-chips" data-notif-chips="unclaimed"></div>
<div class="notif-input-row">
<input type="text" class="notif-threshold-input" data-notif-input="unclaimed" placeholder="15m, 1h, 1d6h, 2d6h, 5">
<button type="button" class="notif-add-btn" data-notif-add="unclaimed">Add</button>
</div>
<div class="notif-presets" data-notif-presets="unclaimed"></div>
</div>
<details class="notif-trigger">
<summary>Trigger conditions</summary>
<div class="field-grid">
<div class="field full-width"><p class="hint">Unclaimed notifications use threshold milestones only.</p></div>
</div>
</details>
</div>
<div class="notif-panel hidden" data-notif-panel="chat">
<div class="notif-toggle-row">
<div class="notif-toggle-group">
<label class="toggle">
<input type="checkbox" data-notif-master>
<span class="slider"></span>
</label>
<span class="notif-toggle-label">Master (all categories)</span>
</div>
<div class="notif-toggle-group">
<label class="toggle">
<input type="checkbox" data-notif-category-toggle="chat">
<span class="slider"></span>
</label>
<span class="notif-toggle-label">All in category</span>
</div>
</div>
<p class="hint">Monitors configured chat channels for unresponded user messages. Fires at escalating intervals while the condition persists. Resets when a staff member responds.</p>
<div class="notif-editor">
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="chat"></select></div>
<div class="hint notif-alert-description" data-notif-description="chat"></div>
<div class="notif-per-alert-row">
<label class="toggle">
<input type="checkbox" data-notif-alert>
<span class="slider"></span>
</label>
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
</div>
<div class="notif-chips" data-notif-chips="chat"></div>
<div class="notif-input-row">
<input type="text" class="notif-threshold-input" data-notif-input="chat" placeholder="15m, 1h, 1d6h, 2d6h, 5">
<button type="button" class="notif-add-btn" data-notif-add="chat">Add</button>
</div>
<div class="notif-presets" data-notif-presets="chat"></div>
</div>
<details class="notif-trigger">
<summary>Trigger conditions</summary>
<div class="field-grid">
<div class="field"><label>Chat Alert Message Count</label><input type="number" data-key="CHAT_ALERT_MESSAGE_COUNT"></div>
<div class="field"><label>Chat No-Response Hours</label><input type="number" data-key="CHAT_ALERT_HOURS_WITHOUT_RESPONSE"></div>
<div class="field"><label>Chat Alert Cooldown (min)</label><input type="number" data-key="CHAT_ALERT_COOLDOWN_MINUTES"></div>
</div>
</details>
</div>
</div>
</div>
<!-- 10. Logging -->
<div class="section" id="s-logging">
<div class="section-header"><h2>Logging</h2><p>Log channel configuration (channels set in Channels section)</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field full-width"><p class="logging-hint">Log channels are configured in the <a href="/channels">Channels</a> section. This section shows which logs are active based on configured channels.</p></div>
</div></div>
</div>
<!-- 11. Automation -->
<div class="section" id="s-automation">
<div class="section-header"><h2>Automation</h2><p>Polling intervals and timer durations</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Gmail Poll Interval (sec)</label><select data-key="GMAIL_POLL_INTERVAL_SECONDS">
<option value="5">5s</option><option value="10">10s</option><option value="30">30s</option><option value="45">45s</option>
<option value="60">1m</option><option value="120">2m</option><option value="180">3m</option><option value="240">4m</option>
<option value="300">5m</option><option value="600">10m</option>
</select></div>
<div class="field"><label>Force-Close Timer (sec)</label><select data-key="FORCE_CLOSE_TIMER_SECONDS">
<option value="5">5s</option><option value="10">10s</option><option value="30">30s</option><option value="45">45s</option>
<option value="60">1m</option><option value="120">2m</option><option value="180">3m</option><option value="240">4m</option>
<option value="300">5m</option><option value="600">10m</option>
</select></div>
</div></div>
</div>
<!-- 12. Appearance -->
<div class="section" id="s-appearance">
<div class="section-header"><h2>Appearance</h2><p>Embed colors, button labels, and emojis</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Open Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_OPEN"><span>Open tickets</span></div></div>
<div class="field"><label>Closed Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_CLOSED"><span>Closed tickets</span></div></div>
<div class="field"><label>Claimed Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_CLAIMED"><span>Claimed tickets</span></div></div>
<div class="field"><label>Escalated Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_ESCALATED"><span>Escalated tickets</span></div></div>
<div class="field"><label>Info Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_INFO"><span>Info embeds</span></div></div>
<div class="field"><label>Close Button Label</label><input type="text" data-key="BUTTON_LABEL_CLOSE"></div>
<div class="field"><label>Claim Button Label</label><input type="text" data-key="BUTTON_LABEL_CLAIM"></div>
<div class="field"><label>Unclaim Button Label</label><input type="text" data-key="BUTTON_LABEL_UNCLAIM"></div>
<div class="field"><label>Close Emoji</label><input type="text" data-key="BUTTON_EMOJI_CLOSE"></div>
<div class="field"><label>Claim Emoji</label><input type="text" data-key="BUTTON_EMOJI_CLAIM"></div>
<div class="field"><label>Unclaim Emoji</label><input type="text" data-key="BUTTON_EMOJI_UNCLAIM"></div>
<div class="field"><label>High Priority Emoji</label><input type="text" data-key="PRIORITY_HIGH_EMOJI"></div>
<div class="field"><label>Medium Priority Emoji</label><input type="text" data-key="PRIORITY_MEDIUM_EMOJI"></div>
<div class="field"><label>Low Priority Emoji</label><input type="text" data-key="PRIORITY_LOW_EMOJI"></div>
<div class="field"><label>Claimer Emoji Fallback</label><input type="text" data-key="CLAIMER_EMOJI_FALLBACK"></div>
</div></div>
</div>
<!-- 13. Staff -->
<div class="section" id="s-staff">
<div class="section-header"><h2>Staff</h2><p>Staff IDs, emojis, and admin settings</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field full-width"><label>Staff IDs (comma-separated)</label><input type="text" data-key="STAFF_IDS" data-smart="multi-member"></div>
<div class="field"><label>Admin ID</label><input type="text" data-key="ADMIN_ID" data-smart="member"></div>
<div class="field full-width"><label>Staff Emojis (userId:emoji, comma-separated)</label><input type="text" data-key="STAFF_EMOJIS"><div class="hint">Format: 123456:emoji,789012:emoji</div></div>
<div class="field full-width"><label>Additional Staff Roles (comma-separated)</label><input type="text" data-key="ADDITIONAL_STAFF_ROLES"><div class="hint">Role IDs with staff permissions</div></div>
<div class="field full-width"><label>Blacklisted Roles (comma-separated)</label><input type="text" data-key="BLACKLISTED_ROLES"><div class="hint">Role IDs that cannot open tickets</div></div>
<div class="field full-width"><label>Unclaimed Reminder Thresholds (hours, comma-separated)</label><input type="text" data-key="UNCLAIMED_REMINDER_THRESHOLDS"><div class="hint">e.g. 1,2,4</div></div>
</div></div>
</div>
<!-- 14. Advanced -->
<div class="section" id="s-advanced">
<div class="section-header"><h2>Advanced</h2><p>Ports, URLs, game list, branding</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Bot Port</label><input type="number" data-key="DISCORD_ONLY_PORT"></div>
<div class="field"><label>Healthcheck Host</label><input type="text" data-key="HEALTHCHECK_HOST" placeholder="leave empty for all interfaces"></div>
<div class="field"><label>Settings Port</label><input type="number" data-key="SETTINGS_PORT"></div>
<div class="field"><label>Settings Domain</label><input type="text" data-key="SETTINGS_DOMAIN"></div>
<div class="field"><label>Internal API Port</label><input type="number" data-key="INTERNAL_API_PORT"></div>
<div class="field"><label>Support Name</label><input type="text" data-key="SUPPORT_NAME"></div>
<div class="field"><label>Logo URL</label><input type="text" data-key="LOGO_URL"></div>
<div class="field full-width"><label>Game List (comma-separated)</label><textarea data-key="GAME_LIST" rows="3"></textarea></div>
<div class="field full-width"><label>Email Signature (HTML, use \n for breaks)</label><textarea data-key="EMAIL_SIGNATURE" rows="3"></textarea></div>
<div class="field full-width"><label>Close Subject Prefix</label><input type="text" data-key="TICKET_CLOSE_SUBJECT_PREFIX"></div>
<div class="field full-width"><label>Close Message (email body)</label><textarea data-key="TICKET_CLOSE_MESSAGE" rows="2"></textarea></div>
<div class="field full-width"><label>Discord Close Message</label><textarea data-key="DISCORD_CLOSE_MESSAGE" rows="2"></textarea></div>
<div class="field full-width"><label>Transcript Message</label><textarea data-key="DISCORD_TRANSCRIPT_MESSAGE" rows="2"></textarea><div class="hint">Variables: {channel_name}, {email}, {date_opened}, {date_closed}</div></div>
<div class="field full-width"><label>Auto-Close Message</label><textarea data-key="DISCORD_AUTO_CLOSE_MESSAGE" rows="2"></textarea></div>
</div></div>
</div>
</div>
<!-- Save bar -->
<div id="save-bar" class="save-bar">
<span id="change-count">0 unsaved changes</span>
<div class="save-actions">
<button type="button" id="save-btn">Save</button>
<button type="button" id="save-restart-btn" class="danger">Save &amp; Restart Now</button>
<button type="button" id="schedule-restart-btn" class="secondary">Schedule restart...</button>
</div>
</div>
<!-- Schedule modal -->
<div id="schedule-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="schedule-modal-title">
<div class="modal-card">
<h3 id="schedule-modal-title">Schedule restart</h3>
<input type="datetime-local" id="schedule-datetime" aria-label="Restart date and time">
<div class="modal-actions">
<button type="button" id="schedule-confirm-btn">Schedule</button>
<button type="button" id="schedule-cancel-btn" class="secondary">Cancel</button>
</div>
</div>
</div>
<script defer src="/js/util.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>
</html>

View File

@@ -0,0 +1,162 @@
(function () {
'use strict';
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';
}
document.getElementById('loading').classList.add('hidden');
setupSectionToggles();
Fields.setupSaveBar();
}
function setupSectionToggles() {
document.querySelectorAll('.section-header').forEach(header => {
header.addEventListener('click', () => {
header.closest('.section').classList.toggle('collapsed');
});
});
}
function openScheduleModal() {
const modal = document.getElementById('schedule-modal');
const dt = document.getElementById('schedule-datetime');
const min = Util.formatLocalDateTime(new Date(Date.now() + 60000));
dt.min = min;
dt.value = min;
Util.openModal(modal, { initialFocus: '#schedule-datetime' });
}
async function confirmScheduledRestart() {
const dt = document.getElementById('schedule-datetime').value;
if (!dt) return;
await fetch('/api/restart', {
method: 'POST',
credentials: 'same-origin',
headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ mode: 'scheduled', scheduledFor: new Date(dt).toISOString() })
});
Util.closeModal(document.getElementById('schedule-modal'));
Util.showToast(`Restart scheduled for ${new Date(dt).toLocaleString()}`, 'warning');
}
async function doLogout() {
try {
await fetch('/logout', {
method: 'POST',
credentials: 'same-origin',
headers: Util.csrfHeaders()
});
} catch (e) { /* ignore */ }
window.location.href = '/login';
}
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', () => {
Util.closeModal(document.getElementById('schedule-modal'));
});
document.getElementById('logout-btn')?.addEventListener('click', doLogout);
}
function setupMobileNav() {
const toggle = document.getElementById('menu-toggle');
const backdrop = document.getElementById('sidebar-backdrop');
toggle?.addEventListener('click', () => {
Util.setSidebarOpen(!document.body.classList.contains('sidebar-open'));
});
backdrop?.addEventListener('click', () => Util.setSidebarOpen(false));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && document.body.classList.contains('sidebar-open')) {
Util.setSidebarOpen(false);
}
});
window.addEventListener('resize', () => {
if (!Util.isMobileViewport() && document.body.classList.contains('sidebar-open')) {
Util.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);
}
} catch (_) {
setBotStatus(false);
}
}
function scheduleNextHealthPoll() {
if (document.hidden) return;
healthPollHandle = setTimeout(async () => {
await pollHealth();
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();
});
window.addEventListener('pagehide', stopHealthPolling);
startHealthPolling();
}
document.addEventListener('DOMContentLoaded', async () => {
Router.setupSidebarRouting();
setupActionButtons();
setupMobileNav();
await init();
Router.navigate(location.pathname, false);
setupHealthPolling();
});
window.App = { init };
})();

View File

@@ -2,7 +2,8 @@
'use strict';
const ROUTES = {
'/': 's-core',
'/': 's-landing',
'/core': 's-core',
'/channels': 's-channels',
'/categories': 's-categories',
'/gmail': 's-gmail',
@@ -32,11 +33,10 @@
}
function setupSidebarRouting() {
const sidebar = document.querySelector('.sidebar');
if (!sidebar) return;
sidebar.addEventListener('click', e => {
const a = e.target.closest('a');
// Delegated at document so the same handler covers sidebar links AND
// landing-grid cards without duplication. Scoped by the compound selector.
document.addEventListener('click', e => {
const a = e.target.closest('.sidebar a, .landing-grid a');
if (!a) return;
e.preventDefault();
navigate(a.getAttribute('href'));

View 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 };
})();