feat: add Mood Activities editor to Web UI Status tab

Collapsible section in the Status tab with:
- Normal and Evil mood sections, each collapsible
- Per-mood expandable rows showing songs (🎵) and games (🎮)
- Inline editing: change type, name, weight
- Add/remove entries per mood
- Save via API with client-side validation
- Reload from disk button
- Lazy-loads data only when section is expanded
This commit is contained in:
2026-04-24 13:46:04 +03:00
parent 0f39ccd3c4
commit 9293aec301

View File

@@ -450,6 +450,46 @@
color: #ddd; color: #ddd;
} }
/* Mood Activities Editor */
.act-mood-row {
margin-bottom: 0.5rem;
border: 1px solid #3a3a3a;
border-radius: 4px;
overflow: hidden;
}
.act-mood-header {
cursor: pointer;
user-select: none;
padding: 0.5rem 0.75rem;
background: #2a2a2a;
display: flex;
align-items: center;
gap: 0.5rem;
}
.act-mood-header:hover { background: #333; }
.act-mood-header .act-mood-name { font-weight: bold; min-width: 120px; }
.act-mood-header .act-mood-stats { color: #888; font-size: 0.8rem; }
.act-mood-content { display: none; padding: 0.75rem; background: #1e1e1e; }
.act-entry {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0;
border-bottom: 1px solid #333;
}
.act-entry:last-child { border-bottom: none; }
.act-entry-icon { font-size: 1.1rem; min-width: 24px; text-align: center; }
.act-entry input[type="text"] { flex: 1; }
.act-entry input[type="number"] { width: 55px; }
.act-entry select { width: 110px; }
.act-toolbar {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid #444;
}
.tab-content { .tab-content {
display: none; display: none;
} }
@@ -1328,6 +1368,40 @@
<div id="prompt-cat-info" style="margin-bottom: 0.5rem; font-size: 0.85rem; color: #aaa;"></div> <div id="prompt-cat-info" style="margin-bottom: 0.5rem; font-size: 0.85rem; color: #aaa;"></div>
<pre id="last-prompt" style="white-space: pre-wrap; word-break: break-word;"></pre> <pre id="last-prompt" style="white-space: pre-wrap; word-break: break-word;"></pre>
</div> </div>
<!-- Mood Activities Section (collapsible) -->
<div class="section">
<div style="cursor: pointer; user-select: none;" onclick="activitiesToggle()">
<h3 style="display: inline;"><span id="activities-toggle-icon"></span> 🎵 Mood Activities</h3>
<span id="activities-summary" style="color: #888; font-size: 0.85rem; margin-left: 0.5rem;"></span>
</div>
<div id="activities-body" style="display: none; margin-top: 1rem;">
<div style="margin-bottom: 1rem; display: flex; gap: 0.5rem; align-items: center;">
<button onclick="activitiesLoad()" style="background: #4a7bc9;">🔄 Reload from Disk</button>
<span id="activities-status" style="font-size: 0.85rem; color: #61dafb;"></span>
</div>
<!-- Normal Moods subsection -->
<div style="margin-bottom: 1.5rem;">
<div style="cursor: pointer; user-select: none; padding: 0.5rem; background: #2a2a2a; border-radius: 4px; margin-bottom: 0.5rem;" onclick="activitiesSectionToggle('normal')">
<strong><span id="activities-normal-icon"></span> 😇 Normal Moods</strong>
</div>
<div id="activities-normal-body" style="display: none; padding-left: 0.5rem;">
<div id="activities-normal-list"></div>
</div>
</div>
<!-- Evil Moods subsection -->
<div style="margin-bottom: 1.5rem;">
<div style="cursor: pointer; user-select: none; padding: 0.5rem; background: #2a2a2a; border-radius: 4px; margin-bottom: 0.5rem;" onclick="activitiesSectionToggle('evil')">
<strong><span id="activities-evil-icon"></span> 😈 Evil Moods</strong>
</div>
<div id="activities-evil-body" style="display: none; padding-left: 0.5rem;">
<div id="activities-evil-list"></div>
</div>
</div>
</div>
</div>
</div> </div>
<!-- DM Management Tab Content --> <!-- DM Management Tab Content -->
@@ -6731,6 +6805,209 @@ function escapeJsonForAttribute(obj) {
.replace(/>/g, '&gt;'); .replace(/>/g, '&gt;');
} }
// ============================================================================
// MOOD ACTIVITIES EDITOR
// ============================================================================
let activitiesData = null; // Full activities data from API
let activitiesOpen = false; // Top-level accordion state
let activitiesSections = { normal: false, evil: false }; // Section accordion state
let activitiesEditing = {}; // Track which moods are in edit mode: { "normal/bubbly": true }
let activitiesEditCache = {}; // Temp storage for edits: { "normal/bubbly": [...] }
function activitiesToggle() {
activitiesOpen = !activitiesOpen;
document.getElementById('activities-body').style.display = activitiesOpen ? 'block' : 'none';
document.getElementById('activities-toggle-icon').textContent = activitiesOpen ? '▼' : '▶';
if (activitiesOpen && !activitiesData) activitiesLoad();
}
function activitiesSectionToggle(section) {
activitiesSections[section] = !activitiesSections[section];
document.getElementById(`activities-${section}-body`).style.display = activitiesSections[section] ? 'block' : 'none';
document.getElementById(`activities-${section}-icon`).textContent = activitiesSections[section] ? '▼' : '▶';
}
async function activitiesLoad() {
const statusEl = document.getElementById('activities-status');
statusEl.textContent = 'Loading...';
try {
activitiesData = await apiCall('/activities');
const normalMoods = Object.keys(activitiesData.normal || {});
const evilMoods = Object.keys(activitiesData.evil || {});
const total = normalMoods.length + evilMoods.length;
document.getElementById('activities-summary').textContent = `(${total} moods configured)`;
activitiesRenderSection('normal');
activitiesRenderSection('evil');
statusEl.textContent = '';
} catch (e) {
statusEl.textContent = 'Failed to load: ' + e.message;
statusEl.style.color = '#e74c3c';
}
}
function activitiesRenderSection(section) {
const container = document.getElementById(`activities-${section}-list`);
if (!activitiesData || !activitiesData[section]) { container.innerHTML = '<p style="color:#888;">No data</p>'; return; }
const moods = activitiesData[section];
let html = '';
for (const [mood, entries] of Object.entries(moods)) {
const key = `${section}/${mood}`;
const isEditing = activitiesEditing[key];
const songs = entries.filter(e => e.type === 'listening').length;
const games = entries.filter(e => e.type === 'playing').length;
html += `<div class="act-mood-row">`;
html += `<div class="act-mood-header" onclick="activitiesMoodToggle('${section}','${mood}')">`;
html += `<span class="act-mood-name"><span id="act-icon-${section}-${mood}">▶</span> ${mood}</span>`;
html += `<span class="act-mood-stats">${songs}🎵 ${games}🎮</span>`;
html += `</div>`;
html += `<div class="act-mood-content" id="act-content-${section}-${mood}">`;
if (isEditing) {
html += activitiesRenderEditForm(section, mood, activitiesEditCache[key] || entries);
} else {
html += activitiesRenderView(section, mood, entries);
}
html += `</div></div>`;
}
container.innerHTML = html;
}
function activitiesRenderView(section, mood, entries) {
let html = '';
for (const entry of entries) {
const icon = entry.type === 'listening' ? '🎵' : '🎮';
const label = entry.type === 'listening' ? 'Listening to' : 'Playing';
html += `<div class="act-entry">`;
html += `<span class="act-entry-icon">${icon}</span>`;
html += `<span style="flex:1;">${escapeHtml(entry.name)}</span>`;
html += `<span style="color:#888; font-size:0.8rem;">weight: ${entry.weight}</span>`;
html += `</div>`;
}
html += `<div class="act-toolbar">`;
html += `<button onclick="activitiesStartEdit('${section}','${mood}')" style="background:#4a7bc9;">✏️ Edit</button>`;
html += `</div>`;
return html;
}
function activitiesRenderEditForm(section, mood, entries) {
let html = '';
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
html += `<div class="act-entry">`;
html += `<select id="act-type-${section}-${mood}-${i}" value="${e.type}">`;
html += `<option value="listening" ${e.type === 'listening' ? 'selected' : ''}>🎵 Listening</option>`;
html += `<option value="playing" ${e.type === 'playing' ? 'selected' : ''}>🎮 Playing</option>`;
html += `</select>`;
html += `<input type="text" id="act-name-${section}-${mood}-${i}" value="${escapeHtml(e.name)}" placeholder="Song/Game name">`;
html += `<input type="number" id="act-weight-${section}-${mood}-${i}" value="${e.weight}" min="1" max="20">`;
html += `<button onclick="activitiesRemoveEntry('${section}','${mood}',${i})" style="background:#c0392b; padding:0.3rem 0.5rem;" title="Remove">✕</button>`;
html += `</div>`;
}
html += `<div class="act-toolbar">`;
html += `<button onclick="activitiesAddEntry('${section}','${mood}')" style="background:#27ae60;"> Add Entry</button>`;
html += `<button onclick="activitiesSave('${section}','${mood}')" style="background:#4a7bc9;">💾 Save</button>`;
html += `<button onclick="activitiesCancelEdit('${section}','${mood}')" style="background:#555;">Cancel</button>`;
html += `</div>`;
return html;
}
function activitiesMoodToggle(section, mood) {
const el = document.getElementById(`act-content-${section}-${mood}`);
const iconEl = document.getElementById(`act-icon-${section}-${mood}`);
if (!el) return;
const isOpen = el.style.display === 'block';
el.style.display = isOpen ? 'none' : 'block';
if (iconEl) iconEl.textContent = isOpen ? '▶' : '▼';
}
function activitiesStartEdit(section, mood) {
const key = `${section}/${mood}`;
const entries = activitiesData[section][mood];
// Deep clone entries for editing
activitiesEditCache[key] = JSON.parse(JSON.stringify(entries));
activitiesEditing[key] = true;
activitiesRenderSection(section);
// Auto-expand the mood panel
const el = document.getElementById(`act-content-${section}-${mood}`);
const iconEl = document.getElementById(`act-icon-${section}-${mood}`);
if (el) el.style.display = 'block';
if (iconEl) iconEl.textContent = '▼';
}
function activitiesCancelEdit(section, mood) {
const key = `${section}/${mood}`;
delete activitiesEditing[key];
delete activitiesEditCache[key];
activitiesRenderSection(section);
}
function activitiesAddEntry(section, mood) {
const key = `${section}/${mood}`;
// First, sync current form values to cache
activitiesSyncFormToCache(section, mood);
activitiesEditCache[key].push({ type: 'listening', name: '', weight: 1 });
activitiesRenderSection(section);
// Keep the mood panel open
const el = document.getElementById(`act-content-${section}-${mood}`);
if (el) el.style.display = 'block';
}
function activitiesRemoveEntry(section, mood, index) {
const key = `${section}/${mood}`;
activitiesSyncFormToCache(section, mood);
activitiesEditCache[key].splice(index, 1);
activitiesRenderSection(section);
const el = document.getElementById(`act-content-${section}-${mood}`);
if (el) el.style.display = 'block';
}
function activitiesSyncFormToCache(section, mood) {
const key = `${section}/${mood}`;
const entries = activitiesEditCache[key] || [];
for (let i = 0; i < entries.length; i++) {
const typeEl = document.getElementById(`act-type-${section}-${mood}-${i}`);
const nameEl = document.getElementById(`act-name-${section}-${mood}-${i}`);
const weightEl = document.getElementById(`act-weight-${section}-${mood}-${i}`);
if (typeEl) entries[i].type = typeEl.value;
if (nameEl) entries[i].name = nameEl.value;
if (weightEl) entries[i].weight = parseInt(weightEl.value) || 1;
}
activitiesEditCache[key] = entries;
}
async function activitiesSave(section, mood) {
const key = `${section}/${mood}`;
activitiesSyncFormToCache(section, mood);
const entries = activitiesEditCache[key];
// Client-side validation
for (let i = 0; i < entries.length; i++) {
if (!entries[i].name || !entries[i].name.trim()) {
showNotification(`Entry ${i + 1}: name cannot be empty`, 'error');
return;
}
if (!entries[i].weight || entries[i].weight < 1) {
showNotification(`Entry ${i + 1}: weight must be at least 1`, 'error');
return;
}
}
try {
await apiCall(`/activities/${section}/${mood}`, 'POST', { activities: entries });
showNotification(`Saved activities for ${section}/${mood}`, 'success');
delete activitiesEditing[key];
delete activitiesEditCache[key];
// Reload to get fresh data
await activitiesLoad();
} catch (e) {
showNotification('Save failed: ' + e.message, 'error');
}
}
</script> </script>
</body> </body>