Files
BNI-Vorstellungsrunde/index.html

4231 lines
168 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BNI Chapter Sonne – Vorstellungsrunde</title>
<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/pdfjs-dist@3.11.174/build/pdf.min.js"></script>
<style>
:root {
--red: #cc0000;
--red-dark: #990000;
--red-light: rgba(204,0,0,0.12);
--navy: #1a2744;
--bg: #f0f2f5;
--card: #ffffff;
--text: #1a1a2e;
--muted: #6b7280;
--border: #e5e7eb;
--success: #16a34a;
--warning: #d97706;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
overflow: hidden;
}
/* ============================================================
ROUTING
============================================================ */
.view { display: none; width: 100vw; height: 100vh; }
.view.active { display: flex; }
/* ============================================================
DISPLAY VIEW
============================================================ */
#view-display {
background: linear-gradient(160deg, #0f1f45 0%, #1a2744 40%, #0d1b35 100%);
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding-top: clamp(90px, 12vh, 160px);
position: relative;
overflow: hidden;
}
/* Decorative background circles */
#view-display::before {
content: '';
position: absolute;
width: 600px; height: 600px;
background: radial-gradient(circle, rgba(204,0,0,0.15) 0%, transparent 70%);
top: -150px; right: -150px;
pointer-events: none;
}
#view-display::after {
content: '';
position: absolute;
width: 400px; height: 400px;
background: radial-gradient(circle, rgba(255,255,255,0.05) 0%, transparent 70%);
bottom: -100px; left: -100px;
pointer-events: none;
}
#fullscreen-btn {
position: fixed;
bottom: 20px; right: 20px;
z-index: 999;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
color: rgba(255,255,255,0.4);
border-radius: 8px;
padding: 6px 10px;
font-size: 1.2rem;
cursor: pointer;
opacity: 0;
transition: opacity 0.3s;
}
#view-display:hover #fullscreen-btn { opacity: 1; }
#fullscreen-btn:hover { background: rgba(255,255,255,0.18); color: white; }
.display-header {
position: absolute;
top: clamp(14px, 2vh, 32px); left: 40px;
display: flex;
align-items: center;
gap: clamp(10px, 1.5vh, 18px);
z-index: 10;
}
.bni-badge {
height: clamp(44px, 6vh, 80px);
width: auto;
display: block;
object-fit: contain;
}
.chapter-name {
color: rgba(255,255,255,0.7);
font-size: clamp(1rem, 1.8vh, 2rem);
font-weight: 300;
letter-spacing: 1px;
}
.progress-indicator {
position: absolute;
top: clamp(28px, 4vh, 50px); right: 40px;
color: rgba(255,255,255,0.5);
font-size: clamp(0.85rem, 1.5vh, 1.8rem);
letter-spacing: 1px;
z-index: 10;
}
.round-badge {
position: absolute;
top: 96px; right: 40px;
background: var(--red);
color: white;
font-size: 0.8rem;
font-weight: 600;
padding: 4px 12px;
border-radius: 20px;
letter-spacing: 1px;
text-transform: uppercase;
display: none;
}
.round-badge.visible { display: block; }
.main-card {
display: flex;
flex-direction: row;
align-items: center;
gap: clamp(40px, 5vw, 100px);
background: rgba(255,255,255,0.07);
backdrop-filter: blur(24px);
border: 1px solid rgba(255,255,255,0.12);
border-radius: clamp(20px, 3vh, 36px);
padding: clamp(32px, 5vh, 80px) clamp(40px, 5vw, 100px);
max-width: none;
width: 96vw;
flex: 1;
position: relative;
z-index: 1;
margin-bottom: clamp(16px, 2.5vh, 40px);
}
.photo-section {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.member-photo-wrap {
width: 100%;
height: 100%;
border-radius: 50%;
border: 4px solid var(--red);
overflow: hidden;
background: rgba(255,255,255,0.1);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 40px rgba(204,0,0,0.3);
}
.member-photo-wrap img {
width: 100%;
height: 100%;
object-fit: cover;
}
.initials-avatar {
font-size: clamp(4rem, 12vh, 12rem);
font-weight: 700;
color: white;
}
.qr-corner {
position: absolute;
top: clamp(102px, 14vh, 180px);
right: clamp(24px, 3vw, 60px);
z-index: 3;
background: white;
padding: clamp(8px, 1vh, 14px);
border-radius: 14px;
box-shadow: 0 4px 24px rgba(0,0,0,0.35);
}
.qr-label {
color: #1a2744;
font-size: clamp(0.6rem, 1vh, 0.9rem);
text-align: center;
margin-top: 5px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
}
.info-section {
flex: 1;
display: flex;
flex-direction: column;
gap: clamp(14px, 2.5vh, 36px);
}
.substitute-label {
color: rgba(255,255,255,0.5);
font-size: clamp(0.75rem, 1.5vh, 1.5rem);
text-transform: uppercase;
letter-spacing: 2px;
border-left: 3px solid var(--red);
padding-left: 10px;
}
.display-name {
font-size: clamp(3rem, max(5vw, 7vh), 10rem);
font-weight: 800;
color: white;
line-height: 1.1;
}
.display-company {
font-size: clamp(1.6rem, max(2.8vw, 4vh), 6rem);
color: rgba(255,255,255,0.75);
font-weight: 400;
}
.display-specialty {
display: inline-block;
background: rgba(204,0,0,0.25);
border: 1px solid rgba(204,0,0,0.5);
color: #ff8080;
font-size: clamp(1rem, 2.2vh, 2.4rem);
padding: clamp(6px, 1vh, 14px) clamp(12px, 1.8vw, 28px);
border-radius: 30px;
align-self: flex-start;
}
.visitor-tag {
display: inline-block;
background: rgba(234,179,8,0.2);
border: 1px solid rgba(234,179,8,0.4);
color: #fcd34d;
font-size: clamp(0.8rem, 1.6vh, 1.6rem);
padding: clamp(4px, 0.8vh, 10px) clamp(10px, 1.5vw, 20px);
border-radius: 20px;
align-self: flex-start;
margin-top: 4px;
}
.presenter-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 20px;
padding: 12px 16px;
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 12px;
}
.presenter-avatar {
width: clamp(36px, 4.5vh, 64px); height: clamp(36px, 4.5vh, 64px);
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
border: 2px solid rgba(255,255,255,0.2);
display: flex; align-items: center; justify-content: center;
font-size: clamp(0.8rem, 1.4vh, 1.4rem); font-weight: 700; color: white;
}
.presenter-avatar img {
width: 100%; height: 100%; object-fit: cover;
}
.presenter-label {
font-size: clamp(0.65rem, 1.2vh, 1.2rem);
text-transform: uppercase;
letter-spacing: 1.5px;
color: rgba(255,255,255,0.4);
margin-bottom: 2px;
}
.presenter-name {
font-size: clamp(0.9rem, 1.8vh, 1.8rem);
font-weight: 600;
color: rgba(255,255,255,0.85);
}
.presenter-company {
font-size: clamp(0.75rem, 1.4vh, 1.4rem);
color: rgba(255,255,255,0.5);
}
.empty-state-display {
color: rgba(255,255,255,0.4);
font-size: 1.5rem;
text-align: center;
}
/* Next Bar */
.next-bar {
position: absolute;
top: 0; left: 0; right: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255,255,255,0.1);
padding: clamp(12px, 1.8vh, 24px) 40px;
display: flex;
align-items: center;
gap: clamp(16px, 2.5vh, 36px);
z-index: 5;
}
#next-bar {
padding-left: 320px;
}
.next-bar-divider {
width: 1px;
height: clamp(40px, 7vh, 96px);
background: rgba(255,255,255,0.15);
}
.next-photo-small {
width: clamp(56px, 8.5vh, 120px); height: clamp(56px, 8.5vh, 120px);
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(255,255,255,0.3);
background: rgba(255,255,255,0.1);
}
.next-info .next-name-text {
color: white;
font-size: clamp(1.5rem, 3.8vh, 4.2rem);
font-weight: 700;
}
.next-info .next-company-text {
color: rgba(255,255,255,0.6);
font-size: clamp(1rem, 2.5vh, 2.8rem);
}
.next-bar-label {
color: rgba(255,255,255,0.5);
font-size: clamp(0.8rem, 1.8vh, 2rem);
text-transform: uppercase;
letter-spacing: 2px;
white-space: nowrap;
}
.next-bar-end-text {
color: rgba(255,255,255,0.4);
font-style: italic;
font-size: clamp(1.2rem, 2.5vh, 2.8rem);
}
/* ============================================================
CONTROL VIEW
============================================================ */
#view-control {
flex-direction: column;
background: var(--bg);
}
.ctrl-header {
background: var(--navy);
color: white;
padding: 14px 24px;
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
box-shadow: 0 2px 12px rgba(0,0,0,0.3);
}
.ctrl-header .bni-badge { height: 38px; border-radius: 6px; }
.ctrl-title {
font-size: 1.1rem;
font-weight: 600;
flex: 1;
}
.btn {
padding: 9px 20px;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.btn:active { transform: scale(0.97); }
.btn-primary {
background: var(--red);
color: white;
}
.btn-primary:hover { background: var(--red-dark); }
.btn-secondary {
background: rgba(255,255,255,0.15);
color: white;
border: 1px solid rgba(255,255,255,0.2);
}
.btn-secondary:hover { background: rgba(255,255,255,0.25); }
.btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
}
.btn-ghost:hover { background: white; color: var(--text); }
.btn-sm {
padding: 5px 12px;
font-size: 0.82rem;
border-radius: 6px;
}
.btn-danger {
background: transparent;
color: #dc2626;
border: 1px solid #fca5a5;
}
.btn-danger:hover { background: #fef2f2; }
.btn-next {
background: var(--red);
color: white;
font-size: 1rem;
padding: 11px 28px;
display: flex;
align-items: center;
gap: 8px;
}
.btn-next:hover { background: var(--red-dark); }
.btn-next:disabled { background: #9ca3af; cursor: not-allowed; }
.btn-next:disabled:active { transform: none; }
.ctrl-body {
flex: 1;
display: grid;
grid-template-columns: 1fr 320px;
gap: 0;
overflow: hidden;
}
/* Left panel: member queue */
.panel-left {
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--border);
background: var(--bg);
}
.panel-section-header {
padding: 14px 20px 10px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
background: white;
flex-shrink: 0;
}
.panel-section-title {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--muted);
}
.queue-tabs {
display: flex;
gap: 4px;
}
.tab-btn {
padding: 6px 14px;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--border);
background: white;
color: var(--muted);
transition: all 0.15s;
}
.tab-btn.active {
background: var(--navy);
color: white;
border-color: var(--navy);
}
.member-queue {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
min-height: 80px;
}
#queue-resize-handle {
flex-shrink: 0;
height: 10px;
background: #edf0f3;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
cursor: row-resize;
user-select: none;
display: none;
align-items: center;
justify-content: center;
gap: 3px;
transition: background 0.15s;
}
#queue-resize-handle::before,
#queue-resize-handle::after {
content: '';
display: block;
width: 28px; height: 2px;
background: #c0c8d0;
border-radius: 2px;
}
#queue-resize-handle:hover,
#queue-resize-handle.is-dragging { background: #dde2e8; }
#absent-members-section {
overflow-y: auto;
min-height: 60px;
flex-shrink: 0;
}
.queue-item {
display: flex;
align-items: center;
gap: 12px;
background: white;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 14px;
margin-bottom: 6px;
cursor: grab;
transition: all 0.15s;
position: relative;
}
.queue-item:active { cursor: grabbing; }
.queue-item.current {
border-color: var(--red);
background: #fff5f5;
box-shadow: 0 0 0 2px rgba(204,0,0,0.15);
}
.queue-item.next-up {
border-color: var(--navy);
background: #f0f4ff;
}
.queue-item.done {
opacity: 0.45;
}
.queue-item.sortable-ghost {
opacity: 0.35;
border: 2px dashed var(--red);
}
.queue-badge {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
padding: 2px 8px;
border-radius: 4px;
position: absolute;
top: 8px; right: 8px;
}
.badge-current { background: var(--red); color: white; }
.badge-next { background: var(--navy); color: white; }
.badge-done { background: #e5e7eb; color: #6b7280; }
.badge-visitor { background: #fef3c7; color: #92400e; }
.badge-substitute { background: #e0e7ff; color: #3730a3; }
.drag-handle {
color: #d1d5db;
cursor: grab;
font-size: 1.2rem;
user-select: none;
flex-shrink: 0;
}
.queue-photo-wrap {
width: 44px; height: 44px;
border-radius: 50%;
overflow: hidden;
background: #e5e7eb;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.queue-photo-wrap img {
width: 100%; height: 100%;
object-fit: cover;
}
.queue-initials {
font-size: 1rem;
font-weight: 700;
color: white;
background: var(--navy);
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
border-radius: 50%;
}
.queue-info {
flex: 1;
min-width: 0;
}
.queue-name {
font-size: 0.95rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.queue-company {
font-size: 0.8rem;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.queue-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.sub-info {
font-size: 0.75rem;
color: #4f46e5;
margin-top: 2px;
}
/* Absent / Medical status in queue */
.status-tag {
font-size: 0.68rem; font-weight: 700; padding: 2px 7px;
border-radius: 4px; letter-spacing: 0.5px; cursor: pointer;
border: none; white-space: nowrap;
}
.status-tag-absent { background: #fef3c7; color: #92400e; }
.status-tag-medical { background: #ede9fe; color: #5b21b6; }
/* Absent section below the main queue */
.absent-section {
padding: 10px 16px 12px;
background: #fafafa;
overflow-y: auto;
}
.absent-section-title {
font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 1px; color: var(--muted); margin-bottom: 6px;
}
.absent-section-title + .absent-section-title { margin-top: 10px; }
/* Display: Absent summary overlay */
#display-absent-summary {
position: fixed; inset: 0; z-index: 5;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 56px 80px 48px;
gap: 28px;
overflow: hidden;
}
.absent-summary-heading {
color: white; font-size: 1.6rem; font-weight: 800;
letter-spacing: 3px; text-transform: uppercase;
opacity: 0.9; flex-shrink: 0;
}
.absent-summary-cols {
display: flex; gap: 60px; align-items: flex-start;
width: 100%; justify-content: center;
overflow: hidden;
}
.absent-summary-col {
flex: 1; min-width: 0; max-width: 600px;
}
.absent-summary-col-title {
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 2px; margin-bottom: 14px; flex-shrink: 0;
}
.absent-summary-col-title.medical { color: #a78bfa; }
.absent-summary-col-title.absent { color: #fcd34d; }
.absent-summary-name {
color: white; font-size: var(--absent-name-size, 1.3rem); font-weight: 600;
margin-bottom: 0.4em; opacity: 0.92;
break-inside: avoid; line-height: 1.25;
}
#absent-list-medical, #absent-list-absent {
column-gap: 36px;
}
/* ============================================================
TIMER
============================================================ */
.photo-timer-wrap {
position: relative;
width: min(42vh, 45vmin); height: min(42vh, 45vmin);
flex-shrink: 0;
}
.timer-ring-svg {
position: absolute;
top: -14px; left: -14px;
width: calc(min(42vh, 45vmin) + 28px); height: calc(min(42vh, 45vmin) + 28px);
transform: rotate(-90deg);
pointer-events: none;
display: none;
}
.timer-ring-track {
fill: none;
stroke: rgba(255,255,255,0.12);
stroke-width: 7;
}
.timer-ring-progress {
fill: none;
stroke: rgba(255,255,255,0.75);
stroke-width: 7;
stroke-linecap: round;
transition: stroke-dashoffset 0.95s linear, stroke 0.4s;
}
.timer-ring-progress.warn { stroke: #fbbf24; }
.timer-ring-progress.danger { stroke: #ef4444; }
.timer-ring-progress.expired { stroke: #ef4444; animation: timerBlink 0.5s alternate infinite; }
@keyframes timerBlink { to { opacity: 0.15; } }
#display-timer-text {
font-size: clamp(2rem, 4vh, 5rem); font-weight: 700;
color: rgba(255,255,255,0.95);
font-variant-numeric: tabular-nums;
letter-spacing: 3px;
text-align: center;
text-shadow: 0 2px 12px rgba(0,0,0,0.5);
line-height: 1;
display: none;
}
#display-timer-text.warn { color: #fbbf24; }
#display-timer-text.danger { color: #ef4444; }
#display-timer-text.expired { color: #ef4444; animation: timerBlink 0.5s alternate infinite; }
.queue-timer-btn {
font-size: 0.75rem; font-weight: 700;
color: var(--muted);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
padding: 3px 7px;
cursor: pointer;
font-variant-numeric: tabular-nums;
letter-spacing: 0.5px;
line-height: 1;
}
.queue-timer-btn:hover { border-color: var(--red); color: var(--red); }
.queue-timer-btn.custom { background: #fff5f5; border-color: var(--red); color: var(--red); font-weight: 800; }
#ctrl-timer-display {
font-variant-numeric: tabular-nums;
letter-spacing: 1px;
}
#ctrl-timer-display.warn { color: #fbbf24; }
#ctrl-timer-display.danger, #ctrl-timer-display.expired { color: #ef4444; }
/* Right panel */
.panel-right {
display: flex;
flex-direction: column;
overflow: hidden;
background: white;
}
.panel-right-tabs {
display: flex;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.panel-right-tab {
flex: 1;
padding: 12px 8px;
text-align: center;
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--muted);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.panel-right-tab.active {
color: var(--red);
border-bottom-color: var(--red);
}
.panel-right-content {
flex: 1;
overflow-y: auto;
}
.right-panel-section { display: none; }
.right-panel-section.active { display: block; }
/* Visitors panel */
.visitor-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
}
.visitor-item .v-name {
flex: 1;
font-size: 0.9rem;
font-weight: 600;
}
.visitor-item .v-company {
font-size: 0.8rem;
color: var(--muted);
}
.add-form {
padding: 16px;
background: #f9fafb;
border-bottom: 1px solid var(--border);
}
.add-form input, .add-form textarea, .add-form select {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.88rem;
font-family: inherit;
margin-bottom: 8px;
color: var(--text);
}
.add-form input:focus, .add-form textarea:focus {
outline: none;
border-color: var(--red);
}
.add-form label {
font-size: 0.78rem;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 1px;
display: block;
margin-bottom: 4px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
/* Members management panel */
.members-list-manage {
padding: 12px;
}
.member-manage-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 6px;
background: #fafafa;
}
.member-manage-item .m-name {
flex: 1;
font-size: 0.88rem;
font-weight: 600;
}
.member-manage-item .m-company {
font-size: 0.78rem;
color: var(--muted);
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.55);
z-index: 1000;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.modal-overlay.open { display: flex; }
.modal {
background: white;
border-radius: 16px;
padding: 28px;
width: min(560px, 95vw);
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.modal-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--text);
}
.modal-close {
background: none;
border: none;
font-size: 1.4rem;
color: var(--muted);
cursor: pointer;
padding: 4px;
}
.modal input, .modal textarea, .modal select {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.9rem;
font-family: inherit;
color: var(--text);
margin-bottom: 12px;
}
.modal input:focus, .modal textarea:focus {
outline: none;
border-color: var(--red);
}
.modal label {
font-size: 0.8rem;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 1px;
display: block;
margin-bottom: 4px;
}
.modal-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.btn-save {
background: var(--red);
color: white;
}
.btn-save:hover { background: var(--red-dark); }
/* Profile Extras Section in Modal */
.extras-section-divider {
border: none;
border-top: 1px solid var(--border);
margin: 16px 0 12px;
}
.extras-section-title {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--muted);
margin-bottom: 12px;
}
.offline-photo-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--bg);
border-radius: 8px;
border: 1px solid var(--border);
margin-bottom: 8px;
}
.offline-photo-thumb {
width: 52px; height: 52px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--border);
flex-shrink: 0;
}
.offline-photo-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.btn-file-label {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 12px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
border-radius: 6px;
border: 1px solid var(--border);
background: white;
color: var(--muted);
transition: all 0.15s;
}
.btn-file-label:hover { background: white; color: var(--text); }
/* Import/Export */
.import-export-panel {
padding: 16px;
}
.import-export-panel textarea {
width: 100%;
height: 200px;
font-family: monospace;
font-size: 0.8rem;
padding: 10px;
border: 1px solid var(--border);
border-radius: 8px;
resize: vertical;
}
/* ============================================================
UTILITY
============================================================ */
.flex-gap-8 { display: flex; align-items: center; gap: 8px; }
.mt-8 { margin-top: 8px; }
.mt-16 { margin-top: 16px; }
.text-muted { color: var(--muted); font-size: 0.85rem; }
.text-red { color: var(--red); }
.w-full { width: 100%; }
.empty-hint {
text-align: center;
color: var(--muted);
padding: 32px 16px;
font-size: 0.9rem;
}
.empty-hint h3 {
font-size: 1rem;
margin-bottom: 8px;
}
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-live { background: #22c55e; }
.dot-idle { background: #9ca3af; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }
/* Prevent photo load errors showing broken image icon */
img { font-size: 0; }
</style>
</head>
<body>
<!-- ============================================================
DISPLAY VIEW (Monitor 2)
============================================================ -->
<div id="view-display" class="view">
<button id="fullscreen-btn" onclick="toggleFullscreen()" title="Vollbild">⛶</button>
<div class="display-header">
<img class="bni-badge" id="display-logo" src="logo.jpeg" alt="BNI">
<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>
</div>
<div class="progress-indicator" id="progress-text"></div>
<div class="qr-corner" id="qr-wrap" style="display:none">
<div id="display-qr"></div>
<div class="qr-label">Profil</div>
</div>
<div class="round-badge" id="round-badge">2. Runde – Besucher</div>
<!-- Abwesenheits-Zusammenfassung -->
<div id="display-absent-summary" style="display:none;">
<div class="absent-summary-heading">Abwesenheiten</div>
<div class="absent-summary-cols">
<div id="absent-col-medical" class="absent-summary-col" style="display:none;">
<div class="absent-summary-col-title medical">Medical</div>
<div id="absent-list-medical"></div>
</div>
<div id="absent-col-absent" class="absent-summary-col" style="display:none;">
<div class="absent-summary-col-title absent">Abwesend</div>
<div id="absent-list-absent"></div>
</div>
</div>
</div>
<!-- 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;">
<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 class="main-card" id="main-card">
<div id="display-content" style="display:none; width:100%; display:none; flex-direction:row; align-items:center; gap:56px;">
<div class="photo-section">
<div class="photo-timer-wrap">
<svg class="timer-ring-svg" id="timer-ring-svg" viewBox="0 0 248 248">
<circle class="timer-ring-track" cx="124" cy="124" r="116"/>
<circle class="timer-ring-progress" id="timer-ring-progress" cx="124" cy="124" r="116"
stroke-dasharray="729" stroke-dashoffset="0"/>
</svg>
<div class="member-photo-wrap" id="photo-wrap">
<div class="initials-avatar" id="display-initials">?</div>
</div>
</div>
<div id="display-timer-text"></div>
</div>
<div class="info-section">
<div class="display-name" id="display-name">—</div>
<div class="display-company" id="display-company">—</div>
<div class="display-specialty" id="display-specialty" style="display:none"></div>
<div class="visitor-tag" id="visitor-tag" style="display:none">Besucher</div>
<div class="presenter-row" id="presenter-row" style="display:none">
<div class="presenter-avatar" id="presenter-avatar"></div>
<div class="presenter-text">
<div class="presenter-label" id="presenter-label">Vertreten durch</div>
<div class="presenter-name" id="presenter-name"></div>
<div class="presenter-company" id="presenter-company"></div>
</div>
</div>
</div>
</div>
</div>
<div class="next-bar" id="next-bar" style="display:none">
<span class="next-bar-label">Als nächstes</span>
<div class="next-bar-divider"></div>
<img class="next-photo-small" id="next-photo-img" src="" alt="" onerror="this.style.display='none'">
<div class="next-info">
<div class="next-name-text" id="next-name-text">—</div>
<div class="next-company-text" id="next-company-text"></div>
<div id="next-sub-text" style="display:none;font-size:clamp(0.7rem,1.5vh,1.5rem);color:rgba(255,255,255,0.4);margin-top:2px;"></div>
</div>
</div>
<div class="next-bar" id="next-bar-end" style="display:none">
<span class="next-bar-end-text">Das war die letzte Vorstellung – Danke!</span>
</div>
<div style="position:fixed;bottom:14px;right:80px;display:flex;align-items:center;gap:8px;opacity:0.35;pointer-events:none;">
<span style="color:white;font-size:0.7rem;letter-spacing:1px;text-transform:uppercase;">powered by</span>
<img src="digiseed.png" alt="Digiseed" style="height:28px;width:auto;border-radius:4px;">
</div>
</div>
<!-- ============================================================
CONTROL VIEW (Monitor 1)
============================================================ -->
<div id="view-control" class="view">
<!-- Header -->
<div class="ctrl-header">
<img class="bni-badge" id="ctrl-logo" src="logo.jpeg" alt="BNI">
<div class="ctrl-title" id="ctrl-chapter-name">Vorstellungsrunde steuern</div>
<div class="flex-gap-8">
<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="openPdfImport()">📄 PDF importieren</button>
<button class="btn btn-secondary btn-sm" onclick="editDefaultTimer()" title="Standard-Redezeit anpassen" style="font-variant-numeric:tabular-nums;">⏱ <span id="ctrl-timer-default">1:00</span></button>
<button class="btn btn-secondary btn-sm" id="ctrl-timer-toggle" onclick="toggleTimer()" style="font-variant-numeric:tabular-nums;min-width:80px;">▶ Start</button>
<button class="btn btn-secondary btn-sm" id="ctrl-timer-reset" onclick="resetTimer()" title="Timer zurücksetzen">↺ <span id="ctrl-timer-display" style="min-width:36px;display:inline-block;"></span></button>
<button class="btn btn-secondary btn-sm" onclick="openMemberSync()">🔄 Mitglieder aktualisieren</button>
<button class="btn btn-secondary btn-sm" onclick="openImportExport()">⬇ Import / Export</button>
<button class="btn btn-secondary btn-sm" onclick="window.open(location.href.split('#')[0] + '#display', '_blank', 'noopener')">🖥 Anzeige öffnen</button>
<button class="btn btn-next" id="btn-next" onclick="goNext()" disabled>
Weiter <span id="btn-next-name"></span> →
</button>
</div>
</div>
<!-- Body -->
<div class="ctrl-body">
<!-- LEFT: Queue -->
<div class="panel-left">
<div class="panel-section-header">
<span class="panel-section-title">Reihenfolge (ziehen zum Sortieren)</span>
<div class="queue-tabs">
<div class="tab-btn active" id="tab-members" onclick="switchTab('members')">Mitglieder</div>
<div class="tab-btn" id="tab-visitors" onclick="switchTab('visitors')">Besucher</div>
</div>
</div>
<div class="member-queue" id="member-queue">
<!-- Member items rendered here -->
</div>
<div id="queue-resize-handle" title="Ziehen zum Anpassen der Höhe"></div>
<div id="absent-members-section" style="display:none;"></div>
<div class="member-queue" id="visitor-queue" style="display:none">
<!-- Visitor items rendered here -->
</div>
</div>
<!-- RIGHT: Visitors + Members Management -->
<div class="panel-right">
<div class="panel-right-tabs">
<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="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 class="panel-right-content">
<!-- VISITORS -->
<div id="visitors-panel" class="right-panel-section active">
<div class="add-form">
<div style="font-size:0.85rem; font-weight:700; margin-bottom:10px; color:var(--text)">Besucher hinzufügen</div>
<label>Name</label>
<input id="v-name" type="text" placeholder="Max Mustermann">
<div class="form-row">
<div>
<label>Firma</label>
<input id="v-company" type="text" placeholder="Firma GmbH">
</div>
<div>
<label>Fachgebiet</label>
<input id="v-specialty" type="text" placeholder="Steuerberatung">
</div>
</div>
<label>Eingeladen von (optional)</label>
<select id="v-invited-by">
<option value="">— kein Mitglied —</option>
</select>
<label>Foto-URL (optional)</label>
<input id="v-photo" type="text" placeholder="https://...">
<button class="btn btn-primary btn-sm w-full mt-8" onclick="addVisitor()">+ Besucher hinzufügen</button>
</div>
<div id="visitors-list">
<!-- visitor items -->
</div>
</div>
<!-- MEMBERS MANAGEMENT -->
<div id="members-panel" class="right-panel-section">
<div style="padding: 12px 16px 6px; display:flex; align-items:center; justify-content:space-between;">
<span class="text-muted">Alle Mitglieder verwalten</span>
<button class="btn btn-primary btn-sm" onclick="openAddMember()">+ Mitglied</button>
</div>
<div id="members-manage-list" class="members-list-manage">
<!-- member management items -->
</div>
</div>
<!-- SUBSTITUTES -->
<div id="admin-panel" class="right-panel-section">
<!-- rendered by renderAdminPanel() -->
</div>
<div id="substitutes-panel" class="right-panel-section">
<div style="padding: 14px 16px 8px;">
<div style="font-size:0.85rem; color:var(--muted); margin-bottom:12px;">Mitglied als vertreten markieren:</div>
<label style="font-size:0.78rem; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:1px; display:block; margin-bottom:4px;">Mitglied</label>
<select id="sub-member-select" style="width:100%; padding:8px; border:1px solid var(--border); border-radius:6px; font-size:0.88rem; margin-bottom:10px;">
<option value="">— Mitglied auswählen —</option>
</select>
<label style="font-size:0.78rem; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:1px; display:block; margin-bottom:4px;">Vertreter Name</label>
<input id="sub-rep-name" type="text" placeholder="Name der Vertretung" style="width:100%; padding:8px; border:1px solid var(--border); border-radius:6px; font-size:0.88rem; margin-bottom:8px;">
<label style="font-size:0.78rem; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:1px; display:block; margin-bottom:4px;">Firma</label>
<input id="sub-rep-company" type="text" placeholder="Firma der Vertretung" style="width:100%; padding:8px; border:1px solid var(--border); border-radius:6px; font-size:0.88rem; margin-bottom:8px;">
<label style="font-size:0.78rem; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:1px; display:block; margin-bottom:4px;">Fachgebiet</label>
<input id="sub-rep-specialty" type="text" placeholder="Fachgebiet" style="width:100%; padding:8px; border:1px solid var(--border); border-radius:6px; font-size:0.88rem; margin-bottom:8px;">
<label style="font-size:0.78rem; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:1px; display:block; margin-bottom:4px;">Foto-URL (optional)</label>
<input id="sub-rep-photo" type="text" placeholder="https://..." style="width:100%; padding:8px; border:1px solid var(--border); border-radius:6px; font-size:0.88rem; margin-bottom:10px;">
<button class="btn btn-primary btn-sm w-full" onclick="saveSubstitute()">Vertretung speichern</button>
</div>
<div style="padding: 0 16px; border-top:1px solid var(--border); margin-top:8px;">
<div style="padding:12px 0 6px; font-size:0.78rem; font-weight:700; color:var(--muted); text-transform:uppercase; letter-spacing:1px;">Aktive Vertretungen</div>
<div id="substitutes-list"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ============================================================
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>
<!-- ============================================================
MODALS
============================================================ -->
<!-- Add/Edit Member Modal -->
<div class="modal-overlay" id="modal-member">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modal-member-title">Mitglied hinzufügen</div>
<button class="modal-close" onclick="closeMemberModal()">✕</button>
</div>
<input type="hidden" id="edit-member-id">
<label>Name *</label>
<input id="m-name" type="text" placeholder="Vorname Nachname">
<div class="form-row">
<div>
<label>Firma *</label>
<input id="m-company" type="text" placeholder="Firma GmbH">
</div>
<div>
<label>Fachgebiet *</label>
<input id="m-specialty" type="text" placeholder="Webentwicklung">
</div>
</div>
<label>Foto-URL (von BNI-Profil kopieren)</label>
<input id="m-photo" type="text" placeholder="https://cdn.bniconnectglobal.com/...">
<label>Profil-URL (von BNI, wird für Sync genutzt)</label>
<input id="m-profile" type="text" placeholder="https://bni-ulm.com/sonne/de/memberdetails?...">
<div id="photo-preview-wrap" style="display:none; text-align:center; margin:8px 0;">
<img id="photo-preview" src="" alt="" style="width:80px; height:80px; border-radius:50%; object-fit:cover; border:2px solid var(--border);">
</div>
<hr class="extras-section-divider">
<div class="extras-section-title">Dauerhaft gespeichert (bleibt nach BNI-Sync erhalten)</div>
<label>Kurzlink (für QR-Code) <span style="font-weight:400;color:var(--muted);">— überschreibt Profil-URL im QR</span></label>
<input id="m-kurzlink" type="text" placeholder="z.B. https://qr.bp.com/kai">
<label>Offline-Foto <span style="font-weight:400;color:var(--muted);">— lokal gespeichert, kein Internet nötig</span></label>
<div class="offline-photo-row" id="offline-photo-row">
<img class="offline-photo-thumb" id="offline-photo-thumb" src="" alt="" style="display:none;">
<div class="offline-photo-actions">
<label class="btn-file-label">
📁 Datei wählen
<input type="file" id="m-photo-file" accept="image/*" style="display:none" onchange="handleOfflinePhotoFile(this)">
</label>
<button class="btn btn-ghost btn-sm" onclick="loadOfflinePhotoFromUrl()">⬇ Von Profil-URL laden</button>
<button class="btn btn-danger btn-sm" id="btn-remove-offline-photo" onclick="removeOfflinePhoto()" style="display:none;">✕ Entfernen</button>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeMemberModal()">Abbrechen</button>
<button class="btn btn-save btn-primary" onclick="saveMember()">Speichern</button>
</div>
</div>
</div>
<!-- Import/Export Modal -->
<div class="modal-overlay" id="modal-importexport">
<div class="modal">
<div class="modal-header">
<div class="modal-title">Import / Export</div>
<button class="modal-close" onclick="closeModal('modal-importexport')">✕</button>
</div>
<p style="font-size:0.88rem; color:var(--muted); margin-bottom:14px;">
Mitgliederdaten als JSON exportieren oder importieren. Daten werden im Browser gespeichert.
</p>
<textarea id="json-textarea" placeholder="JSON hier einfügen..."></textarea>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="exportJSON()">Exportieren</button>
<button class="btn btn-ghost" onclick="closeModal('modal-importexport')">Abbrechen</button>
<button class="btn btn-save btn-primary" onclick="importJSON()">Importieren</button>
</div>
<hr class="extras-section-divider" style="margin-top:24px;">
<div class="extras-section-title">Profildaten (Kurzlinks + Offline-Fotos)</div>
<p style="font-size:0.85rem; color:var(--muted); margin-bottom:14px;">
Kurzlinks und lokale Fotos werden <strong>getrennt</strong> gespeichert und bleiben nach jedem BNI-Sync erhalten.
Diese Datei im Projektordner ablegen und nach einem Browser-Reset wieder importieren.
</p>
<div style="display:flex; gap:8px; align-items:center;">
<button class="btn btn-ghost" onclick="exportProfildaten()">⬇ profildaten.json speichern</button>
<label class="btn-file-label" style="cursor:pointer;">
⬆ profildaten.json laden
<input type="file" id="profildaten-file-input" accept=".json" style="display:none" onchange="importProfildaten(this)">
</label>
<span id="profildaten-status" style="font-size:0.82rem;color:var(--muted);"></span>
</div>
</div>
</div>
<!-- Substitute Quick-Pick Modal -->
<div class="modal-overlay" id="modal-substitute">
<div class="modal">
<div class="modal-header">
<div class="modal-title">Vertretung für <span id="sub-modal-member-name"></span></div>
<button class="modal-close" onclick="closeModal('modal-substitute')">✕</button>
</div>
<input type="hidden" id="sub-modal-member-id">
<label>Vertreter Name *</label>
<input id="sub-modal-name" type="text" placeholder="Name">
<div class="form-row">
<div>
<label>Firma</label>
<input id="sub-modal-company" type="text" placeholder="Firma GmbH">
</div>
<div>
<label>Fachgebiet</label>
<input id="sub-modal-specialty" type="text" placeholder="Fachgebiet">
</div>
</div>
<label>Foto-URL (optional)</label>
<input id="sub-modal-photo" type="text" placeholder="https://...">
<label style="display:flex;align-items:center;gap:8px;margin-top:10px;cursor:pointer;font-weight:normal;">
<input type="checkbox" id="sub-modal-skip-round2" style="width:16px;height:16px;cursor:pointer;">
Tritt nicht in der 2. Runde auf <span style="font-size:0.75rem;color:var(--muted);">(z.B. Mitarbeiter, Dauervertreter)</span>
</label>
<div class="modal-footer">
<button class="btn btn-danger btn-sm" onclick="removeSubstitute(document.getElementById('sub-modal-member-id').value)">Vertretung entfernen</button>
<button class="btn btn-ghost" onclick="closeModal('modal-substitute')">Abbrechen</button>
<button class="btn btn-save btn-primary" onclick="saveSubstituteModal()">Speichern</button>
</div>
</div>
</div>
<!-- Besucher bearbeiten Modal -->
<div class="modal-overlay" id="modal-visitor-edit">
<div class="modal">
<div class="modal-header">
<div class="modal-title">Besucher bearbeiten</div>
<button class="modal-close" onclick="closeModal('modal-visitor-edit')">✕</button>
</div>
<input type="hidden" id="ve-id">
<label>Name *</label>
<input id="ve-name" type="text" placeholder="Vor- und Nachname">
<div class="form-row">
<div>
<label>Firma</label>
<input id="ve-company" type="text" placeholder="Firma GmbH">
</div>
<div>
<label>Fachgebiet / Beruf</label>
<input id="ve-specialty" type="text" placeholder="Fachgebiet">
</div>
</div>
<label>Eingeladen von</label>
<input id="ve-invited-by" type="text" placeholder="Name des Mitglieds">
<label>Foto-URL (optional)</label>
<input id="ve-photo" type="text" placeholder="https://...">
<div class="modal-footer">
<button class="btn btn-danger btn-sm" onclick="removeVisitor(document.getElementById('ve-id').value);closeModal('modal-visitor-edit')">Entfernen</button>
<button class="btn btn-ghost" onclick="closeModal('modal-visitor-edit')">Abbrechen</button>
<button class="btn btn-primary" onclick="saveVisitorEdit()">Speichern</button>
</div>
</div>
</div>
<!-- Schnellerfassung Modal (ersetzt PDF-Import) -->
<div class="modal-overlay" id="modal-pdf-import">
<div class="modal" style="width:min(760px,97vw);max-height:90vh;">
<div class="modal-header">
<div class="modal-title">Besucher- &amp; Vertreterliste erfassen</div>
<button class="modal-close" onclick="closeModal('modal-pdf-import')">✕</button>
</div>
<div id="pdf-drop-zone"
onclick="document.getElementById('pdf-file-input').click()"
ondragover="event.preventDefault();this.classList.add('dragging')"
ondragleave="this.classList.remove('dragging')"
ondrop="event.preventDefault();this.classList.remove('dragging');handlePdfImportFile(event.dataTransfer.files[0])"
style="border:2px dashed var(--border);border-radius:10px;padding:14px 20px;text-align:center;cursor:pointer;margin-bottom:10px;transition:border-color .2s,background .2s;background:#fafafa;">
<input type="file" id="pdf-file-input" accept=".pdf" style="display:none"
onchange="handlePdfImportFile(this.files[0]);this.value=''">
<span style="font-size:0.88rem;color:var(--muted);">📄 <strong>Chapterportal-PDF hier ablegen</strong> oder klicken – Einträge werden automatisch erkannt</span>
</div>
<div id="pdf-parse-status" style="display:none;font-size:0.82rem;padding:8px 12px;border-radius:8px;margin-bottom:10px;"></div>
<p style="font-size:0.82rem;color:var(--muted);margin-bottom:14px;">
Einträge können nach dem Import noch angepasst werden. Nur ausgefüllte Zeilen werden importiert.
</p>
<div style="overflow-x:auto;">
<table id="quick-entry-table" style="width:100%;border-collapse:collapse;font-size:0.85rem;">
<thead>
<tr style="background:#f9fafb;font-size:0.75rem;text-transform:uppercase;letter-spacing:1px;color:var(--muted);">
<th style="padding:8px 4px;border-bottom:2px solid var(--border);width:24px;"></th>
<th style="padding:8px 6px;text-align:left;border-bottom:2px solid var(--border);width:120px;">Typ</th>
<th style="padding:8px 6px;text-align:left;border-bottom:2px solid var(--border);">Name</th>
<th style="padding:8px 6px;text-align:left;border-bottom:2px solid var(--border);">Fachgebiet / Beruf</th>
<th style="padding:8px 6px;text-align:left;border-bottom:2px solid var(--border);">Vertreter für / Eingeladen von</th>
</tr>
</thead>
<tbody id="quick-entry-tbody"></tbody>
</table>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('modal-pdf-import')">Abbrechen</button>
<button class="btn btn-primary" onclick="confirmQuickEntry()">Importieren</button>
</div>
</div>
</div>
<style>
.qe-row td { padding: 4px 4px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.qe-row:last-child td { border-bottom: none; }
.qe-num { color: var(--muted); font-size: 0.78rem; text-align: center; width: 20px; }
.qe-row select, .qe-row input {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.85rem;
font-family: inherit;
color: var(--text);
background: white;
}
.qe-row select:focus, .qe-row input:focus {
outline: none;
border-color: var(--red);
box-shadow: 0 0 0 2px rgba(204,0,0,0.1);
}
#pdf-drop-zone.dragging { border-color: var(--red); background: var(--red-light); }
</style>
<!-- Mitglieder-Sync Modal -->
<div class="modal-overlay" id="modal-sync">
<div class="modal" style="width:min(600px,95vw)">
<div class="modal-header">
<div class="modal-title">🔄 Mitglieder mit BNI-Website abgleichen</div>
<button class="modal-close" onclick="closeModal('modal-sync')">✕</button>
</div>
<!-- Step 1: Bookmarklet -->
<div style="background:#f0f9f0;border:1px solid #bbf0bb;border-radius:10px;padding:16px;margin-bottom:14px;">
<div style="font-weight:700;font-size:0.9rem;margin-bottom:6px;">Schritt 1 – Bookmarklets einrichten <span style="font-weight:400;color:var(--muted)">(einmalig)</span></div>
<p style="font-size:0.85rem;color:var(--muted);margin-bottom:10px;">
Ziehe beide Buttons in die Lesezeichenleiste:
</p>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<a id="bookmarklet-link" href="" style="display:inline-block;background:var(--red);color:white;padding:8px 18px;border-radius:8px;font-weight:700;font-size:0.9rem;text-decoration:none;cursor:grab;">
📋 BNI Mitglieder exportieren
</a>
<a id="bookmarklet-diag" href="" style="display:inline-block;background:#555;color:white;padding:8px 18px;border-radius:8px;font-weight:700;font-size:0.9rem;text-decoration:none;cursor:grab;">
🔍 Diagnose (einmalig)
</a>
</div>
<p style="font-size:0.78rem;color:var(--muted);margin-top:8px;">
Zuerst <strong>🔍 Diagnose</strong> auf der Mitgliederliste ausführen und die heruntergeladene Datei hier hochladen – dann wird der Export-Button automatisch angepasst.
</p>
</div>
<!-- Diagnose upload -->
<div id="diag-upload-area" style="background:#fffbe6;border:1px solid #f0d060;border-radius:10px;padding:12px 16px;margin-bottom:14px;display:none;">
<div style="font-weight:700;font-size:0.85rem;margin-bottom:6px;">🔍 Diagnose-Datei auswerten</div>
<input type="file" id="diag-file-input" accept=".json" style="display:none" onchange="handleDiagFile(this.files[0])">
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('diag-file-input').click()">bni-diagnose.json laden</button>
<div id="diag-result" style="margin-top:8px;font-size:0.82rem;color:var(--muted);"></div>
</div>
<!-- Step 2: Use it -->
<div style="background:#f0f4ff;border:1px solid #c0c8f0;border-radius:10px;padding:16px;margin-bottom:14px;">
<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;">
<li>BNI-Mitgliederliste öffnen:
<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>Warten bis alle Mitglieder geladen sind</li>
<li>Bookmarklet <strong>"BNI Mitglieder exportieren"</strong> klicken</li>
<li>JSON-Datei wird automatisch heruntergeladen</li>
</ol>
</div>
<!-- Step 3: Import -->
<div style="background:#fff8f0;border:1px solid #f0d8b0;border-radius:10px;padding:16px;">
<div style="font-weight:700;font-size:0.9rem;margin-bottom:10px;">Schritt 3 – Importieren</div>
<p style="font-size:0.85rem;color:var(--muted);margin-bottom:10px;">
Heruntergeladene JSON-Datei hier einlesen. Bestehende Mitglieder werden aktualisiert (Foto, Fachgebiet), neue werden hinzugefügt. Niemand wird gelöscht.
</p>
<input type="file" id="sync-file-input" accept=".json" style="display:none" onchange="handleSyncFile(this.files[0])">
<button class="btn btn-primary" onclick="document.getElementById('sync-file-input').click()">
📂 JSON-Datei laden &amp; synchronisieren
</button>
<div id="sync-result" style="margin-top:10px;font-size:0.85rem;display:none;"></div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('modal-sync')">Schließen</button>
</div>
</div>
</div>
<script>
// ============================================================
// CONSTANTS & CHAPTER CONFIG
// ============================================================
const APP_VERSION = '1.1.0';
const GITEA_RAW = 'http://72.60.134.73:3000/api/v1/repos/cheubaum/BNI-Sonne/raw';
function giteaUrl(path) { return `${GITEA_RAW}/${path}?ref=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
// ============================================================
let state = loadState();
let sortableInstance = null;
let sortableVisitorInstance = null;
let qrInstance = null;
let activeTab = 'members'; // 'members' | 'visitors'
function defaultState() {
return {
members: [], // { id, name, company, specialty, photoUrl, profileUrl }
meetingOrder: [], // member IDs in meeting order
currentIndex: -1, // -1 = not started, 0..n-1 = presenting
substitutes: {}, // { memberId: { name, company, specialty, photoUrl } }
absentIds: [], // member IDs absent today (no substitute)
medicalIds: [], // member IDs on medical leave
visitors: [], // { id, name, company, specialty, photoUrl }
visitorsOrder: [], // visitor IDs in order
visitorsCurrentIndex: -1,
mode: 'members', // 'members' | 'absent-summary' | 'visitors'
defaultTimer: 60, // seconds (standard speaking time)
memberTimers: {}, // { memberId: customSeconds }
};
}
function loadState() {
try {
const s = localStorage.getItem(STORAGE_KEY);
const parsed = s ? JSON.parse(s) : defaultState();
// Ensure all fields exist (for upgrades)
return Object.assign(defaultState(), parsed);
} catch(e) { return defaultState(); }
}
function saveState(broadcast = true) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
if (broadcast) {
try { channel.postMessage({ type: 'STATE_UPDATE', state }); } catch(e) {}
}
}
// ============================================================
// PROFILE EXTRAS (Kurzlinks + Offline-Fotos, persistent)
// ============================================================
let EXTRAS_KEY = activeChapter ? `bni-extras-${activeChapter.code}` : 'bni-profile-extras';
function loadExtras() {
try {
const s = localStorage.getItem(EXTRAS_KEY);
return s ? JSON.parse(s) : {};
} catch(e) { return {}; }
}
function saveExtras(extras) {
localStorage.setItem(EXTRAS_KEY, JSON.stringify(extras));
}
function extrasKey(name) {
return (name || '').toLowerCase().trim();
}
// Resize an image File to base64 JPEG (max maxPx on longest side)
function resizePhotoToBase64(file, maxPx = 400) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
let w = img.width, h = img.height;
if (w > maxPx || h > maxPx) {
if (w > h) { h = Math.round(h * maxPx / w); w = maxPx; }
else { w = Math.round(w * maxPx / h); h = maxPx; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', 0.82));
};
img.onerror = reject;
img.src = e.target.result;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// Try to load a remote photo as base64 via canvas (requires CORS)
function loadPhotoFromUrl(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const maxPx = 400;
let w = img.width, h = img.height;
if (w > maxPx || h > maxPx) {
if (w > h) { h = Math.round(h * maxPx / w); w = maxPx; }
else { w = Math.round(w * maxPx / h); h = maxPx; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', 0.82));
};
img.onerror = () => reject(new Error('Foto konnte nicht geladen werden (CORS oder URL ungültig).'));
img.src = url;
});
}
// Module-level state for currently editing offline photo
let _editingOfflinePhotoB64 = null;
function _updateOfflinePhotoUI(b64) {
_editingOfflinePhotoB64 = b64 || null;
const thumb = document.getElementById('offline-photo-thumb');
const removeBtn = document.getElementById('btn-remove-offline-photo');
if (b64) {
thumb.src = b64;
thumb.style.display = 'block';
removeBtn.style.display = 'inline-flex';
} else {
thumb.src = '';
thumb.style.display = 'none';
removeBtn.style.display = 'none';
}
}
function handleOfflinePhotoFile(input) {
const file = input.files && input.files[0];
if (!file) return;
resizePhotoToBase64(file)
.then(b64 => _updateOfflinePhotoUI(b64))
.catch(e => alert('Fehler beim Laden des Fotos: ' + e.message));
input.value = '';
}
function loadOfflinePhotoFromUrl() {
const url = (document.getElementById('m-photo').value || '').trim();
if (!url) { alert('Bitte zuerst eine Foto-URL eingeben.'); return; }
// Remove any previous hint
const existingHint = document.getElementById('offline-photo-url-hint');
if (existingHint) existingHint.remove();
loadPhotoFromUrl(url)
.then(b64 => _updateOfflinePhotoUI(b64))
.catch(() => {
// Show inline hint with direct link instead of an alert
const hint = document.createElement('div');
hint.id = 'offline-photo-url-hint';
hint.style.cssText = 'font-size:0.82rem;color:var(--muted);margin-top:6px;line-height:1.5;';
hint.innerHTML = `Server erlaubt keinen direkten Import. <a href="${url}" target="_blank" rel="noopener" style="color:var(--red);font-weight:600;">Foto öffnen ↗</a> &rarr; Rechtsklick &rarr; Bild speichern &rarr; dann über <strong>Datei wählen</strong> hochladen.`;
document.getElementById('offline-photo-row').insertAdjacentElement('afterend', hint);
});
}
function removeOfflinePhoto() {
_updateOfflinePhotoUI(null);
}
// Export profildaten.json
function exportProfildaten() {
const extras = loadExtras();
const blob = new Blob([JSON.stringify({ version: 1, extras }, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'profildaten.json';
a.click();
document.getElementById('profildaten-status').textContent = '✓ Gespeichert';
setTimeout(() => { document.getElementById('profildaten-status').textContent = ''; }, 3000);
}
// Import profildaten.json
function importProfildaten(input) {
const file = input.files && input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
const incoming = data.extras || data; // support both formats
const existing = loadExtras();
// Merge: incoming data wins for kurzlink/photoB64 per member
Object.entries(incoming).forEach(([k, v]) => {
existing[k] = Object.assign(existing[k] || {}, v);
});
saveExtras(existing);
const count = Object.keys(incoming).length;
document.getElementById('profildaten-status').textContent = `✓ ${count} Einträge geladen`;
setTimeout(() => { document.getElementById('profildaten-status').textContent = ''; }, 4000);
renderCurrentView();
} catch(err) {
alert('Ungültige Datei: ' + err.message);
}
input.value = '';
};
reader.readAsText(file);
}
// ============================================================
// BROADCAST CHANNEL (cross-tab sync)
// ============================================================
let channel;
let syncDot;
function initChannel() {
try {
channel = new BroadcastChannel('bni-channel-' + (activeChapter ? activeChapter.code : 'default'));
channel.onmessage = (ev) => {
if (ev.data.type === 'STATE_UPDATE') {
state = ev.data.state;
saveState(false);
renderCurrentView();
}
if (ev.data.type === 'PING') {
channel.postMessage({ type: 'PONG' });
if (syncDot) { syncDot.className = 'status-dot dot-live'; }
}
if (ev.data.type === 'PONG') {
if (syncDot) { syncDot.className = 'status-dot dot-live'; }
}
if (ev.data.type === 'TIMER_START') {
_t.total = ev.data.total;
_t.startedAt = ev.data.startedAt;
_t.remaining = Math.max(0, ev.data.total - Math.floor((Date.now() - ev.data.startedAt) / 1000));
_t.running = true;
if (_t.iv) clearInterval(_t.iv);
_t.iv = setInterval(_timerTick, 500);
updateTimerDisplays();
}
if (ev.data.type === 'TIMER_STOP') {
_t.total = 0; _t.remaining = 0; _t.running = false;
if (_t.iv) { clearInterval(_t.iv); _t.iv = null; }
updateTimerDisplays();
}
};
// Ping other tabs every 5s
setInterval(() => {
try { channel.postMessage({ type: 'PING' }); } catch(e) {}
}, 5000);
} catch(e) { /* BroadcastChannel not supported */ }
}
// ============================================================
// ROUTING
// ============================================================
function getView() {
return window.location.hash === '#display' ? 'display' : 'control';
}
function renderCurrentView() {
const hasChapter = !!activeChapter;
const view = getView();
document.getElementById('view-activation').classList.toggle('active', !hasChapter);
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') {
renderDisplay();
} else {
renderControl();
}
}
window.addEventListener('hashchange', renderCurrentView);
// ============================================================
// DISPLAY VIEW RENDERING
// ============================================================
function getInitials(name) {
if (!name) return '?';
return name.trim().split(/\s+/).map(w => w[0]).join('').toUpperCase().slice(0, 2);
}
function getColor(name) {
const colors = ['#1a2744','#cc0000','#0f3460','#1e3a5f','#4a1942'];
let h = 0;
for (let c of (name || '')) h = (h * 31 + c.charCodeAt(0)) & 0xffffffff;
return colors[Math.abs(h) % colors.length];
}
function renderDisplay() {
const presenter = getCurrentPresenter();
const next = getNextPresenter();
const emptyEl = document.getElementById('display-empty');
const contentEl = document.getElementById('display-content');
const nextBar = document.getElementById('next-bar');
const nextBarEnd = document.getElementById('next-bar-end');
const progressText = document.getElementById('progress-text');
const roundBadge = document.getElementById('round-badge');
const mainCard = document.getElementById('main-card');
const displayHeader = document.querySelector('#view-display .display-header');
const absentSummaryEl = document.getElementById('display-absent-summary');
// ABSENT SUMMARY MODE
if (state.mode === 'absent-summary') {
emptyEl.style.display = 'none';
mainCard.style.display = 'none';
contentEl.style.display = 'none';
nextBar.style.display = 'none';
roundBadge.classList.remove('visible');
progressText.textContent = '';
if (displayHeader) displayHeader.style.display = '';
absentSummaryEl.style.display = 'flex';
const medicalIds = state.medicalIds || [];
const medicalMembers = medicalIds.map(id => state.members.find(m => m.id === id)).filter(Boolean);
const absentMembers = state.absentIds.map(id => state.members.find(m => m.id === id)).filter(Boolean);
const colMedical = document.getElementById('absent-col-medical');
const colAbsent = document.getElementById('absent-col-absent');
const listMedical = document.getElementById('absent-list-medical');
const listAbsent = document.getElementById('absent-list-absent');
const total = medicalMembers.length + absentMembers.length;
// Scale font size down as list grows
const fontSize = total <= 8 ? '1.35rem'
: total <= 14 ? '1.1rem'
: total <= 22 ? '0.95rem'
: total <= 32 ? '0.82rem'
: '0.7rem';
absentSummaryEl.style.setProperty('--absent-name-size', fontSize);
// Auto-columns within each section
const absentCols = absentMembers.length > 20 ? 3
: absentMembers.length > 10 ? 2 : 1;
const medicalCols = medicalMembers.length > 10 ? 2 : 1;
if (medicalMembers.length > 0) {
colMedical.style.display = 'block';
listMedical.style.columns = medicalCols;
listMedical.innerHTML = medicalMembers.map(m =>
`<div class="absent-summary-name">${escHtml(m.name)}</div>`).join('');
} else {
colMedical.style.display = 'none';
}
if (absentMembers.length > 0) {
colAbsent.style.display = 'block';
listAbsent.style.columns = absentCols;
listAbsent.innerHTML = absentMembers.map(m =>
`<div class="absent-summary-name">${escHtml(m.name)}</div>`).join('');
} else {
colAbsent.style.display = 'none';
}
if (state.visitorsOrder.length > 0) {
nextBarEnd.style.display = 'none';
// Show a hint that visitors round is next
nextBar.style.display = 'none'; // keep it simple
} else {
nextBarEnd.style.display = 'flex';
}
return;
}
// Hide absent summary
if (absentSummaryEl) absentSummaryEl.style.display = 'none';
// Logo transition between rounds
if (state.mode === 'round-transition') {
emptyEl.style.display = 'flex';
mainCard.style.display = 'none';
if (displayHeader) displayHeader.style.display = 'none';
contentEl.style.display = 'none';
nextBar.style.display = 'none';
nextBarEnd.style.display = 'none';
progressText.textContent = '';
roundBadge.classList.remove('visible');
return;
}
if (!presenter || (state.currentIndex < 0 && state.visitorsCurrentIndex < 0)) {
emptyEl.style.display = 'flex';
mainCard.style.display = 'none';
if (displayHeader) displayHeader.style.display = 'none';
contentEl.style.display = 'none';
nextBar.style.display = 'none';
nextBarEnd.style.display = 'none';
progressText.textContent = '';
roundBadge.classList.remove('visible');
return;
}
emptyEl.style.display = 'none';
mainCard.style.display = '';
if (displayHeader) displayHeader.style.display = '';
contentEl.style.display = 'flex';
// Round badge
if (state.mode === 'visitors') {
roundBadge.classList.add('visible');
} else {
roundBadge.classList.remove('visible');
}
// Progress
if (state.mode === 'members') {
const total = state.meetingOrder.length;
progressText.textContent = total > 0 ? `${state.currentIndex + 1} / ${total}` : '';
} else {
const total = state.visitorsOrder.length;
progressText.textContent = total > 0 ? `Besucher ${state.visitorsCurrentIndex + 1} / ${total}` : '';
}
// Get display data
let data = getDisplayData(presenter);
// Photo
const photoWrap = document.getElementById('photo-wrap');
const initialsEl = document.getElementById('display-initials');
// Remove existing img
const existingImg = photoWrap.querySelector('img');
if (existingImg) existingImg.remove();
if (data.photoUrl) {
const img = document.createElement('img');
img.src = data.photoUrl;
img.alt = data.name;
img.style.cssText = 'width:100%;height:100%;object-fit:cover;';
img.onerror = () => {
img.remove();
initialsEl.textContent = getInitials(data.name);
initialsEl.style.background = getColor(data.name);
initialsEl.style.display = 'flex';
};
initialsEl.style.display = 'none';
photoWrap.appendChild(img);
} else {
initialsEl.textContent = getInitials(data.name);
initialsEl.style.background = getColor(data.name);
initialsEl.style.display = 'flex';
}
// Text
document.getElementById('display-name').textContent = data.name || '—';
document.getElementById('display-company').textContent = data.company || '';
const specEl = document.getElementById('display-specialty');
if (data.specialty) {
specEl.textContent = data.specialty;
specEl.style.display = 'inline-block';
} else {
specEl.style.display = 'none';
}
const visitorTag = document.getElementById('visitor-tag');
if (data.isVisitor || data.isSubstituteVisitor) {
visitorTag.style.display = 'inline-block';
visitorTag.textContent = data.isSubstituteVisitor ? 'Vertreter' : 'Besucher';
} else {
visitorTag.style.display = 'none';
}
// Presenter row: substitute (members) or invited-by (visitors)
const presenterRow = document.getElementById('presenter-row');
const rowPerson = data.presentedBy || data.invitedBy || null;
const rowLabel = data.presentedBy ? 'Vertreten durch' : 'Eingeladen von';
if (rowPerson) {
presenterRow.style.display = 'flex';
document.getElementById('presenter-label').textContent = rowLabel;
document.getElementById('presenter-name').textContent = rowPerson.name;
document.getElementById('presenter-company').textContent = rowPerson.company || '';
const avatarEl = document.getElementById('presenter-avatar');
avatarEl.innerHTML = '';
avatarEl.textContent = '';
avatarEl.style.background = '';
if (rowPerson.photoUrl) {
const img = document.createElement('img');
img.src = rowPerson.photoUrl;
img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;border-radius:50%;';
img.onerror = () => {
img.remove();
avatarEl.textContent = getInitials(rowPerson.name);
avatarEl.style.background = getColor(rowPerson.name);
};
avatarEl.appendChild(img);
} else {
avatarEl.textContent = getInitials(rowPerson.name);
avatarEl.style.background = getColor(rowPerson.name);
}
} else {
presenterRow.style.display = 'none';
}
// QR Code
const qrWrap = document.getElementById('qr-wrap');
const qrEl = document.getElementById('display-qr');
if (data.profileUrl) {
qrWrap.style.display = 'block';
qrEl.innerHTML = '';
try {
const qrSize = Math.round(Math.min(window.innerHeight * 0.24, window.innerWidth * 0.14, 300));
new QRCode(qrEl, {
text: data.profileUrl,
width: qrSize,
height: qrSize,
colorDark: '#1a2744',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.L,
});
} catch(e) { qrWrap.style.display = 'none'; }
} else {
qrWrap.style.display = 'none';
}
// Next bar
if (next) {
nextBar.style.display = 'flex';
nextBarEnd.style.display = 'none';
const nextData = getDisplayData(next);
const nextImg = document.getElementById('next-photo-img');
if (nextData.photoUrl) {
nextImg.src = nextData.photoUrl;
nextImg.style.display = 'block';
} else {
nextImg.style.display = 'none';
}
document.getElementById('next-name-text').textContent = nextData.name;
document.getElementById('next-company-text').textContent = nextData.company;
const nextSubEl = document.getElementById('next-sub-text');
if (nextData.presentedBy) {
nextSubEl.textContent = 'Vertreten durch: ' + nextData.presentedBy.name;
nextSubEl.style.display = 'block';
} else {
nextSubEl.style.display = 'none';
}
} else {
nextBar.style.display = 'none';
nextBarEnd.style.display = 'flex';
}
}
// ============================================================
// ABSENT / MEDICAL STATUS
// ============================================================
function toggleMemberStatus(id) {
const isAbsent = state.absentIds.includes(id);
const isMedical = (state.medicalIds || []).includes(id);
if (!isAbsent && !isMedical) {
// Active → Abwesend
state.absentIds.push(id);
state.meetingOrder = state.meetingOrder.filter(mid => mid !== id);
if (state.currentIndex >= state.meetingOrder.length) {
state.currentIndex = state.meetingOrder.length - 1;
}
} else if (isAbsent) {
// Abwesend → Medical
state.absentIds = state.absentIds.filter(aid => aid !== id);
if (!state.medicalIds) state.medicalIds = [];
state.medicalIds.push(id);
} else {
// Medical → Aktiv (add back to end of queue)
state.medicalIds = state.medicalIds.filter(mid => mid !== id);
if (!state.meetingOrder.includes(id)) {
state.meetingOrder.push(id);
}
}
saveState();
renderControl();
updateNextButton();
renderDisplay();
}
function renderAbsentSection() {
const section = document.getElementById('absent-members-section');
if (!section) return;
const medicalIds = state.medicalIds || [];
const medicalMembers = medicalIds.map(id => state.members.find(m => m.id === id)).filter(Boolean);
const absentMembers = state.absentIds.map(id => state.members.find(m => m.id === id)).filter(Boolean);
if (medicalMembers.length === 0 && absentMembers.length === 0) {
section.style.display = 'none';
setQueueResizeVisible(false);
return;
}
setQueueResizeVisible(true);
section.style.display = 'block';
section.className = 'absent-section';
let html = '';
if (medicalMembers.length > 0) {
html += `<div class="absent-section-title">Medical</div>`;
medicalMembers.forEach(m => { html += absentItemHtml(m, 'medical'); });
}
if (absentMembers.length > 0) {
html += `<div class="absent-section-title">Abwesend</div>`;
absentMembers.forEach(m => { html += absentItemHtml(m, 'absent'); });
}
section.innerHTML = html;
}
function absentItemHtml(member, status) {
const photoHtml = member.photoUrl
? `<img src="${escHtml(member.photoUrl)}" alt="" style="width:36px;height:36px;border-radius:50%;object-fit:cover;flex-shrink:0;">`
: `<div class="queue-initials" style="width:36px;height:36px;min-width:36px;font-size:0.8rem;background:${getColor(member.name)}">${getInitials(member.name)}</div>`;
const tagCls = status === 'medical' ? 'status-tag-medical' : 'status-tag-absent';
const label = status === 'medical' ? 'Medical' : 'Abwesend';
return `<div class="queue-item" style="cursor:default;margin-bottom:6px;opacity:0.75;">
<div class="queue-photo-wrap" style="width:36px;height:36px;">${photoHtml}</div>
<div class="queue-info"><div class="queue-name" style="font-size:0.9rem;">${escHtml(member.name)}</div></div>
<div class="queue-actions">
<button class="btn btn-ghost btn-sm status-tag ${tagCls}" onclick="toggleMemberStatus('${member.id}')" title="Status ändern (klicken zum Wechseln)">${label}</button>
</div>
</div>`;
}
function getCurrentPresenter() {
if (state.mode === 'visitors') {
const id = state.visitorsOrder[state.visitorsCurrentIndex];
return state.visitors.find(v => v.id === id) || null;
}
if (state.mode === 'absent-summary') return null;
if (state.currentIndex < 0) return null;
const id = state.meetingOrder[state.currentIndex];
return state.members.find(m => m.id === id) || null;
}
function getNextPresenter() {
if (state.mode === 'visitors') {
const id = state.visitorsOrder[state.visitorsCurrentIndex + 1];
return state.visitors.find(v => v.id === id) || null;
}
if (state.mode === 'absent-summary') return null;
// Try next in member queue
const nextId = state.meetingOrder[state.currentIndex + 1];
if (nextId) return state.members.find(m => m.id === nextId) || null;
// Absent summary comes next if there are absent/medical members
const hasAbsent = state.absentIds.length > 0 || (state.medicalIds || []).length > 0;
if (hasAbsent) return null; // signal: absent-summary is next (not a regular presenter)
// Try first visitor
if (state.visitorsOrder.length > 0) {
const vid = state.visitorsOrder[0];
return state.visitors.find(v => v.id === vid) || null;
}
return null;
}
function getDisplayData(presenter) {
// Visitor entries have no substitutes
if (presenter.isVisitor) {
// For substitute-visitors, always pull fresh data from state.substitutes
// (the visitor entry in state.visitors may have been created before a photo was added)
let name = presenter.name;
let company = presenter.company;
let specialty = presenter.specialty;
let photoUrl = presenter.photoUrl || '';
if (presenter.isSubstituteVisitor && presenter.forMemberId) {
const sub = state.substitutes[presenter.forMemberId];
if (sub) {
name = sub.name || name;
company = sub.company || company;
specialty = sub.specialty || specialty;
photoUrl = sub.photoUrl || photoUrl;
}
}
// If still no photo, try matching by name in members list
if (!photoUrl) {
const match = state.members.find(m =>
m.name.toLowerCase().trim() === name.toLowerCase().trim()
);
if (match) photoUrl = match.photoUrl || '';
}
let invitedByMember = null;
if (presenter.invitedBy) {
const m = state.members.find(m => m.id === presenter.invitedBy);
if (m) invitedByMember = { name: m.name, company: m.company, photoUrl: m.photoUrl || '' };
}
return {
name,
company,
specialty,
photoUrl,
profileUrl: presenter.profileUrl || '',
presentedBy: null,
invitedBy: invitedByMember,
isVisitor: true,
isSubstituteVisitor: presenter.isSubstituteVisitor || false,
};
}
const sub = state.substitutes[presenter.id];
let resolvedSub = null;
if (sub && sub.name) {
// If substitute has no photo, try to find them as a chapter member
let photoUrl = sub.photoUrl || '';
if (!photoUrl) {
const match = state.members.find(m =>
m.name.toLowerCase().trim() === sub.name.toLowerCase().trim()
);
if (match) photoUrl = match.photoUrl || '';
}
resolvedSub = { ...sub, photoUrl };
}
// Merge profile extras (kurzlink + offline photo override)
const ex = loadExtras()[extrasKey(presenter.name)] || {};
return {
// Member stays in foreground
name: presenter.name,
company: presenter.company,
specialty: presenter.specialty,
photoUrl: ex.photoB64 || presenter.photoUrl || '',
profileUrl: ex.kurzlink || presenter.profileUrl || '',
// Substitute shown as secondary presenter
presentedBy: resolvedSub,
isVisitor: false,
};
}
// ============================================================
// CONTROL VIEW RENDERING
// ============================================================
function renderControl() {
renderMemberQueue();
renderVisitorQueue();
renderVisitorsList();
renderMembersManage();
renderSubstitutesList();
renderSubstituteMemberSelect();
renderInvitedBySelect();
renderAdminPanel();
updateNextButton();
syncDot = document.getElementById('sync-dot');
}
function renderInvitedBySelect() {
const sel = document.getElementById('v-invited-by');
if (!sel) return;
const val = sel.value;
sel.innerHTML = '<option value="">— kein Mitglied —</option>';
state.members.slice().sort((a,b) => a.name.localeCompare(b.name)).forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name;
if (m.id === val) opt.selected = true;
sel.appendChild(opt);
});
}
function renderMemberQueue() {
const container = document.getElementById('member-queue');
container.innerHTML = '';
if (state.meetingOrder.length === 0) {
container.innerHTML = `<div class="empty-hint">
<h3>Keine Mitglieder</h3>
<p>Füge Mitglieder unter "Mitglieder" hinzu, dann erscheinen sie hier.</p>
</div>`;
return;
}
state.meetingOrder.forEach((id, idx) => {
const member = state.members.find(m => m.id === id);
if (!member) return;
const isDone = idx < state.currentIndex || (state.mode === 'visitors' && state.currentIndex >= 0);
const isCurrent = idx === state.currentIndex && state.mode === 'members';
const isNext = !isCurrent && (
(state.mode === 'members' && idx === state.currentIndex + 1) ||
(state.mode === 'members' && state.currentIndex < 0 && idx === 0)
);
const sub = state.substitutes[id];
const isAbsent = state.absentIds.includes(id);
const item = document.createElement('div');
item.className = 'queue-item' + (isCurrent ? ' current' : '') + (isNext ? ' next-up' : '') + (isDone ? ' done' : '');
item.dataset.id = id;
let badge = '';
if (isCurrent) badge = `<span class="queue-badge badge-current">Jetzt</span>`;
else if (isNext) badge = `<span class="queue-badge badge-next">Nächster</span>`;
else if (isDone) badge = `<span class="queue-badge badge-done">✓</span>`;
const photoHtml = member.photoUrl
? `<img src="${escHtml(member.photoUrl)}" alt="" style="width:44px;height:44px;border-radius:50%;object-fit:cover;">`
: `<div class="queue-initials" style="background:${getColor(member.name)}">${getInitials(member.name)}</div>`;
let subInfo = '';
if (sub && sub.name) {
const skipTag = sub.skipRound2 ? ` <span style="font-size:0.68rem;background:#fef3c7;color:#92400e;padding:1px 5px;border-radius:3px;">keine eigene Vorstellung</span>` : '';
subInfo = `<div class="sub-info">↩ Vertreten durch: ${escHtml(sub.name)}${skipTag}</div>`;
}
item.innerHTML = `
${badge}
<div class="drag-handle" title="Ziehen zum Sortieren">⠿</div>
<div class="queue-photo-wrap">${photoHtml}</div>
<div class="queue-info">
<div class="queue-name">${escHtml(member.name)}</div>
<div class="queue-company">${escHtml(member.company)}</div>
${subInfo}
</div>
<div class="queue-actions">
${renderTimerBadge(id)}
<button class="btn btn-ghost btn-sm" onclick="openSubstituteModal('${id}')" title="Vertretung eintragen">↩</button>
<button class="btn btn-ghost btn-sm" onclick="toggleMemberStatus('${id}')" title="Als abwesend/medical markieren" style="font-size:1rem;">🚫</button>
</div>
`;
container.appendChild(item);
});
// Render absent/medical members below the queue
renderAbsentSection();
// Init Sortable
if (sortableInstance) sortableInstance.destroy();
sortableInstance = Sortable.create(container, {
animation: 150,
handle: '.drag-handle',
ghostClass: 'sortable-ghost',
filter: '.empty-hint',
onEnd: (evt) => {
const moved = state.meetingOrder.splice(evt.oldIndex, 1)[0];
state.meetingOrder.splice(evt.newIndex, 0, moved);
// Adjust currentIndex if needed
if (state.currentIndex >= 0) {
// Recalculate current: find item that was current
// Simple approach: keep currentIndex clamped
state.currentIndex = Math.min(state.currentIndex, state.meetingOrder.length - 1);
}
saveState();
renderControl();
updateNextButton();
},
});
}
function renderVisitorQueue() {
const container = document.getElementById('visitor-queue');
container.innerHTML = '';
if (state.visitorsOrder.length === 0) {
container.innerHTML = `<div class="empty-hint">
<h3>Keine Besucher</h3>
<p>Füge Besucher im rechten Panel hinzu.</p>
</div>`;
return;
}
state.visitorsOrder.forEach((id, idx) => {
const visitor = state.visitors.find(v => v.id === id);
if (!visitor) return;
const isDone = idx < state.visitorsCurrentIndex;
const isCurrent = idx === state.visitorsCurrentIndex && state.mode === 'visitors';
const isNext = state.mode === 'visitors' && idx === state.visitorsCurrentIndex + 1;
const item = document.createElement('div');
item.className = 'queue-item' + (isCurrent ? ' current' : '') + (isNext ? ' next-up' : '') + (isDone ? ' done' : '');
item.dataset.id = id;
let badge = '';
if (isCurrent) badge = `<span class="queue-badge badge-current">Jetzt</span>`;
else if (isNext) badge = `<span class="queue-badge badge-next">Nächster</span>`;
else if (isDone) badge = `<span class="queue-badge badge-done">✓</span>`;
const photoHtml = visitor.photoUrl
? `<img src="${escHtml(visitor.photoUrl)}" alt="" style="width:44px;height:44px;border-radius:50%;object-fit:cover;">`
: `<div class="queue-initials" style="background:${getColor(visitor.name)}">${getInitials(visitor.name)}</div>`;
const subTag = visitor.isSubstituteVisitor
? `<div style="font-size:0.72rem;color:#4f46e5;">Vertretung (stellt sich vor)</div>`
: '';
const removeBtn = visitor.isSubstituteVisitor
? '' // substitutes are managed via substitutes panel
: `<button class="btn btn-danger btn-sm" onclick="removeVisitor('${id}')">✕</button>`;
item.innerHTML = `
${badge}
<div class="drag-handle">⠿</div>
<div class="queue-photo-wrap">${photoHtml}</div>
<div class="queue-info">
<div class="queue-name">${escHtml(visitor.name)}</div>
<div class="queue-company">${escHtml(visitor.company || '')}</div>
${subTag}
</div>
<div class="queue-actions">${renderTimerBadge(id)}${removeBtn}</div>
`;
container.appendChild(item);
});
if (sortableVisitorInstance) sortableVisitorInstance.destroy();
sortableVisitorInstance = Sortable.create(container, {
animation: 150,
handle: '.drag-handle',
ghostClass: 'sortable-ghost',
filter: '.empty-hint',
onEnd: (evt) => {
const moved = state.visitorsOrder.splice(evt.oldIndex, 1)[0];
state.visitorsOrder.splice(evt.newIndex, 0, moved);
saveState();
renderControl();
},
});
}
function renderVisitorsList() {
const container = document.getElementById('visitors-list');
container.innerHTML = '';
if (state.visitors.length === 0) {
container.innerHTML = `<div class="empty-hint"><p>Noch keine Besucher eingetragen.</p></div>`;
return;
}
state.visitors.forEach(v => {
const div = document.createElement('div');
div.className = 'visitor-item';
const photoHtml = v.photoUrl
? `<img src="${escHtml(v.photoUrl)}" alt="" style="width:36px;height:36px;border-radius:50%;object-fit:cover;">`
: `<div style="width:36px;height:36px;border-radius:50%;background:${getColor(v.name)};display:flex;align-items:center;justify-content:center;color:white;font-weight:700;font-size:0.85rem;">${getInitials(v.name)}</div>`;
div.innerHTML = `
${photoHtml}
<div style="flex:1">
<div class="v-name">${escHtml(v.name)}</div>
<div class="v-company">${escHtml(v.company || '')} ${v.specialty ? '· ' + escHtml(v.specialty) : ''}</div>
</div>
${renderTimerBadge(v.id)}
<button class="btn btn-ghost btn-sm" onclick="openVisitorEdit('${v.id}')" title="Bearbeiten" style="padding:4px 8px;margin-right:4px;">✎</button>
<button class="btn btn-danger btn-sm" onclick="removeVisitor('${v.id}')">✕</button>
`;
container.appendChild(div);
});
}
function renderMembersManage() {
const container = document.getElementById('members-manage-list');
container.innerHTML = '';
if (state.members.length === 0) {
container.innerHTML = `<div class="empty-hint"><p>Noch keine Mitglieder angelegt.</p></div>`;
return;
}
state.members.forEach(m => {
const div = document.createElement('div');
div.className = 'member-manage-item';
const photoHtml = m.photoUrl
? `<img src="${escHtml(m.photoUrl)}" alt="" style="width:36px;height:36px;border-radius:50%;object-fit:cover;">`
: `<div style="width:36px;height:36px;border-radius:50%;background:${getColor(m.name)};display:flex;align-items:center;justify-content:center;color:white;font-weight:700;font-size:0.85rem;">${getInitials(m.name)}</div>`;
div.innerHTML = `
${photoHtml}
<div style="flex:1">
<div class="m-name">${escHtml(m.name)}</div>
<div class="m-company">${escHtml(m.company)} · ${escHtml(m.specialty || '')}</div>
</div>
<button class="btn btn-ghost btn-sm" onclick="editMember('${m.id}')">✎</button>
<button class="btn btn-danger btn-sm" onclick="deleteMember('${m.id}')">✕</button>
`;
container.appendChild(div);
});
}
function renderSubstitutesList() {
const container = document.getElementById('substitutes-list');
container.innerHTML = '';
const subs = Object.entries(state.substitutes).filter(([, v]) => v && v.name);
if (subs.length === 0) {
container.innerHTML = `<div style="color:var(--muted);font-size:0.85rem;padding:8px 0;">Keine aktiven Vertretungen.</div>`;
return;
}
subs.forEach(([memberId, sub]) => {
const member = state.members.find(m => m.id === memberId);
if (!member) return;
const div = document.createElement('div');
div.style.cssText = 'padding:8px 0;border-bottom:1px solid var(--border);font-size:0.88rem;';
div.innerHTML = `
<div style="font-weight:600;">${escHtml(member.name)}</div>
<div style="color:var(--muted);">↩ ${escHtml(sub.name)} (${escHtml(sub.company || '')})${sub.skipRound2 ? ' <span style="font-size:0.72rem;background:#fef3c7;color:#92400e;padding:1px 5px;border-radius:3px;margin-left:4px;">keine eigene Vorstellung</span>' : ''}</div>
<button class="btn btn-danger btn-sm" style="margin-top:4px;" onclick="removeSubstitute('${memberId}')">Entfernen</button>
`;
container.appendChild(div);
});
}
function renderSubstituteMemberSelect() {
const sel = document.getElementById('sub-member-select');
const val = sel.value;
sel.innerHTML = '<option value="">— Mitglied auswählen —</option>';
state.members.forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name;
if (m.id === val) opt.selected = true;
sel.appendChild(opt);
});
}
function updateNextButton() {
const btn = document.getElementById('btn-next');
const nameEl = document.getElementById('btn-next-name');
if (!btn) return;
if (state.mode === 'round-transition') {
btn.disabled = false;
nameEl.textContent = '2. Runde';
return;
}
const next = getNextPresenter();
const hasStarted = state.currentIndex >= 0 || state.mode === 'visitors';
if (!hasStarted) {
// Not started yet
const first = state.meetingOrder.length > 0
? state.members.find(m => m.id === state.meetingOrder[0])
: null;
if (first) {
btn.disabled = false;
btn.querySelector('span').textContent = first.name;
} else {
btn.disabled = true;
btn.querySelector('span').textContent = '';
}
return;
}
// Absent-summary mode
if (state.mode === 'absent-summary') {
btn.disabled = false;
nameEl.textContent = state.visitorsOrder.length > 0 ? '2. Runde' : '↩ Beenden';
return;
}
if (next) {
btn.disabled = false;
const nextData = getDisplayData(next);
nameEl.textContent = nextData.name;
} else {
// Check if we just finished
const membersDone = state.mode === 'members' && state.currentIndex >= state.meetingOrder.length - 1;
const visitorsDone = state.mode === 'visitors' && state.visitorsCurrentIndex >= state.visitorsOrder.length - 1;
const hasAbsent = state.absentIds.length > 0 || (state.medicalIds || []).length > 0;
if (membersDone && hasAbsent) {
btn.disabled = false;
nameEl.textContent = 'Abwesenheiten';
} else if (membersDone && state.visitorsOrder.length === 0) {
btn.disabled = false;
nameEl.textContent = '↩ Beenden';
} else if (visitorsDone) {
btn.disabled = false;
nameEl.textContent = '↩ Beenden';
} else {
btn.disabled = false;
nameEl.textContent = '';
}
}
}
// ============================================================
// ACTIONS
// ============================================================
function startRoundTransition() {
state.mode = 'round-transition';
saveState();
renderControl();
renderDisplay();
}
function goNext() {
if (state.mode === 'round-transition') {
state.mode = 'visitors';
state.visitorsCurrentIndex = 0;
switchTab('visitors');
saveState();
renderControl();
renderDisplay();
return;
}
if (state.mode === 'members') {
if (state.currentIndex < state.meetingOrder.length - 1) {
state.currentIndex++;
} else {
// End of members: show absent summary if applicable, otherwise go to visitors
const hasAbsent = state.absentIds.length > 0 || (state.medicalIds || []).length > 0;
if (hasAbsent) {
state.mode = 'absent-summary';
} else if (state.visitorsOrder.length > 0) {
startRoundTransition();
return;
}
}
} else if (state.mode === 'absent-summary') {
if (state.visitorsOrder.length > 0) {
startRoundTransition();
return;
} else {
// End → back to logo
state.currentIndex = -1;
state.visitorsCurrentIndex = -1;
state.mode = 'members';
switchTab('members');
}
} else if (state.mode === 'visitors') {
if (state.visitorsCurrentIndex < state.visitorsOrder.length - 1) {
state.visitorsCurrentIndex++;
} else {
// End → back to logo
state.currentIndex = -1;
state.visitorsCurrentIndex = -1;
state.mode = 'members';
switchTab('members');
}
} else if (state.mode === 'members' && state.currentIndex >= state.meetingOrder.length - 1) {
// End of members, no absent, no visitors → back to logo
const hasAbsent = state.absentIds.length > 0 || (state.medicalIds || []).length > 0;
if (!hasAbsent && state.visitorsOrder.length === 0) {
state.currentIndex = -1;
state.visitorsCurrentIndex = -1;
state.mode = 'members';
switchTab('members');
}
}
// Start timer for new presenter (2s delay)
if (state.mode === 'members' || state.mode === 'visitors') {
const nowPresenter = getCurrentPresenter();
if (nowPresenter) scheduleTimer(getPresenterSeconds(nowPresenter));
else stopTimer();
} else {
stopTimer();
}
saveState();
renderControl();
renderDisplay();
}
function resetMeeting() {
if (!confirm('Vorstellungsrunde neu starten? Der Fortschritt wird zurückgesetzt.')) return;
stopTimer();
state.currentIndex = -1;
state.visitorsCurrentIndex = -1;
state.mode = 'members';
switchTab('members');
saveState();
renderControl();
renderDisplay();
}
function switchTab(tab) {
activeTab = tab;
document.getElementById('member-queue').style.display = tab === 'members' ? '' : 'none';
document.getElementById('visitor-queue').style.display = tab === 'visitors' ? '' : 'none';
document.getElementById('tab-members').classList.toggle('active', tab === 'members');
document.getElementById('tab-visitors').classList.toggle('active', tab === 'visitors');
}
function switchRightPanel(panelId) {
document.querySelectorAll('.right-panel-section').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.panel-right-tab').forEach(el => el.classList.remove('active'));
document.getElementById(panelId).classList.add('active');
document.querySelector(`[data-panel="${panelId}"]`).classList.add('active');
}
// ============================================================
// MEMBER CRUD
// ============================================================
function openAddMember() {
document.getElementById('edit-member-id').value = '';
document.getElementById('modal-member-title').textContent = 'Mitglied hinzufügen';
document.getElementById('m-name').value = '';
document.getElementById('m-company').value = '';
document.getElementById('m-specialty').value = '';
document.getElementById('m-photo').value = '';
document.getElementById('m-profile').value = '';
document.getElementById('m-kurzlink').value = '';
document.getElementById('photo-preview-wrap').style.display = 'none';
_updateOfflinePhotoUI(null);
openModal('modal-member');
}
function editMember(id) {
const m = state.members.find(m => m.id === id);
if (!m) return;
document.getElementById('edit-member-id').value = id;
document.getElementById('modal-member-title').textContent = 'Mitglied bearbeiten';
document.getElementById('m-name').value = m.name || '';
document.getElementById('m-company').value = m.company || '';
document.getElementById('m-specialty').value = m.specialty || '';
document.getElementById('m-photo').value = m.photoUrl || '';
document.getElementById('m-profile').value = m.profileUrl || '';
if (m.photoUrl) {
document.getElementById('photo-preview').src = m.photoUrl;
document.getElementById('photo-preview-wrap').style.display = 'block';
} else {
document.getElementById('photo-preview-wrap').style.display = 'none';
}
// Load extras for this member
const ex = loadExtras()[extrasKey(m.name)] || {};
document.getElementById('m-kurzlink').value = ex.kurzlink || '';
_updateOfflinePhotoUI(ex.photoB64 || null);
openModal('modal-member');
}
document.getElementById('m-photo').addEventListener('input', (e) => {
const url = e.target.value.trim();
const wrap = document.getElementById('photo-preview-wrap');
const img = document.getElementById('photo-preview');
if (url) {
img.src = url;
wrap.style.display = 'block';
} else {
wrap.style.display = 'none';
}
});
function saveMember() {
const name = document.getElementById('m-name').value.trim();
const company = document.getElementById('m-company').value.trim();
const specialty = document.getElementById('m-specialty').value.trim();
const photoUrl = document.getElementById('m-photo').value.trim();
const profileUrl = document.getElementById('m-profile').value.trim();
const kurzlink = document.getElementById('m-kurzlink').value.trim();
if (!name) { alert('Bitte Name eingeben.'); return; }
const editId = document.getElementById('edit-member-id').value;
if (editId) {
const m = state.members.find(m => m.id === editId);
if (m) Object.assign(m, { name, company, specialty, photoUrl, profileUrl });
} else {
const id = 'member-' + Date.now() + '-' + Math.random().toString(36).slice(2,7);
state.members.push({ id, name, company, specialty, photoUrl, profileUrl });
// Add to meeting order if not already there
if (!state.meetingOrder.includes(id)) {
state.meetingOrder.push(id);
}
}
// Save extras (kurzlink + offline photo) — keyed by name
const extras = loadExtras();
const key = extrasKey(name);
const entry = extras[key] || {};
if (kurzlink) entry.kurzlink = kurzlink;
else delete entry.kurzlink;
if (_editingOfflinePhotoB64) entry.photoB64 = _editingOfflinePhotoB64;
else delete entry.photoB64;
if (Object.keys(entry).length > 0) extras[key] = entry;
else delete extras[key];
saveExtras(extras);
saveState();
closeMemberModal();
renderControl();
}
function deleteMember(id) {
if (!confirm('Mitglied entfernen?')) return;
state.members = state.members.filter(m => m.id !== id);
state.meetingOrder = state.meetingOrder.filter(mid => mid !== id);
delete state.substitutes[id];
state.absentIds = state.absentIds.filter(aid => aid !== id);
if (state.medicalIds) state.medicalIds = state.medicalIds.filter(mid => mid !== id);
if (state.currentIndex >= state.meetingOrder.length) {
state.currentIndex = state.meetingOrder.length - 1;
}
saveState();
renderControl();
}
function closeMemberModal() {
const hint = document.getElementById('offline-photo-url-hint');
if (hint) hint.remove();
closeModal('modal-member');
}
// ============================================================
// VISITOR CRUD
// ============================================================
function addVisitor() {
const name = document.getElementById('v-name').value.trim();
if (!name) { alert('Bitte Name eingeben.'); return; }
const id = 'visitor-' + Date.now() + '-' + Math.random().toString(36).slice(2,7);
const visitor = {
id,
name,
company: document.getElementById('v-company').value.trim(),
specialty: document.getElementById('v-specialty').value.trim(),
photoUrl: document.getElementById('v-photo').value.trim(),
invitedBy: document.getElementById('v-invited-by').value || '',
isVisitor: true,
};
state.visitors.push(visitor);
state.visitorsOrder.push(id);
document.getElementById('v-name').value = '';
document.getElementById('v-company').value = '';
document.getElementById('v-specialty').value = '';
document.getElementById('v-photo').value = '';
document.getElementById('v-invited-by').value = '';
saveState();
renderControl();
}
function removeVisitor(id) {
state.visitors = state.visitors.filter(v => v.id !== id);
state.visitorsOrder = state.visitorsOrder.filter(vid => vid !== id);
if (state.visitorsCurrentIndex >= state.visitorsOrder.length) {
state.visitorsCurrentIndex = state.visitorsOrder.length - 1;
}
saveState();
renderControl();
}
function openVisitorEdit(id) {
const v = state.visitors.find(v => v.id === id);
if (!v) return;
document.getElementById('ve-id').value = v.id;
document.getElementById('ve-name').value = v.name || '';
document.getElementById('ve-company').value = v.company || '';
document.getElementById('ve-specialty').value = v.specialty || '';
document.getElementById('ve-invited-by').value = v.invitedBy || '';
document.getElementById('ve-photo').value = v.photoUrl || '';
openModal('modal-visitor-edit');
}
function saveVisitorEdit() {
const id = document.getElementById('ve-id').value;
const v = state.visitors.find(v => v.id === id);
if (!v) return;
const name = document.getElementById('ve-name').value.trim();
if (!name) { alert('Bitte Name eingeben.'); return; }
v.name = name;
v.company = document.getElementById('ve-company').value.trim();
v.specialty = document.getElementById('ve-specialty').value.trim();
v.invitedBy = document.getElementById('ve-invited-by').value.trim();
v.photoUrl = document.getElementById('ve-photo').value.trim();
saveState();
renderControl();
closeModal('modal-visitor-edit');
}
// ============================================================
// SUBSTITUTE MANAGEMENT
// ============================================================
function openSubstituteModal(memberId) {
const member = state.members.find(m => m.id === memberId);
if (!member) return;
const sub = state.substitutes[memberId] || {};
document.getElementById('sub-modal-member-id').value = memberId;
document.getElementById('sub-modal-member-name').textContent = member.name;
document.getElementById('sub-modal-name').value = sub.name || '';
document.getElementById('sub-modal-company').value = sub.company || '';
document.getElementById('sub-modal-specialty').value = sub.specialty || '';
document.getElementById('sub-modal-photo').value = sub.photoUrl || '';
document.getElementById('sub-modal-skip-round2').checked = sub.skipRound2 || false;
openModal('modal-substitute');
}
function saveSubstituteModal() {
const memberId = document.getElementById('sub-modal-member-id').value;
const name = document.getElementById('sub-modal-name').value.trim();
if (!name) { alert('Bitte Name der Vertretung eingeben.'); return; }
const subData = {
name,
company: document.getElementById('sub-modal-company').value.trim(),
specialty: document.getElementById('sub-modal-specialty').value.trim(),
photoUrl: document.getElementById('sub-modal-photo').value.trim(),
skipRound2: document.getElementById('sub-modal-skip-round2').checked,
};
state.substitutes[memberId] = subData;
syncSubstituteToVisitors(memberId, subData);
saveState();
closeModal('modal-substitute');
renderControl();
}
function saveSubstitute() {
const memberId = document.getElementById('sub-member-select').value;
const name = document.getElementById('sub-rep-name').value.trim();
if (!memberId) { alert('Bitte Mitglied auswählen.'); return; }
if (!name) { alert('Bitte Name der Vertretung eingeben.'); return; }
const subData = {
name,
company: document.getElementById('sub-rep-company').value.trim(),
specialty: document.getElementById('sub-rep-specialty').value.trim(),
photoUrl: document.getElementById('sub-rep-photo').value.trim(),
};
state.substitutes[memberId] = subData;
syncSubstituteToVisitors(memberId, subData);
document.getElementById('sub-member-select').value = '';
document.getElementById('sub-rep-name').value = '';
document.getElementById('sub-rep-company').value = '';
document.getElementById('sub-rep-specialty').value = '';
document.getElementById('sub-rep-photo').value = '';
saveState();
renderControl();
}
function removeSubstitute(memberId) {
removeSubstituteVisitor(memberId);
delete state.substitutes[memberId];
saveState();
closeModal('modal-substitute');
renderControl();
}
// Automatically add/update substitute as visitor for round 2
function syncSubstituteToVisitors(memberId, subData) {
const visitorId = 'sub-visitor-' + memberId;
// Remove old entry if exists
removeSubstituteVisitor(memberId);
// Skip round 2 if flagged (e.g. same-company employee or recurring substitute)
if (subData.skipRound2) return;
// Add new visitor entry
const visitor = {
id: visitorId,
name: subData.name,
company: subData.company || '',
specialty: subData.specialty || '',
photoUrl: subData.photoUrl || '',
isVisitor: true,
isSubstituteVisitor: true,
forMemberId: memberId,
};
state.visitors.push(visitor);
state.visitorsOrder.push(visitorId);
}
function removeSubstituteVisitor(memberId) {
const visitorId = 'sub-visitor-' + memberId;
state.visitors = state.visitors.filter(v => v.id !== visitorId);
state.visitorsOrder = state.visitorsOrder.filter(id => id !== visitorId);
}
// ============================================================
// IMPORT / EXPORT
// ============================================================
function openImportExport() {
const exportData = {
members: state.members,
meetingOrder: state.meetingOrder,
substitutes: state.substitutes,
visitors: state.visitors,
visitorsOrder: state.visitorsOrder,
};
document.getElementById('json-textarea').value = JSON.stringify(exportData, null, 2);
openModal('modal-importexport');
}
function exportJSON() {
const exportData = {
members: state.members,
meetingOrder: state.meetingOrder,
substitutes: state.substitutes,
visitors: state.visitors,
visitorsOrder: state.visitorsOrder,
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'bni-sonne-mitglieder.json';
a.click();
}
function importJSON() {
try {
const data = JSON.parse(document.getElementById('json-textarea').value);
if (data.members) state.members = data.members;
if (data.meetingOrder) state.meetingOrder = data.meetingOrder;
if (data.substitutes) state.substitutes = data.substitutes;
if (data.visitors) state.visitors = data.visitors;
if (data.visitorsOrder) state.visitorsOrder = data.visitorsOrder;
state.currentIndex = -1;
state.visitorsCurrentIndex = -1;
state.mode = 'members';
saveState();
closeModal('modal-importexport');
renderControl();
alert('Import erfolgreich! ' + state.members.length + ' Mitglieder geladen.');
} catch(e) {
alert('Ungültiges JSON: ' + e.message);
}
}
// ============================================================
// MODAL HELPERS
// ============================================================
function openModal(id) {
document.getElementById(id).classList.add('open');
}
function closeModal(id) {
document.getElementById(id).classList.remove('open');
}
// Close on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.classList.remove('open');
});
});
// ============================================================
// UTILITY
// ============================================================
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function uid() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
// ============================================================
// KEYBOARD SHORTCUTS
// ============================================================
document.addEventListener('keydown', (e) => {
if (getView() !== 'control') return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'ArrowRight' || e.key === ' ') {
e.preventDefault();
const btn = document.getElementById('btn-next');
if (btn && !btn.disabled) goNext();
}
});
// ============================================================
// PDF IMPORT
// ============================================================
function openPdfImport() {
const lib = window['pdfjs-dist/build/pdf'] || window.pdfjsLib;
if (lib) lib.GlobalWorkerOptions.workerSrc =
'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js';
const status = document.getElementById('pdf-parse-status');
if (status) status.style.display = 'none';
renderQuickEntryTable();
openModal('modal-pdf-import');
}
async function handlePdfImportFile(file) {
if (!file || !file.name.toLowerCase().endsWith('.pdf')) return;
const status = document.getElementById('pdf-parse-status');
status.style.display = '';
status.style.cssText += ';background:#f0f4ff;border:1px solid #c0c8f0;color:#1a2744;';
status.textContent = 'PDF wird analysiert…';
try {
const buf = await file.arrayBuffer();
const rows = await parseBNIListPdf(buf);
if (rows.length === 0) {
status.style.cssText += ';background:#fff3cd;border:1px solid #ffc107;color:#664d03;';
status.textContent = 'Keine Einträge erkannt – ist das die BNI Besucher-/Vertreterliste?';
return;
}
fillTableFromPdfRows(rows);
status.style.cssText += ';background:#d1fae5;border:1px solid #6ee7b7;color:#065f46;';
status.textContent = `✓ ${rows.length} Eintr${rows.length===1?'ag':'äge'} aus PDF erkannt und eingetragen.`;
} catch(err) {
status.style.cssText += ';background:#fee2e2;border:1px solid #fca5a5;color:#7f1d1d;';
status.textContent = 'Fehler beim Lesen der PDF: ' + err.message;
}
}
async function parseBNIListPdf(arrayBuffer) {
const lib = window['pdfjs-dist/build/pdf'] || window.pdfjsLib;
if (!lib) throw new Error('pdf.js nicht geladen');
const pdf = await lib.getDocument({ data: arrayBuffer }).promise;
const rows = [];
// Column X boundaries derived from the BNI Chapterportal PDF layout:
// type (B/V): x < 77
// name: 77 ≤ x < 198
// phone: 198 ≤ x < 275
// email: 275 ≤ x < 395
// specialty: 395 ≤ x < 548
// forMember: 548 ≤ x < 670
function colOf(x) {
if (x < 77) return 'type';
if (x < 198) return 'name';
if (x < 275) return 'phone';
if (x < 395) return 'email';
if (x < 548) return 'specialty';
if (x < 670) return 'forMember';
return null;
}
for (let p = 1; p <= pdf.numPages; p++) {
const page = await pdf.getPage(p);
const tc = await page.getTextContent();
const items = tc.items
.map(i => ({ x: i.transform[4], y: i.transform[5], text: i.str.trim() }))
.filter(i => i.text && i.y < 462 && i.y >= 140) // skip header & footer bands
.sort((a, b) => b.y - a.y || a.x - b.x); // top→bottom, left→right
// Group by Y proximity (≤15 units covers multi-line cells within one row)
const groups = [];
for (const item of items) {
const last = groups[groups.length - 1];
if (last && Math.abs(last.refY - item.y) <= 15) {
last.items.push(item);
} else {
groups.push({ refY: item.y, items: [item] });
}
}
for (const g of groups) {
const cols = {};
for (const item of g.items) {
const c = colOf(item.x);
if (!c) continue;
cols[c] = cols[c] ? cols[c] + ' ' + item.text : item.text;
}
// Rows start with a number: "1. B", "2. V", "3." (empty), etc.
const m = (cols.type || '').match(/^\d+\.?\s*([BV])?/i);
if (!m) continue;
const name = (cols.name || '').trim();
if (!name) continue;
rows.push({
type: (m[1] || '').toUpperCase(),
name,
specialty: (cols.specialty || '').trim(),
forMember: (cols.forMember || '').trim(),
});
}
}
return rows;
}
function fillTableFromPdfRows(rows) {
renderQuickEntryTable(); // reset to 10 blank rows first
const memberOptions = state.members.map(m =>
`<option value="${escHtml(m.id)}">${escHtml(m.name)}</option>`
).join('');
// Ensure enough table rows exist
const tbody = document.getElementById('quick-entry-tbody');
while (tbody.querySelectorAll('.qe-row').length < rows.length) {
const i = tbody.querySelectorAll('.qe-row').length + 1;
const tr = document.createElement('tr');
tr.className = 'qe-row';
tr.dataset.row = i;
tr.innerHTML = `
<td style="width:20px" class="qe-num">${i}</td>
<td style="width:105px"><select class="qe-type">
<option value="">—</option>
<option value="B">B – Besucher</option>
<option value="V">V – Vertreter</option>
</select></td>
<td><input type="text" class="qe-name" placeholder="Vor- und Nachname" autocomplete="off"></td>
<td><input type="text" class="qe-specialty" placeholder="Berufsbezeichnung" autocomplete="off"></td>
<td><select class="qe-for-member">
<option value="">— Mitglied auswählen —</option>${memberOptions}
</select></td>`;
tbody.appendChild(tr);
}
const tableRows = tbody.querySelectorAll('.qe-row');
rows.forEach((row, i) => {
const tr = tableRows[i];
tr.querySelector('.qe-type').value = row.type || '';
tr.querySelector('.qe-name').value = row.name;
tr.querySelector('.qe-specialty').value = row.specialty;
if (row.forMember) {
const sel = tr.querySelector('.qe-for-member');
const needle = row.forMember.toLowerCase();
const match = state.members.find(m => {
const mn = m.name.toLowerCase();
return mn === needle
|| mn.includes(needle)
|| needle.includes(mn)
|| needle.includes(mn.split(' ').pop());
});
if (match) sel.value = match.id;
}
});
}
function renderQuickEntryTable() {
const tbody = document.getElementById('quick-entry-tbody');
tbody.innerHTML = '';
const memberOptions = state.members.map(m =>
`<option value="${escHtml(m.id)}">${escHtml(m.name)}</option>`
).join('');
for (let i = 1; i <= 10; i++) {
const tr = document.createElement('tr');
tr.className = 'qe-row';
tr.dataset.row = i;
tr.innerHTML = `
<td style="width:20px" class="qe-num">${i}</td>
<td style="width:105px">
<select class="qe-type">
<option value="">—</option>
<option value="B">B – Besucher</option>
<option value="V">V – Vertreter</option>
</select>
</td>
<td><input type="text" class="qe-name" placeholder="Vor- und Nachname" autocomplete="off"></td>
<td><input type="text" class="qe-specialty" placeholder="Berufsbezeichnung" autocomplete="off"></td>
<td>
<select class="qe-for-member">
<option value="">— Mitglied auswählen —</option>
${memberOptions}
</select>
</td>
`;
tbody.appendChild(tr);
}
}
function confirmQuickEntry() {
const rows = document.querySelectorAll('#quick-entry-tbody .qe-row');
let importedCount = 0;
for (const row of rows) {
const type = row.querySelector('.qe-type').value;
const name = row.querySelector('.qe-name').value.trim();
const specialty = row.querySelector('.qe-specialty').value.trim();
const memberId = row.querySelector('.qe-for-member').value;
if (!name) continue; // skip empty rows
if (type === 'V' && memberId) {
// Vertreter → als Vertretung eintragen + automatisch Besucher-Runde
const subData = { name, company: '', specialty, photoUrl: '' };
state.substitutes[memberId] = subData;
syncSubstituteToVisitors(memberId, subData);
importedCount++;
} else {
// Besucher (oder Vertreter ohne Mitglied-Zuordnung)
const id = 'visitor-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7);
state.visitors.push({ id, name, company: '', specialty, photoUrl: '', isVisitor: true });
state.visitorsOrder.push(id);
importedCount++;
}
}
saveState();
closeModal('modal-pdf-import');
renderControl();
switchRightPanel('visitors-panel');
switchTab('visitors');
alert(`✓ ${importedCount} Eintr${importedCount === 1 ? 'ag' : 'äge'} importiert.`);
}
// ============================================================
// MEMBER SYNC
// ============================================================
const BOOKMARKLET_CODE = `(async function(){
/* ── helper: load a profile page in a hidden iframe, wait for AJAX to render ── */
function loadProfile(url){
return new Promise(function(resolve){
var iframe = document.createElement('iframe');
iframe.style.cssText='position:fixed;opacity:0;pointer-events:none;top:0;left:0;width:1200px;height:900px;z-index:-1;';
var done=false;
var timer;
function finish(){
if(done)return; done=true; clearTimeout(timer);
try{ document.body.removeChild(iframe); }catch(e){}
resolve(null);
}
function extract(){
if(done)return;
try{
var doc=iframe.contentDocument||iframe.contentWindow.document;
var det=doc.getElementById('memberDetail');
if(!det||det.innerHTML.trim().length<100){ return false; }
/* Photo */
var img=det.querySelector('img[src*="ImageDownload"],img[src*="bniconnect"],img[src*="cdn.bni"]')
||det.querySelector('.photoCol img,[class*="photo"] img,img');
var photoUrl=img?(img.getAttribute('src')||''):'';
if(photoUrl&&!photoUrl.startsWith('http')) photoUrl=location.origin+photoUrl;
/* Texts – collect all leaf text nodes */
var leaves=Array.from(det.querySelectorAll('*'))
.filter(function(el){return el.children.length===0&&el.textContent.trim().length>1;})
.map(function(el){return el.textContent.trim().replace(/\\s+/g,' ');});
/* company & specialty: heuristic – look for known BNI class keywords */
function t(sel){var e=det.querySelector(sel);return e?e.textContent.trim().replace(/\\s+/g,' '):'';}
var company =t('[class*="company"],[class*="firm"],[class*="Firm"],[class*="unternehm"]');
var specialty=t('[class*="classif"],[class*="categ"],[class*="spec"],[class*="beruf"],[class*="wirtschaft"],[class*="branch"]');
if(done)return false; done=true; clearTimeout(timer);
try{document.body.removeChild(iframe);}catch(e){}
resolve({photoUrl:photoUrl,company:company,specialty:specialty,leaves:leaves.slice(0,10)});
return true;
}catch(e){return false;}
}
/* Poll until content appears or timeout */
var polls=0;
var poll=setInterval(function(){
polls++;
if(extract()||polls>30) clearInterval(poll);
},400);
timer=setTimeout(function(){ clearInterval(poll); finish(); },14000);
iframe.onload=function(){ /* polling starts above */ };
document.body.appendChild(iframe);
iframe.src=url;
});
}
/* ──────────────────────────────────────────────────────────────── */
/* Step 1: collect all unique member profile links */
var links = Array.from(document.querySelectorAll('a[href*="memberdetails"]'));
var seen = {}; links = links.filter(function(a){ if(seen[a.href]) return false; return (seen[a.href]=true); });
if(!links.length){ alert('Keine Mitglieder gefunden.\\nBitte warten bis alle Kacheln geladen sind.'); return; }
/* Progress overlay */
var ov = document.createElement('div');
ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:999999;display:flex;align-items:center;justify-content:center;font-family:sans-serif;';
var box = document.createElement('div');
box.style.cssText='background:#fff;border-radius:14px;padding:28px 36px;text-align:center;min-width:320px;box-shadow:0 8px 40px rgba(0,0,0,.4);';
box.innerHTML='<div style="font-weight:700;font-size:1.1rem;margin-bottom:10px;">BNI Mitglieder werden geladen…</div>'
+'<div id="_bni_prog" style="color:#555;font-size:.9rem;">0 / '+links.length+'</div>'
+'<div style="margin-top:12px;background:#eee;border-radius:6px;height:8px;"><div id="_bni_bar" style="background:#cc0000;height:8px;border-radius:6px;width:0%;transition:width .2s;"></div></div>'
+'<div style="margin-top:10px;font-size:.78rem;color:#999;">Bitte nicht weg navigieren</div>';
ov.appendChild(box); document.body.appendChild(ov);
/* Step 2: for each member fetch profile detail via AJAX endpoint */
var members = [];
for(var i=0;i<links.length;i++){
var link = links[i];
var url = new URL(link.href);
var encId = url.searchParams.get('encryptedMemberId') || '';
var rawName = (url.searchParams.get('name')||'').replace(/\\+/g,' ');
var name = decodeURIComponent(rawName);
document.getElementById('_bni_prog').textContent = (i+1)+' / '+links.length+' – '+name;
document.getElementById('_bni_bar').style.width = Math.round((i+1)/links.length*100)+'%';
var member = { name:name, company:'', specialty:'', photoUrl:'', profileUrl:link.href };
var detail = await loadProfile(link.href);
if(detail){
member.photoUrl = detail.photoUrl || '';
member.company = detail.company || '';
member.specialty = detail.specialty || '';
/* fallback: if class-based selectors missed, try leaf texts */
if(!member.company && detail.leaves && detail.leaves.length>1) member.company = detail.leaves[1]||'';
if(!member.specialty && detail.leaves && detail.leaves.length>2) member.specialty = detail.leaves[2]||'';
}
members.push(member);
}
document.body.removeChild(ov);
/* Download JSON */
var blob = new Blob([JSON.stringify({members:members,exportedAt:new Date().toISOString()},null,2)],{type:'application/json'});
var a = document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='bni-mitglieder-export.json';
document.body.appendChild(a); a.click(); setTimeout(function(){ document.body.removeChild(a); },1000);
alert('✓ '+members.length+' Mitglieder exportiert!\\n('+members.filter(function(m){return m.photoUrl;}).length+' mit Foto)');
})();`;
const DIAG_CODE = `(function(){
var out = {url:location.href, cards:[]};
var links = Array.from(document.querySelectorAll('a[href*="memberdetails"]'));
var seen={}; links=links.filter(function(a){if(seen[a.href])return false;return(seen[a.href]=true);});
out.totalLinks = links.length;
/* sample first 3 cards */
links.slice(0,3).forEach(function(link){
var c=link; for(var i=0;i<10;i++){var p=c.parentElement;if(!p)break;var r=p.getBoundingClientRect();if(r.width>100&&r.height>60){c=p;break;}c=p;}
/* collect all img srcs in card */
var imgs=Array.from(c.querySelectorAll('img')).map(function(i){return{src:i.src,style:i.getAttribute('style')||'',cls:i.className};});
/* background images */
var bgImgs=[];
Array.from(c.querySelectorAll('*')).forEach(function(el){var s=window.getComputedStyle(el).backgroundImage;if(s&&s!=='none'&&s.includes('url'))bgImgs.push({cls:el.className,tag:el.tagName,bg:s});});
/* text content of direct children */
var texts=Array.from(c.querySelectorAll('*')).filter(function(el){return el.children.length===0&&el.textContent.trim();}).map(function(el){return{tag:el.tagName,cls:el.className,text:el.textContent.trim().substring(0,80)};});
out.cards.push({linkText:link.textContent.trim(),containerTag:c.tagName,containerClass:c.className,imgs:imgs,bgImgs:bgImgs,texts:texts,html:c.outerHTML.substring(0,1500)});
});
var blob=new Blob([JSON.stringify(out,null,2)],{type:'application/json'});
var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='bni-diagnose.json';document.body.appendChild(a);a.click();setTimeout(function(){document.body.removeChild(a);},1000);
alert('Diagnose-Datei heruntergeladen!\\nBitte in der App unter "Mitglieder aktualisieren" hochladen.');
})();`;
function openMemberSync() {
document.getElementById('bookmarklet-link').href = 'javascript:' + encodeURIComponent(BOOKMARKLET_CODE);
document.getElementById('bookmarklet-diag').href = 'javascript:' + encodeURIComponent(DIAG_CODE);
document.getElementById('sync-result').style.display = 'none';
document.getElementById('sync-file-input').value = '';
document.getElementById('diag-upload-area').style.display = 'block';
document.getElementById('diag-result').textContent = '';
openModal('modal-sync');
}
function handleDiagFile(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const diag = JSON.parse(e.target.result);
const res = document.getElementById('diag-result');
if (!diag.cards || diag.cards.length === 0) {
res.textContent = 'Keine Karten gefunden – Seite war noch nicht vollständig geladen?';
return;
}
// Analyse: find img selector that gives photos
const card = diag.cards[0];
let photoSelector = '';
let photoFound = '';
// Check real img tags
const photoImg = card.imgs.find(i =>
i.src && (i.src.includes('ImageDownload') || i.src.includes('bniconnect') || i.src.includes('cdn.bni') || i.src.includes('member'))
&& !i.src.includes('logo') && !i.src.includes('icon') && !i.src.includes('gif')
);
if (photoImg) {
photoFound = photoImg.src;
photoSelector = photoImg.cls ? `img.${photoImg.cls.trim().split(' ')[0]}` : 'img[src*="ImageDownload"],img[src*="bniconnect"]';
}
// Check background images
if (!photoFound && card.bgImgs.length) {
const bg = card.bgImgs.find(b => !b.bg.includes('gradient'));
if (bg) {
photoFound = bg.bg;
photoSelector = bg.cls ? `.${bg.cls.trim().split(' ')[0]}` : bg.tag;
}
}
// Find specialty/company text patterns
const texts = card.texts.map(t => `[${t.tag}.${t.cls}] "${t.text}"`).slice(0, 8).join('\n');
res.innerHTML = `<strong>Gefunden:</strong><br>
Foto-URL: ${photoFound ? `<span style="color:green">✓ ${photoFound.substring(0,60)}...</span>` : '<span style="color:red">✗ kein Foto-Img gefunden</span>'}<br>
Container-Klasse: <code>${card.containerClass}</code><br>
Texte in Karte:<br><pre style="font-size:0.75rem;background:#f5f5f5;padding:6px;border-radius:4px;overflow:auto;max-height:120px;">${texts}</pre>`;
// Store analysis for potential selector improvement
window._bniDiag = diag;
res.innerHTML += `<br><button class="btn btn-primary btn-sm" onclick="downloadDiagReport()">📋 Diagnose-Report herunterladen (für Entwickler)</button>`;
} catch(e) { document.getElementById('diag-result').textContent = 'Fehler: ' + e.message; }
};
reader.readAsText(file);
document.getElementById('diag-upload-area').style.display = 'block';
}
function downloadDiagReport() {
if (!window._bniDiag) return;
const blob = new Blob([JSON.stringify(window._bniDiag, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'bni-diagnose-report.json';
document.body.appendChild(a); a.click();
setTimeout(() => document.body.removeChild(a), 1000);
}
function handleSyncFile(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
if (!data.members || !Array.isArray(data.members)) {
throw new Error('Ungültiges Format – "members"-Array fehlt.');
}
const result = syncMembers(data.members);
const el = document.getElementById('sync-result');
el.style.display = '';
el.innerHTML = `<span style="color:var(--success)">✓ Synchronisiert:</span>
${result.added} neu hinzugefügt ·
${result.updated} aktualisiert ·
${result.unchanged} unverändert`;
saveState();
renderControl();
} catch(err) {
alert('Fehler: ' + err.message);
}
};
reader.readAsText(file);
}
function syncMembers(incoming) {
let added = 0, updated = 0, unchanged = 0;
for (const inc of incoming) {
if (!inc.name || inc.name.length < 2) continue;
// Match by name (case-insensitive)
const existing = state.members.find(m =>
m.name.toLowerCase().trim() === inc.name.toLowerCase().trim()
);
if (existing) {
let changed = false;
// Update photo if we now have one and didn't before
if (inc.photoUrl && !existing.photoUrl) { existing.photoUrl = inc.photoUrl; changed = true; }
// Update specialty if empty
if (inc.specialty && !existing.specialty) { existing.specialty = inc.specialty; changed = true; }
// Update company if empty
if (inc.company && !existing.company) { existing.company = inc.company; changed = true; }
// Always update profileUrl if we got one (encryptedMemberId might change)
if (inc.profileUrl && inc.profileUrl !== existing.profileUrl) { existing.profileUrl = inc.profileUrl; changed = true; }
changed ? updated++ : unchanged++;
} else {
// New member – add to list and meeting order
const id = 'member-' + Date.now() + '-' + Math.random().toString(36).slice(2,7);
state.members.push({
id,
name: inc.name,
company: inc.company || '',
specialty: inc.specialty || '',
photoUrl: inc.photoUrl || '',
profileUrl: inc.profileUrl || '',
});
if (!state.meetingOrder.includes(id)) state.meetingOrder.push(id);
added++;
}
}
return { added, updated, unchanged };
}
// ============================================================
// TIMER ENGINE
// ============================================================
const _t = { total: 0, remaining: 0, running: false, startedAt: 0, pending: null, iv: null };
function fmtSecs(s) {
if (s === undefined || s === null || isNaN(s)) return '0:00';
const m = Math.floor(s / 60);
return `${m}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
}
function timerCls(rem, total) {
if (!total || rem <= 0) return 'expired';
const r = rem / total;
return r <= 0.15 ? 'danger' : r <= 0.30 ? 'warn' : '';
}
function updateTimerDisplays() {
const s = _t.remaining;
const tot = _t.total;
const cls = tot > 0 ? timerCls(s, tot) : '';
const txt = tot > 0 ? fmtSecs(s) : '';
const CIRC = 729; // 2 * PI * 116
// SVG ring
const ring = document.getElementById('timer-ring-progress');
const ringEl = document.getElementById('timer-ring-svg');
if (ring && ringEl) {
const offset = tot > 0 ? CIRC * (1 - s / tot) : 0;
ring.style.strokeDashoffset = offset;
ring.className = 'timer-ring-progress' + (cls ? ' ' + cls : '');
ringEl.style.display = tot > 0 ? 'block' : 'none';
}
// Display timer text
const dt = document.getElementById('display-timer-text');
if (dt) {
dt.textContent = txt;
dt.className = cls;
dt.style.display = tot > 0 ? 'block' : 'none';
}
// Control timer display (live countdown)
const ct = document.getElementById('ctrl-timer-display');
if (ct) { ct.textContent = txt; ct.className = cls; }
// Control default timer label
const cd = document.getElementById('ctrl-timer-default');
if (cd) cd.textContent = fmtSecs(state.defaultTimer || 60);
// Toggle button label
const tb = document.getElementById('ctrl-timer-toggle');
if (tb) tb.textContent = (_t.running || _t.pending) ? '⏸ Stop' : '▶ Start';
}
function _timerTick() {
const elapsed = Math.floor((Date.now() - _t.startedAt) / 1000);
_t.remaining = Math.max(0, _t.total - elapsed);
updateTimerDisplays();
if (_t.remaining <= 0) { clearInterval(_t.iv); _t.iv = null; _t.running = false; }
}
function stopTimer() {
if (_t.pending) { clearTimeout(_t.pending); _t.pending = null; }
if (_t.iv) { clearInterval(_t.iv); _t.iv = null; }
_t.running = false; _t.total = 0; _t.remaining = 0;
if (channel) try { channel.postMessage({ type: 'TIMER_STOP' }); } catch(e) {}
updateTimerDisplays();
}
function toggleTimer() {
if (_t.running || _t.pending) {
stopTimer();
} else {
const presenter = getCurrentPresenter();
const secs = presenter ? getPresenterSeconds(presenter) : (state.defaultTimer || 60);
scheduleTimer(secs);
}
}
function resetTimer() {
const presenter = getCurrentPresenter();
const secs = presenter ? getPresenterSeconds(presenter) : (state.defaultTimer || 60);
scheduleTimer(secs);
}
function scheduleTimer(seconds) {
stopTimer();
_t.total = seconds; _t.remaining = seconds;
updateTimerDisplays();
_t.pending = setTimeout(() => {
_t.pending = null;
_t.startedAt = Date.now();
_t.running = true;
if (channel) try { channel.postMessage({ type: 'TIMER_START', total: seconds, startedAt: _t.startedAt }); } catch(e) {}
_t.iv = setInterval(_timerTick, 500);
updateTimerDisplays();
}, 2000);
}
function getPresenterSeconds(presenter) {
const def = state.defaultTimer > 0 ? state.defaultTimer : 60;
if (!presenter) return def;
return (state.memberTimers && state.memberTimers[presenter.id]) > 0
? state.memberTimers[presenter.id] : def;
}
function renderTimerBadge(memberId) {
const def = state.defaultTimer || 60;
const custom = state.memberTimers && state.memberTimers[memberId];
const secs = custom > 0 ? custom : def;
const isCustom = custom > 0 && custom !== def;
return `<button class="queue-timer-btn${isCustom ? ' custom' : ''}"
onclick="editMemberTimer('${memberId}')"
title="Redezeit anpassen">${fmtSecs(secs)}</button>`;
}
function editMemberTimer(memberId) {
const def = state.defaultTimer || 60;
const current = (state.memberTimers && state.memberTimers[memberId]) || def;
const answer = prompt(
`Individuelle Redezeit (M:SS oder Minuten)\nStandard: ${fmtSecs(def)}\nLeer lassen = Standard wiederherstellen`,
current !== def ? fmtSecs(current) : ''
);
if (answer === null) return;
const trimmed = answer.trim();
if (!trimmed) {
if (state.memberTimers) delete state.memberTimers[memberId];
} else {
const parts = trimmed.split(':');
const secs = parts.length === 2
? parseInt(parts[0]) * 60 + parseInt(parts[1])
: parseInt(parts[0]) * 60;
if (!isNaN(secs) && secs > 0) {
if (!state.memberTimers) state.memberTimers = {};
state.memberTimers[memberId] = secs;
} else {
if (state.memberTimers) delete state.memberTimers[memberId];
}
}
saveState();
renderControl();
}
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
}
document.addEventListener('fullscreenchange', () => {
const btn = document.getElementById('fullscreen-btn');
if (btn) btn.textContent = document.fullscreenElement ? '✕' : '⛶';
});
function editDefaultTimer() {
const current = state.defaultTimer || 60;
const answer = prompt(
`Standard-Redezeit für alle Mitglieder (M:SS oder Minuten)\nAktuell: ${fmtSecs(current)}`,
fmtSecs(current)
);
if (answer === null) return;
const parts = answer.trim().split(':');
const secs = parts.length === 2
? parseInt(parts[0]) * 60 + parseInt(parts[1])
: parseInt(parts[0]) * 60;
if (!isNaN(secs) && secs > 0) {
state.defaultTimer = secs;
saveState();
renderControl();
}
}
// ============================================================
// QUEUE RESIZE HANDLE
// ============================================================
let _queueResizeRatio = parseFloat(localStorage.getItem('bni-queue-ratio') || '0.6');
function setQueueResizeVisible(show) {
const handle = document.getElementById('queue-resize-handle');
const queueEl = document.getElementById('member-queue');
if (!handle) return;
if (show) {
handle.style.display = 'flex';
applyQueueRatio(_queueResizeRatio);
} else {
handle.style.display = 'none';
queueEl.style.flex = '1';
queueEl.style.height = '';
const absentEl = document.getElementById('absent-members-section');
if (absentEl) absentEl.style.height = '';
}
}
function applyQueueRatio(ratio) {
_queueResizeRatio = Math.max(0.15, Math.min(0.85, ratio));
const panelLeft = document.querySelector('.panel-left');
const header = panelLeft.querySelector('.panel-section-header');
const handle = document.getElementById('queue-resize-handle');
const queueEl = document.getElementById('member-queue');
const absentEl = document.getElementById('absent-members-section');
if (!panelLeft || !queueEl || !absentEl) return;
const available = panelLeft.clientHeight
- (header ? header.offsetHeight : 0)
- (handle ? handle.offsetHeight : 0);
const queueH = Math.round(available * _queueResizeRatio);
const absentH = available - queueH;
queueEl.style.flex = 'none';
queueEl.style.height = queueH + 'px';
absentEl.style.flex = 'none';
absentEl.style.height = absentH + 'px';
}
(function initQueueResize() {
const handle = document.getElementById('queue-resize-handle');
if (!handle) return;
let dragging = false, startY = 0, startQH = 0;
handle.addEventListener('mousedown', e => {
dragging = true;
startY = e.clientY;
startQH = document.getElementById('member-queue').getBoundingClientRect().height;
handle.classList.add('is-dragging');
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', e => {
if (!dragging) return;
const panelLeft = document.querySelector('.panel-left');
const hdr = panelLeft.querySelector('.panel-section-header');
const hdl = document.getElementById('queue-resize-handle');
const available = panelLeft.clientHeight
- (hdr ? hdr.offsetHeight : 0)
- (hdl ? hdl.offsetHeight : 0);
const newQH = startQH + (e.clientY - startY);
applyQueueRatio(newQH / available);
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
handle.classList.remove('is-dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
localStorage.setItem('bni-queue-ratio', _queueResizeRatio);
});
// Touch support
handle.addEventListener('touchstart', e => {
dragging = true;
startY = e.touches[0].clientY;
startQH = document.getElementById('member-queue').getBoundingClientRect().height;
e.preventDefault();
}, { passive: false });
document.addEventListener('touchmove', e => {
if (!dragging) return;
const panelLeft = document.querySelector('.panel-left');
const hdr = panelLeft.querySelector('.panel-section-header');
const hdl = document.getElementById('queue-resize-handle');
const available = panelLeft.clientHeight
- (hdr ? hdr.offsetHeight : 0)
- (hdl ? hdl.offsetHeight : 0);
const newQH = startQH + (e.touches[0].clientY - startY);
applyQueueRatio(newQH / available);
e.preventDefault();
}, { passive: false });
document.addEventListener('touchend', () => {
if (!dragging) return;
dragging = false;
localStorage.setItem('bni-queue-ratio', _queueResizeRatio);
});
})();
// ============================================================
// 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(giteaUrl(`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
: giteaUrl(`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(giteaUrl(`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(giteaUrl('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(giteaUrl(`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(giteaUrl('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(giteaUrl(`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
// ============================================================
initChannel();
if (activeChapter) applyChapterUI(activeChapter.config);
renderCurrentView();
checkLicenseAndUpdates();
// Keyboard shortcut hint
if (getView() === 'control') {
console.log('Tastaturkürzel: Leertaste oder → = Weiter');
}
</script>
</body>
</html>