diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 69331b8..2fa0979 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -6140,6 +6140,7 @@ video.emoji { height: auto; /* vertical-align: middle; */ display: inline-block; + max-width: 200px; } /* video in emoji autocomplete dropdown */ diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 71f6d9b..662ce88 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -7343,11 +7343,20 @@ class NotificationSystem { } } } else if (data.type === 'profile_update') { - const { display_name, user, banner_file, banner_position, banner_size } = data.data; + const { display_name, user, banner_file, banner_position, banner_size, avatar_file, username_color, description } = data.data; if (display_name !== undefined) this.applyDisplayNameUpdate(display_name, user); if (banner_file !== undefined || banner_position !== undefined || banner_size !== undefined) { this.applyBannerUpdate(banner_file, banner_position, banner_size, user); } + if (avatar_file !== undefined) { + this.applyAvatarUpdate(avatar_file, user); + } + if (username_color !== undefined) { + this.applyUsernameColorUpdate(username_color, user); + } + if (description !== undefined) { + this.applyDescriptionUpdate(description, user); + } // Sync preferences to global session object for client-side gating (like DND) if (window.f0ckSession && data.data.user_id === window.f0ckSession.id) { for (const key in data.data) { @@ -7377,14 +7386,28 @@ class NotificationSystem { // Capture old value before overwriting — needed for thumb matching below const oldDisplayName = window.f0ckSession?.display_name || null; if (window.f0ckSession) window.f0ckSession.display_name = display_name || null; - const navBtn = document.getElementById('nav-user-toggle'); - if (navBtn) { - const nameSpan = document.getElementById('nav-display-name'); - if (nameSpan) { - nameSpan.textContent = display_name || window.f0ckSession?.user || ''; - } - } + const userName = user || window.f0ckSession?.user; + + // Update ALL nav-display-name instances (handles the profile page having a duplicate ID) + document.querySelectorAll('#nav-display-name').forEach(nameSpan => { + // preserve the badges (Admin, Mod) + for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) { + let node = nameSpan.childNodes[i]; + if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) { + // Match and preserve any non-breaking spaces at the start (from admin/mod badges) + const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)')); + node.nodeValue = (match ? match[1] : '') + (display_name || userName || ''); + break; + } + } + }); + + const bracket = document.getElementById('username-bracket'); + if (bracket) { + bracket.style.display = display_name ? 'inline' : 'none'; + } + if (userName) { const userHref = `/user/${userName}`; // Sidebar / thread comment author links @@ -7406,6 +7429,105 @@ class NotificationSystem { } }; + // Helper: apply a username color change to relevant DOM nodes + this.applyUsernameColorUpdate = (username_color, user) => { + const userName = user || window.f0ckSession?.user; + if (!userName) return; + + const userHref = `/user/${userName}`; + document.querySelectorAll(`a.comment-author[href="${userHref}"]`).forEach(el => { + if (username_color) { + el.style.color = username_color; + } else { + el.style.removeProperty('color'); + } + }); + + // Handle the item view username link + const aUsername = document.getElementById('a_username'); + if (aUsername && aUsername.getAttribute('href') === userHref) { + if (username_color) { + aUsername.style.color = username_color; + } else { + aUsername.style.removeProperty('color'); + } + } + + // Handle favs avatar border color + document.querySelectorAll('#favs img').forEach(img => { + const link = img.closest('a'); + if (link && (link.href.includes(`/user/${userName.toLowerCase()}`) || link.href.includes(`/user/${userName}`))) { + if (username_color) { + img.style.borderColor = username_color; + } else { + img.style.removeProperty('border-color'); + } + } + }); + + // Update the infobox block variables for color accents + const safeUserName = userName.replace(/"/g, '\\"'); + document.querySelectorAll(`.user-infobox-block[data-banner-user="${safeUserName}"]`).forEach(block => { + if (username_color) { + block.style.setProperty('--author-accent', username_color); + block.style.setProperty('--author-border', username_color); + } else { + block.style.setProperty('--author-accent', 'var(--accent)'); + block.style.setProperty('--author-border', 'var(--accent)'); + } + }); + + // Special case for settings page preview block + if (window.f0ckSession && userName === window.f0ckSession.user) { + const previewBlock = document.getElementById('live-profile-preview-box'); + if (previewBlock) { + if (username_color) { + previewBlock.style.setProperty('--author-accent', username_color); + previewBlock.style.setProperty('--author-border', username_color); + } else { + previewBlock.style.setProperty('--author-accent', 'var(--accent)'); + previewBlock.style.setProperty('--author-border', 'var(--accent)'); + } + } + } + + // Handle the nav-display-name span elements (profile header and top navbar) + if (window.f0ckSession && userName === window.f0ckSession.user) { + document.querySelectorAll('#nav-display-name').forEach(el => { + if (username_color) { + el.style.color = username_color; + } else { + el.style.removeProperty('color'); + } + }); + } + }; + + // Helper: apply an avatar update to relevant DOM nodes + this.applyAvatarUpdate = (avatar_file, user) => { + const userName = user || window.f0ckSession?.user; + if (!userName) return; + + const newSrc = avatar_file ? `/a/${avatar_file}?t=${Date.now()}` : '/a/default.png'; + + // Update Nav Avatar & profile header Avatar (only for current user) + if (window.f0ckSession && userName === window.f0ckSession.user) { + document.querySelectorAll('.nav-avatar-img, .profile_head_avatar img').forEach(img => img.src = newSrc); + } + + // Update globally (comments, sidebar, polls, rank, infoboxes, favs) + // We broadly target a[href^="/user/"] img to catch basically any avatar linked to the profile + const lowerHref = `/user/${userName.toLowerCase()}`; + const exactHref = `/user/${userName}`; + + document.querySelectorAll('.comment-avatar img, .sidebar-avatar, .poll-voter-avatar, .rank-avatar, .user-infobox-avatar img, #favs img, a[href^="/user/"] img').forEach(img => { + const link = img.closest('a'); + if (link && (link.href.includes(lowerHref) || link.href.includes(exactHref))) { + img.src = newSrc; + } + }); + }; + // Helper: apply banner update to relevant DOM nodes this.applyBannerUpdate = (banner_file, banner_position, banner_size, user) => { const userName = user || window.f0ckSession?.user; @@ -7439,6 +7561,73 @@ class NotificationSystem { } }; + // Helper: apply description update to relevant DOM nodes + this.applyDescriptionUpdate = (description, user) => { + const userName = user || window.f0ckSession?.user; + console.log('[SSE] applyDescriptionUpdate called for user:', userName, 'description:', description); + if (!userName) return; + + // Escape quotes for selector + const safeUserName = userName.replace(/"/g, '\\"'); + // Find the profile header for this user + const profileHead = document.querySelector(`.profile_head[data-banner-user="${safeUserName}"]`); + console.log('[SSE] applyDescriptionUpdate profileHead found?', !!profileHead); + + if (profileHead) { + const descDisplay = profileHead.querySelector('#profile-desc-display div'); + console.log('[SSE] applyDescriptionUpdate descDisplay found?', !!descDisplay); + + if (descDisplay) { + if (!description || description.trim() === '') { + descDisplay.innerHTML = 'No description'; + } else { + const tempDiv = document.createElement('div'); + tempDiv.textContent = description; + descDisplay.innerHTML = tempDiv.innerHTML.replace(/\n/g, '
'); + } + } + // Also update the edit input if it exists (for the logged in user) + if (window.f0ckSession && userName === window.f0ckSession.user) { + const input = profileHead.querySelector('#inline-desc-input'); + if (input) { + input.value = description || ''; + console.log('[SSE] applyDescriptionUpdate input updated'); + } + } + } + + // Update any item page infoboxes + document.querySelectorAll(`.user-infobox-block[data-banner-user="${safeUserName}"]`).forEach(block => { + const infoboxDesc = block.querySelector('.user-infobox-description'); + if (infoboxDesc) { + if (!description || description.trim() === '') { + infoboxDesc.innerHTML = ''; + } else { + const tempDiv = document.createElement('div'); + tempDiv.textContent = description; + infoboxDesc.innerHTML = tempDiv.innerHTML.replace(/\n/g, '
'); + } + } + }); + + // Special case for settings page preview block + if (window.f0ckSession && userName === window.f0ckSession.user) { + const previewBlock = document.getElementById('live-profile-preview-box'); + if (previewBlock) { + const previewDesc = previewBlock.querySelector('.user-infobox-description'); + if (previewDesc) { + if (!description || description.trim() === '') { + previewDesc.innerHTML = 'Welcome to my profile! This is a live preview.'; + } else { + const tempDiv = document.createElement('div'); + tempDiv.textContent = description; + previewDesc.innerHTML = tempDiv.innerHTML.replace(/\n/g, '
'); + } + } + } + } + }; + // Re-apply own banner after every PJAX navigation and bfcache restore. // Fixes the case where SSE fired on the settings page before the item page DOM was loaded. const reapplyOwnBanner = () => { diff --git a/src/avatar_handler.mjs b/src/avatar_handler.mjs index 3068d3a..2c1e389 100644 --- a/src/avatar_handler.mjs +++ b/src/avatar_handler.mjs @@ -162,6 +162,14 @@ export const handleAvatarUpload = async (req, res) => { where user_id = ${+req.session.id} `; + const payloadStr = JSON.stringify({ + user_id: req.session.id, + user: req.session.user, + avatar_file: finalFilename, + avatar: null + }); + await db`select pg_notify('profile_update', ${payloadStr})`; + console.log('[AVATAR HANDLER] Upload complete:', finalFilename); return sendJson(res, { success: true, @@ -225,6 +233,13 @@ export const handleAvatarDelete = async (req, res) => { where user_id = ${+req.session.id} `; + const payloadStr = JSON.stringify({ + user_id: req.session.id, + user: req.session.user, + avatar_file: null + }); + await db`select pg_notify('profile_update', ${payloadStr})`; + console.log('[AVATAR HANDLER] Delete complete'); return sendJson(res, { success: true, msg: 'Custom avatar removed' }, 200); } catch (err) { diff --git a/src/inc/routes/apiv2/settings.mjs b/src/inc/routes/apiv2/settings.mjs index bf6d5b0..1a1445d 100644 --- a/src/inc/routes/apiv2/settings.mjs +++ b/src/inc/routes/apiv2/settings.mjs @@ -331,6 +331,14 @@ export default router => { `; // Sync session immediately if (req.session) req.session.username_color = color; + + const payloadStr = JSON.stringify({ + user_id: req.session.id, + user: req.session.user, + username_color: color + }); + await db`select pg_notify('profile_update', ${payloadStr})`; + return res.json({ success: true, color }, 200); } catch (e) { console.error('Update Username Color error:', e); @@ -420,6 +428,14 @@ export default router => { `; // Sync session immediately if (req.session) req.session.display_name = cleanDisplayName; + + const payloadStr = JSON.stringify({ + user_id: req.session.id, + user: req.session.user, + display_name: cleanDisplayName + }); + await db`select pg_notify('profile_update', ${payloadStr})`; + return res.json({ success: true, display_name: cleanDisplayName, msg: 'Display name updated successfully' }, 200); } catch (e) { console.error('Update Display Name error:', e); @@ -448,6 +464,14 @@ export default router => { `; // Sync session immediately if (req.session) req.session.description = cleanDescription; + + const payloadStr = JSON.stringify({ + user_id: req.session.id, + user: req.session.user, + description: cleanDescription + }); + await db`select pg_notify('profile_update', ${payloadStr})`; + return res.json({ success: true, description: cleanDescription }, 200); } catch (e) { console.error('Update Description error:', e); diff --git a/src/inc/routes/notifications.mjs b/src/inc/routes/notifications.mjs index 9dd8e4b..cd4fa15 100644 --- a/src/inc/routes/notifications.mjs +++ b/src/inc/routes/notifications.mjs @@ -102,7 +102,10 @@ db.listen('profile_update', (payload) => { display_name: data.display_name, banner_file: data.banner_file, banner_position: data.banner_position, - banner_size: data.banner_size + banner_size: data.banner_size, + avatar_file: data.avatar_file, + username_color: data.username_color, + description: data.description }; client.send({ type: 'profile_update', data: publicData }); } diff --git a/views/user-partial.html b/views/user-partial.html index 6090c25..8637155 100644 --- a/views/user-partial.html +++ b/views/user-partial.html @@ -422,7 +422,37 @@ }); const data = await res.json(); if (data.success) { - location.reload(); + let objectUrl = '/a/' + (data.avatar_file || data.file) + '?t=' + Date.now(); + let wrapper = document.querySelector('.avatar-image-wrapper'); + if (wrapper) { + let placeholder = wrapper.querySelector('.avatar-placeholder'); + if (placeholder) { + let img = document.createElement('img'); + img.className = 'profile-avatar-img'; + img.src = objectUrl; + wrapper.insertBefore(img, placeholder); + placeholder.remove(); + } else { + let img = wrapper.querySelector('.profile-avatar-img'); + if (img) img.src = objectUrl; + } + } + + document.querySelectorAll('.nav-avatar-img').forEach(img => img.src = objectUrl); + document.querySelectorAll('.comment-avatar img, .sidebar-avatar, .poll-voter-avatar, .rank-avatar').forEach(img => { + // Only replace if it matches the user's old avatar file, or if they didn't have one, just match if it links to their profile + const oldFile = '{{ user.avatar_file }}'; + const oldId = '{{ user.avatar }}'; + const isOldImage = (oldFile && img.src.includes('/a/' + oldFile)) || (oldId && img.src.includes('/t/' + oldId + '.webp')); + const link = img.closest('a'); + const isUserLink = link && link.href.includes('/user/{{ user.user }}'); + + if (isOldImage || isUserLink) { + img.src = objectUrl; + } + }); + avatarEditBtn.innerHTML = ''; + avatarEditBtn.disabled = false; } else { alert(data.msg || 'Failed to upload avatar'); avatarEditBtn.innerHTML = ''; @@ -471,7 +501,27 @@ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token }, body: JSON.stringify(cPayload) }); - location.reload(); + nameSaveBtn.disabled = false; + nameContainer.style.display = 'flex'; + nameEditContainer.style.display = 'none'; + + const nameSpan = document.getElementById('nav-display-name'); + if (nameSpan) { + nameSpan.style.color = color; + for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) { + let node = nameSpan.childNodes[i]; + if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) { + // Match and preserve any non-breaking spaces at the start (from admin/mod badges) + const match = node.nodeValue.match(new RegExp('^(\\\\s*)(.*)')); + node.nodeValue = (match ? match[1] : '') + (displayName ? displayName : '{{ user.user }}'); + break; + } + } + } + const bracket = document.getElementById('username-bracket'); + if (bracket) { + bracket.style.display = displayName ? 'inline' : 'none'; + } } catch (err) { alert('Error saving name/color'); nameSaveBtn.disabled = false; @@ -507,7 +557,17 @@ }); const data = await res.json(); if (data.success) { - location.reload(); + descSaveBtn.disabled = false; + descDisplay.style.display = 'block'; + descEditContainer.style.display = 'none'; + const descTextDiv = descDisplay.querySelector('div'); + if (description.trim() === '') { + descTextDiv.innerHTML = 'No description'; + } else { + const tempDiv = document.createElement('div'); + tempDiv.textContent = description; + descTextDiv.innerHTML = tempDiv.innerHTML.replace(new RegExp('\\\\n', 'g'), '
'); + } } else { alert(data.msg || 'Failed to update description'); descSaveBtn.disabled = false;