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
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 NHN Cloud Corp.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+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();
});
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
/*!
* TOAST UI Editor : i18n
* @version 3.2.2
* @author NHN Cloud FE Development Lab <dl_javascript@nhn.com>
* @license MIT
*/
!function(e,n){if("object"==typeof exports&&"object"==typeof module)module.exports=n(require("@toast-ui/editor"));else if("function"==typeof define&&define.amd)define(["@toast-ui/editor"],n);else{var t="object"==typeof exports?n(require("@toast-ui/editor")):n(e.toastui.Editor);for(var o in t)("object"==typeof exports?exports:e)[o]=t[o]}}(self,(function(e){return function(){"use strict";var n={213:function(n){n.exports=e}},t={};function o(e){var r=t[e];if(void 0!==r)return r.exports;var i=t[e]={exports:{}};return n[e](i,i.exports,o),i.exports}o.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return o.d(n,{a:n}),n},o.d=function(e,n){for(var t in n)o.o(n,t)&&!o.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:n[t]})},o.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},o.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})};var r={};return function(){o.r(r);var e=o(213);o.n(e)().setLanguage(["de","de-DE"],{Markdown:"Markdown",WYSIWYG:"WYSIWYG",Write:"Verfassen",Preview:"Vorschau",Headings:"Überschriften",Paragraph:"Text",Bold:"Fett",Italic:"Kursiv",Strike:"Durchgestrichen",Code:"Code",Line:"Trennlinie",Blockquote:"Blocktext","Unordered list":"Aufzählung","Ordered list":"Nummerierte Aufzählung",Task:"Aufgabe",Indent:"Einrücken",Outdent:"Ausrücken","Insert link":"Link einfügen","Insert CodeBlock":"Codeblock einfügen","Insert table":"Tabelle einfügen","Insert image":"Grafik einfügen",Heading:"Titel","Image URL":"Bild URL","Select image file":"Grafik auswählen","Choose a file":"Wähle eine Datei","No file":"Keine Datei",Description:"Beschreibung",OK:"OK",More:"Mehr",Cancel:"Abbrechen",File:"Datei",URL:"URL","Link text":"Anzuzeigender Text","Add row to up":"Zeile nach oben hinzufügen","Add row to down":"Zeile nach unten hinzufügen","Add column to left":"Spalte links hinzufügen","Add column to right":"Spalte rechts hinzufügen","Remove row":"Zeile entfernen","Remove column":"Spalte entfernen","Align column to left":"Links ausrichten","Align column to center":"Zentrieren","Align column to right":"Rechts ausrichten","Remove table":"Tabelle entfernen","Would you like to paste as table?":"Möchten Sie eine Tabelle einfügen?","Text color":"Textfarbe","Auto scroll enabled":"Autoscrollen aktiviert","Auto scroll disabled":"Autoscrollen deaktiviert","Choose language":"Sprache auswählen"})}(),r}()}));