Compare commits

..

47 Commits

Author SHA1 Message Date
elpatron f0c3cacb06 feat(analytics): Plausible über PLAUSIBLE_ENABLED und PLAUSIBLE_HOST steuerbar
Runtime-Konfiguration im Frontend-Container trennt Prod und Staging;
Staging deaktiviert Analytics standardmäßig.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:04:31 +02:00
elpatron 5821e20086 fix(deploy): Backend-Healthcheck und Staging-Wrapper absichern
Expliziter Compose-Healthcheck für das Backend, curl-Fallback und längeres
MAX_WAIT im Deploy-Skript; update-staging.sh lehnt -dest ab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 17:52:05 +02:00
elpatron aff8d1517d feat(deploy): Staging-Umgebung und einheitliches Deploy-Skript
Fügt docker-compose.staging.yml, Staging-Dokumentation und -dest prod|stage
in update-prod.sh hinzu, damit Prod und Staging über ein Skript deploybar sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 17:47:12 +02:00
elpatron f4d6b11414 chore: release v0.1.1.19 2026-06-05 11:47:07 +02:00
elpatron 968e81f4fb feat(auth): Session-Wiederherstellung nach Reload ohne vollen Login
Nach gültigem Server-Cookie wird automatisch Passkey oder PIN zum Entsperren angeboten, statt die komplette Anmelde-Maske zu zeigen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 11:42:06 +02:00
elpatron 10835c9def chore: release v0.1.1.18 2026-06-05 11:30:44 +02:00
elpatron cdbc618521 fix(admin): kompakteres Mobile-Layout für Admin-Dashboard
KPI-Karten bleiben auf schmalen Viewports in zwei Spalten, Header und Filter nutzen weniger vertikalen Platz.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 11:30:25 +02:00
elpatron f75fe42910 chore: release v0.1.1.17 2026-06-05 11:15:46 +02:00
elpatron 212775ffdc fix(deploy): pass ADMIN_USER_IDS into backend container
Docker Compose did not forward the admin whitelist from .env, so production always treated every user as non-admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 11:14:56 +02:00
elpatron c80760db02 chore: release v0.1.1.16 2026-06-05 11:05:03 +02:00
elpatron cd1dd12c15 fix: require auth before rendering admin dashboard
Show login instead of AdminDashboard on /admin when unauthenticated to avoid pointless admin API calls.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 10:31:54 +02:00
elpatron 43cf589613 feat: add in-app admin navigation for whitelisted users
Detect admin access after login and expose a header button that opens /admin via client-side routing so the session stays unlocked when returning to the app.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 10:03:46 +02:00
elpatron e1cb2754c4 fix: keep session when leaving admin
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 09:32:30 +02:00
elpatron 5dedb8fac0 feat: add admin dashboard with usage stats
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 09:26:55 +02:00
elpatron 78f1659db4 chore: release v0.1.1.15 2026-06-04 19:30:53 +02:00
elpatron 935c263648 style: link KnorrLabs to dashy with compass icon badge 2026-06-04 19:29:59 +02:00
elpatron 29ac96f892 chore: release v0.1.1.14 2026-06-04 19:27:12 +02:00
elpatron 4d3b7210b3 style: replace name with email address in footer mail link badge 2026-06-04 19:27:00 +02:00
elpatron 369bca2ef1 chore: release v0.1.1.13 2026-06-04 19:23:13 +02:00
elpatron 2fcc741f5e style: style email link in footer as icon badge similar to Ko-Fi badge 2026-06-04 19:22:43 +02:00
elpatron 27722186d1 AI Video updated 2026-06-04 19:18:10 +02:00
elpatron 5710c74706 feat: add square sticker/sharepic image 2026-06-04 19:09:27 +02:00
elpatron cd27dfa27d Add AI generated marketing video 2026-06-04 19:05:36 +02:00
elpatron c4c7d42de4 feat: add portrait sharepic and update generation script 2026-06-04 18:39:07 +02:00
elpatron 71025b3d61 style: add QR code to sharepic layout 2026-06-04 18:36:51 +02:00
elpatron f790a6adcc feat: add sharepic HTML template, generation script, and client package task 2026-06-04 18:36:12 +02:00
elpatron de5a46938b chore: release v0.1.1.12 2026-06-04 18:26:15 +02:00
elpatron 16944c1a26 chore: update contact email in footer to moin@kapteins-daagbok.eu 2026-06-04 18:26:02 +02:00
elpatron fae7b20f90 chore: release v0.1.1.11 2026-06-03 19:39:37 +02:00
elpatron 73e7613a1b feat(logbook): attribute log events to creator and show in exports 2026-06-03 19:39:15 +02:00
elpatron 6c8aa5af4c chore: release v0.1.1.10 2026-06-03 19:17:02 +02:00
elpatron 9554f4b66e style(client): center PWA update and install banners properly 2026-06-03 19:16:56 +02:00
elpatron 5c77bbfdc3 style(client): hide version footer on mobile when bottom navigation is active 2026-06-03 19:15:09 +02:00
elpatron 979b572136 chore: release v0.1.1.9 2026-06-03 19:11:29 +02:00
elpatron f189317dfc chore: remove visual debug logs panel from voice recording modal 2026-06-03 19:11:25 +02:00
elpatron c54f834311 chore: release v0.1.1.8 2026-06-03 19:07:07 +02:00
elpatron 9d05005bb7 fix: allow blob and data urls in Content-Security-Policy media-src directive 2026-06-03 19:07:03 +02:00
elpatron 40c4874156 chore: release v0.1.1.7 2026-06-03 18:56:39 +02:00
elpatron 2de0636608 fix: call load() to force mobile browsers to fetch blob URL metadata and fix player duration 2026-06-03 18:56:32 +02:00
elpatron 9e7c6f4397 chore: release v0.1.1.6 2026-06-03 18:51:14 +02:00
elpatron 6600ceafce debug: add verbose console logging and on-screen logs area to LiveVoiceCapture 2026-06-03 18:51:08 +02:00
elpatron d7a497a4a2 chore: release v0.1.1.5 2026-06-03 18:44:56 +02:00
elpatron 4c04086d63 fix: solve audio recording on iOS/Safari and fix Dockerfile health check 2026-06-03 18:44:51 +02:00
elpatron 79ce42bec6 chore: release v0.1.1.4 2026-06-03 18:33:39 +02:00
elpatron 72c956162c fix: resolve 0-second duration issue on WebM voice recordings in Chrome/Android 2026-06-03 18:33:35 +02:00
elpatron 3080b59dc8 chore: release v0.1.1.3 2026-06-03 18:27:00 +02:00
elpatron d054e42cc0 style: add sunset background image to login screen 2026-06-03 18:26:52 +02:00
55 changed files with 2892 additions and 202 deletions
+15
View File
@@ -15,6 +15,11 @@ DeepLAPIKey=
# Production (kapteins-daagbok.eu): # Production (kapteins-daagbok.eu):
# RP_ID=kapteins-daagbok.eu # RP_ID=kapteins-daagbok.eu
# ORIGIN=https://kapteins-daagbok.eu # ORIGIN=https://kapteins-daagbok.eu
# Staging (staging.kapteins-daagbok.eu):
# RP_ID=staging.kapteins-daagbok.eu
# ORIGIN=https://staging.kapteins-daagbok.eu
# POSTGRES_DB=daagbox_staging
# NTFY_TOPIC=kapteins-daagbok-staging-feedback
RP_ID=localhost RP_ID=localhost
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost) # Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
ORIGIN=http://localhost:5173 ORIGIN=http://localhost:5173
@@ -36,6 +41,10 @@ ORIGIN=http://localhost:5173
# Generate: openssl rand -base64 48 # Generate: openssl rand -base64 48
SESSION_SECRET= SESSION_SECRET=
# Admin dashboard access — comma-separated list of User IDs (UUIDs)
# Example: ADMIN_USER_IDS=11111111-2222-3333-4444-555555555555,22222222-3333-4444-5555-666666666666
ADMIN_USER_IDS=
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys # Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY # Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
@@ -47,3 +56,9 @@ VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
NTFY_SERVER=https://ntfy.sh NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=kapteins-daagbok-feedback NTFY_TOPIC=kapteins-daagbok-feedback
NTFY_TOKEN=tk_example_ntfy_access_token NTFY_TOKEN=tk_example_ntfy_access_token
# Plausible Analytics (frontend container — see docs/plausible-events.md)
# Production: PLAUSIBLE_ENABLED=true, data-domain = current hostname (kapteins-daagbok.eu)
# Staging: PLAUSIBLE_ENABLED=false (default in docker-compose.staging.yml)
PLAUSIBLE_ENABLED=true
PLAUSIBLE_HOST=https://plausible.elpatron.me
+13 -2
View File
@@ -251,15 +251,25 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führ
```bash ```bash
./scripts/update-prod.sh ./scripts/update-prod.sh -dest prod
``` ```
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar. Standard-Ziel Prod: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container. Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md). Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
### Staging
Testumgebung unter [staging.kapteins-daagbok.eu](https://staging.kapteins-daagbok.eu) — Deploy ohne Release-Tag:
```bash
./scripts/update-prod.sh -dest stage
```
Standard-Ziel Staging: `root@10.0.0.27:/opt/kapteins-daagbok-staging` — per `REMOTE_HOST`, `REMOTE_DIR`, `DEPLOY_BRANCH` überschreibbar. Details: [docs/deployment/staging.md](docs/deployment/staging.md).
## Dokumentation ## Dokumentation
| Dokument | Inhalt | | Dokument | Inhalt |
@@ -267,6 +277,7 @@ Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deploymen
| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header | | [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header |
| [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI | | [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI |
| [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle | | [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
| [docs/deployment/staging.md](docs/deployment/staging.md) | Staging-VM, Deploy, `.env` |
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics | | [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan | | [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan | | [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
+1 -1
View File
@@ -1 +1 @@
0.1.1.3 0.1.1.20
+8 -5
View File
@@ -18,15 +18,18 @@ RUN npm run build
FROM nginx:1.25-alpine FROM nginx:1.25-alpine
WORKDIR /usr/share/nginx/html WORKDIR /usr/share/nginx/html
# Copy custom Nginx configuration RUN apk add --no-cache gettext
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
COPY client/nginx.conf.template /etc/nginx/templates/default.conf.template
COPY client/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Copy built assets from builder # Copy built assets from builder
COPY --from=builder /app/dist . COPY --from=builder /app/dist .
# Expose HTTP port
EXPOSE 80 EXPOSE 80
# Health check to verify Nginx is actively running
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1 CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80/ || exit 1
ENTRYPOINT ["/docker-entrypoint.sh"]
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
PLAUSIBLE_ENABLED="${PLAUSIBLE_ENABLED:-true}"
PLAUSIBLE_HOST="${PLAUSIBLE_HOST:-https://plausible.elpatron.me}"
PLAUSIBLE_HOST="${PLAUSIBLE_HOST%/}"
case "$(printf '%s' "$PLAUSIBLE_ENABLED" | tr '[:upper:]' '[:lower:]')" in
true|1|yes)
PLAUSIBLE_ENABLED_JSON=true
PLAUSIBLE_CSP=" ${PLAUSIBLE_HOST}"
;;
*)
PLAUSIBLE_ENABLED_JSON=false
PLAUSIBLE_CSP=""
;;
esac
export PLAUSIBLE_CSP
envsubst '${PLAUSIBLE_CSP}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
cat > /usr/share/nginx/html/runtime-config.json <<EOF
{"plausibleEnabled":${PLAUSIBLE_ENABLED_JSON},"plausibleHost":"${PLAUSIBLE_HOST}"}
EOF
exec nginx -g 'daemon off;'
+1 -1
View File
@@ -22,6 +22,7 @@
<meta name="apple-mobile-web-app-title" content="Daagbok" /> <meta name="apple-mobile-web-app-title" content="Daagbok" />
<meta name="theme-color" content="#0b0c10" /> <meta name="theme-color" content="#0b0c10" />
<script src="/appearance-bootstrap.js"></script> <script src="/appearance-bootstrap.js"></script>
<script src="/plausible-bootstrap.js"></script>
<script src="/bootstrap-watchdog.js"></script> <script src="/bootstrap-watchdog.js"></script>
<link rel="apple-touch-icon" href="/logo.png" /> <link rel="apple-touch-icon" href="/logo.png" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
@@ -38,7 +39,6 @@
<meta name="twitter:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten Passkey-Anmeldung und Offline-PWA." /> <meta name="twitter:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten Passkey-Anmeldung und Offline-PWA." />
<meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" /> <meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" />
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" /> <meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
<title>Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)</title> <title>Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
</head> </head>
<body> <body>
+2 -51
View File
@@ -1,51 +1,2 @@
server { # Generated at container start from PLAUSIBLE_* — see client/nginx.conf.template and docker-entrypoint.sh
listen 80; # Local Docker Compose uses the template via client/Dockerfile entrypoint.
server_name localhost;
client_max_body_size 50M;
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:5000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
+51
View File
@@ -0,0 +1,51 @@
server {
listen 80;
server_name localhost;
client_max_body_size 50M;
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:5000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
+1
View File
@@ -13,6 +13,7 @@
"generate:flyer:png": "node ../scripts/generate-beta-flyer.mjs --png", "generate:flyer:png": "node ../scripts/generate-beta-flyer.mjs --png",
"generate:flyer:all": "node ../scripts/generate-beta-flyer.mjs --all", "generate:flyer:all": "node ../scripts/generate-beta-flyer.mjs --all",
"generate:flyer:setup": "playwright install chromium", "generate:flyer:setup": "playwright install chromium",
"generate:sharepic": "node ../scripts/generate-sharepic.mjs",
"translate:locales": "node ../scripts/translate-locales.mjs", "translate:locales": "node ../scripts/translate-locales.mjs",
"translate:flyer": "node ../scripts/translate-flyer.mjs", "translate:flyer": "node ../scripts/translate-flyer.mjs",
"validate:i18n": "node ../scripts/validate-i18n-keys.mjs" "validate:i18n": "node ../scripts/validate-i18n-keys.mjs"
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

+25
View File
@@ -0,0 +1,25 @@
/**
* Loads Plausible when enabled via /runtime-config.json (from .env in Docker / Vite dev).
* data-domain is always the current hostname (prod vs staging).
*/
(function () {
function load(cfg) {
if (!cfg || !cfg.plausibleEnabled || !cfg.plausibleHost) return
var host = String(cfg.plausibleHost).replace(/\/$/, '')
if (!host) return
var s = document.createElement('script')
s.defer = true
s.dataset.domain = window.location.hostname
s.src = host + '/js/script.tagged-events.js'
document.head.appendChild(s)
}
fetch('/runtime-config.json', { cache: 'no-store' })
.then(function (r) {
return r.ok ? r.json() : null
})
.then(load)
.catch(function () {
/* analytics optional */
})
})()
+259 -4
View File
@@ -36,6 +36,10 @@ code {
min-height: 100svh; min-height: 100svh;
padding: 24px 16px calc(48px + env(safe-area-inset-bottom, 0px)); padding: 24px 16px calc(48px + env(safe-area-inset-bottom, 0px));
box-sizing: border-box; box-sizing: border-box;
background-image: linear-gradient(rgba(15, 23, 42, 0.3), rgba(15, 23, 42, 0.5)), url('/login-bg.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
} }
/* Glassmorphism Auth Card */ /* Glassmorphism Auth Card */
@@ -4915,6 +4919,177 @@ html.theme-cupertino .events-scroll-container {
padding-bottom: 8px; padding-bottom: 8px;
} }
.admin-page {
width: 100%;
max-width: 1200px;
min-height: 100vh;
margin: 0 auto;
padding: 24px;
box-sizing: border-box;
color: var(--app-text);
display: flex;
flex-direction: column;
gap: 24px;
}
.admin-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--app-header-border);
}
.admin-header-left {
display: flex;
align-items: flex-start;
gap: 20px;
min-width: 0;
}
.admin-title {
margin: 0;
font-size: 24px;
font-weight: 700;
background: var(--app-accent-gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.admin-subtitle {
margin: 6px 0 0;
font-size: 14px;
color: var(--app-text-muted);
}
.admin-main {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
}
.admin-kpi-grid {
margin-top: 0;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.admin-controls {
display: flex;
flex-wrap: wrap;
gap: 24px;
padding: 16px 20px;
border-radius: var(--app-radius-card);
border: 1px solid var(--app-border-subtle);
background: var(--app-surface);
}
.admin-control-group {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.admin-control-label {
font-size: 13px;
font-weight: 600;
color: var(--app-text-muted);
}
.admin-control-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.admin-control-buttons .btn {
width: auto;
padding: 8px 16px;
font-size: 14px;
}
.admin-charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
gap: 20px;
}
.dashboard-subtitle {
margin-top: 4px;
font-size: 14px;
color: var(--app-text-muted);
}
@media (max-width: 640px) {
.admin-page {
padding: 12px 12px 20px;
gap: 16px;
min-height: auto;
}
.admin-header {
padding-bottom: 12px;
}
.admin-header-left {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
align-items: center;
column-gap: 10px;
row-gap: 2px;
}
.admin-header-left .btn-back {
grid-row: 1 / -1;
align-self: center;
padding: 6px 10px;
font-size: 13px;
}
.admin-title {
font-size: 18px;
line-height: 1.2;
}
.admin-subtitle {
font-size: 11px;
margin: 0;
line-height: 1.35;
}
.admin-main {
gap: 16px;
}
.admin-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 12px;
}
.admin-control-buttons {
gap: 6px;
}
.admin-control-label {
font-size: 11px;
}
.admin-control-buttons .btn {
padding: 6px 8px;
font-size: 12px;
}
.admin-charts-grid {
gap: 14px;
}
}
.stats-consumption-chart .stats-bar-column--grouped { .stats-consumption-chart .stats-bar-column--grouped {
display: inline-flex; display: inline-flex;
white-space: normal; white-space: normal;
@@ -5014,6 +5189,36 @@ html.theme-cupertino .events-scroll-container {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
/* Admin dashboard: keep 2-column KPI grid on mobile (overrides rule above) */
.stats-kpi-grid.admin-kpi-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.admin-kpi-grid .stats-kpi-card {
padding: 10px 12px;
gap: 8px;
}
.admin-kpi-grid .stats-kpi-icon {
margin-top: 0;
}
.admin-kpi-grid .stats-kpi-icon svg {
width: 18px;
height: 18px;
}
.admin-kpi-grid .stats-kpi-label {
font-size: 11px;
line-height: 1.25;
margin-bottom: 2px;
}
.admin-kpi-grid .stats-kpi-value {
font-size: 18px;
}
.stats-kpi-value { .stats-kpi-value {
font-size: 20px; font-size: 20px;
} }
@@ -5293,8 +5498,9 @@ html.theme-cupertino .events-scroll-container {
/* PWA install prompt */ /* PWA install prompt */
.pwa-install-banner { .pwa-install-banner {
position: fixed; position: fixed;
left: 16px; left: 0;
right: 16px; right: 0;
width: calc(100% - 32px);
bottom: calc(36px + env(safe-area-inset-bottom, 0px)); bottom: calc(36px + env(safe-area-inset-bottom, 0px));
z-index: 1200; z-index: 1200;
display: grid; display: grid;
@@ -5457,8 +5663,9 @@ html.theme-cupertino .events-scroll-container {
.pwa-update-banner { .pwa-update-banner {
position: fixed; position: fixed;
top: calc(12px + env(safe-area-inset-top, 0px)); top: calc(12px + env(safe-area-inset-top, 0px));
left: 16px; left: 0;
right: 16px; right: 0;
width: calc(100% - 32px);
z-index: 1300; z-index: 1300;
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
@@ -5581,6 +5788,12 @@ html.theme-cupertino .events-scroll-container {
pointer-events: none; pointer-events: none;
} }
@media (max-width: 768px) {
body:has(.app-bottom-nav) .app-version-footer {
display: none;
}
}
.app-version-footer a, .app-version-footer a,
.app-version-footer button { .app-version-footer button {
pointer-events: auto; pointer-events: auto;
@@ -5630,6 +5843,48 @@ html.theme-cupertino .events-scroll-container {
border-color: rgba(255, 94, 91, 0.32); border-color: rgba(255, 94, 91, 0.32);
} }
.mail-footer-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
color: #94a3b8;
text-decoration: none;
background: rgba(56, 189, 248, 0.08);
border: 1px solid rgba(56, 189, 248, 0.18);
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
.mail-footer-badge:hover {
color: #bae6fd;
background: rgba(56, 189, 248, 0.14);
border-color: rgba(56, 189, 248, 0.32);
}
.knorrlabs-footer-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
color: #94a3b8;
text-decoration: none;
background: rgba(139, 92, 246, 0.08);
border: 1px solid rgba(139, 92, 246, 0.18);
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
.knorrlabs-footer-badge:hover {
color: #ddd6fe;
background: rgba(139, 92, 246, 0.14);
border-color: rgba(139, 92, 246, 0.32);
}
.demo-badge { .demo-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+79 -4
View File
@@ -36,6 +36,7 @@ import { syncAppearancePrefs } from './services/appearancePrefs.js'
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js' import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx' import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
import DemoViewer from './components/DemoViewer.tsx' import DemoViewer from './components/DemoViewer.tsx'
import AdminDashboard from './admin/AdminDashboard.tsx'
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx' import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx' import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
import AppFooter from './components/AppFooter.tsx' import AppFooter from './components/AppFooter.tsx'
@@ -49,6 +50,8 @@ import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, La
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx' import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx' import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx' import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
import AdminHeaderButton from './components/AdminHeaderButton.tsx'
import { checkAdminAccess } from './services/adminApi.js'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from './utils/i18nLanguages.js' import { cycleAppLanguage } from './utils/i18nLanguages.js'
import { import {
@@ -92,6 +95,10 @@ function App() {
// Public demo mode (no account required) // Public demo mode (no account required)
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo') const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
const [isAdminRoute, setIsAdminRoute] = useState(() => window.location.pathname.startsWith('/admin'))
const [isAdminUser, setIsAdminUser] = useState(false)
const [sessionChecked, setSessionChecked] = useState(false)
const [serverSessionActive, setServerSessionActive] = useState(false)
const syncQueueCount = useLiveQuery( const syncQueueCount = useLiveQuery(
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(), () => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
@@ -160,14 +167,23 @@ function App() {
}) })
}, []) }, [])
const refreshAdminAccess = useCallback(async () => {
const isAdmin = await checkAdminAccess()
setIsAdminUser(isAdmin)
}, [])
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return if (!isAuthenticated) {
setIsAdminUser(false)
return
}
const userId = localStorage.getItem('active_userid') const userId = localStorage.getItem('active_userid')
if (!userId) return if (!userId) return
void syncAppearancePrefs(userId) void syncAppearancePrefs(userId)
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool()) void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool()) void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
}, [isAuthenticated]) void refreshAdminAccess()
}, [isAuthenticated, refreshAdminAccess])
useEffect(() => { useEffect(() => {
const handleOnline = () => { const handleOnline = () => {
@@ -199,6 +215,13 @@ function App() {
const hashParams = new URLSearchParams(window.location.hash.substring(1)) const hashParams = new URLSearchParams(window.location.hash.substring(1))
const path = window.location.pathname const path = window.location.pathname
if (path.startsWith('/admin')) {
setIsAdminRoute(true)
return
}
setIsAdminRoute(false)
if (path === '/demo') { if (path === '/demo') {
setIsDemoMode(true) setIsDemoMode(true)
setIsViewerMode(false) setIsViewerMode(false)
@@ -240,6 +263,7 @@ function App() {
const clearAuthenticatedAppState = useCallback(() => { const clearAuthenticatedAppState = useCallback(() => {
setIsAuthenticated(false) setIsAuthenticated(false)
setIsAdminUser(false)
setActiveLogbookId(null) setActiveLogbookId(null)
setActiveLogbookTitle(null) setActiveLogbookTitle(null)
setShowUserProfile(false) setShowUserProfile(false)
@@ -249,7 +273,7 @@ function App() {
/** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */ /** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */
const enforceUnlockedSession = useCallback(() => { const enforceUnlockedSession = useCallback(() => {
if (isViewerMode || isDemoMode || isAcceptingInvite) return if (isViewerMode || isDemoMode || isAcceptingInvite || isAdminRoute) return
// Require full local session (incl. userId) so API calls are not left headless. // Require full local session (incl. userId) so API calls are not left headless.
if (isAuthenticated && !hasUnlockedLocalSession()) { if (isAuthenticated && !hasUnlockedLocalSession()) {
clearAuthenticatedAppState() clearAuthenticatedAppState()
@@ -259,6 +283,7 @@ function App() {
isViewerMode, isViewerMode,
isDemoMode, isDemoMode,
isAcceptingInvite, isAcceptingInvite,
isAdminRoute,
clearAuthenticatedAppState clearAuthenticatedAppState
]) ])
@@ -293,6 +318,8 @@ function App() {
const session = await checkServerSession() const session = await checkServerSession()
if (cancelled) return if (cancelled) return
setServerSessionActive(session.authenticated)
if (session.authenticated) { if (session.authenticated) {
persistSessionUserId(session.userId) persistSessionUserId(session.userId)
} }
@@ -312,6 +339,10 @@ function App() {
if (!cancelled) { if (!cancelled) {
console.warn('Session restore failed:', err) console.warn('Session restore failed:', err)
} }
} finally {
if (!cancelled) {
setSessionChecked(true)
}
} }
})() })()
@@ -333,6 +364,14 @@ function App() {
setIsAcceptingInvite(false) setIsAcceptingInvite(false)
}, []) }, [])
const openAdmin = useCallback(() => {
window.history.pushState({}, document.title, '/admin')
setIsAdminRoute(true)
setIsDemoMode(false)
setIsViewerMode(false)
setIsAcceptingInvite(false)
}, [])
const selectLogbook = useCallback((id: string, title: string) => { const selectLogbook = useCallback((id: string, title: string) => {
setActiveLogbookId(id) setActiveLogbookId(id)
setActiveLogbookTitle(title) setActiveLogbookTitle(title)
@@ -497,6 +536,7 @@ function App() {
if (!(await confirmLeave())) return if (!(await confirmLeave())) return
void logoutUser() void logoutUser()
setIsAuthenticated(false) setIsAuthenticated(false)
setIsAdminUser(false)
setActiveLogbookId(null) setActiveLogbookId(null)
setActiveLogbookTitle(null) setActiveLogbookTitle(null)
setShowUserProfile(false) setShowUserProfile(false)
@@ -524,6 +564,28 @@ function App() {
syncRouteFromLocation() syncRouteFromLocation()
} }
const handleBackFromAdmin = () => {
window.history.replaceState({}, document.title, '/')
setIsAdminRoute(false)
syncRouteFromLocation()
}
if (isAdminRoute) {
if (!isAuthenticated) {
return (
<div className="auth-screen">
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
</div>
)
}
return (
<div style={{ display: 'contents' }}>
<AdminDashboard onBack={handleBackFromAdmin} />
</div>
)
}
if (isDemoMode) { if (isDemoMode) {
return ( return (
<div style={{ display: 'contents' }}> <div style={{ display: 'contents' }}>
@@ -564,7 +626,17 @@ function App() {
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
<div className="auth-screen"> <div className="auth-screen">
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} /> {!sessionChecked ? (
<div className="auth-card glass">
<p className="dashboard-status-msg">{t('auth.restore_checking')}</p>
</div>
) : (
<AuthOnboarding
restoreSession={serverSessionActive}
onAuthenticated={handleAuthenticated}
onOpenDemo={openDemo}
/>
)}
</div> </div>
) )
} }
@@ -597,6 +669,7 @@ function App() {
onSelectLogbook={selectLogbook} onSelectLogbook={selectLogbook}
onLogout={handleLogout} onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)} onOpenProfile={() => setShowUserProfile(true)}
onOpenAdmin={isAdminUser ? openAdmin : undefined}
/> />
</div> </div>
) )
@@ -647,6 +720,8 @@ function App() {
<Languages size={18} /> <Languages size={18} />
</button> </button>
{isAdminUser && <AdminHeaderButton onClick={openAdmin} />}
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} /> <ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
<DisclaimerHeaderButton /> <DisclaimerHeaderButton />
+240
View File
@@ -0,0 +1,240 @@
import { useEffect, useState, type ReactNode } from 'react'
import {
fetchAdminMe,
fetchAdminSummary,
fetchAdminTimeSeries,
type AdminSummary,
type AdminTimeSeriesResponse,
type AdminTimeBucket
} from '../services/adminApi.js'
import { BarChart2, Bookmark, ChevronLeft, Image, MapPin, Mic, Users } from 'lucide-react'
function formatNumber(value: number): string {
return value.toLocaleString()
}
function KpiCard({
icon,
label,
value
}: {
icon: ReactNode
label: string
value: number
}) {
return (
<div className="stats-kpi-card glass">
<div className="stats-kpi-icon">{icon}</div>
<div className="stats-kpi-body">
<span className="stats-kpi-label">{label}</span>
<span className="stats-kpi-value">{formatNumber(value)}</span>
</div>
</div>
)
}
function TimeSeriesChart({
title,
seriesKey,
data
}: {
title: string
seriesKey: string
data: AdminTimeSeriesResponse | null
}) {
if (!data) {
return null
}
const metric = data.series.find((s) => s.metric === seriesKey)
if (!metric || metric.points.length === 0) {
return (
<div className="form-card glass">
<div className="form-header">
<BarChart2 className="form-icon" />
<h2>{title}</h2>
</div>
<p className="dashboard-status-msg">Keine Daten im gewählten Zeitraum.</p>
</div>
)
}
const max = metric.points.reduce((acc, p) => (p.count > acc ? p.count : acc), 0) || 1
return (
<div className="form-card glass">
<div className="form-header">
<BarChart2 className="form-icon" />
<h2>{title}</h2>
</div>
<div className="stats-bar-chart" role="img" aria-label={title}>
{metric.points.map((point) => {
const heightPct = Math.max(2, (point.count / max) * 100)
return (
<div key={point.date} className="stats-bar-column" title={`${point.date}: ${point.count}`}>
<span className="stats-bar-value">{point.count > 0 ? String(point.count) : ''}</span>
<div className="stats-bar-track">
<div className="stats-bar stats-bar--distance" style={{ height: `${heightPct}%` }} />
</div>
<span className="stats-bar-label">{point.date.slice(5)}</span>
</div>
)
})}
</div>
</div>
)
}
interface AdminDashboardProps {
onBack: () => void
}
export default function AdminDashboard({ onBack }: AdminDashboardProps) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [summary, setSummary] = useState<AdminSummary | null>(null)
const [timeSeries, setTimeSeries] = useState<AdminTimeSeriesResponse | null>(null)
const [bucket, setBucket] = useState<AdminTimeBucket>('day')
const [windowDays, setWindowDays] = useState(90)
useEffect(() => {
let cancelled = false
async function load() {
try {
setLoading(true)
setError(null)
await fetchAdminMe()
const [summaryRes, tsRes] = await Promise.all([
fetchAdminSummary(),
fetchAdminTimeSeries({ bucket, windowDays })
])
if (!cancelled) {
setSummary(summaryRes)
setTimeSeries(tsRes)
}
} catch (err: unknown) {
if (!cancelled) {
const message =
err instanceof Error && err.message ? err.message : 'Fehler beim Laden des Admin-Dashboards'
setError(message)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
void load()
return () => {
cancelled = true
}
}, [bucket, windowDays])
if (loading && !summary) {
return (
<div className="admin-page">
<p className="dashboard-status-msg">Admin-Dashboard wird geladen</p>
</div>
)
}
if (error) {
return (
<div className="admin-page">
<header className="admin-header">
<button type="button" className="btn-back" onClick={onBack}>
<ChevronLeft size={16} />
Zur App
</button>
</header>
<p className="dashboard-status-msg">{error}</p>
</div>
)
}
if (!summary) {
return (
<div className="admin-page">
<p className="dashboard-status-msg">Keine Admin-Daten verfügbar.</p>
</div>
)
}
return (
<div className="admin-page">
<header className="admin-header">
<div className="admin-header-left">
<button type="button" className="btn-back" onClick={onBack}>
<ChevronLeft size={16} />
Zur App
</button>
<div>
<h1 className="admin-title">Admin-Dashboard</h1>
<p className="admin-subtitle">
Übersicht über Nutzung und Wachstum von Kapteins Daagbok.
</p>
</div>
</div>
</header>
<main className="admin-main">
<section className="stats-kpi-grid admin-kpi-grid">
<KpiCard icon={<Users size={20} />} label="Registrierte Benutzer" value={summary.totalUsers} />
<KpiCard icon={<Bookmark size={20} />} label="Logbücher" value={summary.totalLogbooks} />
<KpiCard icon={<Image size={20} />} label="Fotos" value={summary.totalPhotos} />
<KpiCard icon={<Mic size={20} />} label="Sprachmemos" value={summary.totalVoiceMemos} />
<KpiCard icon={<MapPin size={20} />} label="GPS-Tracks" value={summary.totalGpsTracks} />
<KpiCard
icon={<BarChart2 size={20} />}
label="Einträge mit AI-Zusammenfassung"
value={summary.aiSummaryEntries}
/>
</section>
<section className="admin-controls">
<div className="admin-control-group">
<span className="admin-control-label">Zeitraum</span>
<div className="admin-control-buttons">
{[30, 90, 365].map((days) => (
<button
key={days}
type="button"
className={days === windowDays ? 'btn primary' : 'btn secondary'}
onClick={() => setWindowDays(days)}
>
{days} Tage
</button>
))}
</div>
</div>
<div className="admin-control-group">
<span className="admin-control-label">Aggregation</span>
<div className="admin-control-buttons">
{(['day', 'week', 'month'] as AdminTimeBucket[]).map((b) => (
<button
key={b}
type="button"
className={b === bucket ? 'btn primary' : 'btn secondary'}
onClick={() => setBucket(b)}
>
{b === 'day' ? 'Tag' : b === 'week' ? 'Woche' : 'Monat'}
</button>
))}
</div>
</div>
</section>
<section className="admin-charts-grid">
<TimeSeriesChart title="Neue Benutzer" seriesKey="users_created" data={timeSeries} />
<TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_created" data={timeSeries} />
<TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} />
</section>
</main>
</div>
)
}
@@ -0,0 +1,23 @@
import { useTranslation } from 'react-i18next'
import { LayoutDashboard } from 'lucide-react'
interface AdminHeaderButtonProps {
onClick: () => void
}
export default function AdminHeaderButton({ onClick }: AdminHeaderButtonProps) {
const { t } = useTranslation()
return (
<button
type="button"
className="btn-icon skipper-badge"
onClick={onClick}
title={t('nav.admin')}
aria-label={t('nav.admin')}
>
<LayoutDashboard size={18} aria-hidden="true" />
<span className="skipper-badge__name">{t('nav.admin')}</span>
</button>
)
}
+22 -4
View File
@@ -1,4 +1,4 @@
import { Coffee } from 'lucide-react' import { Coffee, Mail, Compass } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -15,14 +15,32 @@ export default function AppFooter() {
· ·
</span> </span>
<span className="app-version-footer__copyright"> <span className="app-version-footer__copyright">
© 2026 KnorrLabs/ © 2026
</span>
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a <a
href="mailto:elpatron+kd@mailbox.org" className="knorrlabs-footer-badge"
href="https://dashy.elpatron.me/"
target="_blank"
rel="noopener noreferrer"
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)} onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
> >
Markus F.J. Busche <Compass size={14} aria-hidden="true" />
<span>KnorrLabs</span>
</a> </a>
<span className="app-version-footer__sep" aria-hidden="true">
·
</span> </span>
<a
className="mail-footer-badge"
href="mailto:moin@kapteins-daagbok.eu"
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
>
<Mail size={14} aria-hidden="true" />
<span>moin@kapteins-daagbok.eu</span>
</a>
<span className="app-version-footer__sep" aria-hidden="true"> <span className="app-version-footer__sep" aria-hidden="true">
· ·
</span> </span>
+133 -5
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js' import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import { import {
@@ -12,7 +12,8 @@ import {
getKnownUsernames, getKnownUsernames,
forgetUsername, forgetUsername,
hasUnlockedLocalSession, hasUnlockedLocalSession,
logoutUser logoutUser,
resolveRestoreUsername
} from '../services/auth.js' } from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react' import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx' import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
@@ -27,9 +28,15 @@ import {
interface AuthOnboardingProps { interface AuthOnboardingProps {
onAuthenticated: () => void onAuthenticated: () => void
onOpenDemo?: () => void onOpenDemo?: () => void
/** Server session cookie is valid but the in-memory master key was lost (e.g. after reload). */
restoreSession?: boolean
} }
export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnboardingProps) { export default function AuthOnboarding({
onAuthenticated,
onOpenDemo,
restoreSession = false
}: AuthOnboardingProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -60,7 +67,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const [isNewRegistration, setIsNewRegistration] = useState(false) const [isNewRegistration, setIsNewRegistration] = useState(false)
const [showDisclaimer, setShowDisclaimer] = useState(false) const [showDisclaimer, setShowDisclaimer] = useState(false)
const [showHelp, setShowHelp] = useState(false) const [showHelp, setShowHelp] = useState(false)
const [showStandardLogin, setShowStandardLogin] = useState(false)
const autoUnlockAttempted = useRef(false)
const isRestoreFlow = restoreSession && !showStandardLogin
const passkeyHostOk = isPasskeyCompatibleLocation() const passkeyHostOk = isPasskeyCompatibleLocation()
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href) const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
@@ -144,6 +154,23 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
} }
} }
useEffect(() => {
if (!isRestoreFlow || autoUnlockAttempted.current) return
const user = resolveRestoreUsername()
if (user && hasLocalPin(user)) {
autoUnlockAttempted.current = true
setUsername(user)
setShowPinLogin(true)
return
}
if (user && passkeyHostOk) {
autoUnlockAttempted.current = true
void handleLogin(user)
}
}, [isRestoreFlow, passkeyHostOk])
const handleRecoverySubmit = async (e: React.FormEvent) => { const handleRecoverySubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!recoveryInput.trim() || !encryptedPayloads) return if (!recoveryInput.trim() || !encryptedPayloads) return
@@ -347,10 +374,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<div className="auth-card glass"> <div className="auth-card glass">
<div className="auth-header"> <div className="auth-header">
<KeyRound className="auth-icon accent" size={48} /> <KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.enter_pin_title')}</h2> <h2>{isRestoreFlow ? t('auth.restore_title') : t('auth.enter_pin_title')}</h2>
</div> </div>
<p className="recovery-warning"> <p className="recovery-warning">
{t('auth.enter_pin_warning')} {isRestoreFlow ? t('auth.restore_pin_warning') : t('auth.enter_pin_warning')}
</p> </p>
<form onSubmit={handlePinLoginSubmit} className="auth-form"> <form onSubmit={handlePinLoginSubmit} className="auth-form">
@@ -397,6 +424,12 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
type="button" type="button"
className="btn secondary" className="btn secondary"
onClick={() => { onClick={() => {
if (isRestoreFlow) {
setShowPinLogin(false)
setPinLoginInput('')
setError(null)
return
}
void (async () => { void (async () => {
setShowPinLogin(false) setShowPinLogin(false)
setPinLoginInput('') setPinLoginInput('')
@@ -480,6 +513,101 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
) )
} }
// Render: Session restore (active server cookie, master key lost after reload)
if (isRestoreFlow) {
const restoreUser = resolveRestoreUsername()
const restoreKnownUsers = getKnownUsernames()
return (
<div className="auth-card glass">
<div className="auth-header">
<KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.restore_title')}</h2>
</div>
<p className="recovery-warning">{t('auth.restore_subtitle')}</p>
{loading && (
<p className="dashboard-status-msg" style={{ marginTop: '12px' }}>
{t('auth.restore_unlocking')}
</p>
)}
{error && <div className="auth-error">{error}</div>}
{!loading && (
<div className="auth-actions" style={{ flexDirection: 'column', gap: '10px', marginTop: '16px' }}>
{restoreUser && passkeyHostOk && (
<button
type="button"
className="btn primary"
onClick={() => handleLogin(restoreUser)}
style={{ width: '100%' }}
>
{t('auth.restore_with_passkey', { name: restoreUser })}
</button>
)}
{restoreUser && hasLocalPin(restoreUser) && (
<button
type="button"
className="btn secondary"
onClick={() => {
setUsername(restoreUser)
setShowPinLogin(true)
}}
style={{ width: '100%' }}
>
{t('auth.restore_with_pin')}
</button>
)}
{restoreKnownUsers.length > 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
<span style={{ fontSize: '12px', color: '#64748b', textTransform: 'uppercase' }}>
{t('auth.quick_login')}
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', width: '100%' }}>
{restoreKnownUsers.map((name) => (
<button
key={name}
type="button"
onClick={() => {
if (hasLocalPin(name)) {
setUsername(name)
setShowPinLogin(true)
} else {
void handleLogin(name)
}
}}
disabled={loading}
className="btn secondary"
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
>
<UserRound size={16} />
{name}
</button>
))}
</div>
</div>
)}
<button
type="button"
className="btn secondary"
onClick={() => {
setShowStandardLogin(true)
setError(null)
}}
style={{ width: '100%' }}
>
{t('auth.restore_other_account')}
</button>
</div>
)}
</div>
)
}
// Render 3: Standard Login / Registration options form // Render 3: Standard Login / Registration options form
return ( return (
<> <>
+114
View File
@@ -0,0 +1,114 @@
import React from 'react'
interface PersonSnapshot {
name: string
photo?: string | null
role?: string
}
interface CreatorAvatarProps {
creatorId?: string
crewSnapshotsById?: Record<string, PersonSnapshot>
fallbackName?: string
size?: number
}
const colors = [
'#2563eb', // blue
'#059669', // emerald
'#d97706', // amber
'#dc2626', // red
'#7c3aed', // violet
'#db2777', // pink
'#0891b2', // cyan
'#4f46e5', // indigo
'#0f766e', // teal
'#9333ea', // purple
]
function getAvatarColor(name: string): string {
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
}
const index = Math.abs(hash) % colors.length
return colors[index]
}
export default function CreatorAvatar({
creatorId,
crewSnapshotsById,
fallbackName,
size = 28
}: CreatorAvatarProps) {
let name = ''
let photo: string | null = null
let role = ''
if (creatorId && crewSnapshotsById && crewSnapshotsById[creatorId]) {
const snap = crewSnapshotsById[creatorId]
name = snap.name || ''
photo = snap.photo || null
role = snap.role || ''
}
// Fallback to active username if owner or no crew pool matches
if (!name) {
if (creatorId === 'skipper') {
name = fallbackName || localStorage.getItem('active_username') || 'Skipper'
role = 'skipper'
} else if (fallbackName) {
name = fallbackName
} else if (creatorId) {
// If creatorId is a username itself (fallback from LiveLogView)
name = creatorId
} else {
name = '?'
}
}
const initial = name ? name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?' : '?'
const bgColor = name === '?' ? '#64748b' : getAvatarColor(name)
const style: React.CSSProperties = {
width: `${size}px`,
height: `${size}px`,
borderRadius: '50%',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: `${Math.round(size * 0.45)}px`,
fontWeight: 'bold',
color: '#ffffff',
backgroundColor: bgColor,
flexShrink: 0,
verticalAlign: 'middle',
overflow: 'hidden',
border: '1px solid rgba(255, 255, 255, 0.15)',
boxSizing: 'border-box'
}
const roleText = role ? (role === 'skipper' ? 'Skipper' : 'Crew') : ''
const tooltip = name + (roleText ? ` (${roleText})` : '')
if (photo) {
return (
<img
src={photo}
alt={name}
title={tooltip}
style={{
...style,
objectFit: 'cover',
backgroundColor: 'transparent'
}}
/>
)
}
return (
<div style={style} title={tooltip} className="creator-avatar-fallback">
{initial}
</div>
)
}
+76 -3
View File
@@ -23,13 +23,14 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { import {
appendQuickEvent, appendQuickEvent as apiAppendQuickEvent,
appendQuickEvents, appendQuickEvents as apiAppendQuickEvents,
appendTankRefill, appendTankRefill as apiAppendTankRefill,
findOrCreateTodayEntry, findOrCreateTodayEntry,
loadEntry, loadEntry,
removeLastEvent removeLastEvent
} from '../services/quickEventLog.js' } from '../services/quickEventLog.js'
import CreatorAvatar from './CreatorAvatar.tsx'
import { formatEventSummary } from '../utils/formatEventSummary.js' import { formatEventSummary } from '../utils/formatEventSummary.js'
import { import {
getLastAutoPositionMs, getLastAutoPositionMs,
@@ -160,6 +161,24 @@ function gpsFailureAlertBody(
return `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}` return `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
} }
function findActiveCreatorId(
activeUsername: string | null,
crewSnapshotsById: Record<string, any>,
selectedSkipperId: string | null
): string {
const username = (activeUsername || '').trim()
if (username) {
const matchEntry = Object.entries(crewSnapshotsById).find(
([_, snap]) => (snap?.name || '').trim().toLowerCase() === username.toLowerCase()
)
if (matchEntry) {
return matchEntry[0]
}
return username
}
return selectedSkipperId || 'skipper'
}
export default function LiveLogView({ export default function LiveLogView({
logbookId, logbookId,
onOpenEditor, onOpenEditor,
@@ -173,6 +192,8 @@ export default function LiveLogView({
const [dayOfTravel, setDayOfTravel] = useState('') const [dayOfTravel, setDayOfTravel] = useState('')
const [date, setDate] = useState('') const [date, setDate] = useState('')
const [events, setEvents] = useState<LogEventPayload[]>([]) const [events, setEvents] = useState<LogEventPayload[]>([])
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
const [yachtSails, setYachtSails] = useState<string[]>([]) const [yachtSails, setYachtSails] = useState<string[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@@ -214,6 +235,51 @@ export default function LiveLogView({
dateRef.current = date dateRef.current = date
busyRef.current = busy busyRef.current = busy
const getActiveCreatorId = useCallback(() => {
const activeUsername = localStorage.getItem('active_username')
return findActiveCreatorId(activeUsername, crewSnapshotsById, selectedSkipperId)
}, [crewSnapshotsById, selectedSkipperId])
const appendQuickEvent = useCallback((
logbookId: string,
entryId: string,
partialEvent: Partial<LogEventPayload>,
headerPatch?: { departure?: string; destination?: string }
) => {
return apiAppendQuickEvent(
logbookId,
entryId,
{ creatorId: getActiveCreatorId(), ...partialEvent },
headerPatch
)
}, [getActiveCreatorId])
const appendQuickEvents = useCallback((
logbookId: string,
entryId: string,
partialEvents: Partial<LogEventPayload>[]
) => {
const creatorId = getActiveCreatorId()
const mapped = partialEvents.map((p) => ({ creatorId, ...p }))
return apiAppendQuickEvents(logbookId, entryId, mapped)
}, [getActiveCreatorId])
const appendTankRefill = useCallback((
logbookId: string,
entryId: string,
tank: 'fuel' | 'freshwater',
addLiters: number,
event: Partial<LogEventPayload>
) => {
return apiAppendTankRefill(
logbookId,
entryId,
tank,
addLiters,
{ creatorId: getActiveCreatorId(), ...event }
)
}, [getActiveCreatorId])
const defaultSails = useMemo( const defaultSails = useMemo(
() => (i18n.language === 'de' () => (i18n.language === 'de'
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker'] ? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
@@ -237,6 +303,8 @@ export default function LiveLogView({
setDayOfTravel(String(loaded.data.dayOfTravel || '')) setDayOfTravel(String(loaded.data.dayOfTravel || ''))
setDate(String(loaded.data.date || '')) setDate(String(loaded.data.date || ''))
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e })))) setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
}, []) }, [])
const refreshEntry = useCallback(async (id: string) => { const refreshEntry = useCallback(async (id: string) => {
@@ -1152,6 +1220,11 @@ export default function LiveLogView({
return ( return (
<li key={`${event.time}-${index}`} className="live-log-entry"> <li key={`${event.time}-${index}`} className="live-log-entry">
<time className="live-log-time">{event.time}</time> <time className="live-log-time">{event.time}</time>
<CreatorAvatar
creatorId={event.creatorId}
crewSnapshotsById={crewSnapshotsById}
size={24}
/>
<div className="live-log-summary-block"> <div className="live-log-summary-block">
<span className="live-log-summary">{summary}</span> <span className="live-log-summary">{summary}</span>
{voiceId && ( {voiceId && (
+103 -8
View File
@@ -43,6 +43,53 @@ export default function LiveVoiceCapture({
const [previewMime, setPreviewMime] = useState('audio/webm') const [previewMime, setPreviewMime] = useState('audio/webm')
const [previewDurationSec, setPreviewDurationSec] = useState(0) const [previewDurationSec, setPreviewDurationSec] = useState(0)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const log = useCallback((msg: string) => {
console.log(`[VoiceDebug] ${msg}`)
}, [])
const previewAudioRef = useRef<HTMLAudioElement | null>(null)
useEffect(() => {
const el = previewAudioRef.current
if (!el) {
log('previewAudioRef is null')
return
}
log('Preview audio player loaded. readyState=' + el.readyState + ', duration=' + el.duration + ', src=' + el.src)
const handleLoadedMetadata = () => {
log('loadedmetadata event fired. readyState=' + el.readyState + ', duration=' + el.duration)
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
log('Duration correction hack triggered (duration=' + el.duration + '). Seeking to 1e10...')
el.currentTime = 1e10
const onTimeUpdate = () => {
log('timeupdate event. currentTime=' + el.currentTime + ', duration=' + el.duration)
el.currentTime = 0
el.removeEventListener('timeupdate', onTimeUpdate)
log('currentTime reset to 0. Final duration=' + el.duration)
}
el.addEventListener('timeupdate', onTimeUpdate)
} else {
log('Duration correction skipped (duration is valid)')
}
}
if (el.readyState >= 1) {
log('readyState >= 1. Executing hack immediately...')
handleLoadedMetadata()
} else {
log('readyState = 0. Adding loadedmetadata event listener...')
el.addEventListener('loadedmetadata', handleLoadedMetadata)
}
log('Calling el.load() to force loading of the media resource...')
el.load()
return () => {
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
}
}, [previewUrl, log])
const stopStream = useCallback(() => { const stopStream = useCallback(() => {
for (const track of streamRef.current?.getTracks() ?? []) { for (const track of streamRef.current?.getTracks() ?? []) {
@@ -115,22 +162,46 @@ export default function LiveVoiceCapture({
const startRecording = async () => { const startRecording = async () => {
setMicError(null) setMicError(null)
chunksRef.current = [] chunksRef.current = []
log('startRecording flow triggered')
if (!navigator.mediaDevices?.getUserMedia) { if (!navigator.mediaDevices?.getUserMedia) {
log('navigator.mediaDevices.getUserMedia is unavailable')
setMicError(t('logs.live_voice_mic_denied')) setMicError(t('logs.live_voice_mic_denied'))
return return
} }
try { try {
log('Requesting getUserMedia audio stream...')
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream streamRef.current = stream
log('Stream obtained successfully. active=' + stream.active)
stream.getTracks().forEach((track, i) => {
log(`Track ${i}: label="${track.label}" enabled=${track.enabled} readyState=${track.readyState} muted=${track.muted}`)
})
const mimeType = pickMediaRecorderMimeType() const mimeType = pickMediaRecorderMimeType()
log('MIME type candidates support check:')
const MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus'
]
MIME_CANDIDATES.forEach(mime => {
log(` - ${mime}: ${MediaRecorder.isTypeSupported(mime) ? 'SUPPORTED' : 'UNSUPPORTED'}`)
})
log('Selected MIME from picker: ' + mimeType)
const recorder = mimeType const recorder = mimeType
? new MediaRecorder(stream, { mimeType }) ? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream) : new MediaRecorder(stream)
mediaRecorderRef.current = recorder mediaRecorderRef.current = recorder
const resolvedMime = recorder.mimeType || mimeType || 'audio/webm' const resolvedMime = recorder.mimeType || mimeType || 'audio/webm'
log('MediaRecorder created. Resolved mime=' + resolvedMime)
recorder.ondataavailable = (ev) => { recorder.ondataavailable = (ev) => {
if (ev.data.size > 0) chunksRef.current.push(ev.data) log(`ondataavailable event: data size=${ev.data?.size} bytes`)
if (ev.data && ev.data.size > 0) {
chunksRef.current.push(ev.data)
}
} }
recorder.onstop = () => { recorder.onstop = () => {
@@ -138,45 +209,67 @@ export default function LiveVoiceCapture({
VOICE_MEMO_MAX_DURATION_SEC, VOICE_MEMO_MAX_DURATION_SEC,
Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000)) Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000))
) )
log(`onstop triggered. durationSec=${durationSec}. Wrapping in 50ms timeout...`)
setTimeout(() => {
log(`Creating Blob from ${chunksRef.current.length} chunks. Resolved mime=${resolvedMime}`)
const totalChunksSize = chunksRef.current.reduce((acc, chunk) => acc + chunk.size, 0)
log(`Total raw chunks size: ${totalChunksSize} bytes`)
const blob = new Blob(chunksRef.current, { type: resolvedMime }) const blob = new Blob(chunksRef.current, { type: resolvedMime })
chunksRef.current = [] chunksRef.current = []
stopStream() stopStream()
log(`Blob finalized: size=${blob.size} bytes, type=${blob.type}`)
try { try {
assertVoiceMemoBlobSize(blob) assertVoiceMemoBlobSize(blob)
log('Blob size assertion passed. Calling finishRecording...')
finishRecording(blob, resolvedMime, durationSec) finishRecording(blob, resolvedMime, durationSec)
} catch { } catch (err) {
log('Blob size assertion failed (too large)')
setMicError(t('logs.live_voice_too_large')) setMicError(t('logs.live_voice_too_large'))
setPhase('idle') setPhase('idle')
} }
}, 50)
} }
recorder.onerror = () => { recorder.onerror = (ev) => {
log('MediaRecorder onerror triggered: ' + JSON.stringify(ev))
setMicError(t('logs.live_voice_record_failed')) setMicError(t('logs.live_voice_record_failed'))
resetAll() resetAll()
} }
startedAtRef.current = Date.now() startedAtRef.current = Date.now()
recorder.start(200) log('Calling recorder.start()...')
recorder.start()
log('recorder.start() called. State=' + recorder.state)
setPhase('recording') setPhase('recording')
setElapsedSec(0) setElapsedSec(0)
timerRef.current = window.setInterval(() => { timerRef.current = window.setInterval(() => {
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000) const sec = Math.floor((Date.now() - startedAtRef.current) / 1000)
setElapsedSec(sec) setElapsedSec(sec)
if (sec >= VOICE_MEMO_MAX_DURATION_SEC) { if (sec >= VOICE_MEMO_MAX_DURATION_SEC) {
log('Max duration reached. Stopping recording...')
stopRecording() stopRecording()
} }
}, 250) }, 250)
} catch { } catch (err: any) {
log('Error in startRecording try-catch block: ' + (err instanceof Error ? err.stack || err.message : String(err)))
setMicError(t('logs.live_voice_mic_denied')) setMicError(t('logs.live_voice_mic_denied'))
stopStream() stopStream()
} }
} }
const handleSave = async () => { const handleSave = async () => {
if (!previewBlob || saving || busy) return if (!previewBlob || saving || busy) {
log('handleSave ignored. previewBlob=' + (previewBlob ? 'PRESENT' : 'NULL') + ' saving=' + saving + ' busy=' + busy)
return
}
log('handleSave triggered. Saving blob size=' + previewBlob.size + ' mime=' + previewMime + ' duration=' + previewDurationSec)
setSaving(true) setSaving(true)
try { try {
onSave(previewBlob, previewMime, previewDurationSec) log('Invoking onSave callback...')
await onSave(previewBlob, previewMime, previewDurationSec)
log('onSave callback successfully finished!')
} catch (err: any) {
log('Error during onSave execution: ' + (err instanceof Error ? err.stack || err.message : String(err)))
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -241,7 +334,7 @@ export default function LiveVoiceCapture({
{phase === 'preview' && previewUrl && ( {phase === 'preview' && previewUrl && (
<> <>
<audio className="voice-memo-player" controls src={previewUrl} preload="auto" /> <audio ref={previewAudioRef} className="voice-memo-player" controls src={previewUrl} preload="auto" />
{onCaptionChange && ( {onCaptionChange && (
<label className="live-voice-caption-field"> <label className="live-voice-caption-field">
<span>{t('logs.live_voice_caption_label')}</span> <span>{t('logs.live_voice_caption_label')}</span>
@@ -278,6 +371,8 @@ export default function LiveVoiceCapture({
</div> </div>
</> </>
)} )}
</div> </div>
</div> </div>
) )
+41 -3
View File
@@ -11,6 +11,7 @@ import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles } from 'lucide-react' import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx' import PhotoCapture from './PhotoCapture.tsx'
import EventRemarksCell from './EventRemarksCell.tsx' import EventRemarksCell from './EventRemarksCell.tsx'
import CreatorAvatar from './CreatorAvatar.tsx'
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js' import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js' import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js' import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
@@ -173,6 +174,24 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
}) })
} }
function findActiveCreatorId(
activeUsername: string | null,
crewSnapshotsById: Record<string, any>,
selectedSkipperId: string | null
): string {
const username = (activeUsername || '').trim()
if (username) {
const matchEntry = Object.entries(crewSnapshotsById).find(
([_, snap]) => (snap?.name || '').trim().toLowerCase() === username.toLowerCase()
)
if (matchEntry) {
return matchEntry[0]
}
return username
}
return selectedSkipperId || 'skipper'
}
interface LogEntryEditorProps { interface LogEntryEditorProps {
entryId: string entryId: string
logbookId: string logbookId: string
@@ -418,8 +437,17 @@ export default function LogEntryEditor({
}) })
}, [buildPayloadForSigning, signSkipper, signCrew]) }, [buildPayloadForSigning, signSkipper, signCrew])
const buildEventFromForm = (): LogEvent => const buildEventFromForm = (): LogEvent => {
normalizeLogEvent({ let creatorId: string | undefined = undefined
if (editingEventIndex !== null && events[editingEventIndex]) {
creatorId = events[editingEventIndex].creatorId
}
if (!creatorId) {
const activeUsername = localStorage.getItem('active_username')
creatorId = findActiveCreatorId(activeUsername, entryCrew.crewSnapshotsById, entryCrew.selectedSkipperId)
}
return normalizeLogEvent({
time: evTime, time: evTime,
mgk: evMgk, mgk: evMgk,
rwk: evRwk, rwk: evRwk,
@@ -436,8 +464,10 @@ export default function LogEntryEditor({
distance: evDistance, distance: evDistance,
gpsLat: evGpsLat, gpsLat: evGpsLat,
gpsLng: evGpsLng, gpsLng: evGpsLng,
remarks: evRemarks remarks: evRemarks,
creatorId
}) })
}
const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => { const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => {
if (editingEventIndex !== null) { if (editingEventIndex !== null) {
@@ -1815,6 +1845,7 @@ export default function LogEntryEditor({
<thead> <thead>
<tr> <tr>
<th>{t('logs.event_time')}</th> <th>{t('logs.event_time')}</th>
<th>{t('logs.event_creator')}</th>
<th>{t('logs.event_mgk')}</th> <th>{t('logs.event_mgk')}</th>
<th>{t('logs.event_rwk')}</th> <th>{t('logs.event_rwk')}</th>
<th>{t('logs.event_wind_direction')}</th> <th>{t('logs.event_wind_direction')}</th>
@@ -1831,6 +1862,13 @@ export default function LogEntryEditor({
{events.map((ev, idx) => ( {events.map((ev, idx) => (
<tr key={idx}> <tr key={idx}>
<td className="font-mono">{ev.time}</td> <td className="font-mono">{ev.time}</td>
<td style={{ textAlign: 'center', width: '40px', verticalAlign: 'middle' }}>
<CreatorAvatar
creatorId={ev.creatorId}
crewSnapshotsById={entryCrew.crewSnapshotsById}
size={24}
/>
</td>
<td>{ev.mgk ? `${ev.mgk}°` : '—'}</td> <td>{ev.mgk ? `${ev.mgk}°` : '—'}</td>
<td>{ev.rwk ? `${ev.rwk}°` : '—'}</td> <td>{ev.rwk ? `${ev.rwk}°` : '—'}</td>
<td>{ev.windDirection || '—'}</td> <td>{ev.windDirection || '—'}</td>
+5 -1
View File
@@ -15,11 +15,13 @@ import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiO
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx' import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx' import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './ProfileHeaderButton.tsx' import ProfileHeaderButton from './ProfileHeaderButton.tsx'
import AdminHeaderButton from './AdminHeaderButton.tsx'
interface LogbookDashboardProps { interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void onSelectLogbook: (id: string, title: string) => void
onLogout: () => void onLogout: () => void
onOpenProfile: () => void onOpenProfile: () => void
onOpenAdmin?: () => void
} }
type LogbookSortKey = 'name' | 'date' type LogbookSortKey = 'name' | 'date'
@@ -42,7 +44,7 @@ function sortLogbooks(
return sorted return sorted
} }
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) { export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile, onOpenAdmin }: LogbookDashboardProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { showConfirm } = useDialog() const { showConfirm } = useDialog()
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([]) const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
@@ -388,6 +390,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
<ProfileHeaderButton onClick={onOpenProfile} /> <ProfileHeaderButton onClick={onOpenProfile} />
{onOpenAdmin && <AdminHeaderButton onClick={onOpenAdmin} />}
{/* Lang toggle */} {/* Lang toggle */}
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language"> <button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
<Languages size={18} /> <Languages size={18} />
+34 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js' import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js' import { getActiveMasterKey } from '../services/auth.js'
@@ -30,6 +30,38 @@ export default function VoiceMemoPlayer({
const [src, setSrc] = useState<string | null>(preloaded?.audio ?? null) const [src, setSrc] = useState<string | null>(preloaded?.audio ?? null)
const [error, setError] = useState(false) const [error, setError] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
useEffect(() => {
const el = audioRef.current
if (!el) return
const handleLoadedMetadata = () => {
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
el.currentTime = 1e10
const onTimeUpdate = () => {
el.currentTime = 0
el.removeEventListener('timeupdate', onTimeUpdate)
}
el.addEventListener('timeupdate', onTimeUpdate)
}
}
if (el.readyState >= 1) {
handleLoadedMetadata()
} else {
el.addEventListener('loadedmetadata', handleLoadedMetadata)
}
if (src) {
el.load()
}
return () => {
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
}
}, [src])
useEffect(() => { useEffect(() => {
if (preloaded?.audio) { if (preloaded?.audio) {
setSrc(preloaded.audio) setSrc(preloaded.audio)
@@ -75,7 +107,7 @@ export default function VoiceMemoPlayer({
return ( return (
<div className="voice-memo-player-shell"> <div className="voice-memo-player-shell">
<audio className={playerClass} controls preload="none" src={src} /> <audio ref={audioRef} className={playerClass} controls preload="metadata" src={src} />
</div> </div>
) )
} }
+12 -2
View File
@@ -43,7 +43,8 @@
"deviation": "Tabel over distraktioner", "deviation": "Tabel over distraktioner",
"logs": "Indlæg i logbogen", "logs": "Indlæg i logbogen",
"stats": "Statistik", "stats": "Statistik",
"settings": "Indstillinger" "settings": "Indstillinger",
"admin": "Admin"
}, },
"auth": { "auth": {
"welcome": "Velkommen til Kapteins Daagbok.", "welcome": "Velkommen til Kapteins Daagbok.",
@@ -90,7 +91,15 @@
"use_localhost_link": "Skift til localhost", "use_localhost_link": "Skift til localhost",
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.", "error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.",
"error_invalid_rp_id": "Passkey-domæne matcher ikke (RP ID). Brug http://localhost:5173 med RP_ID=localhost i .env til lokal udvikling.", "error_invalid_rp_id": "Passkey-domæne matcher ikke (RP ID). Brug http://localhost:5173 med RP_ID=localhost i .env til lokal udvikling.",
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen." "error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen.",
"restore_checking": "Tjekker session…",
"restore_title": "Gendan session",
"restore_subtitle": "Du er stadig logget ind. Lås din logbog op med passkey eller PIN.",
"restore_unlocking": "Låser op…",
"restore_with_passkey": "Lås op med passkey ({{name}})",
"restore_with_pin": "Lås op med PIN",
"restore_pin_warning": "Indtast din lokale PIN for at låse logbogen op efter genindlæsning.",
"restore_other_account": "Log ind med en anden konto"
}, },
"pwa": { "pwa": {
"title": "Installer app", "title": "Installer app",
@@ -344,6 +353,7 @@
"carry_over_tanks_yes": "Tag over", "carry_over_tanks_yes": "Tag over",
"carry_over_tanks_no": "Start med 0", "carry_over_tanks_no": "Start med 0",
"event_title": "Kronologisk hændelseslog", "event_title": "Kronologisk hændelseslog",
"event_creator": "Indtastet af",
"no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.", "no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.",
"event_time": "Tidspunkt på dagen", "event_time": "Tidspunkt på dagen",
"event_mgk": "MgK-kursus", "event_mgk": "MgK-kursus",
+12 -2
View File
@@ -43,7 +43,8 @@
"deviation": "Ablenkungstabelle", "deviation": "Ablenkungstabelle",
"logs": "Logbucheinträge", "logs": "Logbucheinträge",
"stats": "Statistik", "stats": "Statistik",
"settings": "Einstellungen" "settings": "Einstellungen",
"admin": "Admin"
}, },
"auth": { "auth": {
"welcome": "Willkommen bei Kapteins Daagbok", "welcome": "Willkommen bei Kapteins Daagbok",
@@ -90,7 +91,15 @@
"use_localhost_link": "Zu localhost wechseln", "use_localhost_link": "Zu localhost wechseln",
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.", "error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
"error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.", "error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.",
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden." "error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden.",
"restore_checking": "Session wird geprüft…",
"restore_title": "Session wiederherstellen",
"restore_subtitle": "Deine Anmeldung ist noch aktiv. Entsperre dein Logbuch mit Passkey oder PIN.",
"restore_unlocking": "Wird entsperrt…",
"restore_with_passkey": "Mit Passkey entsperren ({{name}})",
"restore_with_pin": "Mit PIN entsperren",
"restore_pin_warning": "Gib deine lokale PIN ein, um dein Logbuch nach dem Neuladen zu entsperren.",
"restore_other_account": "Anderer Account anmelden"
}, },
"pwa": { "pwa": {
"title": "App installieren", "title": "App installieren",
@@ -344,6 +353,7 @@
"carry_over_tanks_yes": "Übernehmen", "carry_over_tanks_yes": "Übernehmen",
"carry_over_tanks_no": "Mit 0 starten", "carry_over_tanks_no": "Mit 0 starten",
"event_title": "Chronologisches Ereignisprotokoll", "event_title": "Chronologisches Ereignisprotokoll",
"event_creator": "Eingetragen von",
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.", "no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
"event_time": "Uhrzeit", "event_time": "Uhrzeit",
"event_mgk": "MgK Kurs", "event_mgk": "MgK Kurs",
+12 -2
View File
@@ -43,7 +43,8 @@
"deviation": "Deviation Table", "deviation": "Deviation Table",
"logs": "Logbook Entries", "logs": "Logbook Entries",
"stats": "Statistics", "stats": "Statistics",
"settings": "Settings" "settings": "Settings",
"admin": "Admin"
}, },
"auth": { "auth": {
"welcome": "Welcome to Kapteins Daagbok", "welcome": "Welcome to Kapteins Daagbok",
@@ -90,7 +91,15 @@
"use_localhost_link": "Switch to localhost", "use_localhost_link": "Switch to localhost",
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.", "error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
"error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.", "error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.",
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again." "error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again.",
"restore_checking": "Checking session…",
"restore_title": "Restore session",
"restore_subtitle": "You are still signed in. Unlock your logbook with passkey or PIN.",
"restore_unlocking": "Unlocking…",
"restore_with_passkey": "Unlock with passkey ({{name}})",
"restore_with_pin": "Unlock with PIN",
"restore_pin_warning": "Enter your local PIN to unlock your logbook after reload.",
"restore_other_account": "Sign in with another account"
}, },
"pwa": { "pwa": {
"title": "Install app", "title": "Install app",
@@ -344,6 +353,7 @@
"carry_over_tanks_yes": "Carry over", "carry_over_tanks_yes": "Carry over",
"carry_over_tanks_no": "Start at 0", "carry_over_tanks_no": "Start at 0",
"event_title": "Chronological Event Logbook", "event_title": "Chronological Event Logbook",
"event_creator": "Entered by",
"no_events": "No events logged for this travel day yet.", "no_events": "No events logged for this travel day yet.",
"event_time": "Time", "event_time": "Time",
"event_mgk": "MgK Course", "event_mgk": "MgK Course",
+12 -2
View File
@@ -43,7 +43,8 @@
"deviation": "Tabell over distraksjoner", "deviation": "Tabell over distraksjoner",
"logs": "Loggbokoppføringer", "logs": "Loggbokoppføringer",
"stats": "Statistikk", "stats": "Statistikk",
"settings": "Innstillinger" "settings": "Innstillinger",
"admin": "Admin"
}, },
"auth": { "auth": {
"welcome": "Velkommen til Kapteins Daagbok", "welcome": "Velkommen til Kapteins Daagbok",
@@ -90,7 +91,15 @@
"use_localhost_link": "Bytt til localhost", "use_localhost_link": "Bytt til localhost",
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.", "error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.",
"error_invalid_rp_id": "Passkey-domene stemmer ikke (RP ID). Bruk http://localhost:5173 med RP_ID=localhost i .env for lokal utvikling.", "error_invalid_rp_id": "Passkey-domene stemmer ikke (RP ID). Bruk http://localhost:5173 med RP_ID=localhost i .env for lokal utvikling.",
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen." "error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen.",
"restore_checking": "Sjekker økt…",
"restore_title": "Gjenopprett økt",
"restore_subtitle": "Du er fortsatt innlogget. Lås opp loggboken med passkey eller PIN.",
"restore_unlocking": "Låser opp…",
"restore_with_passkey": "Lås opp med passkey ({{name}})",
"restore_with_pin": "Lås opp med PIN",
"restore_pin_warning": "Skriv inn din lokale PIN for å låse opp loggboken etter omlasting.",
"restore_other_account": "Logg inn med en annen konto"
}, },
"pwa": { "pwa": {
"title": "Installer app", "title": "Installer app",
@@ -344,6 +353,7 @@
"carry_over_tanks_yes": "Ta over", "carry_over_tanks_yes": "Ta over",
"carry_over_tanks_no": "Begynn med 0", "carry_over_tanks_no": "Begynn med 0",
"event_title": "Kronologisk hendelseslogg", "event_title": "Kronologisk hendelseslogg",
"event_creator": "Registrert av",
"no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.", "no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.",
"event_time": "Tid på døgnet", "event_time": "Tid på døgnet",
"event_mgk": "MgK-kurs", "event_mgk": "MgK-kurs",
+12 -2
View File
@@ -43,7 +43,8 @@
"deviation": "Distraktionsbord", "deviation": "Distraktionsbord",
"logs": "Loggboksanteckningar", "logs": "Loggboksanteckningar",
"stats": "Statistik", "stats": "Statistik",
"settings": "Inställningar" "settings": "Inställningar",
"admin": "Admin"
}, },
"auth": { "auth": {
"welcome": "Välkommen till Kapteins Daagbok", "welcome": "Välkommen till Kapteins Daagbok",
@@ -90,7 +91,15 @@
"use_localhost_link": "Byt till localhost", "use_localhost_link": "Byt till localhost",
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.", "error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.",
"error_invalid_rp_id": "Passkey-domänen matchar inte (RP ID). Använd http://localhost:5173 med RP_ID=localhost i .env för lokal utveckling.", "error_invalid_rp_id": "Passkey-domänen matchar inte (RP ID). Använd http://localhost:5173 med RP_ID=localhost i .env för lokal utveckling.",
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen." "error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen.",
"restore_checking": "Kontrollerar session…",
"restore_title": "Återställ session",
"restore_subtitle": "Du är fortfarande inloggad. Lås upp din loggbok med passkey eller PIN.",
"restore_unlocking": "Låser upp…",
"restore_with_passkey": "Lås upp med passkey ({{name}})",
"restore_with_pin": "Lås upp med PIN",
"restore_pin_warning": "Ange din lokala PIN för att låsa upp loggboken efter omladdning.",
"restore_other_account": "Logga in med ett annat konto"
}, },
"pwa": { "pwa": {
"title": "Installera app", "title": "Installera app",
@@ -344,6 +353,7 @@
"carry_over_tanks_yes": "Ta över", "carry_over_tanks_yes": "Ta över",
"carry_over_tanks_no": "Börja med 0", "carry_over_tanks_no": "Börja med 0",
"event_title": "Kronologisk händelselogg", "event_title": "Kronologisk händelselogg",
"event_creator": "Registrerad av",
"no_events": "Inga händelser inlagda för denna resdag ännu.", "no_events": "Inga händelser inlagda för denna resdag ännu.",
"event_time": "Tid på dygnet", "event_time": "Tid på dygnet",
"event_mgk": "MgK-kurs", "event_mgk": "MgK-kurs",
+74
View File
@@ -0,0 +1,74 @@
import { ApiError, apiJson } from './api.js'
const ADMIN_BASE = '/api/admin'
export interface AdminMe {
isAdmin: boolean
userId: string
}
export interface AdminSummary {
totalUsers: number
totalLogbooks: number
totalPhotos: number
totalVoiceMemos: number
totalGpsTracks: number
totalCollaborations: number
totalInvitations: number
aiSummaryEntries: number
}
export type AdminTimeBucket = 'day' | 'week' | 'month'
export interface AdminTimeSeriesPoint {
date: string
count: number
}
export interface AdminTimeSeriesMetric {
metric: string
points: AdminTimeSeriesPoint[]
}
export interface AdminTimeSeriesResponse {
bucket: AdminTimeBucket
windowDays: number
series: AdminTimeSeriesMetric[]
}
export async function fetchAdminMe(): Promise<AdminMe> {
return await apiJson<AdminMe>(`${ADMIN_BASE}/me`)
}
/** Returns true only for users listed in server ADMIN_USER_IDS. */
export async function checkAdminAccess(): Promise<boolean> {
try {
await fetchAdminMe()
return true
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
return false
}
return false
}
}
export async function fetchAdminSummary(): Promise<AdminSummary> {
return await apiJson<AdminSummary>(`${ADMIN_BASE}/summary`)
}
export async function fetchAdminTimeSeries(
params: { bucket?: AdminTimeBucket; windowDays?: number } = {}
): Promise<AdminTimeSeriesResponse> {
const search = new URLSearchParams()
if (params.bucket) {
search.set('bucket', params.bucket)
}
if (params.windowDays && Number.isFinite(params.windowDays)) {
search.set('window', String(params.windowDays))
}
const query = search.toString()
const url = query ? `${ADMIN_BASE}/timeseries?${query}` : `${ADMIN_BASE}/timeseries`
return await apiJson<AdminTimeSeriesResponse>(url)
}
+9
View File
@@ -64,6 +64,15 @@ export function persistSessionUserId(userId: string | undefined): void {
} }
} }
/** Username to use when re-unlocking after reload (active account or sole remembered user). */
export function resolveRestoreUsername(): string | null {
const stored = localStorage.getItem('active_username')
if (stored) return stored
const known = getKnownUsernames()
if (known.length === 1) return known[0]
return null
}
export async function reauthWithPasskey(): Promise<boolean> { export async function reauthWithPasskey(): Promise<boolean> {
const options = await apiJson<any>(`${API_BASE}/reauth-options`, { const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
method: 'POST' method: 'POST'
+23
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { import {
hasUnlockedLocalCrypto, hasUnlockedLocalCrypto,
hasUnlockedLocalSession, hasUnlockedLocalSession,
resolveRestoreUsername,
setActiveMasterKey setActiveMasterKey
} from './auth.js' } from './auth.js'
@@ -33,6 +34,28 @@ describe('local session unlock checks', () => {
}) })
}) })
describe('resolveRestoreUsername', () => {
beforeEach(() => {
localStorage.clear()
})
it('prefers active_username from storage', () => {
localStorage.setItem('active_username', 'captain')
localStorage.setItem('daagbox_known_users', JSON.stringify(['other']))
expect(resolveRestoreUsername()).toBe('captain')
})
it('falls back to a single remembered user', () => {
localStorage.setItem('daagbox_known_users', JSON.stringify(['solo']))
expect(resolveRestoreUsername()).toBe('solo')
})
it('returns null when multiple users and no active username', () => {
localStorage.setItem('daagbox_known_users', JSON.stringify(['alpha', 'beta']))
expect(resolveRestoreUsername()).toBeNull()
})
})
describe('persistSessionUserId', () => { describe('persistSessionUserId', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear() localStorage.clear()
+14 -3
View File
@@ -77,7 +77,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary', 'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
'Skipper Signature', 'Crew Signature', 'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)', 'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
'Event Time', 'MgK Course', 'RwK Course', 'Event Time', 'Event Creator', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility', 'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)', 'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
'Latitude', 'Longitude', 'Remarks', 'Latitude', 'Longitude', 'Remarks',
@@ -122,6 +122,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const greywaterLevel = entry.greywater?.level ?? ''; const greywaterLevel = entry.greywater?.level ?? '';
const aiSummary = entry.aiSummary ?? ''; const aiSummary = entry.aiSummary ?? '';
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
const eventsList = entry.events || []; const eventsList = entry.events || [];
if (eventsList.length === 0) { if (eventsList.length === 0) {
// Create one row even if there are no events for the day // Create one row even if there are no events for the day
@@ -129,7 +130,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
dateVal, travelDay, dep, dest, aiSummary, dateVal, travelDay, dep, dest, aiSummary,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, motorH, trackDist, trackMax, trackAvg, motorH,
'', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '',
@@ -142,11 +143,21 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
// Sort events chronologically by time // Sort events chronologically by time
const sortedEvents = sortLogEventsByTime(eventsList); const sortedEvents = sortLogEventsByTime(eventsList);
for (const ev of sortedEvents) { for (const ev of sortedEvents) {
const creatorSnap = ev.creatorId ? crewSnapshots[ev.creatorId] : null;
let creatorName = '';
if (creatorSnap) {
creatorName = creatorSnap.name || '';
} else if (ev.creatorId === 'skipper') {
creatorName = 'Skipper';
} else if (ev.creatorId) {
creatorName = ev.creatorId;
}
rows.push([ rows.push([
dateVal, travelDay, dep, dest, aiSummary, dateVal, travelDay, dep, dest, aiSummary,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, motorH, trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '', ev.time || '', creatorName, ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '', ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.visibility || '', ev.visibility || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '', ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
+66 -12
View File
@@ -13,12 +13,13 @@ function formatPasskeySignDate(signedAt: string): string {
} }
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> { export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = ''; let yachtName = '', owner = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
let entry: any = null; let entry: any = null;
if (preloadedData) { if (preloadedData) {
const yacht = preloadedData.yacht || {}; const yacht = preloadedData.yacht || {};
yachtName = yacht.name || ''; yachtName = yacht.name || '';
owner = yacht.owner || '';
homePort = yacht.port || ''; homePort = yacht.port || '';
registration = yacht.registrationNumber || yacht.registration || ''; registration = yacht.registrationNumber || yacht.registration || '';
callsign = yacht.callSign || ''; callsign = yacht.callSign || '';
@@ -35,6 +36,7 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
const yacht = await resolveVesselForLogbook(logbookId) const yacht = await resolveVesselForLogbook(logbookId)
if (yacht) { if (yacht) {
yachtName = yacht.name || '' yachtName = yacht.name || ''
owner = yacht.owner || ''
homePort = yacht.homePort || '' homePort = yacht.homePort || ''
registration = yacht.registrationNumber || '' registration = yacht.registrationNumber || ''
callsign = yacht.callSign || '' callsign = yacht.callSign || ''
@@ -74,24 +76,56 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.setFontSize(8.5); doc.setFontSize(8.5);
doc.setFont('Helvetica', 'normal'); doc.setFont('Helvetica', 'normal');
doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21); doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21);
doc.text(`Heimathafen: ${homePort || '—'}`, 60, 21); doc.text(`Eigner: ${owner || '—'}`, 55, 21);
doc.text(`Kennzeichen: ${registration || '—'}`, 110, 21); doc.text(`Heimathafen: ${homePort || '—'}`, 100, 21);
doc.text(`Rufzeichen: ${callsign || '—'}`, 160, 21); doc.text(`Kennzeichen: ${registration || '—'}`, 145, 21);
doc.text(`ATIS: ${atis || '—'}`, 210, 21); doc.text(`Rufzeichen: ${callsign || '—'}`, 190, 21);
doc.text(`MMSI: ${mmsi || '—'}`, 250, 21); doc.text(`ATIS: ${atis || '—'}`, 230, 21);
doc.text(`MMSI: ${mmsi || '—'}`, 260, 21);
doc.text(`Datum: ${entry.date || '—'}`, 10, 23); doc.text(`Datum: ${entry.date || '—'}`, 10, 24);
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 23); doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 24);
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 23); doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 24);
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 23); doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 24);
// Format Crew names with initials
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {}
const crewList: string[] = []
if (entry.selectedSkipperId && crewSnapshots[entry.selectedSkipperId]) {
const name = crewSnapshots[entry.selectedSkipperId].name || 'Skipper'
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || 'S'
crewList.push(`${name} [${initial}] (Skipper)`)
} else if (crewSnapshots['skipper']) {
const name = crewSnapshots['skipper'].name || 'Skipper'
crewList.push(`${name} [S] (Skipper)`)
}
if (Array.isArray(entry.selectedCrewIds)) {
for (const crewId of entry.selectedCrewIds) {
const snap = crewSnapshots[crewId]
if (snap) {
const name = snap.name || ''
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?'
crewList.push(`${name} [${initial}]`)
}
}
}
const crewText = crewList.length > 0 ? `Besatzung (Crew): ${crewList.join(', ')}` : ''
if (entry.trackDistanceNm) {
doc.setFont('Helvetica', 'normal'); doc.setFont('Helvetica', 'normal');
if (entry.trackDistanceNm) {
doc.text( doc.text(
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`, `GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
10, 10,
27 27
); );
if (crewText) {
doc.text(crewText, 140, 27);
}
} else if (crewText) {
doc.text(crewText, 10, 27);
} }
// Divider line // Divider line
@@ -175,8 +209,28 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.text(gps, writeX + 1, y + 4.2); doc.text(gps, writeX + 1, y + 4.2);
writeX += colWidths[11]; writeX += colWidths[11];
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
let initial = '';
if (ev.creatorId) {
const snap = crewSnapshots[ev.creatorId];
let name = '';
if (snap) {
name = snap.name || '';
} else if (ev.creatorId === 'skipper') {
name = 'Skipper';
} else {
name = ev.creatorId;
}
if (name) {
initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '';
}
}
// Clip remarks to fit within the 94mm bounds // Clip remarks to fit within the 94mm bounds
const remarks = ev.remarks || ''; let remarks = ev.remarks || '';
if (initial) {
remarks = `[${initial}] ${remarks}`;
}
const maxChars = 65; const maxChars = 65;
const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks; const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks;
doc.text(clippedRemarks, writeX + 1, y + 4.2); doc.text(clippedRemarks, writeX + 1, y + 4.2);
+10 -1
View File
@@ -97,6 +97,14 @@ function buildEncryptedPayload(
consumption: fuel.consumption ?? 0 consumption: fuel.consumption ?? 0
} }
const entryCrew = data.selectedSkipperId
? {
selectedSkipperId: String(data.selectedSkipperId),
selectedCrewIds: Array.isArray(data.selectedCrewIds) ? data.selectedCrewIds.map(String) : [],
crewSnapshotsById: (data.crewSnapshotsById as Record<string, any>) || {}
}
: undefined
const payload = buildLogEntryPayload({ const payload = buildLogEntryPayload({
date: String(data.date || ''), date: String(data.date || ''),
dayOfTravel: String(data.dayOfTravel || ''), dayOfTravel: String(data.dayOfTravel || ''),
@@ -121,7 +129,8 @@ function buildEncryptedPayload(
motorHoursRaw != null && motorHoursRaw !== '' motorHoursRaw != null && motorHoursRaw !== ''
? parseFloat(String(motorHoursRaw)) ? parseFloat(String(motorHoursRaw))
: undefined, : undefined,
events: options.events events: options.events,
entryCrew
}) })
const clear = options.clearSignatures const clear = options.clearSignatures
+6 -4
View File
@@ -22,6 +22,7 @@ export interface LogEventPayload {
gpsLat: string gpsLat: string
gpsLng: string gpsLng: string
remarks: string remarks: string
creatorId?: string
} }
/** Calendar date YYYY-MM-DD in local timezone (matches logbook entry `date` field). */ /** Calendar date YYYY-MM-DD in local timezone (matches logbook entry `date` field). */
@@ -85,7 +86,7 @@ export function joinTimeHHMM(hours: string, minutes: string): string {
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [ const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState', 'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance', 'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
'gpsLat', 'gpsLng', 'remarks' 'gpsLat', 'gpsLng', 'remarks', 'creatorId'
] ]
/** Normalize partial/legacy events so all fields are strings (safe for form + save). */ /** Normalize partial/legacy events so all fields are strings (safe for form + save). */
@@ -109,10 +110,11 @@ export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<strin
distance: '', distance: '',
gpsLat: '', gpsLat: '',
gpsLng: '', gpsLng: '',
remarks: '' remarks: '',
creatorId: e.creatorId ? String(e.creatorId).trim() : undefined
} }
for (const key of LOG_EVENT_FIELDS) { for (const key of LOG_EVENT_FIELDS) {
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection') continue if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection' || key === 'creatorId') continue
normalized[key] = String(e[key] ?? '').trim() normalized[key] = String(e[key] ?? '').trim()
} }
return normalized return normalized
@@ -122,7 +124,7 @@ export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key]) return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
} }
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time') const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time' && key !== 'creatorId')
/** Draft with only a time (or empty fields) — not an unsaved log entry change. */ /** Draft with only a time (or empty fields) — not an unsaved log entry change. */
export function isLogEventDraftEmpty(event: LogEventPayload): boolean { export function isLogEventDraftEmpty(event: LogEventPayload): boolean {
+29
View File
@@ -33,8 +33,36 @@ function versionJsonPlugin(version: string): Plugin {
} }
} }
function readPlausibleConfig(): { plausibleEnabled: boolean; plausibleHost: string } {
const host = (process.env.PLAUSIBLE_HOST || 'https://plausible.elpatron.me').replace(/\/$/, '')
const flag = (process.env.PLAUSIBLE_ENABLED ?? 'true').trim().toLowerCase()
const plausibleEnabled = !['false', '0', 'no'].includes(flag)
return { plausibleEnabled, plausibleHost: host }
}
function runtimeConfigPlugin(): Plugin {
return {
name: 'runtime-config',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url !== '/runtime-config.json') return next()
res.setHeader('Content-Type', 'application/json')
res.end(`${JSON.stringify(readPlausibleConfig())}\n`)
})
},
writeBundle(options) {
const outDir = options.dir ?? resolve(__dirname, 'dist')
writeFileSync(
resolve(outDir, 'runtime-config.json'),
`${JSON.stringify(readPlausibleConfig(), null, 2)}\n`
)
}
}
}
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
envDir: resolve(__dirname, '..'),
test: { test: {
environment: 'happy-dom', environment: 'happy-dom',
include: ['src/**/*.test.ts'] include: ['src/**/*.test.ts']
@@ -59,6 +87,7 @@ export default defineConfig({
plugins: [ plugins: [
react(), react(),
versionJsonPlugin(readAppVersion()), versionJsonPlugin(readAppVersion()),
runtimeConfigPlugin(),
VitePWA({ VitePWA({
strategies: 'injectManifest', strategies: 'injectManifest',
srcDir: 'src', srcDir: 'src',
+71
View File
@@ -0,0 +1,71 @@
services:
db:
image: postgres:16-alpine
container_name: daagbox-staging-db
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: ${POSTGRES_DB:-daagbox_staging}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
context: ./server
dockerfile: Dockerfile
container_name: daagbox-staging-backend
restart: always
environment:
PORT: 5000
DATABASE_URL: "postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-daagbox_staging}?schema=public"
RP_ID: ${RP_ID:-localhost}
ORIGIN: ${ORIGIN:-http://localhost}
TRUST_PROXY: ${TRUST_PROXY:-1}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
SESSION_SECRET: ${SESSION_SECRET:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
NTFY_TOPIC: ${NTFY_TOPIC:-}
NTFY_TOKEN: ${NTFY_TOKEN:-}
command: sh -c "npx prisma db push && node dist/index.js"
healthcheck:
test: ["CMD", "node", "-e", "const http = require('http'); http.get('http://localhost:5000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
interval: 15s
timeout: 5s
start_period: 60s
retries: 5
depends_on:
db:
condition: service_healthy
frontend:
build:
context: .
dockerfile: client/Dockerfile
args:
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
container_name: daagbox-staging-frontend
restart: always
environment:
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-false}
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
ports:
- "80:80"
depends_on:
backend:
condition: service_healthy
volumes:
pgdata:
name: daagbox-staging-pgdata
+10
View File
@@ -35,10 +35,17 @@ services:
OpenRouterAPIKey: ${OpenRouterAPIKey:-} OpenRouterAPIKey: ${OpenRouterAPIKey:-}
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku} OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
SESSION_SECRET: ${SESSION_SECRET:-} SESSION_SECRET: ${SESSION_SECRET:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh} NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
NTFY_TOPIC: ${NTFY_TOPIC:-} NTFY_TOPIC: ${NTFY_TOPIC:-}
NTFY_TOKEN: ${NTFY_TOKEN:-} NTFY_TOKEN: ${NTFY_TOKEN:-}
command: sh -c "npx prisma db push && node dist/index.js" command: sh -c "npx prisma db push && node dist/index.js"
healthcheck:
test: ["CMD", "node", "-e", "const http = require('http'); http.get('http://localhost:5000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
interval: 15s
timeout: 5s
start_period: 60s
retries: 5
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -51,6 +58,9 @@ services:
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev} APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
container_name: daagbox-prod-frontend container_name: daagbox-prod-frontend
restart: always restart: always
environment:
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-true}
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
ports: ports:
- "80:80" - "80:80"
depends_on: depends_on:
+13 -6
View File
@@ -1,14 +1,14 @@
# Deployment: Nginx Proxy Manager & Security (Sprint 1) # Deployment: Nginx Proxy Manager & Security (Sprint 1)
Kapteins Daagbok läuft öffentlich unter **https://kapteins-daagbok.eu/** hinter **Nginx Proxy Manager** (NPM, z. B. `172.16.10.10`) mit Upstream auf den App-Stack (`172.16.10.110`). Kapteins Daagbok läuft öffentlich unter **https://kapteins-daagbok.eu/** (Produktion) und **https://staging.kapteins-daagbok.eu/** (Staging) hinter **Nginx Proxy Manager** (NPM, z. B. `172.16.10.10`) mit Upstream auf die App-VMs (`10.0.0.25` Prod, `10.0.0.27` Staging).
## NPM Proxy Host ## NPM Proxy Host
| Einstellung | Wert | | Einstellung | Wert |
|-------------|------| |-------------|------|
| Domain | `kapteins-daagbok.eu` | | Domain | `kapteins-daagbok.eu` / `staging.kapteins-daagbok.eu` |
| Scheme | `https` | | Scheme | `https` |
| Forward Hostname / IP | `172.16.10.110` (oder Container-Port auf dem Host) | | Forward Hostname / IP | `10.0.0.25` (Prod) / `10.0.0.27` (Staging) |
| Forward Port | `80` (Frontend-Nginx) | | Forward Port | `80` (Frontend-Nginx) |
| Websockets | an, falls genutzt | | Websockets | an, falls genutzt |
| Block Common Exploits | an | | Block Common Exploits | an |
@@ -40,13 +40,20 @@ TRUST_PROXY=1
## Security-Header ## Security-Header
- **HSTS, CSP (optional restriktiver):** können in NPM unter „Custom Headers“ oder im Advanced-Block gesetzt werden. - **HSTS, CSP (optional restriktiver):** können in NPM unter „Custom Headers“ oder im Advanced-Block gesetzt werden.
- **Basis-Header** für statische Dateien setzt [`client/nginx.conf`](../../client/nginx.conf) (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP inkl. Plausible). - **Basis-Header** für statische Dateien setzt [`client/nginx.conf.template`](../../client/nginx.conf.template) via Container-Entrypoint (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP optional inkl. Plausible).
### Plausible Analytics ### Plausible Analytics
Script-Host: `https://plausible.elpatron.me` — in CSP als `script-src` und `connect-src` erlaubt. Gemessene Site: `data-domain="kapteins-daagbok.eu"`. Konfiguration über `.env` (Frontend-Container):
Optional später: `analytics.kapteins-daagbok.eu` als Alias auf dieselbe Plausible-Instanz. ```env
PLAUSIBLE_ENABLED=true
PLAUSIBLE_HOST=https://plausible.elpatron.me
```
Staging-Default: `PLAUSIBLE_ENABLED=false` in [`docker-compose.staging.yml`](../../docker-compose.staging.yml).
Script-Host wird in CSP (`script-src`, `connect-src`) nur bei `PLAUSIBLE_ENABLED=true` freigegeben. `data-domain` ist immer der aktuelle Hostname (Prod vs. Staging getrennt, wenn Staging aktiviert wird).
## Nach Deploy prüfen ## Nach Deploy prüfen
+2 -2
View File
@@ -34,9 +34,9 @@ cd server && npm test
[`scripts/update-prod.sh`](../../scripts/update-prod.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung, vor dem SSH-Deploy). [`scripts/update-prod.sh`](../../scripts/update-prod.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung, vor dem SSH-Deploy).
```bash ```bash
./scripts/update-prod.sh ./scripts/update-prod.sh -dest prod
``` ```
Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh` Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh -dest prod`
Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)). Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)).
+106
View File
@@ -0,0 +1,106 @@
# Staging-Umgebung
Staging läuft auf **VM3** (`10.0.0.27`) unter **https://staging.kapteins-daagbok.eu/** — hinter Nginx Proxy Manager wie Produktion.
## Unterschiede zu Produktion
| | Staging | Produktion |
|---|---------|------------|
| Host | `10.0.0.27` | `10.0.0.25` |
| Verzeichnis | `/opt/kapteins-daagbok-staging` | `/opt/kapteins-daagbok` |
| Compose | `docker-compose.staging.yml` | `docker-compose.yml` |
| Deploy-Skript | `./scripts/update-prod.sh -dest stage` | `./scripts/update-prod.sh -dest prod` |
| Release-Tag | nein | ja (`v*`) |
| Datenbank-Volume | `daagbox-staging-pgdata` | `daagbox-prod-pgdata` |
Staging ist **vollständig isoliert**: eigene DB, Session-Secrets, Passkeys (`RP_ID=staging.kapteins-daagbok.eu`) und optional eigene VAPID-/Ntfy-Konfiguration.
## Erstinstallation (VM3)
```bash
ssh root@10.0.0.27
git clone https://gitea.elpatron.me/elpatron/kapteins-daagbok.git /opt/kapteins-daagbok-staging
cd /opt/kapteins-daagbok-staging
git checkout master
# .env anlegen — Secrets neu generieren, nicht von Prod kopieren
openssl rand -hex 24 # POSTGRES_PASSWORD
openssl rand -base64 48 # SESSION_SECRET
nano .env
docker compose -f docker-compose.staging.yml up -d --build
```
### `.env` (Staging)
```env
ORIGIN=https://staging.kapteins-daagbok.eu
RP_ID=staging.kapteins-daagbok.eu
TRUST_PROXY=1
POSTGRES_USER=postgres
POSTGRES_PASSWORD=<generiert>
POSTGRES_DB=daagbox_staging
SESSION_SECRET=<generiert>
NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=kapteins-daagbok-staging-feedback
# Analytics aus (Staging soll Prod-Statistik nicht verfälschen)
PLAUSIBLE_ENABLED=false
PLAUSIBLE_HOST=https://plausible.elpatron.me
```
Optional: `VAPID_*`, `OpenWeatherMapAPIKey`, `OpenRouterAPIKey`, `ADMIN_USER_IDS`, `NTFY_TOKEN`.
## Deploy vom Entwicklungsrechner
Führt `npm run check` aus, dann SSH-Deploy ohne Release-Tag:
```bash
./scripts/update-prod.sh -dest stage
```
Konfiguration via Umgebungsvariablen:
```bash
REMOTE_HOST=10.0.0.27 \
REMOTE_DIR=/opt/kapteins-daagbok-staging \
DEPLOY_BRANCH=master \
./scripts/update-prod.sh -dest stage
```
Notfall ohne Checks: `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh -dest stage`
## NPM (VM1)
| Einstellung | Wert |
|-------------|------|
| Domain | `staging.kapteins-daagbok.eu` |
| Forward Hostname / IP | `10.0.0.27` |
| Forward Port | `80` |
| SSL | Let's Encrypt |
Empfohlen: Custom Header `X-Robots-Tag: noindex, nofollow` (Staging nicht indexieren).
Details zu Proxy-Headern und Security: [npm-security.md](npm-security.md).
## Nach Deploy prüfen
1. https://staging.kapteins-daagbok.eu/api/health — `status: ok`
2. Neuen Test-Account registrieren (Prod-Passkeys funktionieren nicht auf Staging)
3. Passkey Login
4. Cookie `daagbok_session`: `Secure`, `HttpOnly`, `SameSite=Lax`
## Daten zurücksetzen
Staging-Daten sind wegwerfbar:
```bash
cd /opt/kapteins-daagbok-staging
docker compose -f docker-compose.staging.yml down
docker volume rm daagbox-staging-pgdata
docker compose -f docker-compose.staging.yml up -d
```
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 KiB

+318
View File
@@ -0,0 +1,318 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Kapteins Daagbok — Sharepic (Portrait)</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=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
width: 1080px;
height: 1920px;
font-family: 'Plus Jakarta Sans', sans-serif;
color: #e2e8f0;
background: #0f172a;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
padding: 100px 80px;
background:
radial-gradient(circle at 50% 10%, rgba(56, 189, 248, 0.18) 0%, transparent 45%),
radial-gradient(circle at 50% 90%, rgba(134, 59, 255, 0.22) 0%, transparent 45%),
linear-gradient(180deg, #090d16 0%, #111827 50%, #090d16 100%);
position: relative;
}
/* Subtle background grid pattern */
body::after {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(148, 163, 184, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
/* Outer border */
.outer-border {
position: absolute;
inset: 40px;
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 30px;
pointer-events: none;
z-index: 1;
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
z-index: 2;
text-align: center;
margin-top: 40px;
}
.logo {
width: 140px;
height: 140px;
object-fit: contain;
filter: drop-shadow(0 8px 24px rgba(56, 189, 248, 0.3));
}
.title-group h1 {
font-size: 64px;
font-weight: 800;
letter-spacing: -0.03em;
color: #ffffff;
line-height: 1.1;
background: linear-gradient(135deg, #ffffff 60%, #94a3b8 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.title-group p {
font-size: 26px;
color: #38bdf8;
font-weight: 600;
margin-top: 8px;
letter-spacing: -0.01em;
}
.main-content {
width: 100%;
display: flex;
flex-direction: column;
gap: 50px;
z-index: 2;
}
.intro-text {
font-size: 26px;
line-height: 1.6;
color: #cbd5e1;
font-weight: 400;
text-align: center;
padding: 0 20px;
}
.intro-text strong {
color: #ffffff;
font-weight: 600;
}
.features-card {
background: rgba(30, 41, 59, 0.45);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px;
padding: 50px 60px;
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.4);
position: relative;
}
.features-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: 24px;
padding: 1px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.card-title {
font-size: 20px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #94a3b8;
margin-bottom: 35px;
display: flex;
align-items: center;
gap: 12px;
}
.card-title::after {
content: "";
flex: 1;
height: 1px;
background: rgba(148, 163, 184, 0.15);
}
.badge-premium {
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #1e293b;
font-size: 16px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 6px 14px;
border-radius: 8px;
}
.features-list {
display: flex;
flex-direction: column;
gap: 30px;
list-style: none;
}
.feature-item {
display: flex;
align-items: flex-start;
gap: 20px;
font-size: 24px;
line-height: 1.4;
color: #cbd5e1;
font-weight: 500;
}
.feature-icon {
color: #38bdf8;
font-size: 26px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 3px;
text-shadow: 0 0 10px rgba(56, 189, 248, 0.6);
}
.bottom-section {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 50px;
z-index: 2;
margin-bottom: 40px;
}
.cta-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
}
.cta-badge {
background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%);
color: #0f172a;
font-size: 32px;
font-weight: 800;
padding: 20px 45px;
border-radius: 16px;
letter-spacing: -0.02em;
box-shadow: 0 10px 30px rgba(56, 189, 248, 0.25);
}
.qr-code {
width: 160px;
height: 160px;
background: #ffffff;
padding: 10px;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
}
.qr-code img {
width: 100%;
height: 100%;
object-fit: contain;
}
footer {
font-size: 18px;
color: #64748b;
text-align: center;
}
footer strong {
color: #94a3b8;
font-weight: 600;
}
</style>
</head>
<body>
<div class="outer-border"></div>
<div class="brand">
<img class="logo" src="../../client/public/logo.png" alt="Kapteins Daagbok" />
<div class="title-group">
<h1>Kapteins Daagbok</h1>
<p>Digitales Yacht-Logbuch</p>
</div>
</div>
<div class="main-content">
<p class="intro-text">
Führe dein Bordlogbuch modern & digital: Reisetage, GPS-Tracks, Crew- und Schiffsdaten —
<strong>Ende-zu-Ende-verschlüsselt</strong>, als App installierbar und <strong>auch offline</strong> auf See nutzbar.
</p>
<div class="features-card">
<div class="card-title">Top Features <span class="badge-premium">Kostenlos & Werbefrei</span></div>
<ul class="features-list">
<li class="feature-item">
<span class="feature-icon"></span>
<span>Nautisches Logbuch-Format & Streckenstatistik</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Offline-first PWA — läuft auf allen Smartphones & Tablets</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Ende-zu-Ende Verschlüsselung (Zero-Knowledge)</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Einfache passwortlose Passkey-Anmeldung</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>GPS-Track-Upload & automatische NMEA-Erfassung</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Crew-Einladung zur gemeinsamen Logbuch-Arbeit</span>
</li>
</ul>
</div>
</div>
<div class="bottom-section">
<div class="cta-container">
<div class="cta-badge">
kapteins-daagbok.eu
</div>
<div class="qr-code">
<img src="assets/qr-kapteins-daagbok.eu.png" alt="QR Code" />
</div>
</div>
<footer>
<strong>Kapteins Daagbok</strong> ist ein werbefreies, privates Hobbyprojekt.
</footer>
</div>
</body>
</html>
+320
View File
@@ -0,0 +1,320 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Kapteins Daagbok — Sharepic</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=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
width: 1200px;
height: 630px;
font-family: 'Plus Jakarta Sans', sans-serif;
color: #e2e8f0;
background: #0f172a;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 60px 80px;
background:
radial-gradient(circle at 90% 10%, rgba(56, 189, 248, 0.15) 0%, transparent 45%),
radial-gradient(circle at 10% 90%, rgba(134, 59, 255, 0.18) 0%, transparent 45%),
linear-gradient(165deg, #090d16 0%, #111827 50%, #090d16 100%);
position: relative;
}
/* Subtle background grid pattern */
body::after {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(148, 163, 184, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
/* Outer border */
.outer-border {
position: absolute;
inset: 30px;
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 20px;
pointer-events: none;
z-index: 1;
}
.content-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
width: 100%;
z-index: 2;
position: relative;
gap: 50px;
}
.left-col {
flex: 1.1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 30px;
}
.brand {
display: flex;
align-items: center;
gap: 20px;
}
.logo {
width: 80px;
height: 80px;
object-fit: contain;
filter: drop-shadow(0 4px 12px rgba(56, 189, 248, 0.3));
}
.title-group h1 {
font-size: 44px;
font-weight: 800;
letter-spacing: -0.03em;
color: #ffffff;
line-height: 1.1;
background: linear-gradient(135deg, #ffffff 60%, #94a3b8 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.title-group p {
font-size: 18px;
color: #38bdf8;
font-weight: 600;
margin-top: 4px;
letter-spacing: -0.01em;
}
.intro-text {
font-size: 20px;
line-height: 1.6;
color: #cbd5e1;
font-weight: 400;
}
.intro-text strong {
color: #ffffff;
font-weight: 600;
}
.cta-container {
display: flex;
align-items: center;
gap: 20px;
}
.cta-badge {
background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%);
color: #0f172a;
font-size: 22px;
font-weight: 800;
padding: 14px 28px;
border-radius: 12px;
letter-spacing: -0.02em;
box-shadow: 0 4px 20px rgba(56, 189, 248, 0.25);
}
.qr-code {
width: 60px;
height: 60px;
background: #ffffff;
padding: 4px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.qr-code img {
width: 100%;
height: 100%;
object-fit: contain;
}
.right-col {
flex: 0.9;
display: flex;
flex-direction: column;
justify-content: center;
}
.features-card {
background: rgba(30, 41, 59, 0.45);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
padding: 35px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
position: relative;
}
.features-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: 20px;
padding: 1px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.features-list {
display: flex;
flex-direction: column;
gap: 18px;
list-style: none;
}
.feature-item {
display: flex;
align-items: flex-start;
gap: 14px;
font-size: 16px;
line-height: 1.4;
color: #cbd5e1;
font-weight: 500;
}
.feature-icon {
color: #38bdf8;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 2px;
text-shadow: 0 0 8px rgba(56, 189, 248, 0.6);
}
.badge-premium {
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #1e293b;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 4px 10px;
border-radius: 6px;
margin-left: auto;
}
.card-title {
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #94a3b8;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.card-title::after {
content: "";
flex: 1;
height: 1px;
background: rgba(148, 163, 184, 0.15);
}
footer {
position: absolute;
bottom: 45px;
left: 80px;
font-size: 12px;
color: #64748b;
z-index: 2;
}
footer strong {
color: #94a3b8;
font-weight: 600;
}
</style>
</head>
<body>
<div class="outer-border"></div>
<div class="content-wrapper">
<div class="left-col">
<div class="brand">
<img class="logo" src="../../client/public/logo.png" alt="Kapteins Daagbok" />
<div class="title-group">
<h1>Kapteins Daagbok</h1>
<p>Digitales Yacht-Logbuch</p>
</div>
</div>
<p class="intro-text">
Führe dein Bordlogbuch modern & digital: Reisetage, GPS-Tracks, Crew- und Schiffsdaten —
<strong>Ende-zu-Ende-verschlüsselt</strong>, als App installierbar und <strong>auch offline</strong> auf See nutzbar.
</p>
<div class="cta-container">
<div class="cta-badge">
kapteins-daagbok.eu
</div>
<div class="qr-code">
<img src="assets/qr-kapteins-daagbok.eu.png" alt="QR Code" />
</div>
</div>
</div>
<div class="right-col">
<div class="features-card">
<div class="card-title">Top Features <span class="badge-premium">Kostenlos & Werbefrei</span></div>
<ul class="features-list">
<li class="feature-item">
<span class="feature-icon"></span>
<span>Nautisches Logbuch-Format & Streckenstatistik</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Offline-first PWA — läuft auf allen Smartphones & Tablets</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Ende-zu-Ende Verschlüsselung (Zero-Knowledge)</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Einfache passwortlose Passkey-Anmeldung</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>GPS-Track-Upload & automatische NMEA-Erfassung</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Crew-Einladung zur gemeinsamen Logbuch-Arbeit</span>
</li>
</ul>
</div>
</div>
</div>
<footer>
<strong>Kapteins Daagbok</strong> ist ein werbefreies, privates Hobbyprojekt.
</footer>
</body>
</html>
+11 -2
View File
@@ -1,12 +1,21 @@
# Plausible Custom Events # Plausible Custom Events
Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js` auf der Domain `kapteins-daagbok.eu`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`). Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`).
**Konfiguration** (`.env`, Frontend-Container / Vite-Dev):
```env
PLAUSIBLE_ENABLED=true # Staging: false
PLAUSIBLE_HOST=https://plausible.elpatron.me
```
Das Script wird über `plausible-bootstrap.js` geladen; `data-domain` ist der aktuelle Hostname. CSP in Nginx enthält `PLAUSIBLE_HOST` nur wenn aktiviert.
**Datenschutz:** Es werden keine personenbezogenen Daten in Event-Properties übermittelt (keine Nutzernamen, Hafennamen, Koordinaten o.ä.). **Datenschutz:** Es werden keine personenbezogenen Daten in Event-Properties übermittelt (keine Nutzernamen, Hafennamen, Koordinaten o.ä.).
## Setup ## Setup
1. Script in `client/index.html` (bereits eingebunden) 1. `PLAUSIBLE_*` in `.env` setzen (Prod: enabled, Staging: disabled empfohlen)
2. Nach Deploy: Goals im Plausible-Dashboard anlegen — **Namen müssen exakt mit der Event-Spalte „Event name“ übereinstimmen** (Title Case, Leerzeichen) 2. Nach Deploy: Goals im Plausible-Dashboard anlegen — **Namen müssen exakt mit der Event-Spalte „Event name“ übereinstimmen** (Title Case, Leerzeichen)
## Event-Übersicht ## Event-Übersicht
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/**
* Generates the sharepic PNGs (landscape & portrait) from HTML files
*
* Usage:
* node scripts/generate-sharepic.mjs
*/
import { execSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { createRequire } from 'node:module'
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(__dirname, '..')
const clientDir = resolve(repoRoot, 'client')
const marketingDir = resolve(repoRoot, 'docs/marketing')
const require = createRequire(resolve(clientDir, 'package.json'))
function isMissingBrowserError(err) {
const msg = err instanceof Error ? err.message : String(err)
return msg.includes("Executable doesn't exist") || msg.includes('browserType.launch')
}
async function ensurePlaywrightChromium(playwright) {
try {
const browser = await playwright.chromium.launch({ headless: true })
await browser.close()
return
} catch (err) {
if (!isMissingBrowserError(err)) throw err
}
console.log('Playwright Chromium fehlt — installiere Browser (einmalig)…')
execSync('npx playwright install chromium', {
cwd: clientDir,
stdio: 'inherit'
})
}
function loadPlaywright() {
try {
return require('playwright')
} catch {
console.error('Fehlende Abhängigkeit: "npm install -D playwright" in client/ ausführen.')
process.exit(1)
}
}
async function renderSharepic(browser, htmlName, pngName, width, height) {
const htmlPath = resolve(marketingDir, htmlName)
const pngPath = resolve(marketingDir, pngName)
console.log(`Generating sharepic (${width}x${height}) from ${htmlName}...`)
const context = await browser.newContext({
viewport: { width, height },
deviceScaleFactor: 2 // High-DPI for crisp text
})
const page = await context.newPage()
try {
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle' })
await page.screenshot({
path: pngPath,
type: 'png'
})
console.log('Successfully wrote:', pngPath)
} finally {
await page.close()
}
}
async function main() {
const playwright = loadPlaywright()
await ensurePlaywrightChromium(playwright)
const browser = await playwright.chromium.launch({ headless: true })
try {
// Landscape 1200x630
await renderSharepic(browser, 'sharepic.html', 'kapteins-daagbok-sharepic.png', 1200, 630)
// Portrait 1080x1920
await renderSharepic(browser, 'sharepic-portrait.html', 'kapteins-daagbok-sharepic-portrait.png', 1080, 1920)
} finally {
await browser.close()
}
}
main().catch((err) => {
console.error('Error generating sharepics:', err)
process.exit(1)
})
+135 -33
View File
@@ -2,28 +2,102 @@
set -euo pipefail set -euo pipefail
# Remote deployment configuration usage() {
# Override any of these via environment variables if needed, e.g.: cat <<EOF
# REMOTE_HOST=192.168.1.10 ./scripts/update-prod.sh Usage: $(basename "$0") [-dest prod|stage]
REMOTE_USER="${REMOTE_USER:-root}"
REMOTE_HOST="${REMOTE_HOST:-10.0.0.25}"
REMOTE_DIR="${REMOTE_DIR:-/opt/kapteins-daagbok}"
REMOTE_TARGET="${REMOTE_USER}@${REMOTE_HOST}"
# Configuration Deploy Kapteins Daagbok to production or staging.
COMPOSE_FILE="docker-compose.yml"
BACKEND_CONTAINER="daagbox-prod-backend" -dest prod Production (default): release tag, bump VERSION, deploy to 10.0.0.25
MAX_WAIT=35 -dest stage Staging: no release tag, deploy branch to 10.0.0.27
Environment overrides (optional):
REMOTE_HOST, REMOTE_USER, REMOTE_DIR, COMPOSE_FILE, BACKEND_CONTAINER
DEPLOY_BRANCH (stage only, default: master)
SKIP_PREDEPLOY_CHECK=1
Examples:
$(basename "$0") -dest prod
$(basename "$0") -dest stage
DEPLOY_BRANCH=feature/foo $(basename "$0") -dest stage
EOF
}
DEST="prod"
while [[ $# -gt 0 ]]; do
case "$1" in
-dest)
if [[ $# -lt 2 ]]; then
echo "Error: -dest requires an argument (prod or stage)." >&2
usage
exit 1
fi
DEST="$2"
shift 2
;;
-dest=*)
DEST="${1#*=}"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: Unknown argument: $1" >&2
usage
exit 1
;;
esac
done
case "$DEST" in
prod|stage) ;;
*)
echo "Error: Invalid -dest '$DEST' (use prod or stage)." >&2
usage
exit 1
;;
esac
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION_FILE="$REPO_ROOT/VERSION" VERSION_FILE="$REPO_ROOT/VERSION"
DEFAULT_VERSION="0.1.0.0" DEFAULT_VERSION="0.1.0.0"
MAX_WAIT=90
REMOTE_USER="${REMOTE_USER:-root}"
if [[ "$DEST" == "stage" ]]; then
REMOTE_HOST="${REMOTE_HOST:-10.0.0.27}"
REMOTE_DIR="${REMOTE_DIR:-/opt/kapteins-daagbok-staging}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.staging.yml}"
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-staging-backend}"
APP_URL="${APP_URL:-https://staging.kapteins-daagbok.eu}"
DEPLOY_BRANCH="${DEPLOY_BRANCH:-master}"
ENV_LABEL="Staging"
else
REMOTE_HOST="${REMOTE_HOST:-10.0.0.25}"
REMOTE_DIR="${REMOTE_DIR:-/opt/kapteins-daagbok}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
APP_URL="${APP_URL:-https://kapteins-daagbok.eu}"
DEPLOY_BRANCH=""
ENV_LABEL="Production"
fi
REMOTE_TARGET="${REMOTE_USER}@${REMOTE_HOST}"
echo "==================================================" echo "=================================================="
echo " Kapteins Daagbok Prod Environment Update " echo " Kapteins Daagbok ${ENV_LABEL} Update"
echo "==================================================" echo "=================================================="
echo "Destination: ${DEST}"
echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}" echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}"
if [[ "$DEST" == "stage" ]]; then
echo "Branch: ${DEPLOY_BRANCH}"
fi
echo "URL: ${APP_URL}"
echo "==================================================" echo "=================================================="
cd "$REPO_ROOT" cd "$REPO_ROOT"
@@ -123,7 +197,11 @@ prepare_release() {
export APP_VERSION="$release_version" export APP_VERSION="$release_version"
} }
if [[ "$DEST" == "prod" ]]; then
prepare_release prepare_release
else
APP_VERSION="$(read_current_version)"
fi
if [[ "${SKIP_PREDEPLOY_CHECK:-}" == "1" ]]; then if [[ "${SKIP_PREDEPLOY_CHECK:-}" == "1" ]]; then
echo "Skipping pre-deploy checks (SKIP_PREDEPLOY_CHECK=1)." echo "Skipping pre-deploy checks (SKIP_PREDEPLOY_CHECK=1)."
@@ -135,23 +213,42 @@ else
fi fi
echo "==================================================" echo "=================================================="
echo "Deploying ${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}" echo "Deploying v${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
echo "==================================================" echo "=================================================="
# Run the whole update procedure remotely over SSH.
ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \ ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$REMOTE_HOST" "$APP_VERSION" <<'REMOTE_SCRIPT' "$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$APP_URL" "$APP_VERSION" "$DEST" "$DEPLOY_BRANCH" <<'REMOTE_SCRIPT'
set -uo pipefail set -uo pipefail
REMOTE_DIR="$1" REMOTE_DIR="$1"
COMPOSE_FILE="$2" COMPOSE_FILE="$2"
BACKEND_CONTAINER="$3" BACKEND_CONTAINER="$3"
MAX_WAIT="$4" MAX_WAIT="$4"
REMOTE_HOST="$5" APP_URL="$5"
APP_VERSION="$6" APP_VERSION="$6"
DEST="$7"
DEPLOY_BRANCH="$8"
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; } cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
echo "Warning: Local changes on deployment host will be discarded."
fi
if [[ "$DEST" == "stage" ]]; then
echo "Syncing repository from origin/${DEPLOY_BRANCH}..."
git fetch origin
if [ $? -ne 0 ]; then
echo "Error: Git fetch failed."
exit 1
fi
git checkout "$DEPLOY_BRANCH" 2>/dev/null || git checkout -b "$DEPLOY_BRANCH" "origin/${DEPLOY_BRANCH}"
git reset --hard "origin/${DEPLOY_BRANCH}"
if [ $? -ne 0 ]; then
echo "Error: Git reset to origin/${DEPLOY_BRANCH} failed."
exit 1
fi
else
echo "Syncing repository from origin..." echo "Syncing repository from origin..."
CURRENT_BRANCH="$(git branch --show-current)" CURRENT_BRANCH="$(git branch --show-current)"
if [ -z "$CURRENT_BRANCH" ]; then if [ -z "$CURRENT_BRANCH" ]; then
@@ -159,10 +256,6 @@ if [ -z "$CURRENT_BRANCH" ]; then
exit 1 exit 1
fi fi
if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
echo "Warning: Local changes on deployment host will be discarded."
fi
git fetch --tags origin git fetch --tags origin
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "Error: Git fetch failed." echo "Error: Git fetch failed."
@@ -180,11 +273,17 @@ if [ "$REMOTE_VERSION" != "$APP_VERSION" ]; then
echo "Note: Remote VERSION file already points to next release (v${REMOTE_VERSION})." echo "Note: Remote VERSION file already points to next release (v${REMOTE_VERSION})."
echo " Building deployed release v${APP_VERSION}." echo " Building deployed release v${APP_VERSION}."
fi fi
fi
export APP_VERSION="$APP_VERSION" export APP_VERSION="$APP_VERSION"
if [[ "$DEST" == "prod" ]]; then
echo "Rebuilding Docker images without cache (APP_VERSION=${APP_VERSION})..." echo "Rebuilding Docker images without cache (APP_VERSION=${APP_VERSION})..."
docker compose -f "$COMPOSE_FILE" build --no-cache docker compose -f "$COMPOSE_FILE" build --no-cache
else
echo "Rebuilding Docker images (APP_VERSION=${APP_VERSION})..."
docker compose -f "$COMPOSE_FILE" build
fi
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "Error: Docker compose build failed." echo "Error: Docker compose build failed."
exit 1 exit 1
@@ -198,23 +297,26 @@ if [ $? -ne 0 ]; then
fi fi
echo "Cleaning up old/unused Docker resources..." echo "Cleaning up old/unused Docker resources..."
docker system prune -f docker system prune -f || echo "Warning: Docker system prune failed."
if [ $? -ne 0 ]; then
echo "Warning: Docker system prune failed to run completely."
fi
echo "Waiting for services to become healthy..." echo "Waiting for services to become healthy..."
COUNTER=0 COUNTER=0
IS_READY=false IS_READY=false
while [ $COUNTER -lt $MAX_WAIT ]; do while [ $COUNTER -lt $MAX_WAIT ]; do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$BACKEND_CONTAINER" 2>/dev/null) STATUS=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$BACKEND_CONTAINER" 2>/dev/null || true)
if [ "$STATUS" = "healthy" ]; then if [ "$STATUS" = "healthy" ]; then
IS_READY=true IS_READY=true
break break
fi fi
# End-to-end fallback via frontend nginx (covers missing/stale container health state)
if curl -sf "http://127.0.0.1/api/health" | grep -q '"status":"ok"'; then
IS_READY=true
break
fi
sleep 1 sleep 1
COUNTER=$((COUNTER + 1)) COUNTER=$((COUNTER + 1))
printf "." printf "."
@@ -227,15 +329,15 @@ docker compose -f "$COMPOSE_FILE" ps
echo "==================================================" echo "=================================================="
if [ "$IS_READY" = true ]; then if [ "$IS_READY" = true ]; then
echo "SUCCESS: Production environment updated and healthy!" echo "SUCCESS: ${DEST} environment updated and healthy!"
echo " -> Version: v${APP_VERSION}" echo " -> Version: v${APP_VERSION}"
echo " -> App Frontend (Nginx): http://${REMOTE_HOST}" echo " -> App Frontend: ${APP_URL}"
echo " -> Backend API Health: http://${REMOTE_HOST}/api/health" echo " -> Backend API Health: ${APP_URL}/api/health"
echo "==================================================" echo "=================================================="
else else
echo "WARNING: Backend did not transition to healthy in time." echo "WARNING: Backend did not transition to healthy in time."
echo "Check backend container logs for details:" echo "Check backend container logs:"
echo " -> docker compose logs backend" echo " -> docker compose -f ${COMPOSE_FILE} logs backend"
echo "==================================================" echo "=================================================="
exit 3 exit 3
fi fi
@@ -244,11 +346,11 @@ REMOTE_SCRIPT
REMOTE_EXIT=$? REMOTE_EXIT=$?
echo "==================================================" echo "=================================================="
if [ $REMOTE_EXIT -eq 0 ]; then if [ $REMOTE_EXIT -eq 0 ]; then
echo "Remote update completed successfully on ${REMOTE_TARGET} (v${APP_VERSION})." echo "${ENV_LABEL} update completed successfully on ${REMOTE_TARGET} (v${APP_VERSION})."
elif [ $REMOTE_EXIT -eq 3 ]; then elif [ $REMOTE_EXIT -eq 3 ]; then
echo "Remote update finished, but the backend was not healthy in time on ${REMOTE_TARGET}." echo "${ENV_LABEL} update finished, but the backend was not healthy in time on ${REMOTE_TARGET}."
else else
echo "Remote update FAILED on ${REMOTE_TARGET} (exit code: ${REMOTE_EXIT})." echo "${ENV_LABEL} update FAILED on ${REMOTE_TARGET} (exit code: ${REMOTE_EXIT})."
fi fi
echo "==================================================" echo "=================================================="
exit $REMOTE_EXIT exit $REMOTE_EXIT
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# Backward-compatible wrapper — prefer: ./scripts/update-prod.sh -dest stage
set -euo pipefail
for arg in "$@"; do
case "$arg" in
-dest|-dest=*)
echo "Error: update-staging.sh always deploys to staging." >&2
echo " Use ./scripts/update-prod.sh -dest prod for production." >&2
exit 1
;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/update-prod.sh" -dest stage "$@"
+25
View File
@@ -0,0 +1,25 @@
const ADMIN_ENV_KEY = 'ADMIN_USER_IDS'
export function getAdminUserIds(): Set<string> {
const raw = process.env[ADMIN_ENV_KEY]
if (!raw) {
return new Set()
}
const ids = raw
.split(',')
.map((id) => id.trim())
.filter((id) => id.length > 0)
if (ids.length === 0) {
return new Set()
}
return new Set(ids)
}
export function isAdminUserId(userId: string): boolean {
const adminIds = getAdminUserIds()
return adminIds.has(userId)
}
+2
View File
@@ -12,6 +12,7 @@ import pushRouter from './routes/push.js'
import weatherRouter from './routes/weather.js' import weatherRouter from './routes/weather.js'
import aiRouter from './routes/ai.js' import aiRouter from './routes/ai.js'
import feedbackRouter from './routes/feedback.js' import feedbackRouter from './routes/feedback.js'
import adminRouter from './routes/admin.js'
import { prisma } from './db.js' import { prisma } from './db.js'
import { buildCorsOptions } from './cors.js' import { buildCorsOptions } from './cors.js'
@@ -121,6 +122,7 @@ export function createApp(): express.Express {
app.use('/api/weather', weatherRouter) app.use('/api/weather', weatherRouter)
app.use('/api/ai', aiRouter) app.use('/api/ai', aiRouter)
app.use('/api/feedback', feedbackRouter) app.use('/api/feedback', feedbackRouter)
app.use('/api/admin', adminRouter)
app.get('/api/health', async (_req, res) => { app.get('/api/health', async (_req, res) => {
try { try {
+19
View File
@@ -1,5 +1,6 @@
import type { Request, Response, NextFunction } from 'express' import type { Request, Response, NextFunction } from 'express'
import { hasValidReauth, readSessionFromRequest } from '../session.js' import { hasValidReauth, readSessionFromRequest } from '../session.js'
import { isAdminUserId } from '../adminConfig.js'
export interface AuthedRequest extends Request { export interface AuthedRequest extends Request {
userId: string userId: string
@@ -31,3 +32,21 @@ export function requireReauth(req: Request, res: Response, next: NextFunction):
;(req as AuthedRequest).session = session ;(req as AuthedRequest).session = session
next() next()
} }
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
const session = readSessionFromRequest(req)
if (!session) {
res.status(401).json({ error: 'Unauthorized: valid session required' })
return
}
if (!isAdminUserId(session.userId)) {
res.status(403).json({ error: 'Forbidden: admin access required' })
return
}
;(req as AuthedRequest).userId = session.userId
;(req as AuthedRequest).session = session
next()
}
+151
View File
@@ -0,0 +1,151 @@
import { Router } from 'express'
import { prisma } from '../db.js'
import { requireUser, requireAdmin, type AuthedRequest } from '../middleware/auth.js'
const router = Router()
router.get('/me', requireUser, requireAdmin, (req, res) => {
const { userId } = req as AuthedRequest
res.json({ isAdmin: true, userId })
})
router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
try {
const [totalUsers, totalLogbooks, totalPhotos, totalVoiceMemos, totalGpsTracks, totalCollaborations, totalInvitations, aiSummaryEntries] =
await Promise.all([
prisma.user.count(),
prisma.logbook.count(),
prisma.photoPayload.count(),
prisma.voiceMemoPayload.count(),
prisma.gpsTrackPayload.count(),
prisma.collaboration.count(),
prisma.invitation.count(),
prisma.aiSummaryUsage.count()
])
res.json({
totalUsers,
totalLogbooks,
totalPhotos,
totalVoiceMemos,
totalGpsTracks,
totalCollaborations,
totalInvitations,
aiSummaryEntries
})
} catch (error: unknown) {
console.error('admin/summary error', error)
res.status(500).json({ error: 'Failed to load admin summary' })
}
})
type TimeBucket = 'day' | 'week' | 'month'
interface TimeSeriesPoint {
date: string
count: number
}
interface TimeSeries {
metric: string
points: TimeSeriesPoint[]
}
function normalizeBucket(value: string | undefined | null): TimeBucket {
if (value === 'week' || value === 'month') return value
return 'day'
}
function parseWindowDays(raw: string | undefined | null): number {
const n = raw ? Number.parseInt(raw, 10) : NaN
if (!Number.isFinite(n) || n <= 0) return 90
return Math.min(n, 365)
}
function startOfDay(date: Date): Date {
const d = new Date(date)
d.setUTCHours(0, 0, 0, 0)
return d
}
function startOfWeek(date: Date): Date {
const d = startOfDay(date)
const day = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() - (day - 1))
return d
}
function startOfMonth(date: Date): Date {
const d = startOfDay(date)
d.setUTCDate(1)
return d
}
function bucketDate(date: Date, bucket: TimeBucket): string {
const base =
bucket === 'week' ? startOfWeek(date) : bucket === 'month' ? startOfMonth(date) : startOfDay(date)
return base.toISOString().slice(0, 10)
}
async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<TimeSeries[]> {
const since = new Date()
since.setUTCDate(since.getUTCDate() - windowDays)
const [users, logbooks, photos] = await Promise.all([
prisma.user.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true }
}),
prisma.logbook.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true }
}),
prisma.photoPayload.findMany({
where: { updatedAt: { gte: since } },
select: { updatedAt: true }
})
])
function aggregate(dates: Date[], metric: string): TimeSeries {
const map = new Map<string, number>()
for (const d of dates) {
const key = bucketDate(d, bucket)
map.set(key, (map.get(key) ?? 0) + 1)
}
const points = Array.from(map.entries())
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([date, count]) => ({ date, count }))
return { metric, points }
}
return [
aggregate(
users.map((u) => u.createdAt),
'users_created'
),
aggregate(
logbooks.map((l) => l.createdAt),
'logbooks_created'
),
aggregate(
photos.map((p) => p.updatedAt),
'photos_updated'
)
]
}
router.get('/timeseries', requireUser, requireAdmin, async (req, res) => {
try {
const bucket = normalizeBucket(typeof req.query.bucket === 'string' ? req.query.bucket : undefined)
const windowDays = parseWindowDays(typeof req.query.window === 'string' ? req.query.window : undefined)
const series = await buildTimeSeries(bucket, windowDays)
res.json({ bucket, windowDays, series })
} catch (error: unknown) {
console.error('admin/timeseries error', error)
res.status(500).json({ error: 'Failed to load admin time series' })
}
})
export default router