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 @@ + + +