diff --git a/Dockerfile b/Dockerfile index 5d092e0..70291ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,11 @@ 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 EXPOSE 8080 -ENTRYPOINT ["/app/server"] +ENTRYPOINT ["/app/start.sh"] diff --git a/README.md b/README.md index 9dcea11..b6e5c84 100644 --- a/README.md +++ b/README.md @@ -51,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.)* -### 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`). - **`availableDates`**: Liste von verfügbaren Tagen mit `date` (`YYYY-MM-DD`) und `type` (`short` oder `long`). - **`names`**: Optional vordefinierte Namen zur Auswahl (ist die Liste leer, wird ein Standard-Fallback angeboten). +Die Terminliste lässt sich auch über die passwortgeschützte Verwaltung unter `http://localhost:8080/admin` bearbeiten. Das Passwort bleibt dabei auf dem Server und wird nicht an Besucher ausgeliefert. + +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 diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..ff86481 --- /dev/null +++ b/appsettings.json @@ -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" +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index c91a55c..a50aef1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: date-wizard: build: @@ -7,14 +5,23 @@ services: dockerfile: Dockerfile container_name: date-wizard restart: unless-stopped - ports: - - "8080:8080" + networks: + - proxy env_file: - .env + environment: + SETTINGS_FILE: /data/appsettings.json + BOOKED_DATES_FILE: /data/booked_dates.json + volumes: + - /docker/data/date:/data labels: - "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.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" + +networks: + proxy: + external: true diff --git a/main.go b/main.go index 858f1c0..7f9ba9b 100644 --- a/main.go +++ b/main.go @@ -3,13 +3,17 @@ package main import ( "bufio" "bytes" + "crypto/rand" + "crypto/subtle" "encoding/json" "fmt" "log" "net/http" "net/smtp" "os" + "sort" "strings" + "sync" "time" ) @@ -33,6 +37,32 @@ type Response struct { 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() { loadEnv(".env") @@ -48,6 +78,11 @@ func main() { 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 fs := http.FileServer(http.Dir(webDir)) @@ -59,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) { if enableCORS(w, r) { return diff --git a/public/activity.js b/public/activity.js index 9320895..aaaf9cf 100644 --- a/public/activity.js +++ b/public/activity.js @@ -32,7 +32,7 @@ return act && (act.name === "Etwas eigenes" || act.name === "Sonstiges" || act.type === "custom"); } - fetch("appsettings.json") + fetch("/api/settings", { cache: "no-store" }) .then((response) => response.json()) .then((settings) => { const allActivities = settings.activities || []; diff --git a/public/admin.css b/public/admin.css new file mode 100644 index 0000000..f2b2904 --- /dev/null +++ b/public/admin.css @@ -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; + } +} \ No newline at end of file diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..ead6827 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,74 @@ + + + + + + Terminverwaltung - Date-Planer + + + + + + + +
+
+

Date-Planer

+

Terminverwaltung

+ + +

+
+ + +
+ + + \ No newline at end of file diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..c22538c --- /dev/null +++ b/public/admin.js @@ -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(); +})(); \ No newline at end of file diff --git a/public/appsettings.json b/public/appsettings.json deleted file mode 100644 index e3827ee..0000000 --- a/public/appsettings.json +++ /dev/null @@ -1,54 +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-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": [] -} diff --git a/public/checkout.js b/public/checkout.js index 65cf42f..82c89b4 100644 --- a/public/checkout.js +++ b/public/checkout.js @@ -45,7 +45,7 @@ if (storedActivities.length > 0) { activityIcon.textContent = storedActivities.map((a) => a.icon).join(" "); } else { - fetch("appsettings.json") + fetch("/api/settings", { cache: "no-store" }) .then((res) => res.json()) .then((settings) => { const match = (settings.activities || []).find((a) => a.name === selectedActivity); diff --git a/public/datetime.js b/public/datetime.js index 0f06d38..577fcd6 100644 --- a/public/datetime.js +++ b/public/datetime.js @@ -38,7 +38,7 @@ } Promise.all([ - fetch("appsettings.json?t=" + Date.now()).then((res) => res.json()), + fetch("/api/settings?t=" + Date.now(), { cache: "no-store" }).then((res) => res.json()), fetch("/api/booked-dates?t=" + Date.now(), { cache: "no-store" }) .then((res) => (res.ok ? res.json() : [])) .catch(() => []), diff --git a/public/name.js b/public/name.js index 68bdfa0..23cd2f5 100644 --- a/public/name.js +++ b/public/name.js @@ -28,7 +28,7 @@ let selectedName = null; - fetch("appsettings.json") + fetch("/api/settings", { cache: "no-store" }) .then((response) => response.json()) .then((settings) => renderNames(settings.names || [])) .catch(() => { diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..1b0cd79 --- /dev/null +++ b/start.sh @@ -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 \ No newline at end of file