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}
|
||||
/* ── 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>
|
||||
|
|
|
|||
75
worker.js
75
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";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue