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,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 };