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:
@@ -450,6 +450,46 @@
|
||||
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 {
|
||||
display: none;
|
||||
}
|
||||
@@ -1328,6 +1368,40 @@
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- DM Management Tab Content -->
|
||||
@@ -6731,6 +6805,209 @@ function escapeJsonForAttribute(obj) {
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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>
|
||||
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user