huge changes

This commit is contained in:
indifferentketchup
2026-04-07 01:43:06 -05:00
parent ca63ecbcfd
commit 69c247ed1b
37 changed files with 3468 additions and 169 deletions

View File

@@ -0,0 +1,5 @@
SETTINGS_PORT=12752
SETTINGS_ADMIN_PASSWORD=
SETTINGS_DOMAIN=tickets.indifferentketchup.com
INTERNAL_API_PORT=12753
INTERNAL_API_SECRET=

View File

@@ -0,0 +1,13 @@
services:
broccolini-settings:
build: .
container_name: broccolini-settings
restart: unless-stopped
env_file: ../.env
ports:
- "100.114.205.53:12752:12752"
network_mode: host
# network_mode: host is needed so the settings site can reach
# 127.0.0.1:12753 (the bot's internal API). If running both as
# Docker containers on the same host, use a shared Docker network
# instead and reference the bot container by name.

View File

@@ -0,0 +1,15 @@
{
"name": "broccolini-settings",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"express": "^4.18.0",
"express-session": "^1.17.3",
"dotenv": "^16.0.0",
"node-fetch": "^2.7.0"
}
}

View File

@@ -0,0 +1,134 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--card: #1e2235;
--border: #2a2d3e;
--accent: #5865f2;
--accent-hover: #4752c4;
--success: #57f287;
--warning: #fee75c;
--danger: #ed4245;
--text: #e0e0e0;
--text-muted: #888;
--sidebar-width: 260px;
}
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); display: flex; min-height: 100vh; }
/* Top bar */
.topbar { position: fixed; top: 0; left: var(--sidebar-width); right: 0; height: 56px; background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; padding: 0 24px; z-index: 100; }
.topbar h1 { font-size: 16px; font-weight: 600; }
.topbar .status { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-muted); }
.topbar .status .dot { width: 8px; height: 8px; border-radius: 50%; }
.topbar .status .dot.online { background: var(--success); }
.topbar .status .dot.offline { background: var(--danger); }
.topbar .actions { display: flex; gap: 12px; align-items: center; }
.topbar .actions button { background: none; border: 1px solid var(--border); color: var(--text-muted); padding: 6px 14px; border-radius: 6px; font-size: 13px; cursor: pointer; transition: all 200ms; }
.topbar .actions button:hover { border-color: var(--accent); color: var(--text); }
/* Sidebar */
.sidebar { position: fixed; top: 0; left: 0; width: var(--sidebar-width); height: 100vh; background: var(--surface); border-right: 1px solid var(--border); padding: 16px 0; overflow-y: auto; z-index: 101; }
.sidebar .logo { padding: 12px 20px 24px; font-size: 18px; font-weight: 700; }
.sidebar a { display: flex; align-items: center; gap: 10px; padding: 10px 20px; color: var(--text-muted); text-decoration: none; font-size: 13px; font-weight: 500; border-left: 3px solid transparent; transition: all 200ms; }
.sidebar a:hover { color: var(--text); background: rgba(88,101,242,0.08); }
.sidebar a.active { color: var(--accent); border-left-color: var(--accent); background: rgba(88,101,242,0.1); }
/* Main */
.main { margin-left: var(--sidebar-width); margin-top: 56px; padding: 24px; flex: 1; padding-bottom: 100px; }
/* Section cards */
.section { margin-bottom: 24px; }
.section-header { background: var(--card); border: 1px solid var(--border); border-radius: 12px 12px 0 0; padding: 20px 24px; cursor: pointer; display: flex; align-items: center; gap: 12px; transition: background 200ms; }
.section-header:hover { background: #232740; }
.section-header h2 { font-size: 15px; font-weight: 600; flex: 1; }
.section-header p { font-size: 12px; color: var(--text-muted); }
.section-header .chevron { transition: transform 200ms; font-size: 18px; color: var(--text-muted); }
.section.collapsed .section-header { border-radius: 12px; }
.section.collapsed .section-body { display: none; }
.section.collapsed .chevron { transform: rotate(-90deg); }
.section-body { background: var(--card); border: 1px solid var(--border); border-top: none; border-radius: 0 0 12px 12px; padding: 24px; }
/* Field grid */
.field-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field.full-width { grid-column: 1 / -1; }
.field label { font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
.field input, .field select, .field textarea { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; color: var(--text); font-size: 14px; font-family: inherit; outline: none; transition: border-color 200ms; }
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(88,101,242,0.2); }
.field textarea { min-height: 80px; resize: vertical; }
.field input.changed, .field select.changed, .field textarea.changed { border-color: var(--warning); }
.field .hint { font-size: 11px; color: var(--text-muted); }
/* Toggle switch */
.toggle-wrap { display: flex; align-items: center; gap: 12px; }
.toggle { position: relative; width: 44px; height: 24px; }
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle .slider { position: absolute; inset: 0; background: var(--border); border-radius: 12px; cursor: pointer; transition: background 200ms; }
.toggle .slider::before { content: ''; position: absolute; left: 3px; top: 3px; width: 18px; height: 18px; background: #fff; border-radius: 50%; transition: transform 200ms; }
.toggle input:checked + .slider { background: var(--accent); }
.toggle input:checked + .slider::before { transform: translateX(20px); }
/* Color picker */
.color-field { display: flex; align-items: center; gap: 10px; }
.color-field input[type="color"] { width: 40px; height: 40px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer; background: none; padding: 2px; }
/* Smart select */
.smart-select { position: relative; }
.smart-select-display { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; min-height: 42px; transition: border-color 200ms; }
.smart-select-display:hover { border-color: var(--accent); }
.smart-select-dropdown { position: absolute; top: 100%; left: 0; right: 0; background: var(--card); border: 1px solid var(--border); border-radius: 10px; margin-top: 4px; z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); max-height: 300px; overflow: hidden; display: flex; flex-direction: column; }
.smart-select-dropdown.hidden { display: none; }
.ss-search { background: var(--bg); border: none; border-bottom: 1px solid var(--border); padding: 10px 14px; color: var(--text); font-size: 13px; outline: none; }
.ss-list { overflow-y: auto; max-height: 250px; padding: 4px; }
.ss-option { padding: 8px 12px; border-radius: 6px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 13px; transition: background 200ms; }
.ss-option:hover { background: rgba(88,101,242,0.15); }
.ss-option.selected { background: rgba(88,101,242,0.2); }
.ss-option.ss-clear { color: var(--text-muted); font-style: italic; }
.ss-label { flex: 1; }
.ss-sub { font-size: 11px; color: var(--text-muted); }
.ss-id { font-size: 11px; color: var(--text-muted); font-family: monospace; }
.ss-placeholder { color: var(--text-muted); }
.ss-avatar { width: 20px; height: 20px; border-radius: 50%; }
.ss-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
/* Save bar */
.save-bar { position: fixed; bottom: 0; left: var(--sidebar-width); right: 0; background: var(--surface); border-top: 1px solid var(--border); padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; transform: translateY(100%); transition: transform 300ms ease; z-index: 100; }
.save-bar.visible { transform: translateY(0); }
.save-bar span { font-size: 13px; color: var(--warning); font-weight: 500; }
.save-actions { display: flex; gap: 8px; }
.save-actions button { padding: 8px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; transition: all 200ms; }
.save-actions button:first-child { background: var(--accent); color: #fff; }
.save-actions button:first-child:hover { background: var(--accent-hover); }
.save-actions button.secondary { background: var(--card); color: var(--text); border: 1px solid var(--border); }
.save-actions button.secondary:hover { border-color: var(--accent); }
.save-actions button.danger { background: var(--danger); color: #fff; }
.save-actions button.danger:hover { background: #c9363a; }
/* Toast */
#toast-container { position: fixed; top: 72px; right: 24px; z-index: 300; display: flex; flex-direction: column; gap: 8px; }
.toast { padding: 12px 20px; border-radius: 8px; font-size: 13px; font-weight: 500; animation: toast-in 300ms ease; }
.toast-success { background: rgba(87,242,135,0.15); color: var(--success); border: 1px solid rgba(87,242,135,0.3); }
.toast-warning { background: rgba(254,231,92,0.15); color: var(--warning); border: 1px solid rgba(254,231,92,0.3); }
.toast-error { background: rgba(237,66,69,0.15); color: var(--danger); border: 1px solid rgba(237,66,69,0.3); }
@keyframes toast-in { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
/* Modal */
.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 400; }
.modal.hidden { display: none; }
.modal-card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 24px; min-width: 340px; }
.modal-card h3 { margin-bottom: 16px; font-size: 16px; }
.modal-card input { width: 100%; padding: 10px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; color: var(--text); font-size: 14px; margin-bottom: 16px; }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
.modal-actions button { padding: 8px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; }
.modal-actions button:first-child { background: var(--accent); color: #fff; }
.modal-actions button.secondary { background: var(--card); color: var(--text); border: 1px solid var(--border); }
/* Loading */
.loading { position: fixed; inset: 0; background: var(--bg); display: flex; align-items: center; justify-content: center; z-index: 500; }
.loading.hidden { display: none; }
.spinner { width: 40px; height: 40px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }

View File

@@ -0,0 +1,311 @@
<!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 -->
<nav class="sidebar">
<div class="logo">Broccolini Settings</div>
<a href="#s-core" class="active">Core</a>
<a href="#s-channels">Channels</a>
<a href="#s-categories">Categories</a>
<a href="#s-gmail">Gmail</a>
<a href="#s-behavior">Ticket Behavior</a>
<a href="#s-threads">Staff Threads</a>
<a href="#s-pins">Pin Messages</a>
<a href="#s-surge">Surge Alerts</a>
<a href="#s-patterns">Pattern Detection</a>
<a href="#s-logging">Logging</a>
<a href="#s-automation">Automation</a>
<a href="#s-appearance">Appearance</a>
<a href="#s-staff">Staff</a>
<a href="#s-advanced">Advanced</a>
</nav>
<!-- Top bar -->
<div class="topbar">
<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">
<form action="/logout" method="POST" style="display:inline"><button type="submit">Logout</button></form>
</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 Escalation Category</label><input type="text" data-key="EMAIL_ESCALATED_CATEGORY_ID" data-smart="category"></div>
<div class="field"><label>Discord Escalation Category</label><input type="text" data-key="DISCORD_ESCALATED_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. Surge Alerts -->
<div class="section" id="s-surge">
<div class="section-header"><h2>Surge Alerts</h2><p>Ticket volume and staffing alerts</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><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 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></div>
</div>
<!-- 9. Pattern Detection -->
<div class="section" id="s-patterns">
<div class="section-header"><h2>Pattern Detection</h2><p>Thresholds for automated pattern alerts</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><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></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 style="color:var(--text-muted);font-size:13px;">Log channels are configured in the <a href="#s-channels" style="color:var(--accent);">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>Internal API Secret</label><input type="password" data-key="INTERNAL_API_SECRET"></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 onclick="saveConfig('apply')">Save &amp; Apply</button>
<button onclick="saveConfig('pending')" class="secondary">Save (pending restart)</button>
<button onclick="saveConfig('restart')" class="danger">Save &amp; Restart Now</button>
<button onclick="openScheduleModal()" class="secondary">Schedule restart...</button>
</div>
</div>
<!-- Schedule modal -->
<div id="schedule-modal" class="modal hidden">
<div class="modal-card">
<h3>Schedule restart</h3>
<input type="datetime-local" id="schedule-datetime">
<div class="modal-actions">
<button onclick="confirmScheduledRestart()">Schedule</button>
<button onclick="document.getElementById('schedule-modal').classList.add('hidden')" class="secondary">Cancel</button>
</div>
</div>
</div>
<script src="/js/discord.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,175 @@
let savedConfig = {};
let pendingChanges = {};
async function init() {
document.getElementById('loading').classList.remove('hidden');
try {
const [config] = await Promise.all([
fetch('/api/config').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);
initSmartSelects(config);
} catch (e) {
document.getElementById('bot-status-dot').className = 'dot offline';
document.getElementById('bot-status-text').textContent = 'Unreachable';
}
document.getElementById('loading').classList.add('hidden');
setupSectionToggles();
setupSaveBar();
}
function populateFields(config) {
document.querySelectorAll('[data-key]').forEach(el => {
const key = el.dataset.key;
const value = config[key] || '';
if (el.type === 'checkbox') {
el.checked = value === 'true' || value === true;
} else if (el.type === 'color') {
// Convert 0xRRGGBB to #RRGGBB
const num = parseInt(value) || 0;
el.value = '#' + num.toString(16).padStart(6, '0');
} else {
el.value = value;
}
el.addEventListener('change', () => handleFieldChange(el, key));
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 setupSectionToggles() {
document.querySelectorAll('.section-header').forEach(header => {
header.addEventListener('click', () => {
header.closest('.section').classList.toggle('collapsed');
});
});
// Sidebar navigation
document.querySelectorAll('.sidebar a').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = document.getElementById(link.getAttribute('href').slice(1));
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
target.classList.remove('collapsed');
}
document.querySelectorAll('.sidebar a').forEach(l => l.classList.remove('active'));
link.classList.add('active');
});
});
}
function markChanged(key, value) {
if (String(value) === String(savedConfig[key] || '')) {
delete pendingChanges[key];
} else {
pendingChanges[key] = value;
}
updateSaveBar();
}
function setupSaveBar() {
updateSaveBar();
}
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) {
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: { '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'));
showToast(`${data.applied.length} settings saved.`, 'success');
}
if (data.errors && data.errors.length > 0) {
showToast(`Errors: ${data.errors.join(', ')}`, 'error');
}
if (mode === 'restart') {
await fetch('/api/restart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'immediate' })
});
showToast('Restart initiated.', 'warning');
}
} catch (e) {
showToast('Failed to save. Bot may be unreachable.', 'error');
}
}
function openScheduleModal() {
const modal = document.getElementById('schedule-modal');
modal.classList.remove('hidden');
const dt = document.getElementById('schedule-datetime');
const min = new Date(Date.now() + 60000).toISOString().slice(0, 16);
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',
headers: { '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');
}
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);
}
document.addEventListener('DOMContentLoaded', init);

