import existing sources & deployment
Publish and deploy birthday invitation / Check JavaScript (push) Successful in 10s
Publish and deploy birthday invitation / Build and push (push) Successful in 34s
Publish and deploy birthday invitation / Deploy to Kubernetes (push) Failing after 12s

This commit is contained in:
2026-09-08 18:30:41 +02:00
parent 8c29d02398
commit edacc21bea
20 changed files with 1813 additions and 2 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.env
data
node_modules
npm-debug.log
README.md
k8s
Einladung.pdf
private-assets
+123
View File
@@ -0,0 +1,123 @@
name: Publish and deploy birthday invitation
on:
push:
env:
IMAGE: registry.brunner.ninja/feedc0de/30-birthday
KUBERNETES_API: https://host.containers.internal:6443
KUBERNETES_TLS_SERVER_NAME: 192.168.0.2
jobs:
test:
name: Check JavaScript
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@v4
- name: Check server and browser scripts
run: |
docker run --rm --interactive node:24-alpine node --check - < server.js
docker run --rm --interactive node:24-alpine node --check - < database.js
docker run --rm --interactive node:24-alpine node --check - < public/app.js
docker run --rm --interactive node:24-alpine node --check - < public/admin.js
publish:
name: Build and push
needs: test
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@v4
- name: Log in to Quay
uses: docker/login-action@v3
with:
registry: registry.brunner.ninja
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_TOKEN }}
- name: Build image
env:
COMMIT_SHA: ${{ gitea.sha }}
run: |
docker build \
--load \
--label "org.opencontainers.image.revision=${COMMIT_SHA}" \
--label "org.opencontainers.image.source=https://code.brunner.ninja/feedc0de/30-birthday" \
--tag "${IMAGE}:${COMMIT_SHA}" \
.
- name: Push commit tag
env:
COMMIT_SHA: ${{ gitea.sha }}
run: docker push "${IMAGE}:${COMMIT_SHA}"
- name: Push latest tag
if: gitea.ref == 'refs/heads/main'
env:
COMMIT_SHA: ${{ gitea.sha }}
run: |
docker tag "${IMAGE}:${COMMIT_SHA}" "${IMAGE}:latest"
docker push "${IMAGE}:latest"
deploy:
name: Deploy to Kubernetes
if: gitea.ref == 'refs/heads/main'
needs: publish
runs-on: ubuntu-latest
steps:
- name: Install kubectl
env:
KUBECTL_VERSION: v1.36.4
run: |
curl --fail --silent --show-error --location \
--output "${RUNNER_TEMP}/kubectl" \
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
curl --fail --silent --show-error --location \
--output "${RUNNER_TEMP}/kubectl.sha256" \
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256"
printf '%s %s\n' "$(cat "${RUNNER_TEMP}/kubectl.sha256")" "${RUNNER_TEMP}/kubectl" | sha256sum --check
chmod 0700 "${RUNNER_TEMP}/kubectl"
- name: Configure Kubernetes access
env:
KUBE_CONFIG_BASE64: ${{ secrets.KUBE_CONFIG_BASE64 }}
run: |
test -n "${KUBE_CONFIG_BASE64}"
printf '%s' "${KUBE_CONFIG_BASE64}" | base64 --decode > "${RUNNER_TEMP}/kubeconfig"
chmod 0600 "${RUNNER_TEMP}/kubeconfig"
export KUBECONFIG="${RUNNER_TEMP}/kubeconfig"
"${RUNNER_TEMP}/kubectl" config set-cluster cluster \
--server="${KUBERNETES_API}" \
--tls-server-name="${KUBERNETES_TLS_SERVER_NAME}"
- name: Deploy commit image
env:
COMMIT_SHA: ${{ gitea.sha }}
run: |
export KUBECONFIG="${RUNNER_TEMP}/kubeconfig"
"${RUNNER_TEMP}/kubectl" --namespace default set image \
deployment/30-birthday \
"30-birthday=${IMAGE}:${COMMIT_SHA}"
for attempt in {1..60}; do
IFS='|' read -r generation observed desired updated available unavailable <<< "$(
"${RUNNER_TEMP}/kubectl" --namespace default get deployment/30-birthday \
--output=jsonpath='{.metadata.generation}|{.status.observedGeneration}|{.spec.replicas}|{.status.updatedReplicas}|{.status.availableReplicas}|{.status.unavailableReplicas}'
)"
echo "Rollout ${attempt}/60: generation ${observed}/${generation}, replicas ${updated}/${desired} updated, ${available}/${desired} available"
if [[ "${observed}" == "${generation}" \
&& "${updated}" == "${desired}" \
&& "${available}" == "${desired}" \
&& ( -z "${unavailable}" || "${unavailable}" == "0" ) ]]; then
echo "Deployment successfully rolled out"
exit 0
fi
sleep 5
done
"${RUNNER_TEMP}/kubectl" --namespace default describe deployment/30-birthday
exit 1
+6
View File
@@ -0,0 +1,6 @@
data/invites.json
data/invites.db*
Einladung.pdf
private-assets/
.env
node_modules/
+19
View File
@@ -0,0 +1,19 @@
FROM node:24-alpine
ENV NODE_ENV=production \
PORT=3000 \
HOST=0.0.0.0 \
DATABASE_FILE=/data/invites.db
WORKDIR /app
RUN mkdir -p /data && chown 65532:65532 /data
COPY --chown=65532:65532 package.json server.js database.js ./
COPY --chown=65532:65532 public ./public
COPY --chown=65532:65532 scripts ./scripts
# Root is convenient with rootless Podman (it maps to the invoking host user).
# Kubernetes overrides this to the unprivileged UID/GID 65532.
USER 0:0
EXPOSE 3000
CMD ["node", "server.js"]
+123 -2
View File
@@ -1,3 +1,124 @@
# 30-birthday
# Einladung & RSVP
Geburtstagskarte zum 30. Geburtstag mit Zu- oder Absagendatenbank
Eine responsive Einladung für Vicky und Daniels 30. Geburtstag. Jeder Gast erhält einen persönlichen Link, kann die Originaleinladung als PDF öffnen und eine von drei Antworten abgeben:
- Ich komme nicht
- Ich komme alleine
- Ich komme mit jemandem (inklusive Name der Begleitung)
Der Server benötigt nur Node.js 22.5 oder neuer und keine zusätzlichen Pakete. Als Datenbank dient SQLite über Nodes eingebautes `node:sqlite`-Modul.
## Lokal starten
```bash
ADMIN_KEY='ein-langes-geheimes-passwort' \
ORIGIN='http://localhost:3000' \
npm start
```
Danach läuft die Seite unter `http://localhost:3000`. Die Verwaltung ist unter `http://localhost:3000/admin` erreichbar. Ohne `ADMIN_KEY` oder vorgeschaltetes Authentik bleiben die Admin-Endpunkte aus Sicherheitsgründen deaktiviert.
## Viele Links erstellen und Antworten ansehen
Öffne `/admin`, melde dich mit dem `ADMIN_KEY` an und füge bis zu 200 Namen auf einmal ein – einen Namen pro Zeile. Die erzeugten Links können einzeln oder gesammelt kopiert werden.
Die Übersicht zeigt:
- ob und wann ein Link erstmals geöffnet wurde
- wann er zuletzt geöffnet wurde und wie oft
- die aktuelle Zu- oder Absage
- den Namen einer Begleitperson
- wann die Antwort zuletzt gespeichert beziehungsweise geändert wurde
- die erwartete Gesamtzahl der kommenden Gäste
Über „CSV herunterladen“ lässt sich die vollständige Liste für Excel, Numbers oder LibreOffice exportieren. Eine Öffnung wird gezählt, sobald das JavaScript der RSVP-Seite die Einladung abruft. Manche besonders aktive Link-Vorschau-Bots können theoretisch ebenfalls eine Öffnung erzeugen.
## Persönlichen Einladungslink anlegen
Am einfachsten direkt auf dem Server:
```bash
ORIGIN='https://30.brunner.ninja' npm run invite -- "Max Mustermann"
```
Die Ausgabe enthält den fertigen Link, zum Beispiel:
```text
Max Mustermann: https://30.brunner.ninja/i/zufaelliger-sicherer-token
```
Alternativ können mehrere Links über die Admin-API erzeugt werden:
```bash
curl -X POST 'https://30.brunner.ninja/api/admin/invites' \
-H 'Authorization: Bearer DEIN_ADMIN_KEY' \
-H 'Content-Type: application/json' \
--data '{"names":["Max Mustermann","Erika Musterfrau"]}'
```
## Antworten exportieren
```bash
curl 'https://30.brunner.ninja/api/admin/responses.csv' \
-H 'Authorization: Bearer DEIN_ADMIN_KEY' \
-o zusagen.csv
```
Die CSV-Datei lässt sich direkt mit Excel, Numbers oder LibreOffice öffnen.
## Daten und Betrieb
Antworten werden in `data/invites.db` gespeichert. SQLite läuft im WAL-Modus; das Verzeichnis muss im Produktionsbetrieb beschreibbar und persistent sein. Regelmäßige Backups der Datenbank werden empfohlen.
Für die öffentliche Domain sollte die App hinter einem HTTPS-Reverse-Proxy (beispielsweise Caddy oder nginx) laufen. Benötigte Umgebungsvariablen:
| Variable | Zweck | Standard |
| --- | --- | --- |
| `PORT` | Interner HTTP-Port | `3000` |
| `HOST` | Listen-Adresse | `0.0.0.0` |
| `ORIGIN` | Öffentliche Basis-URL für erzeugte Links | `http://localhost:3000` |
| `DATABASE_FILE` | Pfad zur SQLite-Datenbank | `data/invites.db` |
| `ADMIN_KEY` | Geheimer Schlüssel für lokalen Admin-Zugriff | keiner |
| `TRUST_AUTHENTIK` | Akzeptiert Authentiks verifiziertes Benutzer-Header (`true`) | `false` |
Die RSVP-Seiten liegen unter `/i/<token>`; von dort kann die persönliche PDF-Einladung geöffnet werden.
Die Einladungsgrafik und PDF sind bewusst nicht im Git-Repository oder Container-Image enthalten. In Kubernetes werden sie aus dem Secret `30-birthday-invitation` schreibgeschützt eingebunden und ausschließlich über einen gültigen Gast-Token ausgeliefert. Direkte öffentliche Asset-URLs existieren nicht.
## Kubernetes, Traefik und Authentik
Ein vollständiges Beispiel liegt in `k8s/invitation.yaml`. Es verwendet:
- genau einen Pod (SQLite ist hier bewusst nicht horizontal skaliert)
- einen `128Mi`-`rook-ceph-block`-PVC für `/data/invites.db`
- die Domain `30.brunner.ninja`
- ausschließlich Traefiks EntryPoint `websecure`
- das bestehende `default-authentik@kubernetescrd`-Middleware nur für `/admin*` und `/api/admin/*`
- öffentliche Gast- und Asset-Routen ohne Authentik
- `Recreate`-Updates mit einer kurzen Unterbrechung, damit nur ein SQLite-Prozess den Block-PVC verwendet
Zuerst das Image bauen und in die eigene Registry pushen:
```bash
docker build -t registry.brunner.ninja/feedc0de/30-birthday:latest .
docker push registry.brunner.ninja/feedc0de/30-birthday:latest
```
Die Gitea Action in `.gitea/workflows/container.yml` übernimmt Build, Push und Deployment automatisch. Sie benötigt diese Repository-Secrets:
- `QUAY_USERNAME`: `feedc0de+birthday30`
- `QUAY_TOKEN`: Token dieses Robot-Accounts
- `KUBE_CONFIG_BASE64`: Kubeconfig des eingeschränkten `30-birthday-deployer`-ServiceAccounts
Workload und CI-Berechtigungen werden einmalig mit einem Cluster-Administrator angelegt:
```bash
./k8s/create-invitation-secret.sh
kubectl apply -f k8s/invitation.yaml
kubectl apply -f k8s/ci-deployer.yaml
./k8s/create-ci-kubeconfig.sh
```
Die Action baut `registry.brunner.ninja/feedc0de/30-birthday`, versieht jedes Image mit dem unveränderlichen Commit-SHA und aktualisiert auf `main` ausschließlich das bestehende `default/30-birthday`-Deployment. Ihre Kubernetes-Rolle kann keine Secrets oder anderen Deployments lesen oder verändern.
Der Cluster stellt Authentiks `/outpost.goauthentik.io/*` bereits zentral bereit. Der App-Service ist nur als `ClusterIP` erreichbar; daher kann ein externer Client Authentiks Identitätsheader nicht direkt am App-Port einschleusen.
+1
View File
@@ -0,0 +1 @@
+122
View File
@@ -0,0 +1,122 @@
const fs = require("node:fs");
const path = require("node:path");
const { DatabaseSync } = require("node:sqlite");
const DATABASE_FILE = process.env.DATABASE_FILE || process.env.DATA_FILE || path.join(__dirname, "data", "invites.db");
fs.mkdirSync(path.dirname(DATABASE_FILE), { recursive: true });
const database = new DatabaseSync(DATABASE_FILE);
database.exec("PRAGMA journal_mode = WAL");
database.exec("PRAGMA busy_timeout = 5000");
database.exec("PRAGMA foreign_keys = ON");
database.exec(`
CREATE TABLE IF NOT EXISTS invites (
token TEXT PRIMARY KEY,
guest_name TEXT NOT NULL,
answer TEXT CHECK (answer IN ('not_coming', 'alone', 'with_someone')),
companion_name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
first_opened_at TEXT,
last_opened_at TEXT,
open_count INTEGER NOT NULL DEFAULT 0,
responded_at TEXT
) STRICT
`);
const selectColumns = `
token,
guest_name AS guestName,
answer,
companion_name AS companionName,
created_at AS createdAt,
first_opened_at AS firstOpenedAt,
last_opened_at AS lastOpenedAt,
open_count AS openCount,
responded_at AS respondedAt
`;
const selectOne = database.prepare(`SELECT ${selectColumns} FROM invites WHERE token = ?`);
const selectAll = database.prepare(`SELECT ${selectColumns} FROM invites ORDER BY created_at ASC`);
const insertOne = database.prepare(`
INSERT INTO invites (token, guest_name, created_at)
VALUES (?, ?, ?)
`);
function migrateLegacyJson() {
if (process.env.DATABASE_FILE || process.env.DATA_FILE) return;
const legacyFile = path.join(__dirname, "data", "invites.json");
const count = database.prepare("SELECT COUNT(*) AS count FROM invites").get().count;
if (count || !fs.existsSync(legacyFile)) return;
try {
const legacy = JSON.parse(fs.readFileSync(legacyFile, "utf8"));
const insertLegacy = database.prepare(`
INSERT OR IGNORE INTO invites (
token, guest_name, answer, companion_name, created_at,
first_opened_at, last_opened_at, open_count, responded_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
database.exec("BEGIN IMMEDIATE");
for (const invite of legacy.invites || []) {
insertLegacy.run(
invite.token,
invite.guestName || "",
invite.answer || null,
invite.companionName || "",
invite.createdAt || new Date().toISOString(),
invite.firstOpenedAt || null,
invite.lastOpenedAt || null,
Number(invite.openCount || 0),
invite.respondedAt || null
);
}
database.exec("COMMIT");
} catch (error) {
try { database.exec("ROLLBACK"); } catch {}
console.warn("Legacy JSON data could not be imported:", error.message);
}
}
migrateLegacyJson();
function getInvite(token) {
return selectOne.get(token) || null;
}
function listInvites() {
return selectAll.all();
}
function recordOpen(token, openedAt) {
const result = database.prepare(`
UPDATE invites
SET first_opened_at = COALESCE(first_opened_at, ?),
last_opened_at = ?,
open_count = open_count + 1
WHERE token = ?
`).run(openedAt, openedAt, token);
return result.changes ? getInvite(token) : null;
}
function saveRsvp(token, answer, companionName, respondedAt) {
const result = database.prepare(`
UPDATE invites
SET answer = ?, companion_name = ?, responded_at = ?
WHERE token = ?
`).run(answer, companionName, respondedAt, token);
return result.changes ? getInvite(token) : null;
}
function createInvites(entries) {
const createdAt = new Date().toISOString();
database.exec("BEGIN IMMEDIATE");
try {
for (const entry of entries) insertOne.run(entry.token, entry.guestName, createdAt);
database.exec("COMMIT");
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
return entries.map(entry => getInvite(entry.token));
}
module.exports = { DATABASE_FILE, createInvites, getInvite, listInvites, recordOpen, saveRsvp };
+49
View File
@@ -0,0 +1,49 @@
# Bootstrap once with an administrator identity. CI can update only the
# existing 30-birthday Deployment and cannot access Kubernetes Secrets.
apiVersion: v1
kind: ServiceAccount
metadata:
name: 30-birthday-deployer
namespace: default
automountServiceAccountToken: false
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: 30-birthday-deployer
namespace: default
rules:
- apiGroups:
- apps
resources:
- deployments
resourceNames:
- 30-birthday
verbs:
- get
- patch
- update
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: 30-birthday-deployer
namespace: default
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: 30-birthday-deployer
subjects:
- kind: ServiceAccount
name: 30-birthday-deployer
namespace: default
---
apiVersion: v1
kind: Secret
metadata:
name: 30-birthday-deployer-token
namespace: default
annotations:
kubernetes.io/service-account.name: 30-birthday-deployer
type: kubernetes.io/service-account-token
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
namespace=default
secret=30-birthday-deployer-token
workdir=$(mktemp --directory)
trap 'rm -rf "${workdir}"' EXIT
server=$(kubectl config view --minify --output 'jsonpath={.clusters[0].cluster.server}')
kubectl --namespace "${namespace}" get secret "${secret}" --output 'jsonpath={.data.ca\.crt}' \
| base64 --decode > "${workdir}/ca.crt"
token=$(kubectl --namespace "${namespace}" get secret "${secret}" --output 'jsonpath={.data.token}' \
| base64 --decode)
export KUBECONFIG="${workdir}/config"
kubectl config set-cluster cluster --server "${server}" --certificate-authority "${workdir}/ca.crt" --embed-certs=true \
> /dev/null
kubectl config set-credentials 30-birthday-deployer --token "${token}" > /dev/null
kubectl config set-context 30-birthday --cluster cluster --user 30-birthday-deployer --namespace "${namespace}" \
> /dev/null
kubectl config use-context 30-birthday > /dev/null
base64 --wrap=0 "${KUBECONFIG}"
printf '\n'
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
kubectl --namespace default create secret generic 30-birthday-invitation \
--from-file="Einladung.pdf=${repo_root}/Einladung.pdf" \
--from-file="einladung.jpg=${repo_root}/private-assets/einladung.jpg" \
--dry-run=client \
--output=yaml \
| kubectl apply --server-side --field-manager=30-birthday-secret-bootstrap --filename=-
+171
View File
@@ -0,0 +1,171 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: 30-birthday-data
namespace: default
spec:
accessModes:
- ReadWriteOnce
storageClassName: rook-ceph-block
resources:
requests:
storage: 128Mi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: 30-birthday
namespace: default
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: 30-birthday
template:
metadata:
labels:
app: 30-birthday
spec:
automountServiceAccountToken: false
securityContext:
fsGroup: 65532
fsGroupChangePolicy: OnRootMismatch
containers:
- name: 30-birthday
image: registry.brunner.ninja/feedc0de/30-birthday:latest
imagePullPolicy: Always
env:
- name: ORIGIN
value: https://30.brunner.ninja
- name: DATABASE_FILE
value: /data/invites.db
- name: TRUST_AUTHENTIK
value: "true"
- name: INVITATION_PDF
value: /private/Einladung.pdf
- name: INVITATION_IMAGE
value: /private/einladung.jpg
ports:
- name: http
containerPort: 3000
volumeMounts:
- name: data
mountPath: /data
- name: invitation
mountPath: /private
readOnly: true
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 2
periodSeconds: 5
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 15
resources:
requests:
cpu: 10m
memory: 32Mi
ephemeral-storage: 32Mi
limits:
cpu: 250m
memory: 192Mi
ephemeral-storage: 128Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
volumes:
- name: data
persistentVolumeClaim:
claimName: 30-birthday-data
- name: invitation
secret:
secretName: 30-birthday-invitation
---
apiVersion: v1
kind: Service
metadata:
name: 30-birthday
namespace: default
spec:
selector:
app: 30-birthday
ports:
- name: http
port: 80
protocol: TCP
targetPort: http
---
# Guest pages, invitation assets, and guest RSVP APIs stay public.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: 30-birthday-public
namespace: default
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.priority: "10"
spec:
ingressClassName: traefik
tls:
- hosts:
- 30.brunner.ninja
secretName: brunner-ninja-tls
rules:
- host: 30.brunner.ninja
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: 30-birthday
port:
name: http
---
# Longer admin prefixes win over the public catch-all and require Authentik.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: 30-birthday-admin
namespace: default
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.middlewares: default-authentik@kubernetescrd
traefik.ingress.kubernetes.io/router.priority: "100"
spec:
ingressClassName: traefik
tls:
- hosts:
- 30.brunner.ninja
secretName: brunner-ninja-tls
rules:
- host: 30.brunner.ninja
http:
paths:
- path: /admin
pathType: Prefix
backend:
service:
name: 30-birthday
port:
name: http
- path: /api/admin/
pathType: Prefix
backend:
service:
name: 30-birthday
port:
name: http
+15
View File
@@ -0,0 +1,15 @@
{
"name": "vicky-daniel-rsvp",
"version": "1.0.0",
"private": true,
"description": "Responsive invitation and token-based RSVP page",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"invite": "node scripts/create-invite.js",
"test": "node --test"
},
"engines": {
"node": ">=22.5"
}
}
+55
View File
@@ -0,0 +1,55 @@
.admin-body { background-attachment: fixed; font-family: system-ui, sans-serif; }
.admin-shell { width: min(1240px, calc(100% - 40px)); margin: 0 auto; padding: 48px 0 80px; }
.admin-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; margin-bottom: 30px; }
.admin-header .hello { font-family: "Segoe Script", "Bradley Hand", cursive; }
.admin-header h1 { margin: 0; color: var(--ink); font: 400 clamp(2.4rem, 6vw, 4rem)/1 Georgia, serif; }
.header-actions, .created-actions { display: flex; gap: .6rem; }
.admin-card { padding: 30px; background: rgba(255,253,249,.94); border: 1px solid rgba(0,104,128,.1); border-radius: 22px; box-shadow: 0 16px 45px rgba(15,78,88,.1); }
.admin-card h2 { font-size: 1.8rem; }
.admin-card label { display: block; margin: 1.2rem 0 .4rem; color: var(--ink-dark); font-weight: 700; }
.admin-card input, .admin-card textarea { width: 100%; padding: .85rem 1rem; color: var(--ink-dark); background: white; border: 1.5px solid var(--line); border-radius: 12px; outline: none; resize: vertical; }
.admin-card input:focus, .admin-card textarea:focus { border-color: var(--turquoise); box-shadow: 0 0 0 3px rgba(24,185,187,.15); }
.login-card { width: min(480px, 100%); margin: 10vh auto 0; text-align: center; }
.login-card .state-icon { margin-bottom: 1rem; }
.login-card label { text-align: left; }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 22px; }
.stats div { padding: 22px; background: var(--ink); border-radius: 18px; box-shadow: 0 10px 30px rgba(0,104,128,.12); }
.stats div:nth-child(2) { background: #008ea0; }
.stats div:nth-child(3) { background: var(--turquoise); }
.stats div:nth-child(4) { background: var(--mint); }
.stats strong, .stats span { display: block; color: white; }
.stats strong { font: 400 2.2rem/1 Georgia, serif; }
.stats span { margin-top: .4rem; font-size: .74rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
.admin-grid { display: grid; grid-template-columns: minmax(300px, 1fr) minmax(300px, 1fr); gap: 22px; margin-bottom: 22px; }
.admin-card .eyebrow { margin: 0 0 .7rem; }
.admin-card p { color: #56767d; }
button.small { width: auto; margin: 0; padding: .7rem 1rem; font-size: .78rem; }
.links-card { min-height: 380px; }
.empty { padding: 2.5rem 1rem; color: #789096; text-align: center; }
#created-list { overflow: auto; max-height: 270px; margin: 1rem 0 0; padding: 0; list-style: none; }
#created-list li { display: grid; grid-template-columns: minmax(100px, .7fr) minmax(180px, 1.3fr) auto; gap: 10px; align-items: center; padding: .65rem 0; border-bottom: 1px solid var(--line); }
#created-list a { overflow: hidden; color: var(--ink); font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; }
#created-list button { width: auto; margin: 0; padding: .45rem .7rem; font-size: .7rem; }
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; }
.tracking-note { margin: .8rem 0 1.2rem; font-size: .78rem; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: .82rem; white-space: nowrap; }
th, td { padding: .85rem .7rem; border-bottom: 1px solid var(--line); text-align: left; }
th { color: #68848a; font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; }
td:first-child { color: var(--ink-dark); font-weight: 700; }
.status { display: inline-block; padding: .32rem .55rem; color: #5e7479; background: #edf2f1; border-radius: 999px; font-size: .69rem; font-weight: 700; }
.status.alone, .status.with_someone { color: #167039; background: #e6f7e8; }
.status.not_coming { color: #9a462d; background: #fcedea; }
.link-button { width: auto; margin: 0; padding: .42rem .65rem; font-size: .68rem; }
code { padding: .1em .3em; background: #edf5f3; border-radius: 4px; }
@media (max-width: 760px) {
.admin-shell { width: min(100% - 24px, 620px); padding-top: 24px; }
.admin-header { align-items: flex-start; }
.stats { grid-template-columns: 1fr 1fr; }
.admin-grid { grid-template-columns: 1fr; }
.admin-card { padding: 22px 18px; }
.section-heading { align-items: flex-start; flex-direction: column; }
#created-list li { grid-template-columns: 1fr auto; }
#created-list a { grid-column: 1 / -1; }
}
+102
View File
@@ -0,0 +1,102 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#f8f4ef">
<meta name="robots" content="noindex, nofollow">
<title>Einladungen verwalten · Vicky &amp; Daniel</title>
<link rel="stylesheet" href="/styles.css">
<link rel="stylesheet" href="/admin.css">
<script src="/admin.js" defer></script>
</head>
<body class="admin-body">
<main class="admin-shell">
<header class="admin-header">
<div>
<p class="hello">Vicky &amp; Daniel</p>
<h1>Gästeliste</h1>
</div>
<div class="header-actions" id="header-actions" hidden>
<button id="refresh-button" class="small secondary" type="button">Aktualisieren</button>
<button id="logout-button" class="small secondary" type="button">Abmelden</button>
</div>
</header>
<section id="login-panel" class="admin-card login-card">
<div class="state-icon" aria-hidden="true">↗</div>
<h2>Admin-Zugang</h2>
<p>Gib den Admin-Schlüssel ein, den du beim Deployment als <code>ADMIN_KEY</code> gesetzt hast.</p>
<form id="login-form">
<label for="admin-key">Admin-Schlüssel</label>
<input id="admin-key" type="password" required autocomplete="current-password">
<p id="login-error" class="form-error" role="alert" hidden></p>
<button type="submit">Anmelden</button>
</form>
</section>
<div id="dashboard" hidden>
<section class="stats" aria-label="Zusammenfassung">
<div><strong id="stat-total">0</strong><span>Einladungen</span></div>
<div><strong id="stat-opened">0</strong><span>geöffnet</span></div>
<div><strong id="stat-responded">0</strong><span>geantwortet</span></div>
<div><strong id="stat-attending">0</strong><span>Gäste kommen</span></div>
</section>
<section class="admin-grid">
<div class="admin-card create-card">
<p class="eyebrow">Neue Links</p>
<h2>Einladungen erstellen</h2>
<p>Ein Name pro Zeile, maximal 200 auf einmal.</p>
<form id="create-form">
<label for="guest-names">Namen</label>
<textarea id="guest-names" rows="8" required placeholder="Max Mustermann&#10;Erika Musterfrau"></textarea>
<p id="create-error" class="form-error" role="alert" hidden></p>
<button id="create-button" type="submit">Persönliche Links erstellen</button>
</form>
</div>
<div class="admin-card links-card">
<p class="eyebrow">Gerade erstellt</p>
<h2>Links versenden</h2>
<div id="created-empty" class="empty">Neu erstellte Links erscheinen hier.</div>
<div id="created-links" hidden>
<div class="created-actions">
<button id="copy-all-button" class="small secondary" type="button">Alle kopieren</button>
</div>
<ul id="created-list"></ul>
</div>
</div>
</section>
<section class="admin-card response-card">
<div class="section-heading">
<div>
<p class="eyebrow">Live-Übersicht</p>
<h2>Antworten</h2>
</div>
<button id="csv-button" class="small" type="button">CSV herunterladen</button>
</div>
<p class="tracking-note">„Geöffnet“ wird gezählt, sobald die RSVP-Seite im Browser geladen wird. Automatische Link-Vorschauen können in seltenen Fällen ebenfalls als Öffnung erscheinen.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Gast</th>
<th>Status</th>
<th>Begleitung</th>
<th>Erstmals geöffnet</th>
<th>Zuletzt geöffnet</th>
<th>Zuletzt gespeichert</th>
<th>Link</th>
</tr>
</thead>
<tbody id="response-body"></tbody>
</table>
<p id="table-empty" class="empty">Noch keine Einladungen erstellt.</p>
</div>
</section>
</div>
</main>
</body>
</html>
+194
View File
@@ -0,0 +1,194 @@
const loginPanel = document.querySelector("#login-panel");
const loginForm = document.querySelector("#login-form");
const keyInput = document.querySelector("#admin-key");
const loginError = document.querySelector("#login-error");
const dashboard = document.querySelector("#dashboard");
const headerActions = document.querySelector("#header-actions");
const createForm = document.querySelector("#create-form");
const namesInput = document.querySelector("#guest-names");
const createError = document.querySelector("#create-error");
const createButton = document.querySelector("#create-button");
const createdEmpty = document.querySelector("#created-empty");
const createdLinks = document.querySelector("#created-links");
const createdList = document.querySelector("#created-list");
const responseBody = document.querySelector("#response-body");
const tableEmpty = document.querySelector("#table-empty");
let adminKey = sessionStorage.getItem("inviteAdminKey") || "";
let lastCreated = [];
const answerLabels = {
not_coming: "Kommt nicht",
alone: "Kommt alleine",
with_someone: "Mit Begleitung"
};
function authHeaders(extra = {}) {
return { ...extra, Authorization: `Bearer ${adminKey}` };
}
function formatDate(value) {
if (!value) return "—";
return new Intl.DateTimeFormat("de-AT", {
dateStyle: "short",
timeStyle: "short",
timeZone: "Europe/Vienna"
}).format(new Date(value));
}
async function copyText(text, button) {
await navigator.clipboard.writeText(text);
const old = button.textContent;
button.textContent = "Kopiert!";
setTimeout(() => { button.textContent = old; }, 1200);
}
function cell(row, text) {
const td = document.createElement("td");
td.textContent = text;
row.append(td);
return td;
}
function renderTable(invites) {
responseBody.replaceChildren();
tableEmpty.hidden = invites.length > 0;
for (const invite of [...invites].reverse()) {
const row = document.createElement("tr");
cell(row, invite.guestName);
const statusCell = document.createElement("td");
const status = document.createElement("span");
status.className = `status ${invite.answer || ""}`;
status.textContent = answerLabels[invite.answer] || (invite.firstOpenedAt ? "Geöffnet" : "Noch offen");
statusCell.append(status);
row.append(statusCell);
cell(row, invite.companionName || "—");
cell(row, formatDate(invite.firstOpenedAt));
cell(row, invite.lastOpenedAt ? `${formatDate(invite.lastOpenedAt)} (${invite.openCount}×)` : "—");
cell(row, formatDate(invite.lastSavedAt));
const linkCell = document.createElement("td");
const copyButton = document.createElement("button");
copyButton.type = "button";
copyButton.className = "link-button";
copyButton.textContent = "Link kopieren";
copyButton.addEventListener("click", () => copyText(invite.link, copyButton));
linkCell.append(copyButton);
row.append(linkCell);
responseBody.append(row);
}
}
function renderCreated(invites) {
lastCreated = invites;
createdList.replaceChildren();
createdEmpty.hidden = true;
createdLinks.hidden = false;
for (const invite of invites) {
const item = document.createElement("li");
const name = document.createElement("strong");
name.textContent = invite.name;
const link = document.createElement("a");
link.href = invite.link;
link.target = "_blank";
link.rel = "noopener";
link.textContent = invite.link;
const button = document.createElement("button");
button.type = "button";
button.textContent = "Kopieren";
button.addEventListener("click", () => copyText(invite.link, button));
item.append(name, link, button);
createdList.append(item);
}
}
async function loadDashboard() {
const response = await fetch("/api/admin/invites", { headers: authHeaders() });
if (!response.ok) throw new Error("Der Admin-Schlüssel ist nicht gültig.");
const data = await response.json();
document.querySelector("#stat-total").textContent = data.summary.total;
document.querySelector("#stat-opened").textContent = data.summary.opened;
document.querySelector("#stat-responded").textContent = data.summary.responded;
document.querySelector("#stat-attending").textContent = data.summary.attending;
renderTable(data.invites);
loginPanel.hidden = true;
dashboard.hidden = false;
headerActions.hidden = false;
}
loginForm.addEventListener("submit", async event => {
event.preventDefault();
loginError.hidden = true;
adminKey = keyInput.value;
try {
await loadDashboard();
sessionStorage.setItem("inviteAdminKey", adminKey);
} catch (error) {
loginError.textContent = error.message;
loginError.hidden = false;
}
});
createForm.addEventListener("submit", async event => {
event.preventDefault();
createError.hidden = true;
const names = namesInput.value.split(/\r?\n/).map(name => name.trim()).filter(Boolean);
createButton.disabled = true;
createButton.textContent = "Links werden erstellt …";
try {
const response = await fetch("/api/admin/invites", {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ names })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Links konnten nicht erstellt werden.");
renderCreated(data.invites);
namesInput.value = "";
await loadDashboard();
} catch (error) {
createError.textContent = error.message;
createError.hidden = false;
} finally {
createButton.disabled = false;
createButton.textContent = "Persönliche Links erstellen";
}
});
document.querySelector("#copy-all-button").addEventListener("click", event => {
const text = lastCreated.map(invite => `${invite.name}: ${invite.link}`).join("\n");
copyText(text, event.currentTarget);
});
document.querySelector("#refresh-button").addEventListener("click", async event => {
event.currentTarget.disabled = true;
try { await loadDashboard(); } finally { event.currentTarget.disabled = false; }
});
document.querySelector("#logout-button").addEventListener("click", () => {
sessionStorage.removeItem("inviteAdminKey");
window.location.reload();
});
document.querySelector("#csv-button").addEventListener("click", async event => {
event.currentTarget.disabled = true;
try {
const response = await fetch("/api/admin/responses.csv", { headers: authHeaders() });
if (!response.ok) throw new Error("CSV konnte nicht geladen werden.");
const blob = await response.blob();
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "zusagen.csv";
link.click();
URL.revokeObjectURL(link.href);
} finally {
event.currentTarget.disabled = false;
}
});
loadDashboard().catch(() => {
sessionStorage.removeItem("inviteAdminKey");
adminKey = "";
loginPanel.hidden = false;
dashboard.hidden = true;
headerActions.hidden = true;
});
+134
View File
@@ -0,0 +1,134 @@
const loading = document.querySelector("#loading");
const form = document.querySelector("#rsvp-form");
const invalidState = document.querySelector("#invalid-state");
const invalidMessage = document.querySelector("#invalid-message");
const successState = document.querySelector("#success-state");
const greeting = document.querySelector("#guest-greeting");
const companionWrap = document.querySelector("#companion-wrap");
const companionInput = document.querySelector("#companion-name");
const errorBox = document.querySelector("#form-error");
const submitButton = document.querySelector("#submit-button");
const updateNote = document.querySelector("#update-note");
const changeButton = document.querySelector("#change-button");
const successCopy = document.querySelector("#success-copy");
const inviteCard = document.querySelector("#invite-card");
const inviteImage = document.querySelector("#invite-image");
const pdfLink = document.querySelector("#pdf-link");
const inviteColumn = document.querySelector(".invite-column");
const tokenMatch = window.location.pathname.match(/^\/i\/([A-Za-z0-9_-]+)\/?$/);
const token = tokenMatch?.[1];
function show(element) { element.hidden = false; }
function hide(element) { element.hidden = true; }
function toggleCompanion() {
const selected = form.elements.answer.value;
companionWrap.hidden = selected !== "with_someone";
companionInput.required = selected === "with_someone";
}
function populate(invite) {
const assetBase = `/api/invites/${encodeURIComponent(token)}/assets`;
inviteImage.src = `${assetBase}/image`;
inviteCard.href = `${assetBase}/pdf`;
pdfLink.href = `${assetBase}/pdf`;
greeting.textContent = invite.guestName ? `Hallo ${invite.guestName}!` : "Hallo!";
if (invite.answer) {
const radio = form.querySelector(`[name="answer"][value="${invite.answer}"]`);
if (radio) radio.checked = true;
companionInput.value = invite.companionName || "";
show(updateNote);
submitButton.textContent = "Antwort aktualisieren";
}
toggleCompanion();
}
async function loadInvitation() {
if (!token) {
hide(inviteColumn);
hide(loading);
show(invalidState);
invalidMessage.textContent = "Bitte öffne den persönlichen Link aus deiner Einladung.";
return;
}
try {
const response = await fetch(`/api/invites/${encodeURIComponent(token)}`);
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Einladung nicht gefunden.");
populate(data);
hide(loading);
show(form);
} catch (error) {
hide(inviteColumn);
hide(loading);
show(invalidState);
invalidMessage.textContent = error.message;
}
}
form.addEventListener("change", event => {
if (event.target.name === "answer") {
hide(errorBox);
toggleCompanion();
if (!companionWrap.hidden) companionInput.focus();
}
});
form.addEventListener("submit", async event => {
event.preventDefault();
hide(errorBox);
const answer = form.elements.answer.value;
const companionName = companionInput.value.trim();
if (!answer) {
errorBox.textContent = "Bitte wähle eine Antwort aus.";
show(errorBox);
return;
}
if (answer === "with_someone" && !companionName) {
errorBox.textContent = "Bitte verrate uns den Namen deiner Begleitung.";
show(errorBox);
companionInput.focus();
return;
}
submitButton.disabled = true;
submitButton.textContent = "Wird gespeichert …";
try {
const response = await fetch(`/api/invites/${encodeURIComponent(token)}/rsvp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ answer, companionName })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Antwort konnte nicht gespeichert werden.");
const messages = {
not_coming: "Schade, dass du diesmal nicht dabei sein kannst. Danke fürs Bescheidgeben!",
alone: "Wunderbar – wir freuen uns darauf, mit dir zu feiern!",
with_someone: `Wunderbar – wir freuen uns auf euch${data.companionName ? ` und besonders auch auf ${data.companionName}` : ""}!`
};
successCopy.textContent = messages[data.answer];
hide(form);
show(successState);
successState.focus();
} catch (error) {
errorBox.textContent = error.message;
show(errorBox);
} finally {
submitButton.disabled = false;
submitButton.textContent = "Antwort aktualisieren";
}
});
changeButton.addEventListener("click", () => {
hide(successState);
show(form);
show(updateNote);
submitButton.textContent = "Antwort aktualisieren";
form.querySelector("input:checked")?.focus();
});
loadInvitation();
+96
View File
@@ -0,0 +1,96 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#f8f4ef">
<meta name="description" content="Einladung zum 30. Geburtstag von Vicky und Daniel">
<title>Vicky &amp; Daniel werden 30</title>
<link rel="stylesheet" href="/styles.css">
<script src="/app.js" defer></script>
</head>
<body>
<a class="skip-link" href="#antwort">Zur Antwort springen</a>
<main class="page-shell">
<section class="invite-column" aria-labelledby="page-title">
<h1 id="page-title" class="visually-hidden">Einladung zum 30. Geburtstag von Vicky und Daniel</h1>
<a id="invite-card" class="invite-card" target="_blank" rel="noopener" aria-label="Einladung als PDF öffnen">
<img id="invite-image" alt="Persönliche Einladung zum 30. Geburtstag von Vicky und Daniel.">
</a>
<a id="pdf-link" class="pdf-link" download="Einladung-Vicky-und-Daniel.pdf">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12m0 0 5-5m-5 5-5-5M5 19h14"/></svg>
Einladung als PDF
</a>
</section>
<section id="antwort" class="rsvp-card" aria-labelledby="rsvp-title">
<div class="eyebrow">Persönliche Einladung</div>
<div id="loading" class="loading" role="status">
<span></span><span></span><span></span>
<span class="visually-hidden">Einladung wird geladen</span>
</div>
<div id="invalid-state" class="state-message" hidden>
<div class="state-icon" aria-hidden="true">?</div>
<h2>Link nicht gefunden</h2>
<p id="invalid-message">Dieser Einladungslink ist leider nicht gültig.</p>
</div>
<form id="rsvp-form" hidden novalidate>
<p class="hello" id="guest-greeting">Hallo!</p>
<h2 id="rsvp-title">Bist du dabei?</h2>
<p class="intro">Bitte gib uns kurz Bescheid, damit wir Essen und Getränke planen können.</p>
<fieldset>
<legend class="visually-hidden">Deine Antwort</legend>
<label class="choice">
<input type="radio" name="answer" value="not_coming">
<span class="choice-mark" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="m7 7 10 10M17 7 7 17"/></svg>
</span>
<span><strong>Ich komme nicht</strong><small>Schade, beim nächsten Mal!</small></span>
</label>
<label class="choice">
<input type="radio" name="answer" value="alone">
<span class="choice-mark" aria-hidden="true">
<svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="3.5"/><path d="M5.5 20c.7-4 3-6 6.5-6s5.8 2 6.5 6"/></svg>
</span>
<span><strong>Ich komme alleine</strong><small>Wir freuen uns auf dich!</small></span>
</label>
<label class="choice">
<input type="radio" name="answer" value="with_someone">
<span class="choice-mark" aria-hidden="true">
<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.3"/><path d="M3 20c.6-4 2.8-6 6-6s5.4 2 6 6m0-5.5c3 0 4.8 1.8 5 5.5"/></svg>
</span>
<span><strong>Ich komme mit jemandem</strong><small>Zu zweit feiert es sich schöner.</small></span>
</label>
</fieldset>
<div id="companion-wrap" class="companion" hidden>
<label for="companion-name">Wie heißt deine Begleitung?</label>
<input id="companion-name" name="companionName" type="text" maxlength="100" autocomplete="name" placeholder="Vorname &amp; Nachname">
</div>
<p id="form-error" class="form-error" role="alert" hidden></p>
<button id="submit-button" type="submit">Antwort senden</button>
<p class="update-note" id="update-note" hidden>Du hast schon geantwortet. Du kannst deine Auswahl jederzeit ändern.</p>
</form>
<div id="success-state" class="state-message success" hidden aria-live="polite">
<div class="state-icon" aria-hidden="true">✓</div>
<p class="hello">Danke!</p>
<h2 id="success-title">Deine Antwort ist gespeichert.</h2>
<p id="success-copy"></p>
<button id="change-button" type="button" class="secondary">Antwort ändern</button>
</div>
<div class="mini-confetti" aria-hidden="true">
<i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
</div>
</section>
</main>
<footer>Vicky &amp; Daniel · 30 Jahre</footer>
</body>
</html>
+273
View File
@@ -0,0 +1,273 @@
:root {
--paper: #f8f4ef;
--white: #fffdf9;
--ink: #006880;
--ink-dark: #07556b;
--turquoise: #18b9bb;
--mint: #53c85a;
--orange: #f08b45;
--line: #d8e9e6;
--shadow: 0 24px 65px rgba(15, 78, 88, .14);
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { scroll-behavior: smooth; }
body {
margin: 0;
min-height: 100vh;
color: var(--ink-dark);
background-color: var(--paper);
background-image:
radial-gradient(circle at 4% 10%, rgba(27, 185, 187, .18) 0 4px, transparent 5px),
radial-gradient(circle at 93% 21%, rgba(82, 200, 90, .20) 0 5px, transparent 6px),
radial-gradient(circle at 13% 82%, rgba(240, 139, 69, .14) 0 3px, transparent 4px),
radial-gradient(circle at 85% 89%, rgba(0, 151, 178, .15) 0 3px, transparent 4px);
font-family: Georgia, "Times New Roman", serif;
line-height: 1.5;
}
button, input { font: inherit; }
.skip-link {
position: fixed;
z-index: 20;
top: .75rem;
left: .75rem;
padding: .65rem 1rem;
color: white;
background: var(--ink);
border-radius: 999px;
transform: translateY(-200%);
}
.skip-link:focus { transform: translateY(0); }
.page-shell {
width: min(1120px, calc(100% - 40px));
margin: 0 auto;
min-height: calc(100vh - 60px);
padding: 64px 0 36px;
display: grid;
grid-template-columns: minmax(300px, 500px) minmax(340px, 470px);
gap: clamp(40px, 7vw, 100px);
align-items: center;
justify-content: center;
}
.invite-column { min-width: 0; text-align: center; }
.invite-card {
display: block;
overflow: hidden;
border-radius: 5px;
box-shadow: var(--shadow), 0 0 0 1px rgba(0, 93, 111, .06);
transition: transform .25s ease, box-shadow .25s ease;
}
.invite-card:hover {
transform: translateY(-4px) rotate(-.15deg);
box-shadow: 0 30px 78px rgba(15, 78, 88, .19);
}
.invite-card img { display: block; width: 100%; height: auto; }
.pdf-link {
display: inline-flex;
align-items: center;
gap: .5rem;
margin-top: 1.2rem;
color: var(--ink);
font: 700 .75rem/1.2 system-ui, sans-serif;
letter-spacing: .09em;
text-decoration: none;
text-transform: uppercase;
}
.pdf-link:hover { text-decoration: underline; }
.pdf-link svg { width: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; }
.rsvp-card {
position: relative;
overflow: hidden;
min-height: 540px;
padding: clamp(30px, 5vw, 54px);
background: rgba(255, 253, 249, .92);
border: 1px solid rgba(0, 104, 128, .1);
border-radius: 28px;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.eyebrow {
margin-bottom: 1.15rem;
color: var(--turquoise);
font: 750 .74rem/1 system-ui, sans-serif;
letter-spacing: .26em;
}
.hello {
margin: 0 0 .15rem;
color: var(--mint);
font-family: "Segoe Script", "Bradley Hand", cursive;
font-size: clamp(1.5rem, 4vw, 2rem);
font-weight: 700;
}
h2 {
margin: 0;
color: var(--ink);
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 400;
line-height: 1.05;
}
.intro { margin: 1rem 0 1.6rem; font-size: 1rem; }
fieldset { display: grid; gap: .7rem; margin: 0; padding: 0; border: 0; }
.choice {
position: relative;
display: flex;
align-items: center;
gap: .9rem;
padding: .9rem 1rem;
cursor: pointer;
background: var(--white);
border: 1.5px solid var(--line);
border-radius: 15px;
transition: border-color .16s, background .16s, transform .16s;
}
.choice:hover { border-color: #8ad4ce; transform: translateX(2px); }
.choice:has(input:checked) { background: #effbf8; border-color: var(--turquoise); }
.choice:has(input:focus-visible) { outline: 3px solid rgba(24, 185, 187, .25); outline-offset: 2px; }
.choice input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.choice-mark {
display: grid;
flex: 0 0 42px;
width: 42px;
height: 42px;
place-items: center;
color: var(--ink);
background: #edf8f6;
border-radius: 50%;
}
.choice:has(input:checked) .choice-mark { color: white; background: var(--turquoise); }
.choice-mark svg { width: 23px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.7; }
.choice strong, .choice small { display: block; }
.choice strong { color: var(--ink-dark); font-size: .98rem; }
.choice small { margin-top: .1rem; color: #618087; font-family: system-ui, sans-serif; font-size: .74rem; }
.companion { margin-top: 1.15rem; }
.companion label { display: block; margin-bottom: .4rem; font-weight: 700; }
.companion input {
width: 100%;
padding: .85rem 1rem;
color: var(--ink-dark);
background: white;
border: 1.5px solid var(--line);
border-radius: 12px;
outline: none;
}
.companion input:focus { border-color: var(--turquoise); box-shadow: 0 0 0 3px rgba(24, 185, 187, .15); }
button {
width: 100%;
margin-top: 1.35rem;
padding: .95rem 1.2rem;
cursor: pointer;
color: white;
background: var(--ink);
border: 0;
border-radius: 999px;
font-family: system-ui, sans-serif;
font-size: .94rem;
font-weight: 750;
letter-spacing: .02em;
transition: background .18s, transform .18s;
}
button:hover { background: var(--turquoise); transform: translateY(-1px); }
button:disabled { cursor: wait; opacity: .65; transform: none; }
button.secondary { color: var(--ink); background: transparent; border: 1.5px solid var(--ink); }
button.secondary:hover { color: white; background: var(--ink); }
.form-error { margin: .8rem 0 -.5rem; color: #a43f28; font: 650 .84rem/1.4 system-ui, sans-serif; }
.update-note { margin: .8rem 0 0; color: #6a8488; font: .76rem/1.4 system-ui, sans-serif; text-align: center; }
.state-message { padding-top: 4rem; text-align: center; }
.state-message p:not(.hello) { margin: 1.1rem auto; max-width: 29rem; }
.state-icon {
display: grid;
width: 64px;
height: 64px;
margin: 0 auto 1.6rem;
place-items: center;
color: white;
background: var(--orange);
border-radius: 50%;
font: 700 1.8rem/1 system-ui, sans-serif;
}
.success .state-icon { background: var(--mint); }
.loading {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: var(--white);
}
.loading span:not(.visually-hidden) {
width: 10px;
height: 10px;
background: var(--turquoise);
border-radius: 50%;
animation: bounce .8s infinite alternate;
}
.loading span:nth-child(2) { animation-delay: .15s; background: var(--mint); }
.loading span:nth-child(3) { animation-delay: .3s; background: var(--orange); }
.mini-confetti i { position: absolute; z-index: -1; width: 8px; height: 8px; border-radius: 50%; opacity: .55; }
.mini-confetti i:nth-child(1) { top: 5%; right: 9%; background: var(--mint); }
.mini-confetti i:nth-child(2) { top: 14%; right: 5%; width: 5px; height: 5px; background: var(--turquoise); }
.mini-confetti i:nth-child(3) { bottom: 10%; left: 6%; background: var(--orange); }
.mini-confetti i:nth-child(4) { bottom: 5%; right: 13%; width: 12px; height: 12px; background: var(--turquoise); }
.mini-confetti i:nth-child(5) { top: 3%; left: 41%; width: 5px; height: 5px; background: var(--orange); }
.mini-confetti i:nth-child(6) { bottom: 25%; right: 3%; width: 5px; height: 5px; background: var(--mint); }
.mini-confetti i:nth-child(7) { top: 31%; left: 2%; width: 4px; height: 4px; background: var(--turquoise); }
.mini-confetti i:nth-child(8) { bottom: 3%; left: 36%; width: 6px; height: 6px; background: var(--mint); }
footer { padding: 0 1rem 24px; color: #668286; font: .72rem/1.4 system-ui, sans-serif; letter-spacing: .12em; text-align: center; text-transform: uppercase; }
.visually-hidden { position: absolute !important; overflow: hidden; width: 1px; height: 1px; padding: 0; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@keyframes bounce { to { transform: translateY(-10px); } }
@media (max-width: 820px) {
.page-shell { width: min(590px, calc(100% - 28px)); padding-top: 28px; grid-template-columns: 1fr; gap: 34px; }
.invite-card { max-width: 480px; margin-inline: auto; }
.rsvp-card { min-height: 520px; }
}
@media (max-width: 480px) {
.page-shell { width: 100%; padding: 0 0 24px; gap: 0; }
.invite-card { border-radius: 0; box-shadow: none; }
.pdf-link { margin: 1rem 0 1.4rem; }
.rsvp-card { min-height: 0; padding: 36px 20px 40px; border-inline: 0; border-radius: 28px 28px 0 0; }
.choice { padding: .82rem; }
.state-message { padding-block: 3rem; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; }
}
+13
View File
@@ -0,0 +1,13 @@
const crypto = require("node:crypto");
const { createInvites } = require("../database");
const guestName = process.argv.slice(2).join(" ").trim();
if (!guestName) {
console.error('Verwendung: npm run invite -- "Vorname Nachname"');
process.exit(1);
}
const token = crypto.randomBytes(18).toString("base64url");
createInvites([{ token, guestName: guestName.slice(0, 100) }]);
const origin = (process.env.ORIGIN || "http://localhost:3000").replace(/\/$/, "");
console.log(`${guestName}: ${origin}/i/${token}`);
+271
View File
@@ -0,0 +1,271 @@
const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
const { createInvites, getInvite, listInvites, recordOpen, saveRsvp } = require("./database");
const PORT = Number(process.env.PORT || 3000);
const HOST = process.env.HOST || "0.0.0.0";
const ADMIN_KEY = process.env.ADMIN_KEY || "";
const TRUST_AUTHENTIK = process.env.TRUST_AUTHENTIK === "true";
const ORIGIN = (process.env.ORIGIN || `http://localhost:${PORT}`).replace(/\/$/, "");
const PUBLIC_DIR = path.join(__dirname, "public");
const INVITATION_PDF = process.env.INVITATION_PDF || path.join(__dirname, "Einladung.pdf");
const INVITATION_IMAGE = process.env.INVITATION_IMAGE || path.join(__dirname, "private-assets", "einladung.jpg");
const MIME_TYPES = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".pdf": "application/pdf",
".ico": "image/x-icon",
".svg": "image/svg+xml"
};
function sendJson(response, status, payload) {
const body = JSON.stringify(payload);
response.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff"
});
response.end(body);
}
function readJson(request) {
return new Promise((resolve, reject) => {
let body = "";
request.on("data", chunk => {
body += chunk;
if (body.length > 16_384) request.destroy();
});
request.on("end", () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch {
reject(new Error("Ungültige Anfrage."));
}
});
request.on("error", reject);
});
}
function isAdmin(request, url) {
if (TRUST_AUTHENTIK && request.headers["x-authentik-username"]) return true;
if (!ADMIN_KEY) return false;
const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, "");
const supplied = bearer || request.headers["x-admin-key"] || url.searchParams.get("key");
if (!supplied) return false;
const expectedBuffer = Buffer.from(ADMIN_KEY);
const suppliedBuffer = Buffer.from(String(supplied));
return expectedBuffer.length === suppliedBuffer.length && crypto.timingSafeEqual(expectedBuffer, suppliedBuffer);
}
function createToken() {
return crypto.randomBytes(18).toString("base64url");
}
function safeInvite(invite) {
return {
guestName: invite.guestName || "",
answer: invite.answer || null,
companionName: invite.companionName || "",
respondedAt: invite.respondedAt || null
};
}
function adminInvite(invite) {
return {
guestName: invite.guestName || "",
token: invite.token,
link: `${ORIGIN}/i/${invite.token}`,
answer: invite.answer || null,
companionName: invite.companionName || "",
createdAt: invite.createdAt || null,
firstOpenedAt: invite.firstOpenedAt || null,
lastOpenedAt: invite.lastOpenedAt || null,
openCount: Number(invite.openCount || 0),
lastSavedAt: invite.respondedAt || null
};
}
function csvCell(value) {
const text = String(value ?? "");
return `"${text.replaceAll('"', '""')}"`;
}
function serveFile(response, requestedPath) {
const relative = requestedPath === "/" ? "index.html" : requestedPath.replace(/^\/+/, "");
const filePath = path.resolve(PUBLIC_DIR, relative);
if (!filePath.startsWith(`${PUBLIC_DIR}${path.sep}`)) {
sendJson(response, 403, { error: "Nicht erlaubt." });
return;
}
fs.stat(filePath, (error, stats) => {
if (error || !stats.isFile()) {
sendJson(response, 404, { error: "Nicht gefunden." });
return;
}
const extension = path.extname(filePath).toLowerCase();
const headers = {
"Content-Type": MIME_TYPES[extension] || "application/octet-stream",
"X-Content-Type-Options": "nosniff",
"Cache-Control": extension === ".html" ? "no-cache" : "public, max-age=86400"
};
if (extension === ".pdf") {
headers["Content-Disposition"] = 'inline; filename="Einladung-Vicky-und-Daniel.pdf"';
}
response.writeHead(200, headers);
fs.createReadStream(filePath).pipe(response);
});
}
function servePrivateAsset(response, filePath, contentType, disposition) {
fs.stat(filePath, (error, stats) => {
if (error || !stats.isFile()) {
sendJson(response, 503, { error: "Die Einladung ist momentan nicht verfügbar." });
return;
}
response.writeHead(200, {
"Content-Type": contentType,
"Content-Length": stats.size,
"Content-Disposition": disposition,
"Cache-Control": "private, max-age=3600",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff"
});
fs.createReadStream(filePath).pipe(response);
});
}
const server = http.createServer(async (request, response) => {
const url = new URL(request.url, `http://${request.headers.host || "localhost"}`);
const route = decodeURIComponent(url.pathname);
try {
if (request.method === "GET" && route === "/health") {
return sendJson(response, 200, { ok: true });
}
const assetMatch = route.match(/^\/api\/invites\/([A-Za-z0-9_-]+)\/assets\/(image|pdf)$/);
if (request.method === "GET" && assetMatch) {
if (!getInvite(assetMatch[1])) {
return sendJson(response, 404, { error: "Dieser Einladungslink ist leider nicht gültig." });
}
if (assetMatch[2] === "pdf") {
return servePrivateAsset(response, INVITATION_PDF, "application/pdf", 'inline; filename="Einladung-Vicky-und-Daniel.pdf"');
}
return servePrivateAsset(response, INVITATION_IMAGE, "image/jpeg", 'inline; filename="einladung.jpg"');
}
const inviteMatch = route.match(/^\/api\/invites\/([A-Za-z0-9_-]+)$/);
if (request.method === "GET" && inviteMatch) {
const invite = recordOpen(inviteMatch[1], new Date().toISOString());
if (!invite) return sendJson(response, 404, { error: "Dieser Einladungslink ist leider nicht gültig." });
return sendJson(response, 200, safeInvite(invite));
}
const rsvpMatch = route.match(/^\/api\/invites\/([A-Za-z0-9_-]+)\/rsvp$/);
if (request.method === "POST" && rsvpMatch) {
const payload = await readJson(request);
const validAnswers = new Set(["not_coming", "alone", "with_someone"]);
if (!validAnswers.has(payload.answer)) {
return sendJson(response, 400, { error: "Bitte wähle eine Antwort aus." });
}
const companionName = String(payload.companionName || "").trim().slice(0, 100);
if (payload.answer === "with_someone" && !companionName) {
return sendJson(response, 400, { error: "Bitte verrate uns den Namen deiner Begleitung." });
}
const invite = saveRsvp(
rsvpMatch[1],
payload.answer,
payload.answer === "with_someone" ? companionName : "",
new Date().toISOString()
);
if (!invite) {
return sendJson(response, 404, { error: "Dieser Einladungslink ist leider nicht gültig." });
}
return sendJson(response, 200, { ok: true, ...safeInvite(invite) });
}
if (request.method === "POST" && route === "/api/admin/invites") {
if (!isAdmin(request, url)) return sendJson(response, 401, { error: "Nicht autorisiert." });
const payload = await readJson(request);
const names = (Array.isArray(payload.names) ? payload.names : [payload.name || ""])
.map(name => String(name || "").trim())
.filter(Boolean);
if (!names.length) return sendJson(response, 400, { error: "Bitte gib mindestens einen Namen an." });
if (names.length > 200) return sendJson(response, 400, { error: "Maximal 200 Einladungen auf einmal." });
const created = createInvites(names.map(name => ({ token: createToken(), guestName: name.slice(0, 100) })))
.map(invite => ({ name: invite.guestName, token: invite.token, link: `${ORIGIN}/i/${invite.token}` }));
return sendJson(response, 201, { invites: created });
}
if (request.method === "GET" && route === "/api/admin/invites") {
if (!isAdmin(request, url)) return sendJson(response, 401, { error: "Nicht autorisiert." });
const invites = listInvites().map(adminInvite);
const summary = invites.reduce((totals, invite) => {
totals.total += 1;
if (invite.firstOpenedAt) totals.opened += 1;
if (invite.answer) totals.responded += 1;
if (invite.answer === "not_coming") totals.notComing += 1;
if (invite.answer === "alone") totals.attending += 1;
if (invite.answer === "with_someone") totals.attending += 2;
return totals;
}, { total: 0, opened: 0, responded: 0, attending: 0, notComing: 0 });
return sendJson(response, 200, { summary, invites });
}
if (request.method === "GET" && route === "/api/admin/responses.csv") {
if (!isAdmin(request, url)) return sendJson(response, 401, { error: "Nicht autorisiert." });
const labels = { not_coming: "Kommt nicht", alone: "Kommt alleine", with_someone: "Kommt mit Begleitung" };
const rows = [["Gast", "Antwort", "Begleitung", "Erstmals geöffnet", "Zuletzt geöffnet", "Öffnungen", "Zuletzt gespeichert", "Erstellt", "Link"]];
for (const invite of listInvites()) {
rows.push([
invite.guestName,
labels[invite.answer] || "Noch offen",
invite.companionName,
invite.firstOpenedAt,
invite.lastOpenedAt,
invite.openCount || 0,
invite.respondedAt,
invite.createdAt,
`${ORIGIN}/i/${invite.token}`
]);
}
const csv = `\uFEFF${rows.map(row => row.map(csvCell).join(";")).join("\n")}`;
response.writeHead(200, {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="zusagen.csv"',
"Content-Length": Buffer.byteLength(csv),
"Cache-Control": "no-store"
});
return response.end(csv);
}
if (request.method === "GET" && /^\/i\/[A-Za-z0-9_-]+\/?$/.test(route)) {
return serveFile(response, "/index.html");
}
if (request.method === "GET" && /^\/admin\/?$/.test(route)) {
return serveFile(response, "/admin.html");
}
if (request.method === "GET") return serveFile(response, route);
return sendJson(response, 405, { error: "Methode nicht erlaubt." });
} catch (error) {
console.error(error);
return sendJson(response, 500, { error: "Da ist etwas schiefgegangen. Bitte versuche es noch einmal." });
}
});
server.listen(PORT, HOST, () => {
console.log(`Invitation server listening on ${ORIGIN}`);
if (!ADMIN_KEY && !TRUST_AUTHENTIK) console.warn("No admin authentication is configured; admin endpoints are disabled.");
});