Files
Gardomatic/internal/web/static/js/main.js
T
2026-09-16 20:30:38 +02:00

818 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});