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));