Files
joel/src/web/assets/dashboard.js
2026-02-26 14:45:57 +01:00

548 lines
17 KiB
JavaScript

const activeTabClasses = ["tab-btn-active"];
const inactiveTabClasses = ["tab-btn-inactive"];
const BOT_OPTIONS_PRESETS = {
default: {
response_mode: "free-will",
free_will_chance: 2,
memory_chance: 30,
mention_probability: 0,
gif_search_enabled: false,
image_gen_enabled: false,
nsfw_image_enabled: false,
spontaneous_posts_enabled: true,
},
lurker: {
response_mode: "mention-only",
free_will_chance: 0,
memory_chance: 18,
mention_probability: 0,
gif_search_enabled: false,
image_gen_enabled: false,
nsfw_image_enabled: false,
spontaneous_posts_enabled: false,
},
balanced: {
response_mode: "free-will",
free_will_chance: 6,
memory_chance: 45,
mention_probability: 8,
gif_search_enabled: true,
image_gen_enabled: false,
nsfw_image_enabled: false,
spontaneous_posts_enabled: true,
},
chaos: {
response_mode: "free-will",
free_will_chance: 22,
memory_chance: 65,
mention_probability: 35,
gif_search_enabled: true,
image_gen_enabled: true,
nsfw_image_enabled: true,
spontaneous_posts_enabled: true,
},
};
function switchTab(button, tabName) {
document.querySelectorAll(".tab-btn").forEach((tab) => {
tab.classList.remove(...activeTabClasses);
tab.classList.add(...inactiveTabClasses);
});
document.querySelectorAll(".tab-panel").forEach((panel) => {
panel.classList.add("hidden");
});
button.classList.remove(...inactiveTabClasses);
button.classList.add(...activeTabClasses);
document.getElementById(`tab-${tabName}`)?.classList.remove("hidden");
}
function setActiveGuildById(guildId) {
document.querySelectorAll(".guild-list-item").forEach((item) => {
const isActive = item.dataset.guildId === guildId;
item.classList.toggle("guild-item-active", isActive);
item.classList.toggle("guild-item-inactive", !isActive);
});
}
function setActiveGuildFromPath() {
const match = window.location.pathname.match(/\/dashboard\/guild\/([^/]+)/);
if (!match) {
setActiveGuildById("");
return;
}
setActiveGuildById(match[1]);
}
document.addEventListener("click", (event) => {
const item = event.target.closest(".guild-list-item");
if (!item) {
return;
}
setActiveGuildById(item.dataset.guildId);
});
document.addEventListener("htmx:afterSwap", (event) => {
if (event.target && event.target.id === "guild-main-content") {
setActiveGuildFromPath();
initBotOptionsUI(event.target);
}
});
setActiveGuildFromPath();
initBotOptionsUI(document);
function showNotification(message, type) {
const existing = document.querySelector(".notification");
if (existing) {
existing.remove();
}
const notification = document.createElement("div");
notification.className = `notification fixed bottom-5 right-5 z-[200] rounded-lg px-5 py-3 text-sm font-medium text-white shadow-lg ${type === "success" ? "bg-emerald-500" : "bg-rose-500"}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 3000);
}
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
const modal = document.querySelector(".modal-overlay");
if (modal) {
modal.remove();
}
}
});
function initBotOptionsUI(scope) {
const root = scope instanceof Element ? scope : document;
const forms = root.querySelectorAll("[data-bot-options-form]");
forms.forEach((form) => {
if (form.dataset.optionsInitialized === "1") {
return;
}
form.dataset.optionsInitialized = "1";
bindSyncedPercentInputs(form);
bindPresetButtons(form);
bindChannelManager(form);
bindDynamicState(form);
updateDynamicState(form);
});
}
function bindSyncedPercentInputs(form) {
const ranges = form.querySelectorAll("[data-options-range]");
ranges.forEach((range) => {
const key = range.dataset.optionsRange;
if (!key) {
return;
}
const numberInput = form.querySelector(`[data-options-number="${key}"]`);
const valueLabel = form.querySelector(`[data-options-value="${key}"]`);
const syncToValue = (rawValue, fromNumberInput) => {
const parsed = Number.parseInt(String(rawValue), 10);
const safe = Number.isFinite(parsed) ? Math.max(0, Math.min(100, parsed)) : 0;
range.value = String(safe);
if (numberInput) {
numberInput.value = String(safe);
}
if (valueLabel) {
valueLabel.textContent = `${safe}%`;
}
if (fromNumberInput && document.activeElement !== numberInput) {
numberInput.value = String(safe);
}
updateDynamicState(form);
};
range.addEventListener("input", () => syncToValue(range.value, false));
if (numberInput) {
numberInput.addEventListener("input", () => syncToValue(numberInput.value, true));
numberInput.addEventListener("blur", () => syncToValue(numberInput.value, true));
}
});
}
function bindPresetButtons(form) {
const buttons = form.parentElement?.querySelectorAll("[data-options-preset]") || [];
buttons.forEach((button) => {
button.addEventListener("click", () => {
const presetKey = button.dataset.optionsPreset;
const preset = BOT_OPTIONS_PRESETS[presetKey] || null;
if (!preset) {
return;
}
setRadioValue(form, "response_mode", preset.response_mode);
setPercentValue(form, "free_will_chance", preset.free_will_chance);
setPercentValue(form, "memory_chance", preset.memory_chance);
setPercentValue(form, "mention_probability", preset.mention_probability);
setCheckboxValue(form, "gif_search_enabled", preset.gif_search_enabled);
setCheckboxValue(form, "image_gen_enabled", preset.image_gen_enabled);
setCheckboxValue(form, "nsfw_image_enabled", preset.nsfw_image_enabled);
setCheckboxValue(form, "spontaneous_posts_enabled", preset.spontaneous_posts_enabled);
updateDynamicState(form);
});
});
}
function bindDynamicState(form) {
form.addEventListener("change", () => {
updateDynamicState(form);
});
}
async function bindChannelManager(form) {
const manager = form.querySelector("[data-channel-manager]");
if (!manager || manager.dataset.channelManagerInitialized === "1") {
return;
}
manager.dataset.channelManagerInitialized = "1";
const guildId = manager.dataset.guildId;
const restrictedInput = form.querySelector("[data-restricted-channel-input]");
const spontaneousInput = form.querySelector("[data-spontaneous-channel-input]");
const loadingNode = manager.querySelector("[data-channel-loading]");
const contentNode = manager.querySelector("[data-channel-content]");
const searchInput = manager.querySelector("[data-channel-search]");
const restrictedList = manager.querySelector("[data-restricted-channel-list]");
const spontaneousList = manager.querySelector("[data-spontaneous-channel-list]");
if (
!guildId ||
!restrictedInput ||
!spontaneousInput ||
!loadingNode ||
!contentNode ||
!searchInput ||
!restrictedList ||
!spontaneousList
) {
return;
}
loadingNode.classList.remove("hidden");
contentNode.classList.add("hidden");
try {
const response = await fetch(`/api/guilds/${guildId}/channels`, {
credentials: "same-origin",
});
if (!response.ok) {
throw new Error(`Failed to load channels (${response.status})`);
}
const channels = await response.json();
if (!Array.isArray(channels)) {
throw new Error("Invalid channel payload");
}
const selectedSpontaneous = parseChannelIdsFromText(spontaneousInput.value);
const render = () => {
const query = searchInput.value.trim().toLowerCase();
const filtered = channels.filter((channel) => {
const haystack = `${channel.name} ${channel.id}`.toLowerCase();
return !query || haystack.includes(query);
});
renderRestrictedChannelButtons({
listNode: restrictedList,
channels: filtered,
selectedChannelId: restrictedInput.value.trim(),
onSelect: (channel) => {
restrictedInput.value = restrictedInput.value === channel.id ? "" : channel.id;
render();
},
});
renderSpontaneousChannelButtons({
listNode: spontaneousList,
channels: filtered,
selectedIds: selectedSpontaneous,
onToggle: (channel) => {
if (selectedSpontaneous.has(channel.id)) {
selectedSpontaneous.delete(channel.id);
} else {
selectedSpontaneous.add(channel.id);
}
spontaneousInput.value = [...selectedSpontaneous].join("\n");
render();
},
});
};
searchInput.addEventListener("input", render);
render();
loadingNode.classList.add("hidden");
contentNode.classList.remove("hidden");
} catch {
loadingNode.textContent = "Failed to load channels.";
loadingNode.classList.remove("text-slate-400");
loadingNode.classList.add("text-rose-300");
}
}
function renderRestrictedChannelButtons({ listNode, channels, selectedChannelId, onSelect }) {
listNode.replaceChildren();
const clearButton = document.createElement("button");
clearButton.type = "button";
clearButton.textContent = "Allow all channels";
clearButton.className =
selectedChannelId.length === 0
? "rounded-lg border border-indigo-500 bg-indigo-500/20 px-2.5 py-1.5 text-xs font-medium text-indigo-200"
: "rounded-lg border border-slate-700 bg-slate-900 px-2.5 py-1.5 text-xs font-medium text-slate-200 hover:bg-slate-800";
clearButton.addEventListener("click", () => {
onSelect({ id: "" });
});
listNode.appendChild(clearButton);
channels.forEach((channel) => {
const button = document.createElement("button");
button.type = "button";
button.textContent = `#${channel.name}`;
const isSelected = selectedChannelId === channel.id;
const isDisabled = !channel.writable;
button.disabled = isDisabled;
button.className = isSelected
? "rounded-lg border border-indigo-500 bg-indigo-500/20 px-2.5 py-1.5 text-xs font-medium text-indigo-200"
: isDisabled
? "rounded-lg border border-slate-800 bg-slate-900/40 px-2.5 py-1.5 text-xs font-medium text-slate-500"
: "rounded-lg border border-slate-700 bg-slate-900 px-2.5 py-1.5 text-xs font-medium text-slate-200 hover:bg-slate-800";
button.title = isDisabled
? "Bot cannot send messages in this channel"
: `${channel.name} (${channel.id})`;
button.addEventListener("click", () => onSelect(channel));
listNode.appendChild(button);
});
if (channels.length === 0) {
const empty = document.createElement("p");
empty.className = "w-full text-xs text-slate-500";
empty.textContent = "No channels match your search.";
listNode.appendChild(empty);
}
}
function renderSpontaneousChannelButtons({ listNode, channels, selectedIds, onToggle }) {
listNode.replaceChildren();
channels.forEach((channel) => {
const button = document.createElement("button");
button.type = "button";
button.textContent = `#${channel.name}`;
const isSelected = selectedIds.has(channel.id);
const isDisabled = !channel.writable;
button.disabled = isDisabled;
button.className = isSelected
? "rounded-lg border border-emerald-500 bg-emerald-500/20 px-2.5 py-1.5 text-xs font-medium text-emerald-200"
: isDisabled
? "rounded-lg border border-slate-800 bg-slate-900/40 px-2.5 py-1.5 text-xs font-medium text-slate-500"
: "rounded-lg border border-slate-700 bg-slate-900 px-2.5 py-1.5 text-xs font-medium text-slate-200 hover:bg-slate-800";
button.title = isDisabled
? "Bot cannot send messages in this channel"
: `${channel.name} (${channel.id})`;
button.addEventListener("click", () => {
onToggle(channel);
});
listNode.appendChild(button);
});
if (channels.length === 0) {
const empty = document.createElement("p");
empty.className = "w-full text-xs text-slate-500";
empty.textContent = "No channels match your search.";
listNode.appendChild(empty);
}
}
function parseChannelIdsFromText(raw) {
const set = new Set();
if (!raw || typeof raw !== "string") {
return set;
}
raw
.split(/[\s,]+/)
.map((entry) => entry.trim())
.filter(Boolean)
.forEach((channelId) => {
set.add(channelId);
});
return set;
}
function updateDynamicState(form) {
const state = readFormState(form);
const score = computeBehaviorScore(state);
const scoreBar = form.parentElement?.querySelector("[data-options-score-bar]") || null;
const scoreLabel = form.parentElement?.querySelector("[data-options-score-label]") || null;
if (scoreBar) {
scoreBar.style.width = `${score}%`;
}
if (scoreLabel) {
scoreLabel.textContent = `${score}% · ${getBehaviorTier(score)}`;
}
const responseModeInputs = form.querySelectorAll("[data-options-response-mode]");
responseModeInputs.forEach((input) => {
const wrapper = input.closest("label");
if (!wrapper) {
return;
}
const isActive = input.checked;
wrapper.classList.toggle("border-indigo-500", isActive);
wrapper.classList.toggle("bg-indigo-500/10", isActive);
wrapper.classList.toggle("border-slate-700", !isActive);
wrapper.classList.toggle("bg-slate-900", !isActive);
});
const freeWillRange = form.querySelector('[data-options-range="free_will_chance"]');
const freeWillNumber = form.querySelector('[data-options-number="free_will_chance"]');
const freeWillDisabled = state.response_mode !== "free-will";
if (freeWillRange) {
freeWillRange.disabled = freeWillDisabled;
}
if (freeWillNumber) {
freeWillNumber.disabled = freeWillDisabled;
}
const nsfwToggle = form.querySelector("[data-options-nsfw-toggle]");
const nsfwRow = form.querySelector("[data-options-nsfw-row]");
if (nsfwToggle && nsfwRow) {
nsfwToggle.disabled = !state.image_gen_enabled;
nsfwRow.classList.toggle("opacity-50", !state.image_gen_enabled);
nsfwRow.classList.toggle("pointer-events-none", !state.image_gen_enabled);
if (!state.image_gen_enabled) {
nsfwToggle.checked = false;
}
}
const intervalInputs = form.querySelectorAll("[data-options-interval]");
intervalInputs.forEach((input) => {
input.disabled = !state.spontaneous_posts_enabled;
});
}
function readFormState(form) {
return {
response_mode: getCheckedValue(form, "response_mode", "free-will"),
free_will_chance: getPercentValue(form, "free_will_chance"),
memory_chance: getPercentValue(form, "memory_chance"),
mention_probability: getPercentValue(form, "mention_probability"),
gif_search_enabled: getCheckboxValue(form, "gif_search_enabled"),
image_gen_enabled: getCheckboxValue(form, "image_gen_enabled"),
nsfw_image_enabled: getCheckboxValue(form, "nsfw_image_enabled"),
spontaneous_posts_enabled: getCheckboxValue(form, "spontaneous_posts_enabled"),
};
}
function computeBehaviorScore(state) {
const modeScore = state.response_mode === "free-will" ? 18 : 6;
const autonomyScore = Math.round(state.free_will_chance * 0.28);
const memoryScore = Math.round(state.memory_chance * 0.2);
const mentionScore = Math.round(state.mention_probability * 0.14);
const mediaScore =
(state.gif_search_enabled ? 8 : 0) +
(state.image_gen_enabled ? 10 : 0) +
(state.nsfw_image_enabled ? 8 : 0);
const spontaneityScore = state.spontaneous_posts_enabled ? 12 : 0;
return Math.max(0, Math.min(100, modeScore + autonomyScore + memoryScore + mentionScore + mediaScore + spontaneityScore));
}
function getBehaviorTier(score) {
if (score < 30) {
return "Conservative";
}
if (score < 60) {
return "Balanced";
}
if (score < 80) {
return "Aggressive";
}
return "Maximum Chaos";
}
function getPercentValue(form, key) {
const range = form.querySelector(`[data-options-range="${key}"]`);
const parsed = Number.parseInt(range?.value || "0", 10);
if (!Number.isFinite(parsed)) {
return 0;
}
return Math.max(0, Math.min(100, parsed));
}
function setPercentValue(form, key, value) {
const safe = Math.max(0, Math.min(100, Number.parseInt(String(value), 10) || 0));
const range = form.querySelector(`[data-options-range="${key}"]`);
const numberInput = form.querySelector(`[data-options-number="${key}"]`);
const label = form.querySelector(`[data-options-value="${key}"]`);
if (range) {
range.value = String(safe);
}
if (numberInput) {
numberInput.value = String(safe);
}
if (label) {
label.textContent = `${safe}%`;
}
}
function getCheckboxValue(form, key) {
const input = form.querySelector(`[name="${key}"][type="checkbox"]`);
return Boolean(input?.checked);
}
function setCheckboxValue(form, key, value) {
const input = form.querySelector(`[name="${key}"][type="checkbox"]`);
if (input) {
input.checked = Boolean(value);
}
}
function getCheckedValue(form, name, fallback) {
const checked = form.querySelector(`[name="${name}"]:checked`);
return checked?.value || fallback;
}
function setRadioValue(form, name, value) {
const radio = form.querySelector(`[name="${name}"][value="${value}"]`);
if (radio) {
radio.checked = true;
}
}
window.switchTab = switchTab;
window.showNotification = showNotification;