1128 lines
42 KiB
JavaScript
1128 lines
42 KiB
JavaScript
|
|
// ============================================================================
|
|||
|
|
// 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';
|
|||
|
|
});
|