Initial commit
CI / test (push) Canceled after 0s

This commit is contained in:
2026-09-12 22:22:17 +02:00
commit 904d14b64c
314 changed files with 31884 additions and 0 deletions
+816
View File
@@ -0,0 +1,816 @@
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)];
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();
});