Files
Wegwichtel/public/js/groups.js
T
Florian Zumpe 0e4cdfbd54
Sonarqube Scanner / Prepare environment (push) Successful in 6s
Sonarqube Scanner / Build and test (push) Successful in 27s
Sonarqube Scanner / SonarQube analysis (push) Failing after 1m14s
Sonarqube Scanner / Deploy to Server (push) Successful in 10s
group code popup
2026-10-18 17:24:41 +02:00

335 lines
14 KiB
JavaScript

globalThis.Wegwichtel ??= {};
(function (ns) {
'use strict';
const ids = {
joinOpen: 'group-join-open', editorJoin: 'editor-group-join', list: 'editor-group-list',
dialog: 'group-dialog', close: 'group-close', status: 'group-status', wheels: 'group-wheels',
entry: 'group-entry', invitation: 'group-invitation', code: 'group-code', join: 'group-join',
edit: 'group-edit', expiry: 'group-expiry'
};
const codeLength = 5;
const wheelStep = 52;
const revisions = new Map();
let selection = [];
let currentGroup = null;
let pending = false;
let dialogGeneration = 0;
const element = id => document.getElementById(id);
const t = (key, variables) => ns.I18n.t(`groups.${key}`, variables);
function activate(button, action) {
button.addEventListener('pointerup', event => {
if (event.isPrimary === false || (event.button && event.button !== 0)) return;
event.preventDefault();
void action();
});
button.addEventListener('keydown', event => {
if (!['Enter', ' '].includes(event.key)) return;
event.preventDefault();
void action();
});
}
function button(label, action) {
const result = document.createElement('button');
result.type = 'button';
result.className = 'editor-secondary-button';
result.textContent = label;
activate(result, action);
return result;
}
function errorMessage(error) {
if (error.reason === 'group-limit') return t('error.limit');
if (error.reason === 'group-full') return t('error.full');
if (error.reason === 'group-editing') return t('code-wait');
if (error.status === 423) return ns.I18n.t('point-locks.busy');
if (error.status === 429) return t('error.rate');
if (error.status === 404) return t('error.code');
if (error.status === 401 || error.status === 403) return t('error.access');
if (error.status === 409) return t('error.conflict');
return t('error.connection');
}
async function request(path, method = 'GET', body = null) {
const base = (ns.Config?.apiBase || '/api').replace(/\/$/, '');
const response = await fetch(`${base}/groups${path}`, {
method, credentials: 'same-origin', cache: 'no-store',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {})
});
if (!response.ok) {
const error = new Error(t('error.connection'));
error.status = response.status;
if (typeof response.json === 'function') {
try { error.reason = (await response.json()).details?.code; } catch { /* Use the translated generic error. */ }
}
throw error;
}
return response.json();
}
async function guarded(action) {
if (pending) return;
pending = true;
element(ids.status).textContent = '';
element(ids.join).disabled = true;
try { await action(); } catch (error) {
element(ids.status).textContent = errorMessage(error);
if (!element(ids.dialog).open) {
element(ids.entry).hidden = true;
element(ids.invitation).hidden = true;
element(ids.dialog).showModal();
}
}
finally { pending = false; element(ids.join).disabled = false; }
}
function picture(symbol) {
const image = document.createElement('img');
image.src = symbol.imageUrl;
image.alt = symbol.label;
image.width = 48;
image.height = 48;
image.draggable = false;
return image;
}
function makeWheel(position) {
const motifs = ns.GroupSymbols.list();
const column = document.createElement('div');
column.className = 'group-wheel';
const viewport = document.createElement('div');
viewport.className = 'group-wheel-viewport';
viewport.tabIndex = 0;
viewport.setAttribute('role', 'spinbutton');
viewport.setAttribute('aria-label', t('position', { number: position + 1 }));
viewport.setAttribute('aria-valuemin', '1');
viewport.setAttribute('aria-valuemax', String(motifs.length));
const paint = index => {
selection[position] = index;
viewport.setAttribute('aria-valuenow', String(index + 1));
viewport.setAttribute('aria-valuetext', motifs[index].label);
};
const choose = index => {
const next = (index + motifs.length) % motifs.length;
paint(next);
viewport.scrollTo({ top: next * wheelStep, behavior: globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
};
const up = button('↑', () => choose(selection[position] - 1));
up.setAttribute('aria-label', t('previous', { number: position + 1 }));
const down = button('↓', () => choose(selection[position] + 1));
down.setAttribute('aria-label', t('next', { number: position + 1 }));
for (const [index, motif] of motifs.entries()) {
const option = document.createElement('div');
option.className = 'group-wheel-option';
option.setAttribute('aria-hidden', 'true');
const image = picture(motif);
image.alt = '';
option.appendChild(image);
activate(option, () => choose(index));
viewport.appendChild(option);
}
viewport.addEventListener('scroll', () => paint(Math.max(0, Math.min(motifs.length - 1, Math.round(viewport.scrollTop / wheelStep)))));
viewport.addEventListener('keydown', event => {
const changes = { ArrowUp: -1, ArrowDown: 1 };
if (Object.hasOwn(changes, event.key)) {
event.preventDefault(); choose(selection[position] + changes[event.key]);
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault(); choose(event.key === 'Home' ? 0 : motifs.length - 1);
}
});
paint(0);
column.append(up, viewport, down);
return column;
}
function openEntry() {
dialogGeneration += 1;
selection = Array(codeLength).fill(0);
element(ids.status).textContent = '';
element(ids.entry).hidden = false;
element(ids.invitation).hidden = true;
const wheels = element(ids.wheels);
wheels.replaceChildren(...selection.map((_, index) => makeWheel(index)));
if (!element(ids.dialog).open) element(ids.dialog).showModal();
}
function showInvitation(payload) {
currentGroup = payload.group;
element(ids.status).textContent = '';
element(ids.entry).hidden = true;
element(ids.invitation).hidden = false;
const motifs = payload.code.map(value => ns.GroupSymbols.get(value));
element(ids.code).replaceChildren(...motifs.map(picture));
const time = new Date(payload.expiresAt * 1000).toLocaleTimeString(ns.I18n.language, { hour: '2-digit', minute: '2-digit' });
element(ids.expiry).textContent = t('expires', { time });
if (!element(ids.dialog).open) element(ids.dialog).showModal();
}
let invitationPolling = false;
async function refreshInvitation() {
const group = currentGroup;
if (!group || invitationPolling || !element(ids.dialog).open || element(ids.invitation).hidden) return;
invitationPolling = true;
try {
const payload = await request(`/${group.id}/invitation`);
if (currentGroup?.id !== group.id || !element(ids.dialog).open || element(ids.invitation).hidden) return;
if (payload.code) showInvitation(payload);
element(ids.status).textContent = payload.busy && payload.expired ? t('code-wait') : '';
} catch { /* Keep the invitation visible; expiry still applies on the server. */ }
finally { invitationPolling = false; }
}
async function edit(group) {
element(ids.dialog).close();
await ns.EditorUi.openGroup(group.route);
}
async function renderPending() {
const list = element('editor-point-drafts');
if (!list || !ns.GroupPoints) return;
try {
const drafts = await ns.GroupPoints.list();
list.replaceChildren();
for (const draft of drafts) {
const item = document.createElement('li'); item.className = 'editor-route-item';
const name = document.createElement('span');
name.textContent = `${draft.route.name}: ${draft.values.title}`;
const actions = document.createElement('div'); actions.className = 'editor-icon-row';
actions.appendChild(button(ns.I18n.t('point-locks.open-draft'), () => guarded(() => ns.EditorUi.openPendingPoint(draft))));
actions.appendChild(button(ns.I18n.t('point-locks.discard'), () => guarded(async () => {
if (globalThis.confirm(ns.I18n.t('point-locks.discard-confirm'))) await ns.GroupPoints.remove(draft.route.id, draft.poiId);
})));
item.append(name, actions); list.appendChild(item);
}
} catch { /* Storage errors are reported when attempting to save; never claim a draft was saved. */ }
}
async function refresh() {
await renderPending();
try {
const payload = await request('');
const list = element(ids.list);
if (!list) return;
list.replaceChildren();
for (const group of payload.groups) {
const item = document.createElement('li');
item.className = 'editor-route-item';
const name = document.createElement('strong');
name.textContent = group.route.name;
const actions = document.createElement('div');
actions.className = 'editor-icon-row group-actions';
actions.appendChild(button(t('edit'), () => guarded(async () => { await edit(group); })));
actions.appendChild(button(t('reload'), () => guarded(async () => {
if (globalThis.confirm(t('reload-confirm'))) await edit(group);
})));
if (group.role === 'owner') {
actions.appendChild(button(t('new-code'), () => guarded(async () => {
if (!globalThis.confirm(t('new-code-confirm'))) return;
showInvitation(await request(`/${group.id}/code`, 'POST', {}));
await refresh();
})));
if (group.active) actions.appendChild(button(t('close-sharing'), () => guarded(async () => {
if (!globalThis.confirm(t('close-confirm'))) return;
await request(`/${group.id}`, 'DELETE');
await refresh();
})));
}
item.append(name, actions);
list.appendChild(item);
}
if (!payload.groups.length) {
const empty = document.createElement('li');
empty.textContent = t('none');
list.appendChild(empty);
}
} catch { /* Existing drafts and forms stay usable without a connection. */ }
}
async function share(routeId) {
try {
const payload = await request('', 'POST', { routeId, creator_id: ns.LocalRoutes.creatorId() });
const editor = document.getElementById('editor-dialog');
if (!editor || editor.open) showInvitation(payload);
await refresh();
} catch (error) { throw new Error(errorMessage(error)); }
}
function shareButton(action) {
const result = document.createElement('button');
result.type = 'button';
result.className = 'editor-icon-button';
result.dataset.editorAction = action;
result.title = t('share');
result.setAttribute('aria-label', t('share'));
result.setAttribute('aria-haspopup', 'dialog');
result.textContent = '👥';
return result;
}
let shareDecision = null;
function confirmShare(name) {
if (shareDecision) return Promise.resolve(false);
const dialog = element('group-share-dialog');
element('group-share-route').textContent = name;
element('group-share-pictures').replaceChildren(...['🐦', '🪨'].map(value => picture(ns.GroupSymbols.get(value))));
return new Promise(resolve => {
shareDecision = resolve;
dialog.showModal();
});
}
function finishShare(accepted) {
const resolve = shareDecision;
shareDecision = null;
element('group-share-dialog').close();
resolve?.(accepted);
}
function headersFor(path) {
const id = /^\/routes\/(\d+)(?:\/|$)/.exec(path)?.[1];
return id && revisions.has(id) ? { 'If-Match': String(revisions.get(id)) } : {};
}
function observe(response) {
const routeId = response.headers?.get('X-Group-Route');
const revision = response.headers?.get('X-Group-Revision');
if (routeId && revision !== null && /^\d+$/.test(revision)) revisions.set(routeId, Number(revision));
}
function init() {
globalThis.setInterval?.(() => { void refreshInvitation(); }, 30_000);
activate(element('group-share-start'), () => finishShare(true));
activate(element('group-share-close'), () => finishShare(false));
element('group-share-dialog').addEventListener('close', () => {
const resolve = shareDecision; shareDecision = null; resolve?.(false);
});
document.addEventListener('wegwichtel:point-drafts-changed', () => { void renderPending(); void ns.StorageUsage?.refresh(); });
element(ids.dialog).addEventListener('cancel', () => { dialogGeneration += 1; });
element(ids.dialog).addEventListener('close', () => { dialogGeneration += 1; });
for (const id of [ids.joinOpen, ids.editorJoin]) activate(element(id), openEntry);
activate(element(ids.close), () => element(ids.dialog).close());
activate(element(ids.join), () => guarded(async () => {
const generation = dialogGeneration;
const motifs = ns.GroupSymbols.list();
let payload;
try {
payload = await request('/join', 'POST', {
code: selection.map(index => motifs[index].unicode), creator_id: ns.LocalRoutes.creatorId()
});
} catch (error) {
if (!element(ids.dialog).open || generation !== dialogGeneration) return;
throw error;
}
if (!element(ids.dialog).open || generation !== dialogGeneration) return;
await edit(payload.group);
await refresh();
}));
activate(element(ids.edit), () => guarded(() => edit(currentGroup)));
}
ns.Groups = Object.freeze({ share, shareButton, confirmShare, refresh, refreshInvitation, headersFor, observe });
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true });
else init();
}(globalThis.Wegwichtel));