445 lines
20 KiB
JavaScript
445 lines
20 KiB
JavaScript
(function () {
|
||
const loginView = document.getElementById("loginView");
|
||
const adminView = document.getElementById("adminView");
|
||
const loginForm = document.getElementById("loginForm");
|
||
const passwordInput = document.getElementById("passwordInput");
|
||
const loginMessage = document.getElementById("loginMessage");
|
||
const addDateMessage = document.getElementById("addDateMessage");
|
||
const datesList = document.getElementById("datesList");
|
||
const dateCount = document.getElementById("dateCount");
|
||
const emptyDates = document.getElementById("emptyDates");
|
||
const logoutBtn = document.getElementById("logoutBtn");
|
||
const monthLabel = document.getElementById("monthLabel");
|
||
const weekdayRow = document.getElementById("weekdayRow");
|
||
const dayGrid = document.getElementById("dayGrid");
|
||
const prevMonthBtn = document.getElementById("prevMonthBtn");
|
||
const nextMonthBtn = document.getElementById("nextMonthBtn");
|
||
const saveDatesBtn = document.getElementById("saveDatesBtn");
|
||
const activitiesList = document.getElementById("activitiesList");
|
||
const emptyActivities = document.getElementById("emptyActivities");
|
||
const activityMessage = document.getElementById("activityMessage");
|
||
const activityAddRow = document.getElementById("activityAddRow");
|
||
const newActivityIcon = document.getElementById("newActivityIcon");
|
||
const newActivityName = document.getElementById("newActivityName");
|
||
const newActivityType = document.getElementById("newActivityType");
|
||
const activityIcons = ["🧺", "🍸", "💆", "🥾", "🎬", "🍣", "🎨", "🎢", "🌲", "🍒", "🍜", "🏊", "🛋️", "🏸", "🍛", "🖼️", "🎮", "✏️", "🍕", "🍔", "☕", "🍰", "🍽️", "🎵", "🎤", "🎭", "📚", "🧩", "🎲", "🚲", "🚗", "✈️", "🏖️", "⛺", "🌅", "🌸", "🐶", "🐱", "📷", "🎸", "🧘", "🏋️", "⚽", "🎳", "🛍️", "💌", "❤️", "⭐", "🌈"];
|
||
const weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||
const months = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
let dates = [];
|
||
let bookedDates = new Set();
|
||
let draftDates = new Map();
|
||
let viewYear = today.getFullYear();
|
||
let viewMonth = today.getMonth();
|
||
let activities = [];
|
||
|
||
function setMessage(element, message, success) {
|
||
element.textContent = message;
|
||
element.classList.toggle("is-success", Boolean(success));
|
||
}
|
||
|
||
function toDateKey(date) {
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||
}
|
||
|
||
function formatDate(date) {
|
||
return new Date(`${date}T00:00:00`).toLocaleDateString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit", year: "numeric" });
|
||
}
|
||
|
||
async function request(path, options) {
|
||
const response = await fetch(path, { ...options, headers: { "Content-Type": "application/json", ...(options && options.headers) } });
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
const error = new Error(data.message || "Die Anfrage ist fehlgeschlagen.");
|
||
error.status = response.status;
|
||
throw error;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function showLogin(message) {
|
||
adminView.classList.add("hidden");
|
||
loginView.classList.remove("hidden");
|
||
if (message) setMessage(loginMessage, message, false);
|
||
passwordInput.focus();
|
||
}
|
||
|
||
function renderDates() {
|
||
const plannedDates = Array.from(draftDates, ([date, type]) => ({ date, type })).sort((a, b) => a.date.localeCompare(b.date));
|
||
datesList.innerHTML = "";
|
||
dateCount.textContent = `${plannedDates.length} ${plannedDates.length === 1 ? "Termin" : "Termine"}`;
|
||
emptyDates.classList.toggle("hidden", plannedDates.length !== 0);
|
||
plannedDates.forEach((date) => {
|
||
const row = document.createElement("li");
|
||
row.className = "date-row";
|
||
const label = document.createElement("span");
|
||
label.className = "date-label";
|
||
label.textContent = formatDate(date.date);
|
||
const typeLabel = document.createElement("span");
|
||
typeLabel.className = "date-type-label";
|
||
typeLabel.textContent = date.type === "short" ? "Kurz" : "Lang";
|
||
const deleteButton = document.createElement("button");
|
||
deleteButton.className = "delete-date-btn";
|
||
deleteButton.type = "button";
|
||
deleteButton.textContent = "Entfernen";
|
||
if (bookedDates.has(date.date)) {
|
||
deleteButton.disabled = true;
|
||
deleteButton.classList.add("is-booked");
|
||
deleteButton.title = "Gebuchte Termine können nicht entfernt werden.";
|
||
} else {
|
||
deleteButton.addEventListener("click", () => {
|
||
draftDates.delete(date.date);
|
||
renderCalendar();
|
||
renderDates();
|
||
setMessage(addDateMessage, "Änderung vorgemerkt. Noch nicht gespeichert.", false);
|
||
});
|
||
}
|
||
row.append(label, typeLabel, deleteButton);
|
||
datesList.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function renderCalendar() {
|
||
monthLabel.textContent = `${months[viewMonth]} ${viewYear}`;
|
||
weekdayRow.innerHTML = "";
|
||
weekdays.forEach((day) => {
|
||
const span = document.createElement("span");
|
||
span.textContent = day;
|
||
weekdayRow.appendChild(span);
|
||
});
|
||
dayGrid.innerHTML = "";
|
||
const firstOfMonth = new Date(viewYear, viewMonth, 1);
|
||
const leadingBlanks = (firstOfMonth.getDay() + 6) % 7;
|
||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||
for (let index = 0; index < leadingBlanks; index += 1) {
|
||
const blank = document.createElement("span");
|
||
blank.className = "day-cell is-empty";
|
||
dayGrid.appendChild(blank);
|
||
}
|
||
for (let day = 1; day <= daysInMonth; day += 1) {
|
||
const cellDate = new Date(viewYear, viewMonth, day);
|
||
const key = toDateKey(cellDate);
|
||
const type = draftDates.get(key);
|
||
const isBooked = bookedDates.has(key);
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "day-cell";
|
||
button.textContent = String(day);
|
||
if (cellDate <= today) {
|
||
button.disabled = true;
|
||
button.classList.add("is-disabled");
|
||
} else if (isBooked) {
|
||
button.disabled = true;
|
||
button.classList.add("is-booked");
|
||
} else if (type) {
|
||
button.classList.add(type === "short" ? "is-short" : "is-long");
|
||
}
|
||
if (cellDate.getTime() === today.getTime()) button.classList.add("is-today");
|
||
button.setAttribute("aria-label", `${formatDate(key)}${isBooked ? ", gebucht" : type ? `, ${type === "short" ? "kurz" : "lang"}` : ", nicht verfügbar"}`);
|
||
if (!isBooked && cellDate > today) button.addEventListener("click", () => {
|
||
const nextType = type === undefined ? "short" : type === "short" ? "long" : undefined;
|
||
if (nextType) draftDates.set(key, nextType); else draftDates.delete(key);
|
||
renderCalendar();
|
||
renderDates();
|
||
setMessage(addDateMessage, "Änderung vorgemerkt. Noch nicht gespeichert.", false);
|
||
});
|
||
dayGrid.appendChild(button);
|
||
}
|
||
prevMonthBtn.disabled = viewYear === today.getFullYear() && viewMonth === today.getMonth();
|
||
}
|
||
|
||
async function loadDates() {
|
||
try {
|
||
const [loadedDates, loadedBookedDates] = await Promise.all([
|
||
request("/api/admin/dates", { method: "GET" }),
|
||
request(`/api/booked-dates?t=${Date.now()}`, { method: "GET" }),
|
||
]);
|
||
dates = loadedDates;
|
||
bookedDates = new Set((loadedBookedDates || []).filter((date) => typeof date === "string"));
|
||
dates.sort((a, b) => a.date.localeCompare(b.date));
|
||
draftDates = new Map(dates.map((date) => [date.date, date.type]));
|
||
loginView.classList.add("hidden");
|
||
adminView.classList.remove("hidden");
|
||
await loadActivities();
|
||
renderCalendar();
|
||
renderDates();
|
||
} catch (error) {
|
||
if (error.status === 401) showLogin();
|
||
else showLogin("Die Termine konnten nicht geladen werden.");
|
||
}
|
||
}
|
||
|
||
function renderActivities() {
|
||
activitiesList.innerHTML = "";
|
||
emptyActivities.classList.toggle("hidden", activities.length > 0);
|
||
const orderedActivities = [...activities].sort((first, second) => {
|
||
return (first.type === "custom") - (second.type === "custom");
|
||
});
|
||
orderedActivities.forEach((activity) => {
|
||
const row = document.createElement("li");
|
||
row.className = `activity-row${activity.disabled ? " is-disabled" : ""}`;
|
||
const identity = document.createElement("div");
|
||
identity.className = "activity-identity";
|
||
const icon = document.createElement("button");
|
||
icon.className = "activity-row-icon";
|
||
icon.type = "button";
|
||
icon.textContent = activity.icon;
|
||
icon.title = "Icon ändern";
|
||
icon.setAttribute("aria-label", `${activity.name}: Icon ändern`);
|
||
icon.disabled = activity.type === "custom";
|
||
icon.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
toggleIconPicker(activity, identity);
|
||
});
|
||
const iconPicker = createIconPicker(activity);
|
||
const name = document.createElement("span");
|
||
name.className = "activity-row-name";
|
||
name.textContent = activity.name;
|
||
const meta = document.createElement("small");
|
||
meta.textContent = `${activity.type === "custom" ? "Benutzerdefiniert" : activity.type === "short" ? "Kurz" : "Lang"}${activity.disabled ? " · Deaktiviert" : ""}`;
|
||
identity.append(icon, name, meta);
|
||
const actions = document.createElement("div");
|
||
actions.className = "activity-actions";
|
||
const renameButton = createRowAction("✎", "Aktivität umbenennen", () => renameActivity(activity, identity, name, renameButton));
|
||
const typeButton = createRowAction("", activity.type === "long" ? "Auf kurz ändern" : "Auf lang ändern", () => changeType(activity));
|
||
typeButton.classList.add("type-action", activity.type === "long" ? "is-long" : "is-short");
|
||
const durationIndicator = document.createElement("span");
|
||
durationIndicator.className = "duration-indicator";
|
||
typeButton.appendChild(durationIndicator);
|
||
typeButton.disabled = activity.type === "custom";
|
||
const disableButton = document.createElement("button");
|
||
disableButton.className = "row-action icon-action";
|
||
disableButton.type = "button";
|
||
disableButton.textContent = activity.disabled ? "▶" : "⏸";
|
||
disableButton.title = activity.disabled ? "Aktivität aktivieren" : "Aktivität deaktivieren";
|
||
disableButton.setAttribute("aria-label", `${activity.name}: ${activity.disabled ? "Aktivität aktivieren" : "Aktivität deaktivieren"}`);
|
||
disableButton.addEventListener("click", () => toggleActivity(activity));
|
||
if (activity.type !== "custom") {
|
||
const deleteButton = createRowAction("🗑", "Aktivität löschen", () => deleteActivity(activity));
|
||
deleteButton.classList.add("is-danger");
|
||
actions.append(renameButton, typeButton, disableButton, deleteButton);
|
||
} else {
|
||
actions.append(renameButton, disableButton);
|
||
}
|
||
identity.append(iconPicker);
|
||
row.append(identity, actions);
|
||
activitiesList.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function createRowAction(symbol, label, action) {
|
||
const button = document.createElement("button");
|
||
button.className = "row-action icon-action";
|
||
button.type = "button";
|
||
button.textContent = symbol;
|
||
button.title = label;
|
||
button.setAttribute("aria-label", label);
|
||
button.addEventListener("click", action);
|
||
return button;
|
||
}
|
||
|
||
function createIconPicker(activity, onSelect) {
|
||
const picker = document.createElement("div");
|
||
picker.className = "activity-icon-picker hidden";
|
||
picker.setAttribute("role", "dialog");
|
||
picker.setAttribute("aria-label", `${activity.name}: Icon auswählen`);
|
||
activityIcons.forEach((icon) => {
|
||
const option = document.createElement("button");
|
||
option.type = "button";
|
||
option.className = "icon-option";
|
||
option.textContent = icon;
|
||
option.title = `Icon ${icon}`;
|
||
option.setAttribute("aria-label", `Icon ${icon} auswählen`);
|
||
option.classList.toggle("is-selected", icon === activity.icon);
|
||
option.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
picker.classList.add("hidden");
|
||
if (onSelect) onSelect(icon);
|
||
else if (icon !== activity.icon) updateActivity(activity, { icon });
|
||
});
|
||
picker.appendChild(option);
|
||
});
|
||
return picker;
|
||
}
|
||
|
||
function toggleIconPicker(activity, identity) {
|
||
const picker = identity.querySelector(".activity-icon-picker");
|
||
if (!picker) return;
|
||
document.querySelectorAll(".activity-icon-picker").forEach((item) => {
|
||
if (item !== picker) item.classList.add("hidden");
|
||
});
|
||
picker.classList.toggle("hidden");
|
||
}
|
||
|
||
async function updateActivity(activity, changes) {
|
||
try {
|
||
await request("/api/admin/activities", { method: "PUT", body: JSON.stringify({ originalName: activity.name, ...activity, ...changes }) });
|
||
await loadActivities();
|
||
setMessage(activityMessage, "Aktivität gespeichert.", true);
|
||
} catch (error) {
|
||
if (error.status === 401) showLogin("Deine Anmeldung ist abgelaufen.");
|
||
else setMessage(activityMessage, error.message, false);
|
||
}
|
||
}
|
||
|
||
function renameActivity(activity, identity, nameElement, renameButton) {
|
||
const existingInput = identity.querySelector(".rename-input");
|
||
if (existingInput) return;
|
||
|
||
const input = document.createElement("input");
|
||
input.className = "activity-name-input rename-input";
|
||
input.type = "text";
|
||
input.maxLength = 100;
|
||
input.value = activity.name;
|
||
input.setAttribute("aria-label", `${activity.name}: neuer Name`);
|
||
nameElement.replaceWith(input);
|
||
|
||
const cancelButton = createRowAction("×", "Umbenennen abbrechen", () => renderActivities());
|
||
renameButton.textContent = "✓";
|
||
renameButton.title = "Namen speichern";
|
||
renameButton.setAttribute("aria-label", "Namen speichern");
|
||
renameButton.onclick = async () => {
|
||
const nextName = input.value.trim();
|
||
if (!nextName || nextName === activity.name) {
|
||
renderActivities();
|
||
return;
|
||
}
|
||
await updateActivity(activity, { name: nextName });
|
||
};
|
||
renameButton.parentElement.insertBefore(cancelButton, renameButton.nextSibling);
|
||
input.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter") renameButton.click();
|
||
if (event.key === "Escape") cancelButton.click();
|
||
});
|
||
input.focus();
|
||
input.select();
|
||
}
|
||
|
||
function changeType(activity) {
|
||
updateActivity(activity, { type: activity.type === "long" ? "short" : "long" });
|
||
}
|
||
|
||
|
||
async function loadActivities() {
|
||
try {
|
||
activities = await request("/api/admin/activities", { method: "GET" });
|
||
renderActivities();
|
||
} catch (error) {
|
||
if (error.status === 401) showLogin("Deine Anmeldung ist abgelaufen.");
|
||
else setMessage(activityMessage, error.message, false);
|
||
}
|
||
}
|
||
|
||
async function toggleActivity(activity) {
|
||
try {
|
||
await request("/api/admin/activities", { method: "PUT", body: JSON.stringify({ originalName: activity.name, ...activity, disabled: !activity.disabled }) });
|
||
await loadActivities();
|
||
setMessage(activityMessage, "Aktivitätsstatus gespeichert.", true);
|
||
} catch (error) { setMessage(activityMessage, error.message, false); }
|
||
}
|
||
|
||
async function deleteActivity(activity) {
|
||
if (!window.confirm(`Aktivität „${activity.name}“ wirklich löschen?`)) return;
|
||
try {
|
||
await request("/api/admin/activities", { method: "DELETE", body: JSON.stringify({ name: activity.name }) });
|
||
await loadActivities();
|
||
setMessage(activityMessage, "Aktivität gelöscht.", true);
|
||
} catch (error) { setMessage(activityMessage, error.message, false); }
|
||
}
|
||
|
||
function createPickerForNewActivity() {
|
||
const draft = { name: "Neue Aktivität", icon: newActivityIcon.textContent, type: newActivityType.classList.contains("is-long") ? "long" : "short" };
|
||
const picker = createIconPicker(draft, (icon) => { newActivityIcon.textContent = icon; });
|
||
picker.classList.remove("hidden");
|
||
picker.classList.add("activity-add-picker");
|
||
newActivityIcon.parentElement.appendChild(picker);
|
||
return picker;
|
||
}
|
||
|
||
loginForm.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
setMessage(loginMessage, "", false);
|
||
try {
|
||
await request("/api/admin/login", { method: "POST", body: JSON.stringify({ password: passwordInput.value }) });
|
||
passwordInput.value = "";
|
||
await loadDates();
|
||
} catch (error) {
|
||
setMessage(loginMessage, error.message, false);
|
||
}
|
||
});
|
||
|
||
saveDatesBtn.addEventListener("click", async () => {
|
||
saveDatesBtn.disabled = true;
|
||
try {
|
||
await request("/api/admin/dates/bulk", { method: "POST", body: JSON.stringify(Array.from(draftDates, ([date, type]) => ({ date, type }))) });
|
||
dates = Array.from(draftDates, ([date, type]) => ({ date, type }));
|
||
renderDates();
|
||
setMessage(addDateMessage, "Änderungen gespeichert.", true);
|
||
} catch (error) {
|
||
if (error.status === 401) showLogin("Deine Anmeldung ist abgelaufen.");
|
||
else setMessage(addDateMessage, error.message, false);
|
||
} finally {
|
||
saveDatesBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
newActivityIcon.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
const existingPicker = newActivityIcon.parentElement.querySelector(".activity-add-picker");
|
||
if (existingPicker) {
|
||
existingPicker.remove();
|
||
return;
|
||
}
|
||
createPickerForNewActivity();
|
||
});
|
||
newActivityType.addEventListener("click", () => {
|
||
const isLong = newActivityType.classList.toggle("is-long");
|
||
newActivityType.classList.toggle("is-short", !isLong);
|
||
newActivityType.title = isLong ? "Auf kurz ändern" : "Auf lang ändern";
|
||
newActivityType.setAttribute("aria-label", `Neue Aktivität: ${isLong ? "auf kurz" : "auf lang"} ändern`);
|
||
});
|
||
activityAddRow.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const activity = { name: newActivityName.value.trim(), icon: newActivityIcon.textContent, type: newActivityType.classList.contains("is-long") ? "long" : "short" };
|
||
try {
|
||
await request("/api/admin/activities", { method: "POST", body: JSON.stringify(activity) });
|
||
newActivityName.value = "";
|
||
newActivityIcon.textContent = "🧺";
|
||
newActivityType.classList.remove("is-long");
|
||
newActivityType.classList.add("is-short");
|
||
await loadActivities();
|
||
setMessage(activityMessage, "Aktivität hinzugefügt.", true);
|
||
} catch (error) {
|
||
if (error.status === 401) showLogin("Deine Anmeldung ist abgelaufen.");
|
||
else setMessage(activityMessage, error.message, false);
|
||
}
|
||
});
|
||
|
||
document.querySelectorAll(".admin-nav-btn").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
document.querySelectorAll(".admin-nav-btn").forEach((item) => item.classList.toggle("is-active", item === button));
|
||
document.querySelectorAll(".admin-panel").forEach((panel) => panel.classList.toggle("hidden", panel.id !== button.dataset.view));
|
||
});
|
||
});
|
||
document.addEventListener("click", () => {
|
||
document.querySelectorAll(".activity-icon-picker").forEach((picker) => picker.classList.add("hidden"));
|
||
});
|
||
|
||
prevMonthBtn.addEventListener("click", () => {
|
||
viewMonth -= 1;
|
||
if (viewMonth < 0) { viewMonth = 11; viewYear -= 1; }
|
||
renderCalendar();
|
||
});
|
||
nextMonthBtn.addEventListener("click", () => {
|
||
viewMonth += 1;
|
||
if (viewMonth > 11) { viewMonth = 0; viewYear += 1; }
|
||
renderCalendar();
|
||
});
|
||
logoutBtn.addEventListener("click", async () => {
|
||
try { await request("/api/admin/logout", { method: "POST" }); } finally { showLogin(); }
|
||
});
|
||
|
||
loadDates();
|
||
})();
|