Checkpoint: Profildaten, Profilbilder und index.html Stand April 2026
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
277
index.html
@@ -1004,6 +1004,59 @@
|
|||||||
}
|
}
|
||||||
.btn-save:hover { background: var(--red-dark); }
|
.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 */
|
||||||
.import-export-panel {
|
.import-export-panel {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -1307,13 +1360,32 @@
|
|||||||
<label>Foto-URL (von BNI-Profil kopieren)</label>
|
<label>Foto-URL (von BNI-Profil kopieren)</label>
|
||||||
<input id="m-photo" type="text" placeholder="https://cdn.bniconnectglobal.com/...">
|
<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?...">
|
<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;">
|
<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);">
|
<img id="photo-preview" src="" alt="" style="width:80px; height:80px; border-radius:50%; object-fit:cover; border:2px solid var(--border);">
|
||||||
</div>
|
</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">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-ghost" onclick="closeMemberModal()">Abbrechen</button>
|
<button class="btn btn-ghost" onclick="closeMemberModal()">Abbrechen</button>
|
||||||
<button class="btn btn-save btn-primary" onclick="saveMember()">Speichern</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-ghost" onclick="closeModal('modal-importexport')">Abbrechen</button>
|
||||||
<button class="btn btn-save btn-primary" onclick="importJSON()">Importieren</button>
|
<button class="btn btn-save btn-primary" onclick="importJSON()">Importieren</button>
|
||||||
</div>
|
</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>
|
||||||
</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> → Rechtsklick → Bild speichern → 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)
|
// BROADCAST CHANNEL (cross-tab sync)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -1889,14 +2131,14 @@ function renderDisplay() {
|
|||||||
qrWrap.style.display = 'block';
|
qrWrap.style.display = 'block';
|
||||||
qrEl.innerHTML = '';
|
qrEl.innerHTML = '';
|
||||||
try {
|
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, {
|
new QRCode(qrEl, {
|
||||||
text: data.profileUrl,
|
text: data.profileUrl,
|
||||||
width: qrSize,
|
width: qrSize,
|
||||||
height: qrSize,
|
height: qrSize,
|
||||||
colorDark: '#1a2744',
|
colorDark: '#1a2744',
|
||||||
colorLight: '#ffffff',
|
colorLight: '#ffffff',
|
||||||
correctLevel: QRCode.CorrectLevel.M,
|
correctLevel: QRCode.CorrectLevel.L,
|
||||||
});
|
});
|
||||||
} catch(e) { qrWrap.style.display = 'none'; }
|
} catch(e) { qrWrap.style.display = 'none'; }
|
||||||
} else {
|
} else {
|
||||||
@@ -2097,13 +2339,15 @@ function getDisplayData(presenter) {
|
|||||||
}
|
}
|
||||||
resolvedSub = { ...sub, photoUrl };
|
resolvedSub = { ...sub, photoUrl };
|
||||||
}
|
}
|
||||||
|
// Merge profile extras (kurzlink + offline photo override)
|
||||||
|
const ex = loadExtras()[extrasKey(presenter.name)] || {};
|
||||||
return {
|
return {
|
||||||
// Member stays in foreground
|
// Member stays in foreground
|
||||||
name: presenter.name,
|
name: presenter.name,
|
||||||
company: presenter.company,
|
company: presenter.company,
|
||||||
specialty: presenter.specialty,
|
specialty: presenter.specialty,
|
||||||
photoUrl: presenter.photoUrl || '',
|
photoUrl: ex.photoB64 || presenter.photoUrl || '',
|
||||||
profileUrl: presenter.profileUrl || '',
|
profileUrl: ex.kurzlink || presenter.profileUrl || '',
|
||||||
// Substitute shown as secondary presenter
|
// Substitute shown as secondary presenter
|
||||||
presentedBy: resolvedSub,
|
presentedBy: resolvedSub,
|
||||||
isVisitor: false,
|
isVisitor: false,
|
||||||
@@ -2572,7 +2816,9 @@ function openAddMember() {
|
|||||||
document.getElementById('m-specialty').value = '';
|
document.getElementById('m-specialty').value = '';
|
||||||
document.getElementById('m-photo').value = '';
|
document.getElementById('m-photo').value = '';
|
||||||
document.getElementById('m-profile').value = '';
|
document.getElementById('m-profile').value = '';
|
||||||
|
document.getElementById('m-kurzlink').value = '';
|
||||||
document.getElementById('photo-preview-wrap').style.display = 'none';
|
document.getElementById('photo-preview-wrap').style.display = 'none';
|
||||||
|
_updateOfflinePhotoUI(null);
|
||||||
openModal('modal-member');
|
openModal('modal-member');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2593,6 +2839,12 @@ function editMember(id) {
|
|||||||
} else {
|
} else {
|
||||||
document.getElementById('photo-preview-wrap').style.display = 'none';
|
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');
|
openModal('modal-member');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2614,6 +2866,7 @@ function saveMember() {
|
|||||||
const specialty = document.getElementById('m-specialty').value.trim();
|
const specialty = document.getElementById('m-specialty').value.trim();
|
||||||
const photoUrl = document.getElementById('m-photo').value.trim();
|
const photoUrl = document.getElementById('m-photo').value.trim();
|
||||||
const profileUrl = document.getElementById('m-profile').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; }
|
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();
|
saveState();
|
||||||
closeMemberModal();
|
closeMemberModal();
|
||||||
renderControl();
|
renderControl();
|
||||||
@@ -2651,6 +2916,8 @@ function deleteMember(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeMemberModal() {
|
function closeMemberModal() {
|
||||||
|
const hint = document.getElementById('offline-photo-url-hint');
|
||||||
|
if (hint) hint.remove();
|
||||||
closeModal('modal-member');
|
closeModal('modal-member');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
4
profildaten.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"extras": {}
|
||||||
|
}
|
||||||
BIN
profile-image-type-person-1367611.JPG
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
profile-image-type-person-1368964.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
profile-image-type-person-1612262.jpeg
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
profile-image-type-person-1861412.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
profile-image-type-person-1889624.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
profile-image-type-person-2132885.jpg
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
profile-image-type-person-2155257.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
profile-image-type-person-2249834.jpg
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
profile-image-type-person-2290957.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
profile-image-type-person-2326664.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
profile-image-type-person-317636.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |