From 4a53ab0ebe568d0348da255f1a1c7263045b6a29 Mon Sep 17 00:00:00 2001 From: Eddy Date: Sat, 22 Aug 2026 18:12:09 +0200 Subject: [PATCH] Textnotizen, echte Thumbnails, eigene Dialoge, Bild-Cache Textnotizen: neuer Punkt "Notizen (n)" am Auftrag, direkt unter der Sprachnotiz. Liste mit Betreff, Vorschau und Zeitstempel; antippen oeffnet die Notiz zum Lesen und Aendern, loeschen mit Rueckfrage. Gespeichert wird ueber api/note.php als notiz__.txt im Auftragsordner - derselbe Weg wie bei der Sprachnotiz, damit die Notiz auch im Dolibarr-Auftrag und in der Anhaenge-Spalte des Bericht-Editors auftaucht. In "Weitere Dokumente" sind Notizen ausgefiltert, sie haetten dort doppelt gestanden. Fotokacheln laden jetzt serverseitige Thumbnails (photo.php?size=thumb) statt der Originale: rund 10 KB statt mehrerer hundert KB je Kachel. Das haengt direkt an der URL statt an einer Blob-URL - damit greifen HTTP-Cache und loading="lazy" tatsaechlich und der Browser laedt parallel. Vorher lief eine sequenzielle Schleife, die jedes Foto komplett holte, bevor das naechste anfing, und im Fehlerfall ein zweites Mal. Bericht-Seiten zeigen composite_path, wenn vorhanden - also das im Editor gebaute Seitenbild mit Anmerkungen statt der Rohdatei. Raster-Layouts bekommen ein Kennzeichen ("4"); Seiten ohne Bildquelle zeigen keine Fehlerkachel mehr. confirm/alert/prompt sind an allen zehn Stellen durch eigene Modale ersetzt (confirmDialog/alertDialog/inputDialog). Browser-Dialoge zeigen in der installierten PWA die Herkunfts-URL und wirken wie ein Systemfehler. Der Hardware-Zurueck-Button schliesst sie als Abbruch. Service Worker: eigener Cache baustelle-media (cache-first, LRU 300) fuer Thumbnails. Vorher war ohne Netz kein einziges schon angesehenes Foto verfuegbar. Der Cache ueberlebt Deploys bewusst und wird beim Abmelden geleert (CLEAR_MEDIA), damit keine Kundenfotos auf dem Geraet zurueckbleiben. Nebenbei behoben: - .modal-header, .close-btn und .modal-body hatten ueberhaupt kein CSS, obwohl das Markup sie nutzt (u. a. beim Loeschen eines Berichts) - der Schliessen-Knopf sass unter der Ueberschrift statt daneben. - Das Laden eines Thumbnails ueberschrieb den ganzen Kachel-Inhalt und nahm die Overlays mit: Auswahl-Haekchen und Seitennummer verschwanden, sobald das Bild da war. - manifest.webmanifest: share_target.action war eine absolute URL auf den Prod-Host und lag damit ausserhalb des Scopes - der Browser verwarf das share_target komplett. Jetzt relativ ("share.html"), funktioniert lokal und auf Prod. ROADMAP.md neu angelegt. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 30 +++- ROADMAP.md | 149 ++++++++++++++++ app.css | 189 ++++++++++++++++++++ app.js | 403 +++++++++++++++++++++++++++++++++++++++---- lib/api.js | 43 ++++- manifest.webmanifest | 2 +- sw.js | 53 +++++- 7 files changed, 825 insertions(+), 44 deletions(-) create mode 100644 ROADMAP.md diff --git a/README.md b/README.md index 7fd23cf..8fb8c67 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Baustelle PWA -Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprachnotizen, Skizzen, offline-fähig. Spricht die REST-API des Dolibarr-Bericht-Moduls. +Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprach- und Textnotizen, Skizzen, offline-fähig. Spricht die REST-API des Dolibarr-Bericht-Moduls. ## Stack @@ -25,15 +25,22 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprachnotiz - ✅ **Persist-First / Datenverlust-Schutz**: jedes Foto wird beim Auslösen ZUERST in IndexedDB gesichert, Upload erst danach; Queue-Item wird nur nach bestätigtem Upload (HTTP 2xx mit gültigem `relpath`) gelöscht — überlebt fehlendes/schwaches Netz, hängende Uploads (45s-Timeout) und App-Kill. Erkennt 2xx-HTML (abgelaufene Session/Proxy-Loginseite) als Fehler statt als Erfolg - ✅ Auto-Sync bei "online", periodisch (15s) und bei App-Fokus; Status-Badge (🟢 alles gesichert / 🟡 lädt hoch / 🔴 offline / ⚠️ fehlgeschlagen) - ✅ Recovery: Tipp auf das Status-Badge öffnet die Warteschlange → erneut senden / teilen; Warnung beim Schließen mit noch ungesicherten Fotos +- ✅ Kacheln laden **serverseitige Thumbnails** (`photo.php?size=thumb`) statt der Originale — rund 10 KB statt mehrerer hundert KB je Kachel, parallel und mit `loading="lazy"`, Wiederholaufrufe enden mit 304 - ✅ Foto-Viewer mit Zoom + Swipe - ✅ Foto-Skizze: Annotationen mit Pfeilen, Kreisen, Rechtecken, Text +**Notizen** +- ✅ Getippte Notiz zum Auftrag: Betreff + Text, Liste mit Vorschau und Zeitstempel, jederzeit wieder les- und änderbar +- ✅ Ablage als `notiz__.txt` im Auftragsordner — damit auch im Dolibarr-Auftrag und in der Anhänge-Spalte des Bericht-Editors sichtbar +- ✅ Löschen mit Rückfrage; in „Weitere Dokumente" tauchen Notizen nicht doppelt auf + **Sprachnotizen** - ✅ Sprachaufnahme via MediaRecorder (webm) - ✅ Whisper-Transkription (serverseitig, API-Aufruf) **Berichte** - ✅ Berichte pro Auftrag anzeigen +- ✅ Seitenkacheln zeigen das im Editor gebaute Seitenbild (`composite_path`) samt Anmerkungen — nicht mehr nur das Rohbild; Raster-Layouts sind mit „⊞ 4" gekennzeichnet - ✅ Bericht erstellen mit Template-Auswahl - ✅ Seiten umordnen per Drag&Drop - ✅ Seite löschen, Notiz bearbeiten @@ -65,6 +72,8 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprachnotiz - ✅ Android-Hardware-Back-Button schließt Modale/Ansichten sauber (History-Modal-Stack), bricht Mehrfachauswahl ab statt App zu verlassen - ✅ „Nochmal drücken zum Beenden"-Toast auf Top-Level-Routen - ✅ PIN-Schutz (optional) +- ✅ **Bild-Cache** `baustelle-media` (cache-first, LRU 300): schon angesehene Fotos sind auch ohne Netz da; überlebt Deploys, wird beim Abmelden geleert +- ✅ **Keine Browser-Dialoge** — `confirm`/`alert`/`prompt` sind durch eigene Modale ersetzt (`confirmDialog`, `alertDialog`, `inputDialog`); der Hardware-Zurück-Button schließt sie als Abbruch ## Geplant @@ -81,8 +90,9 @@ baustelle-pwa/ ├── index.php App-Shell (Cache-Busting via filemtime → ?v=) ├── share.html Web-Share-Target-Empfänger ├── manifest.webmanifest PWA Manifest -├── sw.js Service Worker (Network-First, API pass-through) -├── app.js Hauptlogik (Routes, Views) +├── sw.js Service Worker (Network-First für Assets, API pass-through, +│ cache-first für photo.php?size=thumb → Cache baustelle-media) +├── app.js Hauptlogik (Routes, Views, Dialoge, Notizen) ├── app.css Mobile-first Styling ├── lib/ │ ├── idb.js IndexedDB-Wrapper (Foto-Queue) @@ -106,7 +116,7 @@ Die App spricht die Bericht-API unter `/custom/bericht/api/`: **Aufträge** - `GET /orders.php` — Aufträge des Users (Filter: `?open=1`, `?q=suche`) - `GET /orders.php?id=X` — Auftrag-Detail inkl. verknüpfte Berichte -- `GET /orders.php?id=X&action=photos` — Anhang-Liste +- `GET /orders.php?id=X&action=photos` — Anhang-Liste (je Datei `taken_at` = EXIF-Aufnahmezeit, danach sortiert) - `POST /orders.php?id=X&action=upload_photo` — Foto-Upload (multipart) **Kunden** @@ -115,7 +125,7 @@ Die App spricht die Bericht-API unter `/custom/bericht/api/`: **Berichte** - `GET /reports.php` — Alle Berichte des Users -- `GET /reports.php?id=X` — Bericht-Detail mit Seiten +- `GET /reports.php?id=X` — Bericht-Detail mit Seiten (je Seite `composite_path`, `title`, `layout` — `composite_path` anzeigen, wenn gesetzt) - `POST /reports.php?action=create` — Neuen Bericht anlegen - `POST /reports.php?id=X&action=finalize` — Bericht finalisieren - `DELETE /reports.php?id=X` — Bericht löschen @@ -137,8 +147,16 @@ Die App spricht die Bericht-API unter `/custom/bericht/api/`: - `GET /shipments.php?id=X&action=pdf[&variant=auto|signed|unsigned]` — Lieferschein-PDF - `POST /shipments.php?id=X&action=confirm` — Kunden-Unterschrift einstempeln (FormData mit signature_png, signer_name, gps_lat, gps_lon, signed_at). Backend setzt signed_status=1 und schließt die Expedition +**Notizen** +- `GET /note.php?order_id=X` — Liste (Betreff, Vorschau, Zeitstempel) +- `GET /note.php?order_id=X&file=` — eine Notiz mit vollem Text +- `POST /note.php?order_id=X` — anlegen `{ subject, text }` +- `POST /note.php?order_id=X&file=` — ändern +- `POST /note.php?order_id=X&file=&delete=1` — löschen + **Medien** -- `GET /photo.php?relpath=X` — Foto abrufen (mit Thumbnail: `&size=thumb`); Auth über `awl_sso`-Cookie (same-origin, auch für ``-Tags — kein `?jwt=` mehr nötig) +- `GET /photo.php?relpath=X` — Datei abrufen; Auth über `awl_sso`-Cookie (same-origin, auch für ``-Tags — kein `?jwt=` mehr nötig) +- `GET /photo.php?relpath=X&size=thumb&w=` — serverseitiges Thumbnail (GD, gecacht, ETag → 304). **Der Weg für Kachel-Ansichten.** `size=small` ist etwas anderes: das liefert nur ein von Dolibarr vorgefertigtes Thumb, das es für PWA-Uploads gar nicht gibt — dort kam bis Modul-Version 1.5.1 immer das Original zurück - `POST /delete_photo.php` — Foto löschen - `POST /voice.php?order_id=X` — Sprachnotiz hochladen - `POST /transcribe.php` — Whisper-Transkription anfordern diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..e8a4db3 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,149 @@ +# ROADMAP — Baustelle PWA + +Stand: 2026-08-22 · Gegenstück: `data/bericht` (Modul-Version 1.5.1) + +Die PWA hat kein eigenes Backend — alles läuft über `custom/bericht/api/`. Punkte, die eine +API-Änderung brauchen, sind mit **[API]** markiert und stehen zusätzlich in der ROADMAP des +Bericht-Moduls. + +--- + +## 1. Nachgezogen aus Bericht 1.5.0 ✅ (2026-08-22, Modul 1.5.1) + +Das Modul hatte mit 1.5.0 Infrastruktur bekommen, von der die PWA nichts hatte. +Die Beschreibungen unten schildern jeweils den Zustand davor. + +### 1.1 Echte Thumbnails statt Vollbilder ✅ [API] + +Bis dahin lud die PWA für **jede Kachel das komplette Foto**: + +- `loadThumbs()` ruft `api.getPhotoBlobUrl(rel, 'small')` auf → `photo.php?size=small` +- `photo.php` liefert nur dann ein Thumbnail, wenn Dolibarr eines unter + `thumbs/_small.` **vorgefertigt** hat +- Die PWA lädt ihre Fotos aber per `orders.php?action=upload_photo` hoch, und dort wird + **nie `vignette()` aufgerufen** → es existiert nie ein Thumb → es kommt immer das Original +- Bei 30 Baustellenfotos sind das im Mobilfunknetz 30 × 0,5–1 MB (bei über die + Dolibarr-Oberfläche hochgeladenen Originalen entsprechend mehr) + +Das Modul kann das seit 1.5.0 längst: `bericht_attachment_thumb($full, $maxside)` in +`lib/bericht.lib.php` (GD, Cache unter `bericht/thumbs/`, EXIF-Rotation, Speicher-Notbremse). +`api/photo.php` lädt diese lib bereits. + +- [x] **[API]** `photo.php` um `size=thumb&w=` erweitern → `bericht_attachment_thumb()`, + mit ETag aus mtime und `Cache-Control: private, max-age=…, immutable` +- [x] `loadThumbs()` umbauen: `` **direkt** setzen statt + fetch → Blob → ObjectURL. Damit greifen HTTP-Cache und `loading="lazy"` wirklich; heute + sind beide wirkungslos, weil der Blob schon vorher komplett geladen wird. +- [x] Sequenzielle Schleife auflösen — `for (const t of thumbs) { await … }` lädt Foto für Foto + nacheinander. Beim ``-Weg erledigt der Browser das parallel von selbst. +- [x] Doppel-Ladung entfernen: schlägt `size=small` fehl, wird das Original **noch einmal** + geladen. + +### 1.2 Aufnahmezeit statt Dateidatum ✅ [API] + +1.5.0 hat einen eigenen EXIF-Parser bekommen (`bericht_jpeg_taken_at()` / +`bericht_file_taken_at()`), weil im Prod-Container das `exif`-Modul fehlt. Der Editor sortiert +Anhänge damit nach Aufnahmezeit. `orders.php?action=photos` lieferte nur `filemtime`. + +- [x] **[API]** `taken_at` je Datei ausgeben +- [x] Fotoliste nach Aufnahmezeit sortieren (Auswahl gemerkt, wie im Editor) + +### 1.3 Seiten-Titel kommt nie an ✅ [API] + +`api/pages.php` **kann** `title` einer Seite setzen, die PWA rendert ihn (`page-title-badge`, +`openPageActionsModal(…, title)`) — aber `api/reports.php` gibt `title` im `pages`-Array +**nicht aus**. Ein im Editor gesetzter Titel ist in der PWA also unsichtbar, und ein in der PWA +gesetzter Titel scheint nach dem Neuladen verschwunden. + +- [x] **[API]** `'title' => $p->title` in `reports.php` ergänzen (eine Zeile) + +### 1.4 Bearbeitete Seiten und Raster-Layouts ✅ [API] + +Seit 1.5.0 hat eine Seite `composite_path` (das im Editor gebaute Seitenbild) und `layout` +(`single`, `grid_2`, `grid_4`, `before_after`, `title_only`, …). Die PWA zeigt stur +`source_path`: + +- Nach Bearbeitung im Desktop-Editor zeigt die PWA weiter das **Rohbild** ohne Anmerkungen +- Bei einer Rasterseite ist `source_path` nur **eines von bis zu sechs** Bildern +- Bei `title_only` gibt es gar keine Quelle → ❌-Kachel + +- [x] **[API]** `composite_path` in `reports.php` mit ausgeben +- [x] PWA zeigt `composite_path`, wenn vorhanden, sonst `source_path` +- [x] Layout-Kennzeichen an der Kachel (z. B. „⊞ 4er-Raster"), damit klar ist, warum dort + mehr drauf ist als das eine Foto + +--- + +## 2. Verbesserungen unabhängig vom Modul + +### 2.1 Browser-Dialoge raus (10 Stück) ✅ + +`confirm()` / `alert()` / `prompt()` widersprechen der Hausregel und sehen in der +Standalone-PWA auf Android wie ein Systemfehler aus. Ein sauberes Muster liegt schon vor: +das „Bericht löschen"-Modal in `router.on('/reports/:id')`. + +| Stelle | Zeile (vor dem Umbau) | +|---|---| +| PIN stimmt nicht überein | `alert` 295 | +| Bericht finalisieren | `confirm` 1111 | +| PIN-Schutz deaktivieren | `confirm` 1264 | +| Material-Eintrag löschen | `confirm` 1432 | +| Seite aus Bericht entfernen | `confirm` 1809 | +| Foto löschen | `confirm` 2701 | +| Nicht hochgeladenes Foto löschen | `confirm` 3019 | +| Mess-Skala: Länge eingeben | `prompt` 3409 | +| Ungültiges Längenformat | `alert` 3419 | +| „Zuerst kalibrieren" | `alert` 3440 | + +- [x] Eine `confirmDialog({title, text, danger})`-Hilfsfunktion (Promise) + `inputDialog()` + für die Längeneingabe, alle zehn Stellen darauf umstellen + +### 2.2 Fotos offline sichtbar halten ✅ + +Der Service Worker reichte alles unter `/custom/bericht/api/` ungecacht durch. Auf der Baustelle +ohne Netz ist damit **kein einziges** schon angesehenes Foto verfügbar. + +- [x] Eigener Runtime-Cache `baustelle-media` für `photo.php`-GETs, cache-first mit + Größenbegrenzung (LRU, z. B. 200 Einträge). Die Thumb-URLs aus 1.1 sind dafür der + richtige Kandidat — klein und unveränderlich. +- [x] Vom App-Shell-Cache getrennt halten, damit ein Deploy die Medien nicht wegräumt + +--- + +## 3. Textnotizen ✅ (2026-08-22) + +Getippte Notiz zum Auftrag — für alles, was man lieber schreibt als diktiert: Zählernummern, +Absprachen, Maße. + +- [x] „📝 Notizen (n)" am Auftrag, direkt unter der Sprachnotiz +- [x] Liste mit Betreff, zweizeiliger Vorschau und Zeitstempel; Antippen öffnet zum Lesen + und Ändern, 🗑 löscht (mit Rückfrage) +- [x] Editor: Betreff-Feld + Textfeld über die volle Höhe, Speichern über ✓ in der Kopfzeile, + Rückfrage beim Verlassen mit ungespeicherten Änderungen +- [x] Ablage als `notiz__.txt` im Auftragsordner (`api/note.php`) — genau der + Weg der Sprachnotiz. Damit auch im Dolibarr-Auftrag und in der Anhänge-Spalte des + Bericht-Editors sichtbar. +- [x] Notizen sind aus „Weitere Dokumente" ausgefiltert — sie haben ihre eigene Sektion und + stünden dort sonst doppelt + +**Nicht** umgesetzt (und bewusst verworfen): eine Zeichenfläche für echte Handschrift. Am +Handy ohne Stift ist das unbrauchbar. + +## Erledigt + +### 2026-08-22 +Abschnitte 1, 2 und 3 vollständig. Dazu nebenbei behoben: + +- `.modal-header`, `.close-btn` und `.modal-body` hatten **überhaupt kein CSS**, obwohl das + Markup sie seit Längerem nutzt (u. a. beim Löschen eines Berichts) — der Schließen-Knopf + saß dadurch unter der Überschrift statt daneben. +- Das Laden eines Thumbnails überschrieb den kompletten Kachel-Inhalt (`t.innerHTML = …`) + und nahm die Overlays mit: Auswahl-Häkchen in der Mehrfachauswahl und Seitennummer der + Bericht-Seiten waren weg, sobald das Bild da war. +- Beim Abmelden bleiben keine Kundenfotos mehr auf dem Gerät (`CLEAR_MEDIA` an den Service + Worker + `clearPhotoCache()`). + +### Davor +Siehe README (Feature-Liste) und die Commit-Historie: SSO-Migration auf awlauth, +WebAuthn-Login, Neuer-Auftrag-Formular, Live-Kamera mit Persist-First-Queue, +Lieferschein-Unterschrift. diff --git a/app.css b/app.css index 62e7498..952a458 100644 --- a/app.css +++ b/app.css @@ -1279,3 +1279,192 @@ body { } .cam-shutter:active { transform: scale(0.92); } .cam-shutter.flash { background: #ffd34d; } + +/* ============================================================ + Eigene Dialoge (Ersatz für confirm/alert/prompt) + ============================================================ */ +.dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.65); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + padding-bottom: calc(20px + env(safe-area-inset-bottom)); +} +.dialog-box { + width: 100%; + max-width: 420px; + background: #25252b; + border: 1px solid #3a3a42; + border-radius: 14px; + padding: 20px; + box-shadow: 0 12px 40px rgba(0,0,0,0.5); +} +.dialog-title { + font-size: 17px; + font-weight: 600; + margin-bottom: 8px; +} +.dialog-text { + font-size: 15px; + line-height: 1.45; + opacity: 0.85; +} +.dialog-input { + width: 100%; + margin-top: 14px; + padding: 12px; + font-size: 16px; /* < 16px lässt iOS beim Fokus zoomen */ + background: #1a1a1f; + color: #f0f0f0; + border: 1px solid #3a3a42; + border-radius: 8px; +} +.dialog-actions { + display: flex; + gap: 10px; + margin-top: 20px; +} +.dialog-actions .btn { flex: 1; margin: 0; } + +/* Layout-Kennzeichen auf einer Bericht-Seitenkachel */ +.page-layout-badge { + position: absolute; + top: 4px; + right: 4px; + font-size: 10px; + padding: 2px 5px; + border-radius: 4px; + background: rgba(0,0,0,0.6); + color: #fff; + pointer-events: none; +} + +/* Thumbnail liegt über dem Platzhalter, statt ihn im Fluss zu verdrängen — + sonst hätte die Kachel kurzzeitig zwei Elemente mit je 100 % Höhe. */ +.photo-grid .thumb img.thumb-img { + position: absolute; + inset: 0; + opacity: 0; + transition: opacity 0.15s ease; +} +.photo-grid .thumb img.thumb-img.is-loaded { opacity: 1; } + +/* ============================================================ + Textnotizen + ============================================================ */ +.note-card { + background: #25252b; + border: 1px solid #33333a; + border-radius: 10px; + padding: 12px 14px; + margin-bottom: 10px; + cursor: pointer; +} +.note-card:active { background: #2c2c33; } +.note-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} +.note-card-head strong { + font-size: 15px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.note-card-preview { + font-size: 13px; + line-height: 1.4; + opacity: 0.75; + /* Zwei Zeilen Vorschau reichen, um die Notiz wiederzuerkennen */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.note-card-meta { + margin-top: 6px; + font-size: 11px; + opacity: 0.5; +} +/* Kopf-/Rumpf-Aufbau der Modale. Die Klassen waren im Markup schon in Gebrauch + (u. a. beim Löschen eines Berichts), hatten aber nie eine Regel — der + Schließen-Knopf landete dadurch unter der Überschrift statt daneben. */ +.modal-header { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: #25252b; + border-bottom: 1px solid #333; + flex: 0 0 auto; +} +.modal-header h2 { + flex: 1; + margin: 0; + font-size: 17px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.close-btn { + flex: 0 0 auto; + width: 38px; + height: 38px; + font-size: 24px; + line-height: 1; + background: transparent; + color: #f0f0f0; + border: none; + border-radius: 8px; + cursor: pointer; +} +.close-btn:active { background: #33333a; } +.modal-body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 16px; + padding-bottom: calc(16px + env(safe-area-inset-bottom)); +} + +/* Editor */ +.note-edit-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + padding-bottom: calc(12px + env(safe-area-inset-bottom)); + background: #1a1a1f; +} +.note-edit-body input, +.note-edit-body textarea { + width: 100%; + background: #25252b; + color: #f0f0f0; + border: 1px solid #3a3a42; + border-radius: 8px; + padding: 12px; + font-size: 16px; /* < 16px lässt iOS beim Fokus zoomen */ + font-family: inherit; +} +.note-edit-body textarea { + flex: 1; + min-height: 0; + resize: none; + line-height: 1.5; +} +.note-edit-meta { + font-size: 11px; + opacity: 0.5; + text-align: right; +} diff --git a/app.js b/app.js index fa6a9fa..5edd05e 100644 --- a/app.js +++ b/app.js @@ -101,6 +101,94 @@ window.addEventListener('popstate', () => { } }); +/* ============================================================ + * Eigene Dialoge statt confirm()/alert()/prompt(). + * + * Browser-Dialoge sind in der installierten PWA fehl am Platz: sie zeigen unter Android + * die Herkunfts-URL, blockieren den kompletten Thread und sehen aus wie ein Systemfehler. + * Alle drei Helfer geben ein Promise zurück und hängen im normalen Modal-Stack, damit der + * Hardware-Zurück-Button sie schließt (= Abbruch). + * + * await confirmDialog({title, text, confirmLabel, danger}) → true/false + * await inputDialog({title, text, placeholder, value}) → String oder null + * await alertDialog({title, text}) → void + * ============================================================ */ +function _dialogShell(inner) { + const el = document.createElement('div'); + el.className = 'dialog-overlay'; + el.innerHTML = ``; + return el; +} + +function confirmDialog({ title, text, confirmLabel, cancelLabel, danger } = {}) { + return new Promise(resolve => { + const el = _dialogShell(` + ${title ? `
${escapeHtml(title)}
` : ''} + ${text ? `
${escapeHtml(text)}
` : ''} +
+ + +
+ `); + let done = false; + const finish = (val) => { if (done) return; done = true; resolve(val); }; + document.body.appendChild(el); + pushModal(el, () => finish(false)); // Android-Back / popstate = Abbruch + + el.querySelector('[data-act="no"]').onclick = () => { finish(false); closeModal(el); }; + el.querySelector('[data-act="yes"]').onclick = () => { finish(true); closeModal(el); }; + // Klick auf die Abdunklung neben der Box bricht ab + el.addEventListener('click', e => { if (e.target === el) { finish(false); closeModal(el); } }); + setTimeout(() => el.querySelector('[data-act="yes"]').focus(), 30); + }); +} + +function alertDialog({ title, text, okLabel } = {}) { + return new Promise(resolve => { + const el = _dialogShell(` + ${title ? `
${escapeHtml(title)}
` : ''} + ${text ? `
${escapeHtml(text)}
` : ''} +
+ +
+ `); + let done = false; + const finish = () => { if (done) return; done = true; resolve(); }; + document.body.appendChild(el); + pushModal(el, finish); + el.querySelector('[data-act="ok"]').onclick = () => { finish(); closeModal(el); }; + el.addEventListener('click', e => { if (e.target === el) { finish(); closeModal(el); } }); + setTimeout(() => el.querySelector('[data-act="ok"]').focus(), 30); + }); +} + +function inputDialog({ title, text, placeholder, value, okLabel, inputMode } = {}) { + return new Promise(resolve => { + const el = _dialogShell(` + ${title ? `
${escapeHtml(title)}
` : ''} + ${text ? `
${escapeHtml(text)}
` : ''} + +
+ + +
+ `); + let done = false; + const finish = (val) => { if (done) return; done = true; resolve(val); }; + document.body.appendChild(el); + pushModal(el, () => finish(null)); + + const input = el.querySelector('.dialog-input'); + const submit = () => { finish(input.value.trim()); closeModal(el); }; + el.querySelector('[data-act="no"]').onclick = () => { finish(null); closeModal(el); }; + el.querySelector('[data-act="yes"]').onclick = submit; + input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }); + el.addEventListener('click', e => { if (e.target === el) { finish(null); closeModal(el); } }); + setTimeout(() => { input.focus(); input.select(); }, 60); + }); +} + /* ============================================================ * Share-Helper: teilt eine oder mehrere Dateien über Web Share API. * shareFile(blob, filename, mime, title) — einzelne Datei @@ -292,7 +380,7 @@ async function promptNewPin() { if (!p1 || p1.length !== 4) return null; const p2 = await promptPin('PIN wiederholen'); if (p2 !== p1) { - alert('PINs stimmen nicht überein'); + await alertDialog({ title: 'PIN', text: 'Die beiden Eingaben stimmen nicht überein.' }); return null; } return p1; @@ -543,7 +631,12 @@ router.on('/orders/:id', async (args) => { try { const data = await api.getOrder(args.id); - const photos = await api.listOrderPhotos(args.id).catch(() => ({ photos: [] })); + // Beides parallel — nacheinander wäre auf der Baustelle eine Wartezeit mehr + const [photos, notes] = await Promise.all([ + api.listOrderPhotos(args.id).catch(() => ({ photos: [] })), + api.listNotes(args.id).catch(() => ({ notes: [] })), + ]); + const notesCount = notes.notes.length; title(data.order.ref); // Nach MIME-Type aufteilen @@ -552,7 +645,9 @@ router.on('/orders/:id', async (args) => { const otherDocs = photos.photos.filter(p => !(p.mime || '').startsWith('image/') && !(p.mime || '').startsWith('audio/') && - !/\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename)); + !/\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename) && + // Notizen haben ihre eigene Sektion — hier wären sie doppelt + !/^notiz_.*\.txt$/i.test(p.filename)); main().innerHTML = `
@@ -574,6 +669,7 @@ router.on('/orders/:id', async (args) => { + @@ -729,6 +825,7 @@ router.on('/orders/:id', async (args) => { // Sprachnotiz document.getElementById('btn-voice').onclick = () => openVoiceModal(args.id); + document.getElementById('btn-notes').onclick = () => openNotesModal(args.id); // Neuen Bericht anlegen document.getElementById('btn-new-report').onclick = () => openNewReportModal(args.id); @@ -862,23 +959,56 @@ async function resizeImage(file, maxSide) { * Nutzt /api/photo.php mit JWT (Header kann nicht schicken, * deshalb Blob-URLs). */ -async function loadThumbs() { - const thumbs = document.querySelectorAll('.photo-grid .thumb[data-relpath]'); - for (const t of thumbs) { +/* Kacheln mit serverseitigen Thumbnails füllen. + * + * Vorher lief das sequenziell über fetch→Blob→ObjectURL und lud dabei die kompletten + * Fotos: photo.php?size=small liefert nur dann ein Thumbnail, wenn Dolibarr eines + * vorgefertigt hat — für Uploads aus der PWA gibt es keins. 30 Fotos hießen also + * 30 Vollbilder, eines nach dem anderen. Jetzt kommt das direkt an die + * Thumb-URL: der Browser lädt parallel, respektiert loading="lazy" und cached über ETag. + */ +function loadThumbs(root) { + const scope = root || document; + const thumbs = scope.querySelectorAll('.photo-grid .thumb[data-relpath]'); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const w = Math.round(160 * dpr); // Kachel ~160 CSS-px + + thumbs.forEach(t => { + if (t.dataset.thumbLoaded === '1') return; const rel = t.dataset.relpath; - try { - // Erst Thumbnail versuchen (_small), bei Misserfolg das Original - let url = await api.getPhotoBlobUrl(rel, 'small'); - if (!url) url = await api.getPhotoBlobUrl(rel); - if (url) { - t.innerHTML = ''; - } else { - t.innerHTML = '
'; + if (!rel) return; // z. B. Seite im Layout "nur Titel" — kein Bild + t.dataset.thumbLoaded = '1'; + + const img = new Image(); + img.loading = 'lazy'; + img.decoding = 'async'; + img.alt = ''; + img.className = 'thumb-img'; + img.onerror = () => { + // Kein Bild (PDF/Video) oder Fehler → Original versuchen, sonst Platzhalter + if (img.dataset.retried === '1') { + img.remove(); + const ph = t.querySelector('.thumb-placeholder'); + if (ph) ph.textContent = '❌'; + return; } - } catch (e) { - t.innerHTML = '
'; - } - } + img.dataset.retried = '1'; + img.src = api.photoUrl(rel); + }; + // Nur den Platzhalter entfernen — die Overlays der Kachel (Auswahl-Häkchen, + // Seitennummer, Titel-Badge) bleiben stehen. Vorher überschrieb das Laden des + // Thumbnails den ganzen Kachel-Inhalt und nahm sie mit. + img.onload = () => { + const ph = t.querySelector('.thumb-placeholder'); + if (ph) ph.remove(); + img.classList.add('is-loaded'); + }; + // Erst einhängen, dann src setzen: ein , das nicht im + // Dokument steht, lädt der Browser überhaupt nicht — es hat kein Layout und + // gilt damit nie als "sichtbar". Der Platzhalter bleibt bis onload stehen. + t.insertBefore(img, t.firstChild); + img.src = api.photoThumbUrl(rel, w); + }); } router.on('/customers', async () => { @@ -1070,13 +1200,20 @@ router.on('/reports/:id', async (args) => { ${hasPages ? `
- ${data.pages.map((p, i) => ` -
-
+ ${data.pages.map((p, i) => { + // composite_path ist das im Editor gebaute Seitenbild (Anmerkungen, + // Raster, Notizzone). Nur source_path zu zeigen hieße: das Rohbild + // ohne Bearbeitung — und bei einem Raster eines von bis zu sechs. + const rel = p.composite_path || p.source_path || ''; + const badge = layoutBadge(p.layout); + return ` +
+
${rel ? '⏳' : '📄'}
${i + 1}
+ ${badge ? `
${badge}
` : ''} ${p.title ? `
${escapeHtml(p.title)}
` : ''} -
- `).join('')} +
`; + }).join('')}
` : '
📭
Dieser Bericht hat noch keine Seiten. Fotos aufnehmen, um Seiten hinzuzufügen.
'} @@ -1108,9 +1245,10 @@ router.on('/reports/:id', async (args) => { if (finalizeBtn) { finalizeBtn.onclick = async () => { if (!hasPages) { showToast('Bericht hat keine Seiten', 'warn'); return; } - if (!confirm(data.report.status === 1 - ? 'PDF neu erzeugen und unter den verknüpften Dokumenten ablegen?' - : 'Bericht jetzt finalisieren und PDF erzeugen?')) return; + const goOn = await confirmDialog(data.report.status === 1 + ? { title: 'PDF neu erzeugen', text: 'PDF neu erzeugen und unter den verknüpften Dokumenten ablegen?', confirmLabel: 'Neu erzeugen' } + : { title: 'Bericht finalisieren', text: 'Bericht jetzt finalisieren und PDF erzeugen?', confirmLabel: 'Finalisieren' }); + if (!goOn) return; showToast('PDF wird erzeugt…'); try { const r = await api.finalizeReport(args.id); @@ -1212,6 +1350,13 @@ router.on('/settings', async () => { await api.logout(); await idb.del('pin_hash'); await idb.del('pin_salt'); + // Gecachte Kundenfotos vom Gerät nehmen — der Bild-Cache überlebt sonst die Abmeldung + try { + if (navigator.serviceWorker && navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage({ type: 'CLEAR_MEDIA' }); + } + } catch (e) { /* ohne SW nichts zu tun */ } + api.clearPhotoCache(); router.go('#/login'); }; // ntfy-Settings @@ -1261,7 +1406,13 @@ router.on('/settings', async () => { showToast('✓ PIN gesetzt'); router.navigate(); } else { - if (!confirm('PIN-Schutz wirklich deaktivieren?')) { + const off = await confirmDialog({ + title: 'PIN-Schutz aus', + text: 'PIN-Schutz wirklich deaktivieren? Die App öffnet dann ohne Abfrage.', + confirmLabel: 'Deaktivieren', + danger: true, + }); + if (!off) { e.target.checked = true; return; } @@ -1429,7 +1580,7 @@ async function openMaterialModal(elementType, elementId) { btn.addEventListener('click', async (e) => { const card = e.target.closest('.mini-card'); const id = card.dataset.id; - if (!confirm('Eintrag löschen?')) return; + if (!await confirmDialog({ title: 'Material', text: 'Diesen Eintrag löschen?', confirmLabel: 'Löschen', danger: true })) return; await api.deleteMaterial(id); reload(); }); @@ -1806,7 +1957,12 @@ async function openPageActionsModal(berichtId, pageId, relpath, note, title) { } }; modal.querySelector('#pa-delete').onclick = async () => { - if (!confirm('Diese Seite aus dem Bericht entfernen?')) return; + if (!await confirmDialog({ + title: 'Seite entfernen', + text: 'Diese Seite aus dem Bericht entfernen? Anmerkungen und Notiz der Seite gehen dabei verloren.', + confirmLabel: 'Entfernen', + danger: true, + })) return; try { await api.deletePage(pageId); showToast('✓ Seite entfernt'); @@ -2440,6 +2596,20 @@ function openHelpModal() { modal.querySelector('#help-close').onclick = () => closeModal(modal); } +/* Kurzzeichen für das Seiten-Layout (siehe layout-Spalte in llx_bericht_page). + * Ohne das wirkt eine Rasterseite in der Kachel wie ein einzelnes Foto. */ +function layoutBadge(layout) { + switch (layout) { + case 'grid_2': return '⊞ 2'; + case 'grid_2v': return '⊞ 2'; + case 'grid_4': return '⊞ 4'; + case 'grid_6': return '⊞ 6'; + case 'before_after': return '↔ V/N'; + case 'title_only': return '📄 Titel'; + default: return ''; + } +} + function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } @@ -2698,7 +2868,12 @@ async function openPhotoModal(orderId, relpath) { modal.querySelector('#pv-zreset').onclick = resetZoom; modal.querySelector('#fs-delete').onclick = async () => { - if (!confirm('Foto wirklich löschen?')) return; + if (!await confirmDialog({ + title: 'Foto löschen', + text: 'Das Foto wird endgültig aus den Dokumenten des Auftrags entfernt.', + confirmLabel: 'Löschen', + danger: true, + })) return; try { await api.deletePhoto(allRelpaths[currentIndex]); showToast('✓ Gelöscht'); @@ -3016,7 +3191,12 @@ async function openCameraModal(orderId) { if (!relpath && !qid) { showToast('Foto wird noch gesichert…', 'warn'); return; } // Noch nicht hochgeladen (nur lokal) → Rückfrage, da unwiederbringlich if (!relpath && qid) { - if (!confirm('Dieses Foto ist noch nicht hochgeladen. Wirklich löschen?')) return; + if (!await confirmDialog({ + title: 'Noch nicht hochgeladen', + text: 'Dieses Foto liegt nur auf dem Gerät und wurde noch nicht übertragen. Beim Löschen ist es weg.', + confirmLabel: 'Trotzdem löschen', + danger: true, + })) return; } t.dataset.deleting = '1'; const delBtn = t.querySelector('.cam-thumb-del'); @@ -3406,7 +3586,12 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) { showToast('Zu kurz — 2 Punkte mit Abstand wählen', 'error'); return; } - const input = prompt('Reale Länge dieser Strecke:\nFormat: Zahl + Einheit (z. B. "50 cm", "1.5 m", "250 mm")'); + const input = await inputDialog({ + title: 'Mess-Skala kalibrieren', + text: 'Reale Länge der gezogenen Strecke — Zahl mit Einheit, z. B. „50 cm", „1.5 m" oder „250 mm".', + placeholder: '1.5 m', + okLabel: 'Übernehmen', + }); if (!input) { await restoreSnapshot(snapshotBeforeShape); snapshotBeforeShape = null; @@ -3416,7 +3601,7 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) { if (!m) { await restoreSnapshot(snapshotBeforeShape); snapshotBeforeShape = null; - alert('Ungültiges Format. Beispiel: 1.5 m'); + await alertDialog({ title: 'Eingabe nicht verstanden', text: 'Bitte Zahl und Einheit angeben, z. B. 1.5 m' }); return; } const val = parseFloat(m[1].replace(',', '.')); @@ -3437,7 +3622,10 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) { if (!calibration) { await restoreSnapshot(snapshotBeforeShape); snapshotBeforeShape = null; - alert('Zuerst kalibrieren! Tap auf 📏⚙, zwei Punkte mit bekannter Länge wählen.'); + await alertDialog({ + title: 'Noch nicht kalibriert', + text: 'Erst 📏⚙ antippen und eine Strecke mit bekannter Länge ziehen — danach kann gemessen werden.', + }); return; } // Die letzte Preview-Zeichnung ist jetzt schon auf dem Canvas — das ist unser finales Ergebnis @@ -3503,3 +3691,150 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) { }, 'image/jpeg', 0.9); }; } + +/* ============================================================ + * TEXTNOTIZEN + * + * Getippte Notiz zum Auftrag — das Gegenstück zur Sprachnotiz für alles, was man + * lieber schreibt als diktiert (Zählernummern, Absprachen, Maße). Landet als + * notiz__.txt im Auftragsordner (api/note.php) und ist damit auch + * im Dolibarr-Auftrag und in der Anhänge-Spalte des Bericht-Editors zu sehen. + * ============================================================ */ + +/* Liste der Notizen eines Auftrags */ +async function openNotesModal(orderId) { + const modal = document.createElement('div'); + modal.className = 'fullscreen-modal notes-modal'; + modal.innerHTML = ` + + + `; + document.body.appendChild(modal); + pushModal(modal); + modal.querySelector('#nl-close').onclick = () => closeModal(modal); + + async function refresh() { + const listEl = modal.querySelector('#nl-list'); + try { + const data = await api.listNotes(orderId); + if (!data.notes.length) { + listEl.innerHTML = '
📝
Noch keine Notiz
'; + return; + } + listEl.innerHTML = data.notes.map(n => ` +
+
+ ${escapeHtml(n.subject || '(ohne Betreff)')} + +
+
${escapeHtml(n.preview || '')}
+
${escapeHtml(n.modified || n.created || formatShortDate(n.date))}
+
+ `).join(''); + + listEl.querySelectorAll('.note-card').forEach(card => { + card.onclick = (e) => { + if (e.target.closest('.note-del')) return; + openNoteEditor(orderId, card.dataset.file, refresh); + }; + card.querySelector('.note-del').onclick = async (e) => { + e.stopPropagation(); + if (!await confirmDialog({ + title: 'Notiz löschen', + text: 'Die Notiz wird endgültig aus den Dokumenten des Auftrags entfernt.', + confirmLabel: 'Löschen', + danger: true, + })) return; + try { + await api.deleteNote(orderId, card.dataset.file); + showToast('Notiz gelöscht'); + refresh(); + router.navigate(); + } catch (err) { + showToast('Fehler: ' + err.message, 'error'); + } + }; + }); + } catch (e) { + listEl.innerHTML = '
⚠️
' + escapeHtml(e.message) + '
'; + } + } + + modal.querySelector('#nl-new').onclick = () => openNoteEditor(orderId, null, refresh); + refresh(); +} + +/* Eine Notiz schreiben oder ändern. file = null → neue Notiz. */ +async function openNoteEditor(orderId, file, onSaved) { + let existing = null; + if (file) { + try { + existing = (await api.getNote(orderId, file)).note; + } catch (e) { + showToast('Notiz konnte nicht geladen werden: ' + e.message, 'error'); + return; + } + } + + const modal = document.createElement('div'); + modal.className = 'fullscreen-modal note-modal'; + modal.innerHTML = ` +
+ +
📝 ${file ? 'Notiz bearbeiten' : 'Neue Notiz'}
+ +
+
+ + + ${existing && existing.created ? `
Erfasst: ${escapeHtml(existing.created)}${existing.modified ? ' · Geändert: ' + escapeHtml(existing.modified) : ''}
` : ''} +
+ `; + document.body.appendChild(modal); + pushModal(modal); + + const subjectEl = modal.querySelector('#nt-subject'); + const textEl = modal.querySelector('#nt-text'); + const startSig = (existing ? existing.subject + ' ' + existing.text : ' '); + + const currentSig = () => subjectEl.value + ' ' + textEl.value; + + modal.querySelector('#nt-close').onclick = async () => { + if (currentSig() !== startSig && !await confirmDialog({ + title: 'Notiz verwerfen', + text: 'Die Änderungen wurden noch nicht gespeichert.', + confirmLabel: 'Verwerfen', + danger: true, + })) return; + closeModal(modal); + }; + + modal.querySelector('#nt-save').onclick = async () => { + const subject = subjectEl.value.trim(); + const text = textEl.value; + if (!subject && !text.trim()) { showToast('Die Notiz ist leer', 'warn'); return; } + + const btn = modal.querySelector('#nt-save'); + btn.disabled = true; + try { + await api.saveNote(orderId, { subject, text }, file); + showToast('✓ Notiz gespeichert'); + closeModal(modal); + if (onSaved) onSaved(); + router.navigate(); + } catch (e) { + btn.disabled = false; + showToast('Speichern fehlgeschlagen: ' + e.message, 'error'); + } + }; + + // Bei einer neuen Notiz erst den Betreff, beim Bearbeiten direkt in den Text + setTimeout(() => { (file ? textEl : subjectEl).focus(); }, 80); +} diff --git a/lib/api.js b/lib/api.js index 11b2026..bf85a5a 100644 --- a/lib/api.js +++ b/lib/api.js @@ -212,6 +212,27 @@ return uploadOrderPhoto(orderId, fileBlob, filename); } + /* Textnotizen — eigener Endpoint wie die Sprachnotiz, damit die Datei als + * notiz__.txt im Auftragsordner landet. */ + async function listNotes(orderId) { + return request('/note.php?order_id=' + orderId); + } + + async function getNote(orderId, file) { + return request('/note.php?order_id=' + orderId + '&file=' + encodeURIComponent(file)); + } + + /** file weglassen → neue Notiz, file angeben → bestehende ändern */ + async function saveNote(orderId, data, file) { + const qs = '/note.php?order_id=' + orderId + (file ? '&file=' + encodeURIComponent(file) : ''); + return request(qs, { method: 'POST', body: JSON.stringify(data), timeoutMs: 45000 }); + } + + async function deleteNote(orderId, file) { + return request('/note.php?order_id=' + orderId + '&file=' + encodeURIComponent(file) + '&delete=1', + { method: 'POST' }); + } + async function deletePage(pageId) { return request('/pages.php?id=' + pageId, { method: 'DELETE' }); } @@ -285,6 +306,25 @@ return URL.createObjectURL(blob); } + /** + * Direkte URL auf ein serverseitig erzeugtes Thumbnail — für . + * + * Bewusst KEIN fetch→Blob: über die echte URL greifen HTTP-Cache (ETag, 30 Tage) und + * loading="lazy", und der Browser lädt die Kacheln parallel. Der Blob-Weg lädt jede + * Datei komplett, bevor das überhaupt existiert — bei 30 Baustellenfotos war das + * der Grund, warum die Fotoliste im Mobilfunknetz so lange stand. + * Auth läuft über das awl_sso-Cookie, das der Browser bei same-origin mitschickt. + */ + function photoThumbUrl(relpath, w) { + const params = new URLSearchParams({ relpath, size: 'thumb', w: String(w || 320) }); + return API_BASE + '/photo.php?' + params.toString(); + } + + /** Direkte URL auf die Originaldatei — für Vollbild-, Audio-Player, Download. */ + function photoUrl(relpath) { + return API_BASE + '/photo.php?' + new URLSearchParams({ relpath }).toString(); + } + /** * Lädt eine Bild-Datei von der API als Blob-URL. * Auth über das awl_sso-Cookie (same-origin) — photo.php nutzt awlauth_verify. @@ -345,8 +385,9 @@ listCustomers, getCustomer, getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport, deleteReport, deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto, + listNotes, getNote, saveNote, deleteNote, listMaterials, addMaterial, deleteMaterial, - getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl, + getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl, photoThumbUrl, photoUrl, deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages, listShipments, getShipment, getShipmentPdfBlobUrl, confirmShipment, }; diff --git a/manifest.webmanifest b/manifest.webmanifest index e2c4621..1388eaa 100644 --- a/manifest.webmanifest +++ b/manifest.webmanifest @@ -19,7 +19,7 @@ { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ], "share_target": { - "action": "https://awl.data-it-solution.de/custom/baustelle/share.html", + "action": "share.html", "method": "POST", "enctype": "multipart/form-data", "params": { diff --git a/sw.js b/sw.js index 0e1a421..cc46a8b 100644 --- a/sw.js +++ b/sw.js @@ -9,6 +9,42 @@ // Cache-Version dynamisch aus ?v= — wird von index.php gesetzt const SW_VERSION = (new URL(self.location.href)).searchParams.get('v') || 'static'; const CACHE = 'baustelle-' + SW_VERSION; + +/* Bild-Cache für die Thumbnails aus photo.php — bewusst NICHT versioniert, damit ein + * Deploy die schon geladenen Fotos nicht wegräumt. Ohne das ist auf der Baustelle ohne + * Netz kein einziges Foto sichtbar, auch keins, das man vorher schon angesehen hat. */ +const MEDIA_CACHE = 'baustelle-media'; +const MEDIA_MAX = 300; + +async function trimMediaCache() { + const c = await caches.open(MEDIA_CACHE); + const keys = await c.keys(); + // keys() liefert in Einfüge-Reihenfolge — die ältesten stehen vorn + for (let i = 0; i < keys.length - MEDIA_MAX; i++) await c.delete(keys[i]); +} + +/* Thumbnails: erst Cache, dann Netz (und im Hintergrund auffrischen). + * Die URL enthält relpath + Größe, und der Server schickt ein ETag aus der mtime — + * ein ersetztes Original bekommt damit ohnehin eine neue Antwort. */ +async function mediaCacheFirst(request) { + const c = await caches.open(MEDIA_CACHE); + const hit = await c.match(request); + if (hit) { + // Auffrischen ohne den Nutzer warten zu lassen + fetch(request).then(r => { + if (r && r.ok) c.put(request, r.clone()).then(trimMediaCache); + }).catch(() => null); + return hit; + } + try { + const r = await fetch(request); + if (r && r.ok) { await c.put(request, r.clone()); trimMediaCache(); } + return r; + } catch (e) { + // Offline und nichts im Cache — der Aufrufer zeigt seinen Platzhalter + return new Response('', { status: 504, statusText: 'Offline' }); + } +} const SHELL = [ './', './index.php', @@ -32,7 +68,8 @@ self.addEventListener('activate', (e) => { e.waitUntil( caches.keys() .then(keys => Promise.all( - keys.filter(k => k.startsWith('baustelle-') && k !== CACHE) + // MEDIA_CACHE ausnehmen — der überlebt Deploys bewusst + keys.filter(k => k.startsWith('baustelle-') && k !== CACHE && k !== MEDIA_CACHE) .map(k => caches.delete(k)) )) .then(() => self.clients.claim()) @@ -84,7 +121,15 @@ self.addEventListener('fetch', (e) => { return; } - // API-Requests: nicht cachen, durchreichen + // Thumbnails aus photo.php: cache-first, damit die Fotoliste offline steht + if (e.request.method === 'GET' + && url.pathname.endsWith('/custom/bericht/api/photo.php') + && url.searchParams.get('size') === 'thumb') { + e.respondWith(mediaCacheFirst(e.request)); + return; + } + + // Übrige API-Requests: nicht cachen, durchreichen if (url.pathname.includes('/custom/bericht/api/')) { return; } @@ -118,4 +163,8 @@ self.addEventListener('message', (e) => { if (e.data && e.data.type === 'SKIP_WAITING') { self.skipWaiting(); } + // Beim Abmelden dürfen keine Kundenfotos auf dem Gerät zurückbleiben + if (e.data && e.data.type === 'CLEAR_MEDIA') { + e.waitUntil(caches.delete(MEDIA_CACHE)); + } });