feat: dashboard
This commit is contained in:
547
src/web/assets/dashboard.js
Normal file
547
src/web/assets/dashboard.js
Normal file
@@ -0,0 +1,547 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user