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 <noreply@anthropic.com>
This commit is contained in:
King Omar 2026-07-12 21:14:07 +10:00
parent 942da4b6a5
commit e9ddf8bb34
2 changed files with 270 additions and 20 deletions

View file

@ -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}
</head>
<body>
<button id="theme-toggle" onclick="toggleTheme()" aria-label="Toggle light/dark mode"></button>
<button id="profile-btn" onclick="openProfileModal()" aria-label="Open profile settings"></button>
<!-- Profile modal -->
<div class="profile-overlay" id="profile-overlay" onclick="closeProfileModal(event)">
<div class="profile-modal" onclick="event.stopPropagation()">
<div class="profile-modal-head">
<h3>YOUR PROFILE</h3>
<button class="profile-modal-close" onclick="closeProfileModal()">×</button>
</div>
<div class="profile-modal-body">
<div class="profile-avatar-wrap">
<div class="profile-avatar-lg" id="pf-avatar-lg"></div>
<label class="profile-upload-btn">
Change Photo
<input type="file" accept="image/jpeg,image/png,image/gif,image/webp" onchange="handleAvatarUpload(this)">
</label>
<div id="pf-avatar-msg" class="profile-msg"></div>
</div>
<div class="profile-field">
<label>Display Name</label>
<input type="text" id="pf-name" class="profile-input" maxlength="40" placeholder="Your name" autocomplete="off">
</div>
<div class="profile-field">
<label>Theme</label>
<div class="profile-theme-row">
<button class="profile-theme-btn" id="pf-theme-dark" onclick="profileSetTheme('dark')">🌙 Dark</button>
<button class="profile-theme-btn" id="pf-theme-light" onclick="profileSetTheme('light')">☀️ Light</button>
</div>
</div>
<div class="profile-uid" id="pf-uid"></div>
<div id="pf-save-msg" class="profile-msg"></div>
<button class="profile-save-btn" id="pf-save-btn" onclick="saveProfile()">SAVE PROFILE</button>
</div>
</div>
</div>
<div class="container">
<header>
<h1>RADICAL</h1>
@ -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() {}
? `<span class="postcode-badge">across <span>${proposal.postcode_count}</span> postcode${proposal.postcode_count > 1 ? 's' : ''}</span>`
: '';
const authorAvatarHtml = proposal.author_avatar
? `<img src="${proposal.author_avatar}" class="author-avatar" alt="" onerror="this.replaceWith(makeInitialEl('${proposal.author_name}'))">`
: makeInitialEl(proposal.author_name).outerHTML;
proposalCard.innerHTML = `
${badgeHtml}
<div class="proposal-author">${proposal.author_name}</div>
<div class="proposal-author">${authorAvatarHtml}${proposal.author_name}</div>
<div class="proposal-text">${proposal.text}</div>
${memeHtml}
${petitionInfo}
@ -2372,7 +2469,7 @@ if (twitterImageElement) {
modalBody.innerHTML = `
${badgeHtml}
<div class="proposal-author" style="font-size: 1.1rem;">${proposal.author_name}</div>
<div class="proposal-author" style="font-size: 1.1rem;">${proposal.author_avatar ? `<img src="${proposal.author_avatar}" class="author-avatar" alt="" style="width:28px;height:28px" onerror="this.style.display='none'">` : ''}${proposal.author_name}</div>
<div class="proposal-text" style="font-size: 1.3rem; margin: 20px 0;">${proposal.text}</div>
${proposal.meme_url ? `<div class="proposal-meme">
@ -2577,7 +2674,7 @@ function renderModalComments(container, comments) {
commentsHtml += `
<div class="comment-item" data-comment-id="${comment.id}">
<div class="comment-header">
<div class="comment-author">${comment.user_name}</div>
<div class="comment-author">${comment.user_avatar ? `<img src="${comment.user_avatar}" class="author-avatar" alt="" onerror="this.style.display='none'">` : ''}${comment.user_name}</div>
<div class="comment-time">${formatDate(comment.timestamp)}</div>
</div>
<div class="comment-text">${formatCommentText(comment.comment_text)}</div>
@ -2754,7 +2851,7 @@ function renderComments(container, comments) {
commentsHtml += `
<div class="comment-item" data-comment-id="${comment.id}">
<div class="comment-header">
<div class="comment-author">${comment.user_name}</div>
<div class="comment-author">${comment.user_avatar ? `<img src="${comment.user_avatar}" class="author-avatar" alt="" onerror="this.style.display='none'">` : ''}${comment.user_name}</div>
<div class="comment-time">${formatDate(comment.timestamp)}</div>
</div>
<div class="comment-text">${formatCommentText(comment.comment_text)}</div>
@ -2817,7 +2914,7 @@ async function submitComment(proposalId, commentText) {
div.style.opacity = '0.6';
div.innerHTML = `
<div class="comment-header">
<div class="comment-author">${optimisticComment.user_name}</div>
<div class="comment-author">${currentUser?.avatar_url ? `<img src="${currentUser.avatar_url}" class="author-avatar" alt="" onerror="this.style.display='none'">` : ''}${optimisticComment.user_name}</div>
<div class="comment-time">just now</div>
</div>
<div class="comment-text">${formatCommentText(commentText)}</div>`;
@ -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';
}
</script>
<div class="music-prompt" id="music-prompt">hit me</div>

View file

@ -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";