Files
miku-discord/bot/static/js/profile.js
koko210Serve 694590a620 refactor: Modularize monolithic HTML control panel into organized components
This commit completes a major refactoring of the Miku control panel from a single 7,191-line monolithic HTML file to a modern modular architecture:

CHANGES:
- Extracted 872 lines of CSS into css/style.css
- Created 10 specialized JavaScript modules (4,964 lines total):
  * core.js: Global state, utilities, initialization, polling system
  * servers.js: Server management and mood handling
  * modes.js: Evil mode, GPU selection, bipolar mode, scoreboard
  * actions.js: Autonomous/manual actions, custom prompts, reactions
  * image-gen.js: Image generation system
  * status.js: Status display and statistics
  * dm.js: DM user management and conversation analysis
  * chat.js: LLM chat interface with streaming and voice calls
  * memories.js: Cheshire Cat memory integration (episodic/declarative/procedural)
  * profile.js: Profile picture, album gallery, activities editor
- Cleaned index.html to 1,351 lines (structure only, zero inline JS/CSS)
- Removed 12 duplicate variable declarations
- Maintained strict script load order for dependency resolution
- Added backup comment to index.html.bak for historical reference

VERIFICATION COMPLETED:
✓ All 191 functions/variables from original accounted for
✓ Cross-referenced with backup to ensure nothing lost
✓ All onclick handlers and modal systems validated
✓ No circular dependencies or broken references
✓ HTML structure integrity verified (11 tabs, all buttons/modals intact)
✓ CropperJS CDN links preserved

The refactored code is production-ready with improved maintainability and clear separation of concerns.
2026-04-29 20:56:49 +03:00

1128 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// Miku Control Panel — Profile Picture, Album & Activities Module
// ============================================================================
// ============================================================================
// Profile Picture Tab (tab11) — Full Management
// ============================================================================
// pfpCropper declared in core.js
function getPfpCropMode() {
const radio = document.querySelector('input[name="pfp-crop-mode"]:checked');
return radio ? radio.value : 'auto';
}
function pfpSetStatus(text, color = '#61dafb') {
const el = document.getElementById('pfp-tab-status');
if (el) { el.textContent = text; el.style.color = color; }
}
function pfpRefreshPreviews() {
const t = Date.now();
const origImg = document.getElementById('pfp-preview-original');
const curImg = document.getElementById('pfp-preview-current');
if (origImg) origImg.src = `/profile-picture/image/original?t=${t}`;
if (curImg) curImg.src = `/profile-picture/image/current?t=${t}`;
}
async function loadPfpTab() {
// Load metadata
try {
const result = await apiCall('/profile-picture/metadata');
if (result.status === 'ok' && result.metadata) {
const metaDiv = document.getElementById('pfp-tab-metadata');
const metaContent = document.getElementById('pfp-tab-metadata-content');
metaContent.textContent = JSON.stringify(result.metadata, null, 2);
metaDiv.style.display = 'block';
// Show original dimensions if available
const dimsEl = document.getElementById('pfp-original-dims');
if (dimsEl && result.metadata.original_width) {
dimsEl.textContent = `${result.metadata.original_width}×${result.metadata.original_height}`;
}
}
} catch (e) {
console.error('Failed to load PFP metadata:', e);
}
// Load description
try {
const result = await apiCall('/profile-picture/description');
if (result.status === 'ok') {
document.getElementById('pfp-description-editor').value = result.description || '';
}
} catch (e) {
console.error('Failed to load PFP description:', e);
}
// Refresh preview images
pfpRefreshPreviews();
// Update album header counts (without opening)
try {
const [listRes, usageRes] = await Promise.all([
apiCall('/profile-picture/album'),
apiCall('/profile-picture/album/disk-usage')
]);
if (listRes.status === 'ok') {
albumEntries = listRes.entries || [];
document.getElementById('album-count').textContent = `(${albumEntries.length} entries)`;
if (albumOpen) albumRenderGrid();
}
if (usageRes.status === 'ok') {
document.getElementById('album-disk-usage').textContent =
`${usageRes.human_readable} · ${usageRes.entry_count} entries`;
}
} catch (e) {
console.error('Failed to load album info:', e);
}
}
// --- Danbooru Change ---
async function pfpChangeDanbooru() {
const mode = getPfpCropMode();
const selectedServer = document.getElementById('server-select').value;
pfpSetStatus('⏳ Searching Danbooru...');
try {
const endpoint = mode === 'manual' ? '/profile-picture/change-no-crop' : '/profile-picture/change';
const params = new URLSearchParams();
if (selectedServer !== 'all') params.append('guild_id', selectedServer);
const url = params.toString() ? `${endpoint}?${params.toString()}` : endpoint;
const result = await apiCall(url, 'POST');
if (result.status === 'ok') {
pfpSetStatus(`${result.message}`, 'green');
showNotification('Profile picture changed!');
// Show metadata
const metaDiv = document.getElementById('pfp-tab-metadata');
const metaContent = document.getElementById('pfp-tab-metadata-content');
if (result.metadata) {
metaContent.textContent = JSON.stringify(result.metadata, null, 2);
metaDiv.style.display = 'block';
}
pfpRefreshPreviews();
// If manual mode, show crop interface
if (mode === 'manual') {
pfpShowCropInterface();
}
} else {
throw new Error(result.message || 'Unknown error');
}
} catch (error) {
console.error('PFP Danbooru error:', error);
pfpSetStatus(`❌ Error: ${error.message}`, 'red');
}
}
// Keep old function names working (backwards compatibility for autonomous/API callers)
async function changeProfilePicture() { await pfpChangeDanbooru(); }
// --- Custom Upload ---
async function pfpUploadCustom() {
const fileInput = document.getElementById('pfp-tab-upload');
const mode = getPfpCropMode();
const selectedServer = document.getElementById('server-select').value;
if (!fileInput.files || fileInput.files.length === 0) {
showNotification('Please select an image file first', 'error');
return;
}
const file = fileInput.files[0];
if (!file.type.startsWith('image/')) {
showNotification('Please select a valid image file', 'error');
return;
}
pfpSetStatus('⏳ Uploading and processing...');
try {
const formData = new FormData();
formData.append('file', file);
const endpoint = mode === 'manual' ? '/profile-picture/change-no-crop' : '/profile-picture/change';
let url = endpoint;
if (selectedServer !== 'all') url += `?guild_id=${selectedServer}`;
const response = await fetch(url, { method: 'POST', body: formData });
const result = await response.json();
if (response.ok && result.status === 'ok') {
pfpSetStatus(`${result.message}`, 'green');
showNotification('Image uploaded successfully!');
fileInput.value = '';
if (result.metadata) {
const metaDiv = document.getElementById('pfp-tab-metadata');
const metaContent = document.getElementById('pfp-tab-metadata-content');
metaContent.textContent = JSON.stringify(result.metadata, null, 2);
metaDiv.style.display = 'block';
}
pfpRefreshPreviews();
if (mode === 'manual') {
pfpShowCropInterface();
}
} else {
throw new Error(result.message || 'Upload failed');
}
} catch (error) {
console.error('PFP upload error:', error);
pfpSetStatus(`❌ Error: ${error.message}`, 'red');
showNotification(error.message, 'error');
}
}
// Keep old function name working
async function uploadCustomPfp() { await pfpUploadCustom(); }
// --- Restore Fallback ---
async function pfpRestoreFallback() {
if (!confirm('Are you sure you want to restore the original fallback avatar?')) return;
pfpSetStatus('⏳ Restoring original avatar...');
try {
const result = await apiCall('/profile-picture/restore-fallback', 'POST');
pfpSetStatus(`${result.message}`, 'green');
document.getElementById('pfp-tab-metadata').style.display = 'none';
pfpRefreshPreviews();
showNotification('Original avatar restored!');
} catch (error) {
console.error('Restore fallback error:', error);
pfpSetStatus(`❌ Error: ${error.message}`, 'red');
}
}
async function restoreFallbackPfp() { await pfpRestoreFallback(); }
// --- Crop Interface ---
function pfpShowCropInterface() {
const section = document.getElementById('pfp-crop-section');
const img = document.getElementById('pfp-crop-image');
// Destroy previous cropper if any
if (pfpCropper) {
pfpCropper.destroy();
pfpCropper = null;
}
// Load original image
img.src = `/profile-picture/image/original?t=${Date.now()}`;
section.style.display = 'block';
img.onload = function() {
pfpCropper = new Cropper(img, {
aspectRatio: 1,
viewMode: 2,
minCropBoxWidth: 64,
minCropBoxHeight: 64,
responsive: true,
autoCropArea: 0.8,
background: true,
guides: true,
center: true,
highlight: true,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false
});
};
}
function pfpHideCropInterface() {
if (pfpCropper) {
pfpCropper.destroy();
pfpCropper = null;
}
document.getElementById('pfp-crop-section').style.display = 'none';
}
// Re-crop: open crop interface on stored original
function pfpRecrop() {
pfpShowCropInterface();
}
async function pfpApplyManualCrop() {
if (!pfpCropper) {
showNotification('No crop region selected', 'error');
return;
}
const data = pfpCropper.getData(true); // rounded integers
pfpSetStatus('⏳ Applying manual crop...');
try {
const response = await fetch('/profile-picture/manual-crop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
x: Math.round(data.x),
y: Math.round(data.y),
width: Math.round(data.width),
height: Math.round(data.height)
})
});
const result = await response.json();
if (response.ok && result.status === 'ok') {
pfpSetStatus(`${result.message}`, 'green');
showNotification('Manual crop applied!');
pfpHideCropInterface();
pfpRefreshPreviews();
// Refresh metadata
if (result.metadata) {
const metaContent = document.getElementById('pfp-tab-metadata-content');
const existing = metaContent.textContent ? JSON.parse(metaContent.textContent) : {};
Object.assign(existing, result.metadata);
metaContent.textContent = JSON.stringify(existing, null, 2);
}
} else {
throw new Error(result.message || 'Crop failed');
}
} catch (error) {
console.error('Manual crop error:', error);
pfpSetStatus(`❌ Error: ${error.message}`, 'red');
}
}
async function pfpApplyAutoCrop() {
pfpSetStatus('⏳ Running auto-crop (face detection)...');
try {
const result = await apiCall('/profile-picture/auto-crop', 'POST');
if (result.status === 'ok') {
pfpSetStatus(`${result.message}`, 'green');
showNotification('Auto-crop applied!');
pfpHideCropInterface();
pfpRefreshPreviews();
} else {
throw new Error(result.message || 'Auto-crop failed');
}
} catch (error) {
console.error('Auto-crop error:', error);
pfpSetStatus(`❌ Error: ${error.message}`, 'red');
}
}
// --- Description ---
async function pfpSaveDescription() {
const descEl = document.getElementById('pfp-description-editor');
const statusEl = document.getElementById('pfp-desc-status');
const description = descEl.value.trim();
if (!description) {
statusEl.textContent = '⚠️ Description cannot be empty';
statusEl.style.color = 'orange';
return;
}
statusEl.textContent = '⏳ Saving description...';
statusEl.style.color = '#61dafb';
try {
const response = await fetch('/profile-picture/description', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description })
});
const result = await response.json();
if (response.ok && result.status === 'ok') {
statusEl.textContent = '✅ Description saved & injected into Cat memory';
statusEl.style.color = 'green';
showNotification('Description saved!');
} else {
throw new Error(result.message || 'Save failed');
}
} catch (error) {
console.error('Save description error:', error);
statusEl.textContent = `❌ Error: ${error.message}`;
statusEl.style.color = 'red';
}
}
async function pfpRegenerateDescription() {
const statusEl = document.getElementById('pfp-desc-status');
statusEl.textContent = '⏳ Regenerating description via vision model...';
statusEl.style.color = '#61dafb';
try {
const result = await apiCall('/profile-picture/regenerate-description', 'POST');
if (result.status === 'ok' && result.description) {
document.getElementById('pfp-description-editor').value = result.description;
statusEl.textContent = '✅ Description regenerated & saved';
statusEl.style.color = 'green';
showNotification('Description regenerated!');
} else {
throw new Error(result.message || 'Regeneration failed');
}
} catch (error) {
console.error('Regenerate description error:', error);
statusEl.textContent = `❌ Error: ${error.message}`;
statusEl.style.color = 'red';
}
}
// --- Role Color (updated element IDs for tab11) ---
async function setCustomRoleColor() {
const statusDiv = document.getElementById('pfp-tab-role-color-status');
const hexInput = document.getElementById('pfp-tab-role-color-hex');
const hexColor = hexInput.value.trim();
if (!hexColor) {
statusDiv.textContent = '⚠️ Please enter a hex color code';
statusDiv.style.color = 'orange';
return;
}
statusDiv.textContent = '⏳ Updating role colors...';
statusDiv.style.color = '#61dafb';
try {
const formData = new FormData();
formData.append('hex_color', hexColor);
const response = await fetch('/role-color/custom', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok && result.status === 'ok') {
statusDiv.textContent = `${result.message}`;
statusDiv.style.color = 'green';
showNotification(`Role color updated to ${result.color.hex}`);
} else {
throw new Error(result.message || 'Failed to update role color');
}
} catch (error) {
console.error('Failed to set custom role color:', error);
statusDiv.textContent = `❌ Error: ${error.message}`;
statusDiv.style.color = 'red';
showNotification(error.message || 'Failed to update role color', 'error');
}
}
async function resetRoleColor() {
const statusDiv = document.getElementById('pfp-tab-role-color-status');
statusDiv.textContent = '⏳ Resetting to fallback color...';
statusDiv.style.color = '#61dafb';
try {
const result = await apiCall('/role-color/reset-fallback', 'POST');
statusDiv.textContent = `${result.message}`;
statusDiv.style.color = 'green';
document.getElementById('pfp-tab-role-color-hex').value = '#86cecb';
showNotification('Role color reset to fallback #86cecb');
} catch (error) {
console.error('Failed to reset role color:', error);
statusDiv.textContent = `❌ Error: ${error.message}`;
statusDiv.style.color = 'red';
}
}
// ============================================================================
// Album / Gallery System
// ============================================================================
// albumEntries, albumSelectedId, albumChecked, albumCropper, albumOpen declared in core.js
function albumSetStatus(text, color = '#61dafb') {
const el = document.getElementById('album-status');
if (el) { el.textContent = text; el.style.color = color; }
}
function albumToggle() {
albumOpen = !albumOpen;
document.getElementById('album-body').style.display = albumOpen ? 'block' : 'none';
document.getElementById('album-toggle-icon').textContent = albumOpen ? '▼' : '▶';
if (albumOpen) albumLoad();
}
async function albumLoad() {
try {
const [listRes, usageRes] = await Promise.all([
apiCall('/profile-picture/album'),
apiCall('/profile-picture/album/disk-usage')
]);
if (listRes.status === 'ok') {
albumEntries = listRes.entries || [];
document.getElementById('album-count').textContent = `(${albumEntries.length} entries)`;
albumRenderGrid();
}
if (usageRes.status === 'ok') {
document.getElementById('album-disk-usage').textContent =
`${usageRes.human_readable} · ${usageRes.entry_count} entries`;
}
} catch (e) {
console.error('Album load error:', e);
}
}
function albumRenderGrid() {
const grid = document.getElementById('album-grid');
if (!grid) return;
if (albumEntries.length === 0) {
grid.innerHTML = '<div style="color: #888; padding: 1rem; grid-column: 1/-1;">No album entries yet. Upload images or archive the current PFP.</div>';
return;
}
grid.innerHTML = albumEntries.map(e => {
const id = e.id;
const isSelected = id === albumSelectedId;
const isChecked = albumChecked.has(id);
const colorDot = e.dominant_color
? `<span class="color-dot" style="background:${e.dominant_color.hex}"></span>`
: '';
const label = (e.source || '').replace('custom_upload', 'upload').substring(0, 12);
return `<div class="album-card${isSelected ? ' selected' : ''}${isChecked ? ' checked' : ''}"
data-id="${id}" onclick="albumSelectEntry('${id}')">
<input type="checkbox" class="album-check" ${isChecked ? 'checked' : ''}
onclick="event.stopPropagation(); albumToggleCheck('${id}', this.checked)">
<img src="/profile-picture/album/${id}/image/cropped?t=${Date.now()}"
onerror="this.src='/profile-picture/album/${id}/image/original?t=${Date.now()}'"
loading="lazy" alt="">
<div class="album-card-info">${colorDot}${label}</div>
</div>`;
}).join('');
}
function albumToggleCheck(id, checked) {
if (checked) albumChecked.add(id); else albumChecked.delete(id);
document.getElementById('album-selected-count').textContent = albumChecked.size;
document.getElementById('album-bulk-delete-btn').disabled = albumChecked.size === 0;
// update card class
const card = document.querySelector(`.album-card[data-id="${id}"]`);
if (card) card.classList.toggle('checked', checked);
}
async function albumSelectEntry(id) {
albumSelectedId = id;
// highlight card
document.querySelectorAll('.album-card').forEach(c => c.classList.toggle('selected', c.dataset.id === id));
// show detail
const detail = document.getElementById('album-detail');
detail.style.display = 'block';
const t = Date.now();
document.getElementById('album-detail-original').src = `/profile-picture/album/${id}/image/original?t=${t}`;
document.getElementById('album-detail-cropped').src = `/profile-picture/album/${id}/image/cropped?t=${t}`;
// load entry metadata
try {
const res = await apiCall(`/profile-picture/album/${id}`);
if (res.status === 'ok' && res.entry) {
const e = res.entry;
document.getElementById('album-detail-dims').textContent =
e.original_width && e.original_height ? `${e.original_width}×${e.original_height}` : '';
document.getElementById('album-detail-description').value = e.description || '';
const metaLines = [];
if (e.added_at) metaLines.push(`Added: ${new Date(e.added_at).toLocaleString()}`);
if (e.source) metaLines.push(`Source: ${e.source}`);
if (e.dominant_color) metaLines.push(`Color: ${e.dominant_color.hex}`);
if (e.was_current) metaLines.push('📌 Previously active');
document.getElementById('album-detail-meta').textContent = metaLines.join(' · ');
}
} catch (e) {
console.error('Album entry load error:', e);
}
}
function albumCloseDetail() {
document.getElementById('album-detail').style.display = 'none';
albumSelectedId = null;
albumHideCropInterface();
document.querySelectorAll('.album-card').forEach(c => c.classList.remove('selected'));
}
// --- Album Upload ---
async function albumUpload() {
const input = document.getElementById('album-upload');
if (!input.files || input.files.length === 0) {
showNotification('Select image(s) to add to album', 'error');
return;
}
const files = Array.from(input.files);
albumSetStatus(`⏳ Adding ${files.length} image(s) to album...`);
try {
if (files.length === 1) {
const formData = new FormData();
formData.append('file', files[0]);
const resp = await fetch('/profile-picture/album/add', { method: 'POST', body: formData });
const result = await resp.json();
if (result.status === 'ok') {
albumSetStatus(`✅ Added to album`, 'green');
showNotification('Image added to album!');
} else {
throw new Error(result.message);
}
} else {
const formData = new FormData();
files.forEach(f => formData.append('files', f));
const resp = await fetch('/profile-picture/album/add-batch', { method: 'POST', body: formData });
const result = await resp.json();
albumSetStatus(`${result.message}`, result.failed > 0 ? 'orange' : 'green');
showNotification(result.message);
}
input.value = '';
await albumLoad();
} catch (error) {
console.error('Album upload error:', error);
albumSetStatus(`❌ Error: ${error.message}`, 'red');
}
}
async function albumAddCurrent() {
albumSetStatus('⏳ Archiving current PFP...');
try {
const result = await apiCall('/profile-picture/album/add-current', 'POST');
if (result.status === 'ok') {
albumSetStatus(`${result.message}`, 'green');
showNotification('Current PFP archived to album!');
await albumLoad();
} else {
throw new Error(result.message);
}
} catch (error) {
albumSetStatus(`❌ Error: ${error.message}`, 'red');
}
}
// --- Album Set as Current ---
async function albumSetAsCurrent() {
if (!albumSelectedId) return;
if (!confirm('Set this album entry as your current Discord profile picture?\nThe current PFP will be archived to the album.')) return;
albumSetStatus('⏳ Setting as current PFP...');
try {
const result = await apiCall(`/profile-picture/album/${albumSelectedId}/set-current`, 'POST');
if (result.status === 'ok') {
albumSetStatus(`${result.message}`, 'green');
showNotification('Album entry set as current PFP!');
pfpRefreshPreviews();
loadPfpTab(); // refresh metadata + description
await albumLoad();
} else {
throw new Error(result.message);
}
} catch (error) {
albumSetStatus(`❌ Error: ${error.message}`, 'red');
}
}
// --- Album Delete ---
async function albumDeleteSelected() {
if (!albumSelectedId) return;
if (!confirm('Delete this album entry permanently?')) return;
try {
const resp = await fetch(`/profile-picture/album/${albumSelectedId}`, { method: 'DELETE' });
const result = await resp.json();
if (result.status === 'ok') {
showNotification('Album entry deleted');
albumCloseDetail();
await albumLoad();
} else {
throw new Error(result.message);
}
} catch (error) {
showNotification(`Error: ${error.message}`, 'error');
}
}
async function albumBulkDelete() {
if (albumChecked.size === 0) return;
if (!confirm(`Delete ${albumChecked.size} selected album entries permanently?`)) return;
albumSetStatus(`⏳ Deleting ${albumChecked.size} entries...`);
try {
const resp = await fetch('/profile-picture/album/delete-bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entry_ids: Array.from(albumChecked) })
});
const result = await resp.json();
albumSetStatus(`${result.message}`, 'green');
showNotification(result.message);
albumChecked.clear();
document.getElementById('album-selected-count').textContent = '0';
document.getElementById('album-bulk-delete-btn').disabled = true;
if (albumSelectedId && !albumEntries.find(e => e.id === albumSelectedId)) {
albumCloseDetail();
}
await albumLoad();
} catch (error) {
albumSetStatus(`❌ Error: ${error.message}`, 'red');
}
}
// --- Album Crop ---
function albumShowCropInterface() {
if (!albumSelectedId) return;
if (albumCropper) { albumCropper.destroy(); albumCropper = null; }
const section = document.getElementById('album-crop-section');
const img = document.getElementById('album-crop-image');
img.src = `/profile-picture/album/${albumSelectedId}/image/original?t=${Date.now()}`;
section.style.display = 'block';
img.onload = function() {
albumCropper = new Cropper(img, {
aspectRatio: 1,
viewMode: 2,
minCropBoxWidth: 64,
minCropBoxHeight: 64,
responsive: true,
autoCropArea: 0.8,
background: true,
guides: true,
center: true,
highlight: true,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false
});
};
}
function albumHideCropInterface() {
if (albumCropper) { albumCropper.destroy(); albumCropper = null; }
document.getElementById('album-crop-section').style.display = 'none';
}
async function albumApplyManualCrop() {
if (!albumCropper || !albumSelectedId) return;
const data = albumCropper.getData(true);
albumSetStatus('⏳ Applying crop to album entry...');
try {
const resp = await fetch(`/profile-picture/album/${albumSelectedId}/manual-crop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ x: Math.round(data.x), y: Math.round(data.y), width: Math.round(data.width), height: Math.round(data.height) })
});
const result = await resp.json();
if (result.status === 'ok') {
albumSetStatus('✅ Crop applied', 'green');
showNotification('Album entry cropped!');
albumHideCropInterface();
// refresh detail images
const t = Date.now();
document.getElementById('album-detail-cropped').src = `/profile-picture/album/${albumSelectedId}/image/cropped?t=${t}`;
await albumLoad(); // refresh grid thumbnails
} else {
throw new Error(result.message);
}
} catch (error) {
albumSetStatus(`❌ Error: ${error.message}`, 'red');
}
}
async function albumApplyAutoCrop() {
if (!albumSelectedId) return;
albumSetStatus('⏳ Running auto-crop on album entry...');
try {
const result = await apiCall(`/profile-picture/album/${albumSelectedId}/auto-crop`, 'POST');
if (result.status === 'ok') {
albumSetStatus('✅ Auto-crop applied', 'green');
showNotification('Album entry auto-cropped!');
albumHideCropInterface();
const t = Date.now();
document.getElementById('album-detail-cropped').src = `/profile-picture/album/${albumSelectedId}/image/cropped?t=${t}`;
await albumLoad();
} else {
throw new Error(result.message);
}
} catch (error) {
albumSetStatus(`❌ Error: ${error.message}`, 'red');
}
}
// --- Album Description ---
async function albumSaveDescription() {
if (!albumSelectedId) return;
const description = document.getElementById('album-detail-description').value.trim();
if (!description) { showNotification('Description cannot be empty', 'error'); return; }
try {
const resp = await fetch(`/profile-picture/album/${albumSelectedId}/description`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description })
});
const result = await resp.json();
if (result.status === 'ok') {
showNotification('Album entry description saved!');
} else {
throw new Error(result.message);
}
} catch (error) {
showNotification(`Error: ${error.message}`, 'error');
}
}
// ============================================================================
// MOOD ACTIVITIES EDITOR
// ============================================================================
// activitiesData, activitiesOpen, activitiesSections, activitiesEditing, activitiesEditCache declared in core.js
function activitiesToggle() {
activitiesOpen = !activitiesOpen;
document.getElementById('activities-body').style.display = activitiesOpen ? 'block' : 'none';
document.getElementById('activities-toggle-icon').textContent = activitiesOpen ? '▼' : '▶';
if (activitiesOpen) {
if (!activitiesData) activitiesLoad();
activityRefreshCurrent();
}
}
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;
const watches = entries.filter(e => e.type === 'watching').length;
const competes = entries.filter(e => e.type === 'competing').length;
const streams = entries.filter(e => e.type === 'streaming').length;
let stats = `${songs}🎵 ${games}🎮`;
if (watches) stats += ` ${watches}📺`;
if (competes) stats += ` ${competes}🏆`;
if (streams) stats += ` ${streams}🔴`;
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">${stats}</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 (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const icons = { listening: '🎵', playing: '🎮', watching: '📺', competing: '🏆', streaming: '🔴' };
const labels = { listening: 'Listening to', playing: 'Playing', watching: 'Watching', competing: 'Competing in', streaming: 'Streaming' };
const icon = icons[entry.type] || '🎮';
const label = labels[entry.type] || 'Playing';
// Encode entry data for the "Set as Activity" button
const entryData = encodeURIComponent(JSON.stringify({ type: entry.type, name: entry.name, state: entry.state || '', url: entry.url || '' }));
html += `<div class="act-entry">`;
html += `<span class="act-entry-icon">${icon}</span>`;
html += `<span style="flex:1;"><strong style="color:#61dafb; font-size:0.8rem;">${label}</strong> ${escapeHtml(entry.name)}`;
if (entry.state) html += ` <span style="color:#aaa; font-size:0.85rem;">— ${escapeHtml(entry.state)}</span>`;
html += `</span>`;
html += `<span style="color:#888; font-size:0.8rem;">weight: ${entry.weight}</span>`;
html += `<button onclick="activitySetFromEntry(this)" data-entry="${entryData}" style="background:#e67e22; font-size:0.75rem; padding:0.2rem 0.5rem; margin-left:0.3rem;" title="Set this as bot's current activity (30 min override)">🎯 Set</button>`;
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}" onchange="activitiesTypeChanged('${section}','${mood}',${i})">`;
html += `<option value="listening" ${e.type === 'listening' ? 'selected' : ''}>🎵 Listening</option>`;
html += `<option value="playing" ${e.type === 'playing' ? 'selected' : ''}>🎮 Playing</option>`;
html += `<option value="watching" ${e.type === 'watching' ? 'selected' : ''}>📺 Watching</option>`;
html += `<option value="competing" ${e.type === 'competing' ? 'selected' : ''}>🏆 Competing</option>`;
html += `<option value="streaming" ${e.type === 'streaming' ? 'selected' : ''}>🔴 Streaming</option>`;
html += `</select>`;
html += `<input type="text" id="act-name-${section}-${mood}-${i}" value="${escapeHtml(e.name)}" placeholder="Name" style="flex:2; min-width:120px;">`;
html += `<input type="text" id="act-state-${section}-${mood}-${i}" value="${escapeHtml(e.state || '')}" placeholder="Detail (optional)" style="flex:1.5; min-width:100px;">`;
html += `<input type="text" id="act-url-${section}-${mood}-${i}" value="${escapeHtml(e.url || '')}" placeholder="URL (streaming)" style="flex:1.5; min-width:100px; ${e.type === 'streaming' ? '' : 'display:none;'}">`;
html += `<input type="number" id="act-weight-${section}-${mood}-${i}" value="${e.weight}" min="1" max="20" style="width:60px;">`;
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 activitiesTypeChanged(section, mood, index) {
const typeEl = document.getElementById(`act-type-${section}-${mood}-${index}`);
const urlEl = document.getElementById(`act-url-${section}-${mood}-${index}`);
if (!typeEl || !urlEl) return;
urlEl.style.display = typeEl.value === 'streaming' ? '' : 'none';
}
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: '', state: '', 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 stateEl = document.getElementById(`act-state-${section}-${mood}-${i}`);
const urlEl = document.getElementById(`act-url-${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 (stateEl) entries[i].state = stateEl.value || undefined;
if (urlEl) entries[i].url = urlEl.value || undefined;
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;
}
if (entries[i].type === 'streaming' && !entries[i].url) {
showNotification(`Entry ${i + 1}: streaming requires a URL`, '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');
}
}
// ============================================================================
// CURRENT ACTIVITY OVERRIDE
// ============================================================================
function _activityTypeIcon(type) {
return { listening: '🎵', playing: '🎮', watching: '📺', competing: '🏆', streaming: '🔴' }[type] || '🎮';
}
function _activityTypeLabel(type) {
return { listening: 'Listening to', playing: 'Playing', watching: 'Watching', competing: 'Competing in', streaming: 'Streaming' }[type] || 'Playing';
}
async function activityRefreshCurrent() {
const statusEl = document.getElementById('activity-override-status');
try {
const data = await apiCall('/activities/current');
const act = data.activity;
const isOverride = data.manual_override;
if (act) {
const icon = _activityTypeIcon(act.type);
const label = _activityTypeLabel(act.type);
let html = `${icon} <strong>${label}</strong> ${escapeHtml(act.name)}`;
if (act.state) html += ` <span style="color:#aaa;">— ${escapeHtml(act.state)}</span>`;
if (isOverride) html += ` <span style="color:#e67e22; font-size:0.8rem;">⚡ MANUAL OVERRIDE (30 min)</span>`;
statusEl.innerHTML = html;
} else {
let html = '<span style="color:#888;">No activity (idle)</span>';
if (isOverride) html += ' <span style="color:#e67e22; font-size:0.8rem;">⚡ MANUAL OVERRIDE</span>';
statusEl.innerHTML = html;
}
} catch (e) {
statusEl.innerHTML = `<span style="color:#e74c3c;">Error: ${e.message}</span>`;
}
}
async function activitySetManual() {
const type = document.getElementById('act-manual-type').value;
const name = document.getElementById('act-manual-name').value.trim();
const state = document.getElementById('act-manual-state').value.trim();
const url = document.getElementById('act-manual-url').value.trim();
if (!name) { showNotification('Activity name is required', 'error'); return; }
if (type === 'streaming' && !url) { showNotification('Streaming requires a URL', 'error'); return; }
try {
const body = { type, name };
if (state) body.state = state;
if (url) body.url = url;
await apiCall('/activities/current', 'POST', body);
showNotification(`Set activity: ${type} ${name}`, 'success');
await activityRefreshCurrent();
} catch (e) {
showNotification('Failed to set activity: ' + e.message, 'error');
}
}
async function activitySetFromEntry(btnElement) {
const raw = btnElement.getAttribute('data-entry');
if (!raw) return;
let entry;
try { entry = JSON.parse(decodeURIComponent(raw)); } catch (e) { showNotification('Failed to parse activity data', 'error'); return; }
const type = entry.type;
const name = entry.name;
const state = entry.state || null;
const url = entry.url || null;
if (!name) { showNotification('Activity name is empty', 'error'); return; }
if (type === 'streaming' && !url) { showNotification('Streaming requires a URL', 'error'); return; }
try {
const body = { type, name };
if (state) body.state = state;
if (url) body.url = url;
await apiCall('/activities/current', 'POST', body);
const icon = _activityTypeIcon(type);
showNotification(`${icon} Set activity: ${name}`, 'success');
await activityRefreshCurrent();
} catch (e) {
showNotification('Failed to set activity: ' + e.message, 'error');
}
}
async function activityClearManual() {
try {
await apiCall('/activities/current', 'DELETE');
showNotification('Activity cleared (manual override active)', 'success');
await activityRefreshCurrent();
} catch (e) {
showNotification('Failed to clear: ' + e.message, 'error');
}
}
async function activityReleaseAuto() {
try {
await apiCall('/activities/current/auto', 'POST');
showNotification('Returned to automatic mode', 'success');
await activityRefreshCurrent();
} catch (e) {
showNotification('Failed to release override: ' + e.message, 'error');
}
}
// Show/hide URL field when streaming is selected in manual override
document.getElementById('act-manual-type').addEventListener('change', function() {
document.getElementById('act-manual-url').style.display = this.value === 'streaming' ? '' : 'none';
});