diff --git a/.github/prompts/11-admin-page.prompt.md b/.github/prompts/11-admin-page.prompt.md new file mode 100644 index 0000000..21a1899 --- /dev/null +++ b/.github/prompts/11-admin-page.prompt.md @@ -0,0 +1,87 @@ +--- +agent: agent +description: Create or improve the Date Wizard admin page for managing available date slots, including authentication, calendar bulk editing, staged saves, and booked-date protection. +--- + +Implement or review the complete admin page for the Date Wizard application. Preserve the existing project architecture, styling conventions, API patterns, and German user-facing language. + +## Existing admin functionality + +The admin page is available at `/admin` and must provide: + +- A password login form. +- Session-based authentication using the existing admin login and logout endpoints. +- A clear message when the password is invalid. +- A clear message when the session has expired. +- A logout action. +- Loading and displaying all available dates from the admin API. +- Displaying each date with its configured type: `short` (Kurz) or `long` (Lang). +- Date validation that allows only today or future dates and only the two supported types. +- Responsive styling consistent with the existing Date Wizard design. + +## Calendar bulk editor + +Replace the need to add dates one at a time with a monthly calendar editor similar to the public booking calendar. + +- Show a month calendar with Monday-first weekday headings. +- Provide previous-month and next-month navigation. +- Do not allow selecting today or dates in the past. +- Clicking an editable future date cycles through exactly three states: + 1. Not selected: no available date. + 2. First click: `short` / Kurz. + 3. Second click: `long` / Lang. + 4. Third click: back to not selected. +- Render a short date with a half-filled circle, for example a circle split into two colors. +- Render a long date with the normal full selected circle. +- Make the state visually and programmatically accessible with appropriate labels and disabled states. +- Show a legend explaining the short and long visual states. +- Keep all calendar changes in a client-side draft. Clicking a date must never save immediately. +- Include an explicit `Änderungen speichern` button. +- Save the complete draft in one authenticated bulk request only after the administrator clicks the save button. +- Refresh the visible date list from the draft after local changes and show a success or error message after saving. +- Allow removing an unbooked date from the pending draft without immediately saving it. + +## Booked-date protection + +Dates that have already been booked are read-only in the admin page. + +- Load booked dates from the existing `/api/booked-dates` endpoint. +- A booked date must remain in the available-date configuration and must not be removable through the admin calendar. +- Render a booked future date as a green calendar circle. +- Disable the calendar button for a booked date so clicks do nothing. +- Mark the calendar entry as booked using an accessible label. +- Disable its `Entfernen` button in the date list. +- Style the disabled removal button grey with a not-allowed cursor. +- Do not let bulk-save requests remove or modify booked dates, even if a malicious or stale client submits a draft that omits them. +- Enforce this protection server-side as well as in the browser. + +## Backend requirements + +Use the existing Go HTTP server and settings persistence mechanisms. + +- Keep the existing single-date endpoints compatible unless there is a compelling project-specific reason to change them. +- Add an authenticated bulk endpoint, such as `POST /api/admin/dates/bulk`. +- Accept a JSON array of objects shaped like `{ "date": "YYYY-MM-DD", "type": "short" | "long" }`. +- Reject malformed dates, unsupported types, duplicate dates, past dates, and today. +- Validate the complete request before changing the settings file. +- Save the complete future-date selection atomically under the existing settings mutex. +- Preserve historical dates according to the existing application behavior. +- Preserve all booked dates regardless of whether they are included in the submitted draft. +- Do not create duplicate available-date entries. +- Return useful JSON success and error responses and use appropriate HTTP status codes. + +## Implementation and validation + +Before editing, inspect the existing admin HTML, JavaScript, CSS, Go handlers, settings model, public booking calendar, and booked-date storage/API. Reuse existing helpers and visual patterns where practical. + +After editing: + +1. Run the project's formatter. +2. Run the Go test/build command available in the repository. +3. Run a JavaScript syntax check for the admin script when Node.js is available. +4. Check diagnostics and whitespace errors. +5. Verify that the page renders the login view when unauthenticated. +6. Verify that the calendar, save button, staged state changes, booked green state, and disabled grey removal button are present after authentication. +7. Do not modify booked-date fixtures or unrelated application behavior. + +When reporting the result, summarize the files changed, the user-visible behavior, the API changes, and the validation commands that passed or failed. diff --git a/main.go b/main.go index 7f9ba9b..6b0d186 100644 --- a/main.go +++ b/main.go @@ -82,6 +82,7 @@ func main() { http.HandleFunc("/api/admin/login", handleAdminLogin) http.HandleFunc("/api/admin/logout", handleAdminLogout) http.HandleFunc("/api/admin/dates", handleAdminDates) + http.HandleFunc("/api/admin/dates/bulk", handleAdminBulkDates) http.HandleFunc("/admin", handleAdminPage) // Serve static files from webDir directory @@ -281,6 +282,61 @@ func handleAdminDates(w http.ResponseWriter, r *http.Request) { } } +func handleAdminBulkDates(w http.ResponseWriter, r *http.Request) { + if !isAdminRequest(r) { + writeJSON(w, http.StatusUnauthorized, Response{Success: false, Message: "Anmeldung erforderlich."}) + return + } + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var requestedDates []AvailableDate + if err := json.NewDecoder(r.Body).Decode(&requestedDates); err != nil { + writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Ungültige Terminauswahl."}) + return + } + seen := make(map[string]bool, len(requestedDates)) + for _, date := range requestedDates { + if !isValidAvailableDate(date) || !isTodayOrFuture(date.Date) || seen[date.Date] { + writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte wähle gültige, eindeutige Daten ab heute."}) + return + } + seen[date.Date] = true + } + bookedDates, err := getBookedDates() + if err != nil { + log.Printf("Error reading booked dates: %v", err) + http.Error(w, "Booked dates could not be loaded", http.StatusInternalServerError) + return + } + bookedDateSet := make(map[string]bool, len(bookedDates)) + for _, date := range bookedDates { + bookedDateSet[date] = true + } + + if err := updateAvailableDates(func(dates []AvailableDate) ([]AvailableDate, error) { + preserved := make([]AvailableDate, 0, len(dates)+len(requestedDates)) + for _, date := range dates { + if !isTodayOrFuture(date.Date) || bookedDateSet[date.Date] { + preserved = append(preserved, date) + } + } + for _, date := range requestedDates { + if !bookedDateSet[date.Date] { + preserved = append(preserved, date) + } + } + return preserved, nil + }); err != nil { + log.Printf("Error saving available dates: %v", err) + http.Error(w, "Dates could not be saved", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, Response{Success: true, Message: "Änderungen gespeichert."}) +} + func getSettingsFilePath() string { path := os.Getenv("SETTINGS_FILE") if path == "" { diff --git a/public/admin.css b/public/admin.css index f2b2904..6fba6ff 100644 --- a/public/admin.css +++ b/public/admin.css @@ -225,11 +225,101 @@ background: #fff0f2; } +.delete-date-btn.is-booked, +.delete-date-btn.is-booked:hover, +.delete-date-btn.is-booked:focus-visible { + border-color: #bdb7b2; + background: #e3e0dd; + color: #88827d; + cursor: not-allowed; +} + .empty-dates { margin: 18px 0 0; color: var(--color-text-light); } +.admin-calendar { + margin-top: 20px; + padding: 16px; + background: #fffefa; + border: 1px solid #eaded1; + border-radius: 8px; +} + +.calendar-header, +.weekday-row, +.day-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; +} + +.calendar-header { + grid-template-columns: 40px 1fr 40px; + align-items: center; + margin-bottom: 12px; +} + +.month-label { + margin: 0; + color: var(--color-rose-dark); + font-size: 1.05rem; + text-align: center; +} + +.month-nav-btn { + width: 40px; + height: 40px; + border: 0; + border-radius: 50%; + background: var(--color-rose-light); + color: var(--color-rose-dark); + font-size: 1.2rem; + cursor: pointer; +} + +.month-nav-btn:disabled { opacity: 0.35; cursor: not-allowed; } +.weekday-row { margin-bottom: 6px; } +.weekday-row span { text-align: center; color: var(--color-text-light); font-size: 0.78rem; font-weight: 700; } + +.day-cell { + position: relative; + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--color-text); + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.day-cell.is-empty { pointer-events: none; } +.day-cell.is-disabled { color: #d9c3cb; cursor: not-allowed; } +.day-cell.is-today { box-shadow: inset 0 0 0 2px var(--color-rose); } +.day-cell.is-short { background: linear-gradient(90deg, var(--color-rose) 0 50%, var(--color-rose-light) 50%); color: var(--color-white); } +.day-cell.is-long { background: linear-gradient(135deg, var(--color-rose) 0%, var(--color-rose-dark) 100%); color: var(--color-white); } +.day-cell.is-booked { background: #4b9b68; color: var(--color-white); cursor: not-allowed; } + +.calendar-legend { + display: flex; + gap: 18px; + margin: 12px 0 16px; + color: var(--color-text-light); + font-size: 0.85rem; +} + +.calendar-legend span { display: inline-flex; align-items: center; gap: 6px; } +.legend-dot { width: 16px; height: 16px; border-radius: 50%; } +.legend-dot.is-short { background: linear-gradient(90deg, var(--color-rose) 0 50%, var(--color-rose-light) 50%); } +.legend-dot.is-long { background: var(--color-rose-dark); } + +.date-type-label { color: var(--color-text-light); font-weight: 700; } + .hidden { display: none !important; } diff --git a/public/admin.html b/public/admin.html index ead6827..a271874 100644 --- a/public/admin.html +++ b/public/admin.html @@ -35,25 +35,23 @@
-

