Add protected date management admin

This commit is contained in:
2026-09-17 15:32:33 +02:00
parent 253b65d976
commit 097e72d364
14 changed files with 1100 additions and 67 deletions
+1 -1
View File
@@ -32,7 +32,7 @@
return act && (act.name === "Etwas eigenes" || act.name === "Sonstiges" || act.type === "custom");
}
fetch("appsettings.json")
fetch("/api/settings", { cache: "no-store" })
.then((response) => response.json())
.then((settings) => {
const allActivities = settings.activities || [];
+260
View File
@@ -0,0 +1,260 @@
.page-admin {
background: #f8f4ec;
font-size: 1rem;
}
.admin-shell {
width: min(100% - 32px, 720px);
margin: 0 auto;
padding: 40px 0 56px;
}
.login-view {
width: min(100%, 400px);
margin: 12vh auto 0;
padding: 32px;
background: var(--color-white);
border: 1px solid #eaded1;
border-radius: 8px;
box-shadow: 0 12px 30px rgba(74, 44, 61, 0.12);
}
.eyebrow {
margin-bottom: 6px;
color: #8c6d52;
font-family: var(--font-heading);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.login-copy,
.section-heading p {
color: var(--color-text-light);
}
.login-form,
.date-form {
display: grid;
gap: 12px;
margin-top: 24px;
}
.login-form label,
.date-form > label,
.type-selector legend {
color: var(--color-text);
font-family: var(--font-heading);
font-size: 0.92rem;
font-weight: 700;
}
.login-form input,
.date-form > input {
width: 100%;
min-height: 46px;
padding: 10px 12px;
border: 1px solid #cfbba9;
border-radius: 6px;
background: #fffefa;
color: var(--color-text);
font: inherit;
}
.login-form input:focus,
.date-form > input:focus,
.date-type-select:focus {
outline: 3px solid rgba(224, 87, 126, 0.28);
outline-offset: 1px;
border-color: var(--color-rose);
}
.login-form .btn,
.date-form .btn {
min-height: 48px;
border-radius: 6px;
font-size: 1rem;
}
.form-message {
min-height: 1.5em;
margin: 12px 0 0;
color: var(--color-rose-dark);
font-weight: 700;
}
.form-message.is-success {
color: #287540;
}
.admin-view {
display: grid;
gap: 32px;
}
.admin-header,
.dates-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.admin-header h1 {
margin-bottom: 0;
}
.text-action {
padding: 8px 0;
border: 0;
background: transparent;
color: var(--color-rose-dark);
font: inherit;
font-weight: 700;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.admin-section {
padding-top: 24px;
border-top: 1px solid #dfcdbd;
}
.section-heading h2 {
margin-bottom: 4px;
font-size: 1.25rem;
}
.section-heading p {
margin-bottom: 0;
}
.type-selector {
display: flex;
gap: 8px;
margin: 4px 0 2px;
padding: 0;
border: 0;
}
.type-selector legend {
margin-bottom: 8px;
}
.type-selector label {
flex: 1;
}
.type-selector input {
position: absolute;
opacity: 0;
}
.type-selector span {
display: block;
padding: 10px 12px;
border: 1px solid #cfbba9;
border-radius: 6px;
background: #fffefa;
color: var(--color-text-light);
font-family: var(--font-heading);
font-weight: 700;
text-align: center;
cursor: pointer;
}
.type-selector input:checked + span {
border-color: var(--color-rose-dark);
background: var(--color-rose-dark);
color: var(--color-white);
}
.type-selector input:focus-visible + span {
outline: 3px solid rgba(224, 87, 126, 0.28);
outline-offset: 1px;
}
.dates-list {
display: grid;
gap: 10px;
margin: 18px 0 0;
padding: 0;
list-style: none;
}
.date-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 118px auto;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid #e5d8ce;
}
.date-label {
font-family: var(--font-heading);
font-weight: 700;
}
.date-type-select {
min-height: 38px;
padding: 6px 28px 6px 8px;
border: 1px solid #cfbba9;
border-radius: 5px;
background: #fffefa;
color: var(--color-text);
font: inherit;
}
.delete-date-btn {
min-height: 38px;
padding: 6px 10px;
border: 1px solid #c84a62;
border-radius: 5px;
background: transparent;
color: #a83049;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.delete-date-btn:hover,
.delete-date-btn:focus-visible {
background: #fff0f2;
}
.empty-dates {
margin: 18px 0 0;
color: var(--color-text-light);
}
.hidden {
display: none !important;
}
@media (max-width: 480px) {
.admin-shell {
width: min(100% - 24px, 720px);
padding-top: 24px;
}
.login-view {
margin-top: 8vh;
padding: 24px;
}
.date-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.date-type-select {
grid-column: 1;
}
.delete-date-btn {
grid-column: 2;
grid-row: 1 / span 2;
}
}
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>Terminverwaltung - Date-Planer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&family=Quicksand:wght@600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="admin.css" />
</head>
<body class="page-admin">
<main class="admin-shell">
<section id="loginView" class="login-view" aria-labelledby="loginTitle">
<p class="eyebrow">Date-Planer</p>
<h1 id="loginTitle">Terminverwaltung</h1>
<p class="login-copy">Melde dich an, um verfügbare Tage zu verwalten.</p>
<form id="loginForm" class="login-form">
<label for="passwordInput">Passwort</label>
<input id="passwordInput" name="password" type="password" autocomplete="current-password" required />
<button class="btn btn-primary" type="submit">Anmelden</button>
</form>
<p id="loginMessage" class="form-message" role="status" aria-live="polite"></p>
</section>
<section id="adminView" class="admin-view hidden" aria-labelledby="adminTitle">
<header class="admin-header">
<div>
<p class="eyebrow">Date-Planer</p>
<h1 id="adminTitle">Verfügbare Tage</h1>
</div>
<button id="logoutBtn" class="text-action" type="button">Abmelden</button>
</header>
<section class="admin-section add-date-section" aria-labelledby="addDateTitle">
<div class="section-heading">
<h2 id="addDateTitle">Neuen Tag hinzufügen</h2>
<p>Datum im Kalender wählen und Zeitumfang festlegen.</p>
</div>
<form id="addDateForm" class="date-form">
<label for="dateInput">Datum</label>
<input id="dateInput" name="date" type="date" required />
<fieldset class="type-selector">
<legend>Tagestyp</legend>
<label>
<input type="radio" name="dateType" value="short" checked />
<span>Kurz</span>
</label>
<label>
<input type="radio" name="dateType" value="long" />
<span>Lang</span>
</label>
</fieldset>
<button class="btn btn-primary" type="submit">Datum hinzufügen</button>
</form>
<p id="addDateMessage" class="form-message" role="status" aria-live="polite"></p>
</section>
<section class="admin-section dates-section" aria-labelledby="datesTitle">
<div class="section-heading dates-heading">
<div>
<h2 id="datesTitle">Eingetragene Tage</h2>
<p id="dateCount">Lade Termine...</p>
</div>
</div>
<ul id="datesList" class="dates-list" aria-live="polite"></ul>
<p id="emptyDates" class="empty-dates hidden">Noch keine Termine eingetragen.</p>
</section>
</section>
</main>
<script src="admin.js" defer></script>
</body>
</html>
+206
View File
@@ -0,0 +1,206 @@
(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 addDateForm = document.getElementById("addDateForm");
const dateInput = document.getElementById("dateInput");
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");
let dates = [];
function setMessage(element, message, success) {
element.textContent = message;
element.classList.toggle("is-success", Boolean(success));
}
function todayKey() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, "0");
const day = String(today.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
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() {
datesList.innerHTML = "";
dateCount.textContent = `${dates.length} ${dates.length === 1 ? "Termin" : "Termine"}`;
emptyDates.classList.toggle("hidden", dates.length !== 0);
dates.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 typeSelect = document.createElement("select");
typeSelect.className = "date-type-select";
typeSelect.setAttribute("aria-label", `Tagestyp für ${formatDate(date.date)}`);
[["short", "Kurz"], ["long", "Lang"]].forEach(([value, text]) => {
const option = document.createElement("option");
option.value = value;
option.textContent = text;
option.selected = date.type === value;
typeSelect.appendChild(option);
});
typeSelect.addEventListener("change", async () => {
typeSelect.disabled = true;
try {
await request("/api/admin/dates", {
method: "PUT",
body: JSON.stringify({ date: date.date, type: typeSelect.value }),
});
date.type = typeSelect.value;
setMessage(addDateMessage, "Tagestyp aktualisiert.", true);
} catch (error) {
if (error.status === 401) {
showLogin("Deine Anmeldung ist abgelaufen.");
return;
}
typeSelect.value = date.type;
setMessage(addDateMessage, error.message, false);
} finally {
typeSelect.disabled = false;
}
});
const deleteButton = document.createElement("button");
deleteButton.className = "delete-date-btn";
deleteButton.type = "button";
deleteButton.textContent = "Löschen";
deleteButton.addEventListener("click", async () => {
if (!window.confirm(`${formatDate(date.date)} wirklich löschen?`)) {
return;
}
deleteButton.disabled = true;
try {
await request("/api/admin/dates", {
method: "DELETE",
body: JSON.stringify({ date: date.date }),
});
dates = dates.filter((item) => item.date !== date.date);
renderDates();
setMessage(addDateMessage, "Datum gelöscht.", true);
} catch (error) {
if (error.status === 401) {
showLogin("Deine Anmeldung ist abgelaufen.");
return;
}
setMessage(addDateMessage, error.message, false);
deleteButton.disabled = false;
}
});
row.append(label, typeSelect, deleteButton);
datesList.appendChild(row);
});
}
async function loadDates() {
try {
dates = await request("/api/admin/dates", { method: "GET" });
dates.sort((first, second) => first.date.localeCompare(second.date));
loginView.classList.add("hidden");
adminView.classList.remove("hidden");
renderDates();
} catch (error) {
if (error.status === 401) {
showLogin();
return;
}
showLogin("Die Termine konnten nicht geladen werden.");
}
}
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);
}
});
addDateForm.addEventListener("submit", async (event) => {
event.preventDefault();
const type = addDateForm.elements.dateType.value;
setMessage(addDateMessage, "", false);
try {
await request("/api/admin/dates", {
method: "POST",
body: JSON.stringify({ date: dateInput.value, type }),
});
dates.push({ date: dateInput.value, type });
dates.sort((first, second) => first.date.localeCompare(second.date));
renderDates();
setMessage(addDateMessage, "Datum hinzugefügt.", true);
addDateForm.reset();
dateInput.min = todayKey();
} catch (error) {
if (error.status === 401) {
showLogin("Deine Anmeldung ist abgelaufen.");
return;
}
setMessage(addDateMessage, error.message, false);
}
});
logoutBtn.addEventListener("click", async () => {
try {
await request("/api/admin/logout", { method: "POST" });
} finally {
showLogin();
}
});
dateInput.min = todayKey();
loadDates();
})();
-54
View File
@@ -1,54 +0,0 @@
{
"activities": [
{ "name": "Picknick auf der Wiese", "icon": "🧺", "type": "short" },
{ "name": "Cocktailabend zu zweit", "icon": "🍸", "type": "long" },
{ "name": "Massage zu Hause", "icon": "💆", "type": "short" },
{ "name": "Ausgedehnte Wanderung", "icon": "🥾", "type": "long" },
{ "name": "Kinofilm", "icon": "🎬", "type": "short" },
{ "name": "Sushi selber machen", "icon": "🍣", "type": "short" },
{ "name": "Untersetzer aus Ton basteln", "icon": "🪨", "type": "long" },
{ "name": "Freizeitpark", "icon": "🎢", "type": "long" },
{ "name": "Spaziergang im Wald", "icon": "🌲", "type": "short" },
{ "name": "Kirschbier Tasting", "icon": "🍒", "type": "short" },
{ "name": "Nudeln selber machen", "icon": "🍜", "type": "long" },
{ "name": "Chillen im Freibad", "icon": "🏊", "type": "short" },
{ "name": "Film zu Hause + Essen bestellen", "icon": "🛋️", "type": "long" },
{ "name": "Squash spielen", "icon": "🏸", "type": "short" },
{ "name": "Essen im Namaste", "icon": "🍛", "type": "short" },
{ "name": "Museum für Gegenwartskunst", "icon": "🖼️", "type": "short" },
{ "name": "Playstation Co-Op", "icon": "🎮", "type": "short" },
{ "name": "Etwas eigenes", "icon": "✏️", "type": "custom" }
],
"availableDates": [
{ "date": "2026-08-11", "type": "short" },
{ "date": "2026-08-15", "type": "short" },
{ "date": "2026-08-18", "type": "short" },
{ "date": "2026-08-20", "type": "short" },
{ "date": "2026-08-20", "type": "short" },
{ "date": "2026-08-22", "type": "long" },
{ "date": "2026-08-23", "type": "long" },
{ "date": "2026-08-24", "type": "short" },
{ "date": "2026-08-26", "type": "short" },
{ "date": "2026-08-27", "type": "short" },
{ "date": "2026-08-28", "type": "short" },
{ "date": "2026-08-29", "type": "long" },
{ "date": "2026-08-31", "type": "short" },
{ "date": "2026-09-01", "type": "short" },
{ "date": "2026-09-07", "type": "short" },
{ "date": "2026-09-08", "type": "short" },
{ "date": "2026-09-14", "type": "short" },
{ "date": "2026-09-15", "type": "short" },
{ "date": "2026-09-17", "type": "short" },
{ "date": "2026-09-18", "type": "short" },
{ "date": "2026-09-25", "type": "short" },
{ "date": "2026-09-26", "type": "long" },
{ "date": "2026-09-27", "type": "short" },
{ "date": "2026-09-28", "type": "short" },
{ "date": "2026-09-29", "type": "short" },
{ "date": "2026-09-30", "type": "short" }
],
"names": []
}
+1 -1
View File
@@ -45,7 +45,7 @@
if (storedActivities.length > 0) {
activityIcon.textContent = storedActivities.map((a) => a.icon).join(" ");
} else {
fetch("appsettings.json")
fetch("/api/settings", { cache: "no-store" })
.then((res) => res.json())
.then((settings) => {
const match = (settings.activities || []).find((a) => a.name === selectedActivity);
+1 -1
View File
@@ -38,7 +38,7 @@
}
Promise.all([
fetch("appsettings.json?t=" + Date.now()).then((res) => res.json()),
fetch("/api/settings?t=" + Date.now(), { cache: "no-store" }).then((res) => res.json()),
fetch("/api/booked-dates?t=" + Date.now(), { cache: "no-store" })
.then((res) => (res.ok ? res.json() : []))
.catch(() => []),
+1 -1
View File
@@ -28,7 +28,7 @@
let selectedName = null;
fetch("appsettings.json")
fetch("/api/settings", { cache: "no-store" })
.then((response) => response.json())
.then((settings) => renderNames(settings.names || []))
.catch(() => {