From 944eed3eb3d72ea9edda871989c0a377df1c1a05 Mon Sep 17 00:00:00 2001 From: troogs Date: Thu, 1 Oct 2026 17:58:41 +0200 Subject: [PATCH] Add admin activity management --- .../12-admin-activity-management.prompt.md | 91 +++++++ README.md | 4 +- main.go | 198 ++++++++++++++- public/activity.js | 4 +- public/admin.css | 99 ++++++++ public/admin.html | 34 +++ public/admin.js | 239 ++++++++++++++++++ 7 files changed, 664 insertions(+), 5 deletions(-) create mode 100644 .github/prompts/12-admin-activity-management.prompt.md diff --git a/.github/prompts/12-admin-activity-management.prompt.md b/.github/prompts/12-admin-activity-management.prompt.md new file mode 100644 index 0000000..d703a4a --- /dev/null +++ b/.github/prompts/12-admin-activity-management.prompt.md @@ -0,0 +1,91 @@ +--- +agent: agent +description: Manage the Date Wizard activity catalog from the authenticated admin page, including activity CRUD, temporary pause/resume, inline creation, icon selection, duration switching, and the special custom activity. +--- + +Implement or review activity management for the Date Wizard application. Preserve the existing Go server, JSON settings persistence, German user-facing language, authentication, and visual conventions. + +## Admin navigation and activity list + +The authenticated admin page must separate date management from activity management with clear navigation. Do not show every admin workflow in one long view. + +The activity view must show a list of all configured activities and must support: + +- Displaying the activity icon, name, and duration state. +- Renaming an activity through an inline name editor with save and cancel actions. Do not rely on `window.prompt`, because the browser environment may not support it. +- Switching an activity between `short` (Kurz) and `long` (Lang) with a compact icon-only control. +- Showing `short` with a half-filled circle and `long` with a full circle. Both indicators must have identical dimensions and the indicator must be centered inside its button. +- Changing an activity icon by clicking its icon and opening a sufficiently large popup icon picker. +- Providing a broad, curated emoji/icon selection. Highlight the currently selected icon. +- Closing the icon picker after selection and when clicking outside it. +- Temporarily disabling and re-enabling an activity. Use a pause icon for an active activity and a play icon for a paused activity. +- Deleting regular activities with a trash icon and confirmation. +- Using icon-only action buttons with tooltips and accessible `aria-label` values. Do not use visible text labels for row actions when a familiar icon exists. +- Keeping the layout responsive on desktop and narrow screens. + +## Inline activity creation + +Include an add row that visually matches the existing activity rows. Adding is handled from this row rather than a separate form: + +- On the left, show an icon selector button. +- Next to it, provide an input for the new activity name. +- On the right, provide a short/long duration toggle using the same half/full circle treatment as existing activities. +- Provide an icon-only save button that creates the activity. +- Validate that the name is non-empty, within the supported length limit, and that the selected duration is `short` or `long`. +- Reject duplicate names and show a useful success or error message. +- Reset the add row after a successful save. + +## Special custom activity + +The settings may contain one special activity with `type: "custom"`, originally named `Etwas eigenes` and using an appropriate edit/pencil icon. It has special rules: + +- Always render the custom activity as the last activity in the admin list and the public activity list. +- It must remain available in the booking flow. +- When selected publicly, it lets the visitor enter a custom description. +- The description is stored with the booking submission and included in the notification email and generated iCalendar file. +- It cannot be combined with any other activity and is the only selected activity when used. +- The custom activity cannot be deleted. +- It cannot switch between short and long; hide or remove its duration/type button. +- It can be paused and resumed like other activities. +- Its rename action remains available. Renaming must not break custom-activity behavior: public code must identify the special entry by `type: "custom"`, not only by the original name. +- If custom icon editing is intentionally restricted, keep its icon stable while still allowing rename and pause/resume. + +## Backend requirements + +Use the existing authenticated Go HTTP API and settings file persistence. + +- Add or preserve an authenticated activity endpoint such as `/api/admin/activities`. +- `GET` returns all activities for the admin, including paused activities. +- `POST` creates a regular activity. +- `PUT` updates a regular activity's name, icon, type, and disabled state. +- `PUT` allows the custom activity's name and disabled state to change while keeping its `type: "custom"` and protected fields valid. +- `DELETE` removes regular activities only and rejects deletion of the custom activity with an appropriate error response. +- Persist changes atomically under the existing settings mutex. +- Validate names, icons, supported regular types, duplicates, and malformed requests server-side. +- Filter disabled activities from the public settings response while retaining them in admin responses. +- Do not expose the admin password in public settings. + +## Public booking behavior + +- Public activity selection must use the enabled activities from the settings API. +- The custom activity must be identified by `type: "custom"` even if it has been renamed. +- The custom activity must remain last. +- Preserve existing short-date and long-date selection rules for regular activities. +- Preserve custom-description handling in booking, email, and iCalendar output. + +## Implementation and validation + +Before editing, inspect the existing admin HTML, JavaScript, CSS, public activity flow, Go handlers, settings model, and prompt conventions. Reuse existing helpers and styling patterns. + +After editing: + +1. Run the project formatter for changed Go files. +2. Run the available Go test/build command. +3. Run JavaScript syntax checks for changed scripts when Node.js is available. +4. Check diagnostics for changed files. +5. Verify the unauthenticated login view and authenticated navigation. +6. Verify the activity list, custom-last ordering, custom pause-only controls, inline rename behavior, inline add row, duration indicators, icon picker, pause/play states, and trash confirmation in the browser. +7. Verify that disabled activities are absent from public activity selection. +8. Do not modify `appsettings.json` or unrelated fixtures when documenting or committing this feature unless explicitly requested. + +When reporting the result, summarize changed files, user-visible behavior, API behavior, and validation commands that passed or failed. diff --git a/README.md b/README.md index b6e5c84..625bfab 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,11 @@ Trage in der `.env` deine Server- und SMTP-Zugangsdaten ein: Passe in `appsettings.json` die zur Auswahl stehenden Aktivitäten, verfügbaren Tage und das Admin-Passwort an: - **`adminPassword`**: Passwort für die Terminverwaltung unter `/admin`. Ändere den Standardwert `change-me`, bevor du die Anwendung veröffentlichst. -- **`activities`**: Liste von Objekten mit `name`, `icon` (Unicode Emoji) und `type` (`short` oder `long`). +- **`activities`**: Liste von Objekten mit `name`, `icon` (Unicode Emoji), `type` (`short` oder `long`) und optional `disabled` (`true` blendet die Aktivität vorübergehend aus). - **`availableDates`**: Liste von verfügbaren Tagen mit `date` (`YYYY-MM-DD`) und `type` (`short` oder `long`). - **`names`**: Optional vordefinierte Namen zur Auswahl (ist die Liste leer, wird ein Standard-Fallback angeboten). -Die Terminliste lässt sich auch über die passwortgeschützte Verwaltung unter `http://localhost:8080/admin` bearbeiten. Das Passwort bleibt dabei auf dem Server und wird nicht an Besucher ausgeliefert. +Die Termin- und Aktivitätenlisten lassen sich über die passwortgeschützte Verwaltung unter `http://localhost:8080/admin` bearbeiten. Das Passwort bleibt dabei auf dem Server und wird nicht an Besucher ausgeliefert. Bei Docker Compose werden `appsettings.json` und `booked_dates.json` dauerhaft unter `/docker/data/date` gespeichert. Beim ersten Start legt der Container die Einstellungen dort aus der im Image enthaltenen Vorlage an. Ändere danach den Standardwert `change-me` in `/docker/data/date/appsettings.json` oder übernimm vor dem ersten Start eine vorbereitete Konfigurationsdatei. diff --git a/main.go b/main.go index 6b0d186..2a3043c 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ type Activity struct { Name string `json:"name"` Icon string `json:"icon"` Type string `json:"type"` + Disabled bool `json:"disabled,omitempty"` CustomDescription string `json:"customDescription,omitempty"` } @@ -83,6 +84,7 @@ func main() { http.HandleFunc("/api/admin/logout", handleAdminLogout) http.HandleFunc("/api/admin/dates", handleAdminDates) http.HandleFunc("/api/admin/dates/bulk", handleAdminBulkDates) + http.HandleFunc("/api/admin/activities", handleAdminActivities) http.HandleFunc("/admin", handleAdminPage) // Serve static files from webDir directory @@ -117,12 +119,158 @@ func handlePublicSettings(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, PublicSettings{ - Activities: settings.Activities, + Activities: enabledActivities(settings.Activities), AvailableDates: availableDatesFromToday(settings.AvailableDates), Names: settings.Names, }) } +func handleAdminActivities(w http.ResponseWriter, r *http.Request) { + if !isAdminRequest(r) { + writeJSON(w, http.StatusUnauthorized, Response{Success: false, Message: "Anmeldung erforderlich."}) + return + } + + switch r.Method { + case http.MethodGet: + settings, err := readSettings() + if err != nil { + http.Error(w, "Settings could not be loaded", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, settings.Activities) + case http.MethodPost: + var activity Activity + if err := json.NewDecoder(r.Body).Decode(&activity); err != nil { + writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Ungültige Aktivität."}) + return + } + if err := validateActivity(activity); err != nil { + writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: err.Error()}) + return + } + if err := updateActivities(func(activities []Activity) ([]Activity, error) { + for _, existing := range activities { + if strings.EqualFold(existing.Name, activity.Name) { + return nil, fmt.Errorf("activity already exists") + } + } + return append(activities, activity), nil + }); err != nil { + if err.Error() == "activity already exists" { + writeJSON(w, http.StatusConflict, Response{Success: false, Message: "Diese Aktivität existiert bereits."}) + return + } + http.Error(w, "Activity could not be saved", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusCreated, Response{Success: true, Message: "Aktivität hinzugefügt."}) + case http.MethodPut: + var request struct { + OriginalName string `json:"originalName"` + Activity + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.OriginalName == "" { + writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Ungültige Aktivität."}) + return + } + if err := validateActivity(request.Activity); err != nil && request.Type != "custom" { + writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: err.Error()}) + return + } + if request.Type == "custom" && (strings.TrimSpace(request.Name) == "" || len([]rune(request.Name)) > 100) { + writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte gib einen Namen mit maximal 100 Zeichen ein."}) + return + } + updated := false + if err := updateActivities(func(activities []Activity) ([]Activity, error) { + for index := range activities { + if activities[index].Name == request.OriginalName { + if activities[index].Type == "custom" { + if request.Icon != activities[index].Icon || request.Type != "custom" { + return nil, fmt.Errorf("custom activity fields are read-only") + } + for otherIndex, other := range activities { + if otherIndex != index && strings.EqualFold(other.Name, request.Name) { + return nil, fmt.Errorf("activity already exists") + } + } + activities[index].Name = request.Name + activities[index].Disabled = request.Disabled + updated = true + break + } + for otherIndex, other := range activities { + if otherIndex != index && strings.EqualFold(other.Name, request.Name) { + return nil, fmt.Errorf("activity already exists") + } + } + activities[index] = request.Activity + updated = true + break + } + } + if !updated { + return nil, fmt.Errorf("activity does not exist") + } + return activities, nil + }); err != nil { + switch err.Error() { + case "activity already exists": + writeJSON(w, http.StatusConflict, Response{Success: false, Message: "Diese Aktivität existiert bereits."}) + case "activity does not exist": + writeJSON(w, http.StatusNotFound, Response{Success: false, Message: "Aktivität wurde nicht gefunden."}) + case "custom activity fields are read-only": + writeJSON(w, http.StatusForbidden, Response{Success: false, Message: "Typ und Icon dieser Aktivität können nicht geändert werden."}) + default: + http.Error(w, "Activity could not be saved", http.StatusInternalServerError) + } + return + } + writeJSON(w, http.StatusOK, Response{Success: true, Message: "Aktivität aktualisiert."}) + case http.MethodDelete: + var request struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Name == "" { + writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Aktivität wurde nicht angegeben."}) + return + } + deleted := false + if err := updateActivities(func(activities []Activity) ([]Activity, error) { + filtered := make([]Activity, 0, len(activities)) + for _, activity := range activities { + if activity.Name == request.Name { + if activity.Type == "custom" { + return nil, fmt.Errorf("activity cannot be deleted") + } + deleted = true + continue + } + filtered = append(filtered, activity) + } + if !deleted { + return nil, fmt.Errorf("activity does not exist") + } + return filtered, nil + }); err != nil { + if err.Error() == "activity does not exist" { + writeJSON(w, http.StatusNotFound, Response{Success: false, Message: "Aktivität wurde nicht gefunden."}) + return + } + if err.Error() == "activity cannot be deleted" { + writeJSON(w, http.StatusForbidden, Response{Success: false, Message: "Diese Aktivität kann nicht gelöscht werden."}) + return + } + http.Error(w, "Activity could not be saved", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, Response{Success: true, Message: "Aktivität gelöscht."}) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + func handleAdminLogin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -389,6 +537,54 @@ func updateAvailableDates(update func([]AvailableDate) ([]AvailableDate, error)) return os.Rename(temporaryPath, getSettingsFilePath()) } +func updateActivities(update func([]Activity) ([]Activity, error)) error { + settingsMu.Lock() + defer settingsMu.Unlock() + + settings, err := readSettingsFile() + if err != nil { + return err + } + activities, err := update(settings.Activities) + if err != nil { + return err + } + settings.Activities = activities + + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + temporaryPath := getSettingsFilePath() + ".tmp" + if err := os.WriteFile(temporaryPath, data, 0600); err != nil { + return err + } + return os.Rename(temporaryPath, getSettingsFilePath()) +} + +func validateActivity(activity Activity) error { + if strings.TrimSpace(activity.Name) == "" || len([]rune(activity.Name)) > 100 { + return fmt.Errorf("Bitte gib einen Namen mit maximal 100 Zeichen ein.") + } + if strings.TrimSpace(activity.Icon) == "" || len([]rune(activity.Icon)) > 8 { + return fmt.Errorf("Bitte wähle ein Icon aus.") + } + if activity.Type != "short" && activity.Type != "long" { + return fmt.Errorf("Bitte wähle Kurz oder Lang.") + } + return nil +} + +func enabledActivities(activities []Activity) []Activity { + enabled := make([]Activity, 0, len(activities)) + for _, activity := range activities { + if !activity.Disabled { + enabled = append(enabled, activity) + } + } + return enabled +} + func isValidAvailableDate(availableDate AvailableDate) bool { if availableDate.Type != "short" && availableDate.Type != "long" { return false diff --git a/public/activity.js b/public/activity.js index aaaf9cf..e3e175b 100644 --- a/public/activity.js +++ b/public/activity.js @@ -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"); } diff --git a/public/admin.css b/public/admin.css index 6fba6ff..6864efe 100644 --- a/public/admin.css +++ b/public/admin.css @@ -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; } } \ No newline at end of file diff --git a/public/admin.html b/public/admin.html index a271874..1a66dec 100644 --- a/public/admin.html +++ b/public/admin.html @@ -33,6 +33,12 @@ + + +

Verfügbare Tage planen

@@ -65,6 +71,34 @@
    +
    + + diff --git a/public/admin.js b/public/admin.js index a7dc2d1..2584fa2 100644 --- a/public/admin.js +++ b/public/admin.js @@ -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; }