From e53baf8ba81ee1aaa5a74330df3bc47c80a51575 Mon Sep 17 00:00:00 2001 From: Ghanshyam Singh Date: Mon, 8 Jun 2026 22:05:27 +0530 Subject: [PATCH 1/2] feat:user-profile-directory --- migrations/0004_add_user_profiles.sql | 21 + public/js/layout.js | 111 +++-- public/partials/navbar.html | 102 +++- public/profile.html | 680 ++++++++++++++++++++++++++ public/public-profile.html | 266 ++++++++++ public/users.html | 232 +++++++++ schema.sql | 40 +- src/worker.py | 359 +++++++++++++- tests/test_api_activities.py | 11 +- tests/test_api_admin.py | 3 +- tests/test_api_auth.py | 1 + tests/test_api_profile.py | 407 +++++++++++++++ wrangler.toml | 6 + 13 files changed, 2158 insertions(+), 81 deletions(-) create mode 100644 migrations/0004_add_user_profiles.sql create mode 100644 public/profile.html create mode 100644 public/public-profile.html create mode 100644 public/users.html create mode 100644 tests/test_api_profile.py 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..45734b4 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,41 @@ 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) { + el.innerHTML = ``; + } else { + el.textContent = initials; + } +} + function updateAuthSection() { const { token, user } = getAuth(); const notLoggedInDiv = document.getElementById('auth-not-logged-in'); @@ -84,39 +120,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 +302,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..e67d699 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 @@ diff --git a/public/profile.html b/public/profile.html index 660bdec..d37ddb8 100644 --- a/public/profile.html +++ b/public/profile.html @@ -77,11 +77,11 @@

Upload a photo to use as your profile picture.

- - @@ -192,7 +192,7 @@

I am a teacher / instructor

Shows a teacher badge on your public profile.

- @@ -238,7 +238,7 @@

Edit Notification Preferences - @@ -330,11 +330,11 @@

- - @@ -403,7 +403,7 @@

${esc(msg)}`; + el.innerHTML = `${esc(msg)}`; el.classList.remove('hidden'); clearTimeout(el._t); el._t = setTimeout(() => el.classList.add('hidden'), 5000); diff --git a/public/public-profile.html b/public/public-profile.html index af893f2..a0eab6f 100644 --- a/public/public-profile.html +++ b/public/public-profile.html @@ -188,13 +188,20 @@

} // Avatar + const _initials = (p.name || p.username || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase(); + const _imgEl = document.getElementById('profile-avatar-img'); + const _initialsEl = document.getElementById('profile-initials'); if (p.avatar_url) { - document.getElementById('profile-avatar-img').src = p.avatar_url; - document.getElementById('profile-avatar-img').classList.remove('hidden'); - document.getElementById('profile-initials').classList.add('hidden'); + _imgEl.src = p.avatar_url; + _imgEl.classList.remove('hidden'); + _initialsEl.classList.add('hidden'); + _imgEl.addEventListener('error', () => { + _imgEl.classList.add('hidden'); + _initialsEl.textContent = _initials; + _initialsEl.classList.remove('hidden'); + }); } else { - const initials = (p.name || p.username || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase(); - document.getElementById('profile-initials').textContent = initials; + _initialsEl.textContent = _initials; } // Bio diff --git a/public/users.html b/public/users.html index 152bb1a..678f24e 100644 --- a/public/users.html +++ b/public/users.html @@ -53,10 +53,11 @@

Member Directory

- diff --git a/tests/test_api_profile.py b/tests/test_api_profile.py index 56bc3ef..1d7e9c0 100644 --- a/tests/test_api_profile.py +++ b/tests/test_api_profile.py @@ -405,3 +405,20 @@ async def test_no_r2_falls_back_to_base64(self): assert r.status == 200 url = _parse(r)["data"]["avatar_url"] assert url.startswith("data:image/png;base64,") + + async def test_r2_upload_returns_public_url(self): + from unittest.mock import AsyncMock, MagicMock + small_b64 = base64.b64encode(b"\x89PNG fake").decode() + env = make_env(db=MockDB([make_stmt(), make_stmt()])) + mock_r2 = MagicMock() + mock_r2.put = AsyncMock(return_value=None) + env.R2 = mock_r2 + env.R2_PUBLIC_URL = "https://pub-abc123.r2.dev" + r = await worker.api_upload_avatar( + self._req({"image_data": small_b64, "image_type": "image/png"}), env + ) + assert r.status == 200 + url = _parse(r)["data"]["avatar_url"] + assert url.startswith("https://pub-abc123.r2.dev/avatars/") + assert url.endswith(".png") + mock_r2.put.assert_awaited_once() diff --git a/wrangler.toml b/wrangler.toml index 7e4d32a..382f62e 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -19,7 +19,12 @@ database_id = "a0021f2e-a8cc-4e20-8910-3c7290ba47a6" migrations_dir = "migrations" -# Create bucket: wrangler r2 bucket create learn-avatars +# To enable R2 avatar storage: +# 1. Create the bucket: wrangler r2 bucket create learn-avatars +# 2. Enable public access in the Cloudflare dashboard for the bucket +# 3. Add R2_PUBLIC_URL as an environment variable (Workers → Settings → Variables) +# e.g. https://pub-xxxxx.r2.dev or your custom domain +# Without R2_PUBLIC_URL, the worker falls back to base64 data-URLs stored in D1. # [[r2_buckets]] # binding = "R2" # bucket_name = "learn-avatars"