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);
|
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) => {
|
$('guest-form').addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const inviteCode = $('invite-code').value;
|
const inviteCode = $('invite-code').value;
|
||||||
const firstName = $('first-name').value;
|
const firstName = $('first-name').value;
|
||||||
const lastName = $('last-name').value;
|
const lastName = $('last-name').value;
|
||||||
|
|
||||||
const res = await fetch('/api/auth/guest', {
|
setStatus($('login-status'), 'Wird geprüft…');
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
let res;
|
||||||
body: JSON.stringify({ inviteCode, firstName, lastName }),
|
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) {
|
if (!res.ok) {
|
||||||
$('login-status').textContent = `Fehler: ${res.status}`;
|
setStatus($('login-status'), `Fehler: ${res.status}`, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken } = await res.json();
|
const { accessToken } = await res.json();
|
||||||
state.token = accessToken;
|
await onLoggedIn(accessToken);
|
||||||
state.kcId = decodeJwtPayload(accessToken)?.kcId ?? null;
|
|
||||||
$('login-status').textContent = 'Angemeldet.';
|
|
||||||
$('login-section').hidden = true;
|
|
||||||
$('app-section').hidden = false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// --- 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 () => {
|
$('submit-wahl').addEventListener('click', async () => {
|
||||||
const wahlId = $('wahl-id').value;
|
const wahlId = $('wahl-id').value;
|
||||||
const prioritaeten = $('prioritaeten')
|
const prioritaeten = $('prioritaeten')
|
||||||
@@ -43,24 +321,51 @@ $('submit-wahl').addEventListener('click', async () => {
|
|||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const res = await fetch(`/api/wahl/${wahlId}/teilnehmer`, {
|
if (!wahlId || prioritaeten.length === 0) {
|
||||||
method: 'POST',
|
setStatus($('wahl-status'), 'Bitte Wahl-ID und mindestens eine Priorität angeben.', 'error');
|
||||||
headers: {
|
return;
|
||||||
'Content-Type': 'application/json',
|
}
|
||||||
Authorization: `Bearer ${state.token}`,
|
|
||||||
},
|
setStatus($('wahl-status'), 'Wird gesendet…');
|
||||||
body: JSON.stringify({ prioritaeten }),
|
|
||||||
});
|
let res;
|
||||||
$('wahl-status').textContent = res.ok ? 'Gespeichert.' : `Fehler: ${res.status}`;
|
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 () => {
|
$('load-files').addEventListener('click', async () => {
|
||||||
if (!state.kcId) return;
|
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 files = res.ok ? await res.json() : [];
|
||||||
const list = $('file-list');
|
const list = $('file-list');
|
||||||
|
|
||||||
|
if (!files.length) {
|
||||||
|
clearList(list, 'Keine Dateien verfügbar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const li = document.createElement('li');
|
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', () => {
|
$('join-channel').addEventListener('click', () => {
|
||||||
const channelId = $('channel-id').value;
|
const channelId = $('channel-id').value;
|
||||||
if (!channelId || !state.token) return;
|
if (!channelId || !state.token) return;
|
||||||
|
joinChannel(channelId);
|
||||||
|
});
|
||||||
|
|
||||||
|
function joinChannel(channelId) {
|
||||||
if (state.socket) state.socket.close();
|
if (state.socket) state.socket.close();
|
||||||
|
state.activeChannelId = channelId;
|
||||||
|
|
||||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
const socket = new WebSocket(`${protocol}://${location.host}/chat?token=${state.token}`);
|
const socket = new WebSocket(`${protocol}://${location.host}/chat?token=${state.token}`);
|
||||||
state.socket = socket;
|
state.socket = socket;
|
||||||
|
|
||||||
|
const chatLog = $('chat-log');
|
||||||
|
clearList(chatLog, 'Verbindung wird hergestellt…');
|
||||||
|
|
||||||
socket.addEventListener('open', () => {
|
socket.addEventListener('open', () => {
|
||||||
socket.send(JSON.stringify({ event: 'chat:join', data: { channelId } }));
|
socket.send(JSON.stringify({ event: 'chat:join', data: { channelId } }));
|
||||||
|
clearList(chatLog, 'Verbunden. Warte auf Nachrichten…');
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener('message', (event) => {
|
socket.addEventListener('message', (event) => {
|
||||||
const { event: name, data } = JSON.parse(event.data);
|
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;
|
if (name !== 'chat:message') return;
|
||||||
|
|
||||||
|
const emptyPlaceholder = chatLog.querySelector('.list-empty');
|
||||||
|
if (emptyPlaceholder) emptyPlaceholder.remove();
|
||||||
|
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.textContent = data.body;
|
const body = document.createElement('span');
|
||||||
$('chat-log').appendChild(li);
|
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();
|
||||||
});
|
});
|
||||||
|
|||||||
+193
-27
@@ -7,45 +7,211 @@
|
|||||||
<link rel="stylesheet" href="style.css" />
|
<link rel="stylesheet" href="style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<div class="bg-decor" aria-hidden="true"></div>
|
||||||
<h1>KC-App</h1>
|
|
||||||
<p class="subtitle">Web-Client (Platzhalter, bis der Flutter-Client bereitsteht)</p>
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-mark">KC</span>
|
||||||
|
<div>
|
||||||
|
<h1>KC-App</h1>
|
||||||
|
<p class="subtitle">Web-Client · Konfi-Castle Events</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span id="conn-badge" class="badge badge-muted">nicht angemeldet</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section id="login-section">
|
<section id="login-section" class="card">
|
||||||
<h2>Guest/Konfi-Zugang</h2>
|
<div class="card-header">
|
||||||
<form id="guest-form">
|
<h2>Anmelden</h2>
|
||||||
<label>Einladungscode <input id="invite-code" name="inviteCode" required /></label>
|
<p class="card-hint">Als Gast/Konfi mit Einladungscode oder als Team-Mitglied mit Passwort.</p>
|
||||||
<label>Vorname <input id="first-name" name="firstName" required /></label>
|
</div>
|
||||||
<label>Nachname <input id="last-name" name="lastName" required /></label>
|
|
||||||
<button type="submit">Beitreten</button>
|
<div class="tabs">
|
||||||
|
<button type="button" class="tab-btn active" data-tab="guest-tab">Gast / Konfi</button>
|
||||||
|
<button type="button" class="tab-btn" data-tab="team-tab">Team-Login</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="guest-form" class="form-grid tab-panel" data-panel="guest-tab">
|
||||||
|
<label>
|
||||||
|
<span>Einladungscode</span>
|
||||||
|
<input id="invite-code" name="inviteCode" placeholder="z. B. AB12-CD34" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Vorname</span>
|
||||||
|
<input id="first-name" name="firstName" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Nachname</span>
|
||||||
|
<input id="last-name" name="lastName" required />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<span>Beitreten</span>
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p id="login-status"></p>
|
|
||||||
|
<form id="team-form" class="form-grid tab-panel" data-panel="team-tab" hidden>
|
||||||
|
<label>
|
||||||
|
<span>Gemeinde-Name</span>
|
||||||
|
<input id="team-gemeinde" name="gemeindeName" placeholder="z. B. Mustergemeinde" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Passwort</span>
|
||||||
|
<input id="team-password" name="password" type="password" required />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<span>Anmelden</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p id="login-status" class="status"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="app-section" hidden>
|
<section id="app-section" class="stack" hidden>
|
||||||
<h2>Wahl</h2>
|
<div class="card" id="whoami-card" hidden>
|
||||||
<div>
|
<div class="card-header row">
|
||||||
<label>Wahl-ID <input id="wahl-id" /></label>
|
<div>
|
||||||
<label>Prioritäten (Workshop-IDs, Komma-getrennt) <input id="prioritaeten" /></label>
|
<h2>Angemeldet als</h2>
|
||||||
<button id="submit-wahl">Absenden</button>
|
<p class="card-hint" id="whoami-text">–</p>
|
||||||
|
</div>
|
||||||
|
<button id="logout-btn" class="btn btn-ghost">Abmelden</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p id="wahl-status"></p>
|
|
||||||
|
|
||||||
<h2>Dateien</h2>
|
<div class="card" id="gruppen-card" hidden>
|
||||||
<button id="load-files">Dateien laden</button>
|
<div class="card-header row">
|
||||||
<ul id="file-list"></ul>
|
<div>
|
||||||
|
<h2>Gruppenchat erstellen</h2>
|
||||||
<h2>Chat (Broadcast)</h2>
|
<p class="card-hint">Team-Mitglieder und Konfis frei auswählen (KC-weit, egal welche Gemeinde).</p>
|
||||||
<div>
|
</div>
|
||||||
<label>Channel-ID <input id="channel-id" /></label>
|
<button id="load-candidates" class="btn btn-ghost">Kandidaten laden</button>
|
||||||
<button id="join-channel">Beitreten</button>
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>KC-ID</span>
|
||||||
|
<input id="gruppe-kcid" placeholder="KC-ID" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Name der Gruppe</span>
|
||||||
|
<input id="gruppe-name" placeholder="z. B. Ausflugsplanung" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="two-col">
|
||||||
|
<div>
|
||||||
|
<p class="card-hint">Team-Mitglieder</p>
|
||||||
|
<ul id="candidate-users" class="list list-picker">
|
||||||
|
<li class="list-empty">Noch nicht geladen.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="card-hint">Konfis</p>
|
||||||
|
<ul id="candidate-guests" class="list list-picker">
|
||||||
|
<li class="list-empty">Noch nicht geladen.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="create-gruppe" class="btn btn-primary">Gruppenchat erstellen</button>
|
||||||
|
<p id="gruppe-status" class="status"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="manage-card" hidden>
|
||||||
|
<div class="card-header row">
|
||||||
|
<div>
|
||||||
|
<h2>Teilnehmer verwalten</h2>
|
||||||
|
<p class="card-hint">Für einen bestehenden Gruppenchat Teilnehmer hinzufügen/entfernen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid form-inline">
|
||||||
|
<label>
|
||||||
|
<span>Channel-ID</span>
|
||||||
|
<input id="manage-channel-id" placeholder="Channel-ID des Gruppenchats" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid form-inline">
|
||||||
|
<label>
|
||||||
|
<span>User-ID</span>
|
||||||
|
<input id="manage-user-id" placeholder="optional" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Gast-ID</span>
|
||||||
|
<input id="manage-guest-id" placeholder="optional" />
|
||||||
|
</label>
|
||||||
|
<button id="add-participant" class="btn btn-primary">Hinzufügen</button>
|
||||||
|
<button id="remove-participant" class="btn btn-ghost">Entfernen</button>
|
||||||
|
</div>
|
||||||
|
<p id="manage-status" class="status"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Workshop-Wahl</h2>
|
||||||
|
<p class="card-hint">Wünsche in Reihenfolge deiner Priorität eintragen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>Wahl-ID</span>
|
||||||
|
<input id="wahl-id" placeholder="Wahl-ID" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Prioritäten</span>
|
||||||
|
<input id="prioritaeten" placeholder="Workshop-IDs, mit Komma getrennt" />
|
||||||
|
</label>
|
||||||
|
<button id="submit-wahl" class="btn btn-primary">
|
||||||
|
<span>Absenden</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="wahl-status" class="status"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header row">
|
||||||
|
<div>
|
||||||
|
<h2>Dateien</h2>
|
||||||
|
<p class="card-hint">Für dich freigegebene Dokumente.</p>
|
||||||
|
</div>
|
||||||
|
<button id="load-files" class="btn btn-ghost">Aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
<ul id="file-list" class="list list-files">
|
||||||
|
<li class="list-empty">Noch keine Dateien geladen.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header row">
|
||||||
|
<div>
|
||||||
|
<h2>Chat</h2>
|
||||||
|
<p class="card-hint">Channel-ID eingeben und live mitlesen/schreiben.</p>
|
||||||
|
</div>
|
||||||
|
<button id="load-channels" class="btn btn-ghost">Meine Chats laden</button>
|
||||||
|
</div>
|
||||||
|
<ul id="channel-list" class="list list-channels">
|
||||||
|
<li class="list-empty">Noch keine Chats geladen.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="form-grid form-inline">
|
||||||
|
<label>
|
||||||
|
<span>Channel-ID</span>
|
||||||
|
<input id="channel-id" placeholder="Channel-ID" />
|
||||||
|
</label>
|
||||||
|
<button id="join-channel" class="btn btn-primary">Beitreten</button>
|
||||||
|
</div>
|
||||||
|
<ul id="chat-log" class="list list-chat">
|
||||||
|
<li class="list-empty">Noch keine Nachrichten.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="form-grid form-inline">
|
||||||
|
<label>
|
||||||
|
<span>Nachricht</span>
|
||||||
|
<input id="chat-message" placeholder="Nachricht schreiben…" />
|
||||||
|
</label>
|
||||||
|
<button id="send-message" class="btn btn-primary">Senden</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul id="chat-log"></ul>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>KC-App · Zero-Dependency Web-Fallback</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+361
-35
@@ -1,62 +1,388 @@
|
|||||||
body {
|
:root {
|
||||||
font-family: system-ui, sans-serif;
|
--bg: #0f1220;
|
||||||
max-width: 640px;
|
--bg-soft: #171b2e;
|
||||||
margin: 2rem auto;
|
--card: #1c2138;
|
||||||
padding: 0 1rem;
|
--card-border: #2a3050;
|
||||||
color: #1a1a1a;
|
--text: #eef0fb;
|
||||||
|
--text-muted: #9aa1c4;
|
||||||
|
--accent: #6c8cff;
|
||||||
|
--accent-strong: #8f6cff;
|
||||||
|
--accent-text: #ffffff;
|
||||||
|
--success: #4ade80;
|
||||||
|
--danger: #f87171;
|
||||||
|
--radius: 14px;
|
||||||
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
* {
|
||||||
margin-bottom: 2rem;
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-decor {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
background:
|
||||||
|
radial-gradient(600px circle at 15% -10%, rgba(108, 140, 255, 0.25), transparent 60%),
|
||||||
|
radial-gradient(500px circle at 100% 10%, rgba(143, 108, 255, 0.18), transparent 55%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1.25rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
box-shadow: 0 8px 20px rgba(108, 140, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
color: #666;
|
margin: 0.1rem 0 0;
|
||||||
font-size: 0.9rem;
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
section {
|
.badge {
|
||||||
margin-bottom: 2rem;
|
font-size: 0.75rem;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
form,
|
.badge-muted {
|
||||||
#app-section > div {
|
color: var(--text-muted);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-online {
|
||||||
|
color: var(--success);
|
||||||
|
background: rgba(74, 222, 128, 0.12);
|
||||||
|
border-color: rgba(74, 222, 128, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0.5rem 1.25rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 1.25rem;
|
||||||
max-width: 360px;
|
}
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack .card {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h2 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-inline {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-inline label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
font-size: 0.9rem;
|
gap: 0.35rem;
|
||||||
gap: 0.25rem;
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
padding: 0.4rem;
|
padding: 0.65rem 0.75rem;
|
||||||
font-size: 1rem;
|
font-size: 0.95rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
background: var(--bg-soft);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
input::placeholder {
|
||||||
padding: 0.5rem;
|
color: #6b7194;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(108, 140, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.7rem 1.1rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.08s ease, opacity 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||||
|
color: var(--accent-text);
|
||||||
|
box-shadow: 0 8px 20px rgba(108, 140, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
padding: 0.5rem 0.85rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
min-height: 1.1rem;
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.status-ok {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.status-error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list li {
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-files li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-files li::before {
|
||||||
|
content: "📄";
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-chat li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-chat li .chat-body {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat-log,
|
.tab-btn.active {
|
||||||
#file-list {
|
color: var(--accent-text);
|
||||||
list-style: none;
|
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||||
padding: 0;
|
border-color: transparent;
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat-log li,
|
.two-col {
|
||||||
#file-list li {
|
display: grid;
|
||||||
padding: 0.4rem 0.6rem;
|
grid-template-columns: 1fr 1fr;
|
||||||
border-bottom: 1px solid #eee;
|
gap: 1rem;
|
||||||
|
margin: 0.75rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.two-col {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-picker {
|
||||||
|
max-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-picker li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-picker li input {
|
||||||
|
padding: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-picker li.picked {
|
||||||
|
background: rgba(108, 140, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-channels li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-channels li:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 1.25rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.topbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 1.1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user