Preview uploaded documents in an in-page modal instead of a new tab

Chrome blocks top-level navigation to data: URLs (anti-phishing), so
the earlier target="_blank" preview links silently opened a blank tab
for every uploaded document — real bug, caught by live testing.
Replaces that with a same-tab modal: clicking a document name opens
it in an overlay (<iframe> for PDFs, <img> for images — data: URLs
work fine for embedding, just not top-level navigation), with its own
download button and a close control. The small ⬇ icon next to each
chip still downloads directly, unaffected since the `download`
attribute uses a different code path than navigation.
This commit is contained in:
Giuseppe Lombardo 2026-08-13 12:18:17 +00:00
parent 92101e3077
commit dd24d7217a

View File

@ -360,6 +360,41 @@
.contract-template-form .field-checkbox input { width: auto; }
.signature-pad-wrap { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
.signature-pad-canvas { border: 1px solid var(--border); border-radius: 8px; background: #fff; touch-action: none; cursor: crosshair; }
/* --- Dokument-Vorschau (Modal) --- */
.doc-preview-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.doc-preview-box {
background: var(--card-bg);
border-radius: 14px;
box-shadow: var(--shadow);
width: 100%;
max-width: 720px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.doc-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
gap: 12px;
}
.doc-preview-header strong { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc-preview-body { flex: 1; overflow: auto; background: var(--bg); display: flex; align-items: center; justify-content: center; min-height: 300px; }
.doc-preview-body img { max-width: 100%; display: block; }
.doc-preview-body iframe { width: 100%; height: 75vh; border: none; }
#trashCalendarEl { font-size: 13px; }
#trashCalendarEl .fc { font-family: inherit; }
#trashCalendarEl .fc-toolbar-title { font-size: 16px; font-weight: 700; color: var(--text); }
@ -1032,6 +1067,20 @@
</div>
</div>
<!-- ================= DOKUMENT-VORSCHAU (Modal) ================= -->
<div id="docPreviewOverlay" class="doc-preview-overlay" style="display:none" onclick="if (event.target === this) closeDocumentPreview()">
<div class="doc-preview-box">
<div class="doc-preview-header">
<strong id="docPreviewTitle"></strong>
<div style="display:flex; gap:10px; align-items:center;">
<a id="docPreviewDownload" class="btn-secondary" download>Herunterladen</a>
<button type="button" class="btn-secondary" onclick="closeDocumentPreview()">Schließen ✕</button>
</div>
</div>
<div class="doc-preview-body" id="docPreviewBody"></div>
</div>
</div>
<script>
const API_BASE_URL = window.WG_API_BASE_URL ||
(location.protocol === 'file:' ? 'http://localhost:3000/v1' : '/v1');
@ -2854,25 +2903,30 @@
return `Dokument ${index + 1}.${ext}`;
}
// Klick auf den Namen öffnet eine Vorschau im neuen Tab (Browser können
// PDFs/Bilder nativ anzeigen); der Pfeil daneben lädt bei Bedarf separat
// herunter. Für alle hochgeladenen Dokumente (Verträge, Ausweis, Schufa) einheitlich.
function docFileExt(url) {
return url.includes('application/pdf') ? 'pdf' : (/^data:image\/(\w+)/.exec(url)?.[1] || 'jpg');
}
// Klick auf den Namen öffnet eine kleine Vorschau im selben Tab (Modal) —
// ein Link mit target="_blank" auf eine data:-URL würde Chrome als
// Top-Level-Navigation blockieren (Phishing-Schutz), als eingebettetes
// <img>/<iframe> im Modal ist das kein Problem. Der Pfeil daneben lädt bei
// Bedarf separat herunter. Für alle hochgeladenen Dokumente (Verträge,
// Ausweis, Schufa) einheitlich.
function buildDocumentChip(label, url) {
const chip = el('span', 'invite-status-chip');
chip.style.cssText = 'background:var(--bg); color:var(--text); text-transform:none; display:inline-flex; align-items:center; gap:7px;';
const previewLink = document.createElement('a');
previewLink.href = url;
previewLink.target = '_blank';
previewLink.rel = 'noopener';
previewLink.textContent = label;
previewLink.style.color = 'var(--text)';
chip.appendChild(previewLink);
const previewBtn = document.createElement('button');
previewBtn.type = 'button';
previewBtn.textContent = label;
previewBtn.style.cssText = 'background:none; border:none; padding:0; margin:0; font:inherit; color:var(--text); text-decoration:underline; cursor:pointer;';
previewBtn.onclick = () => openDocumentPreview(label, url);
chip.appendChild(previewBtn);
const ext = url.includes('application/pdf') ? 'pdf' : (/^data:image\/(\w+)/.exec(url)?.[1] || 'jpg');
const downloadLink = document.createElement('a');
downloadLink.href = url;
downloadLink.download = `${label.replace(/[^\w.-]+/g, '_')}.${ext}`;
downloadLink.download = `${label.replace(/[^\w.-]+/g, '_')}.${docFileExt(url)}`;
downloadLink.title = 'Herunterladen';
downloadLink.textContent = '⬇';
downloadLink.style.cssText = 'color:var(--text-muted); text-decoration:none; font-size:12px;';
@ -2881,6 +2935,31 @@
return chip;
}
function openDocumentPreview(label, url) {
document.getElementById('docPreviewTitle').textContent = label;
const body = document.getElementById('docPreviewBody');
body.innerHTML = '';
if (url.startsWith('data:application/pdf')) {
const iframe = document.createElement('iframe');
iframe.src = url;
body.appendChild(iframe);
} else {
const img = document.createElement('img');
img.src = url;
img.alt = label;
body.appendChild(img);
}
const downloadLink = document.getElementById('docPreviewDownload');
downloadLink.href = url;
downloadLink.download = `${label.replace(/[^\w.-]+/g, '_')}.${docFileExt(url)}`;
document.getElementById('docPreviewOverlay').style.display = 'flex';
}
function closeDocumentPreview() {
document.getElementById('docPreviewOverlay').style.display = 'none';
document.getElementById('docPreviewBody').innerHTML = '';
}
function buildProfileCompletenessBadge(user) {
const missing = [];
if (!user.phoneNumber) missing.push('Telefonnummer');