Add admin activity management

This commit is contained in:
troogs
2026-10-01 17:58:41 +02:00
parent 84ed5a7a28
commit 944eed3eb3
7 changed files with 664 additions and 5 deletions
+2 -2
View File
@@ -37,13 +37,13 @@
.then((settings) => {
const allActivities = settings.activities || [];
const customAct = allActivities.find((a) => a.name === "Etwas eigenes" || a.name === "Sonstiges") || {
const customAct = allActivities.find((a) => a.type === "custom") || {
name: "Etwas eigenes",
icon: "✏️",
type: "custom",
};
let otherActivities = allActivities.filter((a) => a.name !== "Etwas eigenes" && a.name !== "Sonstiges");
let otherActivities = allActivities.filter((a) => a.type !== "custom");
if (selectedDateType === "short") {
otherActivities = otherActivities.filter((a) => a.type === "short");
}
+99
View File
@@ -93,6 +93,29 @@
gap: 32px;
}
.admin-nav {
display: flex;
gap: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #dfcdbd;
}
.admin-nav-btn {
padding: 10px 16px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--color-text-light);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.admin-nav-btn.is-active,
.admin-nav-btn:hover { border-color: #cfbba9; background: #fffefa; color: var(--color-rose-dark); }
.admin-panel { display: grid; gap: 32px; }
.admin-header,
.dates-heading {
display: flex;
@@ -347,4 +370,80 @@
grid-column: 2;
grid-row: 1 / span 2;
}
}
.activity-form {
display: grid;
gap: 12px;
max-width: 560px;
margin-top: 20px;
}
.activity-form > label:not(.toggle-label),
.icon-picker legend {
color: var(--color-text);
font-family: var(--font-heading);
font-size: 0.92rem;
font-weight: 700;
}
.activity-form > input[type="text"] {
min-height: 46px;
padding: 10px 12px;
border: 1px solid #cfbba9;
border-radius: 6px;
background: #fffefa;
color: var(--color-text);
font: inherit;
}
.activity-form > input[type="text"]:focus { outline: 3px solid rgba(224, 87, 126, 0.28); outline-offset: 1px; border-color: var(--color-rose); }
.icon-picker { margin: 4px 0 0; padding: 0; border: 0; }
.icon-picker legend { margin-bottom: 8px; }
.icon-options { display: flex; flex-wrap: wrap; gap: 8px; }
.icon-option { width: 42px; height: 42px; padding: 0; border: 1px solid #cfbba9; border-radius: 6px; background: #fffefa; font-size: 1.35rem; cursor: pointer; }
.icon-option.is-selected { border-color: var(--color-rose-dark); background: var(--color-rose-light); box-shadow: inset 0 0 0 2px var(--color-rose-dark); }
.toggle-label { display: flex; align-items: center; gap: 8px; margin-top: 4px; cursor: pointer; }
.toggle-label input { width: 18px; height: 18px; accent-color: var(--color-rose-dark); }
.form-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 4px; }
.btn-secondary { border: 1px solid #cfbba9; background: #fffefa; color: var(--color-text); }
.activities-list { display: grid; gap: 10px; margin: 18px 0 0; padding: 0; list-style: none; }
.activity-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 0; border-bottom: 1px solid #e5d8ce; }
.activity-row.is-disabled { opacity: 0.58; }
.activity-add-row { margin-top: 18px; padding: 10px 0; border-top: 1px dashed #cfbba9; border-bottom: 1px solid #e5d8ce; }
.activity-add-identity { flex: 1; }
.activity-name-input { min-width: 0; width: 100%; border: 0; border-bottom: 1px solid #cfbba9; background: transparent; color: var(--color-text); font: inherit; font-family: var(--font-heading); font-weight: 700; outline: 0; }
.activity-name-input:focus { border-color: var(--color-rose-dark); box-shadow: 0 2px 0 var(--color-rose-light); }
.activity-name-input::placeholder { color: var(--color-text-light); font-weight: 600; }
.activity-identity { position: relative; display: grid; grid-template-columns: 36px minmax(0, 1fr); align-items: center; column-gap: 10px; min-width: 0; }
.activity-row-icon { grid-row: span 2; width: 38px; height: 38px; padding: 0; border: 1px solid transparent; border-radius: 50%; background: transparent; color: var(--color-text); font-size: 1.5rem; line-height: 1; text-align: center; cursor: pointer; }
.activity-row-icon:hover, .activity-row-icon:focus-visible { border-color: #cfbba9; background: #fffefa; }
.activity-row-icon:disabled { cursor: not-allowed; opacity: 0.55; }
.activity-row-name { font-family: var(--font-heading); font-weight: 700; overflow-wrap: anywhere; }
.activity-identity small { color: var(--color-text-light); }
.activity-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.row-action { width: 36px; height: 36px; padding: 0; border: 1px solid #cfbba9; border-radius: 5px; background: #fffefa; color: var(--color-text); font: inherit; font-size: 1.05rem; line-height: 1; cursor: pointer; }
.row-action:hover, .row-action:focus-visible { border-color: var(--color-rose-dark); color: var(--color-rose-dark); }
.row-action:disabled { opacity: 0.35; cursor: not-allowed; }
.type-action { display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; }
.duration-indicator { display: block; width: 18px; height: 18px; border-radius: 50%; }
.type-action.is-short .duration-indicator { background: linear-gradient(90deg, var(--color-rose) 0 50%, var(--color-rose-light) 50%); }
.type-action.is-long .duration-indicator { background: var(--color-rose-dark); }
.type-action.is-short { color: var(--color-rose); }
.type-action.is-long { color: var(--color-rose-dark); }
.row-action.is-danger { border-color: #c84a62; color: #a83049; }
.activity-icon-picker { position: absolute; z-index: 5; top: 44px; left: 0; display: grid; grid-template-columns: repeat(6, 48px); gap: 8px; width: 352px; padding: 12px; border: 1px solid #cfbba9; border-radius: 8px; background: #fffefa; box-shadow: 0 12px 24px rgba(74, 44, 61, 0.18); }
.activity-icon-picker.hidden { display: none; }
.activity-icon-picker .icon-option { width: 48px; height: 48px; font-size: 1.65rem; }
.activity-add-picker { top: 52px; }
@media (max-width: 560px) {
.activity-row { align-items: flex-start; flex-direction: column; }
.activity-actions { justify-content: flex-start; }
}
@media (max-width: 400px) {
.activity-icon-picker { grid-template-columns: repeat(5, 48px); width: 296px; }
}
+34
View File
@@ -33,6 +33,12 @@
<button id="logoutBtn" class="text-action" type="button">Abmelden</button>
</header>
<nav class="admin-nav" aria-label="Verwaltung">
<button class="admin-nav-btn is-active" type="button" data-view="datesView">Termine</button>
<button class="admin-nav-btn" type="button" data-view="activitiesView">Aktivitäten</button>
</nav>
<div id="datesView" class="admin-panel">
<section class="admin-section add-date-section" aria-labelledby="addDateTitle">
<div class="section-heading">
<h2 id="addDateTitle">Verfügbare Tage planen</h2>
@@ -65,6 +71,34 @@
<ul id="datesList" class="dates-list" aria-live="polite"></ul>
<p id="emptyDates" class="empty-dates hidden">Noch keine Termine eingetragen.</p>
</section>
</div>
<div id="activitiesView" class="admin-panel hidden">
<section class="admin-section" aria-labelledby="activitiesTitle">
<div class="section-heading">
<h2 id="activitiesTitle">Aktivitäten verwalten</h2>
<p>Aktive Aktivitäten werden Besucherinnen und Besuchern zur Auswahl angezeigt.</p>
</div>
</section>
<section class="admin-section" aria-labelledby="activityListTitle">
<div class="section-heading">
<h2 id="activityListTitle">Vorhandene Aktivitäten</h2>
<p id="activityMessage" class="form-message" role="status" aria-live="polite"></p>
</div>
<form id="activityAddRow" class="activity-row activity-add-row">
<div class="activity-identity activity-add-identity">
<button id="newActivityIcon" class="activity-row-icon" type="button" title="Icon auswählen" aria-label="Icon für neue Aktivität auswählen">🧺</button>
<input id="newActivityName" class="activity-name-input" type="text" maxlength="100" placeholder="Neue Aktivität" aria-label="Name der neuen Aktivität" required />
</div>
<div class="activity-actions">
<button id="newActivityType" class="row-action type-action is-short" type="button" title="Auf lang ändern" aria-label="Neue Aktivität: auf lang ändern"><span class="duration-indicator"></span></button>
<button class="row-action" type="submit" title="Aktivität hinzufügen" aria-label="Aktivität hinzufügen">+</button>
</div>
</form>
<ul id="activitiesList" class="activities-list" aria-live="polite"></ul>
<p id="emptyActivities" class="empty-dates hidden">Noch keine Aktivitäten angelegt.</p>
</section>
</div>
</section>
</main>
<script src="admin.js" defer></script>
+239
View File
@@ -15,6 +15,14 @@
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();
@@ -24,6 +32,7 @@
let draftDates = new Map();
let viewYear = today.getFullYear();
let viewMonth = today.getMonth();
let activities = [];
function setMessage(element, message, success) {
element.textContent = message;
@@ -152,6 +161,7 @@
draftDates = new Map(dates.map((date) => [date.date, date.type]));
loginView.classList.add("hidden");
adminView.classList.remove("hidden");
await loadActivities();
renderCalendar();
renderDates();
} catch (error) {
@@ -160,6 +170,193 @@
}
}
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);
@@ -187,6 +384,48 @@
}
});
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; }