Let users upload a profile picture, cropped in the browser
Users now have an avatar. The account page takes an image, lets the user
drag and zoom it under a circular mask, and uploads exactly what the circle
shows as a 256x256 PNG. The picture replaces the initial-letter circle in
the sidebar, on the account page, on the on-call hero and week strip, on
the schedule's day chips and in the incident assign menu; someone without
one keeps the initial.
The picture lives in Postgres (user_avatars, bytea) rather than on a
volume: the chart runs two replicas on a read-only root filesystem, and
the k8up pg_dump backup then covers it without any chart or operator
change. The server does not trust the client's crop. It decodes the PNG or
JPEG, checks the declared size before decoding, centre-crops to a square,
scales to 256 and re-encodes, which drops EXIF and anything that is not
pixels. Standard library only; WebP is cropped by the browser and arrives
as PNG, a raw WebP sent to the API is refused.
GET/PUT/DELETE /api/users/{id}/avatar: anyone signed in may read, only the
owner or an administrator may change. /api/me and /api/users gain
avatar_version, absent when there is no picture, which is also the ?v= that
busts the cache after a change. Incident timeline entries and the queue are
text-only and are unchanged.
Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
// Account: who you are signed in as, changing your password, signing out.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, icon, toast } from './ui.js';
|
||||
import { h, clear, icon, toast, openSheet, closeSheet } from './ui.js';
|
||||
import { initial } from './format.js';
|
||||
import { state } from './state.js';
|
||||
import { signOut } from './app.js';
|
||||
import { state, forgetUsers } from './state.js';
|
||||
import { withAvatar, setAvatarVersion } from './avatar.js';
|
||||
import { signOut, renderAccountNav } from './app.js';
|
||||
|
||||
const view = () => document.getElementById('view-more');
|
||||
|
||||
@@ -24,10 +25,11 @@ function render(providers) {
|
||||
const { user, has_password: hasPassword } = state.me;
|
||||
clear(view(),
|
||||
h('div', { class: 'card account-card' },
|
||||
h('div', { class: 'avatar', text: initial(user.username) }),
|
||||
withAvatar(h('div', { class: 'avatar', text: initial(user.username) }), user.id),
|
||||
h('div', {},
|
||||
h('div', { class: 'account-name', text: user.username }),
|
||||
h('div', { class: 'account-email', text: user.email }))),
|
||||
h('div', { class: 'account-email', text: user.email }),
|
||||
avatarControls(user))),
|
||||
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
|
||||
notifyCard(user, providers),
|
||||
@@ -299,3 +301,125 @@ function shortcuts() {
|
||||
h('td', {}, k.split(' / ').map((x, i) => [i ? ' / ' : '', h('kbd', { text: x })])),
|
||||
h('td', { text: v })))));
|
||||
}
|
||||
|
||||
// ---------- profile picture ----------
|
||||
|
||||
const CROP = 256; // the stored picture is CROP x CROP; the stage shows it 1:1
|
||||
|
||||
function avatarControls(user) {
|
||||
const input = h('input', { type: 'file', accept: 'image/png,image/jpeg,image/webp,image/gif', hidden: true });
|
||||
input.addEventListener('change', async () => {
|
||||
const file = input.files[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(file);
|
||||
} catch {
|
||||
toast('That file is not an image this browser can read.', 'error');
|
||||
return;
|
||||
}
|
||||
const blob = await cropSheet(bitmap);
|
||||
if (!blob) return;
|
||||
try {
|
||||
const { avatar_version: v } = await api.setAvatar(user.id, blob);
|
||||
changed(user, v);
|
||||
toast('Picture updated');
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
const remove = async () => {
|
||||
try {
|
||||
await api.removeAvatar(user.id);
|
||||
changed(user, null);
|
||||
toast('Picture removed');
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
};
|
||||
return h('div', { class: 'avatar-actions' },
|
||||
input,
|
||||
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => input.click(), text: user.avatar_version != null ? 'Change picture' : 'Upload picture' }),
|
||||
user.avatar_version != null && h('button', { class: 'btn btn-sm', type: 'button', onclick: remove, text: 'Remove' }));
|
||||
}
|
||||
|
||||
// The picture changed: keep the cached account and user list in step and repaint.
|
||||
function changed(user, version) {
|
||||
user.avatar_version = version ?? undefined;
|
||||
setAvatarVersion(user.id, version);
|
||||
forgetUsers();
|
||||
renderAccountNav();
|
||||
show();
|
||||
}
|
||||
|
||||
// A square stage with the image under it: drag to move, slider or wheel to
|
||||
// zoom. Resolves with a CROP x CROP PNG of exactly what the circle shows, or
|
||||
// null if dismissed.
|
||||
function cropSheet(bitmap) {
|
||||
const base = CROP / Math.min(bitmap.width, bitmap.height); // scale at which the image just covers the stage
|
||||
let zoom = 1;
|
||||
let cx = bitmap.width / 2; // the image point shown at the stage's centre
|
||||
let cy = bitmap.height / 2;
|
||||
|
||||
const canvas = h('canvas', { class: 'crop-canvas', width: CROP, height: CROP });
|
||||
const clamp = () => {
|
||||
const half = CROP / 2 / (base * zoom);
|
||||
cx = Math.min(bitmap.width - half, Math.max(half, cx));
|
||||
cy = Math.min(bitmap.height - half, Math.max(half, cy));
|
||||
};
|
||||
const paint = (ctx = canvas.getContext('2d'), size = CROP) => {
|
||||
clamp();
|
||||
const s = base * zoom * (size / CROP);
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(bitmap, size / 2 - cx * s, size / 2 - cy * s, bitmap.width * s, bitmap.height * s);
|
||||
};
|
||||
|
||||
const slider = h('input', { type: 'range', min: '1', max: '4', step: '0.01', value: '1', 'aria-label': 'Zoom' });
|
||||
slider.addEventListener('input', () => { zoom = Number(slider.value); paint(); });
|
||||
|
||||
let drag = null;
|
||||
canvas.addEventListener('pointerdown', (e) => {
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
drag = { x: e.clientX, y: e.clientY };
|
||||
});
|
||||
canvas.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
const k = CROP / canvas.getBoundingClientRect().width / (base * zoom);
|
||||
cx -= (e.clientX - drag.x) * k;
|
||||
cy -= (e.clientY - drag.y) * k;
|
||||
drag = { x: e.clientX, y: e.clientY };
|
||||
paint();
|
||||
});
|
||||
const end = () => { drag = null; };
|
||||
canvas.addEventListener('pointerup', end);
|
||||
canvas.addEventListener('pointercancel', end);
|
||||
canvas.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
zoom = Math.min(4, Math.max(1, zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08)));
|
||||
slider.value = String(zoom);
|
||||
paint();
|
||||
}, { passive: false });
|
||||
|
||||
return openSheet(() => {
|
||||
queueMicrotask(() => paint());
|
||||
return [
|
||||
h('h2', { class: 'sheet-title', text: 'Crop your picture' }),
|
||||
h('div', { class: 'crop-stage' }, canvas, h('div', { class: 'crop-mask', 'aria-hidden': 'true' })),
|
||||
h('label', { class: 'crop-zoom' }, h('span', { class: 'muted small', text: 'Zoom' }), slider),
|
||||
h('div', { class: 'sheet-actions' },
|
||||
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }),
|
||||
h('button', {
|
||||
class: 'btn btn-primary', type: 'button', text: 'Save',
|
||||
onclick: () => {
|
||||
const out = document.createElement('canvas');
|
||||
out.width = CROP;
|
||||
out.height = CROP;
|
||||
paint(out.getContext('2d'));
|
||||
out.toBlob((b) => closeSheet(b), 'image/png');
|
||||
},
|
||||
})),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user