Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| faf3b8e3cf | |||
| 74ff8eb16b | |||
| 81d3e3b777 | |||
| 97c5173e63 | |||
| 8b34044481 | |||
| d948325a45 | |||
| 8b8196f6e3 | |||
| 6593b320ee | |||
| 9a931024d6 | |||
| 4dfe2cea4e | |||
| 944f4518e9 | |||
| 0c765f712c | |||
| 676547686b | |||
| 66606c5eca | |||
| a30fac029d | |||
| 796e61f4ea | |||
| 594c65d1a5 | |||
| fafefff29b | |||
| 4fd7f3c6cf | |||
| 262c48a01a | |||
| 9ad3c2cf38 | |||
| 6848390ffa | |||
| 65d2215a35 | |||
| f321e5bbd1 | |||
| d2961b050a | |||
| 6943fd2dc4 | |||
| f332eccf22 | |||
| 9d2a19dbf8 | |||
| e3cd89be5d | |||
| a86da72b04 | |||
| 7d6f381f55 | |||
| 878be33b7c | |||
| 318f5e65da | |||
| 8c6ab59d67 | |||
| a9c3e9ce3e | |||
| 3eaf59e2b3 | |||
| b1e17be7fd | |||
| ac7e7c92d1 | |||
| e10cef4b05 | |||
| 0ec5c51102 | |||
| 57b93b7ce7 | |||
| a4b3515711 | |||
| 41acbaebac | |||
| 6c83cd7d36 | |||
| 9089e1c6f9 | |||
| 1504960d85 | |||
| 599f090895 | |||
| 4eb2b4c517 | |||
| be3b23ed8c | |||
| 697c5781b7 | |||
| 4c36c9160a | |||
| d559a762d2 | |||
| a2180a302c | |||
| cd29115233 | |||
| e4b07ca896 | |||
| f0c3cacb06 | |||
| 5821e20086 | |||
| aff8d1517d | |||
| f4d6b11414 | |||
| 968e81f4fb | |||
| 10835c9def | |||
| cdbc618521 | |||
| f75fe42910 | |||
| 212775ffdc | |||
| c80760db02 | |||
| cd1dd12c15 | |||
| 43cf589613 | |||
| e1cb2754c4 | |||
| 5dedb8fac0 | |||
| 78f1659db4 | |||
| 935c263648 | |||
| 29ac96f892 | |||
| 4d3b7210b3 | |||
| 369bca2ef1 | |||
| 2fcc741f5e | |||
| 27722186d1 | |||
| 5710c74706 | |||
| cd27dfa27d | |||
| c4c7d42de4 | |||
| 71025b3d61 | |||
| f790a6adcc | |||
| de5a46938b | |||
| 16944c1a26 | |||
| fae7b20f90 | |||
| 73e7613a1b | |||
| 6c8aa5af4c | |||
| 9554f4b66e | |||
| 5c77bbfdc3 | |||
| 979b572136 | |||
| f189317dfc | |||
| c54f834311 | |||
| 9d05005bb7 | |||
| 40c4874156 | |||
| 2de0636608 | |||
| 9e7c6f4397 | |||
| 6600ceafce |
@@ -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
|
||||||
@@ -29,6 +34,8 @@ ORIGIN=http://localhost:5173
|
|||||||
# POSTGRES_USER=postgres
|
# POSTGRES_USER=postgres
|
||||||
# POSTGRES_PASSWORD=
|
# POSTGRES_PASSWORD=
|
||||||
# POSTGRES_DB=daagbox
|
# POSTGRES_DB=daagbox
|
||||||
|
# Optional: lock Docker Compose to a specific configuration file (e.g. staging or production) on the server:
|
||||||
|
# COMPOSE_FILE=docker-compose.staging.yml
|
||||||
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
|
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
|
||||||
# CORS_ORIGINS=http://localhost:5173
|
# CORS_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
@@ -36,6 +43,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 +58,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
|
||||||
|
|||||||
@@ -251,15 +251,27 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führ
|
|||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/update-prod.sh
|
./scripts/update-remotes.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.
|
||||||
|
|
||||||
|
Prod-Deploy legt vor dem Update automatisch ein Server-Backup an (DB, `.env`, Compose, App-Code). Tägliches Cron-Backup und Restore: [docs/deployment/backup.md](docs/deployment/backup.md).
|
||||||
|
|
||||||
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-remotes.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 +279,8 @@ 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/backup.md](docs/deployment/backup.md) | Server-Backup, Crontab, Restore (Prod) |
|
||||||
|
| [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 |
|
||||||
|
|||||||
+7
-4
@@ -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://127.0.0.1:80/ || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80/ || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
|
|||||||
Executable
+26
@@ -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
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
})
|
||||||
|
})()
|
||||||
+735
-16
@@ -1939,6 +1939,21 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logbook-card-right-group {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card-right-group .logbook-card-chevron {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.logbook-card .logbook-title-editable,
|
.logbook-card .logbook-title-editable,
|
||||||
.logbook-card .logbook-title-inline-edit,
|
.logbook-card .logbook-title-inline-edit,
|
||||||
.logbook-card .card-title-row {
|
.logbook-card .card-title-row {
|
||||||
@@ -2090,6 +2105,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
cursor: text;
|
cursor: text;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: background-color 0.15s ease;
|
transition: background-color 0.15s ease;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logbook-title-editable:hover {
|
.logbook-title-editable:hover {
|
||||||
@@ -2105,6 +2121,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-icon {
|
.card-icon {
|
||||||
@@ -2163,6 +2180,16 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
color: var(--app-text-subtle);
|
color: var(--app-text-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entry-count-badge {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.entry-sign-badge {
|
.entry-sign-badge {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -2956,6 +2983,12 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logbook-card-right-group .btn-pdf,
|
||||||
|
.logbook-card-right-group .btn-delete {
|
||||||
|
position: static;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.card-meta {
|
.card-meta {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
@@ -3184,6 +3217,7 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
background: #0b0c10;
|
background: #0b0c10;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-container img {
|
.photo-container img {
|
||||||
@@ -3230,6 +3264,78 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Photo Maximized Overlay */
|
||||||
|
.photo-maximized-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(11, 12, 16, 0.9);
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
-webkit-backdrop-filter: blur(15px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 11000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-container {
|
||||||
|
position: relative;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-img {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-close {
|
||||||
|
position: absolute;
|
||||||
|
top: -48px;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: #f1f5f9;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-color: #ffffff;
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-caption {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #f1f5f9;
|
||||||
|
background: rgba(15, 23, 42, 0.75);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
max-width: 80%;
|
||||||
|
text-align: center;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom Dialog Modals Styling */
|
/* Custom Dialog Modals Styling */
|
||||||
.custom-dialog-overlay {
|
.custom-dialog-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -3237,9 +3343,9 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(11, 12, 16, 0.75);
|
background: rgba(11, 12, 16, 0.45);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: var(--app-backdrop);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: var(--app-backdrop);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -3247,13 +3353,15 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.custom-dialog-card {
|
.custom-dialog-card {
|
||||||
background: rgba(15, 23, 42, 0.85);
|
background: var(--app-surface-hover, var(--app-surface));
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
backdrop-filter: var(--app-backdrop);
|
||||||
border-radius: 16px;
|
-webkit-backdrop-filter: var(--app-backdrop);
|
||||||
|
border: 1px solid var(--app-border-subtle);
|
||||||
|
border-radius: var(--app-radius-card, 16px);
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
|
box-shadow: var(--app-shadow);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -3263,7 +3371,7 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
.custom-dialog-title {
|
.custom-dialog-title {
|
||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fbbf24;
|
color: var(--app-accent-light);
|
||||||
margin: 0 0 14px 0;
|
margin: 0 0 14px 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -3271,7 +3379,7 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
|
|
||||||
.custom-dialog-message {
|
.custom-dialog-message {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: #e2e8f0;
|
color: var(--app-text);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0 0 24px 0;
|
margin: 0 0 24px 0;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
@@ -4362,6 +4470,7 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
.consumption-grid .input-group .input-text {
|
.consumption-grid .input-group .input-text {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
}
|
}
|
||||||
|
|
||||||
.consumption-grid .input-text::-webkit-outer-spin-button,
|
.consumption-grid .input-text::-webkit-outer-spin-button,
|
||||||
@@ -4919,6 +5028,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;
|
||||||
@@ -5018,6 +5298,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;
|
||||||
}
|
}
|
||||||
@@ -5297,8 +5607,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;
|
||||||
@@ -5461,8 +5772,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;
|
||||||
@@ -5585,6 +5897,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;
|
||||||
@@ -5634,6 +5952,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;
|
||||||
@@ -5783,13 +6143,15 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
.app-tour-root {
|
.app-tour-root {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 10000;
|
/* Above .app-tour-target-active (10001) so tooltip/backdrop stay topmost */
|
||||||
|
z-index: 10010;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-tour-backdrop {
|
.app-tour-backdrop {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
background: rgba(2, 6, 23, 0.62);
|
background: rgba(2, 6, 23, 0.62);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
@@ -5807,7 +6169,7 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
0 0 32px rgba(56, 189, 248, 0.5),
|
0 0 32px rgba(56, 189, 248, 0.5),
|
||||||
0 12px 40px rgba(0, 0, 0, 0.35);
|
0 12px 40px rgba(0, 0, 0, 0.35);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 10001;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.app-tour-active .app-tour-target-active {
|
body.app-tour-active .app-tour-target-active {
|
||||||
@@ -5818,7 +6180,8 @@ body.app-tour-active .app-tour-target-active {
|
|||||||
|
|
||||||
.app-tour-tooltip {
|
.app-tour-tooltip {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 10002;
|
/* Layer above backdrop/spotlight inside .app-tour-root (not vs. root's 10010) */
|
||||||
|
z-index: 3;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: min(420px, calc(100vw - 32px));
|
width: min(420px, calc(100vw - 32px));
|
||||||
max-width: calc(100vw - 32px);
|
max-width: calc(100vw - 32px);
|
||||||
@@ -5980,3 +6343,359 @@ body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
|
|||||||
.crew-selection-item input {
|
.crew-selection-item input {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Responsive Event Cards */
|
||||||
|
.events-desktop-only {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-mobile-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.events-desktop-only {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-mobile-only {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-mobile-card {
|
||||||
|
background: var(--app-surface-alt);
|
||||||
|
border: 1px solid var(--app-border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-mobile-card:hover {
|
||||||
|
border-color: var(--app-border);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-time {
|
||||||
|
color: #fbbf24;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--app-border-subtle);
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-chip {
|
||||||
|
background: var(--app-surface-hover, rgba(255, 255, 255, 0.03));
|
||||||
|
border: 1px solid var(--app-border-muted);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-chip svg {
|
||||||
|
color: var(--app-text-muted, #94a3b8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-weather-img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-remarks {
|
||||||
|
background: var(--app-surface-inset, rgba(11, 12, 16, 0.2));
|
||||||
|
border-left: 3px solid var(--app-accent, #fbbf24);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accordion Styling */
|
||||||
|
.accordion-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: -8px -12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header:hover {
|
||||||
|
background-color: var(--app-surface-hover, rgba(255, 255, 255, 0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-chevron {
|
||||||
|
color: var(--app-text-muted, #94a3b8);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific styling for nested member-editor-card header */
|
||||||
|
.member-editor-card .accordion-header {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.01);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-editor-card .accordion-header:hover {
|
||||||
|
background: var(--app-surface-hover, rgba(255, 255, 255, 0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column Selector / Customizer Popover */
|
||||||
|
.column-selector-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: 40px;
|
||||||
|
right: 0;
|
||||||
|
width: 240px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--app-surface-alt, rgba(18, 20, 26, 0.98));
|
||||||
|
border: 1px solid var(--app-border, rgba(255, 255, 255, 0.1));
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-selector-title {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-accent, #fbbf24);
|
||||||
|
margin: 0;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--app-border-subtle, rgba(255, 255, 255, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-selector-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-selector-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--app-text-muted, #cbd5e1);
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-selector-item:hover {
|
||||||
|
background: var(--app-surface-hover, rgba(255, 255, 255, 0.04));
|
||||||
|
color: var(--app-text, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-selector-item input[type="checkbox"] {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--app-accent, #fbbf24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Language Dropdown */
|
||||||
|
.lang-dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown-trigger-flag {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown-chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.75;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown.is-open .lang-dropdown-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px;
|
||||||
|
list-style: none;
|
||||||
|
border: 1px solid var(--app-input-border, rgba(255, 255, 255, 0.1));
|
||||||
|
border-radius: var(--app-radius-input, 12px);
|
||||||
|
box-shadow: var(--app-card-shadow, 0 10px 30px rgba(0, 0, 0, 0.3));
|
||||||
|
min-width: 140px;
|
||||||
|
overflow: hidden;
|
||||||
|
isolation: isolate;
|
||||||
|
animation: slideDownFade 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDownFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown.align-right .lang-dropdown-menu {
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown.align-left .lang-dropdown-menu {
|
||||||
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.scheme-light .lang-dropdown-menu {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #0f172a;
|
||||||
|
border-color: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.scheme-dark .lang-dropdown-menu {
|
||||||
|
background: #1c1c1e;
|
||||||
|
color: #f8fafc;
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: calc(var(--app-radius-input, 12px) - 4px);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-flag-svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-flag-svg.trigger-icon-only {
|
||||||
|
width: 24px;
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.scheme-light .lang-dropdown-option {
|
||||||
|
color: #334155;
|
||||||
|
-webkit-text-fill-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.scheme-dark .lang-dropdown-option {
|
||||||
|
color: #cbd5e1;
|
||||||
|
-webkit-text-fill-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown-option:hover {
|
||||||
|
background: var(--app-accent-bg, rgba(217, 119, 6, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.scheme-light .lang-dropdown-option:hover {
|
||||||
|
color: var(--app-accent, #d97706);
|
||||||
|
-webkit-text-fill-color: var(--app-accent, #d97706);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.scheme-dark .lang-dropdown-option:hover {
|
||||||
|
color: var(--app-accent-light, #fbbf24);
|
||||||
|
-webkit-text-fill-color: var(--app-accent-light, #fbbf24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown-option.is-selected {
|
||||||
|
background: var(--app-accent-bg, rgba(217, 119, 6, 0.15));
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.scheme-light .lang-dropdown-option.is-selected {
|
||||||
|
color: var(--app-accent, #d97706);
|
||||||
|
-webkit-text-fill-color: var(--app-accent, #d97706);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.scheme-dark .lang-dropdown-option.is-selected {
|
||||||
|
color: var(--app-accent-light, #fbbf24);
|
||||||
|
-webkit-text-fill-color: var(--app-accent-light, #fbbf24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-trigger-name {
|
||||||
|
max-width: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
+82
-14
@@ -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'
|
||||||
@@ -45,12 +46,14 @@ import { db } from './services/db.js'
|
|||||||
import { getLogbookAccess } from './services/logbookAccess.js'
|
import { getLogbookAccess } from './services/logbookAccess.js'
|
||||||
import type { LogbookAccessRole } from './services/logbook.js'
|
import type { LogbookAccessRole } from './services/logbook.js'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
|
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, BarChart2 } from 'lucide-react'
|
||||||
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 LanguageDropdown from './components/LanguageDropdown.tsx'
|
||||||
import {
|
import {
|
||||||
resolveTourLogbookContext,
|
resolveTourLogbookContext,
|
||||||
seedDemoLogbookIfNeeded
|
seedDemoLogbookIfNeeded
|
||||||
@@ -63,7 +66,7 @@ import { requestPersistentStorage } from './utils/storagePersist.js'
|
|||||||
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { confirmLeave } = useUnsavedChangesContext()
|
const { confirmLeave } = useUnsavedChangesContext()
|
||||||
const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
@@ -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)
|
||||||
@@ -515,15 +555,33 @@ function App() {
|
|||||||
localStorage.removeItem('active_logbook_title')
|
localStorage.removeItem('active_logbook_title')
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
cycleAppLanguage(i18n)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleExitDemo = () => {
|
const handleExitDemo = () => {
|
||||||
window.history.replaceState({}, document.title, '/')
|
window.history.replaceState({}, document.title, '/')
|
||||||
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 +622,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 +665,7 @@ function App() {
|
|||||||
onSelectLogbook={selectLogbook}
|
onSelectLogbook={selectLogbook}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
onOpenProfile={() => setShowUserProfile(true)}
|
onOpenProfile={() => setShowUserProfile(true)}
|
||||||
|
onOpenAdmin={isAdminUser ? openAdmin : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -642,10 +711,9 @@ function App() {
|
|||||||
{online ? <Wifi size={18} /> : <WifiOff size={18} />}
|
{online ? <Wifi size={18} /> : <WifiOff size={18} />}
|
||||||
<span>{online ? 'Online' : t('sync.status_offline')}</span>
|
<span>{online ? 'Online' : t('sync.status_offline')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<LanguageDropdown variant="icon" align="right" />
|
||||||
|
|
||||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
{isAdminUser && <AdminHeaderButton onClick={openAdmin} />}
|
||||||
<Languages size={18} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
|
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
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, Database, Image, MapPin, Mic, Users } from 'lucide-react'
|
||||||
|
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
return value.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number | undefined): string {
|
||||||
|
if (bytes === undefined || bytes === null) return '—'
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
const num = bytes / Math.pow(k, i)
|
||||||
|
return `${num.toFixed(1)} ${sizes[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value
|
||||||
|
}: {
|
||||||
|
icon: ReactNode
|
||||||
|
label: string
|
||||||
|
value: number | string
|
||||||
|
}) {
|
||||||
|
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">{typeof value === 'number' ? formatNumber(value) : 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}
|
||||||
|
/>
|
||||||
|
<KpiCard icon={<Database size={20} />} label="Datenbankgröße" value={formatBytes(summary.dbSize)} />
|
||||||
|
</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} />
|
||||||
|
<TimeSeriesChart title="Datenbankgröße (MB)" seriesKey="database_size" 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,17 +15,35 @@ export default function AppFooter() {
|
|||||||
·
|
·
|
||||||
</span>
|
</span>
|
||||||
<span className="app-version-footer__copyright">
|
<span className="app-version-footer__copyright">
|
||||||
© 2026 KnorrLabs/
|
© 2026
|
||||||
<a
|
|
||||||
href="mailto:elpatron+kd@mailbox.org"
|
|
||||||
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
|
|
||||||
>
|
|
||||||
Markus F.J. Busche
|
|
||||||
</a>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="app-version-footer__sep" aria-hidden="true">
|
<span className="app-version-footer__sep" aria-hidden="true">
|
||||||
·
|
·
|
||||||
</span>
|
</span>
|
||||||
|
<a
|
||||||
|
className="knorrlabs-footer-badge"
|
||||||
|
href="https://dashy.elpatron.me/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
|
||||||
|
>
|
||||||
|
<Compass size={14} aria-hidden="true" />
|
||||||
|
<span>KnorrLabs</span>
|
||||||
|
</a>
|
||||||
|
<span className="app-version-footer__sep" aria-hidden="true">
|
||||||
|
·
|
||||||
|
</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>
|
||||||
<a
|
<a
|
||||||
className="kofi-footer-badge"
|
className="kofi-footer-badge"
|
||||||
href={KOFI_URL}
|
href={KOFI_URL}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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 LanguageDropdown from './LanguageDropdown.tsx'
|
||||||
import {
|
import {
|
||||||
registerUser,
|
registerUser,
|
||||||
loginUser,
|
loginUser,
|
||||||
@@ -12,9 +12,10 @@ 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, HelpCircle, UserRound, X } from 'lucide-react'
|
||||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||||
import DisclaimerModal from './DisclaimerModal.tsx'
|
import DisclaimerModal from './DisclaimerModal.tsx'
|
||||||
import BetaBadge from './BetaBadge.tsx'
|
import BetaBadge from './BetaBadge.tsx'
|
||||||
@@ -27,10 +28,16 @@ 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({
|
||||||
const { t, i18n } = useTranslation()
|
onAuthenticated,
|
||||||
|
onOpenDemo,
|
||||||
|
restoreSession = false
|
||||||
|
}: AuthOnboardingProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -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
|
||||||
@@ -240,9 +267,6 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
setKnownUsers(getKnownUsernames())
|
setKnownUsers(getKnownUsernames())
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
cycleAppLanguage(i18n)
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyToClipboard = () => {
|
const copyToClipboard = () => {
|
||||||
if (recoveryPhrase) {
|
if (recoveryPhrase) {
|
||||||
@@ -347,10 +371,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 +421,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 +510,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 (
|
||||||
<>
|
<>
|
||||||
@@ -652,10 +777,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="auth-footer">
|
<div className="auth-footer">
|
||||||
<button type="button" className="btn-icon-text" onClick={toggleLanguage}>
|
<LanguageDropdown variant="text" align="left" />
|
||||||
<Languages size={18} />
|
|
||||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-icon-text link-sec"
|
className="btn-icon-text link-sec"
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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) {
|
||||||
|
let snap: PersonSnapshot | undefined = crewSnapshotsById[creatorId]
|
||||||
|
|
||||||
|
// Fallback: If not found directly by key, search by role or name or active user
|
||||||
|
if (!snap) {
|
||||||
|
if (creatorId === 'skipper') {
|
||||||
|
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
|
||||||
|
} else {
|
||||||
|
// Try to match name case-insensitively
|
||||||
|
snap = Object.values(crewSnapshotsById).find(
|
||||||
|
(s) => (s.name || '').trim().toLowerCase() === creatorId.trim().toLowerCase()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Try to match active username/userid to the skipper snapshot
|
||||||
|
if (!snap) {
|
||||||
|
const activeUsername = localStorage.getItem('active_username')
|
||||||
|
const activeUserId = localStorage.getItem('active_userid')
|
||||||
|
if (
|
||||||
|
(activeUsername && creatorId.toLowerCase() === activeUsername.toLowerCase()) ||
|
||||||
|
(activeUserId && creatorId === activeUserId)
|
||||||
|
) {
|
||||||
|
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snap) {
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||||
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
||||||
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
||||||
import type { LogbookCrewSelectionData } from '../types/person.js'
|
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||||
import { personToSnapshot } from '../utils/personSnapshots.js'
|
import { personToSnapshot } from '../utils/personSnapshots.js'
|
||||||
import LogEntriesList from './LogEntriesList.tsx'
|
import LogEntriesList from './LogEntriesList.tsx'
|
||||||
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
|
import { Ship, Users, FileText, Lock, ChevronLeft, UserPlus } from 'lucide-react'
|
||||||
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
|
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
|
||||||
import type { VesselData } from '../types/vessel.js'
|
import type { VesselData } from '../types/vessel.js'
|
||||||
import type { LogbookVesselSelectionData } from '../types/vessel.js'
|
import type { LogbookVesselSelectionData } from '../types/vessel.js'
|
||||||
@@ -52,9 +52,6 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
}
|
}
|
||||||
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
|
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
cycleAppLanguage(i18n)
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
@@ -111,10 +108,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
<UserPlus size={14} style={{ marginRight: '4px' }} />
|
<UserPlus size={14} style={{ marginRight: '4px' }} />
|
||||||
{t('demo.cta_register')}
|
{t('demo.cta_register')}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
|
<LanguageDropdown variant="secondary-button" align="right" />
|
||||||
<Globe size={14} style={{ marginRight: '4px' }} />
|
|
||||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -172,7 +166,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
payloadId: v.payloadId,
|
payloadId: v.payloadId,
|
||||||
data: v.data as VesselData
|
data: v.data as VesselData
|
||||||
}))}
|
}))}
|
||||||
preloadedSelection={logbookVesselSelection as LogbookVesselSelectionData}
|
preloadedSelection={logbookVesselSelection as unknown as LogbookVesselSelectionData}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Users } from 'lucide-react'
|
import { Users, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
|
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
|
||||||
import { loadPersonPool } from '../services/personPool.js'
|
import { loadPersonPool } from '../services/personPool.js'
|
||||||
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
|
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
|
||||||
@@ -24,6 +24,7 @@ export default function EntryCrewSection({
|
|||||||
preloadedPool
|
preloadedPool
|
||||||
}: EntryCrewSectionProps) {
|
}: EntryCrewSectionProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [collapsed, setCollapsed] = useState(true)
|
||||||
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
|
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -90,54 +91,78 @@ export default function EntryCrewSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-card" data-tour="entry-crew">
|
<div className="form-card" data-tour="entry-crew">
|
||||||
<div className="form-header">
|
<div
|
||||||
<Users size={22} className="form-icon" />
|
className="form-header accordion-header"
|
||||||
<h3>{t('entry_crew.title')}</h3>
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
</div>
|
onKeyDown={(e) => {
|
||||||
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p>
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
<div className="input-group mb-3">
|
setCollapsed(!collapsed)
|
||||||
<label>{t('entry_crew.day_skipper')}</label>
|
}
|
||||||
{skippers.length === 0 ? (
|
}}
|
||||||
<p className="help-text">{t('entry_crew.no_skipper')}</p>
|
role="button"
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="accordion-header-title">
|
||||||
|
<Users size={22} className="form-icon" />
|
||||||
|
<h3>{t('entry_crew.title')}</h3>
|
||||||
|
</div>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronDown size={20} className="accordion-chevron" />
|
||||||
) : (
|
) : (
|
||||||
<div className="crew-selection-list">
|
<ChevronUp size={20} className="accordion-chevron" />
|
||||||
{skippers.map(([id, data]) => (
|
|
||||||
<label key={id} className="crew-selection-item">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name={`entry-skipper-${logbookId}`}
|
|
||||||
checked={value.selectedSkipperId === id}
|
|
||||||
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
|
|
||||||
disabled={readOnly}
|
|
||||||
/>
|
|
||||||
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="input-group">
|
{!collapsed && (
|
||||||
<label>{t('entry_crew.day_crew')}</label>
|
<>
|
||||||
{crewEntries.length === 0 ? (
|
<p className="help-text mb-3" style={{ marginTop: '16px' }}>{t('entry_crew.subtitle')}</p>
|
||||||
<p className="help-text">{t('entry_crew.no_crew')}</p>
|
|
||||||
) : (
|
<div className="input-group mb-3">
|
||||||
<div className="crew-selection-list">
|
<label>{t('entry_crew.day_skipper')}</label>
|
||||||
{crewEntries.map(([id, data]) => (
|
{skippers.length === 0 ? (
|
||||||
<label key={id} className="crew-selection-item">
|
<p className="help-text">{t('entry_crew.no_skipper')}</p>
|
||||||
<input
|
) : (
|
||||||
type="checkbox"
|
<div className="crew-selection-list">
|
||||||
checked={value.selectedCrewIds.includes(id)}
|
{skippers.map(([id, data]) => (
|
||||||
onChange={() => toggleCrew(id)}
|
<label key={id} className="crew-selection-item">
|
||||||
disabled={readOnly}
|
<input
|
||||||
/>
|
type="radio"
|
||||||
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
name={`entry-skipper-${logbookId}`}
|
||||||
</label>
|
checked={value.selectedSkipperId === id}
|
||||||
))}
|
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
<div className="input-group">
|
||||||
|
<label>{t('entry_crew.day_crew')}</label>
|
||||||
|
{crewEntries.length === 0 ? (
|
||||||
|
<p className="help-text">{t('entry_crew.no_crew')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="crew-selection-list">
|
||||||
|
{crewEntries.map(([id, data]) => (
|
||||||
|
<label key={id} className="crew-selection-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value.selectedCrewIds.includes(id)}
|
||||||
|
onChange={() => toggleCrew(id)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,97 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Mic, Loader2 } from 'lucide-react'
|
||||||
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
||||||
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
||||||
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
|
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
|
||||||
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
|
import { updateVoiceMemoTranscript } from '../services/voiceAttachments.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { getAiAuthorized } from '../services/userPreferences.js'
|
||||||
|
|
||||||
interface EventRemarksCellProps {
|
interface EventRemarksCellProps {
|
||||||
event: LogEventPayload
|
event: LogEventPayload
|
||||||
logbookId: string
|
logbookId: string
|
||||||
voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
|
voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventRemarksCell({
|
export default function EventRemarksCell({
|
||||||
event,
|
event,
|
||||||
logbookId,
|
logbookId,
|
||||||
voiceMemoLookup
|
voiceMemoLookup,
|
||||||
|
readOnly = false
|
||||||
}: EventRemarksCellProps) {
|
}: EventRemarksCellProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { showAlert } = useDialog()
|
||||||
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
|
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
|
||||||
const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined
|
const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined
|
||||||
|
|
||||||
|
const [transcribing, setTranscribing] = useState(false)
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setIsOnline(true)
|
||||||
|
const handleOffline = () => setIsOnline(false)
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTranscribe = async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (transcribing || !preloaded?.audio || !voiceId) return
|
||||||
|
if (!getAiAuthorized()) {
|
||||||
|
void showAlert(
|
||||||
|
t('profile.ai_unauthorized_alert_desc'),
|
||||||
|
t('profile.ai_unauthorized_alert_title')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTranscribing(true)
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 15000)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/transcribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ audioDataUrl: preloaded.audio }),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Server returned status ${res.status}`)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
const text = (data.text || '').trim()
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('Transcription returned empty text')
|
||||||
|
}
|
||||||
|
await updateVoiceMemoTranscript(logbookId, voiceId, text)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||||
|
status: 'success',
|
||||||
|
mode: 'manual'
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
console.error('[EventRemarksCell] Transcription failed:', err)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||||
|
status: 'failed',
|
||||||
|
mode: 'manual'
|
||||||
|
})
|
||||||
|
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
|
||||||
|
} finally {
|
||||||
|
setTranscribing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let summary = formatEventSummary(event, t)
|
let summary = formatEventSummary(event, t)
|
||||||
if (voiceId && preloaded?.caption) {
|
if (voiceId && preloaded?.caption) {
|
||||||
summary = t('logs.live_voice_entry', { caption: preloaded.caption })
|
summary = t('logs.live_voice_entry', { caption: preloaded.caption })
|
||||||
@@ -28,12 +101,39 @@ export default function EventRemarksCell({
|
|||||||
<div className={`event-remarks-cell${voiceId ? ' event-remarks-cell--voice' : ''}`}>
|
<div className={`event-remarks-cell${voiceId ? ' event-remarks-cell--voice' : ''}`}>
|
||||||
<span>{summary}</span>
|
<span>{summary}</span>
|
||||||
{voiceId && (
|
{voiceId && (
|
||||||
<VoiceMemoPlayer
|
<div style={{ display: 'inline-flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginTop: '4px' }}>
|
||||||
audioId={voiceId}
|
<VoiceMemoPlayer
|
||||||
logbookId={logbookId}
|
audioId={voiceId}
|
||||||
preloaded={preloaded}
|
logbookId={logbookId}
|
||||||
compact
|
preloaded={preloaded}
|
||||||
/>
|
compact
|
||||||
|
/>
|
||||||
|
{!readOnly && preloaded && preloaded.transcribed === false && isOnline && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon-text link-sec"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
padding: '2px 6px',
|
||||||
|
height: 'auto',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
margin: 0
|
||||||
|
}}
|
||||||
|
onClick={handleTranscribe}
|
||||||
|
disabled={transcribing}
|
||||||
|
title={t('logs.live_voice_transcribe_action')}
|
||||||
|
>
|
||||||
|
{transcribing ? (
|
||||||
|
<Loader2 size={12} className="spin" />
|
||||||
|
) : (
|
||||||
|
<Mic size={12} />
|
||||||
|
)}
|
||||||
|
{transcribing ? t('logs.live_voice_transcribing') : t('logs.live_voice_transcribe_action')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||||
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
|
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, ArrowRight, KeyRound } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
getActiveMasterKey,
|
getActiveMasterKey,
|
||||||
registerUser,
|
registerUser,
|
||||||
@@ -50,7 +50,7 @@ const hexToBuffer = (hex: string): ArrayBuffer => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
|
export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [accepting, setAccepting] = useState(false)
|
const [accepting, setAccepting] = useState(false)
|
||||||
@@ -308,9 +308,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
setIsLoggedIn(true)
|
setIsLoggedIn(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
cycleAppLanguage(i18n)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recoveryPhrase) {
|
if (recoveryPhrase) {
|
||||||
return (
|
return (
|
||||||
@@ -510,10 +507,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
|
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
|
||||||
<button className="btn-icon-text" onClick={toggleLanguage}>
|
<LanguageDropdown variant="text" align="left" />
|
||||||
<Languages size={18} />
|
|
||||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Languages, Globe, ChevronDown } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
SUPPORTED_LANGUAGES,
|
||||||
|
changeAppLanguage,
|
||||||
|
normalizeAppLanguage,
|
||||||
|
type AppLanguage
|
||||||
|
} from '../utils/i18nLanguages.js'
|
||||||
|
|
||||||
|
function FlagIcon({ lang, className, style }: { lang: string; className?: string; style?: React.CSSProperties }) {
|
||||||
|
const baseStyle = {
|
||||||
|
display: 'inline-block',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
borderRadius: '2px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||||
|
boxSizing: 'border-box' as const,
|
||||||
|
...style
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (lang) {
|
||||||
|
case 'de':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 5 3" className={className} style={baseStyle}>
|
||||||
|
<rect width="5" height="3" fill="#FFCE00"/>
|
||||||
|
<rect width="5" height="2" fill="#DD0000"/>
|
||||||
|
<rect width="5" height="1" fill="#000000"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'en':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 60 30" className={className} style={baseStyle}>
|
||||||
|
<clipPath id="union-jack-clip">
|
||||||
|
<path d="M0,0 L60,30 M60,0 L0,30"/>
|
||||||
|
</clipPath>
|
||||||
|
<rect width="60" height="30" fill="#012169"/>
|
||||||
|
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6"/>
|
||||||
|
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#C8102E" strokeWidth="4" clipPath="url(#union-jack-clip)"/>
|
||||||
|
<path d="M30,0 v30 M0,15 h60" stroke="#fff" strokeWidth="10"/>
|
||||||
|
<path d="M30,0 v30 M0,15 h60" stroke="#C8102E" strokeWidth="6"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'da':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 37 28" className={className} style={baseStyle}>
|
||||||
|
<rect width="37" height="28" fill="#C8102E"/>
|
||||||
|
<path d="M12,0 h4 v28 h-4 z M0,12 h37 v4 h-37 z" fill="#FFFFFF"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'sv':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 10" className={className} style={baseStyle}>
|
||||||
|
<rect width="16" height="10" fill="#006AA7"/>
|
||||||
|
<path d="M5,0 h2 v10 h-2 z M0,4 h16 v2 h-16 z" fill="#FECC00"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'nb':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 22 16" className={className} style={baseStyle}>
|
||||||
|
<rect width="22" height="16" fill="#BA0C2F"/>
|
||||||
|
<path d="M6,0 h4 v16 h-4 z M0,6 h22 v4 h-22 z" fill="#FFFFFF"/>
|
||||||
|
<path d="M7,0 h2 v16 h-2 z M0,7 h22 v2 h-22 z" fill="#00205B"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'fr':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 3 2" className={className} style={baseStyle}>
|
||||||
|
<rect width="3" height="2" fill="#FFFFFF"/>
|
||||||
|
<rect width="1" height="2" fill="#002395"/>
|
||||||
|
<rect x="2" width="1" height="2" fill="#ED2939"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'es':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 3 2" className={className} style={baseStyle}>
|
||||||
|
<rect width="3" height="2" fill="#C1272D"/>
|
||||||
|
<rect y="0.5" width="3" height="1" fill="#FEE100"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LanguageDropdownProps {
|
||||||
|
variant?: 'icon' | 'text' | 'secondary-button'
|
||||||
|
align?: 'left' | 'right'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LanguageDropdown({
|
||||||
|
variant = 'icon',
|
||||||
|
align = 'right'
|
||||||
|
}: LanguageDropdownProps) {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const activeLang = normalizeAppLanguage(i18n.language)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
|
||||||
|
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', closeOnOutsideClick)
|
||||||
|
document.addEventListener('keydown', closeOnEscape)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', closeOnOutsideClick)
|
||||||
|
document.removeEventListener('keydown', closeOnEscape)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const selectLanguage = (lang: AppLanguage) => {
|
||||||
|
changeAppLanguage(i18n, lang)
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger button content based on variant
|
||||||
|
const renderTriggerContent = () => {
|
||||||
|
const name = t(`languages.${activeLang}`)
|
||||||
|
|
||||||
|
if (variant === 'icon') {
|
||||||
|
return (
|
||||||
|
<span className="lang-dropdown-trigger-flag" aria-hidden="true">
|
||||||
|
<FlagIcon lang={activeLang} className="lang-flag-svg trigger-icon-only" />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'secondary-button') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Globe size={14} style={{ marginRight: '4px' }} />
|
||||||
|
<FlagIcon lang={activeLang} className="lang-flag-svg" style={{ marginRight: '4px' }} />
|
||||||
|
<span className="lang-trigger-name">{name}</span>
|
||||||
|
<ChevronDown size={12} className="lang-dropdown-chevron" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default or "text" variant (used in footer)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Languages size={18} />
|
||||||
|
<FlagIcon lang={activeLang} className="lang-flag-svg" style={{ margin: '0 4px' }} />
|
||||||
|
<span>{name}</span>
|
||||||
|
<ChevronDown size={14} className="lang-dropdown-chevron" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerClass =
|
||||||
|
variant === 'icon'
|
||||||
|
? 'btn-icon'
|
||||||
|
: variant === 'secondary-button'
|
||||||
|
? 'btn secondary compact'
|
||||||
|
: 'btn-icon-text'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`lang-dropdown ${isOpen ? 'is-open' : ''} align-${align}`}
|
||||||
|
ref={rootRef}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={triggerClass}
|
||||||
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
title="Switch Language"
|
||||||
|
style={variant === 'secondary-button' ? { width: 'auto', padding: '6px 12px', fontSize: '13px' } : undefined}
|
||||||
|
>
|
||||||
|
{renderTriggerContent()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<ul className="lang-dropdown-menu" role="listbox">
|
||||||
|
{SUPPORTED_LANGUAGES.map((lang) => {
|
||||||
|
const isSelected = lang === activeLang
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={lang}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
className={`lang-dropdown-option ${isSelected ? 'is-selected' : ''}`}
|
||||||
|
onClick={() => selectLanguage(lang)}
|
||||||
|
>
|
||||||
|
<FlagIcon lang={lang} className="lang-flag-svg" />
|
||||||
|
<span className="lang-option-name">{t(`languages.${lang}`)}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,15 +22,16 @@ import {
|
|||||||
Zap
|
Zap
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { getAiAuthorized } from '../services/userPreferences.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 { formatEventSummary } from '../utils/formatEventSummary.js'
|
import CreatorAvatar from './CreatorAvatar.tsx'
|
||||||
import {
|
import {
|
||||||
getLastAutoPositionMs,
|
getLastAutoPositionMs,
|
||||||
getLastLoggedPositionWithin,
|
getLastLoggedPositionWithin,
|
||||||
@@ -42,7 +43,6 @@ import {
|
|||||||
liveFuelRemark,
|
liveFuelRemark,
|
||||||
livePhotoRemark,
|
livePhotoRemark,
|
||||||
liveVoiceRemark,
|
liveVoiceRemark,
|
||||||
parseLiveVoiceRemark,
|
|
||||||
livePrecipRemark,
|
livePrecipRemark,
|
||||||
liveSailsRemark,
|
liveSailsRemark,
|
||||||
liveSogRemark,
|
liveSogRemark,
|
||||||
@@ -79,7 +79,7 @@ import CourseDialInput from './CourseDialInput.tsx'
|
|||||||
import GpsSignalHint from './GpsSignalHint.tsx'
|
import GpsSignalHint from './GpsSignalHint.tsx'
|
||||||
import LiveCameraCapture from './LiveCameraCapture.tsx'
|
import LiveCameraCapture from './LiveCameraCapture.tsx'
|
||||||
import LiveVoiceCapture from './LiveVoiceCapture.tsx'
|
import LiveVoiceCapture from './LiveVoiceCapture.tsx'
|
||||||
import VoiceMemoPlayer from './VoiceMemoPlayer.tsx'
|
import EventRemarksCell from './EventRemarksCell.tsx'
|
||||||
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
|
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
|
||||||
import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
||||||
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||||
@@ -160,6 +160,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 +191,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 +234,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 +302,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) => {
|
||||||
@@ -645,13 +712,27 @@ export default function LiveLogView({
|
|||||||
{ analyticsSource: 'live_log' }
|
{ analyticsSource: 'live_log' }
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
|
if (err instanceof WeatherApiError) {
|
||||||
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
|
if (err.code === 'OFFLINE') {
|
||||||
return
|
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
|
||||||
}
|
return
|
||||||
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
|
}
|
||||||
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
|
if (err.code === 'NO_KEY') {
|
||||||
return
|
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err.code === 'UNAUTHORIZED') {
|
||||||
|
void showAlert(t('settings.weather_unauthorized'), t('logs.live_weather_owm_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err.code === 'NOT_FOUND') {
|
||||||
|
void showAlert(t('settings.weather_not_found'), t('logs.live_weather_owm_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err.code === 'BAD_REQUEST') {
|
||||||
|
void showAlert(t('settings.weather_bad_request'), t('logs.live_weather_owm_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.error('Live log OWM weather failed:', err)
|
console.error('Live log OWM weather failed:', err)
|
||||||
void showAlert(t('settings.weather_error'), t('logs.live_weather_owm_btn'))
|
void showAlert(t('settings.weather_error'), t('logs.live_weather_owm_btn'))
|
||||||
@@ -754,13 +835,50 @@ export default function LiveLogView({
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const audioDataUrl = await blobToAudioDataUrl(blob)
|
const audioDataUrl = await blobToAudioDataUrl(blob)
|
||||||
|
const authorized = getAiAuthorized()
|
||||||
|
let transcriptionText = ''
|
||||||
|
let transcribed = true
|
||||||
|
let transcriptionError = false
|
||||||
|
|
||||||
|
if (authorized) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 4000)
|
||||||
|
|
||||||
|
const res = await fetch('/api/ai/transcribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ audioDataUrl }),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (!res.ok) throw new Error(`Status ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
transcriptionText = (data.text || '').trim()
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[LiveLogView] Automatic transcription failed or timed out:', err)
|
||||||
|
transcriptionError = true
|
||||||
|
transcribed = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
transcribed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalCaption = caption
|
||||||
|
if (transcriptionText) {
|
||||||
|
finalCaption = caption
|
||||||
|
? `${caption}\n(Transkript: ${transcriptionText})`
|
||||||
|
: transcriptionText
|
||||||
|
}
|
||||||
|
|
||||||
const voiceId = await saveEntryVoiceMemo({
|
const voiceId = await saveEntryVoiceMemo({
|
||||||
logbookId,
|
logbookId,
|
||||||
entryId,
|
entryId,
|
||||||
audioDataUrl,
|
audioDataUrl,
|
||||||
mimeType,
|
mimeType,
|
||||||
durationSec,
|
durationSec,
|
||||||
caption,
|
caption: finalCaption,
|
||||||
|
transcribed,
|
||||||
analyticsContext: 'live_log'
|
analyticsContext: 'live_log'
|
||||||
})
|
})
|
||||||
await appendQuickEvent(logbookId, entryId, {
|
await appendQuickEvent(logbookId, entryId, {
|
||||||
@@ -772,6 +890,23 @@ export default function LiveLogView({
|
|||||||
setVoiceCaption('')
|
setVoiceCaption('')
|
||||||
showUndo('voice')
|
showUndo('voice')
|
||||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' })
|
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' })
|
||||||
|
if (transcriptionError) {
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||||
|
status: 'failed',
|
||||||
|
mode: 'auto'
|
||||||
|
})
|
||||||
|
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
|
||||||
|
} else if (authorized) {
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||||
|
status: 'success',
|
||||||
|
mode: 'auto'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
void showAlert(
|
||||||
|
t('profile.ai_unauthorized_alert_desc'),
|
||||||
|
t('profile.ai_unauthorized_alert_title')
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Live log voice save failed:', err)
|
console.error('Live log voice save failed:', err)
|
||||||
const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE'
|
const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE'
|
||||||
@@ -1143,25 +1278,21 @@ export default function LiveLogView({
|
|||||||
) : (
|
) : (
|
||||||
<ol className="live-log-stream">
|
<ol className="live-log-stream">
|
||||||
{events.map((event, index) => {
|
{events.map((event, index) => {
|
||||||
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
|
|
||||||
const voicePreloaded = voiceId ? voiceMemoLookup.get(voiceId) : undefined
|
|
||||||
let summary = formatEventSummary(event, t)
|
|
||||||
if (voiceId && voicePreloaded?.caption) {
|
|
||||||
summary = t('logs.live_voice_entry', { caption: voicePreloaded.caption })
|
|
||||||
}
|
|
||||||
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>
|
<EventRemarksCell
|
||||||
{voiceId && (
|
event={event}
|
||||||
<VoiceMemoPlayer
|
logbookId={logbookId}
|
||||||
audioId={voiceId}
|
voiceMemoLookup={voiceMemoLookup}
|
||||||
logbookId={logbookId}
|
readOnly={false}
|
||||||
preloaded={voicePreloaded}
|
/>
|
||||||
compact
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,34 +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)
|
const previewAudioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = previewAudioRef.current
|
const el = previewAudioRef.current
|
||||||
if (!el) return
|
if (!el) {
|
||||||
|
log('previewAudioRef is null')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Preview audio player loaded. readyState=' + el.readyState + ', duration=' + el.duration + ', src=' + el.src)
|
||||||
|
|
||||||
const handleLoadedMetadata = () => {
|
const handleLoadedMetadata = () => {
|
||||||
|
log('loadedmetadata event fired. readyState=' + el.readyState + ', duration=' + el.duration)
|
||||||
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
|
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
|
||||||
|
log('Duration correction hack triggered (duration=' + el.duration + '). Seeking to 1e10...')
|
||||||
el.currentTime = 1e10
|
el.currentTime = 1e10
|
||||||
const onTimeUpdate = () => {
|
const onTimeUpdate = () => {
|
||||||
|
log('timeupdate event. currentTime=' + el.currentTime + ', duration=' + el.duration)
|
||||||
el.currentTime = 0
|
el.currentTime = 0
|
||||||
el.removeEventListener('timeupdate', onTimeUpdate)
|
el.removeEventListener('timeupdate', onTimeUpdate)
|
||||||
|
log('currentTime reset to 0. Final duration=' + el.duration)
|
||||||
}
|
}
|
||||||
el.addEventListener('timeupdate', onTimeUpdate)
|
el.addEventListener('timeupdate', onTimeUpdate)
|
||||||
|
} else {
|
||||||
|
log('Duration correction skipped (duration is valid)')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (el.readyState >= 1) {
|
if (el.readyState >= 1) {
|
||||||
|
log('readyState >= 1. Executing hack immediately...')
|
||||||
handleLoadedMetadata()
|
handleLoadedMetadata()
|
||||||
} else {
|
} else {
|
||||||
|
log('readyState = 0. Adding loadedmetadata event listener...')
|
||||||
el.addEventListener('loadedmetadata', handleLoadedMetadata)
|
el.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log('Calling el.load() to force loading of the media resource...')
|
||||||
|
el.load()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
}
|
}
|
||||||
}, [previewUrl])
|
}, [previewUrl, log])
|
||||||
|
|
||||||
const stopStream = useCallback(() => {
|
const stopStream = useCallback(() => {
|
||||||
for (const track of streamRef.current?.getTracks() ?? []) {
|
for (const track of streamRef.current?.getTracks() ?? []) {
|
||||||
@@ -143,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 = () => {
|
||||||
@@ -166,47 +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(() => {
|
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)
|
}, 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()
|
||||||
|
log('Calling recorder.start()...')
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -308,6 +371,8 @@ export default function LiveVoiceCapture({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -541,17 +541,17 @@ export default function LogEntriesList({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
|
<div className="logbook-card-right-group">
|
||||||
|
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
||||||
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
<Download size={18} />
|
||||||
<Download size={18} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{!readOnly && (
|
|
||||||
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
|
|
||||||
<Trash2 size={18} />
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
{!readOnly && (
|
||||||
|
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
|
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||||
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
||||||
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
||||||
import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js'
|
import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js'
|
||||||
@@ -11,15 +11,17 @@ import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
|||||||
import { getErrorMessage } from '../utils/errors.js'
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
import { logoutUser } from '../services/auth.js'
|
import { logoutUser } from '../services/auth.js'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
import { BookOpen, Plus, Trash2, LogOut, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
||||||
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'
|
||||||
@@ -33,16 +35,20 @@ function sortLogbooks(
|
|||||||
): DecryptedLogbook[] {
|
): DecryptedLogbook[] {
|
||||||
const sorted = [...items]
|
const sorted = [...items]
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
const cmp =
|
let cmp = 0
|
||||||
sortBy === 'name'
|
if (sortBy === 'name') {
|
||||||
? a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
|
cmp = a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
|
||||||
: new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
} else {
|
||||||
|
const timeA = a.lastTravelDate ? new Date(a.lastTravelDate).getTime() : new Date(a.updatedAt).getTime()
|
||||||
|
const timeB = b.lastTravelDate ? new Date(b.lastTravelDate).getTime() : new Date(b.updatedAt).getTime()
|
||||||
|
cmp = timeA - timeB
|
||||||
|
}
|
||||||
return direction === 'asc' ? cmp : -cmp
|
return direction === 'asc' ? cmp : -cmp
|
||||||
})
|
})
|
||||||
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[]>([])
|
||||||
@@ -196,9 +202,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
onLogout()
|
onLogout()
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
cycleAppLanguage(i18n)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
||||||
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
||||||
@@ -289,8 +292,12 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
{lb.isDemo && (
|
{lb.isDemo && (
|
||||||
<span className="demo-badge">{t('demo.badge')}</span>
|
<span className="demo-badge">{t('demo.badge')}</span>
|
||||||
)}
|
)}
|
||||||
|
<span className="entry-count-badge" title={t('dashboard.travel_days_count', { count: lb.entryCount ?? 0 })}>
|
||||||
|
<CalendarDays size={12} style={{ marginRight: '4px' }} />
|
||||||
|
{lb.entryCount ?? 0}
|
||||||
|
</span>
|
||||||
<span className="date-badge">
|
<span className="date-badge">
|
||||||
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
|
{new Date(lb.lastTravelDate || lb.updatedAt).toLocaleDateString(i18n.language, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
@@ -388,10 +395,9 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
|
|
||||||
<ProfileHeaderButton onClick={onOpenProfile} />
|
<ProfileHeaderButton onClick={onOpenProfile} />
|
||||||
|
|
||||||
{/* Lang toggle */}
|
{onOpenAdmin && <AdminHeaderButton onClick={onOpenAdmin} />}
|
||||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
|
||||||
<Languages size={18} />
|
<LanguageDropdown variant="icon" align="right" />
|
||||||
</button>
|
|
||||||
|
|
||||||
<DisclaimerHeaderButton />
|
<DisclaimerHeaderButton />
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
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'
|
||||||
@@ -8,7 +9,8 @@ import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.j
|
|||||||
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { Camera, Trash2 } from 'lucide-react'
|
import { Camera, Image, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { probeCameraAvailability } from '../utils/cameraAvailability.js'
|
||||||
|
|
||||||
interface PhotoCaptureProps {
|
interface PhotoCaptureProps {
|
||||||
entryId: string
|
entryId: string
|
||||||
@@ -27,12 +29,43 @@ interface DecryptedPhoto {
|
|||||||
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
|
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
const { showConfirm } = useDialog()
|
||||||
|
const [collapsed, setCollapsed] = useState(true)
|
||||||
const [caption, setCaption] = useState('')
|
const [caption, setCaption] = useState('')
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
|
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
|
||||||
|
const [hasCamera, setHasCamera] = useState(false)
|
||||||
|
const [maximizedPhoto, setMaximizedPhoto] = useState<DecryptedPhoto | null>(null)
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const cameraInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!maximizedPhoto) return
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setMaximizedPhoto(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [maximizedPhoto])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
probeCameraAvailability().then((avail) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setHasCamera(avail === 'available')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Reactively query local photos database
|
// Reactively query local photos database
|
||||||
const localPhotos = useLiveQuery(
|
const localPhotos = useLiveQuery(
|
||||||
@@ -119,93 +152,201 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerSelect = () => {
|
const triggerGallerySelect = () => {
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.click()
|
fileInputRef.current.click()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const triggerCameraSelect = () => {
|
||||||
|
if (cameraInputRef.current) {
|
||||||
|
cameraInputRef.current.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-card mt-6">
|
<div className="form-card mt-6">
|
||||||
<div className="form-header mb-4">
|
<div
|
||||||
<Camera size={20} className="form-icon" />
|
className="form-header accordion-header"
|
||||||
<h3>{t('logs.photos_title')}</h3>
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
setCollapsed(!collapsed)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="accordion-header-title">
|
||||||
|
<Camera size={20} className="form-icon" />
|
||||||
|
<h3>{t('logs.photos_title')}</h3>
|
||||||
|
</div>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronDown size={20} className="accordion-chevron" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp size={20} className="accordion-chevron" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="auth-error mb-4">{error}</div>}
|
{!collapsed && (
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
{/* Upload area */}
|
{/* Upload area */}
|
||||||
{/* Upload Form */}
|
{/* Upload Form */}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
||||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
||||||
<label>{t('logs.photo_caption_label')}</label>
|
<label>{t('logs.photo_caption_label')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('logs.photo_caption_placeholder')}
|
placeholder={t('logs.photo_caption_placeholder')}
|
||||||
className="input-text"
|
className="input-text"
|
||||||
value={caption}
|
value={caption}
|
||||||
onChange={(e) => setCaption(e.target.value)}
|
onChange={(e) => setCaption(e.target.value)}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<input
|
||||||
type="button"
|
type="file"
|
||||||
className="btn primary"
|
accept="image/*"
|
||||||
onClick={triggerSelect}
|
capture="environment"
|
||||||
disabled={uploading}
|
ref={cameraInputRef}
|
||||||
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
onChange={handleFileChange}
|
||||||
>
|
style={{ display: 'none' }}
|
||||||
{uploading ? (
|
/>
|
||||||
<span className="spin">⏳</span>
|
|
||||||
) : (
|
|
||||||
<Camera size={16} />
|
|
||||||
)}
|
|
||||||
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Photo Grid */}
|
{hasCamera ? (
|
||||||
{decryptedPhotos.length === 0 ? (
|
<>
|
||||||
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
|
<button
|
||||||
) : (
|
type="button"
|
||||||
<div className="photo-attachments-grid">
|
className="btn primary"
|
||||||
{decryptedPhotos.map((photo) => (
|
onClick={triggerCameraSelect}
|
||||||
<div key={photo.payloadId} className="photo-card glass">
|
disabled={uploading}
|
||||||
<div className="photo-container">
|
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||||
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
>
|
||||||
{!readOnly && (
|
{uploading ? (
|
||||||
|
<span className="spin">⏳</span>
|
||||||
|
) : (
|
||||||
|
<Camera size={16} />
|
||||||
|
)}
|
||||||
|
{uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={triggerGallerySelect}
|
||||||
|
disabled={uploading}
|
||||||
|
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<span className="spin">⏳</span>
|
||||||
|
) : (
|
||||||
|
<Image size={16} />
|
||||||
|
)}
|
||||||
|
{uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="photo-btn-delete"
|
className="btn primary"
|
||||||
onClick={() => handleDelete(photo.payloadId)}
|
onClick={triggerGallerySelect}
|
||||||
title="Remove photo"
|
disabled={uploading}
|
||||||
|
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
{uploading ? (
|
||||||
|
<span className="spin">⏳</span>
|
||||||
|
) : (
|
||||||
|
<Camera size={16} />
|
||||||
|
)}
|
||||||
|
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{photo.caption && (
|
|
||||||
<div className="photo-caption-bar">
|
|
||||||
<span>{photo.caption}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
{/* Photo Grid */}
|
||||||
|
{decryptedPhotos.length === 0 ? (
|
||||||
|
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="photo-attachments-grid">
|
||||||
|
{decryptedPhotos.map((photo) => (
|
||||||
|
<div
|
||||||
|
key={photo.payloadId}
|
||||||
|
className="photo-card glass"
|
||||||
|
onClick={() => setMaximizedPhoto(photo)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<div className="photo-container">
|
||||||
|
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="photo-btn-delete"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDelete(photo.payloadId)
|
||||||
|
}}
|
||||||
|
title="Remove photo"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{photo.caption && (
|
||||||
|
<div className="photo-caption-bar">
|
||||||
|
<span>{photo.caption}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{maximizedPhoto && createPortal(
|
||||||
|
<div
|
||||||
|
className="photo-maximized-overlay"
|
||||||
|
onClick={() => setMaximizedPhoto(null)}
|
||||||
|
>
|
||||||
|
<div className="photo-maximized-container" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="photo-maximized-close"
|
||||||
|
onClick={() => setMaximizedPhoto(null)}
|
||||||
|
aria-label={t('common.close') || 'Close'}
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={maximizedPhoto.image}
|
||||||
|
alt={maximizedPhoto.caption || 'Maximized Attachment'}
|
||||||
|
className="photo-maximized-img"
|
||||||
|
/>
|
||||||
|
{maximizedPhoto.caption && (
|
||||||
|
<div className="photo-maximized-caption">
|
||||||
|
{maximizedPhoto.caption}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
|
import { isGermanLocale } from '../utils/i18nLanguages.js'
|
||||||
|
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||||
import { decryptJson } from '../services/crypto.js'
|
import { decryptJson } from '../services/crypto.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
||||||
@@ -12,7 +13,7 @@ import { emptyLogbookCrewSelection } from '../types/person.js'
|
|||||||
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
|
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
|
||||||
import type { PersonData } from '../types/person.js'
|
import type { PersonData } from '../types/person.js'
|
||||||
import LogEntriesList from './LogEntriesList.tsx'
|
import LogEntriesList from './LogEntriesList.tsx'
|
||||||
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
|
import { Ship, Users, FileText, Lock, AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
interface ReadOnlyViewerProps {
|
interface ReadOnlyViewerProps {
|
||||||
token: string
|
token: string
|
||||||
@@ -215,9 +216,6 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
cycleAppLanguage(i18n)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -258,10 +256,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
|
<LanguageDropdown variant="secondary-button" align="right" />
|
||||||
<Globe size={14} style={{ marginRight: '4px' }} />
|
|
||||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Compass, Palette, Save, Check, Cloud } from 'lucide-react'
|
import { Compass, Palette, Save, Check, Cloud, Brain } from 'lucide-react'
|
||||||
import ThemedSelect from './ThemedSelect.tsx'
|
import ThemedSelect from './ThemedSelect.tsx'
|
||||||
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
||||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
getThemePreference,
|
getThemePreference,
|
||||||
setColorSchemePreference,
|
setColorSchemePreference,
|
||||||
setOwmApiKey,
|
setOwmApiKey,
|
||||||
setThemePreference
|
setThemePreference,
|
||||||
|
getAiAuthorized,
|
||||||
|
setAiAuthorized
|
||||||
} from '../services/userPreferences.js'
|
} from '../services/userPreferences.js'
|
||||||
|
|
||||||
interface UserProfilePreferencesProps {
|
interface UserProfilePreferencesProps {
|
||||||
@@ -28,12 +30,25 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
|||||||
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
|
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
|
||||||
const [savingOwm, setSavingOwm] = useState(false)
|
const [savingOwm, setSavingOwm] = useState(false)
|
||||||
const [owmSaved, setOwmSaved] = useState(false)
|
const [owmSaved, setOwmSaved] = useState(false)
|
||||||
|
const [aiAuthorized, setAiAuthorizedState] = useState(() => getAiAuthorized(userId))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleChanged = () => {
|
||||||
|
setTheme(getThemePreference(userId))
|
||||||
|
setColorScheme(getColorSchemePreference(userId))
|
||||||
|
setAiAuthorizedState(getAiAuthorized(userId))
|
||||||
|
}
|
||||||
|
window.addEventListener('appearance-changed', handleChanged)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('appearance-changed', handleChanged)
|
||||||
|
}
|
||||||
|
}, [userId])
|
||||||
|
|
||||||
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
|
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
|
||||||
setThemePreference(userId, nextTheme)
|
setThemePreference(userId, nextTheme)
|
||||||
setColorSchemePreference(userId, nextColorScheme)
|
setColorSchemePreference(userId, nextColorScheme)
|
||||||
notifyAppearanceChanged()
|
notifyAppearanceChanged()
|
||||||
void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => {
|
void saveAppearancePrefsToServer(nextTheme, nextColorScheme, aiAuthorized, userId).catch((err) => {
|
||||||
console.warn('Failed to save appearance prefs to server:', err)
|
console.warn('Failed to save appearance prefs to server:', err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -58,6 +73,15 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
|||||||
window.setTimeout(() => setOwmSaved(false), 3000)
|
window.setTimeout(() => setOwmSaved(false), 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAiToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const nextVal = e.target.checked
|
||||||
|
setAiAuthorizedState(nextVal)
|
||||||
|
setAiAuthorized(userId, nextVal)
|
||||||
|
void saveAppearancePrefsToServer(theme, colorScheme, nextVal, userId).catch((err) => {
|
||||||
|
console.warn('Failed to save ai preference to server:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="member-editor-card glass">
|
<section className="member-editor-card glass">
|
||||||
@@ -152,6 +176,42 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="member-editor-card glass">
|
||||||
|
<div className="profile-section-header">
|
||||||
|
<Brain size={20} style={{ color: 'var(--app-accent-light)' }} />
|
||||||
|
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
|
||||||
|
{t('profile.ai_title')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 12px 0' }}>
|
||||||
|
{t('profile.ai_desc')}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted" style={{ fontSize: '13px', lineHeight: '145%', margin: '0 0 16px 0', whiteSpace: 'pre-line' }}>
|
||||||
|
{t('profile.ai_help')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className="switch-label"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#f1f5f9'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="profile-ai-authorize"
|
||||||
|
type="checkbox"
|
||||||
|
checked={aiAuthorized}
|
||||||
|
onChange={handleAiToggle}
|
||||||
|
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
<span>{t('profile.ai_enable_label')}</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
<PushNotificationSettings />
|
<PushNotificationSettings />
|
||||||
<PwaInstallPrompt variant="inline" />
|
<PwaInstallPrompt variant="inline" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface PreloadedVoiceMemo {
|
|||||||
mimeType?: string
|
mimeType?: string
|
||||||
durationSec?: number
|
durationSec?: number
|
||||||
caption?: string
|
caption?: string
|
||||||
|
transcribed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VoiceMemoPlayerProps {
|
interface VoiceMemoPlayerProps {
|
||||||
@@ -53,6 +54,10 @@ export default function VoiceMemoPlayer({
|
|||||||
el.addEventListener('loadedmetadata', handleLoadedMetadata)
|
el.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (src) {
|
||||||
|
el.load()
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
}
|
}
|
||||||
@@ -103,7 +108,7 @@ export default function VoiceMemoPlayer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="voice-memo-player-shell">
|
<div className="voice-memo-player-shell">
|
||||||
<audio ref={audioRef} className={playerClass} controls preload="none" src={src} />
|
<audio ref={audioRef} className={playerClass} controls preload="metadata" src={src} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ export function useEntryVoiceMemos(
|
|||||||
audio: String(decrypted.audio),
|
audio: String(decrypted.audio),
|
||||||
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined,
|
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined,
|
||||||
durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined,
|
durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined,
|
||||||
caption: decrypted.caption ? String(decrypted.caption) : ''
|
caption: decrypted.caption ? String(decrypted.caption) : '',
|
||||||
|
transcribed: decrypted.transcribed !== false
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// skip corrupt memo
|
// skip corrupt memo
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import deJson from './locales/de.json'
|
|||||||
import daJson from './locales/da.json'
|
import daJson from './locales/da.json'
|
||||||
import svJson from './locales/sv.json'
|
import svJson from './locales/sv.json'
|
||||||
import nbJson from './locales/nb.json'
|
import nbJson from './locales/nb.json'
|
||||||
|
import frJson from './locales/fr.json'
|
||||||
|
import esJson from './locales/es.json'
|
||||||
import { initSeo } from '../utils/seo.js'
|
import { initSeo } from '../utils/seo.js'
|
||||||
import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js'
|
import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js'
|
||||||
|
|
||||||
@@ -15,7 +17,9 @@ const resources = {
|
|||||||
de: { translation: deJson.translation },
|
de: { translation: deJson.translation },
|
||||||
da: { translation: daJson.translation },
|
da: { translation: daJson.translation },
|
||||||
sv: { translation: svJson.translation },
|
sv: { translation: svJson.translation },
|
||||||
nb: { translation: nbJson.translation }
|
nb: { translation: nbJson.translation },
|
||||||
|
fr: { translation: frJson.translation },
|
||||||
|
es: { translation: esJson.translation }
|
||||||
}
|
}
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import enJson from '../i18n/locales/en.json'
|
|||||||
import daJson from '../i18n/locales/da.json'
|
import daJson from '../i18n/locales/da.json'
|
||||||
import svJson from '../i18n/locales/sv.json'
|
import svJson from '../i18n/locales/sv.json'
|
||||||
import nbJson from '../i18n/locales/nb.json'
|
import nbJson from '../i18n/locales/nb.json'
|
||||||
|
import frJson from '../i18n/locales/fr.json'
|
||||||
|
import esJson from '../i18n/locales/es.json'
|
||||||
|
|
||||||
function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] {
|
function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] {
|
||||||
const keys: string[] = []
|
const keys: string[] = []
|
||||||
@@ -23,7 +25,9 @@ const bundles = {
|
|||||||
en: enJson.translation,
|
en: enJson.translation,
|
||||||
da: daJson.translation,
|
da: daJson.translation,
|
||||||
sv: svJson.translation,
|
sv: svJson.translation,
|
||||||
nb: nbJson.translation
|
nb: nbJson.translation,
|
||||||
|
fr: frJson.translation,
|
||||||
|
es: esJson.translation
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
describe('i18n locale key parity', () => {
|
describe('i18n locale key parity', () => {
|
||||||
|
|||||||
+652
-620
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,9 @@
|
|||||||
"en": "English",
|
"en": "English",
|
||||||
"da": "Dansk",
|
"da": "Dansk",
|
||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk",
|
||||||
|
"fr": "Français",
|
||||||
|
"es": "Español"
|
||||||
},
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
@@ -43,7 +45,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 +93,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",
|
||||||
@@ -177,6 +188,9 @@
|
|||||||
"departure": "Start-Hafen (Reise von)",
|
"departure": "Start-Hafen (Reise von)",
|
||||||
"destination": "Ziel-Hafen (nach)",
|
"destination": "Ziel-Hafen (nach)",
|
||||||
"route": "Reise von/nach",
|
"route": "Reise von/nach",
|
||||||
|
"tanks": "Tanks",
|
||||||
|
"customize_columns": "Spalten anpassen",
|
||||||
|
"column_selector_title": "Anzuzeigende Spalten",
|
||||||
"freshwater": "Frischwasser (Liter)",
|
"freshwater": "Frischwasser (Liter)",
|
||||||
"fuel": "Treibstoff / Fuel (Liter)",
|
"fuel": "Treibstoff / Fuel (Liter)",
|
||||||
"greywater": "Grauwasser (Liter)",
|
"greywater": "Grauwasser (Liter)",
|
||||||
@@ -288,6 +302,9 @@
|
|||||||
"live_voice_entry_plain": "Sprachnotiz",
|
"live_voice_entry_plain": "Sprachnotiz",
|
||||||
"live_voice_caption_label": "Beschriftung (optional)",
|
"live_voice_caption_label": "Beschriftung (optional)",
|
||||||
"live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister",
|
"live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister",
|
||||||
|
"live_voice_transcribe_action": "Transkribieren",
|
||||||
|
"live_voice_transcribing": "Transkribiere...",
|
||||||
|
"live_voice_transcribe_failed": "Sprachmemo gespeichert, aber Transkription fehlgeschlagen.",
|
||||||
"live_undo_voice_hint": "Sprachnotiz gespeichert",
|
"live_undo_voice_hint": "Sprachnotiz gespeichert",
|
||||||
"live_comment_btn": "Kommentar",
|
"live_comment_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Freitext eingeben…",
|
"live_comment_placeholder": "Freitext eingeben…",
|
||||||
@@ -344,6 +361,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",
|
||||||
@@ -426,10 +444,12 @@
|
|||||||
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
|
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
|
||||||
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
|
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
|
||||||
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
|
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||||
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
|
"photos_title": "Foto-Anhänge",
|
||||||
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
||||||
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
||||||
"photo_btn": "Foto aufnehmen / Hochladen",
|
"photo_btn": "Foto aufnehmen / Hochladen",
|
||||||
|
"photo_camera_btn": "Foto aufnehmen",
|
||||||
|
"photo_gallery_btn": "Aus Galerie wählen",
|
||||||
"photo_processing": "Wird verarbeitet...",
|
"photo_processing": "Wird verarbeitet...",
|
||||||
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
|
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
|
||||||
"photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?",
|
"photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?",
|
||||||
@@ -522,6 +542,9 @@
|
|||||||
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok), falls du die Daten später behalten möchtest.",
|
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok), falls du die Daten später behalten möchtest.",
|
||||||
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
|
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
|
||||||
"loading": "Logbücher werden geladen...",
|
"loading": "Logbücher werden geladen...",
|
||||||
|
"travel_days_count_zero": "Keine Reisetage",
|
||||||
|
"travel_days_count_one": "1 Reisetag",
|
||||||
|
"travel_days_count_other": "{{count}} Reisetage",
|
||||||
"status_synced": "Synchronisiert",
|
"status_synced": "Synchronisiert",
|
||||||
"status_local": "Nur lokaler Cache",
|
"status_local": "Nur lokaler Cache",
|
||||||
"delete_btn": "Logbuch löschen",
|
"delete_btn": "Logbuch löschen",
|
||||||
@@ -659,6 +682,12 @@
|
|||||||
"integrations_title": "Integrationen",
|
"integrations_title": "Integrationen",
|
||||||
"owm_key": "OpenWeatherMap API-Schlüssel",
|
"owm_key": "OpenWeatherMap API-Schlüssel",
|
||||||
"owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
|
"owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
|
||||||
|
"ai_title": "KI-Funktionen & Datenschutz",
|
||||||
|
"ai_desc": "Autorisiere die Nutzung von künstlicher Intelligenz (lokale/Cloud-Integrationen) für deine Logbücher.",
|
||||||
|
"ai_help": "Die Aktivierung ermöglicht es, Reiseberichte automatisch zusammenzufassen und Sprachnotizen zu transkribieren. Zur Verarbeitung werden Sprachaufnahmen und Logbucheinträge verschlüsselt an OpenRouter übertragen. Die Daten werden dort nicht dauerhaft gespeichert.\n\nDa der Betrieb dieser Cloud-Ressourcen Kosten verursacht, freuen wir uns über eine freiwillige Unterstützung über den Ko-fi-Spenden-Link im Footer, um diese Funktionen dauerhaft für alle kostenlos anbieten zu können.",
|
||||||
|
"ai_enable_label": "Transkribierung und Tageszusammenfassungen aktivieren",
|
||||||
|
"ai_unauthorized_alert_title": "KI-Funktionen nicht autorisiert",
|
||||||
|
"ai_unauthorized_alert_desc": "Um Sprachnotizen zu transkribieren oder Reiseberichte zusammenzufassen, musst du der Datenübermittlung an OpenRouter in deinem Benutzerprofil unter 'KI-Funktionen & Datenschutz' zustimmen.",
|
||||||
"prefs_save": "Speichern",
|
"prefs_save": "Speichern",
|
||||||
"prefs_saving": "Wird gespeichert…",
|
"prefs_saving": "Wird gespeichert…",
|
||||||
"prefs_saved": "Gespeichert",
|
"prefs_saved": "Gespeichert",
|
||||||
@@ -780,6 +809,9 @@
|
|||||||
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.",
|
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.",
|
||||||
"weather_success": "Wetterdaten erfolgreich abgerufen!",
|
"weather_success": "Wetterdaten erfolgreich abgerufen!",
|
||||||
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.",
|
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.",
|
||||||
|
"weather_unauthorized": "Wetterdatenabruf fehlgeschlagen. Der API-Schlüssel ist ungültig oder nicht autorisiert.",
|
||||||
|
"weather_not_found": "Wetterdatenabruf fehlgeschlagen. Der angegebene Ort oder die Koordinaten wurden nicht gefunden.",
|
||||||
|
"weather_bad_request": "Wetterdatenabruf fehlgeschlagen. Es wurde kein Ort und keine GPS-Position angegeben.",
|
||||||
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
|
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
|
||||||
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
|
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
|
||||||
"share_title": "Logbuch teilen (Schreibgeschützt)",
|
"share_title": "Logbuch teilen (Schreibgeschützt)",
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
"en": "English",
|
"en": "English",
|
||||||
"da": "Dansk",
|
"da": "Dansk",
|
||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk",
|
||||||
|
"fr": "French",
|
||||||
|
"es": "Spanish"
|
||||||
},
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
@@ -43,7 +45,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 +93,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",
|
||||||
@@ -177,6 +188,9 @@
|
|||||||
"departure": "Departure Port (von)",
|
"departure": "Departure Port (von)",
|
||||||
"destination": "Destination Port (nach)",
|
"destination": "Destination Port (nach)",
|
||||||
"route": "Route / Journey",
|
"route": "Route / Journey",
|
||||||
|
"tanks": "Tanks",
|
||||||
|
"customize_columns": "Customize columns",
|
||||||
|
"column_selector_title": "Columns to Show",
|
||||||
"freshwater": "Freshwater (Liters)",
|
"freshwater": "Freshwater (Liters)",
|
||||||
"fuel": "Fuel (Liters)",
|
"fuel": "Fuel (Liters)",
|
||||||
"greywater": "Greywater (Liters)",
|
"greywater": "Greywater (Liters)",
|
||||||
@@ -288,6 +302,9 @@
|
|||||||
"live_voice_entry_plain": "Voice memo",
|
"live_voice_entry_plain": "Voice memo",
|
||||||
"live_voice_caption_label": "Caption (optional)",
|
"live_voice_caption_label": "Caption (optional)",
|
||||||
"live_voice_caption_placeholder": "e.g. radio call with harbour master",
|
"live_voice_caption_placeholder": "e.g. radio call with harbour master",
|
||||||
|
"live_voice_transcribe_action": "Transcribe",
|
||||||
|
"live_voice_transcribing": "Transcribing…",
|
||||||
|
"live_voice_transcribe_failed": "Voice memo saved, but transcription failed.",
|
||||||
"live_undo_voice_hint": "Voice memo saved",
|
"live_undo_voice_hint": "Voice memo saved",
|
||||||
"live_comment_btn": "Comment",
|
"live_comment_btn": "Comment",
|
||||||
"live_comment_placeholder": "Enter text…",
|
"live_comment_placeholder": "Enter text…",
|
||||||
@@ -344,6 +361,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",
|
||||||
@@ -426,10 +444,12 @@
|
|||||||
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
|
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
|
||||||
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
|
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
|
||||||
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
|
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
|
||||||
"photos_title": "Photo Attachments (E2E Encrypted)",
|
"photos_title": "Photo Attachments",
|
||||||
"photo_caption_label": "Photo Caption / Label (Optional)",
|
"photo_caption_label": "Photo Caption / Label (Optional)",
|
||||||
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
||||||
"photo_btn": "Take Photo / Upload",
|
"photo_btn": "Take Photo / Upload",
|
||||||
|
"photo_camera_btn": "Take Photo",
|
||||||
|
"photo_gallery_btn": "Choose from Gallery",
|
||||||
"photo_processing": "Processing...",
|
"photo_processing": "Processing...",
|
||||||
"no_photos": "No photos attached to this journal entry yet.",
|
"no_photos": "No photos attached to this journal entry yet.",
|
||||||
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
|
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
|
||||||
@@ -522,6 +542,9 @@
|
|||||||
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok) if you may need the data later.",
|
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok) if you may need the data later.",
|
||||||
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
|
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
|
||||||
"loading": "Loading logbooks...",
|
"loading": "Loading logbooks...",
|
||||||
|
"travel_days_count_zero": "No travel days",
|
||||||
|
"travel_days_count_one": "1 travel day",
|
||||||
|
"travel_days_count_other": "{{count}} travel days",
|
||||||
"status_synced": "Synced",
|
"status_synced": "Synced",
|
||||||
"status_local": "Local Cache Only",
|
"status_local": "Local Cache Only",
|
||||||
"delete_btn": "Delete logbook",
|
"delete_btn": "Delete logbook",
|
||||||
@@ -659,6 +682,12 @@
|
|||||||
"integrations_title": "Integrations",
|
"integrations_title": "Integrations",
|
||||||
"owm_key": "OpenWeatherMap API key",
|
"owm_key": "OpenWeatherMap API key",
|
||||||
"owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
|
"owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
|
||||||
|
"ai_title": "AI Features & Privacy",
|
||||||
|
"ai_desc": "Authorize artificial intelligence integrations for your logbooks.",
|
||||||
|
"ai_help": "Enabling AI features allows the app to summarize travel days and transcribe recorded voice memos. To process these requests, raw voice data and travel logs are sent securely on-the-fly to OpenRouter. No data is stored permanently by the AI model.\n\nThese cloud resources cost money to run; if you enjoy using them, please consider supporting the project voluntarily with a donation via the Ko-fi link in the footer to keep them free and sustainable for everyone.",
|
||||||
|
"ai_enable_label": "Enable transcription and travel day summaries",
|
||||||
|
"ai_unauthorized_alert_title": "AI Features Not Authorized",
|
||||||
|
"ai_unauthorized_alert_desc": "To use transcription or travel day summaries, please authorize the data transmission to OpenRouter in your User Profile under 'AI Features & Privacy'.",
|
||||||
"prefs_save": "Save",
|
"prefs_save": "Save",
|
||||||
"prefs_saving": "Saving…",
|
"prefs_saving": "Saving…",
|
||||||
"prefs_saved": "Saved",
|
"prefs_saved": "Saved",
|
||||||
@@ -780,6 +809,9 @@
|
|||||||
"no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.",
|
"no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.",
|
||||||
"weather_success": "Weather details fetched successfully!",
|
"weather_success": "Weather details fetched successfully!",
|
||||||
"weather_error": "Failed to fetch weather. Check your API key and connection.",
|
"weather_error": "Failed to fetch weather. Check your API key and connection.",
|
||||||
|
"weather_unauthorized": "Failed to fetch weather. The API key is invalid or unauthorized.",
|
||||||
|
"weather_not_found": "Failed to fetch weather. The specified location or coordinates were not found.",
|
||||||
|
"weather_bad_request": "Failed to fetch weather. No location or GPS position was specified.",
|
||||||
"weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.",
|
"weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.",
|
||||||
"gps_error": "Please enter a location or fetch GPS coordinates first.",
|
"gps_error": "Please enter a location or fetch GPS coordinates first.",
|
||||||
"share_title": "Share Logbook (Read-Only)",
|
"share_title": "Share Logbook (Read-Only)",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+607
-575
File diff suppressed because it is too large
Load Diff
+656
-624
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
|||||||
|
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
|
||||||
|
dbSize: 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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ export const PlausibleEvents = {
|
|||||||
LIVE_LOG_OPENED: 'Live Log Opened',
|
LIVE_LOG_OPENED: 'Live Log Opened',
|
||||||
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
||||||
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
|
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
|
||||||
|
VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
|
||||||
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
|
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
|
||||||
AI_SUMMARY_GENERATED: 'AI Summary Generated',
|
AI_SUMMARY_GENERATED: 'AI Summary Generated',
|
||||||
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ describe('appearancePrefs', () => {
|
|||||||
await expect(fetchAppearancePrefs()).resolves.toEqual({
|
await expect(fetchAppearancePrefs()).resolves.toEqual({
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
colorScheme: 'auto',
|
colorScheme: 'auto',
|
||||||
|
aiAuthorized: false,
|
||||||
persisted: false
|
persisted: false
|
||||||
})
|
})
|
||||||
expect(mockedApiJson).not.toHaveBeenCalled()
|
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||||
@@ -36,6 +37,7 @@ describe('appearancePrefs', () => {
|
|||||||
mockedApiJson.mockResolvedValueOnce({
|
mockedApiJson.mockResolvedValueOnce({
|
||||||
theme: 'ocean',
|
theme: 'ocean',
|
||||||
colorScheme: 'dark',
|
colorScheme: 'dark',
|
||||||
|
aiAuthorized: true,
|
||||||
persisted: true
|
persisted: true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ describe('appearancePrefs', () => {
|
|||||||
|
|
||||||
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
|
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
|
||||||
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
|
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
|
||||||
|
expect(localStorage.getItem(`user_pref_ai_authorized_${USER_ID}`)).toBe('true')
|
||||||
expect(changed).toHaveBeenCalledTimes(1)
|
expect(changed).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -53,20 +56,20 @@ describe('appearancePrefs', () => {
|
|||||||
localStorage.setItem('active_userid', USER_ID)
|
localStorage.setItem('active_userid', USER_ID)
|
||||||
setThemePreference(USER_ID, 'material')
|
setThemePreference(USER_ID, 'material')
|
||||||
mockedApiJson
|
mockedApiJson
|
||||||
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false })
|
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false })
|
||||||
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true })
|
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', aiAuthorized: false, persisted: true })
|
||||||
|
|
||||||
await syncAppearancePrefs(USER_ID)
|
await syncAppearancePrefs(USER_ID)
|
||||||
|
|
||||||
expect(mockedApiJson).toHaveBeenCalledTimes(2)
|
expect(mockedApiJson).toHaveBeenCalledTimes(2)
|
||||||
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
|
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ theme: 'material', colorScheme: 'auto' })
|
body: JSON.stringify({ theme: 'material', colorScheme: 'auto', aiAuthorized: false })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('saveAppearancePrefsToServer skips when not authenticated', async () => {
|
it('saveAppearancePrefsToServer skips when not authenticated', async () => {
|
||||||
await saveAppearancePrefsToServer('ocean', 'light')
|
await saveAppearancePrefsToServer('ocean', 'light', true)
|
||||||
expect(mockedApiJson).not.toHaveBeenCalled()
|
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -76,6 +79,7 @@ describe('appearancePrefs', () => {
|
|||||||
mockedApiJson.mockResolvedValue({
|
mockedApiJson.mockResolvedValue({
|
||||||
theme: 'material',
|
theme: 'material',
|
||||||
colorScheme: 'dark',
|
colorScheme: 'dark',
|
||||||
|
aiAuthorized: false,
|
||||||
persisted: true
|
persisted: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
getColorSchemePreference,
|
getColorSchemePreference,
|
||||||
getThemePreference,
|
getThemePreference,
|
||||||
setColorSchemePreference,
|
setColorSchemePreference,
|
||||||
setThemePreference
|
setThemePreference,
|
||||||
|
getAiAuthorized,
|
||||||
|
setAiAuthorized
|
||||||
} from './userPreferences.js'
|
} from './userPreferences.js'
|
||||||
|
|
||||||
const API_BASE = '/api/auth/appearance-prefs'
|
const API_BASE = '/api/auth/appearance-prefs'
|
||||||
@@ -13,13 +15,15 @@ const API_BASE = '/api/auth/appearance-prefs'
|
|||||||
export interface AppearancePrefs {
|
export interface AppearancePrefs {
|
||||||
theme: string
|
theme: string
|
||||||
colorScheme: string
|
colorScheme: string
|
||||||
|
aiAuthorized: boolean
|
||||||
persisted: boolean
|
persisted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasLocalAppearancePrefs(userId: string): boolean {
|
function hasLocalAppearancePrefs(userId: string): boolean {
|
||||||
return (
|
return (
|
||||||
localStorage.getItem(`user_pref_theme_${userId}`) != null ||
|
localStorage.getItem(`user_pref_theme_${userId}`) != null ||
|
||||||
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null
|
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null ||
|
||||||
|
localStorage.getItem(`user_pref_ai_authorized_${userId}`) != null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +39,7 @@ function resolveSyncedUserId(userId?: string | null): string | null {
|
|||||||
|
|
||||||
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
|
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
|
||||||
if (!resolveSyncedUserId(userId)) {
|
if (!resolveSyncedUserId(userId)) {
|
||||||
return { theme: 'auto', colorScheme: 'auto', persisted: false }
|
return { theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiJson<AppearancePrefs>(API_BASE)
|
return apiJson<AppearancePrefs>(API_BASE)
|
||||||
@@ -44,13 +48,14 @@ export async function fetchAppearancePrefs(userId?: string | null): Promise<Appe
|
|||||||
export async function saveAppearancePrefsToServer(
|
export async function saveAppearancePrefsToServer(
|
||||||
theme: string,
|
theme: string,
|
||||||
colorScheme: string,
|
colorScheme: string,
|
||||||
|
aiAuthorized: boolean,
|
||||||
userId?: string | null
|
userId?: string | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!resolveSyncedUserId(userId)) return
|
if (!resolveSyncedUserId(userId)) return
|
||||||
|
|
||||||
await apiJson<AppearancePrefs>(API_BASE, {
|
await apiJson<AppearancePrefs>(API_BASE, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ theme, colorScheme })
|
body: JSON.stringify({ theme, colorScheme, aiAuthorized })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +70,14 @@ export async function syncAppearancePrefs(userId?: string | null): Promise<void>
|
|||||||
if (server.persisted) {
|
if (server.persisted) {
|
||||||
setThemePreference(id, server.theme)
|
setThemePreference(id, server.theme)
|
||||||
setColorSchemePreference(id, server.colorScheme)
|
setColorSchemePreference(id, server.colorScheme)
|
||||||
|
setAiAuthorized(id, server.aiAuthorized)
|
||||||
} else if (hasLocalAppearancePrefs(id)) {
|
} else if (hasLocalAppearancePrefs(id)) {
|
||||||
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id), id)
|
await saveAppearancePrefsToServer(
|
||||||
|
getThemePreference(id),
|
||||||
|
getColorSchemePreference(id),
|
||||||
|
getAiAuthorized(id),
|
||||||
|
id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to sync appearance preferences:', err)
|
console.warn('Failed to sync appearance preferences:', err)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 || '',
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface DecryptedLogbook {
|
|||||||
isShared: boolean
|
isShared: boolean
|
||||||
accessRole: LogbookAccessRole
|
accessRole: LogbookAccessRole
|
||||||
isDemo?: boolean
|
isDemo?: boolean
|
||||||
|
lastTravelDate?: string
|
||||||
|
entryCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to decrypt a logbook's title using the active logbook key or master key
|
// Helper to decrypt a logbook's title using the active logbook key or master key
|
||||||
@@ -142,10 +144,24 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
|||||||
// Retrieve all from Dexie cache
|
// Retrieve all from Dexie cache
|
||||||
const cachedLogbooks = await db.logbooks.toArray()
|
const cachedLogbooks = await db.logbooks.toArray()
|
||||||
|
|
||||||
// Decrypt titles
|
// Decrypt titles and query last travel dates
|
||||||
const decrypted: DecryptedLogbook[] = []
|
const decrypted: DecryptedLogbook[] = []
|
||||||
for (const lb of cachedLogbooks) {
|
for (const lb of cachedLogbooks) {
|
||||||
const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle)
|
const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle)
|
||||||
|
|
||||||
|
// Find latest travel date from local entries cache
|
||||||
|
const entries = await db.entries.where({ logbookId: lb.id }).toArray()
|
||||||
|
let lastTravelDate: string | undefined = undefined
|
||||||
|
if (entries.length > 0) {
|
||||||
|
const dates = entries
|
||||||
|
.map((e) => e.listCache?.date)
|
||||||
|
.filter((d): d is string => typeof d === 'string' && d.length > 0)
|
||||||
|
if (dates.length > 0) {
|
||||||
|
dates.sort()
|
||||||
|
lastTravelDate = dates[dates.length - 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
decrypted.push({
|
decrypted.push({
|
||||||
id: lb.id,
|
id: lb.id,
|
||||||
title,
|
title,
|
||||||
@@ -155,7 +171,9 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
|||||||
accessRole: lb.isShared === 1
|
accessRole: lb.isShared === 1
|
||||||
? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`)
|
? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`)
|
||||||
: 'OWNER',
|
: 'OWNER',
|
||||||
isDemo: lb.isDemo === 1
|
isDemo: lb.isDemo === 1,
|
||||||
|
lastTravelDate,
|
||||||
|
entryCount: entries.length
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(', ')}` : ''
|
||||||
|
|
||||||
|
doc.setFont('Helvetica', 'normal');
|
||||||
if (entry.trackDistanceNm) {
|
if (entry.trackDistanceNm) {
|
||||||
doc.setFont('Helvetica', 'normal');
|
|
||||||
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);
|
||||||
|
|||||||
@@ -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,7 +6,9 @@ import {
|
|||||||
getThemePreference,
|
getThemePreference,
|
||||||
setColorSchemePreference,
|
setColorSchemePreference,
|
||||||
setOwmApiKey,
|
setOwmApiKey,
|
||||||
setThemePreference
|
setThemePreference,
|
||||||
|
getAiAuthorized,
|
||||||
|
setAiAuthorized
|
||||||
} from './userPreferences.js'
|
} from './userPreferences.js'
|
||||||
|
|
||||||
const USER_ID = 'test-user-123'
|
const USER_ID = 'test-user-123'
|
||||||
@@ -58,4 +60,13 @@ describe('userPreferences', () => {
|
|||||||
expect(getThemePreference(USER_ID)).toBe('ocean')
|
expect(getThemePreference(USER_ID)).toBe('ocean')
|
||||||
expect(getColorSchemePreference(USER_ID)).toBe('light')
|
expect(getColorSchemePreference(USER_ID)).toBe('light')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('stores AI authorization preference per user', () => {
|
||||||
|
localStorage.setItem('active_userid', USER_ID)
|
||||||
|
expect(getAiAuthorized()).toBe(false)
|
||||||
|
setAiAuthorized(USER_ID, true)
|
||||||
|
expect(getAiAuthorized()).toBe(true)
|
||||||
|
expect(getAiAuthorized(USER_ID)).toBe(true)
|
||||||
|
expect(getAiAuthorized('other-user')).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -89,3 +89,20 @@ export function setOwmApiKey(userId: string, value: string): void {
|
|||||||
localStorage.removeItem(owmKey(userId))
|
localStorage.removeItem(owmKey(userId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function aiAuthorizedKey(userId: string): string {
|
||||||
|
return `user_pref_ai_authorized_${userId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAiAuthorized(userId?: string | null): boolean {
|
||||||
|
const id = resolveUserId(userId)
|
||||||
|
if (id) {
|
||||||
|
return localStorage.getItem(aiAuthorizedKey(id)) === 'true'
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAiAuthorized(userId: string, value: boolean): void {
|
||||||
|
localStorage.setItem(aiAuthorizedKey(userId), String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { db } from './db.js'
|
import { db } from './db.js'
|
||||||
import { getActiveMasterKey } from './auth.js'
|
import { getActiveMasterKey } from './auth.js'
|
||||||
import { getLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
import { encryptJson } from './crypto.js'
|
import { encryptJson, decryptJson } from './crypto.js'
|
||||||
import { syncLogbook } from './sync.js'
|
import { syncLogbook } from './sync.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ export async function saveEntryVoiceMemo(options: {
|
|||||||
mimeType: string
|
mimeType: string
|
||||||
durationSec: number
|
durationSec: number
|
||||||
caption?: string
|
caption?: string
|
||||||
|
transcribed?: boolean
|
||||||
analyticsContext?: string
|
analyticsContext?: string
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const {
|
const {
|
||||||
@@ -27,6 +28,7 @@ export async function saveEntryVoiceMemo(options: {
|
|||||||
mimeType,
|
mimeType,
|
||||||
durationSec,
|
durationSec,
|
||||||
caption = '',
|
caption = '',
|
||||||
|
transcribed = true,
|
||||||
analyticsContext = 'logbook'
|
analyticsContext = 'logbook'
|
||||||
} = options
|
} = options
|
||||||
const masterKey = await getEncryptionKey(logbookId)
|
const masterKey = await getEncryptionKey(logbookId)
|
||||||
@@ -35,7 +37,8 @@ export async function saveEntryVoiceMemo(options: {
|
|||||||
audio: audioDataUrl,
|
audio: audioDataUrl,
|
||||||
mimeType,
|
mimeType,
|
||||||
durationSec,
|
durationSec,
|
||||||
caption: caption.trim()
|
caption: caption.trim(),
|
||||||
|
transcribed: !!transcribed
|
||||||
}
|
}
|
||||||
|
|
||||||
const encrypted = await encryptJson(voicePayload, masterKey)
|
const encrypted = await encryptJson(voicePayload, masterKey)
|
||||||
@@ -98,3 +101,55 @@ export async function removeLastVoiceMemoForEntry(
|
|||||||
await deleteEntryVoiceMemo(logbookId, lastId)
|
await deleteEntryVoiceMemo(logbookId, lastId)
|
||||||
return lastId
|
return lastId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Updates an existing voice memo payload with a new transcript and sets transcribed: true. */
|
||||||
|
export async function updateVoiceMemoTranscript(
|
||||||
|
logbookId: string,
|
||||||
|
voiceId: string,
|
||||||
|
transcript: string
|
||||||
|
): Promise<void> {
|
||||||
|
const masterKey = await getEncryptionKey(logbookId)
|
||||||
|
const record = await db.voiceMemos.get(voiceId)
|
||||||
|
if (!record) throw new Error('Voice memo not found')
|
||||||
|
|
||||||
|
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
|
||||||
|
if (!decrypted) throw new Error('Failed to decrypt voice memo')
|
||||||
|
|
||||||
|
const manualCaption = decrypted.caption ? String(decrypted.caption).trim() : ''
|
||||||
|
const finalCaption = manualCaption
|
||||||
|
? `${manualCaption}\n(Transkript: ${transcript.trim()})`
|
||||||
|
: transcript.trim()
|
||||||
|
|
||||||
|
const updatedPayload = {
|
||||||
|
...decrypted,
|
||||||
|
caption: finalCaption,
|
||||||
|
transcribed: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const encrypted = await encryptJson(updatedPayload, masterKey)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
await db.voiceMemos.put({
|
||||||
|
...record,
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.syncQueue.put({
|
||||||
|
action: 'update',
|
||||||
|
type: 'voiceMemo',
|
||||||
|
payloadId: voiceId,
|
||||||
|
logbookId,
|
||||||
|
data: JSON.stringify({
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
entryId: record.entryId
|
||||||
|
}),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,4 +69,51 @@ describe('fetchOpenWeatherCurrent', () => {
|
|||||||
|
|
||||||
expect(trackPlausibleEvent).not.toHaveBeenCalled()
|
expect(trackPlausibleEvent).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('throws UNAUTHORIZED when status is 401', async () => {
|
||||||
|
apiFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
json: async () => ({ error: 'Unauthorized' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
|
||||||
|
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
|
||||||
|
expect(err).toBeInstanceOf(WeatherApiError)
|
||||||
|
expect((err as any).code).toBe('UNAUTHORIZED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws NOT_FOUND when status is 404', async () => {
|
||||||
|
apiFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({ error: 'Not Found' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
|
||||||
|
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
|
||||||
|
expect(err).toBeInstanceOf(WeatherApiError)
|
||||||
|
expect((err as any).code).toBe('NOT_FOUND')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws BAD_REQUEST when status is 400', async () => {
|
||||||
|
apiFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
json: async () => ({ error: 'Bad Request' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
|
||||||
|
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
|
||||||
|
expect(err).toBeInstanceOf(WeatherApiError)
|
||||||
|
expect((err as any).code).toBe('BAD_REQUEST')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws BAD_REQUEST when coordinates or query are missing', async () => {
|
||||||
|
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
|
||||||
|
const err = await fetchOpenWeatherCurrent({}).catch((e) => e)
|
||||||
|
expect(err).toBeInstanceOf(WeatherApiError)
|
||||||
|
expect((err as any).code).toBe('BAD_REQUEST')
|
||||||
|
expect(apiFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import {
|
|||||||
} from './analytics.js'
|
} from './analytics.js'
|
||||||
|
|
||||||
export class WeatherApiError extends Error {
|
export class WeatherApiError extends Error {
|
||||||
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED'
|
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'BAD_REQUEST'
|
||||||
|
|
||||||
constructor(message: string, code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'BAD_REQUEST' = 'REQUEST_FAILED'
|
||||||
|
) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'WeatherApiError'
|
this.name = 'WeatherApiError'
|
||||||
this.code = code
|
this.code = code
|
||||||
@@ -38,7 +41,7 @@ export async function fetchOpenWeatherCurrent(
|
|||||||
} else if (params.q?.trim()) {
|
} else if (params.q?.trim()) {
|
||||||
searchParams.set('q', params.q.trim())
|
searchParams.set('q', params.q.trim())
|
||||||
} else {
|
} else {
|
||||||
throw new WeatherApiError('lat/lon or location query required')
|
throw new WeatherApiError('lat/lon or location query required', 'BAD_REQUEST')
|
||||||
}
|
}
|
||||||
|
|
||||||
const userKey = getOwmApiKeyForActiveUser().trim()
|
const userKey = getOwmApiKeyForActiveUser().trim()
|
||||||
@@ -65,6 +68,15 @@ export async function fetchOpenWeatherCurrent(
|
|||||||
if (res.status === 503) {
|
if (res.status === 503) {
|
||||||
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
|
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
|
||||||
}
|
}
|
||||||
|
if (res.status === 401) {
|
||||||
|
throw new WeatherApiError('Invalid OpenWeatherMap API key', 'UNAUTHORIZED')
|
||||||
|
}
|
||||||
|
if (res.status === 404) {
|
||||||
|
throw new WeatherApiError('Location or coordinates not found', 'NOT_FOUND')
|
||||||
|
}
|
||||||
|
if (res.status === 400) {
|
||||||
|
throw new WeatherApiError('Invalid or missing location parameters', 'BAD_REQUEST')
|
||||||
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -20,14 +20,13 @@ vi.mock('../services/analytics.js', async (importOriginal) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function createMockI18n(language: string): I18nInstance {
|
function createMockI18n(language: string): I18nInstance {
|
||||||
let current = language
|
const mock = {
|
||||||
return {
|
language,
|
||||||
language: current,
|
|
||||||
changeLanguage: vi.fn(async (lng: string) => {
|
changeLanguage: vi.fn(async (lng: string) => {
|
||||||
current = lng
|
mock.language = lng
|
||||||
;(this as { language: string }).language = lng
|
|
||||||
})
|
})
|
||||||
} as unknown as I18nInstance
|
} as unknown as I18nInstance
|
||||||
|
return mock
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('i18nLanguages', () => {
|
describe('i18nLanguages', () => {
|
||||||
@@ -72,11 +71,11 @@ describe('i18nLanguages', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('cycleAppLanguage tracks the next language', () => {
|
it('cycleAppLanguage tracks the next language', () => {
|
||||||
const i18n = createMockI18n('nb')
|
const i18n = createMockI18n('es')
|
||||||
cycleAppLanguage(i18n)
|
cycleAppLanguage(i18n)
|
||||||
|
|
||||||
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
|
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
|
||||||
from: 'nb',
|
from: 'es',
|
||||||
to: 'de'
|
to: 'de'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,10 +2,20 @@ import type { i18n as I18nInstance } from 'i18next'
|
|||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
/** Supported UI languages (ISO 639-1, language-only). */
|
/** Supported UI languages (ISO 639-1, language-only). */
|
||||||
export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb'] as const
|
export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb', 'fr', 'es'] as const
|
||||||
|
|
||||||
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
|
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
|
||||||
|
|
||||||
|
export const LANGUAGE_FLAGS: Record<AppLanguage, string> = {
|
||||||
|
de: '🇩🇪',
|
||||||
|
en: '🇬🇧',
|
||||||
|
da: '🇩🇰',
|
||||||
|
sv: '🇸🇪',
|
||||||
|
nb: '🇳🇴',
|
||||||
|
fr: '🇫🇷',
|
||||||
|
es: '🇪🇸'
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeAppLanguage(language?: string): AppLanguage {
|
export function normalizeAppLanguage(language?: string): AppLanguage {
|
||||||
const base = (language ?? 'en').split('-')[0].toLowerCase()
|
const base = (language ?? 'en').split('-')[0].toLowerCase()
|
||||||
if ((SUPPORTED_LANGUAGES as readonly string[]).includes(base)) {
|
if ((SUPPORTED_LANGUAGES as readonly string[]).includes(base)) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ const OG_LOCALES: Record<SeoLang, string> = {
|
|||||||
en: 'en_GB',
|
en: 'en_GB',
|
||||||
da: 'da_DK',
|
da: 'da_DK',
|
||||||
sv: 'sv_SE',
|
sv: 'sv_SE',
|
||||||
nb: 'nb_NO'
|
nb: 'nb_NO',
|
||||||
|
fr: 'fr_FR',
|
||||||
|
es: 'es_ES'
|
||||||
}
|
}
|
||||||
|
|
||||||
let i18nRef: I18nInstance | null = null
|
let i18nRef: I18nInstance | null = null
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Server-Backup (Produktion)
|
||||||
|
|
||||||
|
Automatische und manuelle Sicherung von PostgreSQL, `.env`, `docker-compose.yml` und App-Code (Git-Archiv) auf der Prod-VM.
|
||||||
|
|
||||||
|
**Staging:** Kein automatisches Backup — Daten sind bewusst wegwerfbar. Deploy via `update-remotes.sh -dest stage` legt kein Backup an. Zum manuellen Testen auf Staging: `-dest stage` (oder Auto-Fallback, wenn nur `daagbox-staging-db` läuft).
|
||||||
|
|
||||||
|
## Was wird gesichert?
|
||||||
|
|
||||||
|
| Inhalt | Beschreibung |
|
||||||
|
|--------|--------------|
|
||||||
|
| `database.sql.gz` | `pg_dump` aus dem laufenden DB-Container |
|
||||||
|
| `.env` | Server-Secrets (Sessions, DB-Passwort, VAPID, …) |
|
||||||
|
| `docker-compose.yml` | Aktive Compose-Datei |
|
||||||
|
| `app.tar.gz` | `git archive HEAD` — Code-Snapshot |
|
||||||
|
| `manifest.json` | Timestamp, Git-Tag, SHA, Grund (`cron` / `pre-deploy` / `manual`) |
|
||||||
|
|
||||||
|
Backups liegen in `/var/backups/kapteins-daagbok/` (mode 700, root-only). Es werden **maximal 5** Archive aufbewahrt.
|
||||||
|
|
||||||
|
## Einmalige Einrichtung (Prod-Server)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@10.0.0.25
|
||||||
|
mkdir -p /var/backups/kapteins-daagbok
|
||||||
|
chmod 700 /var/backups/kapteins-daagbok
|
||||||
|
cd /opt/kapteins-daagbok
|
||||||
|
git pull
|
||||||
|
chmod +x scripts/backup.sh scripts/restore-backup.sh
|
||||||
|
./scripts/backup.sh --reason manual
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manuell sichern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/kapteins-daagbok
|
||||||
|
./scripts/backup.sh
|
||||||
|
./scripts/backup.sh --reason manual --dry-run # Vorschau ohne Schreiben
|
||||||
|
```
|
||||||
|
|
||||||
|
### Staging (manueller Test)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/kapteins-daagbok-staging
|
||||||
|
./scripts/backup.sh -dest stage --reason manual
|
||||||
|
# oder: Auto-Fallback, wenn nur daagbox-staging-db läuft
|
||||||
|
./scripts/backup.sh --reason manual
|
||||||
|
```
|
||||||
|
|
||||||
|
## Crontab (unbeaufsichtigt)
|
||||||
|
|
||||||
|
Beispiel: [`scripts/crontab.prod.example`](../../scripts/crontab.prod.example)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
# Zeile einfügen:
|
||||||
|
0 3 * * * cd /opt/kapteins-daagbok && ./scripts/backup.sh --reason cron >> /var/log/kapteins-backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pre-Deploy-Backup
|
||||||
|
|
||||||
|
Bei `./scripts/update-remotes.sh -dest prod` wird **vor** dem Git-Sync auf dem Server automatisch ein Backup mit Tag `v{VERSION}-predeploy` erstellt. Schlägt das Backup fehl, wird das Deploy abgebrochen.
|
||||||
|
|
||||||
|
Staging-Deploys (`-dest stage`) erstellen **kein** Backup.
|
||||||
|
|
||||||
|
## Wiederherstellen
|
||||||
|
|
||||||
|
Verfügbare Backups anzeigen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore-backup.sh --list
|
||||||
|
```
|
||||||
|
|
||||||
|
Vollständige Wiederherstellung (DB + `.env`, optional Git-Tag checkout):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore-backup.sh --restore /var/backups/kapteins-daagbok/kapteins-daagbok_YYYYMMDD-HHMMSS_vX.Y.Z.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Nur Datenbank:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore-backup.sh --restore PATH --db-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Nur `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore-backup.sh --restore PATH --env-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne Rückfragen (Notfall):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore-backup.sh --restore PATH --full --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vor Passwort-Rotation
|
||||||
|
|
||||||
|
Vor [`rotate-postgres-password.sh`](../../scripts/rotate-postgres-password.sh) ein Backup anlegen — siehe auch [postgres-password.md](postgres-password.md):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/backup.sh --reason manual
|
||||||
|
```
|
||||||
|
|
||||||
|
## Umgebungsvariablen
|
||||||
|
|
||||||
|
| Variable | Prod (default) | Staging (`-dest stage`) |
|
||||||
|
|----------|----------------|-------------------------|
|
||||||
|
| `COMPOSE_FILE` | `docker-compose.yml` | `docker-compose.staging.yml` |
|
||||||
|
| `DB_CONTAINER` | `daagbox-prod-db` | `daagbox-staging-db` |
|
||||||
|
| `BACKUP_DIR` | `/var/backups/kapteins-daagbok` | gleich |
|
||||||
|
| `RETENTION` | `5` | `5` |
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## Empfohlene Schritte
|
## Empfohlene Schritte
|
||||||
|
|
||||||
1. **Backup/Snapshot** (hast du laut Vorgabe).
|
1. **Backup/Snapshot** — auf dem Server: `./scripts/backup.sh --reason manual` (Details: [backup.md](backup.md)).
|
||||||
2. Auf dem Server im Repo:
|
2. Auf dem Server im Repo:
|
||||||
```bash
|
```bash
|
||||||
cd /opt/kapteins-daagbok
|
cd /opt/kapteins-daagbok
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ cd server && npm test
|
|||||||
|
|
||||||
## Nach erfolgreichem Check
|
## Nach erfolgreichem Check
|
||||||
|
|
||||||
[`scripts/update-prod.sh`](../../scripts/update-prod.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung, vor dem SSH-Deploy).
|
[`scripts/update-remotes.sh`](../../scripts/update-remotes.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung bei Prod, vor dem SSH-Deploy).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/update-prod.sh
|
./scripts/update-remotes.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-remotes.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)).
|
||||||
|
|||||||
@@ -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-remotes.sh -dest stage` | `./scripts/update-remotes.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-remotes.sh -dest stage
|
||||||
|
```
|
||||||
|
|
||||||
|
Konfiguration via Umgebungsvariablen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REMOTE_HOST=10.0.0.27 \
|
||||||
|
REMOTE_DIR=/opt/kapteins-daagbok-staging \
|
||||||
|
DEPLOY_BRANCH=master \
|
||||||
|
./scripts/update-remotes.sh -dest stage
|
||||||
|
```
|
||||||
|
|
||||||
|
Notfall ohne Checks: `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-remotes.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
|
||||||
|
```
|
||||||
BIN
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: 359 KiB |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -38,6 +47,7 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
|||||||
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
|
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
|
||||||
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
|
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
|
||||||
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
|
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
|
||||||
|
| Voice Memo Transcribed | Sprachmemo transkribiert (`LiveLogView.tsx`, `EventRemarksCell.tsx`) | `status`: `success` \| `failed`, `mode`: `auto` (beim Speichern) \| `manual` (nachträglich) |
|
||||||
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
|
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
|
||||||
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
|
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
|
||||||
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) |
|
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) |
|
||||||
@@ -152,6 +162,7 @@ trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
|
|||||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
|
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
|
||||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' })
|
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' })
|
||||||
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: 'live_log' })
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: 'live_log' })
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, { status: 'success', mode: 'auto' })
|
||||||
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
|
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
|
||||||
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
|
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
|
||||||
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
|
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
|
||||||
|
|||||||
Executable
+255
@@ -0,0 +1,255 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Create a local backup of PostgreSQL, .env, docker-compose and app (git archive).
|
||||||
|
#
|
||||||
|
# Run on the server in repo root (/opt/kapteins-daagbok on production).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/backup.sh
|
||||||
|
# ./scripts/backup.sh -dest stage # Staging-Container (daagbox-staging-db)
|
||||||
|
# ./scripts/backup.sh --reason cron
|
||||||
|
# ./scripts/backup.sh --reason pre-deploy --tag v0.1.1.20
|
||||||
|
# ./scripts/backup.sh --dry-run
|
||||||
|
#
|
||||||
|
# Environment overrides:
|
||||||
|
# BACKUP_DIR, COMPOSE_FILE, DB_CONTAINER, RETENTION, ENV_FILE
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/var/backups/kapteins-daagbok}"
|
||||||
|
ENV_FILE="${ENV_FILE:-.env}"
|
||||||
|
RETENTION="${RETENTION:-5}"
|
||||||
|
DEST="prod"
|
||||||
|
REASON="manual"
|
||||||
|
EXPLICIT_TAG=""
|
||||||
|
DRY_RUN=0
|
||||||
|
COMPOSE_FILE=""
|
||||||
|
DB_CONTAINER=""
|
||||||
|
|
||||||
|
apply_dest_config() {
|
||||||
|
local dest="$1"
|
||||||
|
local force="${2:-0}"
|
||||||
|
if [[ "$dest" == "stage" ]]; then
|
||||||
|
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
|
||||||
|
COMPOSE_FILE="docker-compose.staging.yml"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
|
||||||
|
DB_CONTAINER="daagbox-staging-db"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
|
||||||
|
DB_CONTAINER="daagbox-prod-db"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,14p' "$0"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " -dest prod|stage Target environment (default: prod)"
|
||||||
|
echo " --reason cron|pre-deploy|manual Backup trigger (default: manual)"
|
||||||
|
echo " --tag TAG Git tag label (e.g. v0.1.1.20 for pre-deploy)"
|
||||||
|
echo " --dry-run Show actions without writing backup"
|
||||||
|
echo " -h, --help Show this help"
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-dest)
|
||||||
|
DEST="${2:?-dest requires an argument}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-dest=*)
|
||||||
|
DEST="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--reason)
|
||||||
|
REASON="${2:?--reason requires an argument}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--tag)
|
||||||
|
EXPLICIT_TAG="${2:?--tag requires an argument}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$DEST" in
|
||||||
|
prod|stage) ;;
|
||||||
|
*)
|
||||||
|
echo "Error: invalid -dest '$DEST' (use prod or stage)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
apply_dest_config "$DEST"
|
||||||
|
|
||||||
|
case "$REASON" in
|
||||||
|
cron|pre-deploy|manual) ;;
|
||||||
|
*)
|
||||||
|
echo "Error: invalid --reason '$REASON' (use cron, pre-deploy, or manual)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||||
|
echo "Error: $COMPOSE_FILE not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
GIT_SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||||
|
GIT_TAG="$EXPLICIT_TAG"
|
||||||
|
if [ -z "$GIT_TAG" ]; then
|
||||||
|
GIT_TAG="$(git describe --tags --exact-match HEAD 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [ -z "$GIT_TAG" ] && [ -f VERSION ]; then
|
||||||
|
GIT_TAG="v$(tr -d '[:space:]' < VERSION)"
|
||||||
|
fi
|
||||||
|
if [ -z "$GIT_TAG" ]; then
|
||||||
|
GIT_TAG="unknown"
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_VERSION="$(tr -d '[:space:]' < VERSION 2>/dev/null || echo unknown)"
|
||||||
|
TAG_SLUG="${GIT_TAG}"
|
||||||
|
if [ "$REASON" = "pre-deploy" ]; then
|
||||||
|
TAG_SLUG="${GIT_TAG}-predeploy"
|
||||||
|
fi
|
||||||
|
TAG_SLUG="${TAG_SLUG//\//-}"
|
||||||
|
|
||||||
|
ARCHIVE_NAME="kapteins-daagbok_${TIMESTAMP}_${TAG_SLUG}.tar.gz"
|
||||||
|
ARCHIVE_PATH="${BACKUP_DIR}/${ARCHIVE_NAME}"
|
||||||
|
|
||||||
|
echo "Backup: reason=$REASON tag=$GIT_TAG sha=${GIT_SHA:0:8} → $ARCHIVE_PATH"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" -eq 1 ]; then
|
||||||
|
echo "[dry-run] Would dump database from $DB_CONTAINER"
|
||||||
|
echo "[dry-run] Would copy $ENV_FILE and $COMPOSE_FILE"
|
||||||
|
echo "[dry-run] Would create git archive"
|
||||||
|
echo "[dry-run] Would write manifest and pack to $ARCHIVE_PATH"
|
||||||
|
echo "[dry-run] Would apply retention (keep $RETENTION)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "Error: $ENV_FILE not found (run from repo root)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
POSTGRES_USER="${POSTGRES_USER:-postgres}"
|
||||||
|
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
|
||||||
|
|
||||||
|
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
|
||||||
|
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
|
||||||
|
if [[ "$DEST" == "prod" ]] && docker inspect daagbox-staging-db >/dev/null 2>&1; then
|
||||||
|
echo "Note: $DB_CONTAINER not found — falling back to staging (daagbox-staging-db). Use -dest stage explicitly."
|
||||||
|
apply_dest_config stage 1
|
||||||
|
DEST="stage"
|
||||||
|
else
|
||||||
|
echo "Error: DB container '$DB_CONTAINER' not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(docker inspect -f '{{.State.Running}}' "$DB_CONTAINER")" != "true" ]; then
|
||||||
|
echo "Error: DB container '$DB_CONTAINER' is not running" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
chmod 700 "$BACKUP_DIR"
|
||||||
|
|
||||||
|
WORK_DIR="$(mktemp -d)"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "Dumping PostgreSQL ($POSTGRES_DB)..."
|
||||||
|
export PGPASSWORD="$POSTGRES_PASSWORD"
|
||||||
|
if ! docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
|
||||||
|
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --no-owner --no-acl \
|
||||||
|
| gzip > "$WORK_DIR/database.sql.gz"; then
|
||||||
|
echo "Error: pg_dump failed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
unset PGPASSWORD
|
||||||
|
|
||||||
|
cp "$ENV_FILE" "$WORK_DIR/.env"
|
||||||
|
chmod 600 "$WORK_DIR/.env"
|
||||||
|
cp "$COMPOSE_FILE" "$WORK_DIR/docker-compose.yml"
|
||||||
|
|
||||||
|
echo "Creating app snapshot (git archive)..."
|
||||||
|
if git archive --format=tar HEAD | gzip > "$WORK_DIR/app.tar.gz"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
echo "Warning: git archive failed — backup continues without app.tar.gz" >&2
|
||||||
|
rm -f "$WORK_DIR/app.tar.gz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$WORK_DIR/manifest.json" <<PY
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"local_timestamp": "${TIMESTAMP}",
|
||||||
|
"destination": "${DEST}",
|
||||||
|
"reason": "${REASON}",
|
||||||
|
"git_tag": "${GIT_TAG}",
|
||||||
|
"git_sha": "${GIT_SHA}",
|
||||||
|
"app_version": "${APP_VERSION}",
|
||||||
|
"compose_file": "${COMPOSE_FILE}",
|
||||||
|
"db_container": "${DB_CONTAINER}",
|
||||||
|
"postgres_db": "${POSTGRES_DB}",
|
||||||
|
"hostname": socket.gethostname(),
|
||||||
|
"archive_name": "${ARCHIVE_NAME}",
|
||||||
|
}
|
||||||
|
with open(sys.argv[1], "w", encoding="utf-8") as f:
|
||||||
|
json.dump(manifest, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "Packing backup archive..."
|
||||||
|
tar -czf "$ARCHIVE_PATH" -C "$WORK_DIR" \
|
||||||
|
manifest.json database.sql.gz .env docker-compose.yml \
|
||||||
|
$( [ -f "$WORK_DIR/app.tar.gz" ] && echo app.tar.gz )
|
||||||
|
chmod 600 "$ARCHIVE_PATH"
|
||||||
|
|
||||||
|
echo "Applying retention (keep last $RETENTION backups)..."
|
||||||
|
mapfile -t ALL_BACKUPS < <(ls -1t "$BACKUP_DIR"/kapteins-daagbok_*.tar.gz 2>/dev/null || true)
|
||||||
|
if [ "${#ALL_BACKUPS[@]}" -gt "$RETENTION" ]; then
|
||||||
|
for ((i = RETENTION; i < ${#ALL_BACKUPS[@]}; i++)); do
|
||||||
|
echo "Removing old backup: ${ALL_BACKUPS[$i]}"
|
||||||
|
rm -f "${ALL_BACKUPS[$i]}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Backup complete: $ARCHIVE_PATH"
|
||||||
|
echo "$ARCHIVE_PATH"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Kapteins Daagbok — Production backup cron (install on 10.0.0.25)
|
||||||
|
#
|
||||||
|
# Install:
|
||||||
|
# crontab -e
|
||||||
|
# (paste the line below)
|
||||||
|
#
|
||||||
|
# Ensure log directory exists:
|
||||||
|
# touch /var/log/kapteins-backup.log && chmod 600 /var/log/kapteins-backup.log
|
||||||
|
|
||||||
|
# Daily backup at 03:00 UTC — keeps last 5 in /var/backups/kapteins-daagbok/
|
||||||
|
0 3 * * * cd /opt/kapteins-daagbok && ./scripts/backup.sh --reason cron >> /var/log/kapteins-backup.log 2>&1
|
||||||
Executable
+95
@@ -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)
|
||||||
|
})
|
||||||
Executable
+345
@@ -0,0 +1,345 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Restore server backup created by scripts/backup.sh
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/restore-backup.sh --list
|
||||||
|
# ./scripts/restore-backup.sh -dest stage --restore PATH
|
||||||
|
# ./scripts/restore-backup.sh --restore /var/backups/kapteins-daagbok/kapteins-daagbok_....tar.gz
|
||||||
|
#
|
||||||
|
# Environment overrides:
|
||||||
|
# BACKUP_DIR, COMPOSE_FILE, DB_CONTAINER, BACKEND_CONTAINER, ENV_FILE
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/var/backups/kapteins-daagbok}"
|
||||||
|
ENV_FILE="${ENV_FILE:-.env}"
|
||||||
|
MAX_WAIT=90
|
||||||
|
DEST="prod"
|
||||||
|
COMPOSE_FILE=""
|
||||||
|
DB_CONTAINER=""
|
||||||
|
BACKEND_CONTAINER=""
|
||||||
|
|
||||||
|
apply_dest_config() {
|
||||||
|
local dest="$1"
|
||||||
|
local force="${2:-0}"
|
||||||
|
if [[ "$dest" == "stage" ]]; then
|
||||||
|
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
|
||||||
|
COMPOSE_FILE="docker-compose.staging.yml"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
|
||||||
|
DB_CONTAINER="daagbox-staging-db"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${BACKEND_CONTAINER}" ]]; then
|
||||||
|
BACKEND_CONTAINER="daagbox-staging-backend"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
|
||||||
|
DB_CONTAINER="daagbox-prod-db"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${BACKEND_CONTAINER}" ]]; then
|
||||||
|
BACKEND_CONTAINER="daagbox-prod-backend"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
MODE="full"
|
||||||
|
RESTORE_PATH=""
|
||||||
|
LIST=0
|
||||||
|
ASSUME_YES=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,10p' "$0"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " -dest prod|stage Target environment (default: prod)"
|
||||||
|
echo " --list List available backups"
|
||||||
|
echo " --restore PATH Backup archive to restore"
|
||||||
|
echo " --full Restore DB + .env (default)"
|
||||||
|
echo " --db-only Restore database only"
|
||||||
|
echo " --env-only Restore .env only"
|
||||||
|
echo " --yes Skip confirmation prompts"
|
||||||
|
echo " -h, --help Show this help"
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
local prompt="$1"
|
||||||
|
if [ "$ASSUME_YES" -eq 1 ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
read -r -p "$prompt [y/N] " answer
|
||||||
|
[[ "$answer" =~ ^[yY]$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-dest)
|
||||||
|
DEST="${2:?-dest requires an argument}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-dest=*)
|
||||||
|
DEST="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--list)
|
||||||
|
LIST=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--restore)
|
||||||
|
RESTORE_PATH="${2:?--restore requires a path}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--full)
|
||||||
|
MODE="full"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--db-only)
|
||||||
|
MODE="db-only"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--env-only)
|
||||||
|
MODE="env-only"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--yes)
|
||||||
|
ASSUME_YES=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$DEST" in
|
||||||
|
prod|stage) ;;
|
||||||
|
*)
|
||||||
|
echo "Error: invalid -dest '$DEST' (use prod or stage)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
apply_dest_config "$DEST"
|
||||||
|
|
||||||
|
list_backups() {
|
||||||
|
if [ ! -d "$BACKUP_DIR" ]; then
|
||||||
|
echo "No backup directory: $BACKUP_DIR"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local found=0
|
||||||
|
while IFS= read -r archive; do
|
||||||
|
found=1
|
||||||
|
echo "=== $archive ==="
|
||||||
|
tar -xOf "$archive" manifest.json 2>/dev/null | python3 -m json.tool 2>/dev/null || echo "(no manifest)"
|
||||||
|
echo ""
|
||||||
|
done < <(ls -1t "$BACKUP_DIR"/kapteins-daagbok_*.tar.gz 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ "$found" -eq 0 ]; then
|
||||||
|
echo "No backups found in $BACKUP_DIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$LIST" -eq 1 ]; then
|
||||||
|
list_backups
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$RESTORE_PATH" ]; then
|
||||||
|
echo "Error: --restore PATH or --list required" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$RESTORE_PATH" ]; then
|
||||||
|
echo "Error: backup archive not found: $RESTORE_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
WORK_DIR="$(mktemp -d)"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "Extracting $RESTORE_PATH..."
|
||||||
|
tar -xzf "$RESTORE_PATH" -C "$WORK_DIR"
|
||||||
|
|
||||||
|
if [ ! -f "$WORK_DIR/manifest.json" ]; then
|
||||||
|
echo "Error: manifest.json missing in backup archive" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
MANIFEST="$WORK_DIR/manifest.json"
|
||||||
|
echo "Backup manifest:"
|
||||||
|
python3 -m json.tool "$MANIFEST"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read_manifest_field() {
|
||||||
|
python3 -c "import json; print(json.load(open('$MANIFEST')).get('$1', '') or '')"
|
||||||
|
}
|
||||||
|
|
||||||
|
MANIFEST_COMPOSE="$(read_manifest_field compose_file)"
|
||||||
|
MANIFEST_DB="$(read_manifest_field db_container)"
|
||||||
|
MANIFEST_DEST="$(read_manifest_field destination)"
|
||||||
|
|
||||||
|
if [ -n "$MANIFEST_COMPOSE" ]; then
|
||||||
|
COMPOSE_FILE="$MANIFEST_COMPOSE"
|
||||||
|
fi
|
||||||
|
if [ -n "$MANIFEST_DB" ]; then
|
||||||
|
DB_CONTAINER="$MANIFEST_DB"
|
||||||
|
fi
|
||||||
|
if [ -n "$MANIFEST_DEST" ]; then
|
||||||
|
if [[ "$MANIFEST_DEST" == "stage" ]]; then
|
||||||
|
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-staging-backend}"
|
||||||
|
else
|
||||||
|
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
|
||||||
|
if [[ "$DEST" == "prod" ]] && docker inspect daagbox-staging-db >/dev/null 2>&1; then
|
||||||
|
echo "Note: $DB_CONTAINER not found — falling back to staging (daagbox-staging-db). Use -dest stage explicitly."
|
||||||
|
apply_dest_config stage 1
|
||||||
|
DEST="stage"
|
||||||
|
else
|
||||||
|
echo "Error: DB container '$DB_CONTAINER' not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
GIT_TAG="$(read_manifest_field git_tag)"
|
||||||
|
GIT_SHA="$(read_manifest_field git_sha)"
|
||||||
|
BACKUP_TS="$(read_manifest_field local_timestamp)"
|
||||||
|
|
||||||
|
if ! confirm "Restore backup from $BACKUP_TS (tag: $GIT_TAG)? Mode: $MODE"; then
|
||||||
|
echo "Aborted."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
restore_env() {
|
||||||
|
if [ ! -f "$WORK_DIR/.env" ]; then
|
||||||
|
echo "Error: .env missing in backup archive" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
BAK="${ENV_FILE}.bak-restore.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
cp "$ENV_FILE" "$BAK"
|
||||||
|
echo "Current $ENV_FILE saved to $BAK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "$WORK_DIR/.env" "$ENV_FILE"
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
echo "Restored $ENV_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_db() {
|
||||||
|
if [ ! -f "$WORK_DIR/database.sql.gz" ]; then
|
||||||
|
echo "Error: database.sql.gz missing in backup archive" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "Error: $ENV_FILE required for database restore" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
POSTGRES_USER="${POSTGRES_USER:-postgres}"
|
||||||
|
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
|
||||||
|
|
||||||
|
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
|
||||||
|
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
|
||||||
|
echo "Error: DB container '$DB_CONTAINER' not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Stopping backend before database restore..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" stop backend || true
|
||||||
|
|
||||||
|
echo "Resetting public schema..."
|
||||||
|
export PGPASSWORD="$POSTGRES_PASSWORD"
|
||||||
|
docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
|
||||||
|
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 <<SQL
|
||||||
|
DROP SCHEMA IF EXISTS public CASCADE;
|
||||||
|
CREATE SCHEMA public;
|
||||||
|
GRANT ALL ON SCHEMA public TO "${POSTGRES_USER}";
|
||||||
|
GRANT ALL ON SCHEMA public TO public;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Importing database dump..."
|
||||||
|
gunzip -c "$WORK_DIR/database.sql.gz" | docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
|
||||||
|
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1
|
||||||
|
unset PGPASSWORD
|
||||||
|
|
||||||
|
echo "Database restore complete."
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_healthy() {
|
||||||
|
echo "Starting stack and waiting for health..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d
|
||||||
|
|
||||||
|
local counter=0
|
||||||
|
while [ "$counter" -lt "$MAX_WAIT" ]; do
|
||||||
|
local status
|
||||||
|
status="$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$BACKEND_CONTAINER" 2>/dev/null || true)"
|
||||||
|
if [ "$status" = "healthy" ]; then
|
||||||
|
echo "Backend is healthy."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if curl -sf "http://127.0.0.1/api/health" | grep -q '"status":"ok"'; then
|
||||||
|
echo "API health check OK."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
counter=$((counter + 1))
|
||||||
|
printf "."
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "Warning: backend did not become healthy in time." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
env-only)
|
||||||
|
restore_env
|
||||||
|
;;
|
||||||
|
db-only)
|
||||||
|
restore_db
|
||||||
|
wait_for_healthy || exit 1
|
||||||
|
;;
|
||||||
|
full)
|
||||||
|
restore_env
|
||||||
|
restore_db
|
||||||
|
wait_for_healthy || exit 1
|
||||||
|
if [ -f "$WORK_DIR/app.tar.gz" ] && [ "$GIT_TAG" != "unknown" ]; then
|
||||||
|
if confirm "Checkout app code at tag $GIT_TAG? (git fetch + checkout)"; then
|
||||||
|
git fetch --tags origin
|
||||||
|
git checkout "$GIT_TAG"
|
||||||
|
echo "Checked out $GIT_TAG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Error: unknown mode $MODE" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "Restore finished (mode: $MODE, tag: $GIT_TAG, sha: ${GIT_SHA:0:8})."
|
||||||
@@ -81,7 +81,7 @@ require_node_toolchain() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo "On the production host, prefer updating the running stack:"
|
echo "On the production host, prefer updating the running stack:"
|
||||||
echo " docker compose -f docker-compose.yml up -d --build"
|
echo " docker compose -f docker-compose.yml up -d --build"
|
||||||
echo " # or from your workstation: ./scripts/update-prod.sh"
|
echo " # or from your workstation: ./scripts/update-remotes.sh -dest prod"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ const defaultSource = resolve(repoRoot, 'client/src/i18n/locales/de.json')
|
|||||||
const TARGETS = {
|
const TARGETS = {
|
||||||
da: 'DA',
|
da: 'DA',
|
||||||
sv: 'SV',
|
sv: 'SV',
|
||||||
nb: 'NB'
|
nb: 'NB',
|
||||||
|
fr: 'FR',
|
||||||
|
es: 'ES'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keys whose values stay identical to source (language names, brand). */
|
/** Keys whose values stay identical to source (language names, brand). */
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Remote deployment configuration
|
|
||||||
# Override any of these via environment variables if needed, e.g.:
|
|
||||||
# REMOTE_HOST=192.168.1.10 ./scripts/update-prod.sh
|
|
||||||
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
|
|
||||||
COMPOSE_FILE="docker-compose.yml"
|
|
||||||
BACKEND_CONTAINER="daagbox-prod-backend"
|
|
||||||
MAX_WAIT=35
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
VERSION_FILE="$REPO_ROOT/VERSION"
|
|
||||||
DEFAULT_VERSION="0.1.0.0"
|
|
||||||
|
|
||||||
echo "=================================================="
|
|
||||||
echo " Kapteins Daagbok Prod Environment Update "
|
|
||||||
echo "=================================================="
|
|
||||||
echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}"
|
|
||||||
echo "=================================================="
|
|
||||||
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
|
|
||||||
read_current_version() {
|
|
||||||
if [ -f "$VERSION_FILE" ]; then
|
|
||||||
tr -d '[:space:]' < "$VERSION_FILE"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
local latest_tag
|
|
||||||
latest_tag="$(git tag -l 'v*' --sort=-v:refname | head -n 1 || true)"
|
|
||||||
if [ -n "$latest_tag" ]; then
|
|
||||||
echo "${latest_tag#v}"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$DEFAULT_VERSION"
|
|
||||||
}
|
|
||||||
|
|
||||||
bump_patch_version() {
|
|
||||||
local version="$1"
|
|
||||||
local major minor patch build
|
|
||||||
|
|
||||||
IFS='.' read -r major minor patch build <<< "$version"
|
|
||||||
major="${major:-0}"
|
|
||||||
minor="${minor:-1}"
|
|
||||||
patch="${patch:-0}"
|
|
||||||
build="${build:-0}"
|
|
||||||
|
|
||||||
build=$((10#$build + 1))
|
|
||||||
echo "${major}.${minor}.${patch}.${build}"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_clean_git_tree() {
|
|
||||||
if [ -z "$(git status --porcelain)" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Uncommitted local changes detected:"
|
|
||||||
git status --short
|
|
||||||
echo ""
|
|
||||||
read -r -p "Commit all changes now before release? [y/N] " answer
|
|
||||||
|
|
||||||
if [[ ! "$answer" =~ ^[yY]$ ]]; then
|
|
||||||
echo "Aborting: working tree is not clean."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
read -r -p "Commit message: " commit_message
|
|
||||||
if [ -z "$commit_message" ]; then
|
|
||||||
echo "Aborting: commit message is required."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
git add -A
|
|
||||||
git commit -m "$commit_message"
|
|
||||||
}
|
|
||||||
|
|
||||||
prepare_release() {
|
|
||||||
local current_version release_version next_version tag_name
|
|
||||||
|
|
||||||
ensure_clean_git_tree
|
|
||||||
|
|
||||||
current_version="$(read_current_version)"
|
|
||||||
release_version="$current_version"
|
|
||||||
next_version="$(bump_patch_version "$current_version")"
|
|
||||||
tag_name="v${release_version}"
|
|
||||||
|
|
||||||
if git rev-parse "$tag_name" >/dev/null 2>&1; then
|
|
||||||
echo "Error: Git tag '$tag_name' already exists."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$next_version" > "$VERSION_FILE"
|
|
||||||
git add "$VERSION_FILE"
|
|
||||||
git commit -m "chore: release ${tag_name}"
|
|
||||||
git tag -a "$tag_name" -m "Release ${tag_name}"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Prepared release ${tag_name}"
|
|
||||||
echo " Released: ${tag_name}"
|
|
||||||
echo " Next prep: v${next_version}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
read -r -p "Push commit and tag to origin? [Y/n] " push_answer
|
|
||||||
if [[ ! "$push_answer" =~ ^[nN]$ ]]; then
|
|
||||||
current_branch="$(git branch --show-current)"
|
|
||||||
git push origin "$current_branch"
|
|
||||||
git push origin "$tag_name"
|
|
||||||
echo "Pushed ${current_branch} and ${tag_name} to origin."
|
|
||||||
else
|
|
||||||
echo "Skipped push. Remote host must receive this commit/tag manually."
|
|
||||||
fi
|
|
||||||
|
|
||||||
export APP_VERSION="$release_version"
|
|
||||||
}
|
|
||||||
|
|
||||||
prepare_release
|
|
||||||
|
|
||||||
if [[ "${SKIP_PREDEPLOY_CHECK:-}" == "1" ]]; then
|
|
||||||
echo "Skipping pre-deploy checks (SKIP_PREDEPLOY_CHECK=1)."
|
|
||||||
else
|
|
||||||
echo "=================================================="
|
|
||||||
echo " Pre-deploy checks (local)"
|
|
||||||
echo "=================================================="
|
|
||||||
"$SCRIPT_DIR/predeploy-check.sh"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=================================================="
|
|
||||||
echo "Deploying ${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
|
|
||||||
echo "=================================================="
|
|
||||||
|
|
||||||
# Run the whole update procedure remotely over SSH.
|
|
||||||
ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \
|
|
||||||
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$REMOTE_HOST" "$APP_VERSION" <<'REMOTE_SCRIPT'
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
REMOTE_DIR="$1"
|
|
||||||
COMPOSE_FILE="$2"
|
|
||||||
BACKEND_CONTAINER="$3"
|
|
||||||
MAX_WAIT="$4"
|
|
||||||
REMOTE_HOST="$5"
|
|
||||||
APP_VERSION="$6"
|
|
||||||
|
|
||||||
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
|
|
||||||
|
|
||||||
echo "Syncing repository from origin..."
|
|
||||||
CURRENT_BRANCH="$(git branch --show-current)"
|
|
||||||
if [ -z "$CURRENT_BRANCH" ]; then
|
|
||||||
echo "Error: Could not determine current Git branch."
|
|
||||||
exit 1
|
|
||||||
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
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "Error: Git fetch failed."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
git reset --hard "origin/${CURRENT_BRANCH}"
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "Error: Git reset to origin/${CURRENT_BRANCH} failed."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
REMOTE_VERSION="$(tr -d '[:space:]' < VERSION)"
|
|
||||||
if [ "$REMOTE_VERSION" != "$APP_VERSION" ]; then
|
|
||||||
echo "Note: Remote VERSION file already points to next release (v${REMOTE_VERSION})."
|
|
||||||
echo " Building deployed release v${APP_VERSION}."
|
|
||||||
fi
|
|
||||||
|
|
||||||
export APP_VERSION="$APP_VERSION"
|
|
||||||
|
|
||||||
echo "Rebuilding Docker images without cache (APP_VERSION=${APP_VERSION})..."
|
|
||||||
docker compose -f "$COMPOSE_FILE" build --no-cache
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "Error: Docker compose build failed."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Starting updated container stack..."
|
|
||||||
docker compose -f "$COMPOSE_FILE" up -d
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "Error: Failed to spin up docker-compose stack."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Cleaning up old/unused Docker resources..."
|
|
||||||
docker system prune -f
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "Warning: Docker system prune failed to run completely."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Waiting for services to become healthy..."
|
|
||||||
COUNTER=0
|
|
||||||
IS_READY=false
|
|
||||||
|
|
||||||
while [ $COUNTER -lt $MAX_WAIT ]; do
|
|
||||||
STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$BACKEND_CONTAINER" 2>/dev/null)
|
|
||||||
|
|
||||||
if [ "$STATUS" = "healthy" ]; then
|
|
||||||
IS_READY=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep 1
|
|
||||||
COUNTER=$((COUNTER + 1))
|
|
||||||
printf "."
|
|
||||||
done
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "=================================================="
|
|
||||||
echo "Container Statuses:"
|
|
||||||
docker compose -f "$COMPOSE_FILE" ps
|
|
||||||
echo "=================================================="
|
|
||||||
|
|
||||||
if [ "$IS_READY" = true ]; then
|
|
||||||
echo "SUCCESS: Production environment updated and healthy!"
|
|
||||||
echo " -> Version: v${APP_VERSION}"
|
|
||||||
echo " -> App Frontend (Nginx): http://${REMOTE_HOST}"
|
|
||||||
echo " -> Backend API Health: http://${REMOTE_HOST}/api/health"
|
|
||||||
echo "=================================================="
|
|
||||||
else
|
|
||||||
echo "WARNING: Backend did not transition to healthy in time."
|
|
||||||
echo "Check backend container logs for details:"
|
|
||||||
echo " -> docker compose logs backend"
|
|
||||||
echo "=================================================="
|
|
||||||
exit 3
|
|
||||||
fi
|
|
||||||
REMOTE_SCRIPT
|
|
||||||
|
|
||||||
REMOTE_EXIT=$?
|
|
||||||
echo "=================================================="
|
|
||||||
if [ $REMOTE_EXIT -eq 0 ]; then
|
|
||||||
echo "Remote update completed successfully on ${REMOTE_TARGET} (v${APP_VERSION})."
|
|
||||||
elif [ $REMOTE_EXIT -eq 3 ]; then
|
|
||||||
echo "Remote update finished, but the backend was not healthy in time on ${REMOTE_TARGET}."
|
|
||||||
else
|
|
||||||
echo "Remote update FAILED on ${REMOTE_TARGET} (exit code: ${REMOTE_EXIT})."
|
|
||||||
fi
|
|
||||||
echo "=================================================="
|
|
||||||
exit $REMOTE_EXIT
|
|
||||||
Executable
+445
@@ -0,0 +1,445 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $(basename "$0") [-dest prod|stage]
|
||||||
|
|
||||||
|
Deploy Kapteins Daagbok to production or staging.
|
||||||
|
|
||||||
|
-dest prod Production (default): release tag, bump VERSION, deploy to 10.0.0.25
|
||||||
|
-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
|
||||||
|
|
||||||
|
Local repo must be clean and match origin before deploy (git fetch + compare HEAD).
|
||||||
|
|
||||||
|
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)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
VERSION_FILE="$REPO_ROOT/VERSION"
|
||||||
|
DEFAULT_VERSION="0.1.0.0"
|
||||||
|
MAX_WAIT=90
|
||||||
|
|
||||||
|
REMOTE_USER="${REMOTE_USER:-root}"
|
||||||
|
# GIT_REMOTE="${GIT_REMOTE:-github}"
|
||||||
|
# GIT_REMOTE_URL="${GIT_REMOTE_URL:-https://github.com/elpatron68/kapteins-daagbok.git}"
|
||||||
|
GIT_REMOTE="${GIT_REMOTE:-origin}"
|
||||||
|
GIT_REMOTE_URL="${GIT_REMOTE_URL:-https://gitea.elpatron.me/elpatron/kapteins-daagbok.git}"
|
||||||
|
|
||||||
|
|
||||||
|
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 " Kapteins Daagbok ${ENV_LABEL} Update"
|
||||||
|
echo "=================================================="
|
||||||
|
echo "Destination: ${DEST}"
|
||||||
|
echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||||||
|
if [[ "$DEST" == "stage" ]]; then
|
||||||
|
echo "Branch: ${DEPLOY_BRANCH}"
|
||||||
|
fi
|
||||||
|
echo "URL: ${APP_URL}"
|
||||||
|
echo "=================================================="
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
read_current_version() {
|
||||||
|
if [ -f "$VERSION_FILE" ]; then
|
||||||
|
tr -d '[:space:]' < "$VERSION_FILE"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local latest_tag
|
||||||
|
latest_tag="$(git tag -l 'v*' --sort=-v:refname | head -n 1 || true)"
|
||||||
|
if [ -n "$latest_tag" ]; then
|
||||||
|
echo "${latest_tag#v}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$DEFAULT_VERSION"
|
||||||
|
}
|
||||||
|
|
||||||
|
bump_patch_version() {
|
||||||
|
local version="$1"
|
||||||
|
local major minor patch build
|
||||||
|
|
||||||
|
IFS='.' read -r major minor patch build <<< "$version"
|
||||||
|
major="${major:-0}"
|
||||||
|
minor="${minor:-1}"
|
||||||
|
patch="${patch:-0}"
|
||||||
|
build="${build:-0}"
|
||||||
|
|
||||||
|
build=$((10#$build + 1))
|
||||||
|
echo "${major}.${minor}.${patch}.${build}"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_clean_git_tree() {
|
||||||
|
if [ -z "$(git status --porcelain)" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Uncommitted local changes detected:"
|
||||||
|
git status --short
|
||||||
|
echo ""
|
||||||
|
read -r -p "Commit all changes now before release? [y/N] " answer
|
||||||
|
|
||||||
|
if [[ ! "$answer" =~ ^[yY]$ ]]; then
|
||||||
|
echo "Aborting: working tree is not clean."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -r -p "Commit message: " commit_message
|
||||||
|
if [ -z "$commit_message" ]; then
|
||||||
|
echo "Aborting: commit message is required."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -m "$commit_message"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_local_sync_with_origin() {
|
||||||
|
local branch="$1"
|
||||||
|
local local_sha origin_sha current_branch
|
||||||
|
|
||||||
|
if [ -z "$branch" ]; then
|
||||||
|
echo "Error: deploy branch is not set." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
echo "Error: Working tree is not clean. Commit or stash changes before deploying." >&2
|
||||||
|
git status --short
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
current_branch="$(git branch --show-current)"
|
||||||
|
if [ -z "$current_branch" ]; then
|
||||||
|
echo "Error: Detached HEAD — checkout branch '$branch' before deploying." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$current_branch" != "$branch" ]; then
|
||||||
|
echo "Error: On branch '$current_branch', expected '$branch'." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Syncing with ${GIT_REMOTE}..."
|
||||||
|
git fetch --tags "${GIT_REMOTE}"
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: git fetch ${GIT_REMOTE} failed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git rev-parse --verify "${GIT_REMOTE}/${branch}" >/dev/null 2>&1; then
|
||||||
|
echo "Error: ${GIT_REMOTE}/${branch} does not exist." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local_sha="$(git rev-parse HEAD)"
|
||||||
|
origin_sha="$(git rev-parse "${GIT_REMOTE}/${branch}")"
|
||||||
|
|
||||||
|
if [ "$local_sha" = "$origin_sha" ]; then
|
||||||
|
echo "Local branch '$branch' matches ${GIT_REMOTE}/${branch} ($(git rev-parse --short HEAD))."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Error: Local '$branch' is not in sync with ${GIT_REMOTE}/${branch}." >&2
|
||||||
|
echo " local: $(git rev-parse --short HEAD) $(git log -1 --format='%s' HEAD)" >&2
|
||||||
|
echo " ${GIT_REMOTE}: $(git rev-parse --short "${GIT_REMOTE}/${branch}") $(git log -1 --format='%s' "${GIT_REMOTE}/${branch}")" >&2
|
||||||
|
|
||||||
|
if git merge-base --is-ancestor "$local_sha" "${GIT_REMOTE}/${branch}" 2>/dev/null; then
|
||||||
|
echo "Hint: run 'git pull' to fast-forward." >&2
|
||||||
|
elif git merge-base --is-ancestor "${GIT_REMOTE}/${branch}" "$local_sha" 2>/dev/null; then
|
||||||
|
echo "Hint: run 'git push ${GIT_REMOTE} ${branch}' before deploying." >&2
|
||||||
|
else
|
||||||
|
echo "Hint: branches have diverged — reconcile manually before deploying." >&2
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_release() {
|
||||||
|
local current_version release_version next_version tag_name
|
||||||
|
|
||||||
|
ensure_clean_git_tree
|
||||||
|
|
||||||
|
current_version="$(read_current_version)"
|
||||||
|
release_version="$current_version"
|
||||||
|
next_version="$(bump_patch_version "$current_version")"
|
||||||
|
tag_name="v${release_version}"
|
||||||
|
|
||||||
|
if git rev-parse "$tag_name" >/dev/null 2>&1; then
|
||||||
|
echo "Error: Git tag '$tag_name' already exists."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$next_version" > "$VERSION_FILE"
|
||||||
|
git add "$VERSION_FILE"
|
||||||
|
git commit -m "chore: release ${tag_name}"
|
||||||
|
git tag -a "$tag_name" -m "Release ${tag_name}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Prepared release ${tag_name}"
|
||||||
|
echo " Released: ${tag_name}"
|
||||||
|
echo " Next prep: v${next_version}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -r -p "Push commit and tag to ${GIT_REMOTE}? [Y/n] " push_answer
|
||||||
|
if [[ ! "$push_answer" =~ ^[nN]$ ]]; then
|
||||||
|
current_branch="$(git branch --show-current)"
|
||||||
|
git push "${GIT_REMOTE}" "$current_branch"
|
||||||
|
git push "${GIT_REMOTE}" "$tag_name"
|
||||||
|
echo "Pushed ${current_branch} and ${tag_name} to ${GIT_REMOTE}."
|
||||||
|
else
|
||||||
|
echo "Skipped push. Remote host must receive this commit/tag manually."
|
||||||
|
fi
|
||||||
|
|
||||||
|
export APP_VERSION="$release_version"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$DEST" == "prod" ]]; then
|
||||||
|
prepare_release
|
||||||
|
ensure_local_sync_with_origin "$(git branch --show-current)"
|
||||||
|
else
|
||||||
|
ensure_local_sync_with_origin "$DEPLOY_BRANCH"
|
||||||
|
APP_VERSION="$(read_current_version)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${SKIP_PREDEPLOY_CHECK:-}" == "1" ]]; then
|
||||||
|
echo "Skipping pre-deploy checks (SKIP_PREDEPLOY_CHECK=1)."
|
||||||
|
else
|
||||||
|
echo "=================================================="
|
||||||
|
echo " Pre-deploy checks (local)"
|
||||||
|
echo "=================================================="
|
||||||
|
"$SCRIPT_DIR/predeploy-check.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=================================================="
|
||||||
|
echo "Deploying v${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||||||
|
echo "=================================================="
|
||||||
|
|
||||||
|
ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \
|
||||||
|
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$APP_URL" "$APP_VERSION" "$DEST" "$DEPLOY_BRANCH" "$GIT_REMOTE_URL" <<'REMOTE_SCRIPT'
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
REMOTE_DIR="$1"
|
||||||
|
COMPOSE_FILE="$2"
|
||||||
|
BACKEND_CONTAINER="$3"
|
||||||
|
MAX_WAIT="$4"
|
||||||
|
APP_URL="$5"
|
||||||
|
APP_VERSION="$6"
|
||||||
|
DEST="$7"
|
||||||
|
DEPLOY_BRANCH="${8:-}"
|
||||||
|
GIT_REMOTE_URL="${9:-https://github.com/elpatron68/kapteins-daagbok.git}"
|
||||||
|
|
||||||
|
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
|
||||||
|
|
||||||
|
echo "Configuring git remote 'origin' URL to ${GIT_REMOTE_URL} on remote host..."
|
||||||
|
if git remote | grep -q "^origin$"; then
|
||||||
|
git remote set-url origin "$GIT_REMOTE_URL"
|
||||||
|
else
|
||||||
|
git remote add origin "$GIT_REMOTE_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
|
||||||
|
echo "Warning: Local changes on deployment host will be discarded."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$DEST" == "prod" ]]; then
|
||||||
|
echo "Creating pre-deploy backup..."
|
||||||
|
if [ -x "./scripts/backup.sh" ]; then
|
||||||
|
if ! ./scripts/backup.sh --reason pre-deploy --tag "v${APP_VERSION}"; then
|
||||||
|
echo "Error: Pre-deploy backup failed. Aborting update."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Warning: scripts/backup.sh not found or not executable — skipping backup."
|
||||||
|
fi
|
||||||
|
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..."
|
||||||
|
CURRENT_BRANCH="$(git branch --show-current)"
|
||||||
|
if [ -z "$CURRENT_BRANCH" ]; then
|
||||||
|
echo "Error: Could not determine current Git branch."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git fetch --tags origin
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: Git fetch failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git reset --hard "origin/${CURRENT_BRANCH}"
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: Git reset to origin/${CURRENT_BRANCH} failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
REMOTE_VERSION="$(tr -d '[:space:]' < VERSION)"
|
||||||
|
if [ "$REMOTE_VERSION" != "$APP_VERSION" ]; then
|
||||||
|
echo "Note: Remote VERSION file already points to next release (v${REMOTE_VERSION})."
|
||||||
|
echo " Building deployed release v${APP_VERSION}."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
export APP_VERSION="$APP_VERSION"
|
||||||
|
|
||||||
|
if [[ "$DEST" == "prod" ]]; then
|
||||||
|
echo "Rebuilding Docker images without cache (APP_VERSION=${APP_VERSION})..."
|
||||||
|
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
|
||||||
|
echo "Error: Docker compose build failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting updated container stack..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: Failed to spin up docker-compose stack."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Cleaning up old/unused Docker resources..."
|
||||||
|
docker system prune -f || echo "Warning: Docker system prune failed."
|
||||||
|
|
||||||
|
echo "Waiting for services to become healthy..."
|
||||||
|
COUNTER=0
|
||||||
|
IS_READY=false
|
||||||
|
|
||||||
|
while [ $COUNTER -lt $MAX_WAIT ]; do
|
||||||
|
STATUS=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$BACKEND_CONTAINER" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ "$STATUS" = "healthy" ]; then
|
||||||
|
IS_READY=true
|
||||||
|
break
|
||||||
|
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
|
||||||
|
COUNTER=$((COUNTER + 1))
|
||||||
|
printf "."
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=================================================="
|
||||||
|
echo "Container Statuses:"
|
||||||
|
docker compose -f "$COMPOSE_FILE" ps
|
||||||
|
echo "=================================================="
|
||||||
|
|
||||||
|
if [ "$IS_READY" = true ]; then
|
||||||
|
echo "SUCCESS: ${DEST} environment updated and healthy!"
|
||||||
|
echo " -> Version: v${APP_VERSION}"
|
||||||
|
echo " -> App Frontend: ${APP_URL}"
|
||||||
|
echo " -> Backend API Health: ${APP_URL}/api/health"
|
||||||
|
echo "=================================================="
|
||||||
|
else
|
||||||
|
echo "WARNING: Backend did not transition to healthy in time."
|
||||||
|
echo "Check backend container logs:"
|
||||||
|
echo " -> docker compose -f ${COMPOSE_FILE} logs backend"
|
||||||
|
echo "=================================================="
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
REMOTE_SCRIPT
|
||||||
|
|
||||||
|
REMOTE_EXIT=$?
|
||||||
|
echo "=================================================="
|
||||||
|
if [ $REMOTE_EXIT -eq 0 ]; then
|
||||||
|
echo "${ENV_LABEL} update completed successfully on ${REMOTE_TARGET} (v${APP_VERSION})."
|
||||||
|
elif [ $REMOTE_EXIT -eq 3 ]; then
|
||||||
|
echo "${ENV_LABEL} update finished, but the backend was not healthy in time on ${REMOTE_TARGET}."
|
||||||
|
else
|
||||||
|
echo "${ENV_LABEL} update FAILED on ${REMOTE_TARGET} (exit code: ${REMOTE_EXIT})."
|
||||||
|
fi
|
||||||
|
echo "=================================================="
|
||||||
|
exit $REMOTE_EXIT
|
||||||
@@ -11,7 +11,7 @@ import { flattenTranslation } from './lib/deepl-translate.mjs'
|
|||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const localesDir = resolve(__dirname, '../client/src/i18n/locales')
|
const localesDir = resolve(__dirname, '../client/src/i18n/locales')
|
||||||
const localeFiles = ['de.json', 'en.json', 'da.json', 'sv.json', 'nb.json']
|
const localeFiles = ['de.json', 'en.json', 'da.json', 'sv.json', 'nb.json', 'fr.json', 'es.json']
|
||||||
|
|
||||||
async function loadKeys(filename) {
|
async function loadKeys(filename) {
|
||||||
const raw = await readFile(resolve(localesDir, filename), 'utf8')
|
const raw = await readFile(resolve(localesDir, filename), 'utf8')
|
||||||
|
|||||||
@@ -52,10 +52,11 @@ model UserNotificationPrefs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model UserAppearancePrefs {
|
model UserAppearancePrefs {
|
||||||
userId String @id
|
userId String @id
|
||||||
theme String @default("auto")
|
theme String @default("auto")
|
||||||
colorScheme String @default("auto")
|
colorScheme String @default("auto")
|
||||||
updatedAt DateTime @updatedAt
|
aiAuthorized Boolean @default(false)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -59,4 +59,12 @@ describe('API smoke', () => {
|
|||||||
expect(res.status).toBe(401)
|
expect(res.status).toBe(401)
|
||||||
expect(res.body.error).toMatch(/Unauthorized/i)
|
expect(res.body.error).toMatch(/Unauthorized/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('POST /api/ai/transcribe requires session', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/ai/transcribe')
|
||||||
|
.send({ audioDataUrl: 'data:audio/webm;base64,abcdef' })
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(res.body.error).toMatch(/Unauthorized/i)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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()
|
||||||
|
])
|
||||||
|
|
||||||
|
const rawDbSize = await prisma.$queryRaw<[{ size: string }]>`
|
||||||
|
SELECT pg_database_size(current_database())::text as size
|
||||||
|
`
|
||||||
|
const dbSize = Number(rawDbSize[0]?.size || '0')
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
totalUsers,
|
||||||
|
totalLogbooks,
|
||||||
|
totalPhotos,
|
||||||
|
totalVoiceMemos,
|
||||||
|
totalGpsTracks,
|
||||||
|
totalCollaborations,
|
||||||
|
totalInvitations,
|
||||||
|
aiSummaryEntries,
|
||||||
|
dbSize
|
||||||
|
})
|
||||||
|
} 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, dbSizeRaw, photosSize, voiceSize, tracksSize, entriesSize] = 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 }
|
||||||
|
}),
|
||||||
|
prisma.$queryRaw<[{ size: string }]>`
|
||||||
|
SELECT pg_database_size(current_database())::text as size
|
||||||
|
`,
|
||||||
|
prisma.photoPayload.findMany({
|
||||||
|
select: { updatedAt: true, encryptedData: true }
|
||||||
|
}),
|
||||||
|
prisma.voiceMemoPayload.findMany({
|
||||||
|
select: { updatedAt: true, encryptedData: true }
|
||||||
|
}),
|
||||||
|
prisma.gpsTrackPayload.findMany({
|
||||||
|
select: { updatedAt: true, encryptedData: true }
|
||||||
|
}),
|
||||||
|
prisma.entryPayload.findMany({
|
||||||
|
select: { updatedAt: true, encryptedData: true }
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
const dbSizeVal = Number(dbSizeRaw[0]?.size || '0')
|
||||||
|
|
||||||
|
const payloads: { date: Date; size: number }[] = []
|
||||||
|
for (const p of photosSize) {
|
||||||
|
payloads.push({ date: p.updatedAt, size: p.encryptedData.length })
|
||||||
|
}
|
||||||
|
for (const v of voiceSize) {
|
||||||
|
payloads.push({ date: v.updatedAt, size: v.encryptedData.length })
|
||||||
|
}
|
||||||
|
for (const g of tracksSize) {
|
||||||
|
payloads.push({ date: g.updatedAt, size: g.encryptedData.length })
|
||||||
|
}
|
||||||
|
for (const e of entriesSize) {
|
||||||
|
payloads.push({ date: e.updatedAt, size: e.encryptedData.length })
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPayloadsSize = payloads.reduce((acc, p) => acc + p.size, 0)
|
||||||
|
const baseDbSize = Math.max(0, dbSizeVal - totalPayloadsSize)
|
||||||
|
|
||||||
|
payloads.sort((a, b) => a.date.getTime() - b.date.getTime())
|
||||||
|
|
||||||
|
// Generate complete list of date keys for the window
|
||||||
|
const dateKeys: string[] = []
|
||||||
|
const current = new Date(since)
|
||||||
|
const todayStr = bucketDate(new Date(), bucket)
|
||||||
|
while (true) {
|
||||||
|
const key = bucketDate(current, bucket)
|
||||||
|
if (!dateKeys.includes(key)) {
|
||||||
|
dateKeys.push(key)
|
||||||
|
}
|
||||||
|
if (key >= todayStr) break
|
||||||
|
current.setUTCDate(current.getUTCDate() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbSizePoints = dateKeys.map((key) => {
|
||||||
|
let sizeSum = 0
|
||||||
|
for (const p of payloads) {
|
||||||
|
if (bucketDate(p.date, bucket) <= key) {
|
||||||
|
sizeSum += p.size
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const totalBytes = baseDbSize + sizeSum
|
||||||
|
const sizeInMb = Math.round((totalBytes / (1024 * 1024)) * 10) / 10
|
||||||
|
return { date: key, count: sizeInMb }
|
||||||
|
})
|
||||||
|
|
||||||
|
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'
|
||||||
|
),
|
||||||
|
{
|
||||||
|
metric: 'database_size',
|
||||||
|
points: dbSizePoints
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
+74
-1
@@ -3,7 +3,6 @@ import { prisma } from '../db.js'
|
|||||||
import { requireUser } from '../middleware/auth.js'
|
import { requireUser } from '../middleware/auth.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
const MAX_ATTEMPTS_PER_ENTRY = 3
|
const MAX_ATTEMPTS_PER_ENTRY = 3
|
||||||
const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku'
|
const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku'
|
||||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
||||||
@@ -230,4 +229,78 @@ router.post('/summary', async (req: any, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/transcribe', async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { audioDataUrl } = req.body ?? {}
|
||||||
|
if (!audioDataUrl || typeof audioDataUrl !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'audioDataUrl is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = audioDataUrl.match(/^data:(.+);base64,(.+)$/)
|
||||||
|
if (!match) {
|
||||||
|
return res.status(400).json({ error: 'Invalid audio data URL format' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, fullMimeType, base64Data] = match
|
||||||
|
const mimeType = fullMimeType.split(';')[0]
|
||||||
|
|
||||||
|
let ext = 'webm'
|
||||||
|
if (mimeType.includes('mp4')) ext = 'mp4'
|
||||||
|
else if (mimeType.includes('ogg')) ext = 'ogg'
|
||||||
|
else if (mimeType.includes('wav')) ext = 'wav'
|
||||||
|
|
||||||
|
const apiKey = resolveOpenRouterApiKey()
|
||||||
|
if (!apiKey) {
|
||||||
|
console.warn('[server] OpenRouter API key not configured, transcription unavailable')
|
||||||
|
return res.status(503).json({ error: 'Transcription service not configured' })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[server] Forwarding ASR request to OpenRouter (${ext}, ${base64Data.length} chars)`)
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const openRouterRes = await fetch('https://openrouter.ai/api/v1/audio/transcriptions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'openai/whisper-large-v3-turbo',
|
||||||
|
input_audio: {
|
||||||
|
data: base64Data,
|
||||||
|
format: ext
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!openRouterRes.ok) {
|
||||||
|
const errorText = await openRouterRes.text().catch(() => '')
|
||||||
|
console.error(`[server] OpenRouter ASR error response (status=${openRouterRes.status}):`, errorText)
|
||||||
|
throw new Error(`OpenRouter returned status ${openRouterRes.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = await openRouterRes.json()
|
||||||
|
const text = (data?.text || '').trim()
|
||||||
|
|
||||||
|
console.log(`[server] OpenRouter ASR completed successfully: "${text}"`)
|
||||||
|
return res.json({ text })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
console.error('[server] OpenRouter ASR request timed out')
|
||||||
|
return res.status(504).json({ error: 'Transcription request timed out' })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('ASR transcription failed:', error)
|
||||||
|
return res.status(503).json({ error: 'Transcription service unavailable' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ function isMissingAppearancePrefsTable(error: unknown): boolean {
|
|||||||
const DEFAULT_APPEARANCE_PREFS = {
|
const DEFAULT_APPEARANCE_PREFS = {
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
colorScheme: 'auto',
|
colorScheme: 'auto',
|
||||||
|
aiAuthorized: false,
|
||||||
persisted: false
|
persisted: false
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -454,6 +455,7 @@ router.get('/appearance-prefs', requireUser, async (req: any, res) => {
|
|||||||
return res.json({
|
return res.json({
|
||||||
theme: prefs?.theme ?? 'auto',
|
theme: prefs?.theme ?? 'auto',
|
||||||
colorScheme: prefs?.colorScheme ?? 'auto',
|
colorScheme: prefs?.colorScheme ?? 'auto',
|
||||||
|
aiAuthorized: prefs?.aiAuthorized ?? false,
|
||||||
persisted: prefs != null
|
persisted: prefs != null
|
||||||
})
|
})
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -469,6 +471,7 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
|||||||
try {
|
try {
|
||||||
const theme = parseThemePreference(req.body?.theme)
|
const theme = parseThemePreference(req.body?.theme)
|
||||||
const colorScheme = parseColorSchemePreference(req.body?.colorScheme)
|
const colorScheme = parseColorSchemePreference(req.body?.colorScheme)
|
||||||
|
const aiAuthorized = req.body?.aiAuthorized === true
|
||||||
if (!theme || !colorScheme) {
|
if (!theme || !colorScheme) {
|
||||||
return res.status(400).json({ error: 'Invalid theme or colorScheme' })
|
return res.status(400).json({ error: 'Invalid theme or colorScheme' })
|
||||||
}
|
}
|
||||||
@@ -479,11 +482,13 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
|||||||
userId: req.userId,
|
userId: req.userId,
|
||||||
theme,
|
theme,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
|
aiAuthorized,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
theme,
|
theme,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
|
aiAuthorized,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -491,6 +496,7 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
|||||||
return res.json({
|
return res.json({
|
||||||
theme: prefs.theme,
|
theme: prefs.theme,
|
||||||
colorScheme: prefs.colorScheme,
|
colorScheme: prefs.colorScheme,
|
||||||
|
aiAuthorized: prefs.aiAuthorized,
|
||||||
persisted: true
|
persisted: true
|
||||||
})
|
})
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
Reference in New Issue
Block a user