style(web): redesign the zero-dependency web fallback client
- Dark theme with gradient accents, card-based layout replacing the
bare form/section markup
- Header brand mark + live connection status badge
- Nicer inputs/buttons (focus rings, primary/ghost variants), empty
states for the file and chat lists, auto-scroll on new chat messages
- Fix a broken template literal in app.js: the Authorization header
was rendered as the literal string "*** ${state.token}" instead of
"Bearer ${state.token}" -- guest Wahl submission and file loading
were silently failing auth
- Add basic network-error handling around the three fetch() calls
This commit is contained in:
+415
-26
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user