Add admin activity management

This commit is contained in:
troogs
2026-10-01 17:58:41 +02:00
parent 84ed5a7a28
commit 944eed3eb3
7 changed files with 664 additions and 5 deletions
@@ -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.
+2 -2
View File
@@ -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: 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. - **`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`). - **`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). - **`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. 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.
+197 -1
View File
@@ -21,6 +21,7 @@ type Activity struct {
Name string `json:"name"` Name string `json:"name"`
Icon string `json:"icon"` Icon string `json:"icon"`
Type string `json:"type"` Type string `json:"type"`
Disabled bool `json:"disabled,omitempty"`
CustomDescription string `json:"customDescription,omitempty"` CustomDescription string `json:"customDescription,omitempty"`
} }
@@ -83,6 +84,7 @@ func main() {
http.HandleFunc("/api/admin/logout", handleAdminLogout) http.HandleFunc("/api/admin/logout", handleAdminLogout)
http.HandleFunc("/api/admin/dates", handleAdminDates) http.HandleFunc("/api/admin/dates", handleAdminDates)
http.HandleFunc("/api/admin/dates/bulk", handleAdminBulkDates) http.HandleFunc("/api/admin/dates/bulk", handleAdminBulkDates)
http.HandleFunc("/api/admin/activities", handleAdminActivities)
http.HandleFunc("/admin", handleAdminPage) http.HandleFunc("/admin", handleAdminPage)
// Serve static files from webDir directory // Serve static files from webDir directory
@@ -117,12 +119,158 @@ func handlePublicSettings(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, PublicSettings{ writeJSON(w, http.StatusOK, PublicSettings{
Activities: settings.Activities, Activities: enabledActivities(settings.Activities),
AvailableDates: availableDatesFromToday(settings.AvailableDates), AvailableDates: availableDatesFromToday(settings.AvailableDates),
Names: settings.Names, 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) { func handleAdminLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -389,6 +537,54 @@ func updateAvailableDates(update func([]AvailableDate) ([]AvailableDate, error))
return os.Rename(temporaryPath, getSettingsFilePath()) 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 { func isValidAvailableDate(availableDate AvailableDate) bool {
if availableDate.Type != "short" && availableDate.Type != "long" { if availableDate.Type != "short" && availableDate.Type != "long" {
return false return false
+2 -2
View File
@@ -37,13 +37,13 @@
.then((settings) => { .then((settings) => {
const allActivities = settings.activities || []; 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", name: "Etwas eigenes",
icon: "✏️", icon: "✏️",
type: "custom", 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") { if (selectedDateType === "short") {
otherActivities = otherActivities.filter((a) => a.type === "short"); otherActivities = otherActivities.filter((a) => a.type === "short");
} }
+99
View File
@@ -93,6 +93,29 @@
gap: 32px; 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, .admin-header,
.dates-heading { .dates-heading {
display: flex; display: flex;
@@ -348,3 +371,79 @@
grid-row: 1 / span 2; grid-row: 1 / span 2;
} }
} }
.activity-form {
display: grid;
gap: 12px;
max-width: 560px;
margin-top: 20px;
}
.activity-form > label:not(.toggle-label),
.icon-picker legend {
color: var(--color-text);
font-family: var(--font-heading);
font-size: 0.92rem;
font-weight: 700;
}
.activity-form > input[type="text"] {
min-height: 46px;
padding: 10px 12px;
border: 1px solid #cfbba9;
border-radius: 6px;
background: #fffefa;
color: var(--color-text);
font: inherit;
}
.activity-form > input[type="text"]:focus { outline: 3px solid rgba(224, 87, 126, 0.28); outline-offset: 1px; border-color: var(--color-rose); }
.icon-picker { margin: 4px 0 0; padding: 0; border: 0; }
.icon-picker legend { margin-bottom: 8px; }
.icon-options { display: flex; flex-wrap: wrap; gap: 8px; }
.icon-option { width: 42px; height: 42px; padding: 0; border: 1px solid #cfbba9; border-radius: 6px; background: #fffefa; font-size: 1.35rem; cursor: pointer; }
.icon-option.is-selected { border-color: var(--color-rose-dark); background: var(--color-rose-light); box-shadow: inset 0 0 0 2px var(--color-rose-dark); }
.toggle-label { display: flex; align-items: center; gap: 8px; margin-top: 4px; cursor: pointer; }
.toggle-label input { width: 18px; height: 18px; accent-color: var(--color-rose-dark); }
.form-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 4px; }
.btn-secondary { border: 1px solid #cfbba9; background: #fffefa; color: var(--color-text); }
.activities-list { display: grid; gap: 10px; margin: 18px 0 0; padding: 0; list-style: none; }
.activity-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 0; border-bottom: 1px solid #e5d8ce; }
.activity-row.is-disabled { opacity: 0.58; }
.activity-add-row { margin-top: 18px; padding: 10px 0; border-top: 1px dashed #cfbba9; border-bottom: 1px solid #e5d8ce; }
.activity-add-identity { flex: 1; }
.activity-name-input { min-width: 0; width: 100%; border: 0; border-bottom: 1px solid #cfbba9; background: transparent; color: var(--color-text); font: inherit; font-family: var(--font-heading); font-weight: 700; outline: 0; }
.activity-name-input:focus { border-color: var(--color-rose-dark); box-shadow: 0 2px 0 var(--color-rose-light); }
.activity-name-input::placeholder { color: var(--color-text-light); font-weight: 600; }
.activity-identity { position: relative; display: grid; grid-template-columns: 36px minmax(0, 1fr); align-items: center; column-gap: 10px; min-width: 0; }
.activity-row-icon { grid-row: span 2; width: 38px; height: 38px; padding: 0; border: 1px solid transparent; border-radius: 50%; background: transparent; color: var(--color-text); font-size: 1.5rem; line-height: 1; text-align: center; cursor: pointer; }
.activity-row-icon:hover, .activity-row-icon:focus-visible { border-color: #cfbba9; background: #fffefa; }
.activity-row-icon:disabled { cursor: not-allowed; opacity: 0.55; }
.activity-row-name { font-family: var(--font-heading); font-weight: 700; overflow-wrap: anywhere; }
.activity-identity small { color: var(--color-text-light); }
.activity-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.row-action { width: 36px; height: 36px; padding: 0; border: 1px solid #cfbba9; border-radius: 5px; background: #fffefa; color: var(--color-text); font: inherit; font-size: 1.05rem; line-height: 1; cursor: pointer; }
.row-action:hover, .row-action:focus-visible { border-color: var(--color-rose-dark); color: var(--color-rose-dark); }
.row-action:disabled { opacity: 0.35; cursor: not-allowed; }
.type-action { display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; }
.duration-indicator { display: block; width: 18px; height: 18px; border-radius: 50%; }
.type-action.is-short .duration-indicator { background: linear-gradient(90deg, var(--color-rose) 0 50%, var(--color-rose-light) 50%); }
.type-action.is-long .duration-indicator { background: var(--color-rose-dark); }
.type-action.is-short { color: var(--color-rose); }
.type-action.is-long { color: var(--color-rose-dark); }
.row-action.is-danger { border-color: #c84a62; color: #a83049; }
.activity-icon-picker { position: absolute; z-index: 5; top: 44px; left: 0; display: grid; grid-template-columns: repeat(6, 48px); gap: 8px; width: 352px; padding: 12px; border: 1px solid #cfbba9; border-radius: 8px; background: #fffefa; box-shadow: 0 12px 24px rgba(74, 44, 61, 0.18); }
.activity-icon-picker.hidden { display: none; }
.activity-icon-picker .icon-option { width: 48px; height: 48px; font-size: 1.65rem; }
.activity-add-picker { top: 52px; }
@media (max-width: 560px) {
.activity-row { align-items: flex-start; flex-direction: column; }
.activity-actions { justify-content: flex-start; }
}
@media (max-width: 400px) {
.activity-icon-picker { grid-template-columns: repeat(5, 48px); width: 296px; }
}
+34
View File
@@ -33,6 +33,12 @@
<button id="logoutBtn" class="text-action" type="button">Abmelden</button> <button id="logoutBtn" class="text-action" type="button">Abmelden</button>
</header> </header>
<nav class="admin-nav" aria-label="Verwaltung">
<button class="admin-nav-btn is-active" type="button" data-view="datesView">Termine</button>
<button class="admin-nav-btn" type="button" data-view="activitiesView">Aktivitäten</button>
</nav>
<div id="datesView" class="admin-panel">
<section class="admin-section add-date-section" aria-labelledby="addDateTitle"> <section class="admin-section add-date-section" aria-labelledby="addDateTitle">
<div class="section-heading"> <div class="section-heading">
<h2 id="addDateTitle">Verfügbare Tage planen</h2> <h2 id="addDateTitle">Verfügbare Tage planen</h2>
@@ -65,6 +71,34 @@
<ul id="datesList" class="dates-list" aria-live="polite"></ul> <ul id="datesList" class="dates-list" aria-live="polite"></ul>
<p id="emptyDates" class="empty-dates hidden">Noch keine Termine eingetragen.</p> <p id="emptyDates" class="empty-dates hidden">Noch keine Termine eingetragen.</p>
</section> </section>
</div>
<div id="activitiesView" class="admin-panel hidden">
<section class="admin-section" aria-labelledby="activitiesTitle">
<div class="section-heading">
<h2 id="activitiesTitle">Aktivitäten verwalten</h2>
<p>Aktive Aktivitäten werden Besucherinnen und Besuchern zur Auswahl angezeigt.</p>
</div>
</section>
<section class="admin-section" aria-labelledby="activityListTitle">
<div class="section-heading">
<h2 id="activityListTitle">Vorhandene Aktivitäten</h2>
<p id="activityMessage" class="form-message" role="status" aria-live="polite"></p>
</div>
<form id="activityAddRow" class="activity-row activity-add-row">
<div class="activity-identity activity-add-identity">
<button id="newActivityIcon" class="activity-row-icon" type="button" title="Icon auswählen" aria-label="Icon für neue Aktivität auswählen">🧺</button>
<input id="newActivityName" class="activity-name-input" type="text" maxlength="100" placeholder="Neue Aktivität" aria-label="Name der neuen Aktivität" required />
</div>
<div class="activity-actions">
<button id="newActivityType" class="row-action type-action is-short" type="button" title="Auf lang ändern" aria-label="Neue Aktivität: auf lang ändern"><span class="duration-indicator"></span></button>
<button class="row-action" type="submit" title="Aktivität hinzufügen" aria-label="Aktivität hinzufügen">+</button>
</div>
</form>
<ul id="activitiesList" class="activities-list" aria-live="polite"></ul>
<p id="emptyActivities" class="empty-dates hidden">Noch keine Aktivitäten angelegt.</p>
</section>
</div>
</section> </section>
</main> </main>
<script src="admin.js" defer></script> <script src="admin.js" defer></script>
+239
View File
@@ -15,6 +15,14 @@
const prevMonthBtn = document.getElementById("prevMonthBtn"); const prevMonthBtn = document.getElementById("prevMonthBtn");
const nextMonthBtn = document.getElementById("nextMonthBtn"); const nextMonthBtn = document.getElementById("nextMonthBtn");
const saveDatesBtn = document.getElementById("saveDatesBtn"); 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 weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
const months = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]; const months = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
const today = new Date(); const today = new Date();
@@ -24,6 +32,7 @@
let draftDates = new Map(); let draftDates = new Map();
let viewYear = today.getFullYear(); let viewYear = today.getFullYear();
let viewMonth = today.getMonth(); let viewMonth = today.getMonth();
let activities = [];
function setMessage(element, message, success) { function setMessage(element, message, success) {
element.textContent = message; element.textContent = message;
@@ -152,6 +161,7 @@
draftDates = new Map(dates.map((date) => [date.date, date.type])); draftDates = new Map(dates.map((date) => [date.date, date.type]));
loginView.classList.add("hidden"); loginView.classList.add("hidden");
adminView.classList.remove("hidden"); adminView.classList.remove("hidden");
await loadActivities();
renderCalendar(); renderCalendar();
renderDates(); renderDates();
} catch (error) { } 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) => { loginForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
setMessage(loginMessage, "", false); 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", () => { prevMonthBtn.addEventListener("click", () => {
viewMonth -= 1; viewMonth -= 1;
if (viewMonth < 0) { viewMonth = 11; viewYear -= 1; } if (viewMonth < 0) { viewMonth = 11; viewYear -= 1; }