Files
date-wizard/public/admin.js
T

518 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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 bookingsList = document.getElementById("bookingsList");
const emptyBookings = document.getElementById("emptyBookings");
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 = [];
let bookings = [];
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();
}
function renderBookings() {
bookingsList.innerHTML = "";
emptyBookings.classList.toggle("hidden", bookings.length > 0);
bookings.forEach((booking) => {
const row = document.createElement("li");
row.className = "booking-row";
const date = document.createElement("strong");
date.className = "booking-date";
date.textContent = formatDate(booking.date);
row.appendChild(date);
if (booking.activities && booking.activities.length > 0) {
const activityList = document.createElement("div");
activityList.className = "booking-activities";
booking.activities.forEach((activity) => {
const activityItem = document.createElement("span");
activityItem.className = "booking-activity";
activityItem.textContent = `${activity.icon || ""} ${activity.name}`.trim();
activityList.appendChild(activityItem);
});
row.appendChild(activityList);
}
const metadata = document.createElement("div");
metadata.className = "booking-metadata";
if (booking.name && booking.name.trim()) {
const name = document.createElement("span");
name.textContent = booking.name;
metadata.appendChild(name);
}
if (booking.bookedAt) {
const created = document.createElement("time");
created.dateTime = booking.bookedAt;
created.textContent = `Gebucht am ${new Date(booking.bookedAt).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" })}`;
metadata.appendChild(created);
}
if (metadata.childElementCount > 0) row.appendChild(metadata);
const deleteButton = document.createElement("button");
deleteButton.className = "delete-booking-btn";
deleteButton.type = "button";
deleteButton.textContent = "Termin löschen";
deleteButton.addEventListener("click", () => deleteBooking(booking, deleteButton));
row.appendChild(deleteButton);
bookingsList.appendChild(row);
});
}
async function deleteBooking(booking, deleteButton) {
if (!window.confirm(`Den gebuchten Termin am ${formatDate(booking.date)} wirklich löschen?`)) return;
deleteButton.disabled = true;
try {
await request("/api/admin/bookings", { method: "DELETE", body: JSON.stringify({ date: booking.date }) });
bookedDates.delete(booking.date);
await loadBookings();
renderCalendar();
renderDates();
} catch (error) {
deleteButton.disabled = false;
if (error.status === 401) showLogin("Deine Anmeldung ist abgelaufen.");
else window.alert(error.message);
}
}
async function loadBookings() {
bookings = await request("/api/admin/bookings", { method: "GET" });
renderBookings();
}
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 Promise.all([loadActivities(), loadBookings()]);
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();
})();