Compare commits

...

39 Commits

Author SHA1 Message Date
baf236ea40 release: Version 2.2 – Gitea über https://git.digiseed.cloud 2026-10-25 13:27:01 +01:00
e9a98e7423 fix: Gitea-API über https://git.digiseed.cloud statt IP-Adresse 2026-10-25 13:20:24 +01:00
b6fb0f519b Agenda aktualisiert 12.10.2026 2026-10-12 17:27:18 +00:00
7893110125 Agenda aktualisiert 12.10.2026 2026-10-12 16:58:37 +00:00
7b024ae919 Agenda aktualisiert 12.10.2026 2026-10-12 16:53:38 +00:00
d6e6809dd7 Agenda aktualisiert 12.10.2026 2026-10-12 16:45:56 +00:00
b0c83b7b0c Agenda aktualisiert 11.10.2026 2026-10-11 16:58:53 +00:00
Christian Heubaum
7c3523959e Favicon (Sonne) auf allen Seiten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:06:54 +02:00
Christian Heubaum
2bcf6095ab Stammvertreter-Auswahl als A–Z sortiertes Dropdown
Im Vertretungs-Dialog ersetzt ein Dropdown die Kachel-Auswahl; Sidebar- und Admin-Liste ebenfalls nach Name sortiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:01:27 +02:00
bb9186d759 Agenda aktualisiert 6.10.2026 2026-10-06 04:48:24 +00:00
29f4e91e38 Agenda aktualisiert 5.10.2026 2026-10-05 16:30:55 +00:00
ae68d34829 Agenda aktualisiert 29.9.2026 2026-09-29 04:32:53 +00:00
22561af0a4 Agenda aktualisiert 28.9.2026 2026-09-28 18:49:48 +00:00
719bb05610 Agenda aktualisiert 28.9.2026 2026-09-28 18:44:42 +00:00
3ef838a58b Agenda aktualisiert 28.9.2026 2026-09-28 18:41:53 +00:00
475aa65bc0 Agenda aktualisiert 28.9.2026 2026-09-28 18:27:13 +00:00
24eee60cf1 Agenda aktualisiert 21.9.2026 2026-09-21 18:22:02 +00:00
bd1575500e Agenda aktualisiert 15.9.2026 2026-09-15 04:31:50 +00:00
4e2dacc50f Agenda aktualisiert 14.9.2026 2026-09-14 17:39:52 +00:00
9d48b427ba Agenda aktualisiert 14.9.2026 2026-09-14 17:16:37 +00:00
294a1be11c Agenda aktualisiert 7.9.2026 2026-09-07 18:08:06 +00:00
958f8e13c7 Agenda aktualisiert 7.9.2026 2026-09-07 17:40:06 +00:00
8aee368200 Stammvertreter aktualisiert 2026-09-07 17:34:12 +00:00
8c26f26e47 Agenda aktualisiert 7.9.2026 2026-09-07 17:31:18 +00:00
b1cd3b1c5f Agenda aktualisiert 7.9.2026 2026-09-07 17:01:14 +00:00
64fb49f4bc Agenda aktualisiert 7.9.2026 2026-09-07 17:00:17 +00:00
6fa3d62d36 Agenda aktualisiert 7.9.2026 2026-09-07 16:25:59 +00:00
f993ac91b2 Agenda aktualisiert 3.9.2026 2026-09-03 16:42:00 +00:00
be377caf51 Agenda aktualisiert 3.9.2026 2026-09-03 16:40:29 +00:00
9df667e7ee Agenda aktualisiert 3.9.2026 2026-09-03 16:40:17 +00:00
b3f4f41bfa Agenda aktualisiert 31.8.2026 2026-08-31 17:25:09 +00:00
Christian Heubaum
5d9427c7a6 chapters/ vorübergehend wieder auf main (Übergangsphase)
Noch nicht aktualisierte Installationen lesen Chapter-Daten über den
alten Pfad (ref=main) und interpretieren das Fehlen als widerrufene
Lizenz — das hätte laufende Chapter sofort ausgesperrt. chapters/
bleibt deshalb vorerst auf main UND data parallel vorhanden, bis alle
Installationen auf die neue, data-Branch-basierte Version aktualisiert
sind. main wird erst danach final bereinigt.
2026-08-31 17:17:35 +02:00
Christian Heubaum
08340b5aec Chapter-Daten auf separaten data-Branch ausgelagert
chapters/ lag bisher im main-Branch, aus dem sich jede lokale
Installation bedient (z.B. per ZIP-Download) — dadurch bekam jedes
Chapter beim Einrichten die Mitgliederdaten aller anderen Chapter mit,
obwohl die App diese Daten ohnehin nie lokal liest, sondern immer live
über die Gitea-API holt.

