Multi-Chapter-Support: Lizenzcode, Gitea-Sync, Auto-Update

- Aktivierungsscreen mit Lizenzcode-Eingabe
- Hintergrund-Lizenzprüfung bei jedem Start
- Admin-Tab mit Mitglieder-Sync und Update-Check
- version.json + chapters/sonne-7x4m9k/ Struktur

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Heubaum
2026-04-17 14:26:01 +02:00
parent 85dfc53b13
commit 0d7b9b2d6a
4 changed files with 273 additions and 11 deletions

View File

@@ -0,0 +1,6 @@
{
"chapterName": "BNI Chapter Sonne",
"syncUrl": "https://bni-ulm.com/sonne/de/memberlist?chapterName=6035&regionIds=43671&isChapterwebsite=true",
"websiteUrl": "https://bni-ulm.de/sonne",
"defaultTimer": 60
}

View File

@@ -0,0 +1,4 @@
{
"lastUpdated": "2026-04-17",
"members": []
}

View File

@@ -1120,8 +1120,8 @@
<div id="view-display" class="view"> <div id="view-display" class="view">
<button id="fullscreen-btn" onclick="toggleFullscreen()" title="Vollbild">⛶</button> <button id="fullscreen-btn" onclick="toggleFullscreen()" title="Vollbild">⛶</button>
<div class="display-header"> <div class="display-header">
<img class="bni-badge" src="logo.jpeg" alt="BNI Sonne Ulm"> <img class="bni-badge" id="display-logo" src="logo.jpeg" alt="BNI">
<div class="chapter-name">Vorstellungsrunde</div> <div class="chapter-name" id="display-chapter-name">Vorstellungsrunde</div>
<a href="#" onclick="window.location.hash=''" style="margin-left:16px;color:rgba(255,255,255,0.25);font-size:0.78rem;text-decoration:none;letter-spacing:1px;" title="Zur Steuerung">⚙ Steuerung</a> <a href="#" onclick="window.location.hash=''" style="margin-left:16px;color:rgba(255,255,255,0.25);font-size:0.78rem;text-decoration:none;letter-spacing:1px;" title="Zur Steuerung">⚙ Steuerung</a>
</div> </div>
@@ -1149,7 +1149,7 @@
<!-- Startbild: außerhalb der Karte, damit es unabhängig angezeigt werden kann --> <!-- Startbild: außerhalb der Karte, damit es unabhängig angezeigt werden kann -->
<div id="display-empty" style="position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:10;"> <div id="display-empty" style="position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:10;">
<img src="logo.jpeg" alt="BNI Sonne Ulm" style="width:min(520px,72vw);height:auto;border-radius:24px;box-shadow:0 8px 60px rgba(0,0,0,0.5);"> <img id="display-logo-empty" src="logo.jpeg" alt="BNI" style="width:min(520px,72vw);height:auto;border-radius:24px;box-shadow:0 8px 60px rgba(0,0,0,0.5);">
</div> </div>
<div class="main-card" id="main-card"> <div class="main-card" id="main-card">
@@ -1213,8 +1213,8 @@
<!-- Header --> <!-- Header -->
<div class="ctrl-header"> <div class="ctrl-header">
<img class="bni-badge" src="logo.jpeg" alt="BNI Sonne Ulm"> <img class="bni-badge" id="ctrl-logo" src="logo.jpeg" alt="BNI">
<div class="ctrl-title">Vorstellungsrunde steuern</div> <div class="ctrl-title" id="ctrl-chapter-name">Vorstellungsrunde steuern</div>
<div class="flex-gap-8"> <div class="flex-gap-8">
<div class="status-dot dot-idle" id="sync-dot" title="Display-Verbindung"></div> <div class="status-dot dot-idle" id="sync-dot" title="Display-Verbindung"></div>
<button class="btn btn-secondary btn-sm" onclick="resetMeeting()">↺ Neu starten</button> <button class="btn btn-secondary btn-sm" onclick="resetMeeting()">↺ Neu starten</button>
@@ -1259,6 +1259,7 @@
<div class="panel-right-tab active" data-panel="visitors-panel" onclick="switchRightPanel('visitors-panel')">Besucher</div> <div class="panel-right-tab active" data-panel="visitors-panel" onclick="switchRightPanel('visitors-panel')">Besucher</div>
<div class="panel-right-tab" data-panel="members-panel" onclick="switchRightPanel('members-panel')">Mitglieder</div> <div class="panel-right-tab" data-panel="members-panel" onclick="switchRightPanel('members-panel')">Mitglieder</div>
<div class="panel-right-tab" data-panel="substitutes-panel" onclick="switchRightPanel('substitutes-panel')">Vertretungen</div> <div class="panel-right-tab" data-panel="substitutes-panel" onclick="switchRightPanel('substitutes-panel')">Vertretungen</div>
<div class="panel-right-tab" data-panel="admin-panel" onclick="switchRightPanel('admin-panel')">Admin</div>
</div> </div>
<div class="panel-right-content"> <div class="panel-right-content">
@@ -1303,6 +1304,10 @@
</div> </div>
<!-- SUBSTITUTES --> <!-- SUBSTITUTES -->
<div id="admin-panel" class="right-panel-section">
<!-- rendered by renderAdminPanel() -->
</div>
<div id="substitutes-panel" class="right-panel-section"> <div id="substitutes-panel" class="right-panel-section">
<div style="padding: 14px 16px 8px;"> <div style="padding: 14px 16px 8px;">
<div style="font-size:0.85rem; color:var(--muted); margin-bottom:12px;">Mitglied als vertreten markieren:</div> <div style="font-size:0.85rem; color:var(--muted); margin-bottom:12px;">Mitglied als vertreten markieren:</div>
@@ -1610,7 +1615,7 @@
<div style="font-weight:700;font-size:0.9rem;margin-bottom:6px;">Schritt 2 – Daten exportieren</div> <div style="font-weight:700;font-size:0.9rem;margin-bottom:6px;">Schritt 2 – Daten exportieren</div>
<ol style="font-size:0.85rem;color:var(--muted);padding-left:18px;line-height:1.8;"> <ol style="font-size:0.85rem;color:var(--muted);padding-left:18px;line-height:1.8;">
<li>BNI-Mitgliederliste öffnen: <li>BNI-Mitgliederliste öffnen:
<a href="https://bni-ulm.com/sonne/de/memberlist?chapterName=6035&regionIds=43671&isChapterwebsite=true" target="_blank" style="color:var(--red);font-weight:600;">bni-ulm.com → Chapter Sonne</a> <a id="bni-sync-link" href="https://bni-ulm.com/sonne/de/memberlist?chapterName=6035&regionIds=43671&isChapterwebsite=true" target="_blank" style="color:var(--red);font-weight:600;">bni-ulm.com → Chapter Sonne</a>
</li> </li>
<li>Warten bis alle Mitglieder geladen sind</li> <li>Warten bis alle Mitglieder geladen sind</li>
<li>Bookmarklet <strong>"BNI Mitglieder exportieren"</strong> klicken</li> <li>Bookmarklet <strong>"BNI Mitglieder exportieren"</strong> klicken</li>
@@ -1638,10 +1643,32 @@
</div> </div>
<script> <script>
// ============================================================
// CONSTANTS & CHAPTER CONFIG
// ============================================================
const APP_VERSION = '1.1.0';
const GITEA_RAW = 'http://72.60.134.73:3000/cheubaum/BNI-Sonne/raw/branch/main';
const CHAPTER_CONFIG_KEY = 'bni-active-chapter';
function loadChapterConfig() {
try { const s = localStorage.getItem(CHAPTER_CONFIG_KEY); return s ? JSON.parse(s) : null; } catch(e) { return null; }
}
function saveChapterConfig(cfg) {
localStorage.setItem(CHAPTER_CONFIG_KEY, JSON.stringify(cfg));
}
function clearChapterData() {
if (activeChapter) localStorage.removeItem(`bni-chapter-${activeChapter.code}-v2`);
if (activeChapter) localStorage.removeItem(`bni-extras-${activeChapter.code}`);
localStorage.removeItem(CHAPTER_CONFIG_KEY);
activeChapter = null;
}
let activeChapter = loadChapterConfig();
let STORAGE_KEY = activeChapter ? `bni-chapter-${activeChapter.code}-v2` : 'bni-chapter-default-v2';
// ============================================================ // ============================================================
// STATE // STATE
// ============================================================ // ============================================================
const STORAGE_KEY = 'bni-sonne-v2';
let state = loadState(); let state = loadState();
let sortableInstance = null; let sortableInstance = null;
@@ -1685,7 +1712,7 @@ function saveState(broadcast = true) {
// ============================================================ // ============================================================
// PROFILE EXTRAS (Kurzlinks + Offline-Fotos, persistent) // PROFILE EXTRAS (Kurzlinks + Offline-Fotos, persistent)
// ============================================================ // ============================================================
const EXTRAS_KEY = 'bni-profile-extras'; let EXTRAS_KEY = activeChapter ? `bni-extras-${activeChapter.code}` : 'bni-profile-extras';
function loadExtras() { function loadExtras() {
try { try {
@@ -1845,7 +1872,7 @@ let syncDot;
function initChannel() { function initChannel() {
try { try {
channel = new BroadcastChannel('bni-sonne-channel'); channel = new BroadcastChannel('bni-channel-' + (activeChapter ? activeChapter.code : 'default'));
channel.onmessage = (ev) => { channel.onmessage = (ev) => {
if (ev.data.type === 'STATE_UPDATE') { if (ev.data.type === 'STATE_UPDATE') {
state = ev.data.state; state = ev.data.state;
@@ -1889,9 +1916,12 @@ function getView() {
} }
function renderCurrentView() { function renderCurrentView() {
const hasChapter = !!activeChapter;
const view = getView(); const view = getView();
document.getElementById('view-display').classList.toggle('active', view === 'display'); document.getElementById('view-activation').classList.toggle('active', !hasChapter);
document.getElementById('view-control').classList.toggle('active', view === 'control'); document.getElementById('view-display').classList.toggle('active', hasChapter && view === 'display');
document.getElementById('view-control').classList.toggle('active', hasChapter && view === 'control');
if (!hasChapter) return;
if (view === 'display') { if (view === 'display') {
renderDisplay(); renderDisplay();
@@ -2365,6 +2395,7 @@ function renderControl() {
renderSubstitutesList(); renderSubstitutesList();
renderSubstituteMemberSelect(); renderSubstituteMemberSelect();
renderInvitedBySelect(); renderInvitedBySelect();
renderAdminPanel();
updateNextButton(); updateNextButton();
syncDot = document.getElementById('sync-dot'); syncDot = document.getElementById('sync-dot');
} }
@@ -3965,16 +3996,233 @@ function applyQueueRatio(ratio) {
}); });
})(); })();
// ============================================================
// CHAPTER ACTIVATION & LICENSE
// ============================================================
async function activateChapter() {
const input = document.getElementById('activation-code-input');
const code = (input ? input.value : '').trim().toLowerCase();
if (!code) return;
const btn = document.getElementById('activation-btn');
const status = document.getElementById('activation-status');
btn.disabled = true;
status.textContent = 'Überprüfe Lizenzcode…';
status.style.color = 'rgba(255,255,255,0.6)';
try {
const res = await fetch(`${GITEA_RAW}/chapters/${code}/config.json`, { cache: 'no-store' });
if (!res.ok) {
status.textContent = 'Ungültiger oder abgelaufener Lizenzcode.';
status.style.color = '#f87171';
btn.disabled = false;
return;
}
const config = await res.json();
activeChapter = { code, config, activatedAt: new Date().toISOString(), lastChecked: new Date().toISOString() };
saveChapterConfig(activeChapter);
STORAGE_KEY = `bni-chapter-${code}-v2`;
EXTRAS_KEY = `bni-extras-${code}`;
state = loadState();
if (config.defaultTimer && !state.meetingOrder.length) {
state.defaultTimer = config.defaultTimer;
saveState(false);
}
applyChapterUI(config);
status.textContent = `✓ Chapter "${config.chapterName}" aktiviert!`;
status.style.color = '#4ade80';
setTimeout(() => {
renderCurrentView();
fetchChapterMembersFromGitea();
}, 800);
} catch(e) {
status.textContent = 'Verbindungsfehler. Bitte Internetverbindung prüfen.';
status.style.color = '#f87171';
btn.disabled = false;
}
}
function applyChapterUI(config) {
if (!config) return;
const name = config.chapterName || 'BNI Chapter';
document.title = `${name} – Vorstellungsrunde`;
const el = document.getElementById('display-chapter-name');
if (el) el.textContent = name;
const ctrlName = document.getElementById('ctrl-chapter-name');
if (ctrlName) ctrlName.textContent = `${name} – Steuerung`;
if (config.logoUrl && activeChapter) {
const src = config.logoUrl.startsWith('http') ? config.logoUrl
: `${GITEA_RAW}/chapters/${activeChapter.code}/${config.logoUrl}`;
['display-logo','display-logo-empty','ctrl-logo'].forEach(id => {
const img = document.getElementById(id);
if (img) img.src = src;
});
}
const syncLink = document.getElementById('bni-sync-link');
if (syncLink && config.syncUrl) {
syncLink.href = config.syncUrl;
syncLink.textContent = name + ' – Mitgliederliste';
}
}
async function checkLicenseAndUpdates() {
if (!activeChapter) return;
try {
const res = await fetch(`${GITEA_RAW}/chapters/${activeChapter.code}/config.json`, { cache: 'no-store' });
if (!res.ok) {
clearChapterData();
state = defaultState();
alert('Deine Lizenz wurde widerrufen. Bitte gib einen neuen Code ein.');
renderCurrentView();
return;
}
const config = await res.json();
activeChapter.config = config;
activeChapter.lastChecked = new Date().toISOString();
saveChapterConfig(activeChapter);
applyChapterUI(config);
await checkForUpdates();
} catch(e) {
// Offline → continue with cached data
const dot = document.getElementById('sync-dot');
if (dot) dot.title = 'Offline – letzte Prüfung: ' + (activeChapter.lastChecked ? new Date(activeChapter.lastChecked).toLocaleDateString('de') : 'unbekannt');
}
}
async function checkForUpdates() {
try {
const res = await fetch(`${GITEA_RAW}/version.json`, { cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
if (data.version && data.version > APP_VERSION) showUpdateBanner(data.version, data.notes);
} catch(e) { /* ignore */ }
}
function showUpdateBanner(version, notes) {
let banner = document.getElementById('update-banner');
if (!banner) {
banner = document.createElement('div');
banner.id = 'update-banner';
banner.style.cssText = 'background:#1a2744;color:white;padding:8px 16px;display:flex;align-items:center;gap:12px;font-size:0.85rem;flex-shrink:0;z-index:100;';
const ctrlView = document.getElementById('view-control');
ctrlView.insertBefore(banner, ctrlView.firstChild);
}
banner.innerHTML = `🆕 Update ${version} verfügbar${notes ? ' – ' + notes : ''} &nbsp;<button onclick="downloadUpdate('${version}')" style="background:#cc0000;color:white;border:none;border-radius:6px;padding:4px 12px;cursor:pointer;font-size:0.82rem;font-weight:600;">Jetzt laden</button> <button onclick="this.parentElement.style.display='none'" style="background:transparent;border:none;color:rgba(255,255,255,0.5);cursor:pointer;margin-left:auto;font-size:1rem;">✕</button>`;
}
async function downloadUpdate(version) {
try {
const licRes = await fetch(`${GITEA_RAW}/chapters/${activeChapter.code}/config.json`, { cache: 'no-store' });
if (!licRes.ok) {
clearChapterData();
state = defaultState();
alert('Lizenz nicht mehr gültig. Bitte neuen Code eingeben.');
renderCurrentView();
return;
}
} catch(e) { alert('Keine Serververbindung möglich.'); return; }
try {
const res = await fetch(`${GITEA_RAW}/index.html`, { cache: 'no-store' });
const html = await res.text();
const blob = new Blob([html], { type: 'text/html' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'index.html';
a.click();
URL.revokeObjectURL(a.href);
} catch(e) { alert('Download fehlgeschlagen: ' + e.message); }
}
async function fetchChapterMembersFromGitea() {
if (!activeChapter) return;
try {
const res = await fetch(`${GITEA_RAW}/chapters/${activeChapter.code}/members.json`, { cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
if (!data.members || !data.members.length) return;
const mergedMap = new Map(state.members.map(m => [m.id, m]));
data.members.forEach(m => {
const ex = mergedMap.get(m.id);
mergedMap.set(m.id, ex ? Object.assign({}, ex, m) : m);
});
state.members = Array.from(mergedMap.values());
const knownIds = new Set(state.meetingOrder);
state.members.forEach(m => { if (!knownIds.has(m.id)) state.meetingOrder.push(m.id); });
activeChapter.lastMemberSync = new Date().toISOString();
saveChapterConfig(activeChapter);
saveState(false);
renderCurrentView();
renderAdminPanel();
} catch(e) { /* ignore */ }
}
function renderAdminPanel() {
const panel = document.getElementById('admin-panel');
if (!panel) return;
const cfg = activeChapter && activeChapter.config;
const code = activeChapter && activeChapter.code;
const lastChecked = activeChapter && activeChapter.lastChecked ? new Date(activeChapter.lastChecked).toLocaleString('de') : '—';
const lastSync = activeChapter && activeChapter.lastMemberSync ? new Date(activeChapter.lastMemberSync).toLocaleString('de') : '—';
const maskedCode = code ? code.slice(0, -3).replace(/./g, '·') + code.slice(-3) : '—';
panel.innerHTML = `<div style="padding:16px;overflow-y:auto;height:100%;">
<div style="font-weight:700;font-size:0.9rem;margin-bottom:14px;">Chapter-Administration</div>
${cfg ? `
<div style="background:#f8f9fa;border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:14px;">
<div style="font-size:0.78rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;">Aktives Chapter</div>
<div style="font-weight:600;margin-bottom:2px;">${cfg.chapterName || '—'}</div>
<div style="font-size:0.82rem;color:var(--muted);">Version: <strong>${APP_VERSION}</strong></div>
<div style="font-size:0.82rem;color:var(--muted);">Letzte Lizenzprüfung: ${lastChecked}</div>
</div>
<button class="btn btn-secondary btn-sm" style="width:100%;margin-bottom:6px;" onclick="fetchChapterMembersFromGitea()">🔄 Mitglieder vom Server laden</button>
<div style="font-size:0.78rem;color:var(--muted);margin-bottom:14px;">Letzte Synchronisation: ${lastSync}</div>
<button class="btn btn-secondary btn-sm" style="width:100%;margin-bottom:6px;" onclick="checkForUpdates()">🔍 Auf Updates prüfen</button>
<div style="font-size:0.78rem;color:var(--muted);margin-bottom:20px;"></div>
` : `<div style="color:var(--muted);font-size:0.88rem;margin-bottom:20px;">Kein Chapter aktiviert.</div>`}
<div style="border-top:1px solid var(--border);padding-top:14px;">
<button class="btn btn-ghost btn-sm" style="width:100%;" onclick="confirmSwitchChapter()">↔ Chapter wechseln</button>
</div>
</div>`;
}
function confirmSwitchChapter() {
if (!confirm('Chapter wechseln?\n\nAlle lokalen Daten dieses Chapters werden gelöscht. Fortfahren?')) return;
clearChapterData();
state = defaultState();
renderCurrentView();
}
// ============================================================ // ============================================================
// INIT // INIT
// ============================================================ // ============================================================
initChannel(); initChannel();
if (activeChapter) applyChapterUI(activeChapter.config);
renderCurrentView(); renderCurrentView();
checkLicenseAndUpdates();
// Keyboard shortcut hint // Keyboard shortcut hint
if (getView() === 'control') { if (getView() === 'control') {
console.log('Tastaturkürzel: Leertaste oder → = Weiter'); console.log('Tastaturkürzel: Leertaste oder → = Weiter');
} }
</script> </script>
<!-- ============================================================
ACTIVATION VIEW
============================================================ -->
<div id="view-activation" class="view" style="background:linear-gradient(160deg,#0f1f45 0%,#1a2744 40%,#0d1b35 100%);align-items:center;justify-content:center;flex-direction:column;">
<img id="activation-logo" src="logo.jpeg" alt="BNI" style="height:80px;margin-bottom:32px;border-radius:8px;object-fit:contain;">
<div style="background:rgba(255,255,255,0.08);backdrop-filter:blur(20px);border:1px solid rgba(255,255,255,0.15);border-radius:20px;padding:40px;width:min(420px,90vw);color:white;">
<div style="font-size:1.4rem;font-weight:700;margin-bottom:6px;">BNI Vorstellungsrunde</div>
<div style="font-size:0.88rem;color:rgba(255,255,255,0.6);margin-bottom:24px;">Bitte gib deinen Lizenzcode ein, um dein Chapter freizuschalten.</div>
<input id="activation-code-input" type="text" placeholder="chapter-lizenzcode"
style="width:100%;padding:12px 14px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.25);border-radius:10px;color:white;font-size:1rem;outline:none;margin-bottom:12px;letter-spacing:1px;"
onkeydown="if(event.key==='Enter')activateChapter()"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
<div id="activation-status" style="font-size:0.85rem;min-height:20px;margin-bottom:12px;"></div>
<button id="activation-btn" onclick="activateChapter()"
style="width:100%;padding:12px;background:#cc0000;color:white;border:none;border-radius:10px;font-size:1rem;font-weight:700;cursor:pointer;">
Aktivieren
</button>
</div>
</div>
</body> </body>
</html> </html>

4
version.json Normal file
View File

@@ -0,0 +1,4 @@
{
"version": "1.1.0",
"notes": "Multi-Chapter-Support mit Lizenzcode"
}