Compare commits
2
Commits
4766a7d525
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
097e72d364 | ||
|
|
253b65d976 |
Vendored
+32
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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.)*
|
*(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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ 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"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,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")
|
||||||
|
|
||||||
@@ -48,6 +78,11 @@ func main() {
|
|||||||
|
|
||||||
http.HandleFunc("/api/submit", handleSubmit)
|
http.HandleFunc("/api/submit", handleSubmit)
|
||||||
http.HandleFunc("/api/booked-dates", handleBookedDates)
|
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))
|
||||||
@@ -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) {
|
func handleSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
if enableCORS(w, r) {
|
if enableCORS(w, r) {
|
||||||
return
|
return
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@
|
|||||||
return act && (act.name === "Etwas eigenes" || act.name === "Sonstiges" || act.type === "custom");
|
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((response) => response.json())
|
||||||
.then((settings) => {
|
.then((settings) => {
|
||||||
const allActivities = settings.activities || [];
|
const allActivities = settings.activities || [];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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": []
|
|
||||||
}
|
|
||||||
+1
-1
@@ -45,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);
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
Promise.all([
|
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" })
|
fetch("/api/booked-dates?t=" + Date.now(), { cache: "no-store" })
|
||||||
.then((res) => (res.ok ? res.json() : []))
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
|
|||||||
+1
-1
@@ -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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user