379 lines
16 KiB
JavaScript
379 lines
16 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',
|
|
entryDialog: 'group-entry-dialog', entryClose: 'group-entry-close', entryStatus: 'group-entry-status'
|
|
};
|
|
const codeLength = 5;
|
|
const wheelStep = 96;
|
|
const revisions = new Map();
|
|
let selection = [];
|
|
let codeChoice = null;
|
|
let currentGroup = null;
|
|
let currentExpiry = 0;
|
|
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();
|
|
action();
|
|
});
|
|
button.addEventListener('keydown', event => {
|
|
if (!['Enter', ' '].includes(event.key)) return;
|
|
event.preventDefault();
|
|
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 === 'code-taken') return t('error.code-taken');
|
|
if (error.reason === 'invalid-code') return t('error.code');
|
|
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;
|
|
const status = element(element(ids.entryDialog).open ? ids.entryStatus : ids.status);
|
|
status.textContent = '';
|
|
element(ids.join).disabled = true;
|
|
try { await action(); } catch (error) {
|
|
element(element(ids.entryDialog).open ? ids.entryStatus : ids.status).textContent = errorMessage(error);
|
|
if (!element(ids.dialog).open && !element(ids.entryDialog).open) {
|
|
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(selection[position]);
|
|
column.append(up, viewport, down);
|
|
return column;
|
|
}
|
|
|
|
function openEntry(chooseCode = false) {
|
|
if (element(ids.dialog).open) element(ids.dialog).close();
|
|
dialogGeneration += 1;
|
|
selection = Array.from({ length: codeLength }, () => chooseCode ? randomIndex(ns.GroupSymbols.list().length) : 0);
|
|
element('group-entry-title').setAttribute('data-i18n', `groups.${chooseCode ? 'choose-code' : 'join-open'}`);
|
|
element('group-entry-title').textContent = t(chooseCode ? 'choose-code' : 'join-open');
|
|
element(ids.join).setAttribute('data-i18n', `groups.${chooseCode ? 'save-code' : 'join'}`);
|
|
element(ids.join).disabled = false;
|
|
element(ids.join).textContent = t(chooseCode ? 'save-code' : 'join');
|
|
element(ids.entryStatus).textContent = '';
|
|
if (!element(ids.entryDialog).open) element(ids.entryDialog).showModal();
|
|
const wheels = element(ids.wheels);
|
|
wheels.replaceChildren(...selection.map((_, index) => makeWheel(index)));
|
|
for (const [index, column] of Array.from(wheels.children).entries()) column.children[1].scrollTop = selection[index] * wheelStep;
|
|
}
|
|
|
|
function randomIndex(size) {
|
|
const values = new Uint32Array(1);
|
|
const limit = Math.floor(0x100000000 / size) * size;
|
|
do { globalThis.crypto.getRandomValues(values); } while (values[0] >= limit);
|
|
return values[0] % size;
|
|
}
|
|
|
|
function chooseCode() {
|
|
if (codeChoice) return Promise.resolve(null);
|
|
openEntry(true);
|
|
return new Promise(resolve => { codeChoice = resolve; });
|
|
}
|
|
|
|
function cancelChoice() {
|
|
dialogGeneration += 1;
|
|
const resolve = codeChoice;
|
|
codeChoice = null;
|
|
resolve?.(null);
|
|
}
|
|
|
|
function updateCountdown() {
|
|
if (!currentGroup || !element(ids.dialog).open || element(ids.invitation).hidden) return;
|
|
const seconds = Math.max(0, Math.ceil(currentExpiry - Date.now() / 1000));
|
|
const time = new Date(currentExpiry * 1000).toLocaleTimeString(ns.I18n.language, { hour: '2-digit', minute: '2-digit' });
|
|
element(ids.expiry).textContent = seconds ? `${t('expires', { time })} ${t('remaining', {
|
|
minutes: Math.floor(seconds / 60), seconds: String(seconds % 60).padStart(2, '0')
|
|
})}` : t('code-expired');
|
|
}
|
|
|
|
function showInvitation(payload) {
|
|
if (!Array.isArray(payload.code) || payload.code.length !== codeLength) throw new Error(t('error.connection'));
|
|
currentGroup = payload.group;
|
|
element(ids.status).textContent = payload.expired ? t('code-expired') : '';
|
|
if (element(ids.entryDialog).open) element(ids.entryDialog).close();
|
|
element(ids.invitation).hidden = false;
|
|
element('group-route').textContent = payload.group.route.name || '';
|
|
const motifs = payload.code.map(value => ns.GroupSymbols.get(value));
|
|
element(ids.code).replaceChildren(...motifs.map(picture));
|
|
currentExpiry = payload.expiresAt;
|
|
if (!element(ids.dialog).open) element(ids.dialog).showModal();
|
|
updateCountdown();
|
|
}
|
|
|
|
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.expired ? t('code-expired') : '';
|
|
} catch { /* Keep the invitation visible; expiry still applies on the server. */ }
|
|
finally { invitationPolling = false; }
|
|
}
|
|
async function edit(group) {
|
|
if (element(ids.dialog).open) element(ids.dialog).close();
|
|
if (element(ids.entryDialog).open) element(ids.entryDialog).close();
|
|
const payload = await request(`/${group.id}/edit`, 'POST', {});
|
|
await ns.EditorUi.openGroup(payload.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 () => {
|
|
const code = await chooseCode();
|
|
if (!code) return;
|
|
showInvitation(await request(`/${group.id}/code`, 'POST', { code }));
|
|
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();
|
|
})));
|
|
}
|
|
if (group.role === 'owner' && group.active) actions.appendChild(button(t('code-label'), () => guarded(async () => {
|
|
const payload = await request(`/${group.id}/invitation`);
|
|
if (payload.code) showInvitation(payload);
|
|
else { const code = await chooseCode(); if (code) showInvitation(await request(`/${group.id}/code`, 'POST', { code })); }
|
|
})));
|
|
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, code = null) {
|
|
code ??= await chooseCode();
|
|
if (!code) return;
|
|
try {
|
|
const payload = await request('', 'POST', { routeId, code, creator_id: ns.LocalRoutes.creatorId() });
|
|
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 group-share-button';
|
|
result.dataset.editorAction = action;
|
|
result.title = t('share');
|
|
result.setAttribute('aria-label', t('share'));
|
|
result.setAttribute('aria-haspopup', 'dialog');
|
|
const icon = document.createElement('img');
|
|
icon.src = '/images/icons/group-edit.svg';
|
|
icon.alt = '';
|
|
icon.width = 32;
|
|
icon.height = 32;
|
|
result.appendChild(icon);
|
|
return result;
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
async function joinSelectedGroup() {
|
|
if (!element(ids.entryDialog).open) return;
|
|
const motifs = ns.GroupSymbols.list();
|
|
if (codeChoice) {
|
|
const resolve = codeChoice;
|
|
codeChoice = null;
|
|
const code = selection.map(index => motifs[index].unicode);
|
|
element(ids.entryDialog).close();
|
|
resolve(code);
|
|
return;
|
|
}
|
|
const generation = dialogGeneration;
|
|
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.entryDialog).open || generation !== dialogGeneration) return;
|
|
throw error;
|
|
}
|
|
if (!element(ids.entryDialog).open || generation !== dialogGeneration) return;
|
|
await edit(payload.group);
|
|
await refresh();
|
|
}
|
|
|
|
function init() {
|
|
globalThis.setInterval?.(() => { refreshInvitation(); }, 30_000);
|
|
globalThis.setInterval?.(updateCountdown, 1000);
|
|
document.addEventListener('wegwichtel:point-drafts-changed', () => { renderPending(); ns.StorageUsage?.refresh(); });
|
|
element(ids.entryDialog).addEventListener('cancel', cancelChoice);
|
|
element(ids.entryDialog).addEventListener('close', cancelChoice);
|
|
activate(element(ids.entryClose), () => element(ids.entryDialog).close());
|
|
for (const id of [ids.joinOpen, ids.editorJoin]) activate(element(id), () => openEntry());
|
|
activate(element(ids.close), () => element(ids.dialog).close());
|
|
activate(element(ids.join), () => codeChoice ? joinSelectedGroup() : guarded(joinSelectedGroup));
|
|
activate(element(ids.edit), () => guarded(() => edit(currentGroup)));
|
|
}
|
|
|
|
ns.Groups = Object.freeze({ share, chooseCode, shareButton, refresh, refreshInvitation, headersFor, observe });
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true });
|
|
else init();
|
|
}(globalThis.Wegwichtel));
|