Alle Lese-/Schreibzugriffe auf chapters/* laufen jetzt gegen den neuen
data-Branch (giteaDataUrl / giteaAuthUrl(..., 'data') inkl. branch:
'data' in den Schreib-Bodies). App-Code (index.html, admin.html,
version.json) bleibt weiterhin auf main. chapters/ ist aus main
entfernt (lokal weiterhin vorhanden, per .gitignore ausgeschlossen)
und liegt vollständig auf dem neuen data-Branch.
2026-08-31 17:16:29 +02:00
Christian Heubaum
8127f87e03 Per-Chapter Start-Dateien aus dem Repo genommen
start-<chapter>.html enthält den Aktivierungscode des jeweiligen
Chapters im Klartext. Da jede Chapter-Installation aus demselben
Repo/Download besteht, würde sonst jedes Chapter die Codes aller
anderen Chapter mitbekommen. Solche Dateien bleiben ab jetzt lokal
(per .gitignore ausgeschlossen) und werden individuell verschickt.
2026-08-31 17:00:42 +02:00
Christian Heubaum
95df518900 Auto-Aktivierung per URL-Parameter + Start-Datei pro Chapter
index.html liest jetzt ?chapter=<code> aus der URL und aktiviert das
Chapter automatisch, ohne dass der Lizenzcode manuell eingegeben
werden muss. start-helios.html ist ein Beispiel: eine kleine
Redirect-Datei, die man statt index.html verschickt/öffnet und die
direkt zum aktivierten Steuerpult des jeweiligen Chapters führt.
2026-08-31 16:57:36 +02:00
Christian Heubaum
f031e94fd8 BNI-Vorstellungsrunde-Anleitung.pdf aus dem Haupt-Repo entfernt (bleibt lokal, Doku läuft über Zoho Desk) 2026-08-31 16:51:58 +02:00
Christian Heubaum
22997c1add Merge remote-tracking branch 'origin/main' 2026-08-31 16:50:28 +02:00
Christian Heubaum
f15dba0786 chapters/.DS_Store aus dem Repo entfernt (Finder-Metadatei, bereits per .gitignore ausgeschlossen) 2026-08-31 16:50:23 +02:00
Christian Heubaum
7841c4854b Screenshots und anleitung.html aus dem Haupt-Repo entfernt
Beide werden von Chapter-Installationen nicht benötigt und bleiben
lokal erhalten. Die Anleitung wird stattdessen in Zoho Desk gepflegt.
2026-08-31 16:49:58 +02:00
29 changed files with 140 additions and 1003 deletions

4
.gitignore vendored
View File

@@ -1,2 +1,6 @@
Push Push
.DS_Store .DS_Store
screenshots/
anleitung.html
BNI-Vorstellungsrunde-Anleitung.pdf
start-*.html

Binary file not shown.

View File

@@ -4,6 +4,9 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BNI Admin-Panel</title> <title>BNI Admin-Panel</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<style> <style>
:root { :root {
--red: #cc0000; --red-dark: #990000; --red: #cc0000; --red-dark: #990000;
@@ -376,11 +379,12 @@
// SHARED CONSTANTS (copy from index.html) // SHARED CONSTANTS (copy from index.html)
// ============================================================ // ============================================================
const APP_VERSION = '1.1.0'; const APP_VERSION = '1.1.0';
const GITEA_RAW = 'http://72.60.134.73:3000/api/v1/repos/cheubaum/BNI-Vorstellungsrunde/raw'; const GITEA_RAW = 'https://git.digiseed.cloud/api/v1/repos/cheubaum/BNI-Vorstellungsrunde/raw';
const GITEA_API = 'http://72.60.134.73:3000/api/v1/repos/cheubaum/BNI-Vorstellungsrunde'; const GITEA_API = 'https://git.digiseed.cloud/api/v1/repos/cheubaum/BNI-Vorstellungsrunde';
const CHAPTER_CONFIG_KEY = 'bni-active-chapter'; const CHAPTER_CONFIG_KEY = 'bni-active-chapter';
function giteaUrl(path) { return `${GITEA_RAW}/${path}?ref=main`; } function giteaUrl(path) { return `${GITEA_RAW}/${path}?ref=main`; }
function giteaAuthUrl(path, token) { return `${GITEA_API}/contents/${path}?token=${encodeURIComponent(token)}`; } function giteaDataUrl(path) { return `${GITEA_RAW}/${path}?ref=data`; }
function giteaAuthUrl(path, token, ref) { return `${GITEA_API}/contents/${path}?token=${encodeURIComponent(token)}${ref ? '&ref=' + ref : ''}`; }
// ============================================================ // ============================================================
// CHAPTER CONFIG // CHAPTER CONFIG
@@ -470,7 +474,7 @@ function switchAdminTab(tab) {
let _svAdminPhotoB64 = null; let _svAdminPhotoB64 = null;
function renderSvList() { function renderSvList() {
const list = loadStammvertreter(); const list = loadStammvertreter().sort((a, b) => (a.name || '').localeCompare(b.name || '', 'de', { sensitivity: 'base' }));
const container = document.getElementById('admin-sv-list'); const container = document.getElementById('admin-sv-list');
if (!list.length) { if (!list.length) {
container.innerHTML = `<div style="color:var(--muted);font-size:0.88rem;padding:8px 0;">Noch keine Stammvertreter gespeichert.</div>`; container.innerHTML = `<div style="color:var(--muted);font-size:0.88rem;padding:8px 0;">Noch keine Stammvertreter gespeichert.</div>`;
@@ -577,7 +581,7 @@ async function openImportFromChapter() {
const listEl = document.getElementById('import-chapter-select-list'); const listEl = document.getElementById('import-chapter-select-list');
listEl.innerHTML = '<div style="color:var(--muted);font-size:0.85rem;">Lade Chapter…</div>'; listEl.innerHTML = '<div style="color:var(--muted);font-size:0.85rem;">Lade Chapter…</div>';
try { try {
const res = await fetch(`${GITEA_API}/contents/chapters?ref=main`); const res = await fetch(`${GITEA_API}/contents/chapters?ref=data`);
const items = await res.json(); const items = await res.json();
const chapters = Array.isArray(items) ? items.filter(i => i.type === 'dir') : []; const chapters = Array.isArray(items) ? items.filter(i => i.type === 'dir') : [];
if (!chapters.length) { listEl.innerHTML = '<div style="color:var(--muted);">Keine anderen Chapter gefunden.</div>'; return; } if (!chapters.length) { listEl.innerHTML = '<div style="color:var(--muted);">Keine anderen Chapter gefunden.</div>'; return; }
@@ -585,7 +589,7 @@ async function openImportFromChapter() {
for (const ch of chapters) { for (const ch of chapters) {
if (activeChapter && ch.name === activeChapter.code) continue; if (activeChapter && ch.name === activeChapter.code) continue;
try { try {
const cfgRes = await fetch(giteaUrl(`chapters/${ch.name}/config.json`), { cache: 'no-store' }); const cfgRes = await fetch(giteaDataUrl(`chapters/${ch.name}/config.json`), { cache: 'no-store' });
const cfg = cfgRes.ok ? await cfgRes.json() : {}; const cfg = cfgRes.ok ? await cfgRes.json() : {};
const btn = document.createElement('div'); const btn = document.createElement('div');
btn.className = 'chapter-item'; btn.className = 'chapter-item';
@@ -611,7 +615,7 @@ async function loadChapterMembersForImport(code, chapterName) {
const listEl = document.getElementById('import-member-list'); const listEl = document.getElementById('import-member-list');
listEl.innerHTML = '<div style="padding:12px;color:var(--muted);">Lade Mitglieder…</div>'; listEl.innerHTML = '<div style="padding:12px;color:var(--muted);">Lade Mitglieder…</div>';
try { try {
const res = await fetch(giteaUrl(`chapters/${code}/members.json`)); const res = await fetch(giteaDataUrl(`chapters/${code}/members.json`));
if (!res.ok) { listEl.innerHTML = '<div style="padding:12px;color:#dc2626;">Keine Mitglieder gefunden.</div>'; return; } if (!res.ok) { listEl.innerHTML = '<div style="padding:12px;color:#dc2626;">Keine Mitglieder gefunden.</div>'; return; }
const data = await res.json(); const data = await res.json();
const members = data.members || []; const members = data.members || [];
@@ -669,11 +673,11 @@ async function uploadSvToGitea() {
const path = `chapters/${activeChapter.code}/stammvertreter.json`; const path = `chapters/${activeChapter.code}/stammvertreter.json`;
try { try {
let sha = null; let sha = null;
const shaRes = await fetch(giteaAuthUrl(path, token)); const shaRes = await fetch(giteaAuthUrl(path, token, 'data'));
if (shaRes.ok) sha = (await shaRes.json()).sha; if (shaRes.ok) sha = (await shaRes.json()).sha;
const body = { message: 'Stammvertreter aktualisiert', content }; const body = { message: 'Stammvertreter aktualisiert', content, branch: 'data' };
if (sha) body.sha = sha; if (sha) body.sha = sha;
const res = await fetch(giteaAuthUrl(path, token), { method: sha?'PUT':'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify(body) }); const res = await fetch(giteaAuthUrl(path, token, 'data'), { method: sha?'PUT':'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify(body) });
if (res.ok) { statusEl.textContent = `✓ ${list.length} Stammvertreter gesichert`; statusEl.style.color = 'var(--success)'; } if (res.ok) { statusEl.textContent = `✓ ${list.length} Stammvertreter gesichert`; statusEl.style.color = 'var(--success)'; }
else { statusEl.textContent = 'Fehler beim Upload.'; statusEl.style.color = '#dc2626'; } else { statusEl.textContent = 'Fehler beim Upload.'; statusEl.style.color = '#dc2626'; }
} catch(e) { statusEl.textContent = 'Verbindungsfehler.'; statusEl.style.color = '#dc2626'; } } catch(e) { statusEl.textContent = 'Verbindungsfehler.'; statusEl.style.color = '#dc2626'; }
@@ -683,7 +687,7 @@ async function downloadSvFromGitea() {
const statusEl = document.getElementById('sv-sync-status'); const statusEl = document.getElementById('sv-sync-status');
statusEl.textContent = 'Lade…'; statusEl.style.color = 'var(--muted)'; statusEl.textContent = 'Lade…'; statusEl.style.color = 'var(--muted)';
try { try {
const res = await fetch(giteaUrl(`chapters/${activeChapter.code}/stammvertreter.json`), { cache: 'no-store' }); const res = await fetch(giteaDataUrl(`chapters/${activeChapter.code}/stammvertreter.json`), { cache: 'no-store' });
if (!res.ok) { statusEl.textContent = 'Keine Daten auf Server.'; statusEl.style.color = 'var(--muted)'; return; } if (!res.ok) { statusEl.textContent = 'Keine Daten auf Server.'; statusEl.style.color = 'var(--muted)'; return; }
const data = await res.json(); const data = await res.json();
if (data.substitutes) { saveStammvertreter(data.substitutes); renderSvList(); statusEl.textContent = `✓ ${data.substitutes.length} Stammvertreter geladen`; statusEl.style.color = 'var(--success)'; } if (data.substitutes) { saveStammvertreter(data.substitutes); renderSvList(); statusEl.textContent = `✓ ${data.substitutes.length} Stammvertreter geladen`; statusEl.style.color = 'var(--success)'; }
@@ -780,7 +784,7 @@ async function fetchMembersAdmin() {
const statusEl = document.getElementById('members-status'); const statusEl = document.getElementById('members-status');
statusEl.textContent = 'Lade vom Server…'; statusEl.style.color = 'var(--muted)'; statusEl.textContent = 'Lade vom Server…'; statusEl.style.color = 'var(--muted)';
try { try {
const res = await fetch(giteaUrl(`chapters/${activeChapter.code}/members.json`), { cache: 'no-store' }); const res = await fetch(giteaDataUrl(`chapters/${activeChapter.code}/members.json`), { cache: 'no-store' });
if (!res.ok) { statusEl.textContent = 'Keine Mitglieder auf Server.'; return; } if (!res.ok) { statusEl.textContent = 'Keine Mitglieder auf Server.'; return; }
const data = await res.json(); const data = await res.json();
if (!data.members || !data.members.length) { statusEl.textContent = 'Liste ist leer.'; return; } if (!data.members || !data.members.length) { statusEl.textContent = 'Liste ist leer.'; return; }
@@ -817,14 +821,14 @@ async function uploadMembersAdmin() {
const data = { lastUpdated: new Date().toISOString().slice(0,10), members }; const data = { lastUpdated: new Date().toISOString().slice(0,10), members };
const content = btoa(unescape(encodeURIComponent(JSON.stringify(data, null, 2)))); const content = btoa(unescape(encodeURIComponent(JSON.stringify(data, null, 2))));
const path = `chapters/${activeChapter.code}/members.json`; const path = `chapters/${activeChapter.code}/members.json`;
const url = giteaAuthUrl(path, token); const url = giteaAuthUrl(path, token, 'data');
console.log('[Upload] URL:', url); console.log('[Upload] URL:', url);
statusEl.textContent = 'Prüfe SHA…'; statusEl.textContent = 'Prüfe SHA…';
let sha = null; let sha = null;
const shaRes = await fetch(url); const shaRes = await fetch(url);
console.log('[Upload] SHA-Status:', shaRes.status); console.log('[Upload] SHA-Status:', shaRes.status);
if (shaRes.ok) sha = (await shaRes.json()).sha; if (shaRes.ok) sha = (await shaRes.json()).sha;
const body = { message:`Mitglieder aktualisiert ${new Date().toLocaleDateString('de')}`, content }; const body = { message:`Mitglieder aktualisiert ${new Date().toLocaleDateString('de')}`, content, branch: 'data' };
if (sha) body.sha = sha; if (sha) body.sha = sha;
statusEl.textContent = sha ? 'Aktualisiere…' : 'Erstelle…'; statusEl.textContent = sha ? 'Aktualisiere…' : 'Erstelle…';
const res = await fetch(url, { method:sha?'PUT':'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) }); const res = await fetch(url, { method:sha?'PUT':'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) });
@@ -919,7 +923,7 @@ function editAgendaItem(id) {
}; };
thumb.onload = () => { if (thumbHint) thumbHint.style.display = 'none'; }; thumb.onload = () => { if (thumbHint) thumbHint.style.display = 'none'; };
if (thumbHint) thumbHint.style.display = 'none'; if (thumbHint) thumbHint.style.display = 'none';
thumb.src = it.imagePath.startsWith('http') ? it.imagePath : giteaUrl(`chapters/${activeChapter.code}/${it.imagePath}`); thumb.src = it.imagePath.startsWith('http') ? it.imagePath : giteaDataUrl(`chapters/${activeChapter.code}/${it.imagePath}`);
thumb.style.display = 'block'; thumb.style.display = 'block';
} else { } else {
thumb.style.display = 'none'; thumb.style.display = 'none';
@@ -963,11 +967,11 @@ function handleAgendaImageFile(input) {
async function uploadAgendaImage(path, dataUrl, token) { async function uploadAgendaImage(path, dataUrl, token) {
const content = dataUrl.split(',')[1]; const content = dataUrl.split(',')[1];
const url = giteaAuthUrl(path, token); const url = giteaAuthUrl(path, token, 'data');
let sha = null; let sha = null;
const shaRes = await fetch(url); const shaRes = await fetch(url);
if (shaRes.ok) sha = (await shaRes.json()).sha; if (shaRes.ok) sha = (await shaRes.json()).sha;
const body = { message: `Agenda-Bild aktualisiert ${new Date().toLocaleDateString('de')}`, content }; const body = { message: `Agenda-Bild aktualisiert ${new Date().toLocaleDateString('de')}`, content, branch: 'data' };
if (sha) body.sha = sha; if (sha) body.sha = sha;
const res = await fetch(url, { method: sha ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const res = await fetch(url, { method: sha ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) { const t = await res.text(); throw new Error(`${res.status}: ${t.slice(0, 80)}`); } if (!res.ok) { const t = await res.text(); throw new Error(`${res.status}: ${t.slice(0, 80)}`); }
@@ -1047,7 +1051,7 @@ async function fetchAgendaAdmin() {
const statusEl = document.getElementById('agenda-status'); const statusEl = document.getElementById('agenda-status');
statusEl.textContent = 'Lade vom Server…'; statusEl.style.color = 'var(--muted)'; statusEl.textContent = 'Lade vom Server…'; statusEl.style.color = 'var(--muted)';
try { try {
const res = await fetch(giteaUrl(`chapters/${activeChapter.code}/agenda.json`), { cache: 'no-store' }); const res = await fetch(giteaDataUrl(`chapters/${activeChapter.code}/agenda.json`), { cache: 'no-store' });
if (!res.ok) { statusEl.textContent = 'Noch keine Agenda auf dem Server.'; return; } if (!res.ok) { statusEl.textContent = 'Noch keine Agenda auf dem Server.'; return; }
const data = await res.json(); const data = await res.json();
const state = loadState(); const state = loadState();
@@ -1068,11 +1072,11 @@ async function uploadAgendaAdmin() {
const data = { items: state.agendaCatalog || [] }; const data = { items: state.agendaCatalog || [] };
const content = btoa(unescape(encodeURIComponent(JSON.stringify(data, null, 2)))); const content = btoa(unescape(encodeURIComponent(JSON.stringify(data, null, 2))));
const path = `chapters/${activeChapter.code}/agenda.json`; const path = `chapters/${activeChapter.code}/agenda.json`;
const url = giteaAuthUrl(path, token); const url = giteaAuthUrl(path, token, 'data');
let sha = null; let sha = null;
const shaRes = await fetch(url); const shaRes = await fetch(url);
if (shaRes.ok) sha = (await shaRes.json()).sha; if (shaRes.ok) sha = (await shaRes.json()).sha;
const body = { message: `Agenda aktualisiert ${new Date().toLocaleDateString('de')}`, content }; const body = { message: `Agenda aktualisiert ${new Date().toLocaleDateString('de')}`, content, branch: 'data' };
if (sha) body.sha = sha; if (sha) body.sha = sha;
const res = await fetch(url, { method: sha ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const res = await fetch(url, { method: sha ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (res.ok) { statusEl.textContent = `✓ ${data.items.length} Agenda-Punkte hochgeladen`; statusEl.style.color = 'var(--success)'; } if (res.ok) { statusEl.textContent = `✓ ${data.items.length} Agenda-Punkte hochgeladen`; statusEl.style.color = 'var(--success)'; }
@@ -1089,16 +1093,16 @@ async function loadAllChapters() {
const container = document.getElementById('all-chapters-list'); const container = document.getElementById('all-chapters-list');
container.innerHTML = '<div style="color:var(--muted);font-size:0.85rem;">Lade…</div>'; container.innerHTML = '<div style="color:var(--muted);font-size:0.85rem;">Lade…</div>';
try { try {
const res = await fetch(`${GITEA_API}/contents/chapters?ref=main`); const res = await fetch(`${GITEA_API}/contents/chapters?ref=data`);
const items = await res.json(); const items = await res.json();
const chapters = Array.isArray(items) ? items.filter(i => i.type === 'dir') : []; const chapters = Array.isArray(items) ? items.filter(i => i.type === 'dir') : [];
_allChaptersData = []; _allChaptersData = [];
container.innerHTML = ''; container.innerHTML = '';
for (const ch of chapters) { for (const ch of chapters) {
try { try {
const cfgRes = await fetch(giteaUrl(`chapters/${ch.name}/config.json`), { cache: 'no-store' }); const cfgRes = await fetch(giteaDataUrl(`chapters/${ch.name}/config.json`), { cache: 'no-store' });
const cfg = cfgRes.ok ? await cfgRes.json() : {}; const cfg = cfgRes.ok ? await cfgRes.json() : {};
const membersRes = await fetch(giteaUrl(`chapters/${ch.name}/members.json`)); const membersRes = await fetch(giteaDataUrl(`chapters/${ch.name}/members.json`));
const membersData = membersRes.ok ? await membersRes.json() : {}; const membersData = membersRes.ok ? await membersRes.json() : {};
_allChaptersData.push({ code: ch.name, cfg }); _allChaptersData.push({ code: ch.name, cfg });
const isEnabled = !!cfg.memberCoordinatorReport; const isEnabled = !!cfg.memberCoordinatorReport;
@@ -1227,18 +1231,18 @@ async function toggleCoordinatorReport(code, currentEnabled) {
const token = getAdminToken(); const token = getAdminToken();
if (!token) { alert('Bitte Admin-Token in den Einstellungen eintragen.'); return; } if (!token) { alert('Bitte Admin-Token in den Einstellungen eintragen.'); return; }
try { try {
const cfgRes = await fetch(giteaUrl(`chapters/${code}/config.json`), { cache: 'no-store' }); const cfgRes = await fetch(giteaDataUrl(`chapters/${code}/config.json`), { cache: 'no-store' });
if (!cfgRes.ok) { alert('Config nicht gefunden.'); return; } if (!cfgRes.ok) { alert('Config nicht gefunden.'); return; }
const cfg = await cfgRes.json(); const cfg = await cfgRes.json();
const shaRes = await fetch(giteaAuthUrl(`chapters/${code}/config.json`, token)); const shaRes = await fetch(giteaAuthUrl(`chapters/${code}/config.json`, token, 'data'));
if (!shaRes.ok) { alert('SHA-Fetch fehlgeschlagen.'); return; } if (!shaRes.ok) { alert('SHA-Fetch fehlgeschlagen.'); return; }
const sha = (await shaRes.json()).sha; const sha = (await shaRes.json()).sha;
cfg.memberCoordinatorReport = !currentEnabled; cfg.memberCoordinatorReport = !currentEnabled;
const content = btoa(unescape(encodeURIComponent(JSON.stringify(cfg, null, 2)))); const content = btoa(unescape(encodeURIComponent(JSON.stringify(cfg, null, 2))));
const res = await fetch(giteaAuthUrl(`chapters/${code}/config.json`, token), { const res = await fetch(giteaAuthUrl(`chapters/${code}/config.json`, token, 'data'), {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: `Koordinatoren-Bericht ${cfg.memberCoordinatorReport ? 'aktiviert' : 'deaktiviert'} für ${code}`, content, sha }) body: JSON.stringify({ message: `Koordinatoren-Bericht ${cfg.memberCoordinatorReport ? 'aktiviert' : 'deaktiviert'} für ${code}`, content, sha, branch: 'data' })
}); });
if (res.ok) { loadAllChapters(); } if (res.ok) { loadAllChapters(); }
else { alert('Fehler beim Speichern der Konfiguration.'); } else { alert('Fehler beim Speichern der Konfiguration.'); }
@@ -1253,7 +1257,7 @@ async function loadCoordinatorReportData() {
form.style.display = 'block'; form.style.display = 'block';
_coordReportData = null; _coordReportData = null;
try { try {
const res = await fetch(giteaUrl(`chapters/${code}/coordinator-report-${year}.json`), { cache: 'no-store' }); const res = await fetch(giteaDataUrl(`chapters/${code}/coordinator-report-${year}.json`), { cache: 'no-store' });
if (res.ok) _coordReportData = await res.json(); if (res.ok) _coordReportData = await res.json();
} catch(e) {} } catch(e) {}
if (!_coordReportData) { if (!_coordReportData) {
@@ -1429,14 +1433,14 @@ async function _saveCoordinatorReportToGitea(code, data) {
_coordStatus('Speichere…'); _coordStatus('Speichere…');
let sha = null; let sha = null;
try { try {
const shaRes = await fetch(giteaAuthUrl(path, token)); const shaRes = await fetch(giteaAuthUrl(path, token, 'data'));
if (shaRes.ok) sha = (await shaRes.json()).sha; if (shaRes.ok) sha = (await shaRes.json()).sha;
} catch(e) {} } catch(e) {}
const content = btoa(unescape(encodeURIComponent(JSON.stringify(data, null, 2)))); const content = btoa(unescape(encodeURIComponent(JSON.stringify(data, null, 2))));
const body = { message: `Update Koordinatoren-Bericht ${year} für ${code}`, content }; const body = { message: `Update Koordinatoren-Bericht ${year} für ${code}`, content, branch: 'data' };
if (sha) body.sha = sha; if (sha) body.sha = sha;
try { try {
const res = await fetch(giteaAuthUrl(path, token), { const res = await fetch(giteaAuthUrl(path, token, 'data'), {
method: sha ? 'PUT' : 'POST', method: sha ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) body: JSON.stringify(body)

View File

@@ -1,872 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BNI Vorstellungsrunde – Anleitung</title>
<style>
:root {
--red: #cc0000; --navy: #1a2744; --bg: #f0f2f5;
--card: #fff; --muted: #6b7280; --border: #e5e7eb;
--success: #16a34a;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background:var(--bg); color:#1a1a2e; line-height:1.6; }
.cover { background:linear-gradient(160deg,#0f1f45,#1a2744,#0d1b35); color:white; padding:60px 40px; text-align:center; }
.cover h1 { font-size:2rem; font-weight:800; letter-spacing:-0.5px; }
.cover .subtitle { color:rgba(255,255,255,0.6); margin-top:8px; font-size:1rem; }
.cover .badge { display:inline-block; margin-top:24px; background:var(--red); color:white; padding:6px 18px; border-radius:20px; font-size:0.82rem; font-weight:600; }
.toc { background:white; padding:24px 32px; border-bottom:1px solid var(--border); }
.toc h2 { font-size:0.78rem; font-weight:700; color:var(--muted); text-transform:uppercase; letter-spacing:1px; margin-bottom:12px; }
.toc ol { padding-left:18px; }
.toc li { font-size:0.9rem; padding:3px 0; }
.toc a { color:var(--navy); text-decoration:none; }
.toc a:hover { color:var(--red); }
.content { max-width:820px; margin:0 auto; padding:32px 24px 64px; }
.section { margin-bottom:48px; }
.section-header { display:flex; align-items:center; gap:14px; margin-bottom:20px; padding-bottom:12px; border-bottom:2px solid var(--red); }
.step-num { width:36px; height:36px; background:var(--red); color:white; border-radius:50%; display:flex; align-items:center; justify-content:center; font-weight:800; font-size:1rem; flex-shrink:0; }
.section-header h2 { font-size:1.25rem; font-weight:700; color:var(--navy); }
.card { background:white; border-radius:12px; border:1px solid var(--border); padding:20px; margin-bottom:16px; }
.tip { background:#fef3c7; border:1px solid #fcd34d; border-radius:10px; padding:14px 16px; font-size:0.88rem; margin-bottom:16px; }
.tip strong { color:#92400e; }
.info { background:#eff6ff; border:1px solid #bfdbfe; border-radius:10px; padding:14px 16px; font-size:0.88rem; margin-bottom:16px; }
.steps { counter-reset:step; }
.step { display:flex; gap:14px; margin-bottom:12px; align-items:flex-start; }
.step::before { counter-increment:step; content:counter(step); width:24px; height:24px; background:var(--navy); color:white; border-radius:50%; font-size:0.78rem; font-weight:700; display:flex; align-items:center; justify-content:center; flex-shrink:0; margin-top:2px; }
.step p { font-size:0.9rem; }
.step code { background:#f3f4f6; padding:1px 6px; border-radius:4px; font-size:0.85rem; }
/* ——— MOCKUPS ——— */
.mockup { background:#1a1a2e; border-radius:12px; overflow:hidden; margin:16px 0; box-shadow:0 4px 20px rgba(0,0,0,0.2); }
.mockup-bar { background:#0f1420; padding:8px 14px; display:flex; align-items:center; gap:8px; }
.mockup-dot { width:10px; height:10px; border-radius:50%; }
.mockup-body { padding:16px; }
.mock-activation { background:linear-gradient(160deg,#0f1f45,#1a2744); padding:32px 20px; text-align:center; border-radius:8px; }
.mock-activation h3 { color:white; font-size:1.1rem; margin-bottom:6px; }
.mock-activation p { color:rgba(255,255,255,0.5); font-size:0.82rem; margin-bottom:20px; }
.mock-input { background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:8px; padding:10px 14px; color:white; font-size:0.9rem; text-align:center; margin-bottom:12px; width:100%; max-width:280px; }
.mock-btn-red { background:#cc0000; color:white; border:none; border-radius:8px; padding:10px 24px; font-weight:700; cursor:pointer; font-size:0.9rem; }
.mock-btn-gray { background:#374151; color:white; border:none; border-radius:8px; padding:8px 16px; font-weight:600; cursor:pointer; font-size:0.82rem; }
.mock-panel { background:#1e2235; border-radius:10px; padding:12px; }
.mock-member { display:flex; align-items:center; gap:10px; padding:8px 10px; background:rgba(255,255,255,0.05); border-radius:8px; margin-bottom:6px; }
.mock-avatar { width:36px; height:36px; border-radius:50%; object-fit:cover; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:0.75rem; font-weight:700; color:white; }
.mock-name { font-size:0.85rem; font-weight:600; color:white; }
.mock-role { font-size:0.72rem; color:rgba(255,255,255,0.5); }
.mock-badge { padding:2px 8px; border-radius:12px; font-size:0.7rem; font-weight:700; margin-left:auto; }
.mock-badge.absent { background:rgba(204,0,0,0.2); color:#f87171; }
.mock-badge.present { background:rgba(22,163,74,0.2); color:#4ade80; }
.mock-timer { text-align:center; padding:20px; }
.mock-timer-ring { width:100px; height:100px; border-radius:50%; border:6px solid #cc0000; display:flex; align-items:center; justify-content:center; margin:0 auto 12px; }
.mock-timer-num { font-size:2rem; font-weight:800; color:white; }
.mock-timer-name { color:white; font-weight:700; font-size:1rem; }
.mock-timer-company { color:rgba(255,255,255,0.5); font-size:0.8rem; }
.mock-tabs { display:flex; border-bottom:1px solid rgba(255,255,255,0.1); margin-bottom:12px; }
.mock-tab { padding:8px 14px; font-size:0.78rem; color:rgba(255,255,255,0.4); border-bottom:2px solid transparent; }
.mock-tab.active { color:#cc0000; border-bottom-color:#cc0000; }
.mock-sub-modal { background:#2a2f45; border-radius:12px; padding:16px; }
.mock-sub-modal h4 { color:white; font-size:0.9rem; margin-bottom:12px; }
.mock-field { background:rgba(255,255,255,0.1); border-radius:6px; padding:8px 10px; color:rgba(255,255,255,0.7); font-size:0.8rem; margin-bottom:8px; }
.mock-sv-card { display:flex; align-items:center; gap:8px; background:rgba(255,255,255,0.07); border-radius:8px; padding:8px 10px; margin-bottom:6px; border:1px solid transparent; cursor:pointer; }
.mock-sv-card:hover { border-color:rgba(204,0,0,0.5); }
.mock-sv-name { color:white; font-size:0.82rem; font-weight:600; }
.mock-sv-meta { color:rgba(255,255,255,0.45); font-size:0.72rem; }
.admin-mockup { background:#f0f2f5; border-radius:12px; overflow:hidden; border:1px solid var(--border); margin:16px 0; }
.admin-header { background:#1a2744; color:white; padding:12px 16px; font-size:0.9rem; font-weight:700; }
.admin-tabs { background:white; display:flex; border-bottom:1px solid var(--border); padding:0 16px; }
.admin-tab { padding:10px 16px; font-size:0.82rem; font-weight:600; color:var(--muted); border-bottom:2px solid transparent; }
.admin-tab.active { color:var(--red); border-bottom-color:var(--red); }
.admin-body { padding:16px; }
.admin-member-row { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid var(--border); cursor:pointer; }
.admin-member-row:last-child { border-bottom:none; }
.admin-photo-badge { font-size:0.72rem; margin-left:auto; }
.admin-photo-badge.ok { color:var(--success); }
.admin-photo-badge.missing { color:#d97706; }
.highlight-box { border-left:3px solid var(--red); padding:10px 16px; background:white; border-radius:0 8px 8px 0; margin:12px 0; font-size:0.88rem; }
@page { margin: 14mm 14mm 14mm 14mm; }
@media print {
body { -webkit-print-color-adjust:exact; print-color-adjust:exact; }
.cover {
-webkit-print-color-adjust:exact; print-color-adjust:exact;
min-height: 100vh;
display: flex; flex-direction: column; align-items: center; justify-content: center;
page-break-after: always;
}
.toc { page-break-inside: avoid; }
.section { page-break-inside:avoid; }
.mockup, .admin-mockup { -webkit-print-color-adjust:exact; print-color-adjust:exact; }
}
</style>
</head>
<body>
<div class="cover">
<div style="font-size:2.5rem; margin-bottom:8px;">🌟</div>
<h1>BNI Vorstellungsrunde</h1>
<div class="subtitle">Benutzeranleitung für Chapter-Admins</div>
<div class="badge">Version 1.5 · August 2026</div>
</div>
<div class="toc">
<h2>Inhalt</h2>
<ol>
<li><a href="#start">Tool starten</a></li>
<li><a href="#aktivierung">Erstmalige Aktivierung</a></li>
<li><a href="#ansichten">Ansichten: Display &amp; Steuerung</a></li>
<li><a href="#vorbereitung">Meeting vorbereiten</a></li>
<li><a href="#durchfuehrung">Meeting durchführen</a></li>
<li><a href="#vertreter">Vertreter eintragen</a></li>
<li><a href="#besucher">Besucher eintragen</a></li>
<li><a href="#admin">Admin-Panel: Fotos &amp; Stammvertreter</a></li>
<li><a href="#offline">Offline-Nutzung</a></li>
<li><a href="#update">Updates einspielen</a></li>
<li><a href="#timer-schalter">Zeitanzeige deaktivieren</a></li>
<li><a href="#augen-timer">8-Augen-Gespräche Timer</a></li>
<li><a href="#mitglieder-sync">Mitglieder mit BNI-Website synchronisieren</a></li>
<li><a href="#import-export">Import / Export &amp; Backup</a></li>
<li><a href="#agenda">Agenda-Folien für Online-Meetings (Hybrid-Chapter)</a></li>
</ol>
</div>
<div class="content">
<!-- ========== 1. START ========== -->
<div class="section" id="start">
<div class="section-header">
<div class="step-num">1</div>
<h2>Tool starten</h2>
</div>
<div class="card">
<div class="steps">
<div class="step"><p>Den Ordner <code>bni-vorstellungsrunde</code> im Finder öffnen.</p></div>
<div class="step"><p>Die Datei <strong>index.html</strong> per Doppelklick öffnen — sie startet direkt im Standardbrowser.</p></div>
</div>
</div>
<div class="info">
<strong>Alternative über das Terminal:</strong> Statt <code>index.html</code> direkt zu öffnen, kann auch die Datei <strong>start.command</strong> doppelgeklickt werden — es öffnet sich kurz ein Terminal-Fenster, das im Hintergrund einen lokalen Server startet, dann startet Chrome automatisch mit dem Tool. Das Terminal-Fenster muss dabei offen bleiben, solange das Tool genutzt wird. Diese Variante ist nur nötig, falls das direkte Öffnen von <code>index.html</code> mal nicht funktioniert (z. B. Admin-Panel unter <a href="#admin">Admin-Panel öffnen</a>).
</div>
<div class="tip">
<strong>Hinweis zu start.command:</strong> Beim ersten Doppelklick erscheint eventuell eine Sicherheitswarnung von macOS. Dann: Rechtsklick auf <code>start.command</code> → „Öffnen" → „Öffnen" bestätigen. Ab dem zweiten Mal klappt der Doppelklick direkt.
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Terminal (nur bei der Alternative über start.command)</span>
</div>
<div class="mockup-body">
<p style="color:#4ade80;font-size:0.82rem;font-family:monospace;">$ python3 -m http.server 8181</p>
<p style="color:rgba(255,255,255,0.4);font-size:0.82rem;font-family:monospace;">Serving HTTP on 0.0.0.0 port 8181 …</p>
</div>
</div>
</div>
<!-- ========== 2. AKTIVIERUNG ========== -->
<div class="section" id="aktivierung">
<div class="section-header">
<div class="step-num">2</div>
<h2>Erstmalige Aktivierung</h2>
</div>
<div class="info">
Dieser Schritt ist nur beim <strong>allerersten Start</strong> auf einem neuen Gerät oder Browser nötig. Danach öffnet das Tool direkt.
</div>
<div class="card">
<div class="steps">
<div class="step"><p>Den <strong>Lizenzcode</strong> deines Chapters eingeben (erhältst du vom Administrator <a href="mailto:support@digiseed.de">support@digiseed.de</a>).</p></div>
<div class="step"><p>Auf <strong>„Aktivieren"</strong> klicken. Das Tool lädt einmalig die Chapter-Daten vom Server.</p></div>
<div class="step"><p>Nach erfolgreicher Aktivierung öffnet sich direkt die Steuerungsansicht.</p></div>
</div>
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">http://localhost:8181</span>
</div>
<div class="mockup-body">
<div class="mock-activation">
<h3>BNI Vorstellungsrunde</h3>
<p>Bitte gib deinen Lizenzcode ein, um dein Chapter freizuschalten.</p>
<div style="margin-bottom:8px;"><input class="mock-input" placeholder="chapter-lizenzcode" readonly></div>
<button class="mock-btn-red">Aktivieren</button>
</div>
</div>
</div>
</div>
<!-- ========== 3. ANSICHTEN ========== -->
<div class="section" id="ansichten">
<div class="section-header">
<div class="step-num">3</div>
<h2>Ansichten: Display &amp; Steuerung</h2>
</div>
<div class="card">
<p style="font-size:0.9rem;">Das Tool läuft in <strong>zwei Fenstern gleichzeitig</strong>:</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:14px;">
<div style="background:#f8f9fa;border-radius:8px;padding:14px;border-left:3px solid var(--navy);">
<div style="font-weight:700;font-size:0.88rem;color:var(--navy);margin-bottom:6px;">🖥 Display-Fenster</div>
<div style="font-size:0.82rem;color:var(--muted);">Wird auf dem Präsentationsmonitor / Beamer angezeigt. Zeigt das aktuelle Mitglied groß mit Timer.</div>
</div>
<div style="background:#f8f9fa;border-radius:8px;padding:14px;border-left:3px solid var(--red);">
<div style="font-weight:700;font-size:0.88rem;color:var(--red);margin-bottom:6px;">🎛 Steuerungs-Fenster</div>
<div style="font-size:0.82rem;color:var(--muted);">Dein Laptop-Bildschirm. Hier steuerst du den Ablauf, trägst Vertreter ein und navigierst.</div>
</div>
</div>
</div>
<div class="highlight-box">
Im Steuerungs-Fenster oben in der Kopfzeile auf <strong>„🖥 Anzeige öffnen"</strong> klicken — das Display öffnet sich in einem neuen Tab. Ziehe dieses Tab auf den Beamer-Bildschirm und drücke <strong>F11</strong> für Vollbild.
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Display-Ansicht (Beamer)</span>
</div>
<div class="mockup-body" style="padding:0;background:#0a1220;">
<img src="screenshots/display-live.png" alt="Display-Ansicht mit laufendem Timer" style="width:100%;display:block;">
</div>
</div>
<div class="highlight-box">
<strong>Ab Version 1.5:</strong> Zusätzlich zum Ring wird die Restzeit oben in großen Ziffern eingeblendet — gut sichtbar, auch wenn jemand vor dem Podium steht. Bei ≤ 15 s wird die Karte gelb, bei ≤ 5 s rot, am Ende ertönt ein Glockenton.
</div>
<div class="tip">
<strong>🔔 Ton aktivieren:</strong> Beim ersten Öffnen des Display-Fensters erscheint oben ein roter Button <strong>„🔔 Ton aktivieren"</strong>. Einmal klicken — damit erlaubt der Browser das Abspielen des Glockentons. Ohne diesen Klick bleibt die App stumm (technische Einschränkung von Browsern gegen ungewollte Autoplay-Sounds).
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Display-Ansicht – Ton aktivieren</span>
</div>
<div class="mockup-body" style="padding:0;background:#0a1220;">
<img src="screenshots/display-ton-aktivieren.png" alt="Display-Ansicht mit Ton-aktivieren-Button" style="width:100%;display:block;">
</div>
</div>
</div>
<!-- ========== 4. VORBEREITUNG ========== -->
<div class="section" id="vorbereitung">
<div class="section-header">
<div class="step-num">4</div>
<h2>Meeting vorbereiten</h2>
</div>
<div class="card">
<p style="font-weight:700;font-size:0.9rem;margin-bottom:10px;">Vor dem Meeting: Abwesenheiten markieren</p>
<div class="steps">
<div class="step"><p>Im Steuerungs-Fenster: Tab <strong>„Mitglieder"</strong> öffnen.</p></div>
<div class="step"><p>Auf den Namen eines abwesenden Mitglieds klicken → kleines Menü öffnet sich.</p></div>
<div class="step"><p><strong>„Abwesend"</strong> wählen — das Mitglied wird aus der Vorstellungsrunde ausgeblendet (roter Punkt).</p></div>
<div class="step"><p>Optional: Reihenfolge per <strong>Drag &amp; Drop</strong> anpassen.</p></div>
</div>
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Steuerungs-Fenster – Reihenfolge</span>
</div>
<div class="mockup-body" style="padding:0;background:#0a1220;">
<img src="screenshots/steuerung-uebersicht.png" alt="Steuerungs-Fenster mit Mitgliederliste" style="width:100%;display:block;">
</div>
</div>
<!-- Redezeit -->
<div class="card" style="margin-top:24px;">
<p style="font-weight:700;font-size:0.95rem;margin-bottom:10px;">Redezeit anpassen</p>
<p style="font-size:0.88rem;color:var(--muted);margin-bottom:14px;">Standard-Redezeit für alle Mitglieder oder abweichende Zeit pro Person setzen.</p>
<p style="font-weight:700;font-size:0.85rem;margin-bottom:8px;">A) Standardzeit für alle</p>
<div class="steps">
<div class="step"><p>Oben in der Kopfzeile des Steuerungs-Fensters auf das Zeit-Feld <strong>„⏱ 0:50"</strong> (bzw. den aktuellen Wert) klicken.</p></div>
<div class="step"><p>Im Dialog die neue Zeit im Format <code>M:SS</code> (z. B. <code>1:00</code>) oder als Minuten (z. B. <code>1</code>) eingeben.</p></div>
<div class="step"><p>Mit <strong>OK</strong> bestätigen — die neue Zeit gilt sofort für alle Mitglieder ohne individuelle Zeit.</p></div>
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Dialog – Standard-Redezeit setzen</span>
</div>
<div class="mockup-body" style="padding:14px;background:#0a1220;text-align:center;">
<img src="screenshots/zeiteinstellung-global.png" alt="Prompt: Standard-Redezeit für alle Mitglieder" style="max-width:460px;width:100%;display:inline-block;border-radius:8px;">
</div>
</div>
<p style="font-weight:700;font-size:0.85rem;margin:20px 0 8px;">B) Individuelle Zeit pro Mitglied</p>
<div class="steps">
<div class="step"><p>In der Mitgliederliste rechts neben jedem Namen erscheint ein kleines rot umrandetes Zeit-Feld.</p></div>
<div class="step"><p>Auf dieses Feld klicken und den abweichenden Wert eingeben (leer lassen = Standardzeit übernehmen).</p></div>
<div class="step"><p>Nützlich z. B. für neue Mitglieder mit längerer Vorstellung oder für Ehrengäste.</p></div>
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Individuelles Zeit-Feld pro Mitglied</span>
</div>
<div class="mockup-body" style="padding:20px;background:#0a1220;text-align:center;">
<img src="screenshots/zeit-pro-mitglied.png" alt="Kleines Zeit-Feld neben dem Mitglied" style="width:90px;display:inline-block;border-radius:6px;background:white;padding:6px;">
</div>
</div>
</div>
</div>
<!-- ========== 5. DURCHFÜHRUNG ========== -->
<div class="section" id="durchfuehrung">
<div class="section-header">
<div class="step-num">5</div>
<h2>Meeting durchführen</h2>
</div>
<div class="card">
<div class="steps">
<div class="step"><p>Auf <strong>„Start"</strong> klicken — der Timer startet für das erste Mitglied.</p></div>
<div class="step"><p>Wenn die Zeit abgelaufen ist (Timer rot): <strong>„Weiter →"</strong> drücken für das nächste Mitglied.</p></div>
<div class="step"><p>Mit <strong>„← Zurück"</strong> kannst du jederzeit zum vorherigen Mitglied springen.</p></div>
<div class="step"><p>Timer lässt sich jederzeit mit <strong>„Pause / Fortsetzen"</strong> anhalten.</p></div>
<div class="step"><p>Am Ende erscheint eine Abschluss-Ansicht — mit <strong>„Neu starten"</strong> zurück zum Anfang.</p></div>
</div>
</div>
<div class="tip">
<strong>Tipp:</strong> Die Tastenkombination <strong>→ Pfeiltaste</strong> auf der Tastatur funktioniert als „Weiter" — praktisch für die Fernbedienung.
</div>
</div>
<!-- ========== 6. VERTRETER ========== -->
<div class="section" id="vertreter">
<div class="section-header">
<div class="step-num">6</div>
<h2>Vertreter eintragen</h2>
</div>
<div class="card">
<p style="font-size:0.9rem;margin-bottom:12px;">Wenn ein Mitglied einen Vertreter schickt, wird dieser in der Vorstellungsrunde an Stelle des Mitglieds angezeigt.</p>
<div class="steps">
<div class="step"><p>Tab <strong>„Vertretungen"</strong> öffnen.</p></div>
<div class="step"><p>Mitglied aus der Liste wählen, das vertreten wird.</p></div>
<div class="step"><p>Falls die Person bereits als <strong>Stammvertreter</strong> gespeichert ist: oben aus der Schnellauswahl wählen — alle Felder werden automatisch ausgefüllt.</p></div>
<div class="step"><p>Andernfalls: Name, Firma und Fachgebiet des Vertreters eingeben und ggf. ein Foto hochladen.</p></div>
<div class="step"><p>Auf <strong>„Speichern"</strong> klicken.</p></div>
</div>
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Steuerung – Vertretung eintragen</span>
</div>
<div class="mockup-body" style="padding:14px;background:#0a1220;text-align:center;">
<img src="screenshots/vertreter-erfassen.png" alt="Modal: Vertretung für Mitglied erfassen" style="max-width:100%;display:inline-block;border-radius:8px;">
</div>
</div>
</div>
<!-- ========== 7. BESUCHER ========== -->
<div class="section" id="besucher">
<div class="section-header">
<div class="step-num">7</div>
<h2>Besucher eintragen</h2>
</div>
<div class="card">
<div class="steps">
<div class="step"><p>In der rechten Seitenleiste den Tab <strong>„Besucher"</strong> öffnen.</p></div>
<div class="step"><p>Name eingeben, optional Firma, Fachgebiet, „Eingeladen von" (Mitglied wählen) und Foto-URL ergänzen.</p></div>
<div class="step"><p>Auf <strong>„+ Besucher hinzufügen"</strong> klicken — der Besucher erscheint sofort in der Reihenfolge.</p></div>
<div class="step"><p>Besucher werden am Ende der Vorstellungsrunde nach allen Mitgliedern eingeblendet.</p></div>
</div>
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Steuerung – Besucher hinzufügen</span>
</div>
<div class="mockup-body" style="padding:14px;background:#0a1220;text-align:center;">
<img src="screenshots/besucher-eintragen.png" alt="Formular: Besucher hinzufügen" style="max-width:320px;width:100%;display:inline-block;border-radius:8px;">
</div>
</div>
<div class="info">
<strong>Schnellerfassung per PDF:</strong> Wenn du eine Besucher-/Vertreterliste als PDF vorliegen hast (z. B. aus dem Chapter-Portal), klick oben in der Kopfzeile auf <strong>„📄 PDF importieren"</strong> und lege die PDF in das Feld — Einträge werden automatisch als Zeilen erkannt und du kannst sie vor dem Import noch anpassen.
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Steuerung – Schnellerfassung per PDF</span>
</div>
<div class="mockup-body" style="padding:14px;background:#0a1220;text-align:center;">
<img src="screenshots/besucher-vertreterliste.png" alt="Modal: Besucher- und Vertreterliste per PDF importieren" style="max-width:100%;display:inline-block;border-radius:8px;">
</div>
</div>
<div class="tip">
<strong>Tipp:</strong> Besucher werden nicht dauerhaft gespeichert — sie werden nach dem Meeting automatisch gelöscht, wenn die Runde zurückgesetzt wird.
</div>
</div>
<!-- ========== 8. ADMIN ========== -->
<div class="section" id="admin">
<div class="section-header">
<div class="step-num">8</div>
<h2>Admin-Panel: Fotos &amp; Stammvertreter</h2>
</div>
<div class="info">
Das Admin-Panel öffnest du am einfachsten über den Link im Steuerungs-Fenster unter dem Tab <strong>„Admin"</strong> (Button <strong>„⚙ Admin-Panel öffnen"</strong>) — das funktioniert unabhängig davon, wie das Tool gestartet wurde. Alternativ, falls das Tool über <strong>start.command</strong> (Terminal) gestartet wurde, ist es auch direkt unter <code>http://localhost:8181/admin.html</code> erreichbar — diese Adresse funktioniert <strong>nur</strong>, wenn das Tool auf diesem Weg läuft.
</div>
<div class="card">
<p style="font-weight:700;font-size:0.9rem;margin-bottom:10px;">Mitgliederfotos hinzufügen</p>
<div class="steps">
<div class="step"><p>Im Admin-Panel Tab <strong>„Mitglieder"</strong> öffnen.</p></div>
<div class="step"><p>Auf ein Mitglied mit <strong>„+ Foto"</strong> klicken.</p></div>
<div class="step"><p>Foto aus dem Finder wählen (JPG/PNG, beliebige Größe — wird automatisch verkleinert).</p></div>
<div class="step"><p>Auf <strong>„Speichern"</strong> klicken — das Foto ist jetzt lokal gespeichert.</p></div>
<div class="step"><p>Nach allen Fotos: <strong>„Auf Server hochladen"</strong> — dann haben alle Geräte die Fotos beim nächsten „Vom Server laden".</p></div>
</div>
</div>
<div class="admin-mockup" style="padding:0;">
<img src="screenshots/admin-panel-mitglieder.png" alt="Admin-Panel Mitglieder-Tab" style="width:100%;display:block;">
</div>
<div class="card" style="margin-top:16px;">
<p style="font-weight:700;font-size:0.9rem;margin-bottom:10px;">Stammvertreter anlegen</p>
<p style="font-size:0.88rem;color:var(--muted);margin-bottom:12px;">Stammvertreter sind Personen, die regelmäßig als Vertreter kommen. Einmal anlegen → bei jeder Vertretung schnell auswählen.</p>
<div class="steps">
<div class="step"><p>Tab <strong>„Stammvertreter"</strong> öffnen.</p></div>
<div class="step"><p>Auf <strong>„+ Hinzufügen"</strong> klicken.</p></div>
<div class="step"><p>Name, Firma, Fachgebiet und Foto eingeben.</p></div>
<div class="step"><p>Optional: <strong>„Aus anderem Chapter"</strong> — Mitglied aus einem anderen Chapter-Verzeichnis als Stammvertreter übernehmen.</p></div>
<div class="step"><p>Auf <strong>„Auf Server sichern"</strong> klicken, damit die Liste nicht verloren geht.</p></div>
</div>
</div>
</div>
<!-- ========== 9. OFFLINE ========== -->
<div class="section" id="offline">
<div class="section-header">
<div class="step-num">9</div>
<h2>Offline-Nutzung</h2>
</div>
<div class="card">
<p style="font-size:0.9rem;margin-bottom:12px;">Das Tool funktioniert <strong>vollständig ohne Internet</strong>, sobald es einmal online eingerichtet wurde.</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div style="background:#f0fdf4;border-radius:8px;padding:12px;border:1px solid #bbf7d0;">
<div style="font-weight:700;color:var(--success);font-size:0.85rem;margin-bottom:6px;">✓ Funktioniert offline</div>
<ul style="font-size:0.82rem;color:#166534;padding-left:16px;line-height:1.7;">
<li>Meeting starten &amp; steuern</li>
<li>Timer, Navigation</li>
<li>Abwesenheiten markieren</li>
<li>Vertreter &amp; Besucher eintragen</li>
<li>Stammvertreter verwenden</li>
</ul>
</div>
<div style="background:#fef2f2;border-radius:8px;padding:12px;border:1px solid #fecaca;">
<div style="font-weight:700;color:#dc2626;font-size:0.85rem;margin-bottom:6px;">✗ Braucht Internet</div>
<ul style="font-size:0.82rem;color:#991b1b;padding-left:16px;line-height:1.7;">
<li>Erstmalige Aktivierung</li>
<li>Mitglieder vom Server laden</li>
<li>Fotos auf Server hochladen</li>
<li>Stammvertreter sichern</li>
<li>Agenda-Punkte mit Live-Einbettung anzeigen (Zoho Show)</li>
</ul>
</div>
</div>
</div>
<div class="highlight-box">
<strong>Empfehlung:</strong> Vor jedem Meeting einmal kurz <strong>„Vom Server laden"</strong> klicken — <em>vorausgesetzt</em>, die Liste wurde vorher aktualisiert und auf den Server hochgeladen. So sind Mitgliederliste &amp; Fotos auf dem aktuellen Stand, auch wenn beim Meeting kein Internet verfügbar ist.
</div>
<div class="tip">
<strong>Neue Mitglieder oder Änderungen?</strong> Wenn du keine Berechtigung zum Hochladen hast, gib die Änderungen an dein Support-Team weiter — dort werden die Mitgliederdaten angepasst und auf dem Server gespeichert. Beim nächsten „Vom Server laden" hast du die neue Version.
</div>
</div>
<!-- ========== 10. UPDATE ========== -->
<div class="section" id="update">
<div class="section-header">
<div class="step-num">10</div>
<h2>Updates einspielen</h2>
</div>
<div class="info">
Wenn eine neue Version verfügbar ist, erscheint oben im Steuerungs-Fenster automatisch ein roter Hinweis-Banner.
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Steuerung – Update-Banner</span>
</div>
<div class="mockup-body">
<div style="background:#1a2744;color:white;padding:10px 14px;border-radius:8px;display:flex;align-items:center;gap:12px;font-size:0.85rem;">
<span>🆕 Update 1.5 verfügbar – Farbwarnung, Glockenton &amp; 8-Augen-Timer</span>
<button class="mock-btn-red" style="padding:4px 12px;font-size:0.82rem;white-space:nowrap;">Jetzt laden</button>
</div>
</div>
</div>
<div class="card">
<div class="steps">
<div class="step"><p>Auf <strong>„Jetzt laden"</strong> klicken.</p></div>
<div class="step"><p>Es öffnen sich zwei Speichern-Dialoge nacheinander: erst für <code>index.html</code>, dann wird <code>admin.html</code> automatisch heruntergeladen.</p></div>
<div class="step"><p>Im Speichern-Dialog zum BNI-Ordner navigieren und die bestehende <code>index.html</code> <strong>überschreiben</strong>.</p></div>
<div class="step"><p><code>admin.html</code> aus dem Download-Ordner ebenfalls in den BNI-Ordner verschieben und ersetzen.</p></div>
<div class="step"><p>Seite neu laden — das Update ist eingespielt.</p></div>
</div>
</div>
<div class="tip">
<strong>Hinweis:</strong> Alle gespeicherten Daten (Mitglieder, Fotos, Stammvertreter) bleiben beim Update erhalten.
</div>
</div>
<!-- ========== 11. TIMER-SCHALTER ========== -->
<div class="section" id="timer-schalter">
<div class="section-header">
<div class="step-num">11</div>
<h2>Zeitanzeige deaktivieren</h2>
</div>
<div class="info">
Manche Chapter möchten keine Zeitanzeige verwenden. Diese kann im Admin-Tab der Steuerung dauerhaft ausgeschaltet werden.
</div>
<div class="card">
<div class="steps">
<div class="step"><p>Im Steuerungs-Fenster den Tab <strong>„Admin"</strong> (rechte Seitenleiste) öffnen.</p></div>
<div class="step"><p>Unter <strong>„Einstellungen"</strong> den Haken bei <strong>„Zeitanzeige aktiv"</strong> entfernen.</p></div>
<div class="step"><p>Timer-Ring, Countdown und Zeitbadges verschwinden sofort auf allen Ansichten.</p></div>
</div>
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Steuerung – Admin-Tab</span>
</div>
<div class="mockup-body" style="padding:14px;background:#0a1220;text-align:center;">
<img src="screenshots/zeitanzeige-aktiv.png" alt="Admin-Sidebar mit Zeitanzeige-Checkbox" style="max-width:340px;width:100%;display:inline-block;border-radius:8px;">
</div>
</div>
<div class="tip">
<strong>Hinweis:</strong> Die Einstellung wird automatisch gespeichert und bleibt nach einem Neustart erhalten.
</div>
</div>
<!-- ========== 12. 8-AUGEN-TIMER ========== -->
<div class="section" id="augen-timer">
<div class="section-header">
<div class="step-num">12</div>
<h2>8-Augen-Gespräche Timer</h2>
</div>
<div class="info">
Für 8-Augen-Gespräche gibt es einen eigenen Vollbild-Timer mit einer Uhr pro Tischgröße (3er / 4er / 5er / 6er). Am 4er-Tisch wird die gewünschte Sprechzeit eingegeben — die anderen Tische werden automatisch so berechnet, dass alle Redner die passende Zeit haben.
</div>
<div class="card">
<div class="steps">
<div class="step"><p>Im Steuerungs-Fenster den Tab <strong>„Admin"</strong> (rechte Seitenleiste) öffnen.</p></div>
<div class="step"><p>Im Abschnitt <strong>„8-Augen-Gespräche"</strong> auf <strong>„👥 8-Augen Timer starten"</strong> klicken — der Vollbild-Timer öffnet sich.</p></div>
<div class="step"><p>In der unteren Leiste unter <strong>„Wechsel 4er"</strong> die gewünschte Sprechzeit eingeben (z. B. <code>8:00</code> für 8 Minuten) und mit <strong>✓</strong> übernehmen.</p></div>
<div class="step"><p>Mit den Tischgrößen-Buttons (<strong>3er / 4er / 5er / 6er</strong>) auswählen, welche Timer angezeigt werden sollen — nicht benötigte einfach ausblenden.</p></div>
<div class="step"><p>Auf <strong>„▶ Start"</strong> klicken — alle sichtbaren Timer laufen gleichzeitig los.</p></div>
<div class="step"><p>Am Ende jeder Sprechzeit erklingt der Glockenton, danach folgt eine <strong>3 Sekunden Wechsel-Phase</strong> (blaue Karte, Countdown), bevor die nächste Sprechzeit startet.</p></div>
<div class="step"><p>Wenn alle Redner durch sind, stoppt der Timer automatisch — die Karten werden grün mit <strong>✓ Alle N Redner durch</strong>.</p></div>
<div class="step"><p>Mit <strong>„✕ Beenden"</strong> schließt sich der Vollbild-Timer und die App springt zurück in die Vorstellungsrunde.</p></div>
</div>
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Anzeige – 8-Augen Timer (Vollbild)</span>
</div>
<div class="mockup-body" style="background:#0a1220;padding:0;">
<img src="screenshots/augen-timer.png" alt="8-Augen-Timer Vollbild" style="width:100%;display:block;">
</div>
</div>
<div class="highlight-box">
<strong>Farbwarnung:</strong> Timer-Karte wird <strong>gelb</strong> ab 30 s Restzeit und <strong>rot</strong> ab 10 s. Bei jedem Rednerwechsel und Rundenende ertönt zusätzlich ein Glockenton.
</div>
<div class="tip">
<strong>Rechenlogik:</strong> Die Rundendauer bleibt für alle Tische gleich (z. B. 8 min am 4er = 4 × 8 min = 32 min Runde). Der 3er-Tisch bekommt entsprechend 10:40 pro Redner, der 5er 6:24, der 6er 5:20. Die 3 Sekunden Wechsel hängen sich zusätzlich hinter jeden Redner (außer dem letzten).
</div>
</div>
<!-- ========== 13. MITGLIEDER SYNC ========== -->
<div class="section" id="mitglieder-sync">
<div class="section-header">
<div class="step-num">13</div>
<h2>Mitglieder mit BNI-Website synchronisieren</h2>
</div>
<div class="info">
Neue Mitglieder oder aktualisierte Fachgebiete/Fotos direkt aus der offiziellen BNI-Mitgliederliste in die App übernehmen. Läuft einmalig eingerichtet über zwei Bookmarklets im Browser.
</div>
<div class="card">
<p style="font-weight:700;font-size:0.9rem;margin-bottom:10px;">Ablauf</p>
<div class="steps">
<div class="step"><p>Im Steuerungs-Fenster oben in der Kopfzeile auf <strong>„🔄 Mitglieder aktualisieren"</strong> klicken.</p></div>
<div class="step"><p><strong>Schritt 1 (einmalig):</strong> Beide Bookmarklet-Buttons in die Lesezeichenleiste des Browsers ziehen. Zuerst <strong>„Diagnose"</strong> auf der BNI-Mitgliederliste ausführen und die heruntergeladene Diagnose-Datei hier hochladen — damit passt sich der Export-Button automatisch an die aktuelle Seitenstruktur an.</p></div>
<div class="step"><p><strong>Schritt 2:</strong> BNI-Mitgliederliste öffnen (Link im Dialog), warten bis alle Mitglieder geladen sind, dann Bookmarklet <strong>„BNI Mitglieder exportieren"</strong> klicken — es lädt automatisch eine JSON-Datei herunter.</p></div>
<div class="step"><p><strong>Schritt 3:</strong> Die JSON-Datei per <strong>„JSON-Datei laden &amp; synchronisieren"</strong> in der App einlesen. Bestehende Mitglieder werden aktualisiert (Foto, Fachgebiet), neue kommen dazu. Es wird niemand gelöscht.</p></div>
</div>
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Dialog – Mitglieder synchronisieren</span>
</div>
<div class="mockup-body" style="padding:14px;background:#0a1220;text-align:center;">
<img src="screenshots/mitglieder-sync.png" alt="Modal: Mitglieder mit BNI-Website abgleichen" style="max-width:100%;display:inline-block;border-radius:8px;">
</div>
</div>
<div class="tip">
<strong>Hinweis:</strong> Nach dem Sync die neue Liste unbedingt über das Admin-Panel <strong>„Auf Server hochladen"</strong> — sonst haben andere Geräte die alten Daten. Ohne Upload-Berechtigung: Änderungen ans Support-Team melden.
</div>
</div>
<!-- ========== 14. IMPORT / EXPORT ========== -->
<div class="section" id="import-export">
<div class="section-header">
<div class="step-num">14</div>
<h2>Import / Export &amp; Backup</h2>
</div>
<div class="info">
Vollständiges Backup der Mitgliederdaten als JSON-Datei — nützlich für Umzüge zwischen Geräten, für einen sicheren Zwischenstand oder um lokale Kurzlinks &amp; Offline-Fotos (Profildaten) zu sichern.
</div>
<div class="card">
<p style="font-weight:700;font-size:0.9rem;margin-bottom:10px;">Mitgliederdaten (JSON)</p>
<div class="steps">
<div class="step"><p>Im Steuerungs-Fenster oben auf <strong>„⬇ Import / Export"</strong> klicken.</p></div>
<div class="step"><p><strong>„Exportieren"</strong> — die kompletten Mitgliederdaten werden als Text im Feld angezeigt (per Copy&amp;Paste sichern oder in eine Datei speichern).</p></div>
<div class="step"><p><strong>„Importieren"</strong> — vorher exportierten Text wieder einfügen und einlesen. Ersetzt die aktuellen Daten im Browser.</p></div>
</div>
<p style="font-weight:700;font-size:0.9rem;margin:20px 0 10px;">Profildaten (Kurzlinks &amp; Offline-Fotos)</p>
<div class="steps">
<div class="step"><p>Kurzlinks (z. B. Website-Weiterleitungen) und lokal hochgeladene Fotos werden <strong>getrennt</strong> gespeichert und bleiben bei einem Mitglieder-Sync erhalten.</p></div>
<div class="step"><p><strong>„⬇ profildaten.json speichern"</strong> — Datei sichern und im Projektordner ablegen.</p></div>
<div class="step"><p>Nach einem Browser-Reset oder auf einem neuen Gerät: <strong>„⬆ PROFILDATEN.JSON LADEN"</strong> — Kurzlinks &amp; Fotos sind wieder da.</p></div>
</div>
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Dialog – Import / Export</span>
</div>
<div class="mockup-body" style="padding:14px;background:#0a1220;text-align:center;">
<img src="screenshots/import-export.png" alt="Modal: Import / Export" style="max-width:100%;display:inline-block;border-radius:8px;">
</div>
</div>
</div>
<!-- ========== 15. AGENDA-FOLIEN ========== -->
<div class="section" id="agenda">
<div class="section-header">
<div class="step-num">15</div>
<h2>Agenda-Folien für Online-Meetings (Hybrid-Chapter)</h2>
</div>
<div class="info">
Hybrid-Chapter (z. B. Lumora) treffen sich einmal im Monat vor Ort, sonst online. Für Online-Meetings läuft ein deutlich
größerer Ablauf als nur die Vorstellungsrunde — Begrüßung, Meeting-Regeln, DSGVO, BNI-Zahlen, Auszeichnungen, Events
usw. Diese Rahmen-Folien lassen sich direkt im Tool anlegen und an der passenden Stelle im Ablauf anzeigen — als
hochgeladenes Bild oder als Live-Einbettung aus Zoho Show.
</div>
<div class="card">
<p style="font-weight:700;font-size:0.9rem;margin-bottom:10px;">Punkte im Admin-Panel anlegen</p>
<div class="steps">
<div class="step"><p>Im Admin-Panel den Tab <strong>„Agenda"</strong> öffnen.</p></div>
<div class="step"><p>Auf <strong>„+ Agenda-Punkt hinzufügen"</strong> klicken (oder einen bestehenden Punkt anklicken, um ihn zu bearbeiten).</p></div>
<div class="step"><p>Eine <strong>Bezeichnung</strong> eingeben und die <strong>Anzeigeart</strong> wählen: „Bild" oder „Live-Einbettung".</p></div>
<div class="step"><p>Auf <strong>„Speichern"</strong> klicken — der Punkt erscheint in der Liste.</p></div>
</div>
</div>
<div class="admin-mockup" style="padding:0;">
<img src="screenshots/agenda-admin-liste.png" alt="Admin-Panel Agenda-Tab mit Liste der Punkte" style="width:100%;display:block;">
</div>
<p style="font-weight:700;font-size:0.85rem;margin:20px 0 8px;">A) Anzeigeart „Bild"</p>
<div class="card">
<p style="font-size:0.88rem;color:var(--muted);margin-bottom:0;">Für einen Folien-Export (z. B. aus PowerPoint oder Zoho Show als Bild exportiert). Über <strong>„Bild wählen"</strong> die Datei aus dem Finder wählen — sie wird automatisch auf eine für Vollbild-Anzeige passende Größe verkleinert, bevor sie hochgeladen wird.</p>
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Admin-Panel – Agenda-Punkt bearbeiten (Bild)</span>
</div>
<div class="mockup-body" style="padding:14px;background:#0a1220;text-align:center;">
<img src="screenshots/agenda-punkt-bild.png" alt="Modal: Agenda-Punkt bearbeiten, Anzeigeart Bild" style="max-width:360px;width:100%;display:inline-block;border-radius:8px;">
</div>
</div>
<p style="font-weight:700;font-size:0.85rem;margin:20px 0 8px;">B) Anzeigeart „Live-Einbettung"</p>
<div class="card">
<p style="font-size:0.88rem;color:var(--muted);margin-bottom:0;">Für eine Folie, die das Team gemeinsam in Zoho Show pflegt — Änderungen sind sofort live, ohne erneuten Upload. In Zoho Show: <strong>Datei → Freigeben/Veröffentlichen → Einbetten</strong>, den dortigen Link in das Feld „Einbettungs-URL" einfügen. Am zuverlässigsten funktioniert das, wenn jeder Agenda-Punkt eine eigene, kleine Zoho-Show-Datei ist (statt einer großen Gesamt-Präsentation) — dann startet die Einbettung immer bei der ersten Folie.</p>
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Admin-Panel – Agenda-Punkt bearbeiten (Live-Einbettung)</span>
</div>
<div class="mockup-body" style="padding:14px;background:#0a1220;text-align:center;">
<img src="screenshots/agenda-punkt-embed.png" alt="Modal: Agenda-Punkt bearbeiten, Anzeigeart Live-Einbettung" style="max-width:360px;width:100%;display:inline-block;border-radius:8px;">
</div>
</div>
<div class="tip">
<strong>Nicht vergessen:</strong> Nach dem Anlegen/Ändern von Punkten im Admin-Panel oben rechts auf <strong>„📤 Auf Server hochladen"</strong> klicken — sonst kommen die Änderungen nicht im Steuerpult (bzw. auf anderen Geräten) an.
</div>
<div class="card" style="margin-top:24px;">
<p style="font-weight:700;font-size:0.9rem;margin-bottom:10px;">Reihenfolge festlegen und live anzeigen</p>
<div class="steps">
<div class="step"><p>Im Steuerpult den neuen Tab <strong>„Agenda"</strong> öffnen (ganz links, neben „Mitglieder" und „Besucher").</p></div>
<div class="step"><p>Punkte per <strong>Drag &amp; Drop</strong> in die gewünschte Reihenfolge bringen — genau wie bei der Mitgliederliste.</p></div>
<div class="step"><p>Auf <strong>„▶ Anzeigen"</strong> klicken, um einen Punkt sofort auf dem Display zu zeigen.</p></div>
<div class="step"><p>Mit dem <strong>🚫-Button</strong> lässt sich ein Punkt aus der aktiven Reihenfolge ausblenden (für dieses Meeting nicht gebraucht) — er taucht dann unten unter „Ausgeblendet" auf und kann dort per <strong>„+ Einblenden"</strong> jederzeit wieder aktiviert werden.</p></div>
</div>
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Steuerpult – Agenda-Tab</span>
</div>
<div class="mockup-body" style="padding:0;background:#0a1220;">
<img src="screenshots/agenda-steuerung.png" alt="Steuerpult mit Agenda-Tab und sortierbarer Reihenfolge" style="width:100%;display:block;">
</div>
</div>
<div class="highlight-box">
<strong>Mitglieder-, Besucher- und Marketing-/Ergebnisrunde sind eingebaute Agenda-Punkte:</strong> Sie lassen sich genau wie die Bild-/Einbettungs-Folien frei in die Reihenfolge einsortieren — z. B. Begrüßung → Regeln → <em>Mitglieder-Vorstellungsrunde</em> → Auszeichnung → <em>Besucher-Vorstellungsrunde</em> → Events.
</div>
<div class="card">
<p style="font-weight:700;font-size:0.9rem;margin-bottom:10px;">Marketing-/Ergebnisrunde</p>
<p style="font-size:0.88rem;color:var(--muted);">Läuft dieselbe Mitgliederliste noch einmal in derselben Reihenfolge durch wie die Vorstellungsrunde — aber ohne Redezeit-Timer. Gedacht für die Runde, in der jedes Mitglied kurz berichtet, was es in der vergangenen Woche für andere Mitglieder getan hat (Empfehlungen, Besucher, 4-Augen-Gespräche, Trainings, Umsatzdank).</p>
</div>
<div class="card">
<p style="font-weight:700;font-size:0.9rem;margin-bottom:10px;">„Weiter"-Button folgt der Agenda</p>
<p style="font-size:0.88rem;color:var(--muted);">Der rote „Weiter"-Button oben führt automatisch zum jeweils nächsten Punkt in der festgelegten Reihenfolge — egal ob als Nächstes eine Folie, die Mitglieder-Vorstellungsrunde oder die Besucherrunde kommt. Bei einer Live-Einbettung kann „Weiter" nicht innerhalb der eingebetteten Folie selbst blättern (das bleibt Sache von Zoho Show), sondern springt direkt zum nächsten Agenda-Punkt.</p>
</div>
<div class="info">
<strong>Mit Präsentations-Klicker durchklicken:</strong> Pfeiltaste rechts (→) oder Leertaste lösen „Weiter" aus — das funktioniert jetzt sowohl im Steuerpult- als auch im Display-Fenster, je nachdem, welches Fenster gerade den Tastatur-Fokus hat. Zusätzlich gibt es unten rechts im Display-Fenster einen unsichtbaren Klick-Button (erscheint bei Hover), falls der Klicker statt einer Taste einen Mausklick sendet.
</div>
<div class="tip">
<strong>Grenze bei mehrseitigen Einbettungen:</strong> Sobald mit dem Klicker in eine eingebettete Folie mit mehreren internen Seiten hineingeklickt wird, um dort zu blättern, übernimmt Zoho Show den Tastatur-Fokus vollständig — von dort aus kann der Klicker nicht mehr automatisch zum nächsten Agenda-Punkt springen. Am Ende einer solchen Datei einmal bewusst „Weiter" im Steuerpult klicken oder den Klick-Button im Display nutzen. Am zuverlässigsten bleibt weiterhin: ein Zoho-Show-File pro Agenda-Punkt.
</div>
<div class="card">
<p style="font-weight:700;font-size:0.9rem;margin-bottom:10px;">Sprechernotizen (nur im Steuerpult sichtbar)</p>
<p style="font-size:0.88rem;color:var(--muted);">Wie bei PowerPoint lässt sich pro Agenda-Punkt ein Notiz-Text hinterlegen (Admin-Panel → Agenda-Punkt bearbeiten → Feld „Notizen"). Ist ein Punkt mit Notizen live geschaltet, erscheint direkt unter der Kopfzeile im Steuerpult ein gelber Hinweiskasten mit dem Text — unabhängig davon, welcher Reiter (Agenda/Mitglieder/Besucher) gerade offen ist. Im Display-/Beamer-Fenster erscheinen diese Notizen zu keinem Zeitpunkt, das sieht nur die Person am Steuerpult.</p>
</div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f57;"></div>
<div class="mockup-dot" style="background:#febc2e;"></div>
<div class="mockup-dot" style="background:#28c840;"></div>
<span style="color:rgba(255,255,255,0.4);font-size:0.78rem;margin-left:8px;">Display-Ansicht – laufende Agenda-Folie</span>
</div>
<div class="mockup-body" style="padding:0;background:#0a1220;">
<img src="screenshots/agenda-display.png" alt="Display-Ansicht mit einer live eingebetteten Agenda-Folie" style="width:100%;display:block;">
</div>
</div>
<div class="tip">
<strong>Bild wird nicht angezeigt?</strong> Meist liegt es daran, dass der Bild-Upload noch nicht abgeschlossen war, als „Speichern" geklickt wurde — einfach das Bild im Admin-Panel erneut auswählen und speichern. Erscheint stattdessen eine eingebettete Live-Folie nicht: in Zoho Show prüfen, ob die Datei über „Veröffentlichen" freigegeben ist, und im Browser testweise Werbeblocker/Privacy-Erweiterungen deaktivieren.
</div>
</div>
<!-- FOOTER -->
<div style="text-align:center;padding:32px 0;color:var(--muted);font-size:0.8rem;border-top:1px solid var(--border);margin-top:32px;">
BNI Vorstellungsrunde · Interne Anleitung · Bei Fragen an den Administrator wenden
</div>
</div><!-- .content -->
</body>
</html>

BIN
apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -4,6 +4,7 @@
"id": "willkommen", "id": "willkommen",
"label": "Willkommen", "label": "Willkommen",
"type": "embed", "type": "embed",
"notes": "wie könnte euer Produkt als Weihnachtsgeschenk vermarktet werden?\n\nVertreter:\nAlexander Kaya für Sabine Welp\nSandra Stengel für Nathalie Musto\nStefan Roth für Jennifer Born\nNiko Gäßler für Thomas Stetter\nAlexander Weimer für Jens Berger\n\n\nWert: Lebenslanges Lernen - was habt ihr dieses Jahr gelernt?",
"embedUrl": "https://show.zohopublic.eu/publish/cb5g063c60ec44c3347ad841c139671232dff" "embedUrl": "https://show.zohopublic.eu/publish/cb5g063c60ec44c3347ad841c139671232dff"
}, },
{ {
@@ -16,6 +17,7 @@
"id": "visitenkarten", "id": "visitenkarten",
"label": "Visitenkarten", "label": "Visitenkarten",
"type": "image", "type": "image",
"notes": "07:15",
"imagePath": "agenda/visitenkarten.jpg" "imagePath": "agenda/visitenkarten.jpg"
}, },
{ {
@@ -28,12 +30,14 @@
"id": "bericht-des-mitgliederkoordinators", "id": "bericht-des-mitgliederkoordinators",
"label": "Bericht des Mitgliederkoordinators", "label": "Bericht des Mitgliederkoordinators",
"type": "embed", "type": "embed",
"notes": "07:48",
"embedUrl": "https://show.zohopublic.eu/publish/cb5g0d27561ab34c141a39f981ab6b36fbd03" "embedUrl": "https://show.zohopublic.eu/publish/cb5g0d27561ab34c141a39f981ab6b36fbd03"
}, },
{ {
"id": "break-out", "id": "break-out",
"label": "Break Out", "label": "Break Out",
"type": "embed", "type": "embed",
"notes": "07:51",
"embedUrl": "https://show.zohopublic.eu/publish/cb5g0fbb79876ba15437abf469c098a72b789" "embedUrl": "https://show.zohopublic.eu/publish/cb5g0fbb79876ba15437abf469c098a72b789"
}, },
{ {
@@ -46,6 +50,7 @@
"id": "marketing-ergebnisrunde-erklaerung", "id": "marketing-ergebnisrunde-erklaerung",
"label": "Marketing- Ergebnisrunde Erklärung", "label": "Marketing- Ergebnisrunde Erklärung",
"type": "embed", "type": "embed",
"notes": "08:12",
"embedUrl": "https://show.zohopublic.eu/publish/cb5g0848053a6eea54f18bbaaee7d33861386" "embedUrl": "https://show.zohopublic.eu/publish/cb5g0848053a6eea54f18bbaaee7d33861386"
}, },
{ {
@@ -58,18 +63,21 @@
"id": "ziehung-des-tagespreises", "id": "ziehung-des-tagespreises",
"label": "Ziehung des Tagespreises", "label": "Ziehung des Tagespreises",
"type": "embed", "type": "embed",
"notes": "08:20",
"embedUrl": "https://show.zohopublic.eu/publish/cb5g0b231f4b1bdf34850a8a8baca4b2d6972" "embedUrl": "https://show.zohopublic.eu/publish/cb5g0b231f4b1bdf34850a8a8baca4b2d6972"
}, },
{ {
"id": "bericht-des-schatzmeisters", "id": "bericht-des-schatzmeisters",
"label": "Bericht des Schatzmeisters", "label": "Bericht des Schatzmeisters",
"type": "embed", "type": "embed",
"notes": "wer am Termin in Dellmensingen nicht da ist, denk daran das Geld davor an Jens zu geben / überweisen - wir bringen ihn sonst in die blöde Lage, dass er privat das Geld auslegen muss",
"embedUrl": "https://show.zohopublic.eu/publish/cb5g0249a5f37a97e419db184bef50bcbdb8e" "embedUrl": "https://show.zohopublic.eu/publish/cb5g0249a5f37a97e419db184bef50bcbdb8e"
}, },
{ {
"id": "bericht-des-eventkoordinators", "id": "bericht-des-eventkoordinators",
"label": "Bericht des Eventkoordinators", "label": "Bericht des Eventkoordinators",
"type": "embed", "type": "embed",
"notes": "08:23",
"embedUrl": "https://show.zohopublic.eu/publish/cb5g0ad05a18b97214634818823cd3bd3ebca" "embedUrl": "https://show.zohopublic.eu/publish/cb5g0ad05a18b97214634818823cd3bd3ebca"
}, },
{ {

File diff suppressed because one or more lines are too long

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

10
favicon.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<g stroke="#F5D822" stroke-width="3.5" stroke-linecap="round">
<path d="M32 2v8M32 54v8M2 32h8M54 32h8M10.8 10.8l5.7 5.7M47.5 47.5l5.7 5.7M10.8 53.2l5.7-5.7M47.5 16.5l5.7-5.7"/>
</g>
<circle cx="32" cy="32" r="18" fill="#F5D822" stroke="#C70607" stroke-width="3"/>
<g fill="none" stroke="#C70607" stroke-width="2.6" stroke-linecap="round">
<path d="M23 29.5q3-4 6 0M35 29.5q3-4 6 0"/>
<path d="M23.5 36q8.5 8 17 0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 512 B

View File

@@ -4,6 +4,9 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BNI Chapter Sonne – Vorstellungsrunde</title> <title>BNI Chapter Sonne – Vorstellungsrunde</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.js"></script>
@@ -1734,7 +1737,7 @@
<div id="sub-sv-picker" style="display:none;margin-bottom:12px;"> <div id="sub-sv-picker" style="display:none;margin-bottom:12px;">
<div style="font-size:0.75rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;">Stammvertreter wählen</div> <div style="font-size:0.75rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;">Stammvertreter wählen</div>
<div id="sub-sv-list" style="display:flex;flex-wrap:wrap;gap:8px;max-height:110px;overflow-y:auto;"></div> <select id="sub-sv-select" onchange="if(this.value) pickStammvertreter(this.value)" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:0.9rem;"></select>
<hr style="margin:12px 0 0;border:none;border-top:1px solid var(--border);"> <hr style="margin:12px 0 0;border:none;border-top:1px solid var(--border);">
</div> </div>
@@ -1943,11 +1946,12 @@
// ============================================================ // ============================================================
// CONSTANTS & CHAPTER CONFIG // CONSTANTS & CHAPTER CONFIG
// ============================================================ // ============================================================
const APP_VERSION = '2.1'; const APP_VERSION = '2.2';
const GITEA_RAW = 'http://72.60.134.73:3000/api/v1/repos/cheubaum/BNI-Vorstellungsrunde/raw'; const GITEA_RAW = 'https://git.digiseed.cloud/api/v1/repos/cheubaum/BNI-Vorstellungsrunde/raw';
const GITEA_API = 'http://72.60.134.73:3000/api/v1/repos/cheubaum/BNI-Vorstellungsrunde'; const GITEA_API = 'https://git.digiseed.cloud/api/v1/repos/cheubaum/BNI-Vorstellungsrunde';
function giteaUrl(path) { return `${GITEA_RAW}/${path}?ref=main`; } function giteaUrl(path) { return `${GITEA_RAW}/${path}?ref=main`; }
function giteaAuthUrl(path, token) { return `${GITEA_API}/contents/${path}?token=${encodeURIComponent(token)}`; } function giteaDataUrl(path) { return `${GITEA_RAW}/${path}?ref=data`; }
function giteaAuthUrl(path, token, ref) { return `${GITEA_API}/contents/${path}?token=${encodeURIComponent(token)}${ref ? '&ref=' + ref : ''}`; }
const CHAPTER_CONFIG_KEY = 'bni-active-chapter'; const CHAPTER_CONFIG_KEY = 'bni-active-chapter';
function loadChapterConfig() { function loadChapterConfig() {
@@ -2132,10 +2136,14 @@ function deleteStammvertreter(svId) {
renderStammvertreterSection(); renderStammvertreterSection();
} }
function sortSvByName(list) {
return [...list].sort((a, b) => (a.name || '').localeCompare(b.name || '', 'de', { sensitivity: 'base' }));
}
function renderStammvertreterSection() { function renderStammvertreterSection() {
const container = document.getElementById('sv-list-container'); const container = document.getElementById('sv-list-container');
if (!container) return; if (!container) return;
const list = loadStammvertreter(); const list = sortSvByName(loadStammvertreter());
if (list.length === 0) { if (list.length === 0) {
container.innerHTML = `<div style="color:var(--muted);font-size:0.82rem;padding:2px 0 8px;">Noch keine gespeichert. → <a href="admin.html" target="_blank" style="color:var(--red);">Admin öffnen</a> zum Importieren aus anderen Chaptern.</div>`; container.innerHTML = `<div style="color:var(--muted);font-size:0.82rem;padding:2px 0 8px;">Noch keine gespeichert. → <a href="admin.html" target="_blank" style="color:var(--red);">Admin öffnen</a> zum Importieren aus anderen Chaptern.</div>`;
return; return;
@@ -2153,23 +2161,15 @@ function renderStammvertreterSection() {
} }
function renderSvPicker() { function renderSvPicker() {
const list = loadStammvertreter(); const list = sortSvByName(loadStammvertreter());
const picker = document.getElementById('sub-sv-picker'); const picker = document.getElementById('sub-sv-picker');
const container = document.getElementById('sub-sv-list'); const select = document.getElementById('sub-sv-select');
if (!picker || !container) return; if (!picker || !select) return;
if (list.length === 0) { picker.style.display = 'none'; return; } if (list.length === 0) { picker.style.display = 'none'; return; }
picker.style.display = ''; picker.style.display = '';
container.innerHTML = ''; select.innerHTML = '<option value="">– Stammvertreter auswählen –</option>' + list.map(sv =>
list.forEach(sv => { `<option value="${escHtml(sv.id)}">${escHtml(sv.name)}${sv.company ? ' · ' + escHtml(sv.company) : ''}</option>`
const photoHtml = sv.photoB64 ).join('');
? `<img src="${sv.photoB64}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`
: `<div style="width:32px;height:32px;border-radius:50%;background:${getColor(sv.name)};display:flex;align-items:center;justify-content:center;color:white;font-size:0.68rem;font-weight:700;">${getInitials(sv.name)}</div>`;
const card = document.createElement('div');
card.style.cssText = 'display:flex;align-items:center;gap:7px;padding:6px 9px;background:#f8f9fa;border:1px solid var(--border);border-radius:8px;cursor:pointer;';
card.innerHTML = `${photoHtml}<div><div style="font-size:0.79rem;font-weight:600;">${escHtml(sv.name)}</div><div style="font-size:0.69rem;color:var(--muted);">${escHtml(sv.company||'')}</div></div>`;
card.onclick = () => pickStammvertreter(sv.id);
container.appendChild(card);
});
} }
function pickStammvertreter(svId) { function pickStammvertreter(svId) {
@@ -2509,7 +2509,7 @@ function renderDisplay() {
imgEl.src = ''; imgEl.src = '';
} else if (it.imagePath) { } else if (it.imagePath) {
const src = it.imagePath.startsWith('http') ? it.imagePath const src = it.imagePath.startsWith('http') ? it.imagePath
: giteaUrl(`chapters/${activeChapter.code}/${it.imagePath}`); : giteaDataUrl(`chapters/${activeChapter.code}/${it.imagePath}`);
imgEl.onerror = () => { imgEl.onerror = () => {
imgEl.style.display = 'none'; imgEl.style.display = 'none';
if (errEl) errEl.style.display = 'block'; if (errEl) errEl.style.display = 'block';
@@ -3234,7 +3234,7 @@ async function fetchChapterAgendaFromGitea() {
if (!state.agendaCatalog) state.agendaCatalog = []; if (!state.agendaCatalog) state.agendaCatalog = [];
ensureAgendaOrderDefaults(); ensureAgendaOrderDefaults();
try { try {
const res = await fetch(giteaUrl(`chapters/${activeChapter.code}/agenda.json`), { cache: 'no-store' }); const res = await fetch(giteaDataUrl(`chapters/${activeChapter.code}/agenda.json`), { cache: 'no-store' });
const items = res.ok ? (await res.json()).items : null; const items = res.ok ? (await res.json()).items : null;
if (Array.isArray(items)) { if (Array.isArray(items)) {
// Mergen statt Überschreiben: lokale, noch nicht hochgeladene Punkte // Mergen statt Überschreiben: lokale, noch nicht hochgeladene Punkte
@@ -5337,7 +5337,7 @@ async function activateChapter() {
status.textContent = 'Überprüfe Lizenzcode…'; status.textContent = 'Überprüfe Lizenzcode…';
status.style.color = 'rgba(255,255,255,0.6)'; status.style.color = 'rgba(255,255,255,0.6)';
try { try {
const res = await fetch(giteaUrl(`chapters/${code}/config.json`), { cache: 'no-store' }); const res = await fetch(giteaDataUrl(`chapters/${code}/config.json`), { cache: 'no-store' });
if (!res.ok) { if (!res.ok) {
status.textContent = 'Ungültiger oder abgelaufener Lizenzcode.'; status.textContent = 'Ungültiger oder abgelaufener Lizenzcode.';
status.style.color = '#f87171'; status.style.color = '#f87171';
@@ -5379,7 +5379,7 @@ function applyChapterUI(config) {
if (ctrlName) ctrlName.textContent = `${name} – Steuerung`; if (ctrlName) ctrlName.textContent = `${name} – Steuerung`;
if (config.logoUrl && activeChapter) { if (config.logoUrl && activeChapter) {
const src = config.logoUrl.startsWith('http') ? config.logoUrl const src = config.logoUrl.startsWith('http') ? config.logoUrl
: giteaUrl(`chapters/${activeChapter.code}/${config.logoUrl}`); : giteaDataUrl(`chapters/${activeChapter.code}/${config.logoUrl}`);
['display-logo','display-logo-empty','ctrl-logo'].forEach(id => { ['display-logo','display-logo-empty','ctrl-logo'].forEach(id => {
const img = document.getElementById(id); const img = document.getElementById(id);
if (img) img.src = src; if (img) img.src = src;
@@ -5397,7 +5397,7 @@ function applyChapterUI(config) {
async function checkLicenseAndUpdates() { async function checkLicenseAndUpdates() {
if (!activeChapter) return; if (!activeChapter) return;
try { try {
const res = await fetch(giteaUrl(`chapters/${activeChapter.code}/config.json`), { cache: 'no-store' }); const res = await fetch(giteaDataUrl(`chapters/${activeChapter.code}/config.json`), { cache: 'no-store' });
if (!res.ok) { if (!res.ok) {
clearChapterData(); clearChapterData();
state = defaultState(); state = defaultState();
@@ -5441,7 +5441,7 @@ function showUpdateBanner(version, notes) {
async function downloadUpdate(version) { async function downloadUpdate(version) {
try { try {
const licRes = await fetch(giteaUrl(`chapters/${activeChapter.code}/config.json`), { cache: 'no-store' }); const licRes = await fetch(giteaDataUrl(`chapters/${activeChapter.code}/config.json`), { cache: 'no-store' });
if (!licRes.ok) { if (!licRes.ok) {
clearChapterData(); clearChapterData();
state = defaultState(); state = defaultState();
@@ -5502,7 +5502,7 @@ async function downloadUpdate(version) {
async function fetchChapterMembersFromGitea() { async function fetchChapterMembersFromGitea() {
if (!activeChapter) return; if (!activeChapter) return;
try { try {
const res = await fetch(giteaUrl(`chapters/${activeChapter.code}/members.json`), { cache: 'no-store' }); const res = await fetch(giteaDataUrl(`chapters/${activeChapter.code}/members.json`), { cache: 'no-store' });
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
if (!data.members || !data.members.length) return; if (!data.members || !data.members.length) return;
@@ -5563,13 +5563,13 @@ async function uploadMembersToGitea() {
try { try {
// Get current SHA // Get current SHA
let sha = null; let sha = null;
const shaRes = await fetch(giteaAuthUrl(path, token)); const shaRes = await fetch(giteaAuthUrl(path, token, 'data'));
if (shaRes.ok) sha = (await shaRes.json()).sha; if (shaRes.ok) sha = (await shaRes.json()).sha;
const body = { message: `Mitglieder aktualisiert ${new Date().toLocaleDateString('de')}`, content }; const body = { message: `Mitglieder aktualisiert ${new Date().toLocaleDateString('de')}`, content, branch: 'data' };
if (sha) body.sha = sha; if (sha) body.sha = sha;
const res = await fetch(giteaAuthUrl(path, token), { const res = await fetch(giteaAuthUrl(path, token, 'data'), {
method: sha ? 'PUT' : 'POST', method: sha ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) body: JSON.stringify(body)
@@ -5821,7 +5821,7 @@ async function showCoordinatorReport() {
]; ];
const datas = await Promise.all(candidates.map(async f => { const datas = await Promise.all(candidates.map(async f => {
try { try {
const res = await fetch(giteaUrl(`chapters/${activeChapter.code}/${f}`), { cache: 'no-store' }); const res = await fetch(giteaDataUrl(`chapters/${activeChapter.code}/${f}`), { cache: 'no-store' });
if (!res.ok) return null; if (!res.ok) return null;
return await res.json(); return await res.json();
} catch(e) { return null; } } catch(e) { return null; }
@@ -5857,6 +5857,13 @@ async function showCoordinatorReport() {
// ============================================================ // ============================================================
initChannel(); initChannel();
if (activeChapter) applyChapterUI(activeChapter.config); if (activeChapter) applyChapterUI(activeChapter.config);
if (!activeChapter) {
const urlChapterCode = new URLSearchParams(window.location.search).get('chapter');
if (urlChapterCode) {
document.getElementById('activation-code-input').value = urlChapterCode;
activateChapter();
}
}
renderCurrentView(); renderCurrentView();
updateTimerDisplays(); updateTimerDisplays();
checkLicenseAndUpdates(); checkLicenseAndUpdates();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1,4 +1,4 @@
{ {
"version": "2.1", "version": "2.2",
"notes": "Agenda-Folien: Präsentations-Klicker funktioniert jetzt auch dann weiter, wenn der Fokus auf dem Display-(Beamer)-Fenster liegt statt im Steuerpult (Pfeiltaste rechts/Leertaste), zusätzlich ein dezenter Klick-Button unten rechts im Display. Außerdem: Sprechernotizen pro Agenda-Punkt, die nur im Steuerpult sichtbar sind, nie auf dem Display/Beamer." "notes": "Neue, verschlüsselte Serveradresse. Bitte jetzt aktualisieren – die alte Version kann ab 1. November keine Daten mehr laden oder speichern."
} }