settings-site: phase 4 client refactor (split app.js into focused modules, shared dropdown helper, strict-CSP-ready)

This commit is contained in:
2026-04-18 18:04:46 +00:00
parent 0ac6debcf9
commit e2443fd94a
8 changed files with 890 additions and 757 deletions

View File

@@ -1,195 +1,253 @@
// Discord guild data cache
let guildData = null;
let guildDataPromise = null;
(function () {
'use strict';
async function fetchGuildData() {
if (guildData) return guildData;
if (guildDataPromise) return guildDataPromise;
guildDataPromise = fetch('/api/discord/guild')
.then(r => r.json())
.then(data => { guildData = data; return data; })
.catch(() => ({ channels: [], roles: [], members: [], categories: [] }));
return guildDataPromise;
}
let guildData = null;
let guildDataPromise = null;
async function renderChannelSelect(el, currentValue, filter) {
const data = await fetchGuildData();
const channels = filter ? data.channels.filter(filter) : data.channels;
renderSmartSelect(el, channels.map(c => ({
id: c.id,
label: `#${c.name}`,
sub: c.parentId ? (data.categories.find(cat => cat.id === c.parentId)?.name || null) : null
})), currentValue);
}
async function fetchGuildData() {
if (guildData) return guildData;
if (guildDataPromise) return guildDataPromise;
guildDataPromise = fetch('/api/discord/guild')
.then(r => r.json())
.then(data => { guildData = data; return data; })
.catch(() => ({ channels: [], roles: [], members: [], categories: [] }));
return guildDataPromise;
}
async function renderCategorySelect(el, currentValue) {
const data = await fetchGuildData();
renderSmartSelect(el, data.categories.map(c => ({ id: c.id, label: c.name })), currentValue);
}
async function renderChannelSelect(el, currentValue, filter) {
const data = await fetchGuildData();
const channels = filter ? data.channels.filter(filter) : data.channels;
renderSmartSelect(el, channels.map(c => ({
id: c.id,
label: `#${c.name}`,
sub: c.parentId ? (data.categories.find(cat => cat.id === c.parentId)?.name || null) : null
})), currentValue);
}
async function renderRoleSelect(el, currentValue) {
const data = await fetchGuildData();
renderSmartSelect(el, data.roles.map(r => ({ id: r.id, label: `@${r.name}`, color: r.color })), currentValue);
}
async function renderCategorySelect(el, currentValue) {
const data = await fetchGuildData();
renderSmartSelect(el, data.categories.map(c => ({ id: c.id, label: c.name })), currentValue);
}
async function renderMemberSelect(el, currentValue) {
const data = await fetchGuildData();
renderSmartSelect(el, data.members.map(m => ({
id: m.id, label: m.displayName, sub: `@${m.username}`, avatar: m.avatar
})), currentValue);
}
async function renderRoleSelect(el, currentValue) {
const data = await fetchGuildData();
renderSmartSelect(el, data.roles.map(r => ({ id: r.id, label: `@${r.name}`, color: r.color })), currentValue);
}
async function renderMultiMemberSelect(el, currentValue) {
const data = await fetchGuildData();
const currentIds = (currentValue || '').split(',').map(s => s.trim()).filter(Boolean);
renderMultiSelect(el, data.members.map(m => ({
id: m.id, label: m.displayName, sub: `@${m.username}`, avatar: m.avatar
})), currentIds);
}
async function renderMemberSelect(el, currentValue) {
const data = await fetchGuildData();
renderSmartSelect(el, data.members.map(m => ({
id: m.id, label: m.displayName, sub: `@${m.username}`, avatar: m.avatar
})), currentValue);
}
function renderSmartSelect(inputEl, options, currentValue) {
const wrapper = document.createElement('div');
wrapper.className = 'smart-select';
async function renderMultiMemberSelect(el, currentValue) {
const data = await fetchGuildData();
const currentIds = (currentValue || '').split(',').map(s => s.trim()).filter(Boolean);
renderMultiSelect(el, data.members.map(m => ({
id: m.id, label: m.displayName, sub: `@${m.username}`, avatar: m.avatar
})), currentIds);
}
const current = options.find(o => o.id === currentValue);
const display = document.createElement('div');
display.className = 'smart-select-display';
display.innerHTML = current
? `<span class="ss-label">${current.label}</span><span class="ss-id">${current.id}</span>`
: `<span class="ss-placeholder">Not set</span>`;
function buildOptionRow(opt, { selected = false } = {}) {
const item = document.createElement('div');
item.className = 'ss-option' + (selected ? ' selected' : '');
if (opt.avatar) {
const img = document.createElement('img');
img.className = 'ss-avatar';
img.src = opt.avatar;
img.alt = '';
item.appendChild(img);
}
if (opt.color && opt.color !== '#000000') {
const dot = document.createElement('span');
dot.className = 'ss-dot';
dot.style.background = opt.color;
item.appendChild(dot);
}
const label = document.createElement('span');
label.className = 'ss-label';
label.textContent = opt.label;
item.appendChild(label);
if (opt.sub) {
const sub = document.createElement('span');
sub.className = 'ss-sub';
sub.textContent = opt.sub;
item.appendChild(sub);
}
return item;
}
const dropdown = document.createElement('div');
dropdown.className = 'smart-select-dropdown hidden';
function setDisplayValue(displayEl, opt) {
displayEl.replaceChildren();
const labelSpan = document.createElement('span');
labelSpan.className = 'ss-label';
labelSpan.textContent = opt.label;
const idSpan = document.createElement('span');
idSpan.className = 'ss-id';
idSpan.textContent = opt.id;
displayEl.appendChild(labelSpan);
displayEl.appendChild(idSpan);
}
const search = document.createElement('input');
search.type = 'text';
search.placeholder = 'Search...';
search.className = 'ss-search';
function setDisplayPlaceholder(displayEl, text) {
displayEl.replaceChildren();
const placeholder = document.createElement('span');
placeholder.className = 'ss-placeholder';
placeholder.textContent = text;
displayEl.appendChild(placeholder);
}
const list = document.createElement('div');
list.className = 'ss-list';
function createDropdown(options, opts) {
const { multi = false, getCurrentId = () => null, isExcluded = () => false, onChoose, onClear } = opts;
const clearOpt = document.createElement('div');
clearOpt.className = 'ss-option ss-clear';
clearOpt.textContent = 'Clear (not set)';
clearOpt.addEventListener('click', () => {
inputEl.value = '';
display.innerHTML = `<span class="ss-placeholder">Not set</span>`;
dropdown.classList.add('hidden');
inputEl.dispatchEvent(new Event('change'));
});
list.appendChild(clearOpt);
const dropdown = document.createElement('div');
dropdown.className = 'smart-select-dropdown hidden';
function renderOptions(filter = '') {
while (list.children.length > 1) list.removeChild(list.lastChild);
const filtered = filter
? options.filter(o => o.label.toLowerCase().includes(filter.toLowerCase()) || (o.sub || '').toLowerCase().includes(filter.toLowerCase()) || o.id.includes(filter))
: options;
for (const opt of filtered.slice(0, 50)) {
const item = document.createElement('div');
item.className = 'ss-option' + (opt.id === inputEl.value ? ' selected' : '');
let inner = '';
if (opt.avatar) inner += `<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', () => {
const search = document.createElement('input');
search.type = 'text';
search.placeholder = 'Search...';
search.className = 'ss-search';
const list = document.createElement('div');
list.className = 'ss-list';
function renderOptions(filter = '') {
list.replaceChildren();
if (!multi && onClear) {
const clearOpt = document.createElement('div');
clearOpt.className = 'ss-option ss-clear';
clearOpt.textContent = 'Clear (not set)';
clearOpt.addEventListener('click', onClear);
list.appendChild(clearOpt);
}
const lq = filter.toLowerCase();
const filtered = options.filter(o => {
if (isExcluded(o.id)) return false;
if (!filter) return true;
if (multi) {
return o.label.toLowerCase().includes(lq) || o.id.includes(filter);
}
return o.label.toLowerCase().includes(lq)
|| (o.sub || '').toLowerCase().includes(lq)
|| o.id.includes(filter);
});
const currentId = getCurrentId();
for (const opt of filtered.slice(0, 50)) {
const item = buildOptionRow(opt, { selected: !multi && opt.id === currentId });
item.addEventListener('click', () => onChoose(opt));
list.appendChild(item);
}
}
search.addEventListener('input', () => renderOptions(search.value));
dropdown.appendChild(search);
dropdown.appendChild(list);
renderOptions();
return { dropdown, search, list, renderOptions };
}
function renderSmartSelect(inputEl, options, currentValue) {
const wrapper = document.createElement('div');
wrapper.className = 'smart-select';
const display = document.createElement('div');
display.className = 'smart-select-display';
const current = options.find(o => o.id === currentValue);
if (current) setDisplayValue(display, current);
else setDisplayPlaceholder(display, 'Not set');
const { dropdown, search } = createDropdown(options, {
multi: false,
getCurrentId: () => inputEl.value,
onChoose: (opt) => {
inputEl.value = opt.id;
display.innerHTML = `<span class="ss-label">${opt.label}</span><span class="ss-id">${opt.id}</span>`;
setDisplayValue(display, opt);
dropdown.classList.add('hidden');
inputEl.dispatchEvent(new Event('change'));
});
list.appendChild(item);
},
onClear: () => {
inputEl.value = '';
setDisplayPlaceholder(display, 'Not set');
dropdown.classList.add('hidden');
inputEl.dispatchEvent(new Event('change'));
}
});
display.addEventListener('click', () => {
dropdown.classList.toggle('hidden');
if (!dropdown.classList.contains('hidden')) search.focus();
});
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target)) dropdown.classList.add('hidden');
});
wrapper.appendChild(display);
wrapper.appendChild(dropdown);
inputEl.style.display = 'none';
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
}
function renderMultiSelect(inputEl, options, currentIds) {
const wrapper = document.createElement('div');
wrapper.className = 'smart-select';
const selected = new Set(currentIds);
function updateInput() {
inputEl.value = [...selected].join(',');
inputEl.dispatchEvent(new Event('change'));
}
}
renderOptions();
search.addEventListener('input', () => renderOptions(search.value));
display.addEventListener('click', () => {
dropdown.classList.toggle('hidden');
if (!dropdown.classList.contains('hidden')) search.focus();
});
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target)) dropdown.classList.add('hidden');
});
const chipsEl = document.createElement('div');
chipsEl.className = 'ss-chips';
dropdown.appendChild(search);
dropdown.appendChild(list);
wrapper.appendChild(display);
wrapper.appendChild(dropdown);
inputEl.style.display = 'none';
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
}
function renderMultiSelect(inputEl, options, currentIds) {
const wrapper = document.createElement('div');
wrapper.className = 'smart-select';
const selected = new Set(currentIds);
function updateInput() {
inputEl.value = [...selected].join(',');
inputEl.dispatchEvent(new Event('change'));
}
function renderChips() {
chipsEl.innerHTML = '';
for (const id of selected) {
const opt = options.find(o => o.id === id);
const chip = document.createElement('span');
chip.className = 'ss-option selected';
chip.style.cssText = 'display:inline-flex;padding:4px 8px;margin:2px;border-radius:12px;font-size:12px;cursor:pointer;';
chip.textContent = opt ? opt.label : id;
chip.title = 'Click to remove';
chip.addEventListener('click', () => { selected.delete(id); renderChips(); updateInput(); });
chipsEl.appendChild(chip);
function renderChips() {
chipsEl.replaceChildren();
for (const id of selected) {
const opt = options.find(o => o.id === id);
const chip = document.createElement('span');
chip.className = 'ss-option ss-chip selected';
chip.textContent = opt ? opt.label : id;
chip.title = 'Click to remove';
chip.addEventListener('click', () => { selected.delete(id); renderChips(); updateInput(); });
chipsEl.appendChild(chip);
}
}
const addBtn = document.createElement('div');
addBtn.className = 'smart-select-display';
setDisplayPlaceholder(addBtn, '+ Add');
const { dropdown, search, renderOptions } = createDropdown(options, {
multi: true,
isExcluded: (id) => selected.has(id),
onChoose: (opt) => {
selected.add(opt.id);
renderChips();
renderOptions(search.value);
updateInput();
}
});
renderChips();
addBtn.addEventListener('click', () => {
dropdown.classList.toggle('hidden');
if (!dropdown.classList.contains('hidden')) search.focus();
});
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target)) dropdown.classList.add('hidden');
});
wrapper.appendChild(chipsEl);
wrapper.appendChild(addBtn);
wrapper.appendChild(dropdown);
inputEl.style.display = 'none';
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
}
const chipsEl = document.createElement('div');
chipsEl.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;';
renderChips();
const addBtn = document.createElement('div');
addBtn.className = 'smart-select-display';
addBtn.innerHTML = '<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 };
window.DiscordFields = { fetchGuildData, renderChannelSelect, renderCategorySelect, renderRoleSelect, renderMemberSelect, renderMultiMemberSelect };
})();