817 lines
36 KiB
JavaScript
817 lines
36 KiB
JavaScript
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();
|
||
});
|