diff --git a/migrations/0004_add_user_profiles.sql b/migrations/0004_add_user_profiles.sql new file mode 100644 index 0000000..c5233ba --- /dev/null +++ b/migrations/0004_add_user_profiles.sql @@ -0,0 +1,21 @@ +-- Migration 0004: Add user profile fields and avatars table + +ALTER TABLE users ADD COLUMN bio TEXT; +ALTER TABLE users ADD COLUMN avatar_url TEXT; +ALTER TABLE users ADD COLUMN is_teacher INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN github_username TEXT; +ALTER TABLE users ADD COLUMN discord_username TEXT; +ALTER TABLE users ADD COLUMN slack_username TEXT; +ALTER TABLE users ADD COLUMN expertise TEXT; +ALTER TABLE users ADD COLUMN is_profile_public INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS avatars ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + avatar_url TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_avatars_user ON avatars(user_id); +CREATE INDEX IF NOT EXISTS idx_users_public ON users(is_profile_public); diff --git a/public/js/layout.js b/public/js/layout.js index 774f345..ba0cfe1 100644 --- a/public/js/layout.js +++ b/public/js/layout.js @@ -53,17 +53,18 @@ function initializeDarkMode() { } // ── Layout injection ────────────────────────────────────────────────── -async function inject(id, file, callback) { +const _navbarPromise = fetch('/partials/navbar.html'); +const _footerPromise = fetch('/partials/footer.html'); + +async function inject(id, fetchPromise, callback) { const el = document.getElementById(id); if (!el) return; try { - const res = await fetch(file); + const res = await fetchPromise; el.innerHTML = await res.text(); - if (callback && typeof callback === 'function') { - callback(); - } - } catch (err) { - console.error(`Failed to load ${file}:`, err); + if (typeof callback === 'function') callback(); + } catch (e) { + console.error('Layout inject error:', e); } } @@ -76,6 +77,48 @@ window.toggleProfileDropdown = function () { }; // ── Update auth section with profile ───────────────────────────────── +// ── Avatar helpers ─────────────────────────────────────────────────── +function _setNavAvatar(initialsId, imgId, name, username, avatarUrl) { + const initials = (name || username || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase(); + const initialsEl = document.getElementById(initialsId); + const imgEl = document.getElementById(imgId); + if (!initialsEl) return; + if (avatarUrl) { + initialsEl.classList.add('hidden'); + if (imgEl) { + imgEl.src = avatarUrl; + imgEl.classList.remove('hidden'); + imgEl.onerror = function () { + this.classList.add('hidden'); + initialsEl.classList.remove('hidden'); + }; + } + } else { + initialsEl.textContent = initials; + initialsEl.classList.remove('hidden'); + if (imgEl) imgEl.classList.add('hidden'); + } +} + +// Keep the old profile-avatar element (button circle) in sync too +function _setNavButtonAvatar(avatarElId, name, username, avatarUrl) { + const el = document.getElementById(avatarElId); + if (!el) return; + const initials = (name || username || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase(); + if (avatarUrl) { + // Build element programmatically — avoids XSS via inline onerror with user-derived initials + const img = document.createElement('img'); + img.src = avatarUrl; + img.className = 'w-full h-full object-cover rounded-full'; + img.alt = ''; + img.addEventListener('error', () => { el.textContent = initials; }); + el.innerHTML = ''; + el.appendChild(img); + } else { + el.textContent = initials; + } +} + function updateAuthSection() { const { token, user } = getAuth(); const notLoggedInDiv = document.getElementById('auth-not-logged-in'); @@ -84,39 +127,52 @@ function updateAuthSection() { const mobileLoggedIn = document.getElementById('mobile-auth-logged-in'); if (token && user) { - // User is logged in - const firstName = user.name ? user.name.split(' ')[0] : user.username; - const firstLetter = firstName.charAt(0).toUpperCase(); + const firstName = user.name ? user.name.split(' ')[0] : user.username; + const avatarUrl = user.avatar_url || ''; + const role = user.role || ''; - // Desktop view + // Desktop if (notLoggedInDiv) notLoggedInDiv.classList.add('hidden'); - if (loggedInDiv) loggedInDiv.classList.remove('hidden'); - - const avatarEl = document.getElementById('profile-avatar'); - const greetingEl = document.getElementById('profile-greeting'); - const usernameEl = document.getElementById('dropdown-username'); - - if (avatarEl) avatarEl.textContent = firstLetter; + if (loggedInDiv) loggedInDiv.classList.remove('hidden'); + + // Trigger button circle (existing id="profile-avatar") + _setNavButtonAvatar('profile-avatar', user.name, user.username, avatarUrl); + + const greetingEl = document.getElementById('profile-greeting'); + const usernameEl = document.getElementById('dropdown-username'); + const roleEl = document.getElementById('dropdown-user-role'); + if (greetingEl) greetingEl.textContent = `Hi, ${firstName}`; - if (usernameEl) usernameEl.textContent = user.username; + if (usernameEl) usernameEl.textContent = `@${user.username}`; + if (roleEl) roleEl.textContent = role; - // Mobile view + // Dropdown avatar (new elements) + _setNavAvatar('dropdown-avatar-initials', 'dropdown-avatar-img', user.name, user.username, avatarUrl); + + // "My Public Profile" deep link + const profileLink = document.getElementById('dropdown-view-profile-link'); + if (profileLink) profileLink.href = `/public-profile.html?username=${encodeURIComponent(user.username)}`; + + // Mobile if (mobileNotLoggedIn) mobileNotLoggedIn.classList.add('hidden'); - if (mobileLoggedIn) mobileLoggedIn.classList.remove('hidden'); - - const mobileAvatarEl = document.getElementById('mobile-profile-avatar'); + if (mobileLoggedIn) mobileLoggedIn.classList.remove('hidden'); + + _setNavAvatar('mobile-avatar-initials', 'mobile-avatar-img', user.name, user.username, avatarUrl); + const mobileGreetingEl = document.getElementById('mobile-profile-greeting'); - - if (mobileAvatarEl) mobileAvatarEl.textContent = firstLetter; + const mobileRoleEl = document.getElementById('mobile-user-role'); if (mobileGreetingEl) mobileGreetingEl.textContent = `Hi, ${firstName}`; + if (mobileRoleEl) mobileRoleEl.textContent = role; + + const mobileProfileLink = document.getElementById('mobile-view-profile-link'); + if (mobileProfileLink) mobileProfileLink.href = `/public-profile.html?username=${encodeURIComponent(user.username)}`; startUnreadPolling(); } else { - // User is not logged in if (notLoggedInDiv) notLoggedInDiv.classList.remove('hidden'); - if (loggedInDiv) loggedInDiv.classList.add('hidden'); + if (loggedInDiv) loggedInDiv.classList.add('hidden'); if (mobileNotLoggedIn) mobileNotLoggedIn.classList.remove('hidden'); - if (mobileLoggedIn) mobileLoggedIn.classList.add('hidden'); + if (mobileLoggedIn) mobileLoggedIn.classList.add('hidden'); const badge = document.getElementById('notif-unread-badge'); if (badge) badge.classList.add('hidden'); @@ -253,8 +309,10 @@ async function initLayout() { if (!layoutInitPromise) { layoutInitPromise = (async () => { initializeDarkMode(); - await inject('site-navbar', '/partials/navbar.html', updateAuthSection); - await inject('site-footer', '/partials/footer.html'); + await Promise.all([ + inject('site-navbar', _navbarPromise, updateAuthSection), + inject('site-footer', _footerPromise), + ]); updateDarkModeIcon(); })(); } diff --git a/public/partials/navbar.html b/public/partials/navbar.html index acaf410..4f51df0 100644 --- a/public/partials/navbar.html +++ b/public/partials/navbar.html @@ -84,8 +84,8 @@ Features - - Users + + Members Success Stories @@ -232,19 +232,53 @@ - @@ -294,20 +328,42 @@