e8beb7d4f3
- Navigation: new per-user setting (default "side") renders the page links as a left sidebar while keeping theme/bell/avatar/logout in the top-right; falls back to a top bar under 760px or when set to "top". Profile selector added. - Group conversations: record a creatorId; the creator gets a "Manage members" button to add/remove participants (new GET/POST/DELETE members endpoints, creator-only). Existing groups backfilled. - Customer ticketing: admin can map each assigned consultant to their username in that customer's ticketing system. Per-customer mapping takes precedence over the consultant's global ticketingIdentities during sync. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
104 lines
3.7 KiB
JavaScript
104 lines
3.7 KiB
JavaScript
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,
|
||
navLayout: document.getElementById('p-nav').value,
|
||
},
|
||
});
|
||
version = res.user.version;
|
||
const theme = res.user.settings.theme;
|
||
document.documentElement.dataset.theme = theme;
|
||
try { localStorage.setItem('theme', theme); } catch (err) { /* ignore */ }
|
||
document.body.dataset.nav = res.user.settings.navLayout || 'side';
|
||
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';
|
||
});
|