Compare commits

..
7 Commits
22 changed files with 1562 additions and 123 deletions
+1
View File
@@ -1,6 +1,7 @@
# Server Port & Configuration # Server Port & Configuration
PORT=8080 PORT=8080
WEB_DIR=public WEB_DIR=public
BOOKED_DATES_FILE=booked_dates.json
# SMTP Configuration # SMTP Configuration
SMTP_HOST=mail.example.com SMTP_HOST=mail.example.com
@@ -0,0 +1 @@
i want you to add a custom-activity (Sonstiges) with an appropriate icon. The custom activity always appears last in the list of activities. The custom activity should be available for selection in the booking process, and when selected, it should allow the user to enter a description of the activity. The entered description should be stored along with the booking information. The custom activity can not be paired with any other activity, and if selected, it should be the only activity in the booking. Custom activity don't need to be stored for later but included in the information mail and the created ics file
@@ -0,0 +1,6 @@
I want to attach an iCalendar (.ics) file to the reservation confirmation email so that booked dates can be directly imported into a smartphone calendar.
Requirements:
- Format the outgoing email as a `multipart/mixed` MIME message.
- Dynamically generate an iCalendar (`.ics`) file containing the booking details (date, selected activity/activities, name).
- Set proper MIME headers (`Content-Type: text/calendar`, `Content-Disposition: attachment; filename="date-event.ics"`) so smartphones (iOS / Android) and calendar apps present a direct "Add to Calendar" import prompt.
@@ -0,0 +1 @@
I now want the application to maintain a list of booked dates. That can be a simple text file or json file. every time a booking is submitted, the date should be added to that file. When offering available dates for selection, the application should read that file and exclude any dates that have already been booked.
+1
View File
@@ -1,2 +1,3 @@
.env .env
*.exe *.exe
booked_dates.json
+32
View File
@@ -0,0 +1,32 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Run Date Wizard",
"type": "shell",
"command": "go",
"args": [
"run",
"."
],
"isBackground": true,
"problemMatcher": [],
"group": "build",
"statusbar": {
"hide": false,
"label": "Run Date Wizard",
"icon": {
"id": "play"
},
"color": "#6dff72",
"detail": "Running Date Wizard",
"running": {
"icon": {
"id": "gear~spin"
},
"backgroundColor": "statusBarItem.warningBackground"
}
}
}
]
}
+5 -1
View File
@@ -18,7 +18,11 @@ WORKDIR /app
# Copy binary and static front-end assets # Copy binary and static front-end assets
COPY --from=builder /app/server /app/server COPY --from=builder /app/server /app/server
COPY --from=builder /app/public /app/public 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
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/app/server"] ENTRYPOINT ["/app/start.sh"]
+8 -2
View File
@@ -41,6 +41,7 @@ Trage in der `.env` deine Server- und SMTP-Zugangsdaten ein:
| :--- | :--- | :--- | | :--- | :--- | :--- |
| `PORT` | HTTP-Port des Go-Servers | `8080` | | `PORT` | HTTP-Port des Go-Servers | `8080` |
| `WEB_DIR` | Verzeichnis der statischen Assets | `public` | | `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_HOST` | Postausgangsserver (SMTP) | `mail.example.com` |
| `SMTP_PORT` | SMTP-Port (Standard meist 587 oder 465) | `587` | | `SMTP_PORT` | SMTP-Port (Standard meist 587 oder 465) | `587` |
| `SMTP_USER` | Benutzername / E-Mail für SMTP-Auth | `absender@example.com` | | `SMTP_USER` | Benutzername / E-Mail für SMTP-Auth | `absender@example.com` |
@@ -50,14 +51,19 @@ 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.)* *(Hinweis: Wenn `SMTP_HOST` oder `TO_EMAIL` leer bleiben, läuft der Server im Testmodus ohne E-Mail-Versand.)*
### 2. Aktivitäten & Termine anpassen (`public/appsettings.json`) ### 2. Aktivitäten & Termine anpassen (`appsettings.json`)
Passe in `public/appsettings.json` die zur Auswahl stehenden Aktivitäten sowie die verfügbaren Tage 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.
- **`activities`**: Liste von Objekten mit `name`, `icon` (Unicode Emoji) und `type` (`short` oder `long`). - **`activities`**: Liste von Objekten mit `name`, `icon` (Unicode Emoji) und `type` (`short` oder `long`).
- **`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.
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.
--- ---
## 🚀 Inbetriebnahme ## 🚀 Inbetriebnahme
+190
View File
@@ -0,0 +1,190 @@
{
"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"
}
+13 -6
View File
@@ -1,5 +1,3 @@
version: '3.8'
services: services:
date-wizard: date-wizard:
build: build:
@@ -7,14 +5,23 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: date-wizard container_name: date-wizard
restart: unless-stopped restart: unless-stopped
ports: networks:
- "8080:8080" - proxy
env_file: env_file:
- .env - .env
environment:
SETTINGS_FILE: /data/appsettings.json
BOOKED_DATES_FILE: /data/booked_dates.json
volumes:
- /docker/data/date:/data
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.date-wizard.rule=Host(`date.example.com`)" - "traefik.http.routers.date-wizard.rule=Host(`date.allen.beging.de`) || Host(`date.beging.de`)"
- "traefik.http.routers.date-wizard.entrypoints=websecure" - "traefik.http.routers.date-wizard.entrypoints=websecure"
- "traefik.http.routers.date-wizard.tls=true" - "traefik.http.routers.date-wizard.tls=true"
- "traefik.http.routers.date-wizard.tls.certresolver=letsencrypt" - "traefik.http.routers.date-wizard.tls.certresolver=leresolver"
- "traefik.http.services.date-wizard.loadbalancer.server.port=8080" - "traefik.http.services.date-wizard.loadbalancer.server.port=8080"
networks:
proxy:
external: true
+509 -15
View File
@@ -3,19 +3,25 @@ package main
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"crypto/rand"
"crypto/subtle"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"net/smtp" "net/smtp"
"os" "os"
"sort"
"strings" "strings"
"sync"
"time"
) )
type Activity struct { 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"`
CustomDescription string `json:"customDescription,omitempty"`
} }
type SurveyPayload struct { type SurveyPayload struct {
@@ -31,6 +37,32 @@ type Response struct {
Message string `json:"message"` Message string `json:"message"`
} }
type AvailableDate struct {
Date string `json:"date"`
Type string `json:"type"`
}
type AppSettings struct {
Activities []Activity `json:"activities"`
AvailableDates []AvailableDate `json:"availableDates"`
Names []string `json:"names"`
AdminPassword string `json:"adminPassword"`
}
type PublicSettings struct {
Activities []Activity `json:"activities"`
AvailableDates []AvailableDate `json:"availableDates"`
Names []string `json:"names"`
}
type passwordRequest struct {
Password string `json:"password"`
}
var settingsMu sync.Mutex
var sessionsMu sync.Mutex
var adminSessions = make(map[string]time.Time)
func main() { func main() {
loadEnv(".env") loadEnv(".env")
@@ -45,6 +77,12 @@ func main() {
} }
http.HandleFunc("/api/submit", handleSubmit) http.HandleFunc("/api/submit", handleSubmit)
http.HandleFunc("/api/booked-dates", handleBookedDates)
http.HandleFunc("/api/settings", handlePublicSettings)
http.HandleFunc("/api/admin/login", handleAdminLogin)
http.HandleFunc("/api/admin/logout", handleAdminLogout)
http.HandleFunc("/api/admin/dates", handleAdminDates)
http.HandleFunc("/admin", handleAdminPage)
// Serve static files from webDir directory // Serve static files from webDir directory
fs := http.FileServer(http.Dir(webDir)) fs := http.FileServer(http.Dir(webDir))
@@ -56,6 +94,301 @@ func main() {
} }
} }
func handleAdminPage(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/admin" && r.URL.Path != "/admin/" {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, "public/admin.html")
}
func handlePublicSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
settings, err := readSettings()
if err != nil {
log.Printf("Error reading app settings: %v", err)
http.Error(w, "Settings could not be loaded", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, PublicSettings{
Activities: settings.Activities,
AvailableDates: availableDatesFromToday(settings.AvailableDates),
Names: settings.Names,
})
}
func handleAdminLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var request passwordRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
settings, err := readSettings()
if err != nil {
log.Printf("Error reading app settings: %v", err)
http.Error(w, "Settings could not be loaded", http.StatusInternalServerError)
return
}
if settings.AdminPassword == "" || subtle.ConstantTimeCompare([]byte(request.Password), []byte(settings.AdminPassword)) != 1 {
writeJSON(w, http.StatusUnauthorized, Response{Success: false, Message: "Ungültiges Passwort."})
return
}
sessionID, err := createAdminSession()
if err != nil {
log.Printf("Error creating admin session: %v", err)
http.Error(w, "Session could not be created", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "date_wizard_admin",
Value: sessionID,
Path: "/",
MaxAge: int((8 * time.Hour).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: r.TLS != nil,
})
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Angemeldet."})
}
func handleAdminLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if cookie, err := r.Cookie("date_wizard_admin"); err == nil {
sessionsMu.Lock()
delete(adminSessions, cookie.Value)
sessionsMu.Unlock()
}
http.SetCookie(w, &http.Cookie{Name: "date_wizard_admin", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: r.TLS != nil})
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Abgemeldet."})
}
func handleAdminDates(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 {
log.Printf("Error reading app settings: %v", err)
http.Error(w, "Settings could not be loaded", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, availableDatesFromToday(settings.AvailableDates))
case http.MethodPost:
var availableDate AvailableDate
if err := json.NewDecoder(r.Body).Decode(&availableDate); err != nil || !isValidAvailableDate(availableDate) || !isTodayOrFuture(availableDate.Date) {
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte wähle ein gültiges Datum ab heute und einen Tagestyp."})
return
}
if err := updateAvailableDates(func(dates []AvailableDate) ([]AvailableDate, error) {
for _, date := range dates {
if date.Date == availableDate.Date {
return nil, fmt.Errorf("date already exists")
}
}
return append(dates, availableDate), nil
}); err != nil {
if err.Error() == "date already exists" {
writeJSON(w, http.StatusConflict, Response{Success: false, Message: "Dieses Datum ist bereits verfügbar."})
return
}
log.Printf("Error adding available date: %v", err)
http.Error(w, "Date could not be saved", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, Response{Success: true, Message: "Datum hinzugefügt."})
case http.MethodPut:
var availableDate AvailableDate
if err := json.NewDecoder(r.Body).Decode(&availableDate); err != nil || !isValidAvailableDate(availableDate) || !isTodayOrFuture(availableDate.Date) {
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte wähle ein Datum ab heute und einen gültigen Tagestyp."})
return
}
updated := false
if err := updateAvailableDates(func(dates []AvailableDate) ([]AvailableDate, error) {
for index := range dates {
if dates[index].Date == availableDate.Date {
dates[index].Type = availableDate.Type
updated = true
break
}
}
if !updated {
return nil, fmt.Errorf("date does not exist")
}
return dates, nil
}); err != nil {
if err.Error() == "date does not exist" {
writeJSON(w, http.StatusNotFound, Response{Success: false, Message: "Datum wurde nicht gefunden."})
return
}
log.Printf("Error updating available date: %v", err)
http.Error(w, "Date could not be saved", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Tagestyp aktualisiert."})
case http.MethodDelete:
var availableDate AvailableDate
if err := json.NewDecoder(r.Body).Decode(&availableDate); err != nil || !isValidAvailableDate(AvailableDate{Date: availableDate.Date, Type: "short"}) || !isTodayOrFuture(availableDate.Date) {
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte wähle ein Datum ab heute."})
return
}
deleted := false
if err := updateAvailableDates(func(dates []AvailableDate) ([]AvailableDate, error) {
filtered := make([]AvailableDate, 0, len(dates))
for _, date := range dates {
if date.Date == availableDate.Date {
deleted = true
continue
}
filtered = append(filtered, date)
}
if !deleted {
return nil, fmt.Errorf("date does not exist")
}
return filtered, nil
}); err != nil {
if err.Error() == "date does not exist" {
writeJSON(w, http.StatusNotFound, Response{Success: false, Message: "Datum wurde nicht gefunden."})
return
}
log.Printf("Error deleting available date: %v", err)
http.Error(w, "Date could not be saved", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Datum gelöscht."})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func getSettingsFilePath() string {
path := os.Getenv("SETTINGS_FILE")
if path == "" {
path = "appsettings.json"
}
return path
}
func readSettings() (AppSettings, error) {
settingsMu.Lock()
defer settingsMu.Unlock()
return readSettingsFile()
}
func readSettingsFile() (AppSettings, error) {
var settings AppSettings
data, err := os.ReadFile(getSettingsFilePath())
if err != nil {
return settings, err
}
if err := json.Unmarshal(data, &settings); err != nil {
return settings, err
}
return settings, nil
}
func updateAvailableDates(update func([]AvailableDate) ([]AvailableDate, error)) error {
settingsMu.Lock()
defer settingsMu.Unlock()
settings, err := readSettingsFile()
if err != nil {
return err
}
dates, err := update(settings.AvailableDates)
if err != nil {
return err
}
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())
}
func isValidAvailableDate(availableDate AvailableDate) bool {
if availableDate.Type != "short" && availableDate.Type != "long" {
return false
}
parsedDate, err := time.Parse("2006-01-02", availableDate.Date)
return err == nil && parsedDate.Format("2006-01-02") == availableDate.Date
}
func isTodayOrFuture(date string) bool {
return date >= time.Now().Format("2006-01-02")
}
func availableDatesFromToday(dates []AvailableDate) []AvailableDate {
upcomingDates := make([]AvailableDate, 0, len(dates))
for _, availableDate := range dates {
if isTodayOrFuture(availableDate.Date) {
upcomingDates = append(upcomingDates, availableDate)
}
}
return upcomingDates
}
func createAdminSession() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
sessionID := fmt.Sprintf("%x", bytes)
sessionsMu.Lock()
adminSessions[sessionID] = time.Now().Add(8 * time.Hour)
sessionsMu.Unlock()
return sessionID, nil
}
func isAdminRequest(r *http.Request) bool {
cookie, err := r.Cookie("date_wizard_admin")
if err != nil {
return false
}
sessionsMu.Lock()
defer sessionsMu.Unlock()
expiresAt, found := adminSessions[cookie.Value]
if !found || time.Now().After(expiresAt) {
delete(adminSessions, cookie.Value)
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, value interface{}) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
json.NewEncoder(w).Encode(value)
}
func handleSubmit(w http.ResponseWriter, r *http.Request) { func handleSubmit(w http.ResponseWriter, r *http.Request) {
if enableCORS(w, r) { if enableCORS(w, r) {
return return
@@ -80,16 +413,13 @@ 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) log.Printf("Received survey submission from %s for date %s (%s)", payload.Name, payload.Date, payload.Activity)
if err := recordBookedDate(payload.Date); err != nil {
log.Printf("Error recording booked date: %v", err)
}
err = sendEmail(payload) err = sendEmail(payload)
if err != nil { if err != nil {
log.Printf("Error sending email: %v", err) log.Printf("Warning: Email could not be sent: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(Response{
Success: false,
Message: "E-Mail konnte nicht gesendet werden.",
})
return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -100,6 +430,91 @@ func handleSubmit(w http.ResponseWriter, r *http.Request) {
}) })
} }
func handleBookedDates(w http.ResponseWriter, r *http.Request) {
if enableCORS(w, r) {
return
}
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
dates, err := getBookedDates()
if err != nil {
log.Printf("Error reading booked dates: %v", err)
dates = []string{}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(dates)
}
func getBookedDatesFilePath() string {
path := os.Getenv("BOOKED_DATES_FILE")
if path == "" {
path = "booked_dates.json"
}
return path
}
func getBookedDates() ([]string, error) {
filePath := getBookedDatesFilePath()
file, err := os.Open(filePath)
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 = append(dates, trimmed)
}
}
return dates, nil
}
func recordBookedDate(dateStr string) error {
trimmedDate := strings.TrimSpace(dateStr)
if trimmedDate == "" {
return nil
}
dates, err := getBookedDates()
if err != nil {
dates = []string{}
}
for _, d := range dates {
if d == trimmedDate {
return nil
}
}
dates = append(dates, trimmedDate)
filePath := getBookedDatesFilePath()
data, err := json.MarshalIndent(dates, "", " ")
if err != nil {
return err
}
return os.WriteFile(filePath, data, 0644)
}
func enableCORS(w http.ResponseWriter, r *http.Request) bool { func enableCORS(w http.ResponseWriter, r *http.Request) bool {
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
@@ -131,28 +546,54 @@ func sendEmail(payload SurveyPayload) error {
return nil return nil
} }
boundary := fmt.Sprintf("BOUNDARY_%d", time.Now().UnixNano())
var bodyBuilder bytes.Buffer var bodyBuilder bytes.Buffer
bodyBuilder.WriteString(fmt.Sprintf("From: %s\r\n", fromEmail)) bodyBuilder.WriteString(fmt.Sprintf("From: %s\r\n", fromEmail))
bodyBuilder.WriteString(fmt.Sprintf("To: %s\r\n", toEmail)) bodyBuilder.WriteString(fmt.Sprintf("To: %s\r\n", toEmail))
bodyBuilder.WriteString(fmt.Sprintf("Subject: Neue Date-Reservierung von %s! 💕\r\n", payload.Name)) bodyBuilder.WriteString(fmt.Sprintf("Subject: Neue Date-Reservierung von %s! 💕\r\n", payload.Name))
bodyBuilder.WriteString("MIME-Version: 1.0\r\n") bodyBuilder.WriteString("MIME-Version: 1.0\r\n")
bodyBuilder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n") bodyBuilder.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", boundary))
// Text-Teil der E-Mail
bodyBuilder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
bodyBuilder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
bodyBuilder.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
bodyBuilder.WriteString("Hallo!\r\n\r\n") bodyBuilder.WriteString("Hallo!\r\n\r\n")
bodyBuilder.WriteString("Es gibt eine neue Date-Reservierung über deinen Date-Planer:\r\n\r\n") bodyBuilder.WriteString("Es gibt eine neue Date-Reservierung über deinen Date-Planer:\r\n\r\n")
bodyBuilder.WriteString(fmt.Sprintf("👤 Name: %s\r\n", payload.Name)) bodyBuilder.WriteString(fmt.Sprintf("👤 Name: %s\r\n", payload.Name))
bodyBuilder.WriteString(fmt.Sprintf("📅 Datum: %s (%s)\r\n", payload.Date, payload.DateType)) bodyBuilder.WriteString(fmt.Sprintf("📅 Datum: %s (%s)\r\n", payload.Date, payload.DateType))
bodyBuilder.WriteString(fmt.Sprintf("💌 Aktivität(en): %s\r\n\r\n", payload.Activity)) bodyBuilder.WriteString(fmt.Sprintf("💌 Aktivität(en): %s\r\n\r\n", cleanActivityName(payload.Activity)))
if len(payload.Activities) > 0 { if len(payload.Activities) > 0 {
bodyBuilder.WriteString("Details der Aktivitäten:\r\n") bodyBuilder.WriteString("Details der Aktivitäten:\r\n")
for _, act := range payload.Activities { for _, act := range payload.Activities {
bodyBuilder.WriteString(fmt.Sprintf(" - %s %s (%s)\r\n", act.Icon, act.Name, act.Type)) actName := cleanActivityName(act.Name)
if act.CustomDescription != "" {
cleanDesc := cleanActivityName(act.CustomDescription)
if !strings.Contains(actName, cleanDesc) {
actName = cleanDesc
}
}
bodyBuilder.WriteString(fmt.Sprintf(" - %s %s (%s)\r\n", act.Icon, actName, act.Type))
} }
bodyBuilder.WriteString("\r\n") bodyBuilder.WriteString("\r\n")
} }
bodyBuilder.WriteString("Viel Spaß beim gemeinsamen Date! 💕\r\n") bodyBuilder.WriteString("Im Anhang findest du einen Kalendereintrag (.ics), den du direkt auf deinem Smartphone/Kalender importieren kannst. 💕\r\n\r\n")
// iCalendar-Anhang (.ics)
icsData := generateICS(payload)
bodyBuilder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
bodyBuilder.WriteString("Content-Type: text/calendar; charset=UTF-8; method=REQUEST; name=\"date-event.ics\"\r\n")
bodyBuilder.WriteString("Content-Disposition: attachment; filename=\"date-event.ics\"\r\n")
bodyBuilder.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
bodyBuilder.WriteString(icsData)
bodyBuilder.WriteString("\r\n")
// Abschluss-Boundary
bodyBuilder.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
auth := smtp.PlainAuth("", smtpUser, smtpPass, smtpHost) auth := smtp.PlainAuth("", smtpUser, smtpPass, smtpHost)
addr := fmt.Sprintf("%s:%s", smtpHost, smtpPort) addr := fmt.Sprintf("%s:%s", smtpHost, smtpPort)
@@ -160,6 +601,59 @@ func sendEmail(payload SurveyPayload) error {
return smtp.SendMail(addr, auth, fromEmail, []string{toEmail}, bodyBuilder.Bytes()) return smtp.SendMail(addr, auth, fromEmail, []string{toEmail}, bodyBuilder.Bytes())
} }
func cleanActivityName(s string) string {
s = strings.TrimPrefix(s, "Etwas eigenes: ")
s = strings.TrimPrefix(s, "Etwas eigenes:")
s = strings.TrimPrefix(s, "Sonstiges: ")
s = strings.TrimPrefix(s, "Sonstiges:")
return strings.TrimSpace(s)
}
func generateICS(payload SurveyPayload) string {
parsedDate, err := time.Parse("2006-01-02", payload.Date)
dtStart := strings.ReplaceAll(payload.Date, "-", "")
dtEnd := dtStart
if err == nil {
dtStart = parsedDate.Format("20060102")
dtEnd = parsedDate.AddDate(0, 0, 1).Format("20060102")
}
dtStamp := time.Now().UTC().Format("20060102T150405Z")
uid := fmt.Sprintf("date-wizard-%s-%d@date-wizard", dtStart, time.Now().UnixNano())
activityText := cleanActivityName(payload.Activity)
for _, act := range payload.Activities {
if act.CustomDescription != "" {
cleanDesc := cleanActivityName(act.CustomDescription)
if !strings.Contains(activityText, cleanDesc) {
activityText = cleanDesc
}
}
}
summary := fmt.Sprintf("Date: %s", activityText)
description := fmt.Sprintf("Date mit %s\\nAktivität(en): %s", payload.Name, activityText)
var sb strings.Builder
sb.WriteString("BEGIN:VCALENDAR\r\n")
sb.WriteString("VERSION:2.0\r\n")
sb.WriteString("PRODID:-//Date-Wizard//DE\r\n")
sb.WriteString("CALSCALE:GREGORIAN\r\n")
sb.WriteString("METHOD:REQUEST\r\n")
sb.WriteString("BEGIN:VEVENT\r\n")
sb.WriteString(fmt.Sprintf("UID:%s\r\n", uid))
sb.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", dtStamp))
sb.WriteString(fmt.Sprintf("DTSTART;VALUE=DATE:%s\r\n", dtStart))
sb.WriteString(fmt.Sprintf("DTEND;VALUE=DATE:%s\r\n", dtEnd))
sb.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", summary))
sb.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", description))
sb.WriteString("STATUS:CONFIRMED\r\n")
sb.WriteString("END:VEVENT\r\n")
sb.WriteString("END:VCALENDAR\r\n")
return sb.String()
}
func loadEnv(filename string) { func loadEnv(filename string) {
file, err := os.Open(filename) file, err := os.Open(filename)
if err != nil { if err != nil {
+37
View File
@@ -96,6 +96,43 @@
animation: fadeIn 0.3s ease; animation: fadeIn 0.3s ease;
} }
.custom-activity-section {
margin-top: 16px;
background: var(--color-white);
border: 2px solid var(--color-rose);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow-soft);
animation: fadeIn 0.3s ease;
}
.custom-activity-label {
display: block;
font-family: var(--font-heading);
font-weight: 700;
font-size: 0.95rem;
color: var(--color-rose-dark);
margin-bottom: 8px;
}
.custom-activity-input {
width: 100%;
padding: 12px 14px;
font-family: var(--font-body);
font-size: 1rem;
color: var(--color-text);
background: var(--color-bg);
border: 2px solid var(--color-peach);
border-radius: var(--radius-md);
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.custom-activity-input:focus {
border-color: var(--color-rose);
box-shadow: 0 0 0 3px rgba(224, 87, 126, 0.2);
}
.hidden { .hidden {
display: none !important; display: none !important;
} }
+12
View File
@@ -23,6 +23,18 @@
</div> </div>
<div class="activity-grid" id="activityGrid"></div> <div class="activity-grid" id="activityGrid"></div>
<div id="customActivitySection" class="custom-activity-section hidden">
<label for="customActivityInput" class="custom-activity-label">Was möchtest du unternehmen? ✏️</label>
<input
type="text"
id="customActivityInput"
class="custom-activity-input"
placeholder="Beschreibung der Aktivität eingeben..."
maxlength="150"
autocomplete="off"
/>
</div>
</main> </main>
<div class="continue-bar"> <div class="continue-bar">
+149 -36
View File
@@ -5,6 +5,8 @@
const dateSummary = document.getElementById("dateSummary"); const dateSummary = document.getElementById("dateSummary");
const timeNotice = document.getElementById("timeNotice"); const timeNotice = document.getElementById("timeNotice");
const continueBtn = document.getElementById("continueBtn"); const continueBtn = document.getElementById("continueBtn");
const customActivitySection = document.getElementById("customActivitySection");
const customActivityInput = document.getElementById("customActivityInput");
const selectedDate = localStorage.getItem("selectedDate"); const selectedDate = localStorage.getItem("selectedDate");
const selectedDateType = localStorage.getItem("selectedDateType") || "long"; const selectedDateType = localStorage.getItem("selectedDateType") || "long";
@@ -26,15 +28,59 @@
let availableActivities = []; let availableActivities = [];
let selectedList = []; let selectedList = [];
fetch("appsettings.json") function isCustomActivity(act) {
return act && (act.name === "Etwas eigenes" || act.name === "Sonstiges" || act.type === "custom");
}
fetch("/api/settings", { cache: "no-store" })
.then((response) => response.json()) .then((response) => response.json())
.then((settings) => { .then((settings) => {
const allActivities = settings.activities || []; const allActivities = settings.activities || [];
const customAct = allActivities.find((a) => a.name === "Etwas eigenes" || a.name === "Sonstiges") || {
name: "Etwas eigenes",
icon: "✏️",
type: "custom",
};
let otherActivities = allActivities.filter((a) => a.name !== "Etwas eigenes" && a.name !== "Sonstiges");
if (selectedDateType === "short") { if (selectedDateType === "short") {
availableActivities = allActivities.filter((a) => a.type === "short"); otherActivities = otherActivities.filter((a) => a.type === "short");
} else {
availableActivities = allActivities;
} }
// "Etwas eigenes" erscheint IMMER an letzter Stelle
availableActivities = [...otherActivities, customAct];
// Vorherige Auswahl wiederherstellen, falls vorhanden
try {
const stored = JSON.parse(localStorage.getItem("selectedActivities") || "[]");
if (stored && stored.length > 0) {
const isStoredCustom = stored.some(
(a) => a.type === "custom" || (a.name && (a.name.startsWith("Etwas eigenes") || a.name.startsWith("Sonstiges")))
);
if (isStoredCustom) {
const storedCustom = stored.find(
(a) => a.type === "custom" || (a.name && (a.name.startsWith("Etwas eigenes") || a.name.startsWith("Sonstiges")))
);
selectedList = [customAct];
if (storedCustom) {
const desc =
storedCustom.customDescription ||
(storedCustom.name.includes(":")
? storedCustom.name.split(":").slice(1).join(":").trim()
: "");
if (customActivityInput) {
customActivityInput.value = desc;
}
}
} else {
selectedList = stored.filter((s) =>
availableActivities.some((a) => a.name === s.name)
);
}
}
} catch (e) {}
renderActivities(); renderActivities();
}) })
.catch(() => { .catch(() => {
@@ -43,20 +89,35 @@
function renderActivities() { function renderActivities() {
activityGrid.innerHTML = ""; activityGrid.innerHTML = "";
const hasCustomSelected = selectedList.some(isCustomActivity);
const hasShortSelected = selectedList.some((a) => a.type === "short"); const hasShortSelected = selectedList.some((a) => a.type === "short");
if (hasCustomSelected) {
if (customActivitySection) {
customActivitySection.classList.remove("hidden");
}
} else {
if (customActivitySection) {
customActivitySection.classList.add("hidden");
}
}
availableActivities.forEach((activity) => { availableActivities.forEach((activity) => {
const card = document.createElement("button"); const card = document.createElement("button");
card.type = "button"; card.type = "button";
card.className = "activity-card"; card.className = "activity-card";
card.dataset.activity = activity.name; card.dataset.activity = activity.name;
const isSelected = selectedList.some((a) => a.name === activity.name); const isCustom = isCustomActivity(activity);
const isSelected = isCustom
? hasCustomSelected
: selectedList.some((a) => a.name === activity.name);
if (isSelected) { if (isSelected) {
card.classList.add("selected"); card.classList.add("selected");
} }
const isLongDisabled = hasShortSelected && activity.type === "long"; const isLongDisabled = !isCustom && hasShortSelected && activity.type === "long";
if (isLongDisabled) { if (isLongDisabled) {
card.classList.add("is-disabled"); card.classList.add("is-disabled");
card.disabled = true; card.disabled = true;
@@ -84,31 +145,47 @@
} }
function handleCardClick(activity) { function handleCardClick(activity) {
const isAlreadySelected = selectedList.some((a) => a.name === activity.name); const isCustom = isCustomActivity(activity);
const hasCustomSelected = selectedList.some(isCustomActivity);
if (activity.type === "long") { if (isCustom) {
if (isAlreadySelected) { if (hasCustomSelected) {
selectedList = []; selectedList = [];
} else { } else {
// Custom-Aktivität darf mit keiner anderen Aktivität kombiniert werden!
selectedList = [activity]; selectedList = [activity];
setTimeout(() => {
if (customActivityInput) {
customActivityInput.focus();
}
}, 50);
} }
} else { } else {
// Short activity if (hasCustomSelected) {
if (isAlreadySelected) { selectedList = [];
selectedList = selectedList.filter((a) => a.name !== activity.name); }
} else if (selectedDateType === "short") {
// An kurzen Tagen ist nur maximal eine kurze Aktivität erlaubt const isAlreadySelected = selectedList.some((a) => a.name === activity.name);
selectedList = [activity];
} else { if (activity.type === "long") {
// An langen Tagen: if (isAlreadySelected) {
// Falls vorher eine lange Aktivität gewählt war, ersetzen selectedList = [];
if (selectedList.some((a) => a.type === "long")) {
selectedList = [activity];
} else if (selectedList.length >= 2) {
// Maximal 2 kurze Aktivitäten, die zweite ersetzen
selectedList = [selectedList[0], activity];
} else { } else {
selectedList.push(activity); selectedList = [activity];
}
} else {
if (isAlreadySelected) {
selectedList = selectedList.filter((a) => a.name !== activity.name);
} else if (selectedDateType === "short") {
selectedList = [activity];
} else {
if (selectedList.some((a) => a.type === "long")) {
selectedList = [activity];
} else if (selectedList.length >= 2) {
selectedList = [selectedList[0], activity];
} else {
selectedList.push(activity);
}
} }
} }
} }
@@ -116,30 +193,66 @@
renderActivities(); renderActivities();
} }
if (customActivityInput) {
customActivityInput.addEventListener("input", () => {
updateNoticeAndContinue();
});
}
function updateNoticeAndContinue() { function updateNoticeAndContinue() {
const hasCustomSelected = selectedList.some(isCustomActivity);
const shortCount = selectedList.filter((a) => a.type === "short").length; const shortCount = selectedList.filter((a) => a.type === "short").length;
const hasLong = selectedList.some((a) => a.type === "long"); const hasLong = selectedList.some((a) => a.type === "long");
if (selectedDateType === "long" && shortCount === 1 && !hasLong) { if (hasCustomSelected) {
timeNotice.textContent =
"💡 An diesem Tag haben wir reichlich Zeit! Wenn du möchtest, kannst du noch eine zweite kurze Aktivität auswählen.";
timeNotice.classList.remove("hidden");
} else if (selectedDateType === "long" && shortCount === 2) {
timeNotice.textContent = "🎉 Perfekt! Du hast 2 kurze Aktivitäten für unseren Tag ausgewählt.";
timeNotice.classList.remove("hidden");
} else {
timeNotice.classList.add("hidden"); timeNotice.classList.add("hidden");
} const desc = customActivityInput ? customActivityInput.value.trim() : "";
continueBtn.disabled = desc.length === 0;
} else {
if (selectedDateType === "long" && shortCount === 1 && !hasLong) {
timeNotice.textContent =
"💡 An diesem Tag haben wir reichlich Zeit! Wenn du möchtest, kannst du noch eine zweite kurze Aktivität auswählen.";
timeNotice.classList.remove("hidden");
} else if (selectedDateType === "long" && shortCount === 2) {
timeNotice.textContent = "🎉 Perfekt! Du hast 2 kurze Aktivitäten für unseren Tag ausgewählt.";
timeNotice.classList.remove("hidden");
} else {
timeNotice.classList.add("hidden");
}
continueBtn.disabled = selectedList.length === 0; continueBtn.disabled = selectedList.length === 0;
}
} }
continueBtn.addEventListener("click", () => { continueBtn.addEventListener("click", () => {
if (selectedList.length === 0) { if (selectedList.length === 0) {
return; return;
} }
localStorage.setItem("selectedActivities", JSON.stringify(selectedList));
localStorage.setItem("selectedActivity", selectedList.map((a) => a.name).join(" + ")); const hasCustomSelected = selectedList.some(isCustomActivity);
if (hasCustomSelected) {
const customDesc = customActivityInput ? customActivityInput.value.trim() : "";
if (!customDesc) {
return;
}
const customAct = selectedList.find(isCustomActivity);
const savedCustomAct = {
name: customDesc,
icon: customAct.icon || "✏️",
type: "custom",
customDescription: customDesc,
};
localStorage.setItem("selectedActivities", JSON.stringify([savedCustomAct]));
localStorage.setItem("selectedActivity", customDesc);
} else {
localStorage.setItem("selectedActivities", JSON.stringify(selectedList));
localStorage.setItem("selectedActivity", selectedList.map((a) => a.name).join(" + "));
}
window.location.href = "name.html"; window.location.href = "name.html";
}); });
})(); })();
+260
View File
@@ -0,0 +1,260 @@
.page-admin {
background: #f8f4ec;
font-size: 1rem;
}
.admin-shell {
width: min(100% - 32px, 720px);
margin: 0 auto;
padding: 40px 0 56px;
}
.login-view {
width: min(100%, 400px);
margin: 12vh auto 0;
padding: 32px;
background: var(--color-white);
border: 1px solid #eaded1;
border-radius: 8px;
box-shadow: 0 12px 30px rgba(74, 44, 61, 0.12);
}
.eyebrow {
margin-bottom: 6px;
color: #8c6d52;
font-family: var(--font-heading);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.login-copy,
.section-heading p {
color: var(--color-text-light);
}
.login-form,
.date-form {
display: grid;
gap: 12px;
margin-top: 24px;
}
.login-form label,
.date-form > label,
.type-selector legend {
color: var(--color-text);
font-family: var(--font-heading);
font-size: 0.92rem;
font-weight: 700;
}
.login-form input,
.date-form > input {
width: 100%;
min-height: 46px;
padding: 10px 12px;
border: 1px solid #cfbba9;
border-radius: 6px;
background: #fffefa;
color: var(--color-text);
font: inherit;
}
.login-form input:focus,
.date-form > input:focus,
.date-type-select:focus {
outline: 3px solid rgba(224, 87, 126, 0.28);
outline-offset: 1px;
border-color: var(--color-rose);
}
.login-form .btn,
.date-form .btn {
min-height: 48px;
border-radius: 6px;
font-size: 1rem;
}
.form-message {
min-height: 1.5em;
margin: 12px 0 0;
color: var(--color-rose-dark);
font-weight: 700;
}
.form-message.is-success {
color: #287540;
}
.admin-view {
display: grid;
gap: 32px;
}
.admin-header,
.dates-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.admin-header h1 {
margin-bottom: 0;
}
.text-action {
padding: 8px 0;
border: 0;
background: transparent;
color: var(--color-rose-dark);
font: inherit;
font-weight: 700;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.admin-section {
padding-top: 24px;
border-top: 1px solid #dfcdbd;
}
.section-heading h2 {
margin-bottom: 4px;
font-size: 1.25rem;
}
.section-heading p {
margin-bottom: 0;
}
.type-selector {
display: flex;
gap: 8px;
margin: 4px 0 2px;
padding: 0;
border: 0;
}
.type-selector legend {
margin-bottom: 8px;
}
.type-selector label {
flex: 1;
}
.type-selector input {
position: absolute;
opacity: 0;
}
.type-selector span {
display: block;
padding: 10px 12px;
border: 1px solid #cfbba9;
border-radius: 6px;
background: #fffefa;
color: var(--color-text-light);
font-family: var(--font-heading);
font-weight: 700;
text-align: center;
cursor: pointer;
}
.type-selector input:checked + span {
border-color: var(--color-rose-dark);
background: var(--color-rose-dark);
color: var(--color-white);
}
.type-selector input:focus-visible + span {
outline: 3px solid rgba(224, 87, 126, 0.28);
outline-offset: 1px;
}
.dates-list {
display: grid;
gap: 10px;
margin: 18px 0 0;
padding: 0;
list-style: none;
}
.date-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 118px auto;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid #e5d8ce;
}
.date-label {
font-family: var(--font-heading);
font-weight: 700;
}
.date-type-select {
min-height: 38px;
padding: 6px 28px 6px 8px;
border: 1px solid #cfbba9;
border-radius: 5px;
background: #fffefa;
color: var(--color-text);
font: inherit;
}
.delete-date-btn {
min-height: 38px;
padding: 6px 10px;
border: 1px solid #c84a62;
border-radius: 5px;
background: transparent;
color: #a83049;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.delete-date-btn:hover,
.delete-date-btn:focus-visible {
background: #fff0f2;
}
.empty-dates {
margin: 18px 0 0;
color: var(--color-text-light);
}
.hidden {
display: none !important;
}
@media (max-width: 480px) {
.admin-shell {
width: min(100% - 24px, 720px);
padding-top: 24px;
}
.login-view {
margin-top: 8vh;
padding: 24px;
}
.date-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.date-type-select {
grid-column: 1;
}
.delete-date-btn {
grid-column: 2;
grid-row: 1 / span 2;
}
}
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>Terminverwaltung - Date-Planer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&family=Quicksand:wght@600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="admin.css" />
</head>
<body class="page-admin">
<main class="admin-shell">
<section id="loginView" class="login-view" aria-labelledby="loginTitle">
<p class="eyebrow">Date-Planer</p>
<h1 id="loginTitle">Terminverwaltung</h1>
<p class="login-copy">Melde dich an, um verfügbare Tage zu verwalten.</p>
<form id="loginForm" class="login-form">
<label for="passwordInput">Passwort</label>
<input id="passwordInput" name="password" type="password" autocomplete="current-password" required />
<button class="btn btn-primary" type="submit">Anmelden</button>
</form>
<p id="loginMessage" class="form-message" role="status" aria-live="polite"></p>
</section>
<section id="adminView" class="admin-view hidden" aria-labelledby="adminTitle">
<header class="admin-header">
<div>
<p class="eyebrow">Date-Planer</p>
<h1 id="adminTitle">Verfügbare Tage</h1>
</div>
<button id="logoutBtn" class="text-action" type="button">Abmelden</button>
</header>
<section class="admin-section add-date-section" aria-labelledby="addDateTitle">
<div class="section-heading">
<h2 id="addDateTitle">Neuen Tag hinzufügen</h2>
<p>Datum im Kalender wählen und Zeitumfang festlegen.</p>
</div>
<form id="addDateForm" class="date-form">
<label for="dateInput">Datum</label>
<input id="dateInput" name="date" type="date" required />
<fieldset class="type-selector">
<legend>Tagestyp</legend>
<label>
<input type="radio" name="dateType" value="short" checked />
<span>Kurz</span>
</label>
<label>
<input type="radio" name="dateType" value="long" />
<span>Lang</span>
</label>
</fieldset>
<button class="btn btn-primary" type="submit">Datum hinzufügen</button>
</form>
<p id="addDateMessage" class="form-message" role="status" aria-live="polite"></p>
</section>
<section class="admin-section dates-section" aria-labelledby="datesTitle">
<div class="section-heading dates-heading">
<div>
<h2 id="datesTitle">Eingetragene Tage</h2>
<p id="dateCount">Lade Termine...</p>
</div>
</div>
<ul id="datesList" class="dates-list" aria-live="polite"></ul>
<p id="emptyDates" class="empty-dates hidden">Noch keine Termine eingetragen.</p>
</section>
</section>
</main>
<script src="admin.js" defer></script>
</body>
</html>
+206
View File
@@ -0,0 +1,206 @@
(function () {
const loginView = document.getElementById("loginView");
const adminView = document.getElementById("adminView");
const loginForm = document.getElementById("loginForm");
const passwordInput = document.getElementById("passwordInput");
const loginMessage = document.getElementById("loginMessage");
const addDateForm = document.getElementById("addDateForm");
const dateInput = document.getElementById("dateInput");
const addDateMessage = document.getElementById("addDateMessage");
const datesList = document.getElementById("datesList");
const dateCount = document.getElementById("dateCount");
const emptyDates = document.getElementById("emptyDates");
const logoutBtn = document.getElementById("logoutBtn");
let dates = [];
function setMessage(element, message, success) {
element.textContent = message;
element.classList.toggle("is-success", Boolean(success));
}
function todayKey() {
const today = new Date();
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) {
return new Date(`${date}T00:00:00`).toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
async function request(path, options) {
const response = await fetch(path, {
...options,
headers: {
"Content-Type": "application/json",
...(options && options.headers),
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(data.message || "Die Anfrage ist fehlgeschlagen.");
error.status = response.status;
throw error;
}
return data;
}
function showLogin(message) {
adminView.classList.add("hidden");
loginView.classList.remove("hidden");
if (message) {
setMessage(loginMessage, message, false);
}
passwordInput.focus();
}
function renderDates() {
datesList.innerHTML = "";
dateCount.textContent = `${dates.length} ${dates.length === 1 ? "Termin" : "Termine"}`;
emptyDates.classList.toggle("hidden", dates.length !== 0);
dates.forEach((date) => {
const row = document.createElement("li");
row.className = "date-row";
const label = document.createElement("span");
label.className = "date-label";
label.textContent = formatDate(date.date);
const typeSelect = document.createElement("select");
typeSelect.className = "date-type-select";
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");
deleteButton.className = "delete-date-btn";
deleteButton.type = "button";
deleteButton.textContent = "Löschen";
deleteButton.addEventListener("click", async () => {
if (!window.confirm(`${formatDate(date.date)} wirklich löschen?`)) {
return;
}
deleteButton.disabled = true;
try {
await request("/api/admin/dates", {
method: "DELETE",
body: JSON.stringify({ date: date.date }),
});
dates = dates.filter((item) => item.date !== date.date);
renderDates();
setMessage(addDateMessage, "Datum gelöscht.", true);
} catch (error) {
if (error.status === 401) {
showLogin("Deine Anmeldung ist abgelaufen.");
return;
}
setMessage(addDateMessage, error.message, false);
deleteButton.disabled = false;
}
});
row.append(label, typeSelect, deleteButton);
datesList.appendChild(row);
});
}
async function loadDates() {
try {
dates = await request("/api/admin/dates", { method: "GET" });
dates.sort((first, second) => first.date.localeCompare(second.date));
loginView.classList.add("hidden");
adminView.classList.remove("hidden");
renderDates();
} catch (error) {
if (error.status === 401) {
showLogin();
return;
}
showLogin("Die Termine konnten nicht geladen werden.");
}
}
loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
setMessage(loginMessage, "", false);
try {
await request("/api/admin/login", {
method: "POST",
body: JSON.stringify({ password: passwordInput.value }),
});
passwordInput.value = "";
await loadDates();
} catch (error) {
setMessage(loginMessage, error.message, false);
}
});
addDateForm.addEventListener("submit", async (event) => {
event.preventDefault();
const type = addDateForm.elements.dateType.value;
setMessage(addDateMessage, "", false);
try {
await request("/api/admin/dates", {
method: "POST",
body: JSON.stringify({ date: dateInput.value, type }),
});
dates.push({ date: dateInput.value, type });
dates.sort((first, second) => first.date.localeCompare(second.date));
renderDates();
setMessage(addDateMessage, "Datum hinzugefügt.", true);
addDateForm.reset();
dateInput.min = todayKey();
} catch (error) {
if (error.status === 401) {
showLogin("Deine Anmeldung ist abgelaufen.");
return;
}
setMessage(addDateMessage, error.message, false);
}
});
logoutBtn.addEventListener("click", async () => {
try {
await request("/api/admin/logout", { method: "POST" });
} finally {
showLogin();
}
});
dateInput.min = todayKey();
loadDates();
})();
-53
View File
@@ -1,53 +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" }
],
"availableDates": [
{ "date": "2026-08-11", "type": "short" },
{ "date": "2026-08-15", "type": "short" },
{ "date": "2026-08-18", "type": "short" },
{ "date": "2026-08-20", "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": []
}
+17 -4
View File
@@ -10,7 +10,12 @@
const activityIcon = document.getElementById("activityIcon"); const activityIcon = document.getElementById("activityIcon");
const checkoutBtn = document.getElementById("checkoutBtn"); const checkoutBtn = document.getElementById("checkoutBtn");
const selectedActivity = localStorage.getItem("selectedActivity"); function cleanPrefix(str) {
if (!str) return "";
return str.replace(/^(Etwas eigenes|Sonstiges):\s*/i, "");
}
const selectedActivity = cleanPrefix(localStorage.getItem("selectedActivity"));
const selectedDate = localStorage.getItem("selectedDate"); const selectedDate = localStorage.getItem("selectedDate");
const selectedName = localStorage.getItem("selectedName"); const selectedName = localStorage.getItem("selectedName");
@@ -40,7 +45,7 @@
if (storedActivities.length > 0) { if (storedActivities.length > 0) {
activityIcon.textContent = storedActivities.map((a) => a.icon).join(" "); activityIcon.textContent = storedActivities.map((a) => a.icon).join(" ");
} else { } else {
fetch("appsettings.json") fetch("/api/settings", { cache: "no-store" })
.then((res) => res.json()) .then((res) => res.json())
.then((settings) => { .then((settings) => {
const match = (settings.activities || []).find((a) => a.name === selectedActivity); const match = (settings.activities || []).find((a) => a.name === selectedActivity);
@@ -57,8 +62,12 @@
checkoutBtn.textContent = "Wird übermittelt... 💌"; checkoutBtn.textContent = "Wird übermittelt... 💌";
const payload = { const payload = {
activity: selectedActivity, activity: cleanPrefix(selectedActivity),
activities: storedActivities, activities: storedActivities.map((a) => ({
...a,
name: cleanPrefix(a.name),
customDescription: cleanPrefix(a.customDescription || a.name),
})),
date: selectedDate, date: selectedDate,
dateType: localStorage.getItem("selectedDateType") || "long", dateType: localStorage.getItem("selectedDateType") || "long",
name: selectedName, name: selectedName,
@@ -79,6 +88,10 @@
}) })
.then((data) => { .then((data) => {
if (data.success) { if (data.success) {
// Alle lokal gespeicherten Daten (inkl. bookedDates und ausgewählter Werte) leeren,
// damit bei der nächsten Buchung alles frisch geladen / neu ausgewählt wird.
localStorage.clear();
summarySection.classList.add("hidden"); summarySection.classList.add("hidden");
if (backLink) { if (backLink) {
backLink.classList.add("hidden"); backLink.classList.add("hidden");
+28 -5
View File
@@ -18,6 +18,7 @@
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
let availableDatesMap = new Map(); let availableDatesMap = new Map();
let bookedDatesSet = new Set();
let viewYear = today.getFullYear(); let viewYear = today.getFullYear();
let viewMonth = today.getMonth(); let viewMonth = today.getMonth();
let selectedDate = null; let selectedDate = null;
@@ -36,15 +37,37 @@
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`;
} }
fetch("appsettings.json") Promise.all([
.then((response) => response.json()) fetch("/api/settings?t=" + Date.now(), { cache: "no-store" }).then((res) => res.json()),
.then((settings) => { fetch("/api/booked-dates?t=" + Date.now(), { cache: "no-store" })
.then((res) => (res.ok ? res.json() : []))
.catch(() => []),
])
.then(([settings, bookedDates]) => {
let localBooked = [];
try {
localBooked = JSON.parse(localStorage.getItem("bookedDates") || "[]");
} catch (e) {}
const allBooked = [...(bookedDates || []), ...localBooked]
.map((d) => (typeof d === "string" ? d.trim() : ""))
.filter(Boolean);
bookedDatesSet = new Set(allBooked);
const datesList = settings.availableDates || []; const datesList = settings.availableDates || [];
datesList.forEach((item) => { datesList.forEach((item) => {
let key = "";
let type = "long";
if (typeof item === "string") { if (typeof item === "string") {
availableDatesMap.set(item, { type: "long" }); key = item.trim();
} else if (item && item.date) { } else if (item && item.date) {
availableDatesMap.set(item.date, { type: item.type || "long" }); key = item.date.trim();
type = item.type || "long";
}
if (key && !bookedDatesSet.has(key)) {
availableDatesMap.set(key, { type });
} }
}); });
+1 -1
View File
@@ -28,7 +28,7 @@
let selectedName = null; let selectedName = null;
fetch("appsettings.json") fetch("/api/settings", { cache: "no-store" })
.then((response) => response.json()) .then((response) => response.json())
.then((settings) => renderNames(settings.names || [])) .then((settings) => renderNames(settings.names || []))
.catch(() => { .catch(() => {
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
settings_file="${SETTINGS_FILE:-/app/appsettings.json}"
if [ ! -f "$settings_file" ]; then
mkdir -p "$(dirname "$settings_file")"
cp /app/appsettings.default.json "$settings_file"
fi
exec /app/server