diff --git a/client/web/app.js b/client/web/app.js index 8721ce9..299bbea 100644 --- a/client/web/app.js +++ b/client/web/app.js @@ -1,4 +1,12 @@ -const state = { token: null, kcId: null, socket: null }; +const state = { + token: null, + kcId: null, + whoami: null, + socket: null, + activeChannelId: null, + pickedUsers: new Set(), + pickedGuests: new Set(), +}; const $ = (id) => document.getElementById(id); @@ -11,31 +19,301 @@ function decodeJwtPayload(token) { } } +function setStatus(el, message, kind) { + el.textContent = message; + el.classList.remove('status-ok', 'status-error'); + if (kind === 'ok') el.classList.add('status-ok'); + if (kind === 'error') el.classList.add('status-error'); +} + +function setConnBadge(online) { + const badge = $('conn-badge'); + if (!badge) return; + badge.textContent = online ? 'angemeldet' : 'nicht angemeldet'; + badge.classList.toggle('badge-online', online); + badge.classList.toggle('badge-muted', !online); +} + +function clearList(listEl, emptyMessage) { + listEl.innerHTML = ''; + if (emptyMessage) { + const li = document.createElement('li'); + li.className = 'list-empty'; + li.textContent = emptyMessage; + listEl.appendChild(li); + } +} + +async function apiFetch(path, options = {}) { + const headers = Object.assign({}, options.headers); + if (state.token) headers.Authorization = `Bearer ${state.token}`; + if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json'; + return fetch(`/api${path}`, Object.assign({}, options, { headers })); +} + +/// --- Tabs (Gast / Team-Login) --- +document.querySelectorAll('.tab-btn').forEach((btn) => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active')); + document.querySelectorAll('.tab-panel').forEach((p) => (p.hidden = true)); + btn.classList.add('active'); + const panel = document.querySelector(`[data-panel="${btn.dataset.tab}"]`); + if (panel) panel.hidden = false; + }); +}); + +/// --- Login: Gast/Konfi --- $('guest-form').addEventListener('submit', async (event) => { event.preventDefault(); const inviteCode = $('invite-code').value; const firstName = $('first-name').value; const lastName = $('last-name').value; - const res = await fetch('/api/auth/guest', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ inviteCode, firstName, lastName }), - }); + setStatus($('login-status'), 'Wird geprüft…'); + + let res; + try { + res = await apiFetch('/auth/guest', { + method: 'POST', + body: JSON.stringify({ inviteCode, firstName, lastName }), + }); + } catch (err) { + setStatus($('login-status'), 'Netzwerkfehler. Bitte erneut versuchen.', 'error'); + return; + } if (!res.ok) { - $('login-status').textContent = `Fehler: ${res.status}`; + setStatus($('login-status'), `Fehler: ${res.status}`, 'error'); return; } const { accessToken } = await res.json(); - state.token = accessToken; - state.kcId = decodeJwtPayload(accessToken)?.kcId ?? null; - $('login-status').textContent = 'Angemeldet.'; - $('login-section').hidden = true; - $('app-section').hidden = false; + await onLoggedIn(accessToken); }); +/// --- Login: Team (Gemeinde-Name + Passwort) --- +$('team-form').addEventListener('submit', async (event) => { + event.preventDefault(); + const gemeindeName = $('team-gemeinde').value; + const password = $('team-password').value; + + setStatus($('login-status'), 'Wird geprüft…'); + + let res; + try { + res = await apiFetch('/auth/team-login', { + method: 'POST', + body: JSON.stringify({ gemeindeName, password }), + }); + } catch (err) { + setStatus($('login-status'), 'Netzwerkfehler. Bitte erneut versuchen.', 'error'); + return; + } + + if (!res.ok) { + setStatus($('login-status'), `Fehler: ${res.status}`, 'error'); + return; + } + + const { accessToken } = await res.json(); + await onLoggedIn(accessToken); +}); + +async function onLoggedIn(accessToken) { + state.token = accessToken; + const claims = decodeJwtPayload(accessToken); + state.kcId = claims?.kcId ?? null; + + // Fetch identity/role from the API so the UI can show role-appropriate + // sections (Gruppenchat creation is LT/Verantwortliche/r only). + let me = null; + try { + const res = await apiFetch('/auth/me'); + if (res.ok) me = await res.json(); + } catch { + // best-effort + } + state.whoami = me; + + setStatus($('login-status'), 'Angemeldet.', 'ok'); + setConnBadge(true); + $('login-section').hidden = true; + $('app-section').hidden = false; + renderWhoAmI(); +} + +function renderWhoAmI() { + const card = $('whoami-card'); + const text = $('whoami-text'); + const gruppenCard = $('gruppen-card'); + const manageCard = $('manage-card'); + + if (!state.whoami) { + card.hidden = true; + gruppenCard.hidden = true; + manageCard.hidden = true; + return; + } + + card.hidden = false; + if (state.whoami.kind === 'guest') { + text.textContent = `Konfi/Gast (KC ${state.whoami.kcId})`; + gruppenCard.hidden = true; + manageCard.hidden = true; + } else { + const roles = (state.whoami.memberships || []).map((m) => m.role).join(', ') || 'keine Rolle'; + text.textContent = `${state.whoami.email} — ${roles}`; + const canManageGruppen = + state.whoami.isLeitungsteam || + (state.whoami.memberships || []).some((m) => m.role === 'GEMEINDE_VERANTWORTLICHER'); + gruppenCard.hidden = !canManageGruppen; + manageCard.hidden = !canManageGruppen; + if (state.kcId) $('gruppe-kcid').value = state.kcId; + } +} + +$('logout-btn').addEventListener('click', () => { + if (state.socket) state.socket.close(); + state.token = null; + state.kcId = null; + state.whoami = null; + state.activeChannelId = null; + setConnBadge(false); + $('app-section').hidden = true; + $('login-section').hidden = false; + setStatus($('login-status'), ''); +}); + +/// --- Gruppenchat: Kandidaten laden + auswählen --- +$('load-candidates').addEventListener('click', async () => { + const kcId = $('gruppe-kcid').value.trim(); + if (!kcId) { + setStatus($('gruppe-status'), 'Bitte KC-ID angeben.', 'error'); + return; + } + + let res; + try { + res = await apiFetch(`/chat/${kcId}/gruppen/participant-candidates`); + } catch { + setStatus($('gruppe-status'), 'Netzwerkfehler beim Laden.', 'error'); + return; + } + if (!res.ok) { + setStatus($('gruppe-status'), `Fehler: ${res.status}`, 'error'); + return; + } + + const { users, guests } = await res.json(); + state.pickedUsers = new Set(); + state.pickedGuests = new Set(); + renderCandidateList($('candidate-users'), users, 'userId', (u) => `${u.firstName} ${u.lastName} (${u.role})`, state.pickedUsers); + renderCandidateList($('candidate-guests'), guests, 'guestId', (g) => `${g.firstName} ${g.lastName}`, state.pickedGuests); + setStatus($('gruppe-status'), `${users.length} Team-Mitglieder, ${guests.length} Konfis geladen.`, 'ok'); +}); + +function renderCandidateList(listEl, items, idKey, labelFn, pickedSet) { + clearList(listEl); + if (!items.length) { + clearList(listEl, 'Keine Kandidaten gefunden.'); + return; + } + listEl.innerHTML = ''; + for (const item of items) { + const id = item[idKey]; + const li = document.createElement('li'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.addEventListener('change', () => { + if (checkbox.checked) pickedSet.add(id); + else pickedSet.delete(id); + li.classList.toggle('picked', checkbox.checked); + }); + const label = document.createElement('span'); + label.textContent = labelFn(item); + li.appendChild(checkbox); + li.appendChild(label); + li.addEventListener('click', (e) => { + if (e.target === checkbox) return; + checkbox.checked = !checkbox.checked; + checkbox.dispatchEvent(new Event('change')); + }); + listEl.appendChild(li); + } +} + +$('create-gruppe').addEventListener('click', async () => { + const kcId = $('gruppe-kcid').value.trim(); + const name = $('gruppe-name').value.trim(); + if (!kcId) { + setStatus($('gruppe-status'), 'Bitte KC-ID angeben.', 'error'); + return; + } + + setStatus($('gruppe-status'), 'Wird erstellt…'); + + let res; + try { + res = await apiFetch(`/chat/${kcId}/gruppen`, { + method: 'POST', + body: JSON.stringify({ + type: 'GRUPPE', + name, + participantUserIds: [...state.pickedUsers], + participantGuestIds: [...state.pickedGuests], + }), + }); + } catch { + setStatus($('gruppe-status'), 'Netzwerkfehler.', 'error'); + return; + } + + if (!res.ok) { + setStatus($('gruppe-status'), `Fehler: ${res.status}`, 'error'); + return; + } + + const channel = await res.json(); + setStatus($('gruppe-status'), `Erstellt: ${channel.id}`, 'ok'); + $('manage-channel-id').value = channel.id; + $('channel-id').value = channel.id; +}); + +/// --- Teilnehmerverwaltung --- +$('add-participant').addEventListener('click', () => manageParticipant('add')); +$('remove-participant').addEventListener('click', () => manageParticipant('remove')); + +async function manageParticipant(action) { + const channelId = $('manage-channel-id').value.trim(); + const userId = $('manage-user-id').value.trim(); + const guestId = $('manage-guest-id').value.trim(); + if (!channelId || (!userId && !guestId)) { + setStatus($('manage-status'), 'Channel-ID und User- oder Gast-ID angeben.', 'error'); + return; + } + + setStatus($('manage-status'), 'Wird gesendet…'); + const method = action === 'add' ? 'POST' : 'DELETE'; + + let res; + try { + res = await apiFetch(`/chat/gruppen/${channelId}/participants`, { + method, + body: JSON.stringify({ userId: userId || undefined, guestId: guestId || undefined }), + }); + } catch { + setStatus($('manage-status'), 'Netzwerkfehler.', 'error'); + return; + } + + if (!res.ok) { + setStatus($('manage-status'), `Fehler: ${res.status}`, 'error'); + return; + } + setStatus($('manage-status'), action === 'add' ? 'Hinzugefügt.' : 'Entfernt.', 'ok'); +} + +/// --- Workshop-Wahl --- $('submit-wahl').addEventListener('click', async () => { const wahlId = $('wahl-id').value; const prioritaeten = $('prioritaeten') @@ -43,24 +321,51 @@ $('submit-wahl').addEventListener('click', async () => { .map((s) => s.trim()) .filter(Boolean); - const res = await fetch(`/api/wahl/${wahlId}/teilnehmer`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${state.token}`, - }, - body: JSON.stringify({ prioritaeten }), - }); - $('wahl-status').textContent = res.ok ? 'Gespeichert.' : `Fehler: ${res.status}`; + if (!wahlId || prioritaeten.length === 0) { + setStatus($('wahl-status'), 'Bitte Wahl-ID und mindestens eine Priorität angeben.', 'error'); + return; + } + + setStatus($('wahl-status'), 'Wird gesendet…'); + + let res; + try { + res = await apiFetch(`/wahl/${wahlId}/teilnehmer`, { + method: 'POST', + body: JSON.stringify({ prioritaeten }), + }); + } catch (err) { + setStatus($('wahl-status'), 'Netzwerkfehler. Bitte erneut versuchen.', 'error'); + return; + } + + if (res.ok) { + setStatus($('wahl-status'), 'Gespeichert.', 'ok'); + } else { + setStatus($('wahl-status'), `Fehler: ${res.status}`, 'error'); + } }); +/// --- Dateien --- $('load-files').addEventListener('click', async () => { if (!state.kcId) return; - const res = await fetch(`/api/files/${state.kcId}`, { - headers: { Authorization: `Bearer ${state.token}` }, - }); + + let res; + try { + res = await apiFetch(`/files/${state.kcId}`); + } catch (err) { + clearList($('file-list'), 'Fehler beim Laden der Dateien.'); + return; + } + const files = res.ok ? await res.json() : []; const list = $('file-list'); + + if (!files.length) { + clearList(list, 'Keine Dateien verfügbar.'); + return; + } + list.innerHTML = ''; for (const file of files) { const li = document.createElement('li'); @@ -69,24 +374,108 @@ $('load-files').addEventListener('click', async () => { } }); +/// --- Chat: eigene Channels auflisten --- +$('load-channels').addEventListener('click', async () => { + if (!state.kcId) { + setStatus($('login-status'), ''); + return; + } + + let res; + try { + res = await apiFetch(`/chat/${state.kcId}/channels`); + } catch { + clearList($('channel-list'), 'Fehler beim Laden der Chats.'); + return; + } + + const channels = res.ok ? await res.json() : []; + const list = $('channel-list'); + if (!channels.length) { + clearList(list, 'Keine Chats verfügbar.'); + return; + } + + list.innerHTML = ''; + for (const channel of channels) { + const li = document.createElement('li'); + const label = document.createElement('span'); + label.textContent = channel.name || channel.type; + const chip = document.createElement('span'); + chip.className = 'chip'; + chip.textContent = channel.type; + li.appendChild(label); + li.appendChild(chip); + li.addEventListener('click', () => { + $('channel-id').value = channel.id; + joinChannel(channel.id); + }); + list.appendChild(li); + } +}); + +/// --- Chat: beitreten + live mitlesen/schreiben --- $('join-channel').addEventListener('click', () => { const channelId = $('channel-id').value; if (!channelId || !state.token) return; + joinChannel(channelId); +}); +function joinChannel(channelId) { if (state.socket) state.socket.close(); + state.activeChannelId = channelId; + const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; const socket = new WebSocket(`${protocol}://${location.host}/chat?token=${state.token}`); state.socket = socket; + const chatLog = $('chat-log'); + clearList(chatLog, 'Verbindung wird hergestellt…'); + socket.addEventListener('open', () => { socket.send(JSON.stringify({ event: 'chat:join', data: { channelId } })); + clearList(chatLog, 'Verbunden. Warte auf Nachrichten…'); }); socket.addEventListener('message', (event) => { const { event: name, data } = JSON.parse(event.data); + + if (name === 'chat:participants-changed') { + if (data.channelId === state.activeChannelId) { + setStatus($('manage-status'), 'Teilnehmerliste wurde aktualisiert.', 'ok'); + } + return; + } + if (name !== 'chat:message') return; + + const emptyPlaceholder = chatLog.querySelector('.list-empty'); + if (emptyPlaceholder) emptyPlaceholder.remove(); + const li = document.createElement('li'); - li.textContent = data.body; - $('chat-log').appendChild(li); + const body = document.createElement('span'); + body.className = 'chat-body'; + body.textContent = data.body; + li.appendChild(body); + chatLog.appendChild(li); + chatLog.scrollTop = chatLog.scrollHeight; }); + + socket.addEventListener('close', () => { + setConnBadge(!!state.token); + }); +} + +$('send-message').addEventListener('click', () => { + const input = $('chat-message'); + const body = input.value.trim(); + if (!body || !state.socket || !state.activeChannelId) return; + state.socket.send( + JSON.stringify({ event: 'chat:send', data: { channelId: state.activeChannelId, body } }), + ); + input.value = ''; +}); + +$('chat-message').addEventListener('keydown', (e) => { + if (e.key === 'Enter') $('send-message').click(); }); diff --git a/client/web/index.html b/client/web/index.html index ba98f16..09ef778 100644 --- a/client/web/index.html +++ b/client/web/index.html @@ -7,45 +7,211 @@
-Web-Client (Platzhalter, bis der Flutter-Client bereitsteht)
+ + +Web-Client · Konfi-Castle Events
+