diff --git a/.env.example b/.env.example index 0993bad..04235cb 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ NEXTCLOUD_APP_PASSWORD= # optional App-Passwort aus Nextcloud → Einstellung NEXTCLOUD_CALENDAR=morning-meeting # Nur setzen, wenn die Nextcloud ein selbstsigniertes Zertifikat hat NEXTCLOUD_INSECURE_TLS=false +# Vertrauenswürdige Reverse-Proxys für X-Forwarded-For (Adressen oder Netze, kommagetrennt). +# Leer = nur localhost, "none" = keiner. Wichtig, damit die Bremsen je Nutzer statt je Proxy greifen. +TRUSTED_PROXY= # optional z. B. 192.168.2.1 # Bearbeitungspasswort: Betrachter sehen nur den Kalender, Auslosen und Verwalten erfordern die Anmeldung. # Leer = jeder darf alles bearbeiten. EDIT_PASSWORD="" diff --git a/.vch-description b/.vch-description index 7d062da..035d7fb 100644 --- a/.vch-description +++ b/.vch-description @@ -1 +1 @@ -Rotatory lost fair und zufällig aus, wer das tägliche Morning Meeting moderiert: pro Wochentag ein Hauptmoderator und ein Nebenmoderator aus der Belegschaft. Urlaub, Krankheit, Feiertage, Teilzeit-Freitage und Personen, die nicht moderieren, werden automatisch berücksichtigt. Die Monatsübersicht zeigt auf jedem Tag die Fotos der beiden Moderatoren, sodass jeder auf einen Blick sieht, wer dran ist. Einzelne Tage lassen sich manuell festlegen oder neu auslosen. Die Belegschaft samt Fotos und die Abwesenheiten kommen auf Wunsch direkt aus der Nextcloud, und auf Knopfdruck landet der Plan als Termine im geteilten Nextcloud-Kalender des Teams. +Rotatory lost fair und zufällig aus, wer das tägliche Morning Meeting moderiert: pro Wochentag ein Hauptmoderator und ein Nebenmoderator aus der Belegschaft. Urlaub, Krankheit, Feiertage, Teilzeit-Freitage und Personen, die nicht moderieren, werden automatisch berücksichtigt. Die Monatsübersicht zeigt auf jedem Tag die Fotos der beiden Moderatoren, sodass jeder auf einen Blick sieht, wer dran ist. Einzelne Tage lassen sich manuell festlegen oder neu auslosen. Die Belegschaft samt Fotos und die Abwesenheiten kommen auf Wunsch direkt aus der Nextcloud, und auf Knopfdruck landet der Plan als Termine im geteilten Nextcloud-Kalender des Teams. Teammitglieder können ohne Anmeldung einen Tausch anfragen und Fehler mit Screenshot melden; die Bearbeitung ist per Passwort geschützt. diff --git a/.vch/PLATFORM.md b/.vch/PLATFORM.md index 76ec235..3256a74 100644 --- a/.vch/PLATFORM.md +++ b/.vch/PLATFORM.md @@ -330,7 +330,12 @@ Die **Engine** (Postgres oder MySQL) leitet VCH aus deinen Schlüsselnamen und d **Bringt dein Compose seine eigene Datenbank mit**, startet VCH sie mit und bietet **keine** zweite an. Achte dann selbst auf ein dauerhaftes Volume — ein Datenbankdienst ohne Volume -verliert seine Daten beim nächsten Neubau, und VCH warnt nur. +verliert seine Daten beim nächsten Neubau, und VCH warnt nur. Erkannt wird der Dienst **am +Image-Namen**: `postgres`, `postgresql`, `pgvector`, `postgis`, `timescaledb`, `mysql`, +`mariadb`, `percona`/`percona-server`. Ein anderes Image (eigener Build, fremder Name) gilt +nicht als Datenbank — dann bietet VCH eine verwaltete an, und der Deploy läuft blau-grün mit +einem zweiten Stapel. + Eine Compose-Datei, die **nur** eine Datenbank startet, ist Entwicklungs-Beiwerk: sie läuft in Produktion nicht mit. Nimm dort die verwaltete Datenbank. @@ -347,6 +352,8 @@ const datenVerzeichnis = process.env.VCH_DATA_DIR || './data'; VCH setzt die Variable bei allen Betriebsarten (bei Docker als Einhängepunkt `/data`). **Lies sie zur Laufzeit**, schreibe den Pfad nirgends fest und trage sie nicht in `.env`-Dateien ein. Ein Verzeichnis relativ zum Release (`./data`) ist beim nächsten Deploy weg. +Bei Compose gilt das nur im Container des `${VCH_PORT}`-Dienstes — in der Compose-Datei selbst +steht die Variable nicht zur Verfügung (Abschnitt 13, Regel 5). Der Prüfling während des Audits (Abschnitt 10) bekommt diesen Speicher **nicht** — er läuft gegen einen leeren. Das ist Absicht: zwei Prozesse dürfen nicht gleichzeitig auf dieselbe @@ -377,6 +384,16 @@ Projektarbeitsplatz per Rollback zurückholen. **Auto-Deploy:** ist er eingeschaltet, löst jeder Push auf den konfigurierten Deploy-Branch einen Deploy aus. Pushes auf andere Branches werden ignoriert. +- **Das Ergebnis jedes Deploys liegt in `.vch/deploy-report.md`** (nicht im Git — VCH trägt + die Datei wie den Audit-Bericht in `.git/info/exclude` ein). Er nennt Commit, Stufe, + Fehlermeldung, das Ende des Logs (Geheimnisse geschwärzt) und eine Einschätzung: + „probably fixable in this repository" heißt, die Ursache liegt vermutlich im Code — beheben, + committen, pushen. „platform error" heißt: **nicht** im Code suchen, sondern dem Nutzer sagen, + was dort steht. Nach einem erfolgreichen Deploy steht dort genau das; ein alter Fehlerbericht + bleibt nicht liegen. Nennt der Bericht einen anderen Commit als den, nach dem du gefragt + wirst, ist er noch nicht aktualisiert. + + ## 11. Audit — was geprüft wird Das Audit läuft nicht hier, sondern in einem eigenen Runner gegen einen **sauberen Checkout @@ -399,6 +416,11 @@ macht eine Datei für das Audit unsichtbar. `skipped` meldet, gilt als **durchgefallen** — sie ist nie gelaufen. - `secrets` sucht eingecheckte Zugangsdaten. Echte Werte gehören nie ins Repository: in die `.env.example` nur Beispiele, die echten Werte in den Env-Editor von VCH. +- **Das Ergebnis liegt nach jedem Audit in `.vch/audit-report.md`** (nicht im Git — VCH trägt + die Datei in `.git/info/exclude` ein). Bei einem Fehlschlag: lesen, beheben, committen, + pushen — erst der nächste Deploy auditiert den neuen Commit. Der Kopf nennt Commit und + Zeitpunkt; lief die Instanz beim Audit nicht, ist die Datei älter oder fehlt, dann steht der + aktuelle Bericht im Audit-Fenster von VCH zum Kopieren. ## 12. Veröffentlichen @@ -410,7 +432,7 @@ macht eine Datei für das Audit unsichtbar. - Im **Showcase** erscheinen veröffentlichte Projekte mit bestandenem Audit und einer eingecheckten `.vch-description`. -## 13. Compose — die vier Regeln, an denen es sonst scheitert +## 13. Compose — die fünf Regeln, an denen es sonst scheitert 1. **Der App-Dienst muss seinen Port als `ports: ["${VCH_PORT}:"]` veröffentlichen.** VCH setzt `VCH_PORT` und kann so einen Prüfling neben deiner laufenden Fassung starten. @@ -425,3 +447,18 @@ macht eine Datei für das Audit unsichtbar. 4. **Der ganze Stapel startet.** Datenbanken, Caches und Ähnliches aus deinem Compose laufen automatisch mit — eine zusätzliche verwaltete Datenbank brauchst du dafür nicht. Dienste hinter einem `profiles:`-Schlüssel bleiben aus. +5. **Gebaute Dienste bekommen kein `image:`, dauerhafte Daten liegen unter `/data` des + `${VCH_PORT}`-Dienstes.** Für jeden Dienst mit `build:` vergibt VCH den Image-Namen selbst, + je Commit — so starten Prüfling, Live-Stand und Rollback jeweils genau ihren Code. Ein + eigenes `image:` bei einem gebauten Dienst teilt sich ein Image über alle Releases; ein + Rollback startet dann den zuletzt gebauten Code. `VCH_DATA_DIR` gibt es **nur im Container** + des `${VCH_PORT}`-Dienstes (als `/data`), nicht beim Einlesen der Compose-Datei: + `${VCH_DATA_DIR:-.}/data` wird dort zu `./data` im Release-Ordner und ist beim nächsten + Deploy weg. Ein zweiter Dienst, der dieselben Daten braucht, bekommt `volumes_from: []`; + eigene Daten legt er in ein benanntes Volume (`volumes: [daten:/var/lib/…]`) — das überlebt + Deploys, sichert VCH aber nicht mit. `vch-images.yml` wird bei jedem Build neu + geschrieben oder entfernt, `vch-override.yml` bei jedem Start des Live-Stands geschrieben + (sofern es einen `${VCH_PORT}`-Dienst gibt) — beide gehören VCH, keine eigenen Dateien dieses + Namens. + + diff --git a/CLAUDE.md b/CLAUDE.md index 85870ff..7824d6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,9 @@ Wofür dieses Projekt gut ist, und was jemand wissen muss, der hier weiterarbeit - Lieber kleine, abgeschlossene Commits als große - Vor dem Commit linten und formatieren - Tests für die tragende Fachlogik -- Wenig Abhängigkeiten — was Browser und Node schon können, reicht meistens +- Wenig Abhängigkeiten — was Browser und Node schon können, reicht meistens. Ausnahme mit + Begründung: `public/vendor/html2canvas.min.js` (Screenshots für Fehlermeldungen; der Browser + kann Seiten nicht selbst fotografieren), selbst gehostet, nichts von fremden Servern laden - Semantisches HTML, mobil zuerst, bedienbar per Tastatur, ausreichender Kontrast - Konfiguration über Umgebungsvariablen; keine Zugangsdaten im Quelltext diff --git a/README.md b/README.md index fdaed0b..057b662 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,19 @@ Bearbeiter sehen alle Anfragen unter „Anfragen" mit Zähler in der Navigation manuell wählen, „Erledigt" und „Ablehnen" schließen die Anfrage ohne Änderung. Nach einem Tausch den Zeitraum erneut nach Nextcloud schreiben. +## Fehler melden + +Unten rechts sitzt ein Käfer-Button. Ein Klick nimmt den sichtbaren Bildschirm auf (mit der +selbst gehosteten Bibliothek html2canvas, die die Seite aus dem Seiteninhalt nachzeichnet), +zeigt ihn als Vorschau, in der sich rote Rechtecke aufziehen lassen, und fragt nach einer +Beschreibung und optional dem Namen. Dafür ist keine Anmeldung nötig; pro Adresse sind zehn +Meldungen je Stunde möglich. Bearbeiter sehen die Meldungen unter „Fehler" mit Zähler. +„Prompt kopieren" legt eine fertige Anweisung für Claude Code in die Zwischenablage, die +Vorlage dafür steht in den Einstellungen; „Screenshot kopieren" das Bild dazu. Bilder in die +Zwischenablage legen erlauben Browser nur über https; auf einer http-Adresse öffnet +„Screenshot öffnen" das Bild zum Kopieren per Rechtsklick. Screenshots sind nur angemeldet +abrufbar, weil sie Namen und Planung zeigen. + ## Nextcloud 1. Umgebungsvariablen setzen und App neu starten. @@ -125,6 +138,9 @@ server/ holidays.js Gesetzliche Feiertage je Bundesland ics.js iCalendar-Erzeugung nextcloud.js CalDAV/CardDAV-Client (Discovery, PROPFIND, MKCALENDAR, REPORT, PUT, DELETE) + requests.js Tauschanfragen prüfen und ausführen + bugs.js Fehlermeldungen: Screenshot prüfen, Prompt-Vorlage + auth.js Bearbeitungsschutz (Cookie, Bremse) ical.js iCalendar- und vCard-Leser, Namenszuordnung env.js .env-Leser public/ @@ -132,5 +148,6 @@ public/ test/ node:test ``` -Abhängigkeiten: nur `express`. Fotos werden im Browser per Canvas verkleinert, `fetch` und -`crypto` kommen aus Node. +Abhängigkeiten: nur `express`; im Browser zusätzlich die selbst gehostete Bibliothek +html2canvas (`public/vendor/`, MIT) für Screenshots in Fehlermeldungen. Fotos werden im Browser +per Canvas verkleinert, `fetch` und `crypto` kommen aus Node. diff --git a/public/app.js b/public/app.js index 467a979..0dc959e 100644 --- a/public/app.js +++ b/public/app.js @@ -51,7 +51,7 @@ async function api(path, { method = 'GET', body, raw } = {}) { } // ---------- Anmeldung zum Bearbeiten ---------- -const EDITOR_VIEWS = ['personen', 'abwesenheiten', 'anfragen', 'einstellungen']; +const EDITOR_VIEWS = ['personen', 'abwesenheiten', 'anfragen', 'fehler', 'einstellungen']; function isEditor() { return !state.data || state.data.auth?.editor !== false; } function applyAuthUI() { const a = state.data?.auth || { protected: false, editor: true }; @@ -67,6 +67,10 @@ function applyAuthUI() { const badge = $('#requests-count'); badge.hidden = !a.editor || open === 0; badge.textContent = open; + const openBugs = (state.data?.bugs || []).filter((b) => b.status === 'offen').length; + const bugBadge = $('#bugs-count'); + bugBadge.hidden = !a.editor || openBugs === 0; + bugBadge.textContent = openBugs; if (!a.editor && EDITOR_VIEWS.includes(location.hash.slice(1))) location.hash = '#kalender'; } function openLogin() { @@ -144,7 +148,7 @@ function avatarEl(p, cls = '') { // ---------- Navigation ---------- function showView(name) { - const known = ['kalender', 'personen', 'abwesenheiten', 'anfragen', 'einstellungen']; + const known = ['kalender', 'personen', 'abwesenheiten', 'anfragen', 'fehler', 'einstellungen']; if (!known.includes(name)) name = 'kalender'; if (!isEditor() && EDITOR_VIEWS.includes(name)) name = 'kalender'; if (name === 'abwesenheiten' && state.data) prepareAbsenceImport(); @@ -1068,6 +1072,7 @@ function renderSettings() { f.historyWindowDays.value = s.historyWindowDays; f.calendarDisplayName.value = s.calendarDisplayName; f.inviteAttendees.checked = Boolean(s.inviteAttendees); + f.bugPromptTemplate.value = s.bugPromptTemplate || ''; if (!$('#holiday-generate').elements.year.value) $('#holiday-generate').elements.year.value = new Date().getFullYear(); const list = $('#holiday-list'); @@ -1111,6 +1116,7 @@ settingsForm.addEventListener('submit', async (e) => { await api('api/settings', { method: 'PUT', body: { meetingTime: f.meetingTime.value, durationMinutes: Number(f.durationMinutes.value), bundesland: f.bundesland.value, historyWindowDays: Number(f.historyWindowDays.value), calendarDisplayName: f.calendarDisplayName.value, inviteAttendees: f.inviteAttendees.checked, + bugPromptTemplate: f.bugPromptTemplate.value, } }); await load(); toast('Einstellungen gespeichert.'); @@ -1134,6 +1140,319 @@ $('#holiday-add').addEventListener('submit', async (e) => { } catch (err) { toast(err.message, true); } }); +// ---------- Fehler melden (für alle) ---------- +const bugPanel = $('#bug-panel'); +const bugCanvas = $('#bug-canvas'); +const bugState = { base: null, rects: [], draft: null, sending: false, capture: null, closeTimer: null, target: null }; + +function viewName() { + const v = location.hash.slice(1) || 'kalender'; + const names = { kalender: 'Kalender', personen: 'Personen', abwesenheiten: 'Abwesenheiten', anfragen: 'Anfragen', fehler: 'Fehler', einstellungen: 'Einstellungen' }; + const month = v === 'kalender' ? ` (${MONTHS[state.month.getMonth()]} ${state.month.getFullYear()})` : ''; + return `${names[v] || v}${month}${isEditor() ? ', angemeldet' : ''}`; +} + +/** + * Sichtbaren Bildschirm als Canvas aufnehmen – das Meldefenster selbst bleibt außen vor. + * html2canvas zeichnet die Seite aus dem DOM nach. Zwei Dinge kann es nicht: position: sticky (die + * Kopfzeile) und box-shadow-Ringe (sie übermalen das Kennzeichen). Beides wird in der Kopie des + * Dokuments korrigiert, die html2canvas rendert – die echte Seite bleibt unberührt. + */ +async function captureScreenshot(target = null) { + if (typeof window.html2canvas !== 'function') throw new Error('Die Screenshot-Funktion ist nicht geladen.'); + // Auflösung begrenzen: höchstens 1,5-fach und höchstens 2560 px breit, damit das Bild unter 6 MB bleibt + const scale = Math.min(window.devicePixelRatio || 1, 1.5, Math.max(1, 2560 / window.innerWidth)); + // Schatten malt html2canvas als Fläche über das Element – in der Kopie weglassen + const extraStyle = '.slot .badge, .req-mark, dialog, .chip, .card { box-shadow: none; } .logo .arc { animation: none; }'; + if (target) { + // Ein offener Dialog: nur ihn aufnehmen, er ist das, was der Melder gerade sieht + return window.html2canvas(target, { + backgroundColor: '#ffffff', + scale, + logging: false, + useCORS: true, + ignoreElements: (el) => el.id === 'bug-widget' || el.id === 'toast' || el.classList?.contains('dialog-bug'), + onclone: (doc) => { const style = doc.createElement('style'); style.textContent = extraStyle; doc.head.appendChild(style); }, + }); + } + const scrollX = window.scrollX; + const scrollY = window.scrollY; + const topbarHeight = $('.topbar').offsetHeight; + return window.html2canvas(document.documentElement, { + backgroundColor: '#ffffff', + scale, + logging: false, + useCORS: true, + ignoreElements: (el) => el.id === 'bug-widget' || el.id === 'toast', + x: scrollX, + y: scrollY, + width: window.innerWidth, + height: window.innerHeight, + scrollX: 0, + scrollY: 0, + windowWidth: document.documentElement.clientWidth, + windowHeight: document.documentElement.clientHeight, + onclone: (doc) => { + const bar = doc.querySelector('.topbar'); + if (bar) { + // Kopfzeile dorthin setzen, wo sie gerade klebt; der freigewordene Platz oben wird aufgefüllt + bar.style.position = 'absolute'; + bar.style.top = `${scrollY}px`; + bar.style.left = '0'; + bar.style.right = '0'; + doc.body.style.paddingTop = `${topbarHeight}px`; + } + const style = doc.createElement('style'); + style.textContent = extraStyle; + doc.head.appendChild(style); + }, + }); +} + +function drawBugCanvas() { + const c = bugCanvas; + if (!bugState.base) return; + const ctx = c.getContext('2d'); + ctx.clearRect(0, 0, c.width, c.height); + ctx.drawImage(bugState.base, 0, 0); + const lw = Math.max(3, Math.round(c.width / 400)); + ctx.lineWidth = lw; + ctx.strokeStyle = '#B42318'; + ctx.lineJoin = 'miter'; + for (const r of [...bugState.rects, ...(bugState.draft ? [bugState.draft] : [])]) { + ctx.strokeRect(r.x + lw / 2, r.y + lw / 2, Math.max(0, r.w - lw), Math.max(0, r.h - lw)); + } + $('#bug-clear-marks').hidden = bugState.rects.length === 0; +} + +function canvasPoint(e) { + const rect = bugCanvas.getBoundingClientRect(); + const x = Math.min(Math.max(0, (e.clientX - rect.left) / rect.width * bugCanvas.width), bugCanvas.width); + const y = Math.min(Math.max(0, (e.clientY - rect.top) / rect.height * bugCanvas.height), bugCanvas.height); + return { x, y }; +} +let bugDrawStart = null; +bugCanvas.addEventListener('pointerdown', (e) => { + if (!bugState.base) return; + e.preventDefault(); + bugCanvas.setPointerCapture(e.pointerId); + bugDrawStart = canvasPoint(e); + bugState.draft = { x: bugDrawStart.x, y: bugDrawStart.y, w: 0, h: 0 }; +}); +bugCanvas.addEventListener('pointermove', (e) => { + if (!bugDrawStart) return; + const p = canvasPoint(e); + bugState.draft = { x: Math.min(p.x, bugDrawStart.x), y: Math.min(p.y, bugDrawStart.y), w: Math.abs(p.x - bugDrawStart.x), h: Math.abs(p.y - bugDrawStart.y) }; + drawBugCanvas(); +}); +const endBugDraw = () => { + if (!bugDrawStart) return; + const d = bugState.draft; + const min = bugCanvas.width / 150; + if (d && d.w > min && d.h > min) bugState.rects.push(d); + bugState.draft = null; + bugDrawStart = null; + drawBugCanvas(); +}; +bugCanvas.addEventListener('pointerup', endBugDraw); +bugCanvas.addEventListener('pointercancel', endBugDraw); +$('#bug-clear-marks').addEventListener('click', () => { bugState.rects = []; drawBugCanvas(); }); + +function closeBugPanel() { + if (bugPanel.open) bugPanel.close(); // löst 'close' aus, dort wird aufgeräumt +} +// Aufräumen bei jedem Schließen: Escape, Abbrechen, automatisch nach dem Senden +bugPanel.addEventListener('close', () => { + clearTimeout(bugState.closeTimer); + bugState.closeTimer = null; + bugState.capture = null; + bugState.base = null; + bugState.rects = []; + bugState.draft = null; + bugState.target = null; + $('#bug-button').setAttribute('aria-expanded', 'false'); +}); + +/** Panel öffnen und aufnehmen. target = offener Dialog, der stattdessen fotografiert wird. */ +async function openBugPanel(target = null) { + if (bugPanel.open) return; + clearTimeout(bugState.closeTimer); + bugState.closeTimer = null; + bugState.target = target; + $('#bug-button').setAttribute('aria-expanded', 'true'); + $('#bug-shot').hidden = true; + $('#bug-shot-hint').hidden = true; + $('#bug-result').hidden = true; + $('#bug-submit').disabled = false; + $('#bug-text').value = ''; + try { $('#bug-reporter').value = localStorage.getItem('rotatory.reporter') || ''; } catch { /* kein Speicher */ } + const status = $('#bug-status'); + status.hidden = false; + status.textContent = 'Bildschirm wird aufgenommen …'; + bugState.base = null; + bugState.rects = []; + bugPanel.showModal(); // auch über anderen offenen Dialogen bedienbar + $('#bug-text').focus(); + const capture = captureScreenshot(target); + bugState.capture = capture; + try { + const shot = await capture; + if (bugState.capture !== capture) return; // inzwischen geschlossen oder neu geöffnet + bugState.base = shot; + bugCanvas.width = shot.width; + bugCanvas.height = shot.height; + drawBugCanvas(); + status.hidden = true; + $('#bug-shot').hidden = false; + $('#bug-shot-hint').hidden = false; + } catch (err) { + if (bugState.capture !== capture) return; + status.textContent = `Screenshot nicht möglich (${err.message}). Beschreibe den Fehler bitte trotzdem.`; + } finally { + if (bugState.capture === capture) bugState.capture = null; + } +} +$('#bug-button').addEventListener('click', () => { if (bugPanel.open) closeBugPanel(); else openBugPanel(); }); +$('#bug-cancel').addEventListener('click', closeBugPanel); +// Käfer im Kopf jedes Dialogs: nimmt den Dialog auf und öffnet das Panel darüber +document.addEventListener('click', (e) => { + const trigger = e.target.closest('.dialog-bug'); + if (trigger) openBugPanel(trigger.closest('dialog')); +}); +$('#bug-form').addEventListener('submit', async (e) => { + e.preventDefault(); + if (bugState.sending) return; + const out = $('#bug-result'); + const text = $('#bug-text').value.trim(); + if (!text) { out.className = 'result error'; out.textContent = 'Bitte beschreibe kurz, was falsch läuft.'; out.hidden = false; return; } + bugState.sending = true; + $('#bug-submit').disabled = true; + out.className = 'result'; + out.textContent = 'Wird gesendet …'; + out.hidden = false; + const reporter = $('#bug-reporter').value.trim(); + try { localStorage.setItem('rotatory.reporter', reporter); } catch { /* kein Speicher */ } + // Läuft die Aufnahme noch, erst abwarten – sonst ginge die Meldung ohne Bild raus + if (bugState.capture) { + out.textContent = 'Bildschirm wird noch aufgenommen …'; + try { await bugState.capture; } catch { /* dann eben ohne Screenshot */ } + out.textContent = 'Wird gesendet …'; + } + const page = bugState.target ? `${viewName()}, Dialog „${bugState.target.querySelector('h2')?.textContent.trim() || ''}"` : viewName(); + try { + let screenshot = bugState.base ? bugCanvas.toDataURL('image/png') : ''; + if (screenshot.length > 8_000_000) screenshot = bugCanvas.toDataURL('image/jpeg', 0.85); // großes Display: kleiner als JPEG + await api('api/bugs', { method: 'POST', body: { text, reporter, page, url: location.href, viewport: `${window.innerWidth}×${window.innerHeight}`, userAgent: navigator.userAgent, screenshot } }); + } catch (err) { + out.className = 'result error'; + out.textContent = err.message; + $('#bug-submit').disabled = false; + bugState.sending = false; + return; + } + bugState.sending = false; + out.textContent = 'Danke, der Fehler ist gemeldet.'; + bugState.closeTimer = setTimeout(() => { bugState.closeTimer = null; closeBugPanel(); }, 2000); + if (isEditor()) load().catch((err) => toast(err.message, true)); +}); + +// ---------- Fehlermeldungen bearbeiten (nur Bearbeiter) ---------- +const canCopyImages = () => Boolean(window.isSecureContext && navigator.clipboard && navigator.clipboard.write && window.ClipboardItem); +async function copyText(text) { + if (window.isSecureContext && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; } + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); + ta.remove(); + if (!ok) throw new Error('Der Browser hat das Kopieren abgelehnt.'); +} +async function copyImage(url) { + const blob = await (await fetch(url)).blob(); + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); +} +function browserLabel(ua) { + const u = String(ua || ''); + const m = u.match(/(Edg|OPR|Firefox|Chrome|Safari|Version)\/(\d+)/g) || []; + const pick = (name) => m.find((x) => x.startsWith(name + '/')); + const b = pick('Edg') ? `Edge ${pick('Edg').split('/')[1]}` : pick('OPR') ? `Opera ${pick('OPR').split('/')[1]}` : pick('Firefox') ? `Firefox ${pick('Firefox').split('/')[1]}` : pick('Chrome') ? `Chrome ${pick('Chrome').split('/')[1]}` : pick('Version') && /Safari/.test(u) ? `Safari ${pick('Version').split('/')[1]}` : 'Browser'; + const os = /Windows/.test(u) ? 'Windows' : /Mac OS X/.test(u) && !/iPhone|iPad/.test(u) ? 'macOS' : /iPhone|iPad/.test(u) ? 'iOS' : /Android/.test(u) ? 'Android' : /Linux/.test(u) ? 'Linux' : ''; + return os ? `${b}, ${os}` : b; +} +function bugRow(b) { + const li = document.createElement('li'); + if (b.hasScreenshot) { + const a = document.createElement('a'); + a.className = 'bug-thumb'; + a.href = `bugs/${b.id}.png`; + a.target = '_blank'; + a.rel = 'noopener'; + a.title = 'Screenshot in voller Größe öffnen'; + const img = document.createElement('img'); + img.src = `bugs/${b.id}.png`; + img.alt = 'Screenshot zur Fehlermeldung'; + img.loading = 'lazy'; + a.appendChild(img); + li.appendChild(a); + } + const grow = document.createElement('div'); + grow.className = 'grow'; + const text = document.createElement('div'); + text.className = 'bug-text'; + text.textContent = b.text; + grow.appendChild(text); + const meta = document.createElement('div'); + meta.className = 'bug-meta'; + meta.textContent = [b.reporter || 'anonym', new Date(b.createdAt).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }), b.page, b.viewport, browserLabel(b.userAgent), b.version ? `Version ${b.version}` : '', b.status === 'erledigt' ? 'erledigt' : ''].filter(Boolean).join(' · '); + grow.appendChild(meta); + const actions = document.createElement('div'); + actions.className = 'bug-actions'; + const btn = (label, cls, fn) => { const el = document.createElement('button'); el.type = 'button'; el.className = cls; el.textContent = label; el.addEventListener('click', fn); actions.appendChild(el); return el; }; + btn('Prompt kopieren', 'primary', async () => { + try { + const res = await fetch(`api/bugs/${b.id}/prompt`); + if (!res.ok) throw new Error('Prompt konnte nicht geladen werden.'); + await copyText(await res.text()); + toast('Prompt kopiert. Jetzt in Claude Code einfügen, Screenshot dazu.'); + } catch (err) { toast(err.message, true); } + }); + if (b.hasScreenshot) { + if (canCopyImages()) { + btn('Screenshot kopieren', '', async () => { + try { await copyImage(`bugs/${b.id}.png`); toast('Screenshot kopiert.'); } catch (err) { toast(`Screenshot konnte nicht kopiert werden: ${err.message}`, true); } + }); + } else { + btn('Screenshot öffnen', '', () => window.open(`bugs/${b.id}.png`, '_blank', 'noopener')); + } + } + if (b.status === 'offen') btn('Erledigt', 'ghost', () => run(() => api(`api/bugs/${b.id}`, { method: 'PUT', body: { status: 'erledigt' } }), 'Als erledigt markiert.')); + else btn('Wieder öffnen', 'ghost', () => run(() => api(`api/bugs/${b.id}`, { method: 'PUT', body: { status: 'offen' } }))); + btn('Löschen', 'ghost danger', () => { if (confirm('Diese Fehlermeldung samt Screenshot löschen?')) run(() => api(`api/bugs/${b.id}`, { method: 'DELETE' }), 'Meldung gelöscht.'); }); + grow.appendChild(actions); + li.appendChild(grow); + return li; +} +function renderBugs() { + const all = state.data.bugs || []; + const open = all.filter((b) => b.status === 'offen').sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + const done = all.filter((b) => b.status !== 'offen').sort((a, b) => (b.resolvedAt || '').localeCompare(a.resolvedAt || '')); + $('#bugs-clipboard-hint').hidden = canCopyImages(); + const list = $('#bug-list'); + list.textContent = ''; + if (open.length === 0) list.innerHTML = '
  • Keine offenen Fehlermeldungen.
  • '; + for (const b of open) list.appendChild(bugRow(b)); + const doneList = $('#bug-list-done'); + doneList.textContent = ''; + for (const b of done) doneList.appendChild(bugRow(b)); + doneList.hidden = !$('#bugs-show-done').checked || done.length === 0; + $('#bugs-toggle').hidden = done.length === 0; +} +$('#bugs-show-done').addEventListener('change', renderBugs); + // ---------- Start ---------- function renderAll() { applyAuthUI(); @@ -1143,6 +1462,7 @@ function renderAll() { renderPhotoField(); renderAbsences(); renderRequests(); + renderBugs(); renderSettings(); if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport(); } else { diff --git a/public/index.html b/public/index.html index 9f8b3bc..10541d1 100644 --- a/public/index.html +++ b/public/index.html @@ -17,6 +17,7 @@ Personen Abwesenheiten Anfragen + Fehler Einstellungen @@ -157,6 +158,16 @@ + + +