Compare commits
3
Commits
097e72d364
...
944eed3eb3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
944eed3eb3 | ||
|
|
84ed5a7a28 | ||
|
|
3605a42099 |
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
agent: agent
|
||||||
|
description: Create or improve the Date Wizard admin page for managing available date slots, including authentication, calendar bulk editing, staged saves, and booked-date protection.
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement or review the complete admin page for the Date Wizard application. Preserve the existing project architecture, styling conventions, API patterns, and German user-facing language.
|
||||||
|
|
||||||
|
## Existing admin functionality
|
||||||
|
|
||||||
|
The admin page is available at `/admin` and must provide:
|
||||||
|
|
||||||
|
- A password login form.
|
||||||
|
- Session-based authentication using the existing admin login and logout endpoints.
|
||||||
|
- A clear message when the password is invalid.
|
||||||
|
- A clear message when the session has expired.
|
||||||
|
- A logout action.
|
||||||
|
- Loading and displaying all available dates from the admin API.
|
||||||
|
- Displaying each date with its configured type: `short` (Kurz) or `long` (Lang).
|
||||||
|
- Date validation that allows only today or future dates and only the two supported types.
|
||||||
|
- Responsive styling consistent with the existing Date Wizard design.
|
||||||
|
|
||||||
|
## Calendar bulk editor
|
||||||
|
|
||||||
|
Replace the need to add dates one at a time with a monthly calendar editor similar to the public booking calendar.
|
||||||
|
|
||||||
|
- Show a month calendar with Monday-first weekday headings.
|
||||||
|
- Provide previous-month and next-month navigation.
|
||||||
|
- Do not allow selecting today or dates in the past.
|
||||||
|
- Clicking an editable future date cycles through exactly three states:
|
||||||
|
1. Not selected: no available date.
|
||||||
|
2. First click: `short` / Kurz.
|
||||||
|
3. Second click: `long` / Lang.
|
||||||
|
4. Third click: back to not selected.
|
||||||
|
- Render a short date with a half-filled circle, for example a circle split into two colors.
|
||||||
|
- Render a long date with the normal full selected circle.
|
||||||
|
- Make the state visually and programmatically accessible with appropriate labels and disabled states.
|
||||||
|
- Show a legend explaining the short and long visual states.
|
||||||
|
- Keep all calendar changes in a client-side draft. Clicking a date must never save immediately.
|
||||||
|
- Include an explicit `Änderungen speichern` button.
|
||||||
|
- Save the complete draft in one authenticated bulk request only after the administrator clicks the save button.
|
||||||
|
- Refresh the visible date list from the draft after local changes and show a success or error message after saving.
|
||||||
|
- Allow removing an unbooked date from the pending draft without immediately saving it.
|
||||||
|
|
||||||
|
## Booked-date protection
|
||||||
|
|
||||||
|
Dates that have already been booked are read-only in the admin page.
|
||||||
|
|
||||||
|
- Load booked dates from the existing `/api/booked-dates` endpoint.
|
||||||
|
- A booked date must remain in the available-date configuration and must not be removable through the admin calendar.
|
||||||
|
- Render a booked future date as a green calendar circle.
|
||||||
|
- Disable the calendar button for a booked date so clicks do nothing.
|
||||||
|
- Mark the calendar entry as booked using an accessible label.
|
||||||
|
- Disable its `Entfernen` button in the date list.
|
||||||
|
- Style the disabled removal button grey with a not-allowed cursor.
|
||||||
|
- Do not let bulk-save requests remove or modify booked dates, even if a malicious or stale client submits a draft that omits them.
|
||||||
|
- Enforce this protection server-side as well as in the browser.
|
||||||
|
|
||||||
|
## Backend requirements
|
||||||
|
|
||||||
|
Use the existing Go HTTP server and settings persistence mechanisms.
|
||||||
|
|
||||||
|
- Keep the existing single-date endpoints compatible unless there is a compelling project-specific reason to change them.
|
||||||
|
- Add an authenticated bulk endpoint, such as `POST /api/admin/dates/bulk`.
|
||||||
|
- Accept a JSON array of objects shaped like `{ "date": "YYYY-MM-DD", "type": "short" | "long" }`.
|
||||||
|
- Reject malformed dates, unsupported types, duplicate dates, past dates, and today.
|
||||||
|
- Validate the complete request before changing the settings file.
|
||||||
|
- Save the complete future-date selection atomically under the existing settings mutex.
|
||||||
|
- Preserve historical dates according to the existing application behavior.
|
||||||
|
- Preserve all booked dates regardless of whether they are included in the submitted draft.
|
||||||
|
- Do not create duplicate available-date entries.
|
||||||
|
- Return useful JSON success and error responses and use appropriate HTTP status codes.
|
||||||
|
|
||||||
|
## Implementation and validation
|
||||||
|
|
||||||
|
Before editing, inspect the existing admin HTML, JavaScript, CSS, Go handlers, settings model, public booking calendar, and booked-date storage/API. Reuse existing helpers and visual patterns where practical.
|
||||||
|
|
||||||
|
After editing:
|
||||||
|
|
||||||
|
1. Run the project's formatter.
|
||||||
|
2. Run the Go test/build command available in the repository.
|
||||||
|
3. Run a JavaScript syntax check for the admin script when Node.js is available.
|
||||||
|
4. Check diagnostics and whitespace errors.
|
||||||
|
5. Verify that the page renders the login view when unauthenticated.
|
||||||
|
6. Verify that the calendar, save button, staged state changes, booked green state, and disabled grey removal button are present after authentication.
|
||||||
|
7. Do not modify booked-date fixtures or unrelated application behavior.
|
||||||
|
|
||||||
|
When reporting the result, summarize the files changed, the user-visible behavior, the API changes, and the validation commands that passed or failed.
|
||||||
@@ -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.
|
||||||
Vendored
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
{
|
{
|
||||||
"label": "Run Date Wizard",
|
"label": "Run Date Wizard",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "go",
|
"command": "${env:LOCALAPPDATA}\\Programs\\go\\bin\\go.exe",
|
||||||
"args": [
|
"args": [
|
||||||
"run",
|
"run",
|
||||||
"."
|
"."
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +83,8 @@ func main() {
|
|||||||
http.HandleFunc("/api/admin/login", handleAdminLogin)
|
http.HandleFunc("/api/admin/login", handleAdminLogin)
|
||||||
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/activities", handleAdminActivities)
|
||||||
http.HandleFunc("/admin", handleAdminPage)
|
http.HandleFunc("/admin", handleAdminPage)
|
||||||
|
|
||||||
// Serve static files from webDir directory
|
// Serve static files from webDir directory
|
||||||
@@ -116,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)
|
||||||
@@ -281,6 +430,61 @@ func handleAdminDates(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleAdminBulkDates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !isAdminRequest(r) {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, Response{Success: false, Message: "Anmeldung erforderlich."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestedDates []AvailableDate
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&requestedDates); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Ungültige Terminauswahl."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool, len(requestedDates))
|
||||||
|
for _, date := range requestedDates {
|
||||||
|
if !isValidAvailableDate(date) || !isTodayOrFuture(date.Date) || seen[date.Date] {
|
||||||
|
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte wähle gültige, eindeutige Daten ab heute."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[date.Date] = true
|
||||||
|
}
|
||||||
|
bookedDates, err := getBookedDates()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error reading booked dates: %v", err)
|
||||||
|
http.Error(w, "Booked dates could not be loaded", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bookedDateSet := make(map[string]bool, len(bookedDates))
|
||||||
|
for _, date := range bookedDates {
|
||||||
|
bookedDateSet[date] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := updateAvailableDates(func(dates []AvailableDate) ([]AvailableDate, error) {
|
||||||
|
preserved := make([]AvailableDate, 0, len(dates)+len(requestedDates))
|
||||||
|
for _, date := range dates {
|
||||||
|
if !isTodayOrFuture(date.Date) || bookedDateSet[date.Date] {
|
||||||
|
preserved = append(preserved, date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, date := range requestedDates {
|
||||||
|
if !bookedDateSet[date.Date] {
|
||||||
|
preserved = append(preserved, date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return preserved, nil
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("Error saving available dates: %v", err)
|
||||||
|
http.Error(w, "Dates could not be saved", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Änderungen gespeichert."})
|
||||||
|
}
|
||||||
|
|
||||||
func getSettingsFilePath() string {
|
func getSettingsFilePath() string {
|
||||||
path := os.Getenv("SETTINGS_FILE")
|
path := os.Getenv("SETTINGS_FILE")
|
||||||
if path == "" {
|
if path == "" {
|
||||||
@@ -333,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
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -225,11 +248,101 @@
|
|||||||
background: #fff0f2;
|
background: #fff0f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.delete-date-btn.is-booked,
|
||||||
|
.delete-date-btn.is-booked:hover,
|
||||||
|
.delete-date-btn.is-booked:focus-visible {
|
||||||
|
border-color: #bdb7b2;
|
||||||
|
background: #e3e0dd;
|
||||||
|
color: #88827d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-dates {
|
.empty-dates {
|
||||||
margin: 18px 0 0;
|
margin: 18px 0 0;
|
||||||
color: var(--color-text-light);
|
color: var(--color-text-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-calendar {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fffefa;
|
||||||
|
border: 1px solid #eaded1;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header,
|
||||||
|
.weekday-row,
|
||||||
|
.day-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
grid-template-columns: 40px 1fr 40px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-label {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-rose-dark);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-nav-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-rose-light);
|
||||||
|
color: var(--color-rose-dark);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-nav-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.weekday-row { margin-bottom: 6px; }
|
||||||
|
.weekday-row span { text-align: center; color: var(--color-text-light); font-size: 0.78rem; font-weight: 700; }
|
||||||
|
|
||||||
|
.day-cell {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.is-empty { pointer-events: none; }
|
||||||
|
.day-cell.is-disabled { color: #d9c3cb; cursor: not-allowed; }
|
||||||
|
.day-cell.is-today { box-shadow: inset 0 0 0 2px var(--color-rose); }
|
||||||
|
.day-cell.is-short { background: linear-gradient(90deg, var(--color-rose) 0 50%, var(--color-rose-light) 50%); color: var(--color-white); }
|
||||||
|
.day-cell.is-long { background: linear-gradient(135deg, var(--color-rose) 0%, var(--color-rose-dark) 100%); color: var(--color-white); }
|
||||||
|
.day-cell.is-booked { background: #4b9b68; color: var(--color-white); cursor: not-allowed; }
|
||||||
|
|
||||||
|
.calendar-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
margin: 12px 0 16px;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.legend-dot { width: 16px; height: 16px; border-radius: 50%; }
|
||||||
|
.legend-dot.is-short { background: linear-gradient(90deg, var(--color-rose) 0 50%, var(--color-rose-light) 50%); }
|
||||||
|
.legend-dot.is-long { background: var(--color-rose-dark); }
|
||||||
|
|
||||||
|
.date-type-label { color: var(--color-text-light); font-weight: 700; }
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -257,4 +370,80 @@
|
|||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
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; }
|
||||||
}
|
}
|
||||||
+50
-18
@@ -33,27 +33,31 @@
|
|||||||
<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">Neuen Tag hinzufügen</h2>
|
<h2 id="addDateTitle">Verfügbare Tage planen</h2>
|
||||||
<p>Datum im Kalender wählen und Zeitumfang festlegen.</p>
|
<p>Einmal klicken für kurz, zweimal für lang und ein drittes Mal zum Entfernen.</p>
|
||||||
</div>
|
</div>
|
||||||
<form id="addDateForm" class="date-form">
|
<div class="admin-calendar" aria-label="Kalender zur Terminplanung">
|
||||||
<label for="dateInput">Datum</label>
|
<div class="calendar-header">
|
||||||
<input id="dateInput" name="date" type="date" required />
|
<button id="prevMonthBtn" class="month-nav-btn" type="button" aria-label="Vorheriger Monat">‹</button>
|
||||||
<fieldset class="type-selector">
|
<h3 id="monthLabel" class="month-label">Monat Jahr</h3>
|
||||||
<legend>Tagestyp</legend>
|
<button id="nextMonthBtn" class="month-nav-btn" type="button" aria-label="Nächster Monat">›</button>
|
||||||
<label>
|
</div>
|
||||||
<input type="radio" name="dateType" value="short" checked />
|
<div class="weekday-row" id="weekdayRow"></div>
|
||||||
<span>Kurz</span>
|
<div class="day-grid" id="dayGrid"></div>
|
||||||
</label>
|
</div>
|
||||||
<label>
|
<div class="calendar-legend" aria-label="Legende">
|
||||||
<input type="radio" name="dateType" value="long" />
|
<span><i class="legend-dot is-short"></i>Kurz</span>
|
||||||
<span>Lang</span>
|
<span><i class="legend-dot is-long"></i>Lang</span>
|
||||||
</label>
|
</div>
|
||||||
</fieldset>
|
<button id="saveDatesBtn" class="btn btn-primary" type="button">Änderungen speichern</button>
|
||||||
<button class="btn btn-primary" type="submit">Datum hinzufügen</button>
|
|
||||||
</form>
|
|
||||||
<p id="addDateMessage" class="form-message" role="status" aria-live="polite"></p>
|
<p id="addDateMessage" class="form-message" role="status" aria-live="polite"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -67,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>
|
||||||
|
|||||||
+359
-121
@@ -4,46 +4,51 @@
|
|||||||
const loginForm = document.getElementById("loginForm");
|
const loginForm = document.getElementById("loginForm");
|
||||||
const passwordInput = document.getElementById("passwordInput");
|
const passwordInput = document.getElementById("passwordInput");
|
||||||
const loginMessage = document.getElementById("loginMessage");
|
const loginMessage = document.getElementById("loginMessage");
|
||||||
const addDateForm = document.getElementById("addDateForm");
|
|
||||||
const dateInput = document.getElementById("dateInput");
|
|
||||||
const addDateMessage = document.getElementById("addDateMessage");
|
const addDateMessage = document.getElementById("addDateMessage");
|
||||||
const datesList = document.getElementById("datesList");
|
const datesList = document.getElementById("datesList");
|
||||||
const dateCount = document.getElementById("dateCount");
|
const dateCount = document.getElementById("dateCount");
|
||||||
const emptyDates = document.getElementById("emptyDates");
|
const emptyDates = document.getElementById("emptyDates");
|
||||||
const logoutBtn = document.getElementById("logoutBtn");
|
const logoutBtn = document.getElementById("logoutBtn");
|
||||||
|
const monthLabel = document.getElementById("monthLabel");
|
||||||
|
const weekdayRow = document.getElementById("weekdayRow");
|
||||||
|
const dayGrid = document.getElementById("dayGrid");
|
||||||
|
const prevMonthBtn = document.getElementById("prevMonthBtn");
|
||||||
|
const nextMonthBtn = document.getElementById("nextMonthBtn");
|
||||||
|
const saveDatesBtn = document.getElementById("saveDatesBtn");
|
||||||
|
const 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();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
let dates = [];
|
let dates = [];
|
||||||
|
let bookedDates = new Set();
|
||||||
|
let draftDates = new Map();
|
||||||
|
let viewYear = today.getFullYear();
|
||||||
|
let viewMonth = today.getMonth();
|
||||||
|
let activities = [];
|
||||||
|
|
||||||
function setMessage(element, message, success) {
|
function setMessage(element, message, success) {
|
||||||
element.textContent = message;
|
element.textContent = message;
|
||||||
element.classList.toggle("is-success", Boolean(success));
|
element.classList.toggle("is-success", Boolean(success));
|
||||||
}
|
}
|
||||||
|
|
||||||
function todayKey() {
|
function toDateKey(date) {
|
||||||
const today = new Date();
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||||
const year = today.getFullYear();
|
|
||||||
const month = String(today.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(today.getDate()).padStart(2, "0");
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
return new Date(`${date}T00:00:00`).toLocaleDateString("de-DE", {
|
return new Date(`${date}T00:00:00`).toLocaleDateString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit", year: "numeric" });
|
||||||
weekday: "short",
|
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(path, options) {
|
async function request(path, options) {
|
||||||
const response = await fetch(path, {
|
const response = await fetch(path, { ...options, headers: { "Content-Type": "application/json", ...(options && options.headers) } });
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(options && options.headers),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = new Error(data.message || "Die Anfrage ist fehlgeschlagen.");
|
const error = new Error(data.message || "Die Anfrage ist fehlgeschlagen.");
|
||||||
@@ -56,112 +61,307 @@
|
|||||||
function showLogin(message) {
|
function showLogin(message) {
|
||||||
adminView.classList.add("hidden");
|
adminView.classList.add("hidden");
|
||||||
loginView.classList.remove("hidden");
|
loginView.classList.remove("hidden");
|
||||||
if (message) {
|
if (message) setMessage(loginMessage, message, false);
|
||||||
setMessage(loginMessage, message, false);
|
|
||||||
}
|
|
||||||
passwordInput.focus();
|
passwordInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDates() {
|
function renderDates() {
|
||||||
|
const plannedDates = Array.from(draftDates, ([date, type]) => ({ date, type })).sort((a, b) => a.date.localeCompare(b.date));
|
||||||
datesList.innerHTML = "";
|
datesList.innerHTML = "";
|
||||||
dateCount.textContent = `${dates.length} ${dates.length === 1 ? "Termin" : "Termine"}`;
|
dateCount.textContent = `${plannedDates.length} ${plannedDates.length === 1 ? "Termin" : "Termine"}`;
|
||||||
emptyDates.classList.toggle("hidden", dates.length !== 0);
|
emptyDates.classList.toggle("hidden", plannedDates.length !== 0);
|
||||||
|
plannedDates.forEach((date) => {
|
||||||
dates.forEach((date) => {
|
|
||||||
const row = document.createElement("li");
|
const row = document.createElement("li");
|
||||||
row.className = "date-row";
|
row.className = "date-row";
|
||||||
|
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
label.className = "date-label";
|
label.className = "date-label";
|
||||||
label.textContent = formatDate(date.date);
|
label.textContent = formatDate(date.date);
|
||||||
|
const typeLabel = document.createElement("span");
|
||||||
const typeSelect = document.createElement("select");
|
typeLabel.className = "date-type-label";
|
||||||
typeSelect.className = "date-type-select";
|
typeLabel.textContent = date.type === "short" ? "Kurz" : "Lang";
|
||||||
typeSelect.setAttribute("aria-label", `Tagestyp für ${formatDate(date.date)}`);
|
|
||||||
[["short", "Kurz"], ["long", "Lang"]].forEach(([value, text]) => {
|
|
||||||
const option = document.createElement("option");
|
|
||||||
option.value = value;
|
|
||||||
option.textContent = text;
|
|
||||||
option.selected = date.type === value;
|
|
||||||
typeSelect.appendChild(option);
|
|
||||||
});
|
|
||||||
typeSelect.addEventListener("change", async () => {
|
|
||||||
typeSelect.disabled = true;
|
|
||||||
try {
|
|
||||||
await request("/api/admin/dates", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({ date: date.date, type: typeSelect.value }),
|
|
||||||
});
|
|
||||||
date.type = typeSelect.value;
|
|
||||||
setMessage(addDateMessage, "Tagestyp aktualisiert.", true);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.status === 401) {
|
|
||||||
showLogin("Deine Anmeldung ist abgelaufen.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
typeSelect.value = date.type;
|
|
||||||
setMessage(addDateMessage, error.message, false);
|
|
||||||
} finally {
|
|
||||||
typeSelect.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteButton = document.createElement("button");
|
const deleteButton = document.createElement("button");
|
||||||
deleteButton.className = "delete-date-btn";
|
deleteButton.className = "delete-date-btn";
|
||||||
deleteButton.type = "button";
|
deleteButton.type = "button";
|
||||||
deleteButton.textContent = "Löschen";
|
deleteButton.textContent = "Entfernen";
|
||||||
deleteButton.addEventListener("click", async () => {
|
if (bookedDates.has(date.date)) {
|
||||||
if (!window.confirm(`${formatDate(date.date)} wirklich löschen?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
deleteButton.disabled = true;
|
deleteButton.disabled = true;
|
||||||
try {
|
deleteButton.classList.add("is-booked");
|
||||||
await request("/api/admin/dates", {
|
deleteButton.title = "Gebuchte Termine können nicht entfernt werden.";
|
||||||
method: "DELETE",
|
} else {
|
||||||
body: JSON.stringify({ date: date.date }),
|
deleteButton.addEventListener("click", () => {
|
||||||
});
|
draftDates.delete(date.date);
|
||||||
dates = dates.filter((item) => item.date !== date.date);
|
renderCalendar();
|
||||||
renderDates();
|
renderDates();
|
||||||
setMessage(addDateMessage, "Datum gelöscht.", true);
|
setMessage(addDateMessage, "Änderung vorgemerkt. Noch nicht gespeichert.", false);
|
||||||
} catch (error) {
|
});
|
||||||
if (error.status === 401) {
|
}
|
||||||
showLogin("Deine Anmeldung ist abgelaufen.");
|
row.append(label, typeLabel, deleteButton);
|
||||||
return;
|
|
||||||
}
|
|
||||||
setMessage(addDateMessage, error.message, false);
|
|
||||||
deleteButton.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
row.append(label, typeSelect, deleteButton);
|
|
||||||
datesList.appendChild(row);
|
datesList.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCalendar() {
|
||||||
|
monthLabel.textContent = `${months[viewMonth]} ${viewYear}`;
|
||||||
|
weekdayRow.innerHTML = "";
|
||||||
|
weekdays.forEach((day) => {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.textContent = day;
|
||||||
|
weekdayRow.appendChild(span);
|
||||||
|
});
|
||||||
|
dayGrid.innerHTML = "";
|
||||||
|
const firstOfMonth = new Date(viewYear, viewMonth, 1);
|
||||||
|
const leadingBlanks = (firstOfMonth.getDay() + 6) % 7;
|
||||||
|
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||||
|
for (let index = 0; index < leadingBlanks; index += 1) {
|
||||||
|
const blank = document.createElement("span");
|
||||||
|
blank.className = "day-cell is-empty";
|
||||||
|
dayGrid.appendChild(blank);
|
||||||
|
}
|
||||||
|
for (let day = 1; day <= daysInMonth; day += 1) {
|
||||||
|
const cellDate = new Date(viewYear, viewMonth, day);
|
||||||
|
const key = toDateKey(cellDate);
|
||||||
|
const type = draftDates.get(key);
|
||||||
|
const isBooked = bookedDates.has(key);
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "day-cell";
|
||||||
|
button.textContent = String(day);
|
||||||
|
if (cellDate <= today) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.classList.add("is-disabled");
|
||||||
|
} else if (isBooked) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.classList.add("is-booked");
|
||||||
|
} else if (type) {
|
||||||
|
button.classList.add(type === "short" ? "is-short" : "is-long");
|
||||||
|
}
|
||||||
|
if (cellDate.getTime() === today.getTime()) button.classList.add("is-today");
|
||||||
|
button.setAttribute("aria-label", `${formatDate(key)}${isBooked ? ", gebucht" : type ? `, ${type === "short" ? "kurz" : "lang"}` : ", nicht verfügbar"}`);
|
||||||
|
if (!isBooked && cellDate > today) button.addEventListener("click", () => {
|
||||||
|
const nextType = type === undefined ? "short" : type === "short" ? "long" : undefined;
|
||||||
|
if (nextType) draftDates.set(key, nextType); else draftDates.delete(key);
|
||||||
|
renderCalendar();
|
||||||
|
renderDates();
|
||||||
|
setMessage(addDateMessage, "Änderung vorgemerkt. Noch nicht gespeichert.", false);
|
||||||
|
});
|
||||||
|
dayGrid.appendChild(button);
|
||||||
|
}
|
||||||
|
prevMonthBtn.disabled = viewYear === today.getFullYear() && viewMonth === today.getMonth();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDates() {
|
async function loadDates() {
|
||||||
try {
|
try {
|
||||||
dates = await request("/api/admin/dates", { method: "GET" });
|
const [loadedDates, loadedBookedDates] = await Promise.all([
|
||||||
dates.sort((first, second) => first.date.localeCompare(second.date));
|
request("/api/admin/dates", { method: "GET" }),
|
||||||
|
request(`/api/booked-dates?t=${Date.now()}`, { method: "GET" }),
|
||||||
|
]);
|
||||||
|
dates = loadedDates;
|
||||||
|
bookedDates = new Set((loadedBookedDates || []).filter((date) => typeof date === "string"));
|
||||||
|
dates.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
draftDates = new Map(dates.map((date) => [date.date, date.type]));
|
||||||
loginView.classList.add("hidden");
|
loginView.classList.add("hidden");
|
||||||
adminView.classList.remove("hidden");
|
adminView.classList.remove("hidden");
|
||||||
|
await loadActivities();
|
||||||
|
renderCalendar();
|
||||||
renderDates();
|
renderDates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status === 401) {
|
if (error.status === 401) showLogin();
|
||||||
showLogin();
|
else showLogin("Die Termine konnten nicht geladen werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
showLogin("Die Termine konnten nicht geladen werden.");
|
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);
|
||||||
try {
|
try {
|
||||||
await request("/api/admin/login", {
|
await request("/api/admin/login", { method: "POST", body: JSON.stringify({ password: passwordInput.value }) });
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ password: passwordInput.value }),
|
|
||||||
});
|
|
||||||
passwordInput.value = "";
|
passwordInput.value = "";
|
||||||
await loadDates();
|
await loadDates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -169,38 +369,76 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
addDateForm.addEventListener("submit", async (event) => {
|
saveDatesBtn.addEventListener("click", async () => {
|
||||||
event.preventDefault();
|
saveDatesBtn.disabled = true;
|
||||||
const type = addDateForm.elements.dateType.value;
|
|
||||||
setMessage(addDateMessage, "", false);
|
|
||||||
try {
|
try {
|
||||||
await request("/api/admin/dates", {
|
await request("/api/admin/dates/bulk", { method: "POST", body: JSON.stringify(Array.from(draftDates, ([date, type]) => ({ date, type }))) });
|
||||||
method: "POST",
|
dates = Array.from(draftDates, ([date, type]) => ({ date, type }));
|
||||||
body: JSON.stringify({ date: dateInput.value, type }),
|
|
||||||
});
|
|
||||||
dates.push({ date: dateInput.value, type });
|
|
||||||
dates.sort((first, second) => first.date.localeCompare(second.date));
|
|
||||||
renderDates();
|
renderDates();
|
||||||
setMessage(addDateMessage, "Datum hinzugefügt.", true);
|
setMessage(addDateMessage, "Änderungen gespeichert.", true);
|
||||||
addDateForm.reset();
|
|
||||||
dateInput.min = todayKey();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status === 401) {
|
if (error.status === 401) showLogin("Deine Anmeldung ist abgelaufen.");
|
||||||
showLogin("Deine Anmeldung ist abgelaufen.");
|
else setMessage(addDateMessage, error.message, false);
|
||||||
return;
|
|
||||||
}
|
|
||||||
setMessage(addDateMessage, error.message, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
logoutBtn.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await request("/api/admin/logout", { method: "POST" });
|
|
||||||
} finally {
|
} finally {
|
||||||
showLogin();
|
saveDatesBtn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
dateInput.min = todayKey();
|
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; }
|
||||||
|
renderCalendar();
|
||||||
|
});
|
||||||
|
nextMonthBtn.addEventListener("click", () => {
|
||||||
|
viewMonth += 1;
|
||||||
|
if (viewMonth > 11) { viewMonth = 0; viewYear += 1; }
|
||||||
|
renderCalendar();
|
||||||
|
});
|
||||||
|
logoutBtn.addEventListener("click", async () => {
|
||||||
|
try { await request("/api/admin/logout", { method: "POST" }); } finally { showLogin(); }
|
||||||
|
});
|
||||||
|
|
||||||
loadDates();
|
loadDates();
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user