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;
|
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, '>');
|
.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>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user