Compare commits
6
Commits
944eed3eb3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ff605891c | ||
|
|
ea17db45a6 | ||
|
|
98486b9f01 | ||
|
|
00e5c68ea5 | ||
|
|
07f24e9b34 | ||
|
|
2e182d63a3 |
@@ -4,3 +4,4 @@ Dockerfile
|
||||
docker-compose.yml
|
||||
.env
|
||||
*.exe
|
||||
config/
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Server Port & Configuration
|
||||
PORT=8080
|
||||
WEB_DIR=public
|
||||
BOOKED_DATES_FILE=booked_dates.json
|
||||
|
||||
# SMTP Configuration
|
||||
SMTP_HOST=mail.example.com
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
agent: agent
|
||||
description: Split Date Wizard JSON persistence into a fixed config directory, store complete structured bookings, and skip the name step when no names are configured.
|
||||
---
|
||||
|
||||
Implement or review the Date Wizard configuration and booking persistence layout. Preserve the existing Go server, authenticated admin behavior, public booking flow, German user-facing language, and Docker deployment conventions.
|
||||
|
||||
## Fixed config directory
|
||||
|
||||
All JSON data files belong under one project config directory:
|
||||
|
||||
- `config/appsettings.json`: admin password and optional configured names.
|
||||
- `config/activities.json`: activity definitions.
|
||||
- `config/dates_available.json`: available date definitions.
|
||||
- `config/dates_booked.json`: structured booking records.
|
||||
|
||||
The application must use these fixed paths and must not require one environment variable per JSON file. Do not add or retain `ACTIVITIES_FILE`, `AVAILABLE_DATES_FILE`, or `BOOKED_DATES_FILE` configuration requirements. The runtime paths should resolve to `/app/config/...` in the container and `config/...` during local execution.
|
||||
|
||||
Keep legacy read compatibility where practical for existing deployments, but new writes must use the new fixed files and names. The old root-level JSON files should not remain as the active project data source.
|
||||
|
||||
## Docker Compose deployment
|
||||
|
||||
The Compose deployment must expose one persistent volume only:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /docker/data/date:/app/config
|
||||
```
|
||||
|
||||
The container must not require individual file mounts or data-file environment variables. Startup must provision missing files from image defaults when the mounted config directory is empty. Preserve the existing `.env` usage for server and SMTP settings.
|
||||
|
||||
The Dockerfile must include the config templates in the image, and the startup script must initialize the four files beneath `/app/config` without overwriting existing host data.
|
||||
|
||||
Document the host initialization and migration process, including migration from the old `booked_dates.json` format.
|
||||
|
||||
## Structured booking records
|
||||
|
||||
When a booking is submitted, record one object per booked date in `config/dates_booked.json` with:
|
||||
|
||||
- `bookedAt`: UTC booking timestamp in a machine-readable format.
|
||||
- `date`: the selected date.
|
||||
- `dateType`: the selected date duration/type.
|
||||
- `name`: the selected person name, or empty when the name step was skipped.
|
||||
- `activities`: every selected activity with its `name`, `icon`, and `type`.
|
||||
|
||||
Do not persist `customDescription` in the booked activity records. Custom descriptions may remain transient in the submission payload and may still be used for the notification email and generated iCalendar content.
|
||||
|
||||
Avoid duplicate booking records for the same date. Existing date-only booked-date consumers may continue receiving a list of date strings through the public API. Preserve booked-date protection in the admin date editor.
|
||||
|
||||
## Name page shortcut
|
||||
|
||||
When `appsettings.json` contains an empty `names` array:
|
||||
|
||||
- Skip the name-selection page entirely after activity selection.
|
||||
- Store an empty selected name and navigate directly to checkout.
|
||||
- Checkout must accept an empty name and display a clear fallback such as `Nicht angegeben`.
|
||||
|
||||
When names are configured, preserve the existing name-selection page and selection behavior.
|
||||
|
||||
## Validation
|
||||
|
||||
Before editing, inspect the current settings model, file paths, booking submission payload, name-page navigation, Dockerfile, Compose file, startup script, and existing prompt conventions.
|
||||
|
||||
After editing:
|
||||
|
||||
1. Run `gofmt` on changed Go files.
|
||||
2. Run the available Go test/build command.
|
||||
3. Run JavaScript syntax checks for changed frontend scripts.
|
||||
4. Validate all config JSON files.
|
||||
5. Check diagnostics and whitespace errors.
|
||||
6. Smoke-test the public settings and booked-date APIs with the fixed local config paths.
|
||||
7. Verify that Compose has one config-directory volume and no per-file data environment variables.
|
||||
8. Do not overwrite user-edited config data or unrelated fixtures.
|
||||
|
||||
When reporting the result, summarize the fixed paths, Docker contract, booking JSON shape, name-page behavior, migration compatibility, and validation results.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
agent: agent
|
||||
description: Manage booked dates in the authenticated Date Wizard admin page, including newest-first display, activity and booking metadata, and confirmation-gated deletion.
|
||||
---
|
||||
|
||||
Implement or review booked-date management for the Date Wizard application. Preserve the existing Go server, JSON booking persistence, authenticated admin behavior, German user-facing language, and visual conventions.
|
||||
|
||||
## Admin navigation
|
||||
|
||||
Add a separate booking-management view to the authenticated `/admin` page. Keep it between the existing `Termine` and `Aktivitäten` navigation items. The view is reached through a visible navigation button and must remain responsive on narrow screens.
|
||||
|
||||
The booking view is initially read-only except for deleting a booking. Do not add editing controls for the booking date, duration, name, activities, or booking timestamp.
|
||||
|
||||
## Booking list
|
||||
|
||||
Load bookings through an authenticated admin endpoint and display them newest first using the booking creation timestamp `bookedAt`.
|
||||
|
||||
Each booking entry must:
|
||||
|
||||
- Display the selected date as the primary information, formatted in German.
|
||||
- Display every selected activity when one or more activities are present.
|
||||
- Display each activity's stored icon and name, including the single-activity case and multiple-activity case.
|
||||
- Display the booker's name only when a non-empty name is available.
|
||||
- Display the date and time at which the booking was created from `bookedAt`.
|
||||
- Display a useful empty state when no bookings exist.
|
||||
- Render all values as text content rather than injecting untrusted booking data as HTML.
|
||||
|
||||
Keep legacy records with missing optional values displayable. Do not fabricate a booking timestamp when `bookedAt` is empty.
|
||||
|
||||
## Backend API
|
||||
|
||||
Use the existing authenticated Go HTTP API and booking storage helpers.
|
||||
|
||||
- Add or preserve an authenticated `GET /api/admin/bookings` endpoint.
|
||||
- Return structured booking records containing `bookedAt`, `date`, `dateType`, `name`, and `activities`.
|
||||
- Sort records newest first by `bookedAt`, while keeping records with missing timestamps valid.
|
||||
- Do not expose the full booking records through the public `/api/booked-dates` endpoint; that endpoint may continue returning only date strings.
|
||||
- Reject unauthenticated requests with the existing JSON authentication response.
|
||||
- Return appropriate errors for malformed requests and storage failures.
|
||||
|
||||
## Booking deletion
|
||||
|
||||
Provide one `Termin löschen` button for every booking entry.
|
||||
|
||||
- Ask for explicit confirmation before sending a delete request.
|
||||
- Include the formatted booking date in the confirmation question.
|
||||
- Add an authenticated `DELETE /api/admin/bookings` endpoint accepting `{ "date": "YYYY-MM-DD" }`.
|
||||
- Delete only the matching booking record from `config/dates_booked.json`.
|
||||
- Return a not-found response when the requested booking does not exist.
|
||||
- Keep the delete button disabled while the request is in progress.
|
||||
- Reload the booking list after successful deletion.
|
||||
- Remove the deleted date from the admin calendar's booked-date state and refresh the affected date displays.
|
||||
- Keep confirmation and deletion errors visible to the administrator.
|
||||
- Do not delete or modify any other booking record.
|
||||
|
||||
## Persistence and safety
|
||||
|
||||
Preserve the existing structured booking format and booking uniqueness rule: at most one booking record exists for a date. Use the existing booking mutex and atomic JSON write helper for deletion. Do not modify `config/appsettings.json` or expose the admin password.
|
||||
|
||||
## Implementation and validation
|
||||
|
||||
Before editing, inspect the existing admin HTML, JavaScript, CSS, Go handlers, booking model, storage helpers, public booked-date endpoint, and prompt conventions. Reuse existing request helpers and styling patterns.
|
||||
|
||||
After editing:
|
||||
|
||||
1. Run `gofmt` on changed Go files.
|
||||
2. Run the available Go test or build command.
|
||||
3. Run a JavaScript syntax check for changed scripts when Node.js is available.
|
||||
4. Check diagnostics for changed files.
|
||||
5. Verify unauthenticated access still shows the login view.
|
||||
6. Verify the booking tab appears between `Termine` and `Aktivitäten`.
|
||||
7. Verify newest-first ordering, date display, single and multiple activity display with icons, optional name display, and booking timestamp display.
|
||||
8. Verify the delete confirmation prevents deletion when cancelled and deletes only the confirmed booking when accepted.
|
||||
9. Verify the calendar booked state updates after deletion.
|
||||
10. Do not alter `config/appsettings.json` or unrelated fixtures when documenting or committing this feature.
|
||||
|
||||
When reporting the result, summarize changed files, user-visible behavior, API behavior, persistence effects, and validation commands that passed or failed.
|
||||
+2
-3
@@ -18,11 +18,10 @@ WORKDIR /app
|
||||
# Copy binary and static front-end assets
|
||||
COPY --from=builder /app/server /app/server
|
||||
COPY --from=builder /app/public /app/public
|
||||
COPY --from=builder /app/appsettings.json /app/appsettings.default.json
|
||||
COPY start.sh /app/start.sh
|
||||
|
||||
RUN chmod +x /app/start.sh
|
||||
RUN mkdir -p /app/config && chmod +x /app/start.sh
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/app/start.sh"]
|
||||
ENTRYPOINT ["/bin/sh", "/app/start.sh"]
|
||||
|
||||
@@ -8,8 +8,12 @@ Eine spielerische Web-App ("Choose Your Own Adventure") zum Planen gemeinsamer D
|
||||
|
||||
```text
|
||||
datetest/
|
||||
├── config/ # Persistente JSON-Konfiguration und Buchungen
|
||||
│ ├── appsettings.json # Admin-Passwort und optionale Namen
|
||||
│ ├── activities.json # Aktivitäten
|
||||
│ ├── dates_available.json # Verfügbare Termine
|
||||
│ └── dates_booked.json # Strukturierte Buchungen
|
||||
├── public/ # Statische Webseiten & Assets
|
||||
│ ├── appsettings.json # Konfiguration der Aktivitäten & Termine
|
||||
│ ├── index.html # Startseite ("Willst du mit mir gehen?")
|
||||
│ ├── datetime.html/.js # Datumsauswahl (Kalender)
|
||||
│ ├── activity.html/.js # Aktivitätsauswahl
|
||||
@@ -41,7 +45,6 @@ Trage in der `.env` deine Server- und SMTP-Zugangsdaten ein:
|
||||
| :--- | :--- | :--- |
|
||||
| `PORT` | HTTP-Port des Go-Servers | `8080` |
|
||||
| `WEB_DIR` | Verzeichnis der statischen Assets | `public` |
|
||||
| `BOOKED_DATES_FILE` | Speicherpfad für bereits gebuchte Termine | `booked_dates.json` |
|
||||
| `SMTP_HOST` | Postausgangsserver (SMTP) | `mail.example.com` |
|
||||
| `SMTP_PORT` | SMTP-Port (Standard meist 587 oder 465) | `587` |
|
||||
| `SMTP_USER` | Benutzername / E-Mail für SMTP-Auth | `absender@example.com` |
|
||||
@@ -51,18 +54,29 @@ Trage in der `.env` deine Server- und SMTP-Zugangsdaten ein:
|
||||
|
||||
*(Hinweis: Wenn `SMTP_HOST` oder `TO_EMAIL` leer bleiben, läuft der Server im Testmodus ohne E-Mail-Versand.)*
|
||||
|
||||
### 2. Aktivitäten & Termine anpassen (`appsettings.json`)
|
||||
### 2. Einstellungen und Daten
|
||||
|
||||
Passe in `appsettings.json` die zur Auswahl stehenden Aktivitäten, verfügbaren Tage und das Admin-Passwort an:
|
||||
Die persistenten Daten liegen in getrennten JSON-Dateien:
|
||||
|
||||
- **`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), `type` (`short` oder `long`) und optional `disabled` (`true` blendet die Aktivität vorübergehend aus).
|
||||
- **`availableDates`**: Liste von verfügbaren Tagen mit `date` (`YYYY-MM-DD`) und `type` (`short` oder `long`).
|
||||
- **`names`**: Optional vordefinierte Namen zur Auswahl (ist die Liste leer, wird ein Standard-Fallback angeboten).
|
||||
- **`activities.json`**: Liste von Objekten mit `name`, `icon` (Unicode Emoji), `type` (`short`, `long` oder `custom`) und optional `disabled` (`true` blendet die Aktivität vorübergehend aus).
|
||||
- **`dates_available.json`**: Liste von verfügbaren Tagen mit `date` (`YYYY-MM-DD`) und `type` (`short` oder `long`).
|
||||
- **`dates_booked.json`**: Strukturierte Buchungen mit `bookedAt`, `date`, `dateType`, `name` und den ausgewählten Aktivitäten inklusive Name, Typ und Icon.
|
||||
- **`names`**: Optional vordefinierte Namen zur Auswahl (ist die Liste leer, wird die Namensseite übersprungen).
|
||||
|
||||
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 wird `/docker/data/date` als ein Volume nach `/app/config` eingebunden. Die vier JSON-Dateien müssen dort vor dem ersten Start vorhanden sein. Du kannst sie aus dem Projektordner initialisieren:
|
||||
|
||||
```bash
|
||||
mkdir -p /docker/data/date
|
||||
cp config/appsettings.json /docker/data/date/appsettings.json
|
||||
cp config/activities.json /docker/data/date/activities.json
|
||||
cp config/dates_available.json /docker/data/date/dates_available.json
|
||||
cp config/dates_booked.json /docker/data/date/dates_booked.json
|
||||
```
|
||||
|
||||
Wenn bisher `/docker/data/date/booked_dates.json` verwendet wurde, übernimm die alten Buchungsdaten zuerst in `dates_booked.json`. Das neue Format speichert Buchungen als Objekte statt als reine Datumsstrings.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
{
|
||||
"activities": [
|
||||
{
|
||||
"name": "Picknick auf der Wiese",
|
||||
"icon": "🧺",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Cocktailabend zu zweit",
|
||||
"icon": "🍸",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Massage zu Hause",
|
||||
"icon": "💆",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Ausgedehnte Wanderung",
|
||||
"icon": "🥾",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Kinofilm",
|
||||
"icon": "🎬",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Sushi selber machen",
|
||||
"icon": "🍣",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Untersetzer aus Ton basteln",
|
||||
"icon": "🪨",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Freizeitpark",
|
||||
"icon": "🎢",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Spaziergang im Wald",
|
||||
"icon": "🌲",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Kirschbier Tasting",
|
||||
"icon": "🍒",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Nudeln selber machen",
|
||||
"icon": "🍜",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Chillen im Freibad",
|
||||
"icon": "🏊",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Film zu Hause + Essen bestellen",
|
||||
"icon": "🛋️",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Squash spielen",
|
||||
"icon": "🏸",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Essen im Namaste",
|
||||
"icon": "🍛",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Museum für Gegenwartskunst",
|
||||
"icon": "🖼️",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Playstation Co-Op",
|
||||
"icon": "🎮",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Etwas eigenes",
|
||||
"icon": "✏️",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"availableDates": [
|
||||
{
|
||||
"date": "2026-08-18",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-22",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-26",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-27",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-28",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-29",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-31",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-01",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-07",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-08",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-14",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-15",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-17",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-18",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-25",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-26",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-27",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-28",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-29",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-30",
|
||||
"type": "short"
|
||||
}
|
||||
],
|
||||
"names": [],
|
||||
"adminPassword": "change-me"
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
[
|
||||
{
|
||||
"name": "Cocktailabend zu zweit",
|
||||
"icon": "🍸",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Massage zu Hause",
|
||||
"icon": "💆",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Ausgedehnte Wanderung",
|
||||
"icon": "🌲",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Kinofilm gucken",
|
||||
"icon": "🎬",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Sushi selber machen",
|
||||
"icon": "🍣",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Untersetzer aus Ton basteln",
|
||||
"icon": "🪨",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Freizeitpark",
|
||||
"icon": "🎢",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Spaziergang im Wald",
|
||||
"icon": "🌲",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Kirschbier Tasting",
|
||||
"icon": "🍒",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Nudeln selber machen",
|
||||
"icon": "🍜",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Chillen im Freibad",
|
||||
"icon": "🏊",
|
||||
"type": "short",
|
||||
"disabled": true
|
||||
},
|
||||
{
|
||||
"name": "Film zu Hause + Essen bestellen",
|
||||
"icon": "🛋️",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Squash spielen",
|
||||
"icon": "🏸",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Essen im Namaste",
|
||||
"icon": "🍛",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Museum für Gegenwartskunst",
|
||||
"icon": "🖼️",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Playstation Co-Op",
|
||||
"icon": "🎮",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Etwas eigenes",
|
||||
"icon": "✏️",
|
||||
"type": "custom"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"names": [],
|
||||
"adminPassword": "change-me"
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
[
|
||||
{
|
||||
"date": "2026-08-18",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-22",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-26",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-27",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-28",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-29",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-31",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-01",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-07",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-08",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-14",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-15",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-17",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-18",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-25",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-26",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-27",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-28",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-29",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-30",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-02",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-06",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-09",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-11",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-12",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-13",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-14",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-16",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-26",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-27",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-28",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-29",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-30",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-02",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-03",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-04",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-05",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-06",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-08",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-09",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-10",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-11",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-12",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-17",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-18",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-23",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-24",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-25",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-26",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-27",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-28",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-29",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-11-30",
|
||||
"type": "short"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
+1
-4
@@ -9,11 +9,8 @@ services:
|
||||
- proxy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
SETTINGS_FILE: /data/appsettings.json
|
||||
BOOKED_DATES_FILE: /data/booked_dates.json
|
||||
volumes:
|
||||
- /docker/data/date:/data
|
||||
- /docker/data/date:/app/config
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.date-wizard.rule=Host(`date.allen.beging.de`) || Host(`date.beging.de`)"
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -44,10 +45,24 @@ type AvailableDate struct {
|
||||
}
|
||||
|
||||
type AppSettings struct {
|
||||
Activities []Activity `json:"activities"`
|
||||
AvailableDates []AvailableDate `json:"availableDates"`
|
||||
Names []string `json:"names"`
|
||||
AdminPassword string `json:"adminPassword"`
|
||||
Activities []Activity `json:"-"`
|
||||
AvailableDates []AvailableDate `json:"-"`
|
||||
}
|
||||
|
||||
type Booking struct {
|
||||
BookedAt string `json:"bookedAt"`
|
||||
Date string `json:"date"`
|
||||
DateType string `json:"dateType"`
|
||||
Name string `json:"name"`
|
||||
Activities []BookingActivity `json:"activities"`
|
||||
}
|
||||
|
||||
type BookingActivity struct {
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type PublicSettings struct {
|
||||
@@ -61,6 +76,7 @@ type passwordRequest struct {
|
||||
}
|
||||
|
||||
var settingsMu sync.Mutex
|
||||
var bookingsMu sync.Mutex
|
||||
var sessionsMu sync.Mutex
|
||||
var adminSessions = make(map[string]time.Time)
|
||||
|
||||
@@ -85,6 +101,7 @@ func main() {
|
||||
http.HandleFunc("/api/admin/dates", handleAdminDates)
|
||||
http.HandleFunc("/api/admin/dates/bulk", handleAdminBulkDates)
|
||||
http.HandleFunc("/api/admin/activities", handleAdminActivities)
|
||||
http.HandleFunc("/api/admin/bookings", handleAdminBookings)
|
||||
http.HandleFunc("/admin", handleAdminPage)
|
||||
|
||||
// Serve static files from webDir directory
|
||||
@@ -271,6 +288,65 @@ func handleAdminActivities(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminBookings(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:
|
||||
bookings, err := getBookings()
|
||||
if err != nil {
|
||||
log.Printf("Error reading bookings: %v", err)
|
||||
http.Error(w, "Bookings could not be loaded", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sort.SliceStable(bookings, func(first, second int) bool {
|
||||
return bookings[first].BookedAt > bookings[second].BookedAt
|
||||
})
|
||||
writeJSON(w, http.StatusOK, bookings)
|
||||
case http.MethodDelete:
|
||||
var request struct {
|
||||
Date string `json:"date"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || strings.TrimSpace(request.Date) == "" {
|
||||
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte gib ein Datum an."})
|
||||
return
|
||||
}
|
||||
request.Date = strings.TrimSpace(request.Date)
|
||||
|
||||
bookingsMu.Lock()
|
||||
defer bookingsMu.Unlock()
|
||||
bookings, err := readBookings()
|
||||
if err != nil {
|
||||
log.Printf("Error reading bookings: %v", err)
|
||||
http.Error(w, "Bookings could not be loaded", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
filtered := make([]Booking, 0, len(bookings))
|
||||
deleted := false
|
||||
for _, booking := range bookings {
|
||||
if booking.Date == request.Date {
|
||||
deleted = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, booking)
|
||||
}
|
||||
if !deleted {
|
||||
writeJSON(w, http.StatusNotFound, Response{Success: false, Message: "Gebuchter Termin wurde nicht gefunden."})
|
||||
return
|
||||
}
|
||||
if err := writeJSONFile(getBookedDatesFilePath(), filtered); err != nil {
|
||||
log.Printf("Error deleting booking: %v", err)
|
||||
http.Error(w, "Booking could not be deleted", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Gebuchter Termin gelöscht."})
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -486,11 +562,7 @@ func handleAdminBulkDates(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func getSettingsFilePath() string {
|
||||
path := os.Getenv("SETTINGS_FILE")
|
||||
if path == "" {
|
||||
path = "appsettings.json"
|
||||
}
|
||||
return path
|
||||
return filepath.Join("config", "appsettings.json")
|
||||
}
|
||||
|
||||
func readSettings() (AppSettings, error) {
|
||||
@@ -508,9 +580,42 @@ func readSettingsFile() (AppSettings, error) {
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return settings, err
|
||||
}
|
||||
var legacy struct {
|
||||
Activities []Activity `json:"activities"`
|
||||
AvailableDates []AvailableDate `json:"availableDates"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &legacy); err != nil {
|
||||
return settings, err
|
||||
}
|
||||
settings.Activities = legacy.Activities
|
||||
settings.AvailableDates = legacy.AvailableDates
|
||||
if activities, found, err := readOptionalJSON(getActivitiesFilePath(), &[]Activity{}); err != nil {
|
||||
return settings, err
|
||||
} else if found {
|
||||
settings.Activities = *activities.(*[]Activity)
|
||||
}
|
||||
if dates, found, err := readOptionalJSON(getAvailableDatesFilePath(), &[]AvailableDate{}); err != nil {
|
||||
return settings, err
|
||||
} else if found {
|
||||
settings.AvailableDates = *dates.(*[]AvailableDate)
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func readOptionalJSON(path string, target interface{}) (interface{}, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return target, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return target, false, err
|
||||
}
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
return target, false, err
|
||||
}
|
||||
return target, true, nil
|
||||
}
|
||||
|
||||
func updateAvailableDates(update func([]AvailableDate) ([]AvailableDate, error)) error {
|
||||
settingsMu.Lock()
|
||||
defer settingsMu.Unlock()
|
||||
@@ -526,15 +631,7 @@ func updateAvailableDates(update func([]AvailableDate) ([]AvailableDate, error))
|
||||
sort.Slice(dates, func(i, j int) bool { return dates[i].Date < dates[j].Date })
|
||||
settings.AvailableDates = dates
|
||||
|
||||
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())
|
||||
return writeJSONFile(getAvailableDatesFilePath(), dates)
|
||||
}
|
||||
|
||||
func updateActivities(update func([]Activity) ([]Activity, error)) error {
|
||||
@@ -551,15 +648,27 @@ func updateActivities(update func([]Activity) ([]Activity, error)) error {
|
||||
}
|
||||
settings.Activities = activities
|
||||
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
return writeJSONFile(getActivitiesFilePath(), activities)
|
||||
}
|
||||
|
||||
func writeJSONFile(path string, value interface{}) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporaryPath := getSettingsFilePath() + ".tmp"
|
||||
temporaryPath := path + ".tmp"
|
||||
if err := os.WriteFile(temporaryPath, data, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temporaryPath, getSettingsFilePath())
|
||||
return os.Rename(temporaryPath, path)
|
||||
}
|
||||
|
||||
func getActivitiesFilePath() string {
|
||||
return filepath.Join("config", "activities.json")
|
||||
}
|
||||
|
||||
func getAvailableDatesFilePath() string {
|
||||
return filepath.Join("config", "dates_available.json")
|
||||
}
|
||||
|
||||
func validateActivity(activity Activity) error {
|
||||
@@ -577,11 +686,22 @@ func validateActivity(activity Activity) error {
|
||||
|
||||
func enabledActivities(activities []Activity) []Activity {
|
||||
enabled := make([]Activity, 0, len(activities))
|
||||
var customActivity *Activity
|
||||
for _, activity := range activities {
|
||||
if activity.Type == "custom" {
|
||||
if !activity.Disabled {
|
||||
custom := activity
|
||||
customActivity = &custom
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !activity.Disabled {
|
||||
enabled = append(enabled, activity)
|
||||
}
|
||||
}
|
||||
if customActivity != nil {
|
||||
enabled = append(enabled, *customActivity)
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
@@ -665,7 +785,7 @@ func handleSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Printf("Received survey submission from %s for date %s (%s)", payload.Name, payload.Date, payload.Activity)
|
||||
|
||||
if err := recordBookedDate(payload.Date); err != nil {
|
||||
if err := recordBooking(payload); err != nil {
|
||||
log.Printf("Error recording booked date: %v", err)
|
||||
}
|
||||
|
||||
@@ -706,65 +826,98 @@ func handleBookedDates(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func getBookedDatesFilePath() string {
|
||||
path := os.Getenv("BOOKED_DATES_FILE")
|
||||
if path == "" {
|
||||
path = "booked_dates.json"
|
||||
}
|
||||
return path
|
||||
return filepath.Join("config", "dates_booked.json")
|
||||
}
|
||||
|
||||
func getBookedDates() ([]string, error) {
|
||||
filePath := getBookedDatesFilePath()
|
||||
file, err := os.Open(filePath)
|
||||
bookings, err := getBookings()
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var rawDates []string
|
||||
if err := json.NewDecoder(file).Decode(&rawDates); err != nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
dates := make([]string, 0, len(rawDates))
|
||||
for _, d := range rawDates {
|
||||
trimmed := strings.TrimSpace(d)
|
||||
if trimmed != "" {
|
||||
dates := make([]string, 0, len(bookings))
|
||||
for _, booking := range bookings {
|
||||
if trimmed := strings.TrimSpace(booking.Date); trimmed != "" {
|
||||
dates = append(dates, trimmed)
|
||||
}
|
||||
}
|
||||
return dates, nil
|
||||
}
|
||||
|
||||
func recordBookedDate(dateStr string) error {
|
||||
trimmedDate := strings.TrimSpace(dateStr)
|
||||
if trimmedDate == "" {
|
||||
return nil
|
||||
func getBookings() ([]Booking, error) {
|
||||
bookingsMu.Lock()
|
||||
defer bookingsMu.Unlock()
|
||||
return readBookings()
|
||||
}
|
||||
|
||||
dates, err := getBookedDates()
|
||||
if err != nil {
|
||||
dates = []string{}
|
||||
}
|
||||
|
||||
for _, d := range dates {
|
||||
if d == trimmedDate {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
dates = append(dates, trimmedDate)
|
||||
|
||||
func readBookings() ([]Booking, error) {
|
||||
filePath := getBookedDatesFilePath()
|
||||
data, err := json.MarshalIndent(dates, "", " ")
|
||||
data, err := os.ReadFile(filePath)
|
||||
if os.IsNotExist(err) {
|
||||
legacyPath := os.Getenv("LEGACY_BOOKED_DATES_FILE")
|
||||
if legacyPath == "" {
|
||||
legacyPath = filepath.Join("config", "booked_dates.json")
|
||||
}
|
||||
data, err = os.ReadFile(legacyPath)
|
||||
if os.IsNotExist(err) {
|
||||
return []Booking{}, nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
var bookings []Booking
|
||||
if err := json.Unmarshal(data, &bookings); err == nil {
|
||||
return bookings, nil
|
||||
}
|
||||
|
||||
return os.WriteFile(filePath, data, 0644)
|
||||
var legacyDates []string
|
||||
if err := json.Unmarshal(data, &legacyDates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bookings = make([]Booking, 0, len(legacyDates))
|
||||
for _, date := range legacyDates {
|
||||
if trimmed := strings.TrimSpace(date); trimmed != "" {
|
||||
bookings = append(bookings, Booking{Date: trimmed})
|
||||
}
|
||||
}
|
||||
return bookings, nil
|
||||
}
|
||||
|
||||
func recordBooking(payload SurveyPayload) error {
|
||||
date := strings.TrimSpace(payload.Date)
|
||||
if date == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
bookingsMu.Lock()
|
||||
defer bookingsMu.Unlock()
|
||||
bookings, err := readBookings()
|
||||
if err != nil {
|
||||
bookings = []Booking{}
|
||||
}
|
||||
for _, booking := range bookings {
|
||||
if booking.Date == date {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
bookingActivities := make([]BookingActivity, 0, len(payload.Activities))
|
||||
for _, activity := range payload.Activities {
|
||||
bookingActivities = append(bookingActivities, BookingActivity{
|
||||
Name: activity.Name,
|
||||
Icon: activity.Icon,
|
||||
Type: activity.Type,
|
||||
})
|
||||
}
|
||||
|
||||
bookings = append(bookings, Booking{
|
||||
BookedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Date: date,
|
||||
DateType: payload.DateType,
|
||||
Name: payload.Name,
|
||||
Activities: bookingActivities,
|
||||
})
|
||||
return writeJSONFile(getBookedDatesFilePath(), bookings)
|
||||
}
|
||||
|
||||
func enableCORS(w http.ResponseWriter, r *http.Request) bool {
|
||||
|
||||
@@ -262,6 +262,68 @@
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.bookings-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin: 18px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.booking-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid #e5d8ce;
|
||||
}
|
||||
|
||||
.booking-date {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.booking-activities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.booking-activity {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.booking-metadata {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
color: var(--color-text-light);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.delete-booking-btn {
|
||||
justify-self: start;
|
||||
min-height: 36px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #c84a62;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: #a83049;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.delete-booking-btn:hover,
|
||||
.delete-booking-btn:focus-visible {
|
||||
background: #fff0f2;
|
||||
}
|
||||
|
||||
.delete-booking-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.admin-calendar {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
|
||||
<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="bookingsView">Gebuchte Termine</button>
|
||||
<button class="admin-nav-btn" type="button" data-view="activitiesView">Aktivitäten</button>
|
||||
</nav>
|
||||
|
||||
@@ -73,6 +74,17 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="bookingsView" class="admin-panel hidden">
|
||||
<section class="admin-section" aria-labelledby="bookingsTitle">
|
||||
<div class="section-heading">
|
||||
<h2 id="bookingsTitle">Gebuchte Termine</h2>
|
||||
<p>Neueste Buchungen stehen oben.</p>
|
||||
</div>
|
||||
<ul id="bookingsList" class="bookings-list" aria-live="polite"></ul>
|
||||
<p id="emptyBookings" class="empty-dates hidden">Noch keine Termine gebucht.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="activitiesView" class="admin-panel hidden">
|
||||
<section class="admin-section" aria-labelledby="activitiesTitle">
|
||||
<div class="section-heading">
|
||||
|
||||
+74
-1
@@ -19,6 +19,8 @@
|
||||
const emptyActivities = document.getElementById("emptyActivities");
|
||||
const activityMessage = document.getElementById("activityMessage");
|
||||
const activityAddRow = document.getElementById("activityAddRow");
|
||||
const bookingsList = document.getElementById("bookingsList");
|
||||
const emptyBookings = document.getElementById("emptyBookings");
|
||||
const newActivityIcon = document.getElementById("newActivityIcon");
|
||||
const newActivityName = document.getElementById("newActivityName");
|
||||
const newActivityType = document.getElementById("newActivityType");
|
||||
@@ -33,6 +35,7 @@
|
||||
let viewYear = today.getFullYear();
|
||||
let viewMonth = today.getMonth();
|
||||
let activities = [];
|
||||
let bookings = [];
|
||||
|
||||
function setMessage(element, message, success) {
|
||||
element.textContent = message;
|
||||
@@ -149,6 +152,76 @@
|
||||
prevMonthBtn.disabled = viewYear === today.getFullYear() && viewMonth === today.getMonth();
|
||||
}
|
||||
|
||||
function renderBookings() {
|
||||
bookingsList.innerHTML = "";
|
||||
emptyBookings.classList.toggle("hidden", bookings.length > 0);
|
||||
bookings.forEach((booking) => {
|
||||
const row = document.createElement("li");
|
||||
row.className = "booking-row";
|
||||
|
||||
const date = document.createElement("strong");
|
||||
date.className = "booking-date";
|
||||
date.textContent = formatDate(booking.date);
|
||||
row.appendChild(date);
|
||||
|
||||
if (booking.activities && booking.activities.length > 0) {
|
||||
const activityList = document.createElement("div");
|
||||
activityList.className = "booking-activities";
|
||||
booking.activities.forEach((activity) => {
|
||||
const activityItem = document.createElement("span");
|
||||
activityItem.className = "booking-activity";
|
||||
activityItem.textContent = `${activity.icon || ""} ${activity.name}`.trim();
|
||||
activityList.appendChild(activityItem);
|
||||
});
|
||||
row.appendChild(activityList);
|
||||
}
|
||||
|
||||
const metadata = document.createElement("div");
|
||||
metadata.className = "booking-metadata";
|
||||
if (booking.name && booking.name.trim()) {
|
||||
const name = document.createElement("span");
|
||||
name.textContent = booking.name;
|
||||
metadata.appendChild(name);
|
||||
}
|
||||
if (booking.bookedAt) {
|
||||
const created = document.createElement("time");
|
||||
created.dateTime = booking.bookedAt;
|
||||
created.textContent = `Gebucht am ${new Date(booking.bookedAt).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" })}`;
|
||||
metadata.appendChild(created);
|
||||
}
|
||||
if (metadata.childElementCount > 0) row.appendChild(metadata);
|
||||
|
||||
const deleteButton = document.createElement("button");
|
||||
deleteButton.className = "delete-booking-btn";
|
||||
deleteButton.type = "button";
|
||||
deleteButton.textContent = "Termin löschen";
|
||||
deleteButton.addEventListener("click", () => deleteBooking(booking, deleteButton));
|
||||
row.appendChild(deleteButton);
|
||||
bookingsList.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteBooking(booking, deleteButton) {
|
||||
if (!window.confirm(`Den gebuchten Termin am ${formatDate(booking.date)} wirklich löschen?`)) return;
|
||||
deleteButton.disabled = true;
|
||||
try {
|
||||
await request("/api/admin/bookings", { method: "DELETE", body: JSON.stringify({ date: booking.date }) });
|
||||
bookedDates.delete(booking.date);
|
||||
await loadBookings();
|
||||
renderCalendar();
|
||||
renderDates();
|
||||
} catch (error) {
|
||||
deleteButton.disabled = false;
|
||||
if (error.status === 401) showLogin("Deine Anmeldung ist abgelaufen.");
|
||||
else window.alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBookings() {
|
||||
bookings = await request("/api/admin/bookings", { method: "GET" });
|
||||
renderBookings();
|
||||
}
|
||||
|
||||
async function loadDates() {
|
||||
try {
|
||||
const [loadedDates, loadedBookedDates] = await Promise.all([
|
||||
@@ -161,7 +234,7 @@
|
||||
draftDates = new Map(dates.map((date) => [date.date, date.type]));
|
||||
loginView.classList.add("hidden");
|
||||
adminView.classList.remove("hidden");
|
||||
await loadActivities();
|
||||
await Promise.all([loadActivities(), loadBookings()]);
|
||||
renderCalendar();
|
||||
renderDates();
|
||||
} catch (error) {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-item">
|
||||
<div id="summaryNameItem" class="summary-item">
|
||||
<span class="summary-icon">👤</span>
|
||||
<div class="summary-details">
|
||||
<span class="summary-label">Name</span>
|
||||
|
||||
+6
-1
@@ -7,6 +7,7 @@
|
||||
const summaryActivity = document.getElementById("summaryActivity");
|
||||
const summaryDate = document.getElementById("summaryDate");
|
||||
const summaryName = document.getElementById("summaryName");
|
||||
const summaryNameItem = document.getElementById("summaryNameItem");
|
||||
const activityIcon = document.getElementById("activityIcon");
|
||||
const checkoutBtn = document.getElementById("checkoutBtn");
|
||||
|
||||
@@ -19,13 +20,17 @@
|
||||
const selectedDate = localStorage.getItem("selectedDate");
|
||||
const selectedName = localStorage.getItem("selectedName");
|
||||
|
||||
if (!selectedActivity || !selectedDate || !selectedName) {
|
||||
if (!selectedActivity || !selectedDate) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
|
||||
summaryActivity.textContent = selectedActivity;
|
||||
if (selectedName && selectedName.trim()) {
|
||||
summaryName.textContent = selectedName;
|
||||
} else {
|
||||
summaryNameItem.classList.add("hidden");
|
||||
}
|
||||
|
||||
const dateObj = new Date(`${selectedDate}T00:00:00`);
|
||||
const formattedDate = dateObj.toLocaleDateString("de-DE", {
|
||||
|
||||
+10
-4
@@ -30,15 +30,21 @@
|
||||
|
||||
fetch("/api/settings", { cache: "no-store" })
|
||||
.then((response) => response.json())
|
||||
.then((settings) => renderNames(settings.names || []))
|
||||
.then((settings) => {
|
||||
const names = settings.names || [];
|
||||
if (names.length === 0) {
|
||||
localStorage.setItem("selectedName", "");
|
||||
window.location.href = "checkout.html";
|
||||
return;
|
||||
}
|
||||
renderNames(names);
|
||||
})
|
||||
.catch(() => {
|
||||
nameList.textContent = "Namen konnten nicht geladen werden.";
|
||||
});
|
||||
|
||||
function renderNames(names) {
|
||||
const options = names.length > 0 ? names : ["Du weißt, wer ich bin"];
|
||||
|
||||
options.forEach((name) => {
|
||||
names.forEach((name) => {
|
||||
const card = document.createElement("button");
|
||||
card.type = "button";
|
||||
card.className = "name-card";
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
settings_file="${SETTINGS_FILE:-/app/appsettings.json}"
|
||||
config_dir="/app/config"
|
||||
settings_file="$config_dir/appsettings.json"
|
||||
|
||||
if [ ! -f "$settings_file" ]; then
|
||||
mkdir -p "$(dirname "$settings_file")"
|
||||
cp /app/appsettings.default.json "$settings_file"
|
||||
echo "Missing mounted config file: $settings_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
activities_file="$config_dir/activities.json"
|
||||
available_dates_file="$config_dir/dates_available.json"
|
||||
booked_dates_file="$config_dir/dates_booked.json"
|
||||
|
||||
for config_file in "$activities_file" "$available_dates_file" "$booked_dates_file"; do
|
||||
if [ ! -f "$config_file" ]; then
|
||||
echo "Missing mounted config file: $config_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
exec /app/server
|
||||
Reference in New Issue
Block a user