Neuen Tag hinzufügen

-

Datum im Kalender wählen und Zeitumfang festlegen.

+

Verfügbare Tage planen

+

Einmal klicken für kurz, zweimal für lang und ein drittes Mal zum Entfernen.

-
- - -
- Tagestyp - - -
- -
+
+
+ +

Monat Jahr

+ +
+
+
+
+
+ Kurz + Lang +
+

diff --git a/public/admin.js b/public/admin.js index c22538c..a7dc2d1 100644 --- a/public/admin.js +++ b/public/admin.js @@ -4,46 +4,42 @@ 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"); - + 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 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(); 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 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", - }); + 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 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."); @@ -56,101 +52,111 @@ function showLogin(message) { adminView.classList.add("hidden"); loginView.classList.remove("hidden"); - if (message) { - setMessage(loginMessage, message, false); - } + 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 = `${dates.length} ${dates.length === 1 ? "Termin" : "Termine"}`; - emptyDates.classList.toggle("hidden", dates.length !== 0); - - dates.forEach((date) => { + 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 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 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 = "Löschen"; - deleteButton.addEventListener("click", async () => { - if (!window.confirm(`${formatDate(date.date)} wirklich löschen?`)) { - return; - } + deleteButton.textContent = "Entfernen"; + if (bookedDates.has(date.date)) { 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); + 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, "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); + 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 { - dates = await request("/api/admin/dates", { method: "GET" }); - dates.sort((first, second) => first.date.localeCompare(second.date)); + 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"); + renderCalendar(); renderDates(); } catch (error) { - if (error.status === 401) { - showLogin(); - return; - } - showLogin("Die Termine konnten nicht geladen werden."); + if (error.status === 401) showLogin(); + else showLogin("Die Termine konnten nicht geladen werden."); } } @@ -158,10 +164,7 @@ event.preventDefault(); setMessage(loginMessage, "", false); try { - await request("/api/admin/login", { - method: "POST", - body: JSON.stringify({ password: passwordInput.value }), - }); + await request("/api/admin/login", { method: "POST", body: JSON.stringify({ password: passwordInput.value }) }); passwordInput.value = ""; await loadDates(); } catch (error) { @@ -169,38 +172,34 @@ } }); - addDateForm.addEventListener("submit", async (event) => { - event.preventDefault(); - const type = addDateForm.elements.dateType.value; - setMessage(addDateMessage, "", false); + saveDatesBtn.addEventListener("click", async () => { + saveDatesBtn.disabled = true; 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)); + 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, "Datum hinzugefügt.", true); - addDateForm.reset(); - dateInput.min = todayKey(); + setMessage(addDateMessage, "Änderungen gespeichert.", true); } 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" }); + if (error.status === 401) showLogin("Deine Anmeldung ist abgelaufen."); + else setMessage(addDateMessage, error.message, false); } finally { - showLogin(); + saveDatesBtn.disabled = false; } }); - dateInput.min = todayKey(); + 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(); -})(); \ No newline at end of file +})();