View File

@@ -0,0 +1,195 @@
// Discord guild data cache
let guildData = null;
let guildDataPromise = null;
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 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 renderCategorySelect(el, currentValue) {
const data = await fetchGuildData();
renderSmartSelect(el, data.categories.map(c => ({ id: c.id, label: c.name })), 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 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 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);
}
function renderSmartSelect(inputEl, options, currentValue) {
const wrapper = document.createElement('div');
wrapper.className = 'smart-select';
const current = options.find(o => o.id === currentValue);
const display = document.createElement('div');
display.className = 'smart-select-display';
display.innerHTML = current
? `<span class="ss-label">${current.label}</span><span class="ss-id">${current.id}</span>`
: `<span class="ss-placeholder">Not set</span>`;
const dropdown = document.createElement('div');
dropdown.className = 'smart-select-dropdown hidden';
const search = document.createElement('input');
search.type = 'text';
search.placeholder = 'Search...';
search.className = 'ss-search';
const list = document.createElement('div');
list.className = 'ss-list';
const clearOpt = document.createElement('div');
clearOpt.className = 'ss-option ss-clear';
clearOpt.textContent = 'Clear (not set)';
clearOpt.addEventListener('click', () => {
inputEl.value = '';
display.innerHTML = `<span class="ss-placeholder">Not set</span>`;
dropdown.classList.add('hidden');
inputEl.dispatchEvent(new Event('change'));
});
list.appendChild(clearOpt);
function renderOptions(filter = '') {
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 += `<img class="ss-avatar" src="${opt.avatar}" alt="">`;
if (opt.color && opt.color !== '#000000') inner += `<span class="ss-dot" style="background:${opt.color}"></span>`;
inner += `<span class="ss-label">${opt.label}</span>`;
if (opt.sub) inner += `<span class="ss-sub">${opt.sub}</span>`;
item.innerHTML = inner;
item.addEventListener('click', () => {
inputEl.value = opt.id;
display.innerHTML = `<span class="ss-label">${opt.label}</span><span class="ss-id">${opt.id}</span>`;
dropdown.classList.add('hidden');
inputEl.dispatchEvent(new Event('change'));
});
list.appendChild(item);
}
}
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');
});
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);
}
}
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 = '<span class="ss-placeholder">+ Add</span>';
const dropdown = document.createElement('div');
dropdown.className = 'smart-select-dropdown hidden';
const search = document.createElement('input');
search.type = 'text'; search.placeholder = 'Search...'; search.className = 'ss-search';
const list = document.createElement('div');
list.className = 'ss-list';
function renderOptions(filter = '') {
list.innerHTML = '';
const filtered = filter
? options.filter(o => !selected.has(o.id) && (o.label.toLowerCase().includes(filter.toLowerCase()) || o.id.includes(filter)))
: options.filter(o => !selected.has(o.id));
for (const opt of filtered.slice(0, 50)) {
const item = document.createElement('div');
item.className = 'ss-option';
let inner = '';
if (opt.avatar) inner += `<img class="ss-avatar" src="${opt.avatar}" alt="">`;
inner += `<span class="ss-label">${opt.label}</span>`;
if (opt.sub) inner += `<span class="ss-sub">${opt.sub}</span>`;
item.innerHTML = inner;
item.addEventListener('click', () => { selected.add(opt.id); renderChips(); renderOptions(search.value); updateInput(); });
list.appendChild(item);
}
}
renderOptions();
search.addEventListener('input', () => renderOptions(search.value));
addBtn.addEventListener('click', () => { dropdown.classList.toggle('hidden'); if (!dropdown.classList.contains('hidden')) search.focus(); });
document.addEventListener('click', (e) => { if (!wrapper.contains(e.target)) dropdown.classList.add('hidden'); });
dropdown.appendChild(search);
dropdown.appendChild(list);
wrapper.appendChild(chipsEl);
wrapper.appendChild(addBtn);
wrapper.appendChild(dropdown);
inputEl.style.display = 'none';
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
}
window.DiscordFields = { fetchGuildData, renderChannelSelect, renderCategorySelect, renderRoleSelect, renderMemberSelect, renderMultiMemberSelect };

View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Broccolini Settings - Login</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: #0f1117; color: #e0e0e0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-card { background: #1e2235; border: 1px solid #2a2d3e; border-radius: 16px; padding: 48px 40px; width: 380px; text-align: center; box-shadow: 0 8px 32px rgba(0,0,0,0.4); }
.login-card h1 { font-size: 22px; font-weight: 700; margin-bottom: 8px; }
.login-card p { font-size: 14px; color: #888; margin-bottom: 32px; }
.login-card input { width: 100%; padding: 12px 16px; background: #0f1117; border: 1px solid #2a2d3e; border-radius: 8px; color: #e0e0e0; font-size: 14px; margin-bottom: 16px; outline: none; transition: border-color 200ms; }
.login-card input:focus { border-color: #5865f2; }
.login-card button { width: 100%; padding: 12px; background: #5865f2; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: background 200ms; }
.login-card button:hover { background: #4752c4; }
.error { color: #ed4245; font-size: 13px; margin-top: 8px; display: none; }
</style>
</head>
<body>
<div class="login-card">
<h1>Broccolini Settings</h1>
<p>Enter the admin password to continue.</p>
<form id="login-form">
<input type="password" name="password" id="password" placeholder="Password" autofocus required>
<button type="submit">Sign in</button>
<div class="error" id="error">Invalid password</div>
</form>
</div>
<script>
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('password').value;
const res = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
window.location.href = '/';
} else {
document.getElementById('error').style.display = 'block';
}
});
</script>
</body>
</html>

98
settings-site/server.js Normal file
View File

@@ -0,0 +1,98 @@
require('dotenv').config({ path: process.env.ENV_FILE || '../.env' });
const express = require('express');
const session = require('express-session');
const path = require('path');
const fetch = require('node-fetch');
const app = express();
const PORT = parseInt(process.env.SETTINGS_PORT) || 12752;
const INTERNAL_URL = `http://127.0.0.1:${process.env.INTERNAL_API_PORT || 12753}/internal`;
const SECRET = process.env.INTERNAL_API_SECRET;
const ADMIN_PASSWORD = process.env.SETTINGS_ADMIN_PASSWORD;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.use(session({
secret: SECRET || 'fallback-secret-change-me',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: false, // set true if behind HTTPS proxy
maxAge: 8 * 60 * 60 * 1000 // 8 hours
}
}));
// Auth middleware
function requireAuth(req, res, next) {
if (req.session?.authed) return next();
res.redirect('/login');
}
// Internal API proxy helper
async function callBot(method, apiPath, body) {
const res = await fetch(`${INTERNAL_URL}${apiPath}`, {
method,
headers: {
'Content-Type': 'application/json',
'x-internal-secret': SECRET
},
body: body ? JSON.stringify(body) : undefined
});
return res.json();
}
// Routes
app.get('/login', (req, res) => {
if (req.session?.authed) return res.redirect('/');
res.sendFile(path.join(__dirname, 'public', 'login.html'));
});
app.post('/login', (req, res) => {
if (!ADMIN_PASSWORD) return res.status(503).json({ error: 'SETTINGS_ADMIN_PASSWORD not set' });
if (req.body.password === ADMIN_PASSWORD) {
req.session.authed = true;
return res.json({ ok: true });
}
res.status(401).json({ error: 'Invalid password' });
});
app.post('/logout', (req, res) => {
req.session.destroy();
res.redirect('/login');
});
app.get('/', requireAuth, (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Proxy to bot internal API
app.get('/api/config', requireAuth, async (req, res) => {
try { res.json(await callBot('GET', '/config')); }
catch (e) { res.status(502).json({ error: 'Bot unreachable' }); }
});
app.post('/api/config', requireAuth, async (req, res) => {
try { res.json(await callBot('POST', '/config', req.body)); }
catch (e) { res.status(502).json({ error: 'Bot unreachable' }); }
});
app.get('/api/discord/guild', requireAuth, async (req, res) => {
try { res.json(await callBot('GET', '/discord/guild')); }
catch (e) { res.status(502).json({ error: 'Bot unreachable' }); }
});
app.post('/api/restart', requireAuth, async (req, res) => {
try { res.json(await callBot('POST', '/restart', req.body)); }
catch (e) { res.status(502).json({ error: 'Bot unreachable' }); }
});
app.get('/api/restart/status', requireAuth, async (req, res) => {
try { res.json(await callBot('GET', '/restart/status')); }
catch (e) { res.status(502).json({ error: 'Bot unreachable' }); }
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`[settings] running on port ${PORT}`);
});