PWA: Weitere Dokumente anzeigen + öffnen (PDF-Viewer, Download) [deploy]
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 6s

- otherDocs-Sektion mit gestylten doc-items (Icon, Name, Größe, Datum)
- getFileBlobUrl() in api.js ohne Mime-Filter
- PDF/Bilder: Fullscreen-Modal mit iframe/img + Download-Button
- Andere Dateitypen: direkter Download
- CSS analog zu .audio-item

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-17 12:49:15 +02:00
parent 46f680039d
commit a538dbb744
3 changed files with 153 additions and 2 deletions

52
app.css
View file

@ -553,6 +553,58 @@ body {
.btn[disabled] { opacity: 0.5; cursor: not-allowed; } .btn[disabled] { opacity: 0.5; cursor: not-allowed; }
/* Weitere Dokumente (Auftrags-Detail) */
.doc-list { display: flex; flex-direction: column; gap: 8px; }
.doc-item {
background: #2a2a30;
border-radius: 6px;
padding: 10px 12px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
transition: background 0.15s;
}
.doc-item:hover { background: #33333a; }
.doc-item.loading { opacity: 0.6; pointer-events: none; }
.doc-item .doc-icon { font-size: 22px; flex-shrink: 0; }
.doc-item .doc-meta { flex: 1; min-width: 0; }
.doc-item .doc-name {
font-size: 14px;
color: #e0e0e0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-item .doc-sub { font-size: 11px; color: #888; margin-top: 2px; }
.doc-item .doc-open {
background: #337ab7;
color: #fff;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
font-size: 14px;
cursor: pointer;
flex-shrink: 0;
}
/* Doc-Viewer (PDF/Image inline) */
.doc-viewer-modal .dv-body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #1a1a1f;
overflow: hidden;
}
.doc-viewer-modal .fs-header a.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
/* PIN-Modal */ /* PIN-Modal */
.pin-modal .pin-body { .pin-modal .pin-body {
flex: 1; flex: 1;

86
app.js
View file

@ -431,7 +431,16 @@ router.on('/orders/:id', async (args) => {
${otherDocs.length ? ` ${otherDocs.length ? `
<div class="detail-section"> <div class="detail-section">
<h3>Weitere Dokumente (${otherDocs.length})</h3> <h3>Weitere Dokumente (${otherDocs.length})</h3>
${otherDocs.map(p => `<p>📄 ${escapeHtml(p.filename)}</p>`).join('')} <div class="doc-list">
${otherDocs.map(p => `<div class="doc-item" data-relpath="${escapeHtml(p.relpath)}" data-mime="${escapeHtml(p.mime || '')}" data-filename="${escapeHtml(p.filename)}">
<span class="doc-icon">${docIconFor(p.filename, p.mime)}</span>
<div class="doc-meta">
<div class="doc-name">${escapeHtml(p.filename)}</div>
<div class="doc-sub">${formatFileSize(p.size)}${p.date ? ' · ' + formatShortDate(p.date) : ''}</div>
</div>
<button class="doc-open" title="Öffnen"></button>
</div>`).join('')}
</div>
</div>` : ''} </div>` : ''}
`; `;
@ -440,6 +449,19 @@ router.on('/orders/:id', async (args) => {
t.addEventListener('click', () => openPhotoModal(args.id, t.dataset.relpath)); t.addEventListener('click', () => openPhotoModal(args.id, t.dataset.relpath));
}); });
// Weitere Dokumente: Tap = Öffnen (PDF inline, sonst Download)
document.querySelectorAll('.doc-list .doc-item').forEach(el => {
el.addEventListener('click', async () => {
const rel = el.dataset.relpath;
const filename = el.dataset.filename;
el.classList.add('loading');
const res = await api.getFileBlobUrl(rel);
el.classList.remove('loading');
if (!res) { showToast('Datei konnte nicht geladen werden', 'error'); return; }
openFileViewer(res, filename);
});
});
// Sprachnotiz // Sprachnotiz
document.getElementById('btn-voice').onclick = () => openVoiceModal(args.id); document.getElementById('btn-voice').onclick = () => openVoiceModal(args.id);
@ -1746,6 +1768,68 @@ function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
} }
function docIconFor(filename, mime) {
const ext = (filename || '').split('.').pop().toLowerCase();
if ((mime || '').includes('pdf') || ext === 'pdf') return '📕';
if (['doc','docx','odt','rtf'].includes(ext)) return '📝';
if (['xls','xlsx','ods','csv'].includes(ext)) return '📊';
if (['ppt','pptx','odp'].includes(ext)) return '📽';
if (['zip','rar','7z','tar','gz'].includes(ext)) return '🗜';
if (['txt','log','md'].includes(ext)) return '📃';
return '📄';
}
function formatFileSize(bytes) {
const n = Number(bytes) || 0;
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
return (n / 1024 / 1024).toFixed(1) + ' MB';
}
function formatShortDate(ts) {
const d = new Date(Number(ts) * 1000);
if (isNaN(d.getTime())) return '';
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
}
function openFileViewer({ url, blob, mime }, filename) {
const isPdf = (mime || '').includes('pdf') || /\.pdf$/i.test(filename);
const isImage = (mime || '').startsWith('image/');
if (!isPdf && !isImage) {
// Nicht inline anzeigbar → Download anstoßen
const a = document.createElement('a');
a.href = url;
a.download = filename || 'download';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10000);
return;
}
const modal = document.createElement('div');
modal.className = 'fullscreen-modal doc-viewer-modal';
modal.innerHTML = `
<div class="fs-header">
<button class="icon-btn" id="dv-close"></button>
<div class="fs-title">${escapeHtml(filename || '')}</div>
<a class="icon-btn" id="dv-download" title="Herunterladen" download="${escapeHtml(filename || 'download')}" href="${url}"></a>
</div>
<div class="dv-body">
${isPdf
? `<iframe src="${url}" style="width:100%;height:100%;border:0;background:#fff;"></iframe>`
: `<img src="${url}" style="max-width:100%;max-height:100%;object-fit:contain;">`}
</div>
`;
document.body.appendChild(modal);
const cleanup = () => {
URL.revokeObjectURL(url);
modal.remove();
};
modal.querySelector('#dv-close').onclick = cleanup;
}
/* ============================================================ /* ============================================================
* PHOTO VOLLBILD MODAL MIT ZOOM + SWIPE + SKIZZEN-EDITOR * PHOTO VOLLBILD MODAL MIT ZOOM + SWIPE + SKIZZEN-EDITOR
* ============================================================ */ * ============================================================ */

View file

@ -247,6 +247,21 @@
blobUrlCache.clear(); blobUrlCache.clear();
} }
// Lädt eine beliebige Datei (PDF, DOCX, ...) als Blob-URL inkl. JWT.
// Ohne Mime-Filter — Aufrufer entscheidet selbst, was damit passiert.
async function getFileBlobUrl(relpath) {
const t = await getToken();
if (!t) return null;
const params = new URLSearchParams({ relpath, jwt: t });
const r = await fetch(API_BASE + '/photo.php?' + params.toString());
if (!r.ok) {
console.warn('getFileBlobUrl failed', r.status, relpath);
return null;
}
const blob = await r.blob();
return { url: URL.createObjectURL(blob), blob, mime: r.headers.get('Content-Type') || '' };
}
// Low-level request-Funktion auch exposen für Spezialfälle // Low-level request-Funktion auch exposen für Spezialfälle
window.api = { window.api = {
request, request,
@ -257,7 +272,7 @@
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport, deleteReport, getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport, deleteReport,
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto, deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,
listMaterials, addMaterial, deleteMaterial, listMaterials, addMaterial, deleteMaterial,
getPhotoBlobUrl, clearPhotoCache, getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl,
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages, deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
}; };
})(); })();