From e9ddf8bb34343f4310bb7b73bb1c1a76a9cd029d Mon Sep 17 00:00:00 2001 From: King Omar Date: Sun, 12 Jul 2026 21:14:07 +1000 Subject: [PATCH] Add user profile settings with photo upload - Profile avatar button fixed top-left on all pages (initials fallback with hashed color when no photo set) - Profile modal: change display name, upload photo, switch theme - PATCH /api/users/:id updates name in D1; syncs back to currentUser - POST /api/users/:id/avatar uploads image to R2 as avatars/{userId}.ext, stores URL in D1 users.avatar_url (new column via ALTER TABLE) - Author avatars shown on proposal cards, modal view, and comments - serveMedia fixed to use R2 httpMetadata contentType and full key path Co-Authored-By: Claude Sonnet 4.6 --- index.html | 215 +++++++++++++++++++++++++++++++++++++++++++++++++++-- worker.js | 75 +++++++++++++++---- 2 files changed, 270 insertions(+), 20 deletions(-) diff --git a/index.html b/index.html index 568ebe0..ffccfb7 100644 --- a/index.html +++ b/index.html @@ -250,6 +250,54 @@ .postcode-badge span{color:#ff0099} /* ── Inline milestone strip on proposal cards ── */ .card-milestone{margin-top:8px} +/* ── Profile button ── */ +#profile-btn{position:fixed;top:14px;left:14px;z-index:9999;width:38px;height:38px;border-radius:50%;border:2px solid #444;cursor:pointer;padding:0;overflow:hidden;background:#111;display:flex;align-items:center;justify-content:center;transition:border-color .2s} +#profile-btn:hover{border-color:#ff0099} +#profile-btn img{width:100%;height:100%;object-fit:cover} +#profile-btn .pb-initials{font-family:'Roboto Mono',monospace;font-size:13px;font-weight:700;color:#ff0099;line-height:1} +/* ── Profile modal ── */ +.profile-overlay{position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:9998;display:none;align-items:center;justify-content:center;padding:16px} +.profile-overlay.open{display:flex} +.profile-modal{background:#000;border:2px solid #ff0099;max-width:380px;width:100%;font-family:'Roboto Mono',monospace;max-height:90vh;overflow-y:auto} +.profile-modal-head{background:#ff0099;color:#000;padding:12px 16px;display:flex;justify-content:space-between;align-items:center} +.profile-modal-head h3{margin:0;font-size:14px;font-weight:700;letter-spacing:1px} +.profile-modal-close{background:none;border:none;color:#000;font-size:20px;cursor:pointer;line-height:1;padding:0 2px} +.profile-modal-body{padding:20px} +.profile-avatar-wrap{display:flex;flex-direction:column;align-items:center;gap:10px;margin-bottom:20px} +.profile-avatar-lg{width:80px;height:80px;border-radius:50%;border:2px solid #ff0099;overflow:hidden;display:flex;align-items:center;justify-content:center;background:#111;flex-shrink:0} +.profile-avatar-lg img{width:100%;height:100%;object-fit:cover} +.profile-avatar-lg .pf-initials{font-size:28px;font-weight:700;color:#ff0099} +.profile-upload-btn{background:none;border:1px solid #444;color:#aaa;padding:6px 14px;font-family:'Roboto Mono',monospace;font-size:11px;cursor:pointer;letter-spacing:.5px;position:relative;overflow:hidden} +.profile-upload-btn input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;font-size:0} +.profile-upload-btn:hover{border-color:#ff0099;color:#fff} +.profile-field{margin-bottom:14px} +.profile-field label{display:block;font-size:10px;color:#aaa;text-transform:uppercase;letter-spacing:1px;margin-bottom:5px} +.profile-input{width:100%;background:#0a0a0a;border:1px solid #333;color:#fff;padding:9px 12px;font-family:'Roboto Mono',monospace;font-size:13px;box-sizing:border-box} +.profile-input:focus{outline:none;border-color:#ff0099} +.profile-theme-row{display:flex;gap:8px} +.profile-theme-btn{flex:1;background:none;border:1px solid #333;color:#aaa;padding:7px;font-family:'Roboto Mono',monospace;font-size:12px;cursor:pointer;transition:all .15s} +.profile-theme-btn.active{background:#ff0099;color:#000;border-color:#ff0099} +.profile-save-btn{width:100%;background:#ff0099;color:#000;border:none;padding:12px;font-family:'Roboto Mono',monospace;font-size:14px;font-weight:700;cursor:pointer;letter-spacing:1px;margin-top:16px} +.profile-save-btn:hover{background:#ff33aa} +.profile-save-btn:disabled{opacity:.5;cursor:not-allowed} +.profile-msg{font-size:11px;min-height:16px;margin-top:6px;font-family:'Roboto Mono',monospace} +.profile-msg.ok{color:#11cc77} +.profile-msg.err{color:#ff0099} +.profile-uid{font-size:10px;color:#444;margin-top:14px;word-break:break-all} +/* ── Avatars on cards/comments ── */ +.author-avatar{width:20px;height:20px;border-radius:50%;object-fit:cover;vertical-align:middle;margin-right:6px;border:1px solid #333;flex-shrink:0} +.author-initial{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;font-size:9px;font-weight:700;color:#000;vertical-align:middle;margin-right:6px;flex-shrink:0} +.proposal-author{display:flex;align-items:center} +/* ── LIGHT MODE: profile ── */ +html[data-theme="light"] #profile-btn{background:#E8E6E2;border-color:#BBB} +html[data-theme="light"] .profile-overlay{background:rgba(0,0,0,.55)} +html[data-theme="light"] .profile-modal{background:#F5F3EF} +html[data-theme="light"] .profile-input{background:#FFF;border-color:#CCC;color:#111} +html[data-theme="light"] .profile-upload-btn{border-color:#CCC;color:#555} +html[data-theme="light"] .profile-upload-btn:hover{border-color:#ff0099;color:#111} +html[data-theme="light"] .profile-theme-btn{border-color:#CCC;color:#555} +html[data-theme="light"] .profile-avatar-lg{background:#E8E6E2} +html[data-theme="light"] .profile-uid{color:#999} /* ── LIGHT MODE: index.html-specific overrides ── */ html[data-theme="light"] .stats-bar{background:#E8E6E2;border-color:#CCC;color:#555} html[data-theme="light"] .stat-divider{color:#BBB} @@ -389,6 +437,42 @@ html[data-theme="light"] .join-check label{color:#555} + + + +
+
+
+

YOUR PROFILE

+ +
+
+
+
+ +
+
+
+ + +
+
+ +
+ + +
+
+
+
+ +
+
+
+

RADICAL

@@ -837,6 +921,7 @@ function toggleTheme() { updateUIText(); await loadUserData(); if (currentUser?.theme) applyTheme(currentUser.theme); + renderProfileBtn(); loadVerifiedUsers(); setupEventListeners(); await Promise.allSettled([fetchProposals(), fetchLeaderboard(), fetchStats()]); @@ -998,6 +1083,15 @@ async function loadUserData() { const data = await response.json(); console.log("User creation successful:", data); + // Sync server-side fields back to currentUser + if (data.avatar_url && !currentUser.avatar_url) { + currentUser.avatar_url = data.avatar_url; + localStorage.setItem('radical_user', JSON.stringify(currentUser)); + } + if (data.name && data.name !== currentUser.name) { + currentUser.name = data.name; + localStorage.setItem('radical_user', JSON.stringify(currentUser)); + } return data; } catch (error) { console.error('Error creating user:', error); @@ -1357,9 +1451,12 @@ function initializeShareButtons() {} ? `across ${proposal.postcode_count} postcode${proposal.postcode_count > 1 ? 's' : ''}` : ''; + const authorAvatarHtml = proposal.author_avatar + ? `` + : makeInitialEl(proposal.author_name).outerHTML; proposalCard.innerHTML = ` ${badgeHtml} -
${proposal.author_name}
+
${authorAvatarHtml}${proposal.author_name}
${proposal.text}
${memeHtml} ${petitionInfo} @@ -2372,7 +2469,7 @@ if (twitterImageElement) { modalBody.innerHTML = ` ${badgeHtml} -
${proposal.author_name}
+
${proposal.author_avatar ? `` : ''}${proposal.author_name}
${proposal.text}
${proposal.meme_url ? `
@@ -2577,7 +2674,7 @@ function renderModalComments(container, comments) { commentsHtml += `
-
${comment.user_name}
+
${comment.user_avatar ? `` : ''}${comment.user_name}
${formatDate(comment.timestamp)}
${formatCommentText(comment.comment_text)}
@@ -2754,7 +2851,7 @@ function renderComments(container, comments) { commentsHtml += `
-
${comment.user_name}
+
${comment.user_avatar ? `` : ''}${comment.user_name}
${formatDate(comment.timestamp)}
${formatCommentText(comment.comment_text)}
@@ -2817,7 +2914,7 @@ async function submitComment(proposalId, commentText) { div.style.opacity = '0.6'; div.innerHTML = `
-
${optimisticComment.user_name}
+
${currentUser?.avatar_url ? `` : ''}${optimisticComment.user_name}
just now
${formatCommentText(commentText)}
`; @@ -3404,6 +3501,114 @@ async function handleJoinSubmit(e) { btn.textContent = 'JOIN THE REBELLION →'; } +// ── Profile helpers ────────────────────────────────────────────────────── +function avatarColor(name) { + let h = 0; + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffffffff; + const hue = Math.abs(h) % 360; + return `hsl(${hue},70%,50%)`; +} +function makeInitialEl(name) { + const el = document.createElement('span'); + el.className = 'author-initial'; + el.textContent = (name || '?')[0].toUpperCase(); + el.style.background = avatarColor(name || '?'); + return el; +} +function renderProfileBtn() { + const btn = document.getElementById('profile-btn'); + if (!btn || !currentUser) return; + btn.innerHTML = ''; + if (currentUser.avatar_url) { + const img = document.createElement('img'); + img.src = currentUser.avatar_url; + img.alt = ''; + img.onerror = () => { btn.innerHTML = ''; btn.appendChild(makeInitialEl(currentUser.name)); }; + btn.appendChild(img); + } else { + btn.appendChild(makeInitialEl(currentUser.name)); + } +} +function openProfileModal() { + if (!currentUser) return; + document.getElementById('pf-name').value = currentUser.name || ''; + document.getElementById('pf-uid').textContent = 'ID: ' + currentUser.id; + document.getElementById('pf-save-msg').textContent = ''; + document.getElementById('pf-avatar-msg').textContent = ''; + const lg = document.getElementById('pf-avatar-lg'); + lg.innerHTML = ''; + if (currentUser.avatar_url) { + const img = document.createElement('img'); img.src = currentUser.avatar_url; img.alt = ''; + img.onerror = () => { lg.innerHTML = ''; lg.appendChild(makeInitialEl(currentUser.name)); }; + lg.appendChild(img); + } else { + const sp = document.createElement('span'); sp.className = 'pf-initials'; + sp.textContent = (currentUser.name || '?')[0].toUpperCase(); + sp.style.color = avatarColor(currentUser.name || '?'); + lg.appendChild(sp); + } + const t = document.documentElement.getAttribute('data-theme') || 'dark'; + document.getElementById('pf-theme-dark').classList.toggle('active', t === 'dark'); + document.getElementById('pf-theme-light').classList.toggle('active', t === 'light'); + document.getElementById('profile-overlay').classList.add('open'); +} +function closeProfileModal(e) { + if (e && e.target !== document.getElementById('profile-overlay')) return; + document.getElementById('profile-overlay').classList.remove('open'); +} +function profileSetTheme(t) { + applyTheme(t); + document.getElementById('pf-theme-dark').classList.toggle('active', t === 'dark'); + document.getElementById('pf-theme-light').classList.toggle('active', t === 'light'); + if (currentUser) { currentUser.theme = t; localStorage.setItem('radical_user', JSON.stringify(currentUser)); } +} +async function handleAvatarUpload(input) { + const file = input.files[0]; + if (!file) return; + const msg = document.getElementById('pf-avatar-msg'); + msg.textContent = 'Uploading…'; msg.className = 'profile-msg'; + try { + const fd = new FormData(); fd.append('avatar', file); + const res = await fetch(`${API_BASE_URL}/users/${currentUser.id}/avatar`, { method: 'POST', body: fd }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Upload failed'); + currentUser.avatar_url = data.avatar_url; + localStorage.setItem('radical_user', JSON.stringify(currentUser)); + renderProfileBtn(); + // update modal preview + const lg = document.getElementById('pf-avatar-lg'); + lg.innerHTML = ''; + const img = document.createElement('img'); img.src = data.avatar_url + '?t=' + Date.now(); img.alt = ''; + lg.appendChild(img); + msg.textContent = '✓ Photo updated'; msg.className = 'profile-msg ok'; + } catch (e) { + msg.textContent = e.message || 'Upload failed'; msg.className = 'profile-msg err'; + } + input.value = ''; +} +async function saveProfile() { + const btn = document.getElementById('pf-save-btn'); + const msg = document.getElementById('pf-save-msg'); + const name = document.getElementById('pf-name').value.trim(); + if (!name) { msg.textContent = 'Name cannot be empty'; msg.className = 'profile-msg err'; return; } + btn.disabled = true; btn.textContent = 'Saving…'; + try { + const res = await fetch(`${API_BASE_URL}/users/${currentUser.id}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Save failed'); + currentUser.name = data.name; + localStorage.setItem('radical_user', JSON.stringify(currentUser)); + renderProfileBtn(); + msg.textContent = '✓ Saved'; msg.className = 'profile-msg ok'; + } catch (e) { + msg.textContent = e.message || 'Save failed'; msg.className = 'profile-msg err'; + } + btn.disabled = false; btn.textContent = 'SAVE PROFILE'; +} +
hit me
diff --git a/worker.js b/worker.js index ee3f9ed..8677871 100644 --- a/worker.js +++ b/worker.js @@ -140,6 +140,10 @@ export default { return await createOrUpdateVote(request, env); } else if (path === '/api/users' && request.method === 'POST') { return await createOrGetUser(request, env); + } else if (path.match(/^\/api\/users\/[^/]+\/avatar$/) && request.method === 'POST') { + return await uploadAvatar(request, env); + } else if (path.match(/^\/api\/users\/[^/]+$/) && request.method === 'PATCH') { + return await updateUser(request, env); } else if (path === '/api/memes' && request.method === 'POST') { return await uploadMeme(request, env); } else if (path === '/api/comment-votes' && request.method === 'POST') { @@ -199,9 +203,9 @@ async function serveMedia(request, env) { const url = new URL(request.url); const path = url.pathname; - // Extract the filename from the path - const filename = path.split('/').pop(); - + // Strip the /memes/ or /audio/ prefix to get the R2 key + const key = path.startsWith('/memes/') ? path.slice('/memes/'.length) : path.slice('/audio/'.length); + // Determine the correct bucket based on the path const bucket = path.startsWith('/memes/') ? env.MEMES_BUCKET : env.AUDIO_BUCKET; @@ -211,15 +215,14 @@ async function serveMedia(request, env) { } // Get the object from R2 - const object = await bucket.get(filename); - + const object = await bucket.get(key); + if (!object) { - console.error(`File not found: ${filename}`); + console.error(`File not found: ${key}`); return new Response('File Not Found', { status: 404 }); } - - // Determine content type based on file extension - const contentType = path.endsWith('.mp3') ? 'audio/mpeg' : 'image/jpeg'; + + const contentType = object.httpMetadata?.contentType || (path.endsWith('.mp3') ? 'audio/mpeg' : 'image/jpeg'); // Create response with appropriate headers const headers = new Headers(); @@ -1144,7 +1147,7 @@ async function getProposals(request, env) { let query = ` SELECT p.id, p.text, p.timestamp, p.trending, p.meme_url, p.flag_count, - u.name as author_name, u.id as author_id, + u.name as author_name, u.id as author_id, u.avatar_url as author_avatar, (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') as upvotes, (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'downvote') as downvotes, (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote' AND is_petition = 1) as petition_signatures, @@ -1747,7 +1750,7 @@ async function getComments(request, env) { const query = ` SELECT c.id, c.user_id, c.comment_text, c.timestamp, - u.name as user_name, + u.name as user_name, u.avatar_url as user_avatar, (SELECT COUNT(*) FROM comment_votes WHERE comment_id = c.id AND vote_type = 'upvote') as upvotes, (SELECT COUNT(*) FROM comment_votes WHERE comment_id = c.id AND vote_type = 'downvote') as downvotes FROM comments c @@ -1984,7 +1987,7 @@ async function createOrGetUser(request, env) { WHERE id = ? `; - existingUser = await env.DB.prepare(checkQuery).bind(id).first(); + existingUser = await env.DB.prepare(`SELECT id, name, avatar_url FROM users WHERE id = ?`).bind(id).first(); console.log(`Existing user check: ${existingUser ? JSON.stringify(existingUser) : 'None found'}`); } catch (userCheckError) { return createResponse({ @@ -2013,15 +2016,57 @@ async function createOrGetUser(request, env) { console.log(`Found existing user: ${existingUser.id}`); } - return createResponse({ id, name }); + const avatar_url = existingUser?.avatar_url || null; + return createResponse({ id, name, avatar_url }); } catch (error) { - return createResponse({ - error: 'Failed to process user request', + return createResponse({ + error: 'Failed to process user request', details: logError(error, { action: 'user_outer' }) }, 500); } } +async function updateUser(request, env) { + try { + const url = new URL(request.url); + const userId = url.pathname.split('/').pop(); + const { name, avatar_url } = await request.json(); + const fields = []; + const values = []; + if (name !== undefined) { fields.push('name = ?'); values.push(name.trim().slice(0, 40) || 'Citizen'); } + if (avatar_url !== undefined) { fields.push('avatar_url = ?'); values.push(avatar_url); } + if (!fields.length) return createResponse({ error: 'Nothing to update' }, 400); + values.push(userId); + await env.DB.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).bind(...values).run(); + const user = await env.DB.prepare('SELECT id, name, avatar_url FROM users WHERE id = ?').bind(userId).first(); + return createResponse(user); + } catch (e) { + return createResponse({ error: 'Failed to update user' }, 500); + } +} + +async function uploadAvatar(request, env) { + try { + if (!env.MEMES_BUCKET) return createResponse({ error: 'Storage not configured' }, 500); + const url = new URL(request.url); + const userId = url.pathname.split('/').slice(-2)[0]; + const formData = await request.formData(); + const file = formData.get('avatar'); + if (!file || !(file instanceof File)) return createResponse({ error: 'No file provided' }, 400); + const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + if (!validTypes.includes(file.type)) return createResponse({ error: 'Invalid file type' }, 400); + if (file.size > 2 * 1024 * 1024) return createResponse({ error: 'File too large (max 2MB)' }, 400); + const ext = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp' }[file.type]; + const key = `avatars/${userId}.${ext}`; + await env.MEMES_BUCKET.put(key, file.stream(), { httpMetadata: { contentType: file.type } }); + const avatar_url = `https://theradicalparty.com/memes/${key}`; + await env.DB.prepare('UPDATE users SET avatar_url = ? WHERE id = ?').bind(avatar_url, userId).run(); + return createResponse({ avatar_url }); + } catch (e) { + return createResponse({ error: 'Upload failed' }, 500); + } +} + async function verifyTemplateImage(env) { try { const key = "petition-template.png";