Compare commits
14
Commits
617d906d3e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
097e72d364 | ||
|
|
253b65d976 | ||
|
|
4766a7d525 | ||
|
|
020f3ef746 | ||
|
|
23baff9ef0 | ||
|
|
8685b3c466 | ||
|
|
b99b1f4ae5 | ||
|
|
d2cc049b25 | ||
|
|
83eafe1aef | ||
|
|
6b159affda | ||
|
|
89bd6f39f6 | ||
|
|
0ee82eb673 | ||
|
|
ad02939b88 | ||
|
|
964ac21acb |
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.env
|
||||||
|
*.exe
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Server Port & Configuration
|
||||||
|
PORT=8080
|
||||||
|
WEB_DIR=public
|
||||||
|
BOOKED_DATES_FILE=booked_dates.json
|
||||||
|
|
||||||
|
# SMTP Configuration
|
||||||
|
SMTP_HOST=mail.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=user@example.com
|
||||||
|
SMTP_PASS=secretpassword
|
||||||
|
|
||||||
|
# Email Addresses
|
||||||
|
TO_EMAIL=destination@example.com
|
||||||
|
FROM_EMAIL=sender@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,20 @@
|
|||||||
|
I want to add backend functionality to send the survey responses as an email upon submission using Option 1 (a minimalist Go/Golang microservice).
|
||||||
|
|
||||||
|
Please complete the following tasks:
|
||||||
|
|
||||||
|
1. Go Microservice (`main.go`):
|
||||||
|
- Create a lightweight HTTP server using standard Go library packages (`net/http`, `net/smtp`, `encoding/json`).
|
||||||
|
- Create an endpoint (e.g., `POST /api/submit`) to accept JSON payload survey data from the front end.
|
||||||
|
- Configure SMTP credentials and server settings via environment variables (e.g., `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `TO_EMAIL`) via an .env file
|
||||||
|
- Format the incoming JSON survey data into a clean, readable text body and dispatch the email upon request.
|
||||||
|
- Handle CORS preflight (`OPTIONS`) and headers so the HTML/JS front end can POST to it smoothly.
|
||||||
|
|
||||||
|
2. Dockerization (`Dockerfile`):
|
||||||
|
- Provide a multi-stage `Dockerfile` (e.g., compile with `golang:alpine`, copy to `scratch` or `alpine`) to ensure the resulting container image is ultra-lightweight (<20MB).
|
||||||
|
|
||||||
|
3. Front-End Integration:
|
||||||
|
- Show how to update the existing JavaScript submission handler (using `fetch()`) to send the survey data as JSON to the new Go backend endpoint.
|
||||||
|
|
||||||
|
Please keep the code minimalist, robust, and well-commented.
|
||||||
|
|
||||||
|
when everything works, please create a simple docker-compose file that builds the project and runs the container. include traefik labels for reverse proxy routing.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
*.exe
|
||||||
|
booked_dates.json
|
||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# Stage 1: Build the Go binary
|
||||||
|
FROM golang:1.22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod ./
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o server .
|
||||||
|
|
||||||
|
# Stage 2: Ultra-lightweight runtime container
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
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/start.sh"]
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Date-Wizard 💕
|
||||||
|
|
||||||
|
Eine spielerische Web-App ("Choose Your Own Adventure") zum Planen gemeinsamer Dates – mit mobilem Design, dynamischen Aktivitäten & Terminen sowie einem Go-Microservice zum E-Mail-Versand der Reservierung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Projektstruktur
|
||||||
|
|
||||||
|
```text
|
||||||
|
datetest/
|
||||||
|
├── public/ # Statische Webseiten & Assets
|
||||||
|
│ ├── appsettings.json # Konfiguration der Aktivitäten & Termine
|
||||||
|
│ ├── index.html # Startseite ("Willst du mit mir gehen?")
|
||||||
|
│ ├── datetime.html/.js # Datumsauswahl (Kalender)
|
||||||
|
│ ├── activity.html/.js # Aktivitätsauswahl
|
||||||
|
│ ├── name.html/.js # Namensauswahl ("Wer bist du?")
|
||||||
|
│ └── checkout.html/.js # Zusammenfassung & Bestätigung
|
||||||
|
├── main.go # Go-Microservice (HTTP-Server & SMTP E-Mail)
|
||||||
|
├── go.mod # Go-Moduldefinition
|
||||||
|
├── Dockerfile # Multi-Stage Dockerfile (Alpine Runtime)
|
||||||
|
├── docker-compose.yml # Docker-Compose mit Traefik Labels
|
||||||
|
├── .env.example # Vorlage für Umgebungsvariablen
|
||||||
|
└── .env # Lokale/Produktive Umgebungsvariablen (nicht ins Git committen)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Konfiguration
|
||||||
|
|
||||||
|
### 1. E-Mail & Server `.env` anlegen
|
||||||
|
|
||||||
|
Kopiere `.env.example` zu `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Trage in der `.env` deine Server- und SMTP-Zugangsdaten ein:
|
||||||
|
|
||||||
|
| Variable | Beschreibung | Beispiel |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `PORT` | HTTP-Port des Go-Servers | `8080` |
|
||||||
|
| `WEB_DIR` | Verzeichnis der statischen Assets | `public` |
|
||||||
|
| `BOOKED_DATES_FILE` | Speicherpfad für bereits gebuchte Termine | `booked_dates.json` |
|
||||||
|
| `SMTP_HOST` | Postausgangsserver (SMTP) | `mail.example.com` |
|
||||||
|
| `SMTP_PORT` | SMTP-Port (Standard meist 587 oder 465) | `587` |
|
||||||
|
| `SMTP_USER` | Benutzername / E-Mail für SMTP-Auth | `absender@example.com` |
|
||||||
|
| `SMTP_PASS` | Passwort des SMTP-Kontos | `secretpassword` |
|
||||||
|
| `TO_EMAIL` | Ziel-Adresse für eingegangene Date-Anfragen | `ziel@example.com` |
|
||||||
|
| `FROM_EMAIL` | Absender-Adresse für die Benachrichtigung | `absender@example.com` |
|
||||||
|
|
||||||
|
*(Hinweis: Wenn `SMTP_HOST` oder `TO_EMAIL` leer bleiben, läuft der Server im Testmodus ohne E-Mail-Versand.)*
|
||||||
|
|
||||||
|
### 2. Aktivitäten & Termine anpassen (`appsettings.json`)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
### Option A: Mit Docker Compose (Empfohlen)
|
||||||
|
|
||||||
|
1. `.env` wie oben beschrieben anpassen.
|
||||||
|
2. Container erstellen und starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Die Anwendung ist unter `http://localhost:8080` erreichbar.
|
||||||
|
|
||||||
|
#### Traefik / Reverse Proxy
|
||||||
|
|
||||||
|
`docker-compose.yml` enthält bereits Traefik-Labels. Passe bei Bedarf die Domain an:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- "traefik.http.routers.date-wizard.rule=Host(`date.deine-domain.de`)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option B: Direkt mit Go (Lokal)
|
||||||
|
|
||||||
|
#### Voraussetzungen
|
||||||
|
- Installed Go SDK (Version 1.22 oder neuer)
|
||||||
|
|
||||||
|
1. Abhängigkeiten prüfen & kompilieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Die Anwendung startet auf dem in `.env` definierten Port (z. B. `http://localhost:8080`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 Ablauf der Anwendung
|
||||||
|
|
||||||
|
1. **Startseite (`index.html`)**: Spielerische Frage mit Ja/Nein-Interaktion.
|
||||||
|
2. **Datumsauswahl (`datetime.html`)**: Interaktiver Kalender mit Markierung von verfügbaren Tagen (`short`/`long`).
|
||||||
|
3. **Aktivitätsauswahl (`activity.html`)**: Auswahl passender Aktivitäten je nach verbleibender Zeit am gewählten Tag.
|
||||||
|
4. **Namensauswahl (`name.html`)**: Abfrage "Wer bist du?".
|
||||||
|
5. **Checkout & Bestätigung (`checkout.html`)**: Finale Übersicht und Absenden der Reservierung (überträgt Daten an `/api/submit` und löst den E-Mail-Versand aus).
|
||||||
-141
@@ -1,141 +0,0 @@
|
|||||||
// Aktivitäts-Auswahl: lädt Aktivitäten aus appsettings.json, Kartenauswahl + Weiter-Button
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
const activityGrid = document.getElementById("activityGrid");
|
|
||||||
const dateSummary = document.getElementById("dateSummary");
|
|
||||||
const timeNotice = document.getElementById("timeNotice");
|
|
||||||
const continueBtn = document.getElementById("continueBtn");
|
|
||||||
|
|
||||||
const selectedDate = localStorage.getItem("selectedDate");
|
|
||||||
const selectedDateType = localStorage.getItem("selectedDateType") || "long";
|
|
||||||
|
|
||||||
if (!selectedDate) {
|
|
||||||
window.location.href = "datetime.html";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateObj = new Date(`${selectedDate}T00:00:00`);
|
|
||||||
const formattedDate = dateObj.toLocaleDateString("de-DE", {
|
|
||||||
weekday: "long",
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
dateSummary.textContent = `Für euer Date am ${formattedDate}.`;
|
|
||||||
|
|
||||||
let availableActivities = [];
|
|
||||||
let selectedList = [];
|
|
||||||
|
|
||||||
fetch("appsettings.json")
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((settings) => {
|
|
||||||
const allActivities = settings.activities || [];
|
|
||||||
if (selectedDateType === "short") {
|
|
||||||
availableActivities = allActivities.filter((a) => a.type === "short");
|
|
||||||
} else {
|
|
||||||
availableActivities = allActivities;
|
|
||||||
}
|
|
||||||
renderActivities();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
activityGrid.textContent = "Aktivitäten konnten nicht geladen werden.";
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderActivities() {
|
|
||||||
activityGrid.innerHTML = "";
|
|
||||||
const hasShortSelected = selectedList.some((a) => a.type === "short");
|
|
||||||
|
|
||||||
availableActivities.forEach((activity) => {
|
|
||||||
const card = document.createElement("button");
|
|
||||||
card.type = "button";
|
|
||||||
card.className = "activity-card";
|
|
||||||
card.dataset.activity = activity.name;
|
|
||||||
|
|
||||||
const isSelected = selectedList.some((a) => a.name === activity.name);
|
|
||||||
if (isSelected) {
|
|
||||||
card.classList.add("selected");
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLongDisabled = hasShortSelected && activity.type === "long";
|
|
||||||
if (isLongDisabled) {
|
|
||||||
card.classList.add("is-disabled");
|
|
||||||
card.disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const icon = document.createElement("span");
|
|
||||||
icon.className = "icon";
|
|
||||||
icon.setAttribute("aria-hidden", "true");
|
|
||||||
icon.textContent = activity.icon;
|
|
||||||
|
|
||||||
const label = document.createElement("span");
|
|
||||||
label.className = "label";
|
|
||||||
label.textContent = activity.name;
|
|
||||||
|
|
||||||
card.append(icon, label);
|
|
||||||
|
|
||||||
card.addEventListener("click", () => {
|
|
||||||
handleCardClick(activity);
|
|
||||||
});
|
|
||||||
|
|
||||||
activityGrid.appendChild(card);
|
|
||||||
});
|
|
||||||
|
|
||||||
updateNoticeAndContinue();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCardClick(activity) {
|
|
||||||
const isAlreadySelected = selectedList.some((a) => a.name === activity.name);
|
|
||||||
|
|
||||||
if (activity.type === "long") {
|
|
||||||
if (isAlreadySelected) {
|
|
||||||
selectedList = [];
|
|
||||||
} else {
|
|
||||||
selectedList = [activity];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Short activity
|
|
||||||
if (isAlreadySelected) {
|
|
||||||
selectedList = selectedList.filter((a) => a.name !== activity.name);
|
|
||||||
} else {
|
|
||||||
// If a long activity was selected, replace it
|
|
||||||
if (selectedList.some((a) => a.type === "long")) {
|
|
||||||
selectedList = [activity];
|
|
||||||
} else if (selectedList.length >= 2) {
|
|
||||||
// Max 2 short activities, replace the second one
|
|
||||||
selectedList = [selectedList[0], activity];
|
|
||||||
} else {
|
|
||||||
selectedList.push(activity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderActivities();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateNoticeAndContinue() {
|
|
||||||
const shortCount = selectedList.filter((a) => a.type === "short").length;
|
|
||||||
const hasLong = selectedList.some((a) => a.type === "long");
|
|
||||||
|
|
||||||
if (selectedDateType === "long" && shortCount === 1 && !hasLong) {
|
|
||||||
timeNotice.textContent =
|
|
||||||
"💡 An diesem Tag haben wir reichlich Zeit! Wenn du möchtest, kannst du noch eine 2. 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 euren langen Tag ausgewählt.";
|
|
||||||
timeNotice.classList.remove("hidden");
|
|
||||||
} else {
|
|
||||||
timeNotice.classList.add("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
continueBtn.disabled = selectedList.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
continueBtn.addEventListener("click", () => {
|
|
||||||
if (selectedList.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
localStorage.setItem("selectedActivities", JSON.stringify(selectedList));
|
|
||||||
localStorage.setItem("selectedActivity", selectedList.map((a) => a.name).join(" + "));
|
|
||||||
window.location.href = "name.html";
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
+185
-47
@@ -1,52 +1,190 @@
|
|||||||
{
|
{
|
||||||
"activities": [
|
"activities": [
|
||||||
{ "name": "Untersetzer aus Ton", "icon": "🪨", "type": "long" },
|
{
|
||||||
{ "name": "Picknick auf der Wiese", "icon": "🧺", "type": "short" },
|
"name": "Picknick auf der Wiese",
|
||||||
{ "name": "Cocktailabend zu zweit", "icon": "🍸", "type": "long" },
|
"icon": "🧺",
|
||||||
{ "name": "Massage zu Hause", "icon": "💆", "type": "short" },
|
"type": "short"
|
||||||
{ "name": "Ausgedehnte Wanderung", "icon": "🥾", "type": "long" },
|
},
|
||||||
{ "name": "Ins Kino", "icon": "🎬", "type": "short" },
|
{
|
||||||
{ "name": "Sushi selber machen", "icon": "🍣", "type": "short" },
|
"name": "Cocktailabend zu zweit",
|
||||||
{ "name": "Freizeitpark", "icon": "🎢", "type": "long" },
|
"icon": "🍸",
|
||||||
{ "name": "Spaziergang im Wald", "icon": "🌲", "type": "short" },
|
"type": "long"
|
||||||
{ "name": "Nudeln selber machen", "icon": "🍜", "type": "long" },
|
},
|
||||||
{ "name": "Ab ins Freibad", "icon": "🏊", "type": "short" },
|
{
|
||||||
{ "name": "Film zu Hause + Essen bestellen", "icon": "🛋️", "type": "long" },
|
"name": "Massage zu Hause",
|
||||||
{ "name": "Squash spielen", "icon": "🏸", "type": "short" },
|
"icon": "💆",
|
||||||
{ "name": "Essen im Namaste", "icon": "🍛", "type": "short" },
|
"type": "short"
|
||||||
{ "name": "Museum für Gegenwartskunst", "icon": "🖼️", "type": "short" },
|
},
|
||||||
{ "name": "Playstation Co-Op", "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": [
|
"availableDates": [
|
||||||
{ "date": "2026-08-11", "type": "short" },
|
{
|
||||||
{ "date": "2026-08-15", "type": "short" },
|
"date": "2026-08-18",
|
||||||
{ "date": "2026-08-18", "type": "short" },
|
"type": "short"
|
||||||
{ "date": "2026-08-20", "type": "short" },
|
},
|
||||||
{ "date": "2026-08-20", "type": "short" },
|
{
|
||||||
{ "date": "2026-08-22", "type": "long" },
|
"date": "2026-08-20",
|
||||||
{ "date": "2026-08-23", "type": "long" },
|
"type": "short"
|
||||||
{ "date": "2026-08-24", "type": "short" },
|
},
|
||||||
{ "date": "2026-08-26", "type": "short" },
|
{
|
||||||
{ "date": "2026-08-27", "type": "short" },
|
"date": "2026-08-22",
|
||||||
{ "date": "2026-08-28", "type": "short" },
|
"type": "long"
|
||||||
{ "date": "2026-08-29", "type": "long" },
|
},
|
||||||
{ "date": "2026-08-31", "type": "short" },
|
{
|
||||||
{ "date": "2026-09-01", "type": "short" },
|
"date": "2026-08-23",
|
||||||
{ "date": "2026-09-07", "type": "short" },
|
"type": "long"
|
||||||
{ "date": "2026-09-08", "type": "short" },
|
},
|
||||||
{ "date": "2026-09-14", "type": "short" },
|
{
|
||||||
{ "date": "2026-09-15", "type": "short" },
|
"date": "2026-08-24",
|
||||||
{ "date": "2026-09-17", "type": "short" },
|
"type": "short"
|
||||||
{ "date": "2026-09-18", "type": "short" },
|
},
|
||||||
{ "date": "2026-09-25", "type": "short" },
|
{
|
||||||
{ "date": "2026-09-26", "type": "long" },
|
"date": "2026-08-26",
|
||||||
{ "date": "2026-09-27", "type": "short" },
|
"type": "short"
|
||||||
{ "date": "2026-09-28", "type": "short" },
|
},
|
||||||
{ "date": "2026-09-29", "type": "short" },
|
{
|
||||||
{ "date": "2026-09-30", "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": []
|
"names": [],
|
||||||
}
|
"adminPassword": "change-me"
|
||||||
|
}
|
||||||
-62
@@ -1,62 +0,0 @@
|
|||||||
// Checkout & Bestätigungsseite
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
const backLink = document.getElementById("backLink");
|
|
||||||
const summarySection = document.getElementById("summarySection");
|
|
||||||
const confirmationSection = document.getElementById("confirmationSection");
|
|
||||||
const summaryActivity = document.getElementById("summaryActivity");
|
|
||||||
const summaryDate = document.getElementById("summaryDate");
|
|
||||||
const summaryName = document.getElementById("summaryName");
|
|
||||||
const activityIcon = document.getElementById("activityIcon");
|
|
||||||
const checkoutBtn = document.getElementById("checkoutBtn");
|
|
||||||
|
|
||||||
const selectedActivity = localStorage.getItem("selectedActivity");
|
|
||||||
const selectedDate = localStorage.getItem("selectedDate");
|
|
||||||
const selectedName = localStorage.getItem("selectedName");
|
|
||||||
|
|
||||||
if (!selectedActivity || !selectedDate || !selectedName) {
|
|
||||||
window.location.href = "index.html";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
summaryActivity.textContent = selectedActivity;
|
|
||||||
summaryName.textContent = selectedName;
|
|
||||||
|
|
||||||
const dateObj = new Date(`${selectedDate}T00:00:00`);
|
|
||||||
const formattedDate = dateObj.toLocaleDateString("de-DE", {
|
|
||||||
weekday: "long",
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
summaryDate.textContent = formattedDate;
|
|
||||||
|
|
||||||
// Icons aus selectedActivities oder appsettings.json laden
|
|
||||||
let storedActivities = [];
|
|
||||||
try {
|
|
||||||
storedActivities = JSON.parse(localStorage.getItem("selectedActivities") || "[]");
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
if (storedActivities.length > 0) {
|
|
||||||
activityIcon.textContent = storedActivities.map((a) => a.icon).join(" ");
|
|
||||||
} else {
|
|
||||||
fetch("appsettings.json")
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((settings) => {
|
|
||||||
const match = (settings.activities || []).find((a) => a.name === selectedActivity);
|
|
||||||
if (match && match.icon) {
|
|
||||||
activityIcon.textContent = match.icon;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
checkoutBtn.addEventListener("click", () => {
|
|
||||||
// Später: API / Mail-Versand / Kalendereintrag ausführen
|
|
||||||
summarySection.classList.add("hidden");
|
|
||||||
if (backLink) {
|
|
||||||
backLink.classList.add("hidden");
|
|
||||||
}
|
|
||||||
confirmationSection.classList.remove("hidden");
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
services:
|
||||||
|
date-wizard:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: date-wizard
|
||||||
|
restart: unless-stopped
|
||||||
|
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.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=leresolver"
|
||||||
|
- "traefik.http.services.date-wizard.loadbalancer.server.port=8080"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Activity struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
CustomDescription string `json:"customDescription,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SurveyPayload struct {
|
||||||
|
Activity string `json:"activity"`
|
||||||
|
Activities []Activity `json:"activities"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
DateType string `json:"dateType"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
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")
|
||||||
|
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
webDir := os.Getenv("WEB_DIR")
|
||||||
|
if webDir == "" {
|
||||||
|
webDir = "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
http.Handle("/", fs)
|
||||||
|
|
||||||
|
log.Printf("Server starting on port %s (serving static files from %s)...", port, webDir)
|
||||||
|
if err := http.ListenAndServe(":"+port, nil); err != nil {
|
||||||
|
log.Fatalf("Server failed to start: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload SurveyPayload
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(Response{
|
||||||
|
Success: false,
|
||||||
|
Message: "Ungültige Anfragedaten.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Email could not be sent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(Response{
|
||||||
|
Success: true,
|
||||||
|
Message: "Reservierung erfolgreich übermittelt!",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendEmail(payload SurveyPayload) error {
|
||||||
|
smtpHost := os.Getenv("SMTP_HOST")
|
||||||
|
smtpPort := os.Getenv("SMTP_PORT")
|
||||||
|
if smtpPort == "" {
|
||||||
|
smtpPort = "587"
|
||||||
|
}
|
||||||
|
smtpUser := os.Getenv("SMTP_USER")
|
||||||
|
smtpPass := os.Getenv("SMTP_PASS")
|
||||||
|
toEmail := os.Getenv("TO_EMAIL")
|
||||||
|
fromEmail := os.Getenv("FROM_EMAIL")
|
||||||
|
if fromEmail == "" {
|
||||||
|
fromEmail = smtpUser
|
||||||
|
}
|
||||||
|
|
||||||
|
if smtpHost == "" || toEmail == "" {
|
||||||
|
log.Println("HINWEIS: SMTP_HOST oder TO_EMAIL nicht konfiguriert. E-Mail-Versand übersprungen.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
boundary := fmt.Sprintf("BOUNDARY_%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
var bodyBuilder bytes.Buffer
|
||||||
|
bodyBuilder.WriteString(fmt.Sprintf("From: %s\r\n", fromEmail))
|
||||||
|
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("MIME-Version: 1.0\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("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("📅 Datum: %s (%s)\r\n", payload.Date, payload.DateType))
|
||||||
|
bodyBuilder.WriteString(fmt.Sprintf("💌 Aktivität(en): %s\r\n\r\n", cleanActivityName(payload.Activity)))
|
||||||
|
|
||||||
|
if len(payload.Activities) > 0 {
|
||||||
|
bodyBuilder.WriteString("Details der Aktivitäten:\r\n")
|
||||||
|
for _, act := range payload.Activities {
|
||||||
|
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("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)
|
||||||
|
addr := fmt.Sprintf("%s:%s", smtpHost, smtpPort)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
file, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
key := strings.TrimSpace(parts[0])
|
||||||
|
val := strings.TrimSpace(parts[1])
|
||||||
|
val = strings.Trim(val, `"'`)
|
||||||
|
if os.Getenv(key) == "" {
|
||||||
|
os.Setenv(key, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||||
<title>Wähle eure Aktivität – Date-Planer</title>
|
<title>Wähle unsere Aktivität – Date-Planer</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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 href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&family=Quicksand:wght@600;700&display=swap" rel="stylesheet" />
|
||||||
@@ -15,14 +15,26 @@
|
|||||||
<a class="back-link" href="datetime.html">‹ Zurück</a>
|
<a class="back-link" href="datetime.html">‹ Zurück</a>
|
||||||
<div class="intro">
|
<div class="intro">
|
||||||
<h1>Worauf hast du Lust? 🥰</h1>
|
<h1>Worauf hast du Lust? 🥰</h1>
|
||||||
<p id="dateSummary">Wähle eure gemeinsame Aktivität für das Date aus.</p>
|
<p id="dateSummary">Wähle unsere gemeinsame Aktivität für das Date aus.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="timeNotice" class="time-notice hidden">
|
<div id="timeNotice" class="time-notice hidden">
|
||||||
💡 An diesem Tag haben wir reichlich Zeit! Wenn du möchtest, kannst du noch eine 2. kurze Aktivität auswählen.
|
💡 An diesem Tag haben wir reichlich Zeit! Wenn du möchtest, kannst du noch eine zweite kurze Aktivität auswählen.
|
||||||
</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">
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
// Aktivitäts-Auswahl: lädt Aktivitäten aus appsettings.json, Kartenauswahl + Weiter-Button
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const activityGrid = document.getElementById("activityGrid");
|
||||||
|
const dateSummary = document.getElementById("dateSummary");
|
||||||
|
const timeNotice = document.getElementById("timeNotice");
|
||||||
|
const continueBtn = document.getElementById("continueBtn");
|
||||||
|
const customActivitySection = document.getElementById("customActivitySection");
|
||||||
|
const customActivityInput = document.getElementById("customActivityInput");
|
||||||
|
|
||||||
|
const selectedDate = localStorage.getItem("selectedDate");
|
||||||
|
const selectedDateType = localStorage.getItem("selectedDateType") || "long";
|
||||||
|
|
||||||
|
if (!selectedDate) {
|
||||||
|
window.location.href = "datetime.html";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateObj = new Date(`${selectedDate}T00:00:00`);
|
||||||
|
const formattedDate = dateObj.toLocaleDateString("de-DE", {
|
||||||
|
weekday: "long",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
dateSummary.textContent = `Für unser Date am ${formattedDate}.`;
|
||||||
|
|
||||||
|
let availableActivities = [];
|
||||||
|
let selectedList = [];
|
||||||
|
|
||||||
|
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((settings) => {
|
||||||
|
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") {
|
||||||
|
otherActivities = otherActivities.filter((a) => a.type === "short");
|
||||||
|
}
|
||||||
|
|
||||||
|
// "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();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
activityGrid.textContent = "Aktivitäten konnten nicht geladen werden.";
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderActivities() {
|
||||||
|
activityGrid.innerHTML = "";
|
||||||
|
const hasCustomSelected = selectedList.some(isCustomActivity);
|
||||||
|
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) => {
|
||||||
|
const card = document.createElement("button");
|
||||||
|
card.type = "button";
|
||||||
|
card.className = "activity-card";
|
||||||
|
card.dataset.activity = activity.name;
|
||||||
|
|
||||||
|
const isCustom = isCustomActivity(activity);
|
||||||
|
const isSelected = isCustom
|
||||||
|
? hasCustomSelected
|
||||||
|
: selectedList.some((a) => a.name === activity.name);
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
card.classList.add("selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLongDisabled = !isCustom && hasShortSelected && activity.type === "long";
|
||||||
|
if (isLongDisabled) {
|
||||||
|
card.classList.add("is-disabled");
|
||||||
|
card.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
icon.className = "icon";
|
||||||
|
icon.setAttribute("aria-hidden", "true");
|
||||||
|
icon.textContent = activity.icon;
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "label";
|
||||||
|
label.textContent = activity.name;
|
||||||
|
|
||||||
|
card.append(icon, label);
|
||||||
|
|
||||||
|
card.addEventListener("click", () => {
|
||||||
|
handleCardClick(activity);
|
||||||
|
});
|
||||||
|
|
||||||
|
activityGrid.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateNoticeAndContinue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardClick(activity) {
|
||||||
|
const isCustom = isCustomActivity(activity);
|
||||||
|
const hasCustomSelected = selectedList.some(isCustomActivity);
|
||||||
|
|
||||||
|
if (isCustom) {
|
||||||
|
if (hasCustomSelected) {
|
||||||
|
selectedList = [];
|
||||||
|
} else {
|
||||||
|
// Custom-Aktivität darf mit keiner anderen Aktivität kombiniert werden!
|
||||||
|
selectedList = [activity];
|
||||||
|
setTimeout(() => {
|
||||||
|
if (customActivityInput) {
|
||||||
|
customActivityInput.focus();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (hasCustomSelected) {
|
||||||
|
selectedList = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAlreadySelected = selectedList.some((a) => a.name === activity.name);
|
||||||
|
|
||||||
|
if (activity.type === "long") {
|
||||||
|
if (isAlreadySelected) {
|
||||||
|
selectedList = [];
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderActivities();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customActivityInput) {
|
||||||
|
customActivityInput.addEventListener("input", () => {
|
||||||
|
updateNoticeAndContinue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNoticeAndContinue() {
|
||||||
|
const hasCustomSelected = selectedList.some(isCustomActivity);
|
||||||
|
const shortCount = selectedList.filter((a) => a.type === "short").length;
|
||||||
|
const hasLong = selectedList.some((a) => a.type === "long");
|
||||||
|
|
||||||
|
if (hasCustomSelected) {
|
||||||
|
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.addEventListener("click", () => {
|
||||||
|
if (selectedList.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
.summary-item {
|
.summary-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
border-bottom: 1px solid var(--color-rose-light);
|
border-bottom: 1px solid var(--color-rose-light);
|
||||||
@@ -42,7 +42,10 @@
|
|||||||
|
|
||||||
.summary-icon {
|
.summary-icon {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
line-height: 1;
|
line-height: 1.2;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 44px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-details {
|
.summary-details {
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<div id="summarySection">
|
<div id="summarySection">
|
||||||
<div class="intro">
|
<div class="intro">
|
||||||
<h1>Fast geschafft! ✨</h1>
|
<h1>Fast geschafft! ✨</h1>
|
||||||
<p>Überprüfe noch einmal eure Angaben für das perfekt geplante Date.</p>
|
<p>Überprüfe noch einmal deine Angaben für unser perfekt geplantes Date.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="summary-card">
|
<div class="summary-card">
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Checkout & Bestätigungsseite
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const backLink = document.getElementById("backLink");
|
||||||
|
const summarySection = document.getElementById("summarySection");
|
||||||
|
const confirmationSection = document.getElementById("confirmationSection");
|
||||||
|
const summaryActivity = document.getElementById("summaryActivity");
|
||||||
|
const summaryDate = document.getElementById("summaryDate");
|
||||||
|
const summaryName = document.getElementById("summaryName");
|
||||||
|
const activityIcon = document.getElementById("activityIcon");
|
||||||
|
const checkoutBtn = document.getElementById("checkoutBtn");
|
||||||
|
|
||||||
|
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 selectedName = localStorage.getItem("selectedName");
|
||||||
|
|
||||||
|
if (!selectedActivity || !selectedDate || !selectedName) {
|
||||||
|
window.location.href = "index.html";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
summaryActivity.textContent = selectedActivity;
|
||||||
|
summaryName.textContent = selectedName;
|
||||||
|
|
||||||
|
const dateObj = new Date(`${selectedDate}T00:00:00`);
|
||||||
|
const formattedDate = dateObj.toLocaleDateString("de-DE", {
|
||||||
|
weekday: "long",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
summaryDate.textContent = formattedDate;
|
||||||
|
|
||||||
|
// Icons aus selectedActivities oder appsettings.json laden
|
||||||
|
let storedActivities = [];
|
||||||
|
try {
|
||||||
|
storedActivities = JSON.parse(localStorage.getItem("selectedActivities") || "[]");
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
if (storedActivities.length > 0) {
|
||||||
|
activityIcon.textContent = storedActivities.map((a) => a.icon).join(" ");
|
||||||
|
} else {
|
||||||
|
fetch("/api/settings", { cache: "no-store" })
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((settings) => {
|
||||||
|
const match = (settings.activities || []).find((a) => a.name === selectedActivity);
|
||||||
|
if (match && match.icon) {
|
||||||
|
activityIcon.textContent = match.icon;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
checkoutBtn.addEventListener("click", () => {
|
||||||
|
checkoutBtn.disabled = true;
|
||||||
|
const originalText = checkoutBtn.textContent;
|
||||||
|
checkoutBtn.textContent = "Wird übermittelt... 💌";
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
activity: cleanPrefix(selectedActivity),
|
||||||
|
activities: storedActivities.map((a) => ({
|
||||||
|
...a,
|
||||||
|
name: cleanPrefix(a.name),
|
||||||
|
customDescription: cleanPrefix(a.customDescription || a.name),
|
||||||
|
})),
|
||||||
|
date: selectedDate,
|
||||||
|
dateType: localStorage.getItem("selectedDateType") || "long",
|
||||||
|
name: selectedName,
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch("/api/submit", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Fehler beim Übermitteln.");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
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");
|
||||||
|
if (backLink) {
|
||||||
|
backLink.classList.add("hidden");
|
||||||
|
}
|
||||||
|
confirmationSection.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
alert(data.message || "Es gab ein Problem bei der Übermittlung.");
|
||||||
|
checkoutBtn.disabled = false;
|
||||||
|
checkoutBtn.textContent = originalText;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Submission error:", err);
|
||||||
|
alert("Übermittlung fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
|
checkoutBtn.disabled = false;
|
||||||
|
checkoutBtn.textContent = originalText;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<a class="back-link" href="index.html">‹ Zurück</a>
|
<a class="back-link" href="index.html">‹ Zurück</a>
|
||||||
<div class="intro">
|
<div class="intro">
|
||||||
<h1>Wann soll es losgehen? 💌</h1>
|
<h1>Wann soll es losgehen? 💌</h1>
|
||||||
<p id="activitySummary">Wähle das passende Datum für euer Date aus.</p>
|
<p id="activitySummary">Wähl das passende Datum für unser Date aus.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="calendar-card" aria-label="Kalender">
|
<section class="calendar-card" aria-label="Kalender">
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<h1 class="question">Hättest du Lust,<br />mit mir auszugehen? 💕</h1>
|
<h1 class="question">Hallo, ich bin dein Ehemann.<br>Hättest du Lust, mit mir auszugehen? 💕</h1>
|
||||||
<p class="subtitle">Eine kleine Frage mit hoffentlich großer Antwort …</p>
|
<p class="subtitle">Eine kleine Frage mit hoffentlich großer Antwort …</p>
|
||||||
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
@@ -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