This commit is contained in:
2026-09-29 04:35:35 +02:00
parent 73c0659d4a
commit 6c813143eb
51 changed files with 3708 additions and 743 deletions
+114 -18
View File
@@ -151,7 +151,10 @@
const allowedTypes = ['image/gif', 'image/jpeg', 'image/png', 'image/webp'];
if (chooseBtn && fileInput) {
chooseBtn.addEventListener('click', () => fileInput.click());
chooseBtn.addEventListener('click', (e) => {
if (e.target.closest('#avatar-remove-btn')) return; // removing, not choosing
fileInput.click();
});
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
@@ -194,8 +197,24 @@
progressWrapper.style.display = 'flex';
progressFill.style.width = '0%';
progressText.textContent = '0%';
progressWrapper.setAttribute('aria-valuenow', '0');
showStatus(i18n.uploading || 'Uploading...', '');
// Show the chosen image in the live preview right away; the progress overlay sits on top of it
chooseBtn.classList.add('is-uploading');
const liveAvatar = document.getElementById('live-preview-avatar');
const prevAvatarSrc = liveAvatar ? liveAvatar.src : null;
const localUrl = URL.createObjectURL(file);
if (liveAvatar) liveAvatar.src = localUrl;
const finishUpload = (ok) => {
progressWrapper.style.display = 'none';
chooseBtn.classList.remove('is-uploading');
if (!ok && liveAvatar && prevAvatarSrc) liveAvatar.src = prevAvatarSrc;
// Keep the local image until the server copy has loaded, then free it
if (ok && liveAvatar) liveAvatar.addEventListener('load', () => URL.revokeObjectURL(localUrl), { once: true });
else URL.revokeObjectURL(localUrl);
};
const formData = new FormData();
formData.append('file', file);
@@ -205,14 +224,19 @@
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressFill.style.width = percent + '%';
progressText.textContent = percent + '%';
progressWrapper.setAttribute('aria-valuenow', String(percent));
// At 100% the bytes are sent but the server is still processing
if (percent >= 100) progressText.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
else progressText.textContent = percent + '%';
}
});
let uploadOk = false;
xhr.addEventListener('load', () => {
try {
const res = JSON.parse(xhr.responseText);
if (xhr.status === 200 && res.success) {
uploadOk = true;
showStatus(res.msg || 'Avatar uploaded!', 'success');
// Update preview
@@ -236,7 +260,15 @@
// Show remove button if not present
const existingRemoveBtn = document.getElementById('avatar-remove-btn');
if (!existingRemoveBtn) {
if (!existingRemoveBtn && chooseBtn.classList.contains('editable-avatar-container')) {
const btn = document.createElement('button');
btn.type = 'button';
btn.id = 'avatar-remove-btn';
btn.className = 'avatar-quick-remove';
btn.title = 'Remove custom avatar';
btn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
chooseBtn.appendChild(btn);
} else if (!existingRemoveBtn) {
const actionsDiv = document.querySelector('.avatar-upload-actions');
if (actionsDiv) {
const btn = document.createElement('button');
@@ -258,14 +290,14 @@
showStatus('Upload failed: Invalid response', 'error');
}
progressWrapper.style.display = 'none';
finishUpload(uploadOk);
uploadBtn.disabled = true;
chooseBtn.disabled = false;
});
xhr.addEventListener('error', () => {
showStatus('Upload failed: Network error', 'error');
progressWrapper.style.display = 'none';
finishUpload(false);
uploadBtn.disabled = true;
chooseBtn.disabled = false;
});
@@ -654,11 +686,43 @@
if (bannerUploadBtn) bannerUploadBtn.disabled = true;
if (bannerChooseBtn) bannerChooseBtn.disabled = true;
if (bannerProgressWrapper) { bannerProgressWrapper.style.display = 'flex'; }
const bannerLabel = (inner) => '<i class="fa-solid fa-image"></i> ' + inner;
if (bannerProgressWrapper) { bannerProgressWrapper.style.display = 'block'; bannerProgressWrapper.setAttribute('aria-valuenow', '0'); }
if (bannerProgressFill) bannerProgressFill.style.width = '0%';
if (bannerProgressText) bannerProgressText.textContent = '0%';
if (bannerProgressText) bannerProgressText.innerHTML = bannerLabel('0%');
showBannerStatus('Uploading...', '');
// Show the cropped banner on the live card right away; roll back if the upload fails
const liveCard = document.getElementById('live-profile-preview-box');
const prevBanner = liveCard ? {
img: liveCard.style.getPropertyValue('--author-banner'),
size: liveCard.style.getPropertyValue('--author-banner-size'),
pos: liveCard.style.getPropertyValue('--author-banner-position')
} : null;
const localBannerUrl = URL.createObjectURL(payload.file);
if (liveCard) {
liveCard.classList.add('is-banner-uploading');
liveCard.style.setProperty('--author-banner', `url('${localBannerUrl}')`);
liveCard.style.setProperty('--author-banner-size', payload.banner_size);
liveCard.style.setProperty('--author-banner-position', payload.banner_position);
}
const finishBannerUpload = (ok) => {
if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none';
if (bannerChooseBtn) bannerChooseBtn.disabled = false;
if (liveCard) {
liveCard.classList.remove('is-banner-uploading');
if (!ok && prevBanner) {
['--author-banner', '--author-banner-size', '--author-banner-position'].forEach((prop, i) => {
const v = [prevBanner.img, prevBanner.size, prevBanner.pos][i];
if (v) liveCard.style.setProperty(prop, v); else liveCard.style.removeProperty(prop);
});
}
}
// The server URL replaced the local one on success; give the browser time to swap before freeing it
setTimeout(() => URL.revokeObjectURL(localBannerUrl), ok ? 5000 : 0);
};
let bannerOk = false;
const formData = new FormData();
formData.append('file', payload.file);
if (payload.file_orig) formData.append('file_orig', payload.file_orig);
@@ -671,7 +735,9 @@
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
if (bannerProgressFill) bannerProgressFill.style.width = pct + '%';
if (bannerProgressText) bannerProgressText.textContent = pct + '%';
if (bannerProgressWrapper) bannerProgressWrapper.setAttribute('aria-valuenow', String(pct));
// At 100% the bytes are sent but the server is still processing
if (bannerProgressText) bannerProgressText.innerHTML = bannerLabel(pct >= 100 ? '<i class="fa-solid fa-spinner fa-spin"></i>' : pct + '%');
}
});
@@ -679,6 +745,7 @@
try {
const res = JSON.parse(xhr.responseText);
if (xhr.status === 200 && res.success) {
bannerOk = true;
showBannerStatus(res.msg || 'Banner uploaded!', 'success');
const bannerPreview = document.getElementById('banner-preview');
@@ -710,7 +777,19 @@
}
const existingRemoveBtn = document.getElementById('banner-remove-btn');
if (!existingRemoveBtn) {
const descControls = document.getElementById('desc-edit-controls');
if (!existingRemoveBtn && descControls) {
// Live card: add Reposition / Remove next to Change Banner, same as the server render
const mk = (id, cls, icon, label) => {
const b = document.createElement('button');
b.type = 'button'; b.id = id; b.className = cls;
b.style.cssText = 'padding: 4px 10px; font-size: 0.8em;';
b.innerHTML = `<i class="fa-solid ${icon}"></i> ${label}`;
return b;
};
descControls.appendChild(mk('banner-edit-btn', 'button button-sm', 'fa-arrows-up-down-left-right', 'Reposition'));
descControls.appendChild(mk('banner-remove-btn', 'button button-danger button-sm', 'fa-trash', 'Remove Banner'));
} else if (!existingRemoveBtn) {
const actionsDiv = bannerUploadBtn ? bannerUploadBtn.closest('.avatar-upload-actions') : null;
if (actionsDiv) {
const editBtn = document.createElement('button');
@@ -762,14 +841,12 @@
showBannerStatus('Upload failed: Invalid response', 'error');
}
if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none';
if (bannerChooseBtn) bannerChooseBtn.disabled = false;
finishBannerUpload(bannerOk);
});
xhr.addEventListener('error', () => {
showBannerStatus('Upload failed: Network error', 'error');
if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none';
if (bannerChooseBtn) bannerChooseBtn.disabled = false;
finishBannerUpload(false);
});
xhr.open('POST', '/api/v2/settings/uploadBanner');
@@ -1604,9 +1681,12 @@
onaraEnabledToggle.checked = stored !== null ? stored === 'true' : sessionOnara;
// Reflect current state into window.onara so isOnaraActive() sees it
window.onara = onaraEnabledToggle.checked;
try {
document.cookie = `f0ck_onara=${onaraEnabledToggle.checked ? '1' : '0'}; path=/; max-age=31536000; SameSite=Lax`;
} catch {}
// Only persist an explicit choice; without one the config default keeps applying
if (stored !== null) {
try {
document.cookie = `f0ck_onara=${onaraEnabledToggle.checked ? '1' : '0'}; path=/; max-age=31536000; SameSite=Lax`;
} catch {}
}
onaraEnabledToggle.addEventListener('change', () => {
const enabled = onaraEnabledToggle.checked;
@@ -1950,6 +2030,17 @@
const liveDesc = document.getElementById('live-preview-description');
const descControls = document.getElementById('desc-edit-controls');
// Inline-editable profile fields are text inputs: keep their key events away from the site-wide
// shortcut handlers (bubble-phase on document/window), not all of which skip contenteditable.
// The fields' own listeners (Enter / Ctrl+Enter to save, Escape) sit on the element and still run.
document.querySelectorAll('.settings [contenteditable="true"]').forEach((el) => {
if (el.dataset.keysIsolated) return;
el.dataset.keysIsolated = '1';
['keydown', 'keyup', 'keypress'].forEach((type) => {
el.addEventListener(type, (e) => e.stopPropagation());
});
});
if (liveDesc && descriptionTextarea) {
liveDesc.addEventListener('focus', () => {
if (descControls) descControls.style.display = 'flex';
@@ -2072,7 +2163,9 @@
if (passwordForm) {
passwordForm.addEventListener('submit', async (e) => {
e.preventDefault();
const current_password = document.getElementById('current_password').value;
// Absent on passkey-only accounts (no current password; setting one re-enables password login)
const currentInput = document.getElementById('current_password');
const current_password = currentInput ? currentInput.value : '';
const new_password = document.getElementById('new_password').value;
const new_password_confirm = document.getElementById('new_password_confirm').value;
@@ -2082,6 +2175,7 @@
}
const btn = passwordForm.querySelector('button');
const btnLabel = btn.textContent;
btn.disabled = true;
btn.textContent = 'Updating...';
@@ -2098,6 +2192,8 @@
if (data.success) {
showAccountStatus(passwordStatus, data.msg || 'Password updated correctly', 'success');
passwordForm.reset();
// Password login was off: reload so the account section shows the normal state again
if (!currentInput) setTimeout(() => location.reload(), 900);
} else {
showAccountStatus(passwordStatus, data.msg || 'Failed to update password', 'error');
}
@@ -2105,7 +2201,7 @@
showAccountStatus(passwordStatus, 'Request failed', 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Update Password';
btn.textContent = btnLabel;
}
});
}