Checkpoint: Profildaten, Profilbilder und index.html Stand April 2026

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Heubaum
2026-04-17 13:12:53 +02:00
parent ab0397dbe4
commit 85dfc53b13
13 changed files with 276 additions and 5 deletions

View File

@@ -1004,6 +1004,59 @@
}
.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;
@@ -1307,13 +1360,32 @@
<label>Foto-URL (von BNI-Profil kopieren)</label>
<input id="m-photo" type="text" placeholder="https://cdn.bniconnectglobal.com/...">
<label>Profil-URL (für QR-Code)</label>
<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>
@@ -1337,6 +1409,21 @@
<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>
@@ -1595,6 +1682,161 @@ function saveState(broadcast = true) {
}
}
// ============================================================
// PROFILE EXTRAS (Kurzlinks + Offline-Fotos, persistent)
// ============================================================
const EXTRAS_KEY = '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)
// ============================================================
@@ -1889,14 +2131,14 @@ function renderDisplay() {
qrWrap.style.display = 'block';
qrEl.innerHTML = '';
try {
const qrSize = Math.round(Math.min(window.innerHeight * 0.16, window.innerWidth * 0.09, 200));
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.M,
correctLevel: QRCode.CorrectLevel.L,
});
} catch(e) { qrWrap.style.display = 'none'; }
} else {
@@ -2097,13 +2339,15 @@ function getDisplayData(presenter) {
}
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: presenter.photoUrl || '',
profileUrl: presenter.profileUrl || '',
photoUrl: ex.photoB64 || presenter.photoUrl || '',
profileUrl: ex.kurzlink || presenter.profileUrl || '',
// Substitute shown as secondary presenter
presentedBy: resolvedSub,
isVisitor: false,
@@ -2572,7 +2816,9 @@ function openAddMember() {
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');
}
@@ -2593,6 +2839,12 @@ function editMember(id) {
} 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');
}
@@ -2614,6 +2866,7 @@ function saveMember() {
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; }
@@ -2631,6 +2884,18 @@ function saveMember() {
}
}
// 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();
@@ -2651,6 +2916,8 @@ function deleteMember(id) {
}
function closeMemberModal() {
const hint = document.getElementById('offline-photo-url-hint');
if (hint) hint.remove();
closeModal('modal-member');
}

4
profildaten.json Normal file
View File

@@ -0,0 +1,4 @@
{
"version": 1,
"extras": {}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB