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:
parent
942da4b6a5
commit
e9ddf8bb34
2 changed files with 270 additions and 20 deletions
215
index.html
215
index.html
|
|
@ -250,6 +250,54 @@
|
||||||
.postcode-badge span{color:#ff0099}
|
.postcode-badge span{color:#ff0099}
|
||||||
/* ── Inline milestone strip on proposal cards ── */
|
/* ── Inline milestone strip on proposal cards ── */
|
||||||
.card-milestone{margin-top:8px}
|
.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 ── */
|
/* ── LIGHT MODE: index.html-specific overrides ── */
|
||||||
html[data-theme="light"] .stats-bar{background:#E8E6E2;border-color:#CCC;color:#555}
|
html[data-theme="light"] .stats-bar{background:#E8E6E2;border-color:#CCC;color:#555}
|
||||||
html[data-theme="light"] .stat-divider{color:#BBB}
|
html[data-theme="light"] .stat-divider{color:#BBB}
|
||||||
|
|
@ -389,6 +437,42 @@ html[data-theme="light"] .join-check label{color:#555}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<button id="theme-toggle" onclick="toggleTheme()" aria-label="Toggle light/dark mode"></button>
|
<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">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<h1>RADICAL</h1>
|
<h1>RADICAL</h1>
|
||||||
|
|
@ -837,6 +921,7 @@ function toggleTheme() {
|
||||||
updateUIText();
|
updateUIText();
|
||||||
await loadUserData();
|
await loadUserData();
|
||||||
if (currentUser?.theme) applyTheme(currentUser.theme);
|
if (currentUser?.theme) applyTheme(currentUser.theme);
|
||||||
|
renderProfileBtn();
|
||||||
loadVerifiedUsers();
|
loadVerifiedUsers();
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
await Promise.allSettled([fetchProposals(), fetchLeaderboard(), fetchStats()]);
|
await Promise.allSettled([fetchProposals(), fetchLeaderboard(), fetchStats()]);
|
||||||
|
|
@ -998,6 +1083,15 @@ async function loadUserData() {
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log("User creation successful:", data);
|
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;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating user:', 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>`
|
? `<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 = `
|
proposalCard.innerHTML = `
|
||||||
${badgeHtml}
|
${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>
|
<div class="proposal-text">${proposal.text}</div>
|
||||||
${memeHtml}
|
${memeHtml}
|
||||||
${petitionInfo}
|
${petitionInfo}
|
||||||
|
|
@ -2372,7 +2469,7 @@ if (twitterImageElement) {
|
||||||
|
|
||||||
modalBody.innerHTML = `
|
modalBody.innerHTML = `
|
||||||
${badgeHtml}
|
${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>
|
<div class="proposal-text" style="font-size: 1.3rem; margin: 20px 0;">${proposal.text}</div>
|
||||||
|
|
||||||
${proposal.meme_url ? `<div class="proposal-meme">
|
${proposal.meme_url ? `<div class="proposal-meme">
|
||||||
|
|
@ -2577,7 +2674,7 @@ function renderModalComments(container, comments) {
|
||||||
commentsHtml += `
|
commentsHtml += `
|
||||||
<div class="comment-item" data-comment-id="${comment.id}">
|
<div class="comment-item" data-comment-id="${comment.id}">
|
||||||
<div class="comment-header">
|
<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 class="comment-time">${formatDate(comment.timestamp)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-text">${formatCommentText(comment.comment_text)}</div>
|
<div class="comment-text">${formatCommentText(comment.comment_text)}</div>
|
||||||
|
|
@ -2754,7 +2851,7 @@ function renderComments(container, comments) {
|
||||||
commentsHtml += `
|
commentsHtml += `
|
||||||
<div class="comment-item" data-comment-id="${comment.id}">
|
<div class="comment-item" data-comment-id="${comment.id}">
|
||||||
<div class="comment-header">
|
<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 class="comment-time">${formatDate(comment.timestamp)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-text">${formatCommentText(comment.comment_text)}</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.style.opacity = '0.6';
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="comment-header">
|
<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 class="comment-time">just now</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-text">${formatCommentText(commentText)}</div>`;
|
<div class="comment-text">${formatCommentText(commentText)}</div>`;
|
||||||
|
|
@ -3404,6 +3501,114 @@ async function handleJoinSubmit(e) {
|
||||||
btn.textContent = 'JOIN THE REBELLION →';
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="music-prompt" id="music-prompt">hit me</div>
|
<div class="music-prompt" id="music-prompt">hit me</div>
|
||||||
|
|
|
||||||
65
worker.js
65
worker.js
|
|
@ -140,6 +140,10 @@ export default {
|
||||||
return await createOrUpdateVote(request, env);
|
return await createOrUpdateVote(request, env);
|
||||||
} else if (path === '/api/users' && request.method === 'POST') {
|
} else if (path === '/api/users' && request.method === 'POST') {
|
||||||
return await createOrGetUser(request, env);
|
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') {
|
} else if (path === '/api/memes' && request.method === 'POST') {
|
||||||
return await uploadMeme(request, env);
|
return await uploadMeme(request, env);
|
||||||
} else if (path === '/api/comment-votes' && request.method === 'POST') {
|
} else if (path === '/api/comment-votes' && request.method === 'POST') {
|
||||||
|
|
@ -199,8 +203,8 @@ async function serveMedia(request, env) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const path = url.pathname;
|
const path = url.pathname;
|
||||||
|
|
||||||
// Extract the filename from the path
|
// Strip the /memes/ or /audio/ prefix to get the R2 key
|
||||||
const filename = path.split('/').pop();
|
const key = path.startsWith('/memes/') ? path.slice('/memes/'.length) : path.slice('/audio/'.length);
|
||||||
|
|
||||||
// Determine the correct bucket based on the path
|
// Determine the correct bucket based on the path
|
||||||
const bucket = path.startsWith('/memes/') ? env.MEMES_BUCKET : env.AUDIO_BUCKET;
|
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
|
// Get the object from R2
|
||||||
const object = await bucket.get(filename);
|
const object = await bucket.get(key);
|
||||||
|
|
||||||
if (!object) {
|
if (!object) {
|
||||||
console.error(`File not found: ${filename}`);
|
console.error(`File not found: ${key}`);
|
||||||
return new Response('File Not Found', { status: 404 });
|
return new Response('File Not Found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine content type based on file extension
|
const contentType = object.httpMetadata?.contentType || (path.endsWith('.mp3') ? 'audio/mpeg' : 'image/jpeg');
|
||||||
const contentType = path.endsWith('.mp3') ? 'audio/mpeg' : 'image/jpeg';
|
|
||||||
|
|
||||||
// Create response with appropriate headers
|
// Create response with appropriate headers
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
|
|
@ -1144,7 +1147,7 @@ async function getProposals(request, env) {
|
||||||
let query = `
|
let query = `
|
||||||
SELECT
|
SELECT
|
||||||
p.id, p.text, p.timestamp, p.trending, p.meme_url, p.flag_count,
|
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 = '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 = 'downvote') as downvotes,
|
||||||
(SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote' AND is_petition = 1) as petition_signatures,
|
(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 = `
|
const query = `
|
||||||
SELECT
|
SELECT
|
||||||
c.id, c.user_id, c.comment_text, c.timestamp,
|
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 = 'upvote') as upvotes,
|
||||||
(SELECT COUNT(*) FROM comment_votes WHERE comment_id = c.id AND vote_type = 'downvote') as downvotes
|
(SELECT COUNT(*) FROM comment_votes WHERE comment_id = c.id AND vote_type = 'downvote') as downvotes
|
||||||
FROM comments c
|
FROM comments c
|
||||||
|
|
@ -1984,7 +1987,7 @@ async function createOrGetUser(request, env) {
|
||||||
WHERE id = ?
|
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'}`);
|
console.log(`Existing user check: ${existingUser ? JSON.stringify(existingUser) : 'None found'}`);
|
||||||
} catch (userCheckError) {
|
} catch (userCheckError) {
|
||||||
return createResponse({
|
return createResponse({
|
||||||
|
|
@ -2013,7 +2016,8 @@ async function createOrGetUser(request, env) {
|
||||||
console.log(`Found existing user: ${existingUser.id}`);
|
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) {
|
} catch (error) {
|
||||||
return createResponse({
|
return createResponse({
|
||||||
error: 'Failed to process user request',
|
error: 'Failed to process user request',
|
||||||
|
|
@ -2022,6 +2026,47 @@ async function createOrGetUser(request, env) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
async function verifyTemplateImage(env) {
|
||||||
try {
|
try {
|
||||||
const key = "petition-template.png";
|
const key = "petition-template.png";
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue