Files
BountyBoard/web/static/js/profile.js
T
etalon 34bf5b5ac2 feat: base UI shell with themes, auth pages, profile, and role navigation (phase 4)
- embedded Go templates + vanilla ES-module JS + hand-written CSS, no build step
- exact §10 beige light / dark design tokens, square edges (radius 2px),
  system font stack, visible focus rings
- theme toggle persisted to localStorage and the user profile
- login/register/change-password pages wired to the auth API
- profile page: avatar upload (image-sniffed, old file cleanup), bio,
  contacts, arbitrary extra key/value fields, optimistic-concurrency 409
- role-based top navigation with placeholders for later-phase areas
- GET /files/{id} with scope-based access (session) or signed token (§5.1)
- security headers incl. CSP without unsafe-inline scripts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:39:38 +02:00

100 lines
3.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { api, toast } from '/static/js/api.js';
const form = document.getElementById('profile-form');
const errorBox = document.getElementById('error');
const okBox = document.getElementById('ok');
const extraRows = document.getElementById('extra-rows');
let version = parseInt(form.dataset.version, 10);
function addExtraRow(key, value) {
const row = document.createElement('div');
row.className = 'kv-row';
const k = document.createElement('input');
k.type = 'text'; k.placeholder = 'Field name'; k.value = key || '';
k.setAttribute('aria-label', 'Field name');
const v = document.createElement('input');
v.type = 'text'; v.placeholder = 'Value'; v.value = value || '';
v.setAttribute('aria-label', 'Field value');
const rm = document.createElement('button');
rm.type = 'button'; rm.className = 'btn small'; rm.textContent = '×';
rm.setAttribute('aria-label', 'Remove field');
rm.addEventListener('click', () => row.remove());
row.append(k, v, rm);
extraRows.appendChild(row);
}
async function loadExtra() {
const me = await api('GET', '/api/v1/auth/me');
version = me.user.version;
const extra = me.user.extra || {};
Object.keys(extra).forEach((k) => addExtraRow(k, String(extra[k])));
}
loadExtra().catch((e) => { errorBox.textContent = e.message; });
document.getElementById('extra-add').addEventListener('click', () => addExtraRow('', ''));
form.addEventListener('submit', async (e) => {
e.preventDefault();
errorBox.textContent = '';
okBox.textContent = '';
const extra = {};
extraRows.querySelectorAll('.kv-row').forEach((row) => {
const [k, v] = row.querySelectorAll('input');
if (k.value.trim()) extra[k.value.trim()] = v.value;
});
const links = document.getElementById('p-links').value
.split('\n').map((s) => s.trim()).filter(Boolean);
try {
const res = await api('PATCH', '/api/v1/profile', {
version,
name: document.getElementById('p-name').value.trim(),
bio: document.getElementById('p-bio').value,
contact: {
phone: document.getElementById('p-phone').value.trim(),
location: document.getElementById('p-location').value.trim(),
links,
},
extra,
settings: { theme: document.getElementById('p-theme').value },
});
version = res.user.version;
const theme = res.user.settings.theme;
document.documentElement.dataset.theme = theme;
try { localStorage.setItem('theme', theme); } catch (err) { /* ignore */ }
okBox.textContent = 'Profile saved.';
} catch (err) {
if (err.status === 409) {
errorBox.textContent = 'This profile changed elsewhere — reload the page and try again.';
} else {
errorBox.textContent = err.message;
}
}
});
document.getElementById('avatar-file').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const fd = new FormData();
fd.append('file', file);
try {
const res = await api('POST', '/api/v1/profile/avatar', fd);
const img = document.createElement('img');
img.className = 'avatar large';
img.alt = 'Current avatar';
img.src = '/files/' + res.fileId + '?v=' + Date.now();
const preview = document.getElementById('avatar-preview');
preview.replaceChildren(img);
toast('Avatar updated.', 'ok');
} catch (err) {
toast('Avatar upload failed: ' + err.message, 'err');
}
});
document.getElementById('logout-all').addEventListener('click', async () => {
try {
await api('POST', '/api/v1/auth/logout-all', {});
} catch (e) { /* session is gone either way */ }
window.location.href = '/login';
});