if ("serviceWorker" in navigator) { window.addEventListener("load", () => { navigator.serviceWorker.register("/static/sw.js"); }); } const viewPreferenceKey = `gardomatic.view-preferences.${document.body.dataset.userId || "anonymous"}`; function readViewPreferences() { try { return { default: "grid", ...JSON.parse(localStorage.getItem(viewPreferenceKey) || "{}") }; } catch (_) { return { default: "grid" }; } } function initializeCollections(root = document) { const preferences = readViewPreferences(); const selectedMode = (key) => { const selected = preferences[key]; return selected && selected !== "inherit" ? selected : preferences.default; }; root.querySelectorAll("[data-background-image]").forEach((card) => { card.style.backgroundImage = `url("${card.dataset.backgroundImage}")`; }); root.querySelectorAll("[data-view-key]").forEach((collection) => { collection.dataset.viewMode = selectedMode(collection.dataset.viewKey); }); root.querySelectorAll("[data-view-variants]").forEach((container) => { const mode = selectedMode(container.dataset.viewKey); container.querySelectorAll(":scope > [data-view-variant]").forEach((variant) => { variant.hidden = variant.dataset.viewVariant !== mode; }); }); } function initializeViewToggles(root = document) { root.querySelectorAll("[data-view-toggle]").forEach((button) => { const key = button.dataset.viewToggle; const update = () => { const preferences = readViewPreferences(); const mode = preferences[key] && preferences[key] !== "inherit" ? preferences[key] : preferences.default; button.dataset.viewMode = mode; button.setAttribute("aria-label", mode === "grid" ? "Als Liste anzeigen" : "Als Kacheln anzeigen"); button.title = button.getAttribute("aria-label"); }; button.addEventListener("click", () => { const preferences = readViewPreferences(); const current = preferences[key] && preferences[key] !== "inherit" ? preferences[key] : preferences.default; const next = current === "grid" ? "list" : "grid"; preferences[key] = next; localStorage.setItem(viewPreferenceKey, JSON.stringify(preferences)); root.querySelectorAll(`[data-view-key="${key}"]`).forEach((collection) => { collection.dataset.viewMode = next; if (collection.hasAttribute("data-view-variants")) collection.querySelectorAll(":scope > [data-view-variant]").forEach((variant) => { variant.hidden = variant.dataset.viewVariant !== next; }); }); update(); }); update(); }); } function initializeViewSettings() { const form = document.querySelector("[data-view-settings]"); if (!form) return; const preferences = readViewPreferences(); Array.from(form.elements).forEach((field) => { if (!(field instanceof HTMLSelectElement)) return; if (field.name === "entriesPerPage") field.value = preferences.entriesPerPage || preferences.tasksPerPage || "20"; else field.value = preferences[field.name] || (field.name === "default" ? "grid" : "inherit"); }); form.addEventListener("submit", (event) => { event.preventDefault(); const next = {}; new FormData(form).forEach((value, key) => { next[key] = value; }); localStorage.setItem(viewPreferenceKey, JSON.stringify(next)); const userSuffix = document.body.dataset.userId ? `.${document.body.dataset.userId}` : ""; document.cookie = `gardomatic.entries-per-page${userSuffix}=${encodeURIComponent(next.entriesPerPage || "20")}; Path=/; Max-Age=31536000; SameSite=Lax`; form.querySelector("[data-settings-message]").textContent = "Einstellungen gespeichert."; }); } function initializeSearchTabs() { const tabs = Array.from(document.querySelectorAll("[data-search-tab]")); if (!tabs.length) return; const sections = tabs.map((tab) => document.getElementById(tab.dataset.searchTab)).filter(Boolean); const select = (id) => { tabs.forEach((tab) => { const selected = tab.dataset.searchTab === id; tab.setAttribute("aria-current", selected ? "page" : "false"); }); sections.forEach((section) => { section.hidden = section.id !== id; }); }; tabs.forEach((tab) => tab.addEventListener("click", () => select(tab.dataset.searchTab))); const initial = window.location.hash.slice(1); select(sections.some((section) => section.id === initial) ? initial : "search-all"); } initializeCollections(); initializeViewToggles(); initializeViewSettings(); initializeSearchTabs(); document.querySelector("[data-history-back]")?.addEventListener("click", () => window.history.back()); function initializePlantCompatibility(root = document) { const species = root.querySelector("[data-plant-species]"); const warning = root.querySelector("[data-compatibility-warning]"); if (!species || !warning) return; const check = () => { const expected = species.selectedOptions[0]?.dataset || {}; const problems = []; root.querySelectorAll("[data-assignment-row] select[name='location_id']").forEach((select) => { const actual = root.querySelector(`[data-location-id="${select.value}"]`)?.dataset || {}; const name = select.selectedOptions[0]?.textContent?.trim(); const fields=[]; if(expected.sun && actual.sun && expected.sun!==actual.sun) fields.push("Lichtverhältnis"); if(expected.soil && actual.soil && expected.soil!==actual.soil) fields.push("Bodenbeschaffenheit"); if(expected.reaction && actual.reaction && expected.reaction!==actual.reaction) fields.push("Bodenreaktion"); if(fields.length) problems.push(`${name}: ${fields.join(" und ")}`); }); warning.hidden=problems.length===0; warning.textContent=problems.length?`Hinweis: Standort und Art unterscheiden sich bei ${problems.join("; ")}.`:""; }; root.addEventListener("change",(event)=>{if(event.target===species||event.target.matches("select[name='location_id']"))check()}); check(); } initializePlantCompatibility(); function initializeJournalEditor() { const form = document.querySelector("[data-journal-editor]"); const textarea = form?.querySelector("[data-journal-body]"); const mount = document.querySelector("#journal-editor"); if (!form || !textarea || !mount) return; const embeddedInput = form.querySelector("[data-embedded-images]"); const embeddedFiles = new DataTransfer(); const richTextEnabled = readViewPreferences().journalEditor !== "plain" && window.toastui?.Editor; if (richTextEnabled) { const editor = new toastui.Editor({ el: mount, height: "480px", minHeight: "300px", initialEditType: "wysiwyg", previewStyle: "vertical", initialValue: textarea.value, language: "de-DE", usageStatistics: false, placeholder: form.dataset.entryKind === "pinboard" ? "Idee oder Notiz festhalten …" : "Was ist heute im Garten passiert?", hooks: { addImageBlobHook(blob, callback) { const extension = (blob.type.split("/")[1] || "jpg").replace("jpeg", "jpg"); const file = blob instanceof File ? blob : new File([blob], `tagebuchbild-${Date.now()}.${extension}`, { type: blob.type || "image/jpeg" }); const index = embeddedFiles.files.length; embeddedFiles.items.add(file); embeddedInput.files = embeddedFiles.files; callback(`/pending-journal-image/${index}`, file.name); }, }, }); textarea.hidden = true; form.addEventListener("submit", () => { textarea.value = editor.getMarkdown(); }); } else { mount.hidden = true; textarea.hidden = false; textarea.placeholder = form.dataset.entryKind === "pinboard" ? "Idee oder Notiz festhalten …" : "Was ist heute im Garten passiert?"; } const fileInputs = Array.from(form.querySelectorAll("[data-journal-files]")); const generatedInput = form.querySelector("[data-journal-generated-files]"); const preview = form.querySelector("[data-attachment-preview]"); const libraryImages = Array.from(form.querySelectorAll("[data-journal-library-image]")); let chosenFiles = new DataTransfer(); const chosenEntries = []; const rebuildChosenFiles = () => { chosenFiles = new DataTransfer(); chosenEntries.forEach((entry) => chosenFiles.items.add(entry.file)); generatedInput.files = chosenFiles.files; }; const addChosenFile = (file, photoId) => { chosenEntries.push({ file, photoId }); rebuildChosenFiles(); renderFiles(); }; const replaceChosenFile = (file, photoId) => { const entry = chosenEntries.find((value) => value.photoId === photoId); if (!entry) return addChosenFile(file, photoId); entry.file = file; rebuildChosenFiles(); renderFiles(); }; const renderFiles = () => { const nativeFiles = fileInputs.flatMap((input) => Array.from(input.files || [])); const uploaded = [...nativeFiles, ...Array.from(chosenFiles.files)].map((file) => { const item = document.createElement("li"); item.textContent = `${file.name} · ${(file.size / 1024 / 1024).toFixed(1)} MB`; return item; }); const fromLibrary = libraryImages.filter((input) => input.checked).map((input) => { const item = document.createElement("li"); const size = Number(input.dataset.imageSize || 0); item.textContent = `${input.dataset.imageName || "Bild"} · ${(size / 1024 / 1024).toFixed(1)} MB · Bilderdatenbank`; return item; }); preview.replaceChildren(...uploaded,...fromLibrary); }; fileInputs.forEach((input) => input.addEventListener("change", renderFiles)); libraryImages.forEach((input) => input.addEventListener("change", renderFiles)); renderFiles(); form.addEventListener("journal-photo-ready", (event) => { const data = event.detail?.data; if (!data?.startsWith("data:")) return; const [header, encoded] = data.split(",", 2); const mime = header.match(/data:([^;]+)/)?.[1] || "image/jpeg"; const binary = atob(encoded || ""); const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); const file = new File([bytes], `tagebuchfoto-${Date.now()}.jpg`, { type: mime }); if (event.detail?.photoId) replaceChosenFile(file, event.detail.photoId); else addChosenFile(file); }); const recordButton = form.querySelector("[data-audio-record]"); const audioDialog = document.querySelector("[data-audio-dialog]"); const audioToggle = document.querySelector("[data-audio-toggle]"); const audioPause = document.querySelector("[data-audio-pause]"); const audioApply = document.querySelector("[data-audio-apply]"); const audioPreview = document.querySelector("[data-audio-preview]"); const audioCancel = document.querySelector("[data-audio-cancel]"); const audioStatus = document.querySelector("[data-audio-status]"); const audioError = document.querySelector("[data-audio-error]"); const recordingIndicator = document.querySelector("[data-audio-indicator]"); let recorder; let recordingStream; let chunks = []; let cancelAudio = false; let pendingAudio; let audioPreviewURL; const stopAudioStream = () => { recordingStream?.getTracks().forEach((track) => track.stop()); recordingStream = undefined; }; const clearAudioPreview = () => { if (audioPreviewURL) URL.revokeObjectURL(audioPreviewURL); audioPreviewURL = undefined; pendingAudio = undefined; audioPreview.removeAttribute("src"); audioPreview.load(); audioPreview.hidden = true; audioApply.hidden = true; audioPause.textContent = "Pause"; audioPause.hidden = true; recordingIndicator.hidden = true; }; recordButton?.addEventListener("click", () => { clearAudioPreview(); audioError.textContent = ""; audioStatus.textContent = "Bereit zur Aufnahme."; audioToggle.textContent = "Aufnahme starten"; audioToggle.hidden = false; audioPause.hidden = true; audioDialog?.showModal(); }); audioToggle?.addEventListener("click", async () => { if (recorder?.state === "recording" || recorder?.state === "paused") { recorder.stop(); return; } if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) { audioError.textContent = "Audioaufnahme ist in diesem Browser oder ohne HTTPS nicht verfügbar."; return; } try { recordingStream = await navigator.mediaDevices.getUserMedia({ audio: true }); recorder = new MediaRecorder(recordingStream); chunks = []; recorder.addEventListener("dataavailable", (event) => { if (event.data.size) chunks.push(event.data); }); recorder.addEventListener("stop", () => { if (cancelAudio) { cancelAudio = false; chunks = []; stopAudioStream(); return; } const type = recorder.mimeType || "audio/webm"; pendingAudio = new File(chunks, `audio-${new Date().toISOString().replace(/[:.]/g, "-")}.webm`, { type }); audioPreviewURL = URL.createObjectURL(pendingAudio); audioPreview.src = audioPreviewURL; audioPreview.hidden = false; stopAudioStream(); audioToggle.hidden = true; audioPause.hidden = true; recordingIndicator.hidden = true; audioApply.hidden = false; audioStatus.textContent = "Aufnahme bereit. Bitte prüfen und übernehmen."; }); recorder.start(); audioToggle.textContent = "Aufnahme stoppen"; audioPause.hidden = false; recordingIndicator.hidden = false; audioStatus.textContent = "Aufnahme läuft …"; } catch (_) { audioError.textContent = "Das Mikrofon konnte nicht geöffnet werden. Bitte prüfe die Freigabe."; } }); audioPause?.addEventListener("click", () => { if (!recorder) return; if (recorder.state === "recording") { recorder.pause(); audioPause.textContent = "Fortsetzen"; recordingIndicator.hidden = true; audioStatus.textContent = "Aufnahme pausiert."; } else if (recorder.state === "paused") { recorder.resume(); audioPause.textContent = "Pause"; recordingIndicator.hidden = false; audioStatus.textContent = "Aufnahme läuft …"; } }); audioApply?.addEventListener("click", () => { if (!pendingAudio) return; addChosenFile(pendingAudio); clearAudioPreview(); audioDialog?.close(); }); audioCancel?.addEventListener("click", () => { if (recorder?.state === "recording" || recorder?.state === "paused") { cancelAudio = true; recorder.stop(); } else stopAudioStream(); clearAudioPreview(); audioDialog?.close(); }); audioDialog?.addEventListener("close", () => { if (recorder?.state === "recording" || recorder?.state === "paused") { cancelAudio = true; recorder.stop(); } stopAudioStream(); clearAudioPreview(); }); const videoButton = form.querySelector("[data-video-record]"); const videoDialog = document.querySelector("[data-video-dialog]"); const videoPreview = document.querySelector("[data-video-preview]"); const videoToggle = document.querySelector("[data-video-toggle]"); const videoPause = document.querySelector("[data-video-pause]"); const videoApply = document.querySelector("[data-video-apply]"); const videoCancel = document.querySelector("[data-video-cancel]"); const videoSwitchCamera = document.querySelector("[data-video-switch-camera]"); const videoError = document.querySelector("[data-video-error]"); let videoStream; let videoRecorder; let videoChunks = []; let cancelVideo = false; let pendingVideo; let videoPreviewURL; let videoFacingMode = "environment"; const stopVideoStream = () => { videoStream?.getTracks().forEach((track) => track.stop()); videoStream = undefined; videoPreview.srcObject = null; }; const startVideoStream = async () => { stopVideoStream(); videoStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: videoFacingMode } }, audio: true }); videoPreview.srcObject = videoStream; await videoPreview.play(); const devices = await navigator.mediaDevices.enumerateDevices?.(); if (videoSwitchCamera) videoSwitchCamera.hidden = devices ? devices.filter((device) => device.kind === "videoinput").length < 2 : false; }; const clearVideoPreview = () => { videoPreview.pause(); if (videoPreviewURL) URL.revokeObjectURL(videoPreviewURL); videoPreviewURL = undefined; pendingVideo = undefined; videoPreview.removeAttribute("src"); videoPreview.load(); videoPreview.controls = false; videoPreview.muted = true; videoApply.hidden = true; videoPause.textContent = "Pause"; videoPause.hidden = true; }; videoButton?.addEventListener("click", async () => { clearVideoPreview(); videoToggle.hidden = false; videoPause.hidden = true; if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) { videoError.textContent = "Videoaufnahme ist in diesem Browser oder ohne HTTPS nicht verfügbar."; videoDialog.showModal(); return; } try { await startVideoStream(); videoError.textContent = ""; videoToggle.textContent = "Aufnahme starten"; videoDialog.showModal(); } catch (_) { videoError.textContent = "Kamera und Mikrofon konnten nicht geöffnet werden. Bitte prüfe die Freigaben."; videoDialog.showModal(); } }); videoSwitchCamera?.addEventListener("click", async () => { videoFacingMode = videoFacingMode === "environment" ? "user" : "environment"; videoError.textContent = "Kamera wird gewechselt …"; try { await startVideoStream(); videoError.textContent = ""; } catch (_) { videoError.textContent = "Die andere Kamera konnte nicht geöffnet werden."; } }); videoToggle?.addEventListener("click", () => { if (videoRecorder?.state === "recording" || videoRecorder?.state === "paused") { videoRecorder.stop(); return; } if (!videoStream) return; videoChunks = []; videoRecorder = new MediaRecorder(videoStream); if (videoSwitchCamera) videoSwitchCamera.hidden = true; videoRecorder.addEventListener("dataavailable", (event) => { if (event.data.size) videoChunks.push(event.data); }); videoRecorder.addEventListener("stop", () => { if (cancelVideo) { cancelVideo = false; videoChunks = []; stopVideoStream(); return; } const type = videoRecorder.mimeType || "video/webm"; pendingVideo = new File(videoChunks, `video-${new Date().toISOString().replace(/[:.]/g, "-")}.webm`, { type }); stopVideoStream(); videoPreviewURL = URL.createObjectURL(pendingVideo); videoPreview.src = videoPreviewURL; videoPreview.controls = true; videoPreview.muted = false; videoToggle.hidden = true; videoPause.hidden = true; videoApply.hidden = false; videoError.textContent = "Aufnahme bereit. Bitte prüfen und übernehmen."; }); videoRecorder.start(); videoToggle.textContent = "Aufnahme stoppen"; videoPause.hidden = false; }); videoPause?.addEventListener("click", () => { if (!videoRecorder) return; if (videoRecorder.state === "recording") { videoRecorder.pause(); videoPause.textContent = "Fortsetzen"; videoError.textContent = "Aufnahme pausiert."; } else if (videoRecorder.state === "paused") { videoRecorder.resume(); videoPause.textContent = "Pause"; videoError.textContent = "Aufnahme läuft …"; } }); videoApply?.addEventListener("click", () => { if (!pendingVideo) return; addChosenFile(pendingVideo); clearVideoPreview(); videoDialog.close(); }); videoCancel?.addEventListener("click", () => { if (videoRecorder?.state === "recording" || videoRecorder?.state === "paused") { cancelVideo = true; videoRecorder.stop(); } else stopVideoStream(); clearVideoPreview(); videoDialog.close(); }); videoDialog?.addEventListener("close", () => { if (videoRecorder?.state === "recording" || videoRecorder?.state === "paused") { cancelVideo = true; videoRecorder.stop(); } stopVideoStream(); clearVideoPreview(); }); } initializeJournalEditor(); function initializeTagEditors(root = document) { root.querySelectorAll("[data-tag-editor]").forEach((editor) => { if (editor.dataset.tagInitialized) return; const input = editor.querySelector("[data-tag-input]"); const bubbles = editor.querySelector("[data-tag-bubbles]"); const suggestions = editor.querySelector("[data-tag-suggestions]"); if (!input || !bubbles || !suggestions) return; let tags = input.value.split(",").map((tag) => tag.trim().toLowerCase()).filter(Boolean); tags = [...new Set(tags)]; input.value = ""; const render = () => { bubbles.replaceChildren(...tags.map((tag) => { const bubble = document.createElement("span"); bubble.className = "tag-bubble"; bubble.textContent = tag; const remove = document.createElement("button"); remove.type = "button"; remove.className = "tag-bubble-remove"; remove.textContent = "×"; remove.setAttribute("aria-label", `${tag} entfernen`); remove.addEventListener("click", () => { tags = tags.filter((value) => value !== tag); render(); }); bubble.append(remove); return bubble; })); updateSuggestions(); }; const updateSuggestions = () => { const prefix = input.value.trim().toLowerCase(); let visible = 0; suggestions.querySelectorAll("[data-tag-suggestion]").forEach((button) => { const value = button.dataset.tagSuggestion.toLowerCase(); const show = prefix.length > 0 && value.startsWith(prefix) && !tags.includes(value); button.hidden = !show; visible += show ? 1 : 0; }); suggestions.hidden = visible === 0; }; const add = (value) => { value = value.trim().toLowerCase(); if (value && !tags.includes(value)) tags.push(value); input.value = ""; render(); }; input.addEventListener("input", () => { if (input.value.includes(",")) { const values = input.value.split(","); values.slice(0, -1).forEach(add); input.value = values.at(-1).trim(); } updateSuggestions(); }); input.addEventListener("keydown", (event) => { if ((event.key === "Enter" || event.key === ",") && input.value.trim()) { event.preventDefault(); add(input.value); } if (event.key === "Backspace" && !input.value && tags.length) { tags.pop(); render(); } }); suggestions.querySelectorAll("[data-tag-suggestion]").forEach((button) => button.addEventListener("click", () => add(button.dataset.tagSuggestion))); editor.closest("form")?.addEventListener("submit", () => { if (input.value.trim()) add(input.value); input.value = tags.join(", "); }); render(); editor.dataset.tagInitialized = "true"; }); } initializeTagEditors(); document.addEventListener("click", (event) => { document.querySelectorAll(".user-menu[open]").forEach((menu) => { if (!menu.contains(event.target)) { menu.removeAttribute("open"); } }); if (event.target.closest("[data-close-dialog]")) { event.target.closest("dialog")?.remove(); } const dialogOpener = event.target.closest("[data-open-dialog]"); if (dialogOpener) { const dialog = document.getElementById(dialogOpener.dataset.openDialog); if (dialog instanceof HTMLDialogElement && !dialog.open) dialog.showModal(); } if (event.target.closest("[data-cancel-dialog]")) { event.target.closest("dialog")?.close(); } const card = event.target.closest("[data-card-href]"); if (card && !event.target.closest("a, button, input, select, textarea, form, summary, details, video, audio, img")) { window.location.assign(card.dataset.cardHref); } const addAssignment = event.target.closest("[data-add-assignment]"); if (addAssignment) { const template = document.querySelector("#assignment-row-template"); const list = document.querySelector("[data-assignment-list]"); if (template && list) { list.append(template.content.cloneNode(true)); refreshAssignmentFields(list); list.lastElementChild?.querySelector("select")?.focus(); } } const removeAssignment = event.target.closest("[data-remove-assignment]"); if (removeAssignment) { const row = removeAssignment.closest("[data-assignment-row]"); const assignmentID = row?.querySelector("[name='assignment_id']"); if (row && assignmentID?.value !== "0") { row.querySelector("[name='location_id']").value = "0"; row.querySelector("[name='quantity']").value = "1"; row.hidden = true; } else { row?.remove(); } refreshAssignmentFields(document.querySelector("[data-assignment-list]")); } const removePlantTask = event.target.closest("[data-remove-plant-task]"); if (removePlantTask) { const row = removePlantTask.closest("[data-plant-task-row]"); const taskID = row?.querySelector("[name='plant_task_id']"); if (row && taskID?.value !== "0") { row.querySelector("[data-plant-task-delete]").value = "true"; row.hidden = true; row.querySelectorAll("[required]").forEach((field) => field.required = false); } else { row?.remove(); } } }); function initializePlantName() { const name = document.querySelector("[data-plant-name]"); const species = document.querySelector("[data-plant-species]"); if (!name || !species || name.dataset.nameInitialized) return; const selectedName = species.selectedOptions[0]?.dataset.plantNameValue || ""; let manuallyEdited = name.value !== "" && name.value !== selectedName; if (!name.value && selectedName) name.value = selectedName; name.addEventListener("input", () => { manuallyEdited = true; }); species.addEventListener("change", () => { if (manuallyEdited) return; name.value = species.selectedOptions[0]?.dataset.plantNameValue || ""; }); name.dataset.nameInitialized = "true"; } initializePlantName(); function initializeImageEditors(root = document) { root.querySelectorAll("[data-image-editor]").forEach((editor) => { if (editor.dataset.imageInitialized) return; const value = editor.querySelector("[data-image-value]"); const imageID = editor.querySelector("[data-image-id]"); const preview = editor.querySelector("[data-image-preview]"); const placeholder = preview?.querySelector("span"); const dialog = editor.querySelector("[data-image-dialog]"); const image = editor.querySelector("[data-cropper-image]"); const remove = editor.querySelector("[data-image-remove]"); const apply = editor.querySelector("[data-image-apply]"); const error = editor.querySelector("[data-image-error]"); const cameraDialog = editor.querySelector("[data-camera-dialog]"); const cameraVideo = editor.querySelector("[data-camera-video]"); const cameraCapture = editor.querySelector("[data-camera-capture]"); const cameraSwitch = editor.querySelector("[data-camera-switch]"); const cameraError = editor.querySelector("[data-camera-error]"); let cropper; let cameraStream; let cameraFacingMode = "environment"; if (value.value) { if (preview) preview.style.backgroundImage = `url("${value.value}")`; if (placeholder) placeholder.hidden = true; if (remove) remove.hidden = false; } const openCropper = (source) => { cropper?.destroy(); cropper = undefined; apply.disabled = true; error.textContent = "Bild wird geladen …"; image.onload = () => { cropper = new Cropper(image, { aspectRatio: 3 / 2, viewMode: 1, autoCropArea: 1, background: false, ready() { apply.disabled = false; error.textContent = ""; }, }); }; image.onerror = () => { error.textContent = "Das Bild konnte nicht geladen werden."; }; image.src = String(source); dialog.showModal(); }; const choose = (event) => { const file = event.target.files?.[0]; if (!file || !file.type.startsWith("image/")) return; const reader = new FileReader(); reader.onload = () => openCropper(reader.result); reader.readAsDataURL(file); event.target.value = ""; }; editor.querySelector("[data-image-file]")?.addEventListener("change", choose); const stopCamera = () => { cameraStream?.getTracks().forEach((track) => track.stop()); cameraStream = undefined; cameraVideo.srcObject = null; cameraCapture.disabled = true; }; const startCamera = async () => { stopCamera(); cameraStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: cameraFacingMode } }, audio: false }); cameraVideo.srcObject = cameraStream; await cameraVideo.play(); cameraCapture.disabled = cameraVideo.videoWidth === 0; cameraVideo.onloadedmetadata = () => { cameraCapture.disabled = false; }; const devices = await navigator.mediaDevices.enumerateDevices?.(); if (cameraSwitch) cameraSwitch.hidden = devices ? devices.filter((device) => device.kind === "videoinput").length < 2 : false; }; editor.querySelector("[data-image-camera]")?.addEventListener("click", async () => { cameraError.textContent = "Webcam wird gestartet …"; cameraCapture.disabled = true; cameraDialog.showModal(); if (!navigator.mediaDevices?.getUserMedia) { cameraError.textContent = "Die Webcam ist in diesem Browser oder ohne HTTPS nicht verfügbar."; return; } try { await startCamera(); cameraError.textContent = ""; } catch (_) { cameraError.textContent = "Die Webcam konnte nicht geöffnet werden. Bitte prüfe die Kamerafreigabe des Browsers."; stopCamera(); } }); cameraSwitch?.addEventListener("click", async () => { cameraFacingMode = cameraFacingMode === "environment" ? "user" : "environment"; cameraError.textContent = "Kamera wird gewechselt …"; cameraCapture.disabled = true; try { await startCamera(); cameraError.textContent = ""; } catch (_) { cameraError.textContent = "Die andere Kamera konnte nicht geöffnet werden."; stopCamera(); } }); editor.querySelector("[data-camera-cancel]")?.addEventListener("click", () => { stopCamera(); cameraDialog.close(); }); cameraDialog?.addEventListener("close", stopCamera); cameraCapture?.addEventListener("click", () => { if (!cameraVideo.videoWidth || !cameraVideo.videoHeight) return; const canvas = document.createElement("canvas"); canvas.width = cameraVideo.videoWidth; canvas.height = cameraVideo.videoHeight; canvas.getContext("2d").drawImage(cameraVideo, 0, 0); const source = canvas.toDataURL("image/jpeg", 0.9); stopCamera(); cameraDialog.close(); if (editor.hasAttribute("data-journal-photo-editor")) editor.dataset.photoId = `photo-${Date.now()}-${Math.random().toString(36).slice(2)}`; openCropper(source); }); editor.querySelector("[data-image-rotate-left]")?.addEventListener("click", () => cropper?.rotate(-90)); editor.querySelector("[data-image-rotate-right]")?.addEventListener("click", () => cropper?.rotate(90)); editor.querySelector("[data-image-cancel]")?.addEventListener("click", () => { dialog.close(); cropper?.destroy(); cropper = undefined; image.onload = null; }); apply?.addEventListener("click", () => { if (!cropper) return; const canvas = cropper.getCroppedCanvas({ width: 960, height: 640, imageSmoothingQuality: "high" }); if (!canvas) { error.textContent = "Das Bild ist noch nicht bereit. Bitte versuche es gleich noch einmal."; return; } const data = canvas.toDataURL("image/jpeg", 0.82); value.value = data; if (imageID) imageID.value = ""; if (editor.hasAttribute("data-journal-photo-editor")) editor.closest("form")?.dispatchEvent(new CustomEvent("journal-photo-ready", { detail: { data, photoId: editor.dataset.photoId } })); if (preview) preview.style.backgroundImage = `url("${data}")`; if (placeholder) placeholder.hidden = true; if (remove) remove.hidden = false; dialog.close(); cropper.destroy(); cropper = undefined; }); remove?.addEventListener("click", () => { value.value = ""; preview.style.backgroundImage = ""; placeholder.hidden = false; remove.hidden = true; if (imageID) imageID.value = ""; }); const libraryDialog = editor.querySelector("[data-image-library-dialog]"); editor.querySelector("[data-image-library-open]")?.addEventListener("click", () => libraryDialog?.showModal()); editor.querySelector("[data-image-library-cancel]")?.addEventListener("click", () => libraryDialog?.close()); editor.querySelectorAll("[data-image-library-select]").forEach((button) => button.addEventListener("click", () => { value.value = ""; if (imageID) imageID.value = button.dataset.imageId || ""; if (preview) preview.style.backgroundImage = `url("${button.dataset.imageUrl}")`; if (placeholder) placeholder.hidden = true; if (remove) remove.hidden = false; libraryDialog?.close(); })); editor.dataset.imageInitialized = "true"; }); } initializeImageEditors(); document.querySelector("[data-species-template]")?.addEventListener("change", (event) => { const url = new URL(window.location.href); if (event.target.value === "0") url.searchParams.delete("template_id"); else url.searchParams.set("template_id", event.target.value); window.location.assign(url); }); function initializeTaskTemplateTriggers(root = document) { root.querySelectorAll("[data-template-trigger]").forEach((trigger) => { if (trigger.dataset.triggerInitialized) return; const update = () => { const form = trigger.closest("form"); form?.querySelectorAll("[data-trigger-fields]").forEach((group) => { const visible = group.dataset.triggerFields === trigger.value || (group.dataset.triggerFields === "relative" && trigger.value !== "month_of_year"); group.hidden = !visible; group.disabled = !visible; }); }; trigger.addEventListener("change", update); trigger.dataset.triggerInitialized = "true"; update(); }); } initializeTaskTemplateTriggers(); function initializeRoleEditors(root = document) { root.querySelectorAll("[data-role-editor]").forEach((editor) => { if (editor.dataset.roleEditorInitialized) return; const select = editor.querySelector("[data-role-select]"); const update = () => editor.querySelectorAll("[data-role-pane]").forEach((pane) => { pane.hidden = pane.dataset.rolePane !== select.value; }); select?.addEventListener("change", update); editor.dataset.roleEditorInitialized = "true"; update(); }); } initializeRoleEditors(); document.addEventListener("change", (event) => { const control = event.target.closest("[data-toggle-control]"); if (!control) return; const row = control.closest(".plant-task-row, .plant-template-task-row"); const value = row?.querySelector("[data-toggle-value]"); if (value) value.value = control.checked ? "true" : "false"; }); function refreshAssignmentFields(list) { list?.querySelectorAll("[data-assignment-row]").forEach((row, index) => { const select = row.querySelector("[name='location_id']"); const quantity = row.querySelector("[name='quantity']"); const locationLabel = select?.previousElementSibling; const quantityLabel = quantity?.previousElementSibling; if (select) select.id = index === 0 ? "location_id" : `location_id_${index}`; if (quantity) quantity.id = `quantity_${index}`; if (locationLabel) locationLabel.htmlFor = select.id; if (quantityLabel) quantityLabel.htmlFor = quantity.id; }); } document.addEventListener("keydown", (event) => { if (event.key === "Escape") { document.querySelectorAll(".user-menu[open]").forEach((menu) => { menu.removeAttribute("open"); menu.querySelector("summary")?.focus(); }); } const card = event.target.closest("[data-card-href]"); if (card && !event.target.closest("a, button, input, select, textarea, form, summary, details") && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); window.location.assign(card.dataset.cardHref); } }); document.addEventListener("locationCreated", () => { document.querySelector("#location-dialog-host dialog")?.remove(); }); document.addEventListener("plantTaskSaved", () => { document.querySelector("#plant-task-dialog-host dialog")?.remove(); }); document.addEventListener("htmx:afterSwap", (event) => { initializeTaskTemplateTriggers(event.detail.target || document); initializeImageEditors(event.detail.target || document); initializeCollections(event.detail.target || document); initializeTagEditors(event.detail.target || document); initializeViewToggles(event.detail.target || document); initializeRoleEditors(event.detail.target || document); const dialog = event.detail.target?.querySelector(".location-dialog, .task-template-dialog, .plant-task-dialog"); if (dialog && !dialog.open) { dialog.showModal(); } }); document.addEventListener("htmx:afterRequest", (event) => { if (!event.detail.successful) return; const trigger = event.detail.elt; const addSection = trigger?.closest(".care-instruction-add"); if (addSection) addSection.querySelector("form")?.reset(); });