Compare commits

...

47 Commits

Author SHA1 Message Date
elpatron fafefff29b chore: release v0.1.1.25 2026-06-06 22:02:25 +02:00
elpatron 4fd7f3c6cf feat(journal): wrap Crew an diesem Reisetag card inside a collapsible accordion defaulting to collapsed 2026-06-06 21:59:25 +02:00
elpatron 262c48a01a chore: document COMPOSE_FILE in .env.example to lock environment compose stack configurations 2026-06-06 21:53:43 +02:00
elpatron 9ad3c2cf38 Add Database Size single metric and time series history chart to Admin Dashboard 2026-06-06 21:45:19 +02:00
elpatron 6848390ffa chore: release v0.1.1.24 2026-06-06 21:38:12 +02:00
elpatron 65d2215a35 Render maximized photo overlay via React Portal to resolve CSS stacking context issue 2026-06-06 21:33:47 +02:00
elpatron f321e5bbd1 Simplify photos_title localization across all languages by removing E2E encryption label 2026-06-06 21:32:01 +02:00
elpatron d2961b050a Rearrange journal cards layout according to user request order 2026-06-06 21:30:00 +02:00
elpatron 6943fd2dc4 Implement column selector customizer popover for chronological events logbook 2026-06-06 21:17:50 +02:00
elpatron f332eccf22 fix: restore click events for editing logbook title in dashboard 2026-06-06 21:11:29 +02:00
elpatron 9d2a19dbf8 feat: group freshwater, fuel, and greywater cards in collapsible Tanks section 2026-06-06 21:07:51 +02:00
elpatron e3cd89be5d feat: separate chronological events list and add event form into separate cards 2026-06-06 21:04:25 +02:00
elpatron a86da72b04 feat: implement collapsible accordions for event protocol list and form 2026-06-06 21:02:35 +02:00
elpatron 7d6f381f55 feat: implement responsive event cards for mobile viewports 2026-06-06 20:58:04 +02:00
elpatron 878be33b7c feat: add fullscreen photo viewer overlay on click & resolve appearance compat warnings 2026-06-06 20:40:13 +02:00
elpatron 318f5e65da feat: add camera/gallery choice for photos & sync AI profile pref to server 2026-06-06 20:37:21 +02:00
elpatron 8c6ab59d67 chore: release v0.1.1.23 2026-06-06 12:24:33 +02:00
elpatron a9c3e9ce3e Fix custom dialog coloring to support Light Theme via CSS variable mapping 2026-06-06 12:17:40 +02:00
elpatron 3eaf59e2b3 Implement AI consent gating, user preference settings, and Ko-fi hint 2026-06-06 12:08:46 +02:00
elpatron b1e17be7fd feat(analytics): add Plausible custom event VOICE_MEMO_TRANSCRIBED with status and mode properties 2026-06-06 11:51:07 +02:00
elpatron ac7e7c92d1 fix(asr): switch whisper model to whisper-large-v3-turbo 2026-06-06 11:43:09 +02:00
elpatron e10cef4b05 chore: remove parakeet service and configuration, switch completely to OpenRouter Whisper 2026-06-06 11:38:51 +02:00
elpatron 0ec5c51102 chore: configure parakeet to use 1 worker to significantly reduce memory footprint 2026-06-06 11:33:48 +02:00
elpatron 57b93b7ce7 fix: update transcribe route regex to support data URLs with codecs parameters 2026-06-06 11:14:24 +02:00
elpatron a4b3515711 feat: implement voice memo transcription with local parakeet container and fallback timeouts 2026-06-06 11:01:15 +02:00
elpatron 41acbaebac chore: release v0.1.1.22 2026-06-05 19:58:21 +02:00
elpatron 6c83cd7d36 feat: differentiate weather fetch errors by cause 2026-06-05 19:52:33 +02:00
elpatron 9089e1c6f9 feat: resolve user profile photos in chronological event log 2026-06-05 19:46:18 +02:00
elpatron 1504960d85 chore: release v0.1.1.21 2026-06-05 19:13:20 +02:00
elpatron 599f090895 fix: resolve unbound variable error on remote deploy to prod 2026-06-05 19:13:03 +02:00
elpatron 4eb2b4c517 chore: release v0.1.1.20 2026-06-05 19:07:30 +02:00
elpatron be3b23ed8c docs: update marketing sticker image 2026-06-05 19:07:21 +02:00
elpatron 697c5781b7 feat(deploy): Unterstützung für Staging-Backups und -Wiederherstellungen
Erweitert die Backup- und Restore-Skripte um die Möglichkeit, Staging-Umgebungen zu unterstützen. Fügt die Option `-dest stage` hinzu, um spezifische Konfigurationen für Staging zu verwenden, einschließlich separater Docker-Compose-Dateien und Datenbankcontainer. Dokumentation aktualisiert, um manuelle Tests und Umgebungsvariablen für Staging zu reflektieren.
2026-06-05 18:45:52 +02:00
elpatron 4c36c9160a feat(deploy): Server-Backup und Restore für Produktion
Automatisiert pg_dump, .env, Compose und Git-Archiv mit Tag-Zuordnung, Retention (5) und Pre-Deploy-Hook nur für Prod.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:40:47 +02:00
elpatron d559a762d2 fix(deploy): Vor Deploy sauberen und synchronen Git-Stand erzwingen
update-remotes.sh bricht ab, wenn uncommitted Änderungen bestehen oder
HEAD nicht mit origin übereinstimmt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:24:21 +02:00
elpatron a2180a302c refactor(tour): interne z-index-Schichtung im Overlay vereinfachen
Ersetzt irreführende 10001/10002-Werte durch relative Layer 1–3 innerhalb
von .app-tour-root und dokumentiert den Stacking-Context.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:15:17 +02:00
elpatron cd29115233 fix(tour): Tour-Tooltip über hervorgehobenen Profil-Schritten anzeigen
Erhöht den z-index des Tour-Overlays über app-tour-target-active, damit
das Modal in Schritt 8 (Stammcrew & Skipper) nicht von der Spotlight-Karte verdeckt wird.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:12:28 +02:00
elpatron e4b07ca896 refactor(deploy): update-prod.sh zu update-remotes.sh umbenennen
Ein Skript für Prod und Staging; update-staging.sh entfällt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:12:08 +02:00
elpatron f0c3cacb06 feat(analytics): Plausible über PLAUSIBLE_ENABLED und PLAUSIBLE_HOST steuerbar
Runtime-Konfiguration im Frontend-Container trennt Prod und Staging;
Staging deaktiviert Analytics standardmäßig.

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 11:14:56 +02:00
59 changed files with 4144 additions and 1118 deletions
+14 -1
View File
@@ -15,6 +15,11 @@ DeepLAPIKey=
# Production (kapteins-daagbok.eu):
# RP_ID=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
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
ORIGIN=http://localhost:5173
@@ -29,6 +34,8 @@ ORIGIN=http://localhost:5173
# POSTGRES_USER=postgres
# POSTGRES_PASSWORD=
# 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)
# CORS_ORIGINS=http://localhost:5173
@@ -37,7 +44,7 @@ ORIGIN=http://localhost:5173
SESSION_SECRET=
# Admin dashboard access — comma-separated list of User IDs (UUIDs)
# Example: ADMIN_USER_IDS=e6bcd493-80a0-400f-8a27-43c9cdce6e29,11111111-2222-3333-4444-555555555555
# 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
@@ -51,3 +58,9 @@ VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=kapteins-daagbok-feedback
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
+16 -2
View File
@@ -251,15 +251,27 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führ
```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.
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).
### 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
| 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/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/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/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 |
+1 -1
View File
@@ -1 +1 @@
0.1.1.17
0.1.1.26
+7 -4
View File
@@ -18,15 +18,18 @@ RUN npm run build
FROM nginx:1.25-alpine
WORKDIR /usr/share/nginx/html
# Copy custom Nginx configuration
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
RUN apk add --no-cache gettext
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 --from=builder /app/dist .
# Expose HTTP port
EXPOSE 80
# Health check to verify Nginx is actively running
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
ENTRYPOINT ["/docker-entrypoint.sh"]
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
PLAUSIBLE_ENABLED="${PLAUSIBLE_ENABLED:-true}"
PLAUSIBLE_HOST="${PLAUSIBLE_HOST:-https://plausible.elpatron.me}"
PLAUSIBLE_HOST="${PLAUSIBLE_HOST%/}"
case "$(printf '%s' "$PLAUSIBLE_ENABLED" | tr '[:upper:]' '[:lower:]')" in
true|1|yes)
PLAUSIBLE_ENABLED_JSON=true
PLAUSIBLE_CSP=" ${PLAUSIBLE_HOST}"
;;
*)
PLAUSIBLE_ENABLED_JSON=false
PLAUSIBLE_CSP=""
;;
esac
export PLAUSIBLE_CSP
envsubst '${PLAUSIBLE_CSP}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
cat > /usr/share/nginx/html/runtime-config.json <<EOF
{"plausibleEnabled":${PLAUSIBLE_ENABLED_JSON},"plausibleHost":"${PLAUSIBLE_HOST}"}
EOF
exec nginx -g 'daemon off;'
+1 -1
View File
@@ -22,6 +22,7 @@
<meta name="apple-mobile-web-app-title" content="Daagbok" />
<meta name="theme-color" content="#0b0c10" />
<script src="/appearance-bootstrap.js"></script>
<script src="/plausible-bootstrap.js"></script>
<script src="/bootstrap-watchdog.js"></script>
<link rel="apple-touch-icon" href="/logo.png" />
<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:image" content="https://kapteins-daagbok.eu/logo.png" />
<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>
</head>
<body>
+2 -51
View File
@@ -1,51 +1,2 @@
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' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; 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' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; 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' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; 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;
}
}
# Generated at container start from PLAUSIBLE_* — see client/nginx.conf.template and docker-entrypoint.sh
# Local Docker Compose uses the template via client/Dockerfile entrypoint.
+51
View File
@@ -0,0 +1,51 @@
server {
listen 80;
server_name localhost;
client_max_body_size 50M;
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:5000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Loads Plausible when enabled via /runtime-config.json (from .env in Docker / Vite dev).
* data-domain is always the current hostname (prod vs staging).
*/
(function () {
function load(cfg) {
if (!cfg || !cfg.plausibleEnabled || !cfg.plausibleHost) return
var host = String(cfg.plausibleHost).replace(/\/$/, '')
if (!host) return
var s = document.createElement('script')
s.defer = true
s.dataset.domain = window.location.hostname
s.src = host + '/js/script.tagged-events.js'
document.head.appendChild(s)
}
fetch('/runtime-config.json', { cache: 'no-store' })
.then(function (r) {
return r.ok ? r.json() : null
})
.then(load)
.catch(function () {
/* analytics optional */
})
})()
+391 -17
View File
@@ -2090,6 +2090,7 @@ html.scheme-dark .themed-select-option.is-selected {
cursor: text;
border-radius: 4px;
transition: background-color 0.15s ease;
pointer-events: auto;
}
.logbook-title-editable:hover {
@@ -2105,6 +2106,7 @@ html.scheme-dark .themed-select-option.is-selected {
font-size: 16px;
font-weight: 600;
line-height: 1.4;
pointer-events: auto;
}
.card-icon {
@@ -3184,6 +3186,7 @@ html.theme-cupertino .events-scroll-container {
aspect-ratio: 16 / 9;
background: #0b0c10;
overflow: hidden;
cursor: pointer;
}
.photo-container img {
@@ -3230,6 +3233,78 @@ html.theme-cupertino .events-scroll-container {
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-overlay {
position: fixed;
@@ -3237,9 +3312,9 @@ html.theme-cupertino .events-scroll-container {
left: 0;
right: 0;
bottom: 0;
background: rgba(11, 12, 16, 0.75);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
background: rgba(11, 12, 16, 0.45);
backdrop-filter: var(--app-backdrop);
-webkit-backdrop-filter: var(--app-backdrop);
display: flex;
align-items: center;
justify-content: center;
@@ -3247,13 +3322,15 @@ html.theme-cupertino .events-scroll-container {
}
.custom-dialog-card {
background: rgba(15, 23, 42, 0.85);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
background: var(--app-surface-hover, var(--app-surface));
backdrop-filter: var(--app-backdrop);
-webkit-backdrop-filter: var(--app-backdrop);
border: 1px solid var(--app-border-subtle);
border-radius: var(--app-radius-card, 16px);
padding: 28px;
width: 90%;
max-width: 420px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
box-shadow: var(--app-shadow);
text-align: center;
display: flex;
flex-direction: column;
@@ -3263,7 +3340,7 @@ html.theme-cupertino .events-scroll-container {
.custom-dialog-title {
font-size: 19px;
font-weight: 700;
color: #fbbf24;
color: var(--app-accent-light);
margin: 0 0 14px 0;
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -3271,7 +3348,7 @@ html.theme-cupertino .events-scroll-container {
.custom-dialog-message {
font-size: 15px;
color: #e2e8f0;
color: var(--app-text);
line-height: 1.5;
margin: 0 0 24px 0;
white-space: pre-line;
@@ -4362,6 +4439,7 @@ html.theme-cupertino .events-scroll-container {
.consumption-grid .input-group .input-text {
flex-shrink: 0;
-moz-appearance: textfield;
appearance: textfield;
}
.consumption-grid .input-text::-webkit-outer-spin-button,
@@ -5025,16 +5103,68 @@ html.theme-cupertino .events-scroll-container {
@media (max-width: 640px) {
.admin-page {
padding: 16px;
padding: 12px 12px 20px;
gap: 16px;
min-height: auto;
}
.admin-header {
padding-bottom: 12px;
}
.admin-header-left {
flex-direction: column;
gap: 12px;
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
align-items: center;
column-gap: 10px;
row-gap: 2px;
}
.admin-kpi-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
.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;
}
}
@@ -5137,6 +5267,36 @@ html.theme-cupertino .events-scroll-container {
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 {
font-size: 20px;
}
@@ -5952,13 +6112,15 @@ html.theme-cupertino .events-scroll-container {
.app-tour-root {
position: fixed;
inset: 0;
z-index: 10000;
/* Above .app-tour-target-active (10001) so tooltip/backdrop stay topmost */
z-index: 10010;
pointer-events: none;
}
.app-tour-backdrop {
position: absolute;
inset: 0;
z-index: 1;
background: rgba(2, 6, 23, 0.62);
pointer-events: auto;
}
@@ -5976,7 +6138,7 @@ html.theme-cupertino .events-scroll-container {
0 0 32px rgba(56, 189, 248, 0.5),
0 12px 40px rgba(0, 0, 0, 0.35);
pointer-events: none;
z-index: 10001;
z-index: 2;
}
body.app-tour-active .app-tour-target-active {
@@ -5987,7 +6149,8 @@ body.app-tour-active .app-tour-target-active {
.app-tour-tooltip {
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;
width: min(420px, calc(100vw - 32px));
max-width: calc(100vw - 32px);
@@ -6149,3 +6312,214 @@ body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
.crew-selection-item input {
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);
}
+19 -1
View File
@@ -97,6 +97,8 @@ function App() {
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(
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
@@ -316,6 +318,8 @@ function App() {
const session = await checkServerSession()
if (cancelled) return
setServerSessionActive(session.authenticated)
if (session.authenticated) {
persistSessionUserId(session.userId)
}
@@ -335,6 +339,10 @@ function App() {
if (!cancelled) {
console.warn('Session restore failed:', err)
}
} finally {
if (!cancelled) {
setSessionChecked(true)
}
}
})()
@@ -618,7 +626,17 @@ function App() {
if (!isAuthenticated) {
return (
<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>
)
}
+15 -3
View File
@@ -7,12 +7,22 @@ import {
type AdminTimeSeriesResponse,
type AdminTimeBucket
} from '../services/adminApi.js'
import { BarChart2, Bookmark, ChevronLeft, Image, MapPin, Mic, Users } from 'lucide-react'
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,
@@ -20,14 +30,14 @@ function KpiCard({
}: {
icon: ReactNode
label: string
value: number
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">{formatNumber(value)}</span>
<span className="stats-kpi-value">{typeof value === 'number' ? formatNumber(value) : value}</span>
</div>
</div>
)
@@ -194,6 +204,7 @@ export default function AdminDashboard({ onBack }: AdminDashboardProps) {
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">
@@ -233,6 +244,7 @@ export default function AdminDashboard({ onBack }: AdminDashboardProps) {
<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>
+133 -5
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import {
@@ -12,7 +12,8 @@ import {
getKnownUsernames,
forgetUsername,
hasUnlockedLocalSession,
logoutUser
logoutUser,
resolveRestoreUsername
} from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
@@ -27,9 +28,15 @@ import {
interface AuthOnboardingProps {
onAuthenticated: () => void
onOpenDemo?: () => void
/** Server session cookie is valid but the in-memory master key was lost (e.g. after reload). */
restoreSession?: boolean
}
export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnboardingProps) {
export default function AuthOnboarding({
onAuthenticated,
onOpenDemo,
restoreSession = false
}: AuthOnboardingProps) {
const { t, i18n } = useTranslation()
const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false)
@@ -60,7 +67,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const [isNewRegistration, setIsNewRegistration] = useState(false)
const [showDisclaimer, setShowDisclaimer] = useState(false)
const [showHelp, setShowHelp] = useState(false)
const [showStandardLogin, setShowStandardLogin] = useState(false)
const autoUnlockAttempted = useRef(false)
const isRestoreFlow = restoreSession && !showStandardLogin
const passkeyHostOk = isPasskeyCompatibleLocation()
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) => {
e.preventDefault()
if (!recoveryInput.trim() || !encryptedPayloads) return
@@ -347,10 +374,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<div className="auth-card glass">
<div className="auth-header">
<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>
<p className="recovery-warning">
{t('auth.enter_pin_warning')}
{isRestoreFlow ? t('auth.restore_pin_warning') : t('auth.enter_pin_warning')}
</p>
<form onSubmit={handlePinLoginSubmit} className="auth-form">
@@ -397,6 +424,12 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
type="button"
className="btn secondary"
onClick={() => {
if (isRestoreFlow) {
setShowPinLogin(false)
setPinLoginInput('')
setError(null)
return
}
void (async () => {
setShowPinLogin(false)
setPinLoginInput('')
@@ -480,6 +513,101 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
)
}
// Render: Session restore (active server cookie, master key lost after reload)
if (isRestoreFlow) {
const restoreUser = resolveRestoreUsername()
const restoreKnownUsers = getKnownUsernames()
return (
<div className="auth-card glass">
<div className="auth-header">
<KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.restore_title')}</h2>
</div>
<p className="recovery-warning">{t('auth.restore_subtitle')}</p>
{loading && (
<p className="dashboard-status-msg" style={{ marginTop: '12px' }}>
{t('auth.restore_unlocking')}
</p>
)}
{error && <div className="auth-error">{error}</div>}
{!loading && (
<div className="auth-actions" style={{ flexDirection: 'column', gap: '10px', marginTop: '16px' }}>
{restoreUser && passkeyHostOk && (
<button
type="button"
className="btn primary"
onClick={() => handleLogin(restoreUser)}
style={{ width: '100%' }}
>
{t('auth.restore_with_passkey', { name: restoreUser })}
</button>
)}
{restoreUser && hasLocalPin(restoreUser) && (
<button
type="button"
className="btn secondary"
onClick={() => {
setUsername(restoreUser)
setShowPinLogin(true)
}}
style={{ width: '100%' }}
>
{t('auth.restore_with_pin')}
</button>
)}
{restoreKnownUsers.length > 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
<span style={{ fontSize: '12px', color: '#64748b', textTransform: 'uppercase' }}>
{t('auth.quick_login')}
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', width: '100%' }}>
{restoreKnownUsers.map((name) => (
<button
key={name}
type="button"
onClick={() => {
if (hasLocalPin(name)) {
setUsername(name)
setShowPinLogin(true)
} else {
void handleLogin(name)
}
}}
disabled={loading}
className="btn secondary"
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
>
<UserRound size={16} />
{name}
</button>
))}
</div>
</div>
)}
<button
type="button"
className="btn secondary"
onClick={() => {
setShowStandardLogin(true)
setError(null)
}}
style={{ width: '100%' }}
>
{t('auth.restore_other_account')}
</button>
</div>
)}
</div>
)
}
// Render 3: Standard Login / Registration options form
return (
<>
+32 -5
View File
@@ -45,11 +45,38 @@ export default function CreatorAvatar({
let photo: string | null = null
let role = ''
if (creatorId && crewSnapshotsById && crewSnapshotsById[creatorId]) {
const snap = crewSnapshotsById[creatorId]
name = snap.name || ''
photo = snap.photo || null
role = snap.role || ''
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
+69 -44
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
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 { loadPersonPool } from '../services/personPool.js'
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
@@ -24,6 +24,7 @@ export default function EntryCrewSection({
preloadedPool
}: EntryCrewSectionProps) {
const { t } = useTranslation()
const [collapsed, setCollapsed] = useState(true)
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
useEffect(() => {
@@ -90,54 +91,78 @@ export default function EntryCrewSection({
return (
<div className="form-card" data-tour="entry-crew">
<div className="form-header">
<Users size={22} className="form-icon" />
<h3>{t('entry_crew.title')}</h3>
</div>
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p>
<div className="input-group mb-3">
<label>{t('entry_crew.day_skipper')}</label>
{skippers.length === 0 ? (
<p className="help-text">{t('entry_crew.no_skipper')}</p>
<div
className="form-header accordion-header"
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">
<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">
{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>
<ChevronUp size={20} className="accordion-chevron" />
)}
</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>
))}
{!collapsed && (
<>
<p className="help-text mb-3" style={{ marginTop: '16px' }}>{t('entry_crew.subtitle')}</p>
<div className="input-group mb-3">
<label>{t('entry_crew.day_skipper')}</label>
{skippers.length === 0 ? (
<p className="help-text">{t('entry_crew.no_skipper')}</p>
) : (
<div className="crew-selection-list">
{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">
<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>
)
}
+107 -7
View File
@@ -1,24 +1,97 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Mic, Loader2 } from 'lucide-react'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
import { formatEventSummary } from '../utils/formatEventSummary.js'
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 {
event: LogEventPayload
logbookId: string
voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
readOnly?: boolean
}
export default function EventRemarksCell({
event,
logbookId,
voiceMemoLookup
voiceMemoLookup,
readOnly = false
}: EventRemarksCellProps) {
const { t } = useTranslation()
const { showAlert } = useDialog()
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
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)
if (voiceId && 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' : ''}`}>
<span>{summary}</span>
{voiceId && (
<VoiceMemoPlayer
audioId={voiceId}
logbookId={logbookId}
preloaded={preloaded}
compact
/>
<div style={{ display: 'inline-flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginTop: '4px' }}>
<VoiceMemoPlayer
audioId={voiceId}
logbookId={logbookId}
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>
)
+84 -26
View File
@@ -22,6 +22,7 @@ import {
Zap
} from 'lucide-react'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getAiAuthorized } from '../services/userPreferences.js'
import {
appendQuickEvent as apiAppendQuickEvent,
appendQuickEvents as apiAppendQuickEvents,
@@ -31,7 +32,6 @@ import {
removeLastEvent
} from '../services/quickEventLog.js'
import CreatorAvatar from './CreatorAvatar.tsx'
import { formatEventSummary } from '../utils/formatEventSummary.js'
import {
getLastAutoPositionMs,
getLastLoggedPositionWithin,
@@ -43,7 +43,6 @@ import {
liveFuelRemark,
livePhotoRemark,
liveVoiceRemark,
parseLiveVoiceRemark,
livePrecipRemark,
liveSailsRemark,
liveSogRemark,
@@ -80,7 +79,7 @@ import CourseDialInput from './CourseDialInput.tsx'
import GpsSignalHint from './GpsSignalHint.tsx'
import LiveCameraCapture from './LiveCameraCapture.tsx'
import LiveVoiceCapture from './LiveVoiceCapture.tsx'
import VoiceMemoPlayer from './VoiceMemoPlayer.tsx'
import EventRemarksCell from './EventRemarksCell.tsx'
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
@@ -713,13 +712,27 @@ export default function LiveLogView({
{ analyticsSource: 'live_log' }
)
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
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'))
return
if (err instanceof WeatherApiError) {
if (err.code === 'OFFLINE') {
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'NO_KEY') {
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)
void showAlert(t('settings.weather_error'), t('logs.live_weather_owm_btn'))
@@ -822,13 +835,50 @@ export default function LiveLogView({
void (async () => {
try {
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({
logbookId,
entryId,
audioDataUrl,
mimeType,
durationSec,
caption,
caption: finalCaption,
transcribed,
analyticsContext: 'live_log'
})
await appendQuickEvent(logbookId, entryId, {
@@ -840,6 +890,23 @@ export default function LiveLogView({
setVoiceCaption('')
showUndo('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) {
console.error('Live log voice save failed:', err)
const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE'
@@ -1211,12 +1278,6 @@ export default function LiveLogView({
) : (
<ol className="live-log-stream">
{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 (
<li key={`${event.time}-${index}`} className="live-log-entry">
<time className="live-log-time">{event.time}</time>
@@ -1226,15 +1287,12 @@ export default function LiveLogView({
size={24}
/>
<div className="live-log-summary-block">
<span className="live-log-summary">{summary}</span>
{voiceId && (
<VoiceMemoPlayer
audioId={voiceId}
logbookId={logbookId}
preloaded={voicePreloaded}
compact
/>
)}
<EventRemarksCell
event={event}
logbookId={logbookId}
voiceMemoLookup={voiceMemoLookup}
readOnly={false}
/>
</div>
</li>
)
File diff suppressed because it is too large Load Diff
+135 -18
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.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 { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx'
import { Camera, Trash2 } from 'lucide-react'
import { Camera, Image, Trash2, X } from 'lucide-react'
import { probeCameraAvailability } from '../utils/cameraAvailability.js'
interface PhotoCaptureProps {
entryId: string
@@ -31,8 +33,38 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
const [hasCamera, setHasCamera] = useState(false)
const [maximizedPhoto, setMaximizedPhoto] = useState<DecryptedPhoto | null>(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
const localPhotos = useLiveQuery(
@@ -119,12 +151,18 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
}
}
const triggerSelect = () => {
const triggerGallerySelect = () => {
if (fileInputRef.current) {
fileInputRef.current.click()
}
}
const triggerCameraSelect = () => {
if (cameraInputRef.current) {
cameraInputRef.current.click()
}
}
return (
<div className="form-card mt-6">
<div className="form-header mb-4">
@@ -159,20 +197,62 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
style={{ display: 'none' }}
/>
<button
type="button"
className="btn primary"
onClick={triggerSelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button>
<input
type="file"
accept="image/*"
capture="environment"
ref={cameraInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
{hasCamera ? (
<>
<button
type="button"
className="btn primary"
onClick={triggerCameraSelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{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
type="button"
className="btn primary"
onClick={triggerGallerySelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button>
)}
</div>
</div>
)}
@@ -183,14 +263,22 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
) : (
<div className="photo-attachments-grid">
{decryptedPhotos.map((photo) => (
<div key={photo.payloadId} className="photo-card glass">
<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={() => handleDelete(photo.payloadId)}
onClick={(e) => {
e.stopPropagation()
handleDelete(photo.payloadId)
}}
title="Remove photo"
>
<Trash2 size={16} />
@@ -206,6 +294,35 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
))}
</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>
)
}
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
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 PushNotificationSettings from './PushNotificationSettings.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
@@ -13,7 +13,9 @@ import {
getThemePreference,
setColorSchemePreference,
setOwmApiKey,
setThemePreference
setThemePreference,
getAiAuthorized,
setAiAuthorized
} from '../services/userPreferences.js'
interface UserProfilePreferencesProps {
@@ -28,12 +30,25 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
const [savingOwm, setSavingOwm] = 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) => {
setThemePreference(userId, nextTheme)
setColorSchemePreference(userId, nextColorScheme)
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)
})
}
@@ -58,6 +73,15 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
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 (
<>
<section className="member-editor-card glass">
@@ -152,6 +176,42 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
</form>
</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 />
<PwaInstallPrompt variant="inline" />
</>
@@ -11,6 +11,7 @@ export interface PreloadedVoiceMemo {
mimeType?: string
durationSec?: number
caption?: string
transcribed?: boolean
}
interface VoiceMemoPlayerProps {
+2 -1
View File
@@ -48,7 +48,8 @@ export function useEntryVoiceMemos(
audio: String(decrypted.audio),
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : 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 {
// skip corrupt memo
+28 -3
View File
@@ -91,7 +91,15 @@
"use_localhost_link": "Skift til localhost",
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.",
"error_invalid_rp_id": "Passkey-domæne matcher ikke (RP ID). Brug http://localhost:5173 med RP_ID=localhost i .env til lokal udvikling.",
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen."
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen.",
"restore_checking": "Tjekker session…",
"restore_title": "Gendan session",
"restore_subtitle": "Du er stadig logget ind. Lås din logbog op med passkey eller PIN.",
"restore_unlocking": "Låser op…",
"restore_with_passkey": "Lås op med passkey ({{name}})",
"restore_with_pin": "Lås op med PIN",
"restore_pin_warning": "Indtast din lokale PIN for at låse logbogen op efter genindlæsning.",
"restore_other_account": "Log ind med en anden konto"
},
"pwa": {
"title": "Installer app",
@@ -177,7 +185,10 @@
"travel_day_number": "Rejsedag {{number}}",
"departure": "Starthavn (rejse fra)",
"destination": "Destinationsport (til)",
"route": "Rejse fra/til",
"route": "Reje fra/til",
"tanks": "Tanke",
"customize_columns": "Tilpas kolonner",
"column_selector_title": "Kolonner at vise",
"freshwater": "Ferskvand (liter)",
"fuel": "Treibstoff / Brændstof (liter)",
"greywater": "Gråt vand (liter)",
@@ -289,6 +300,9 @@
"live_voice_entry_plain": "Stemmenotat",
"live_voice_caption_label": "Billedtekst (valgfrit)",
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnemester",
"live_voice_transcribe_action": "Transkribere",
"live_voice_transcribing": "Transkriberer…",
"live_voice_transcribe_failed": "Stemmebesked gemt, men transkribering mislykkedes.",
"live_undo_voice_hint": "Stemmenotat gemt",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Indtast tekst…",
@@ -428,10 +442,12 @@
"ai_summary_error_rate_limited": "Maksimalt antal genereringer nået for denne rejsedag.",
"ai_summary_error_forbidden": "Kun skipperen må generere AI-resuméer.",
"ai_summary_offline": "AI-resumé kræver internetforbindelse. Du er offline lige nu.",
"photos_title": "Vedhæftede billeder (E2E-krypteret)",
"photos_title": "Vedhæftede billeder",
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
"photo_btn": "Tag foto / upload",
"photo_camera_btn": "Tag foto",
"photo_gallery_btn": "Vælg fra galleri",
"photo_processing": "Er ved at blive behandlet...",
"no_photos": "Der er endnu ingen billeder knyttet til denne rejsedag.",
"photo_delete_confirm": "Er du sikker på, at du vil slette dette foto permanent?",
@@ -661,6 +677,12 @@
"integrations_title": "Integrationer",
"owm_key": "OpenWeatherMap API-nøgle",
"owm_help": "Valgfrit: egen OpenWeatherMap API-nøgle. Hvis der ikke er nogen indtastning, bruges nøglen på serversiden fra operatørkonfigurationen.",
"ai_title": "AI-funktioner og privatliv",
"ai_desc": "Autoriser integrationer af kunstig intelligens for dine logbøger.",
"ai_help": "Aktivering af AI-funktioner giver appen mulighed for at opsummere dine rejsedage og transkribere optagede stemmememoer. For at behandle disse anmodninger sendes rå stemmedata og rejselogfiler sikkert løbende til OpenRouter. Der gemmes ingen data permanent af AI-modellen.\n\nDisse cloud-ressourcer koster penge at køre. Hvis du kan lide at bruge dem, bedes du overveje at støtte projektet frivilligt med en donation via Ko-fi-linket i footeren for at holde dem gratis og bæredygtige for alle.",
"ai_enable_label": "Aktiver transkribering og resuméer af rejsedage",
"ai_unauthorized_alert_title": "AI-funktioner er ikke autoriseret",
"ai_unauthorized_alert_desc": "For at bruge transkribering eller rejsedagsresuméer skal du autorisere dataoverførslen til OpenRouter i din brugerprofil under 'AI-funktioner og privatliv'.",
"prefs_save": "Gemme",
"prefs_saving": "Vil blive reddet...",
"prefs_saved": "Gemt",
@@ -782,6 +804,9 @@
"no_key": "Ingen OpenWeatherMap API-nøgle tilgængelig. Gem din egen nøgle i brugerprofilen, eller kontakt operatøren.",
"weather_success": "Vejrdata hentet med succes!",
"weather_error": "Hentning af vejrdata mislykkedes. Tjek API-nøglen og forbindelsen.",
"weather_unauthorized": "Hentning af vejrdata mislykkedes. API-nøglen er ugyldig eller ikke autoriseret.",
"weather_not_found": "Hentning af vejrdata mislykkedes. Den angivne placering eller koordinater blev ikke fundet.",
"weather_bad_request": "Hentning af vejrdata mislykkedes. Ingen placering eller GPS-position blev angivet.",
"weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.",
"gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
"share_title": "Del logbog (skrivebeskyttet)",
+27 -2
View File
@@ -91,7 +91,15 @@
"use_localhost_link": "Zu localhost wechseln",
"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_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": {
"title": "App installieren",
@@ -178,6 +186,9 @@
"departure": "Start-Hafen (Reise von)",
"destination": "Ziel-Hafen (nach)",
"route": "Reise von/nach",
"tanks": "Tanks",
"customize_columns": "Spalten anpassen",
"column_selector_title": "Anzuzeigende Spalten",
"freshwater": "Frischwasser (Liter)",
"fuel": "Treibstoff / Fuel (Liter)",
"greywater": "Grauwasser (Liter)",
@@ -289,6 +300,9 @@
"live_voice_entry_plain": "Sprachnotiz",
"live_voice_caption_label": "Beschriftung (optional)",
"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_comment_btn": "Kommentar",
"live_comment_placeholder": "Freitext eingeben…",
@@ -428,10 +442,12 @@
"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_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_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
"photo_btn": "Foto aufnehmen / Hochladen",
"photo_camera_btn": "Foto aufnehmen",
"photo_gallery_btn": "Aus Galerie wählen",
"photo_processing": "Wird verarbeitet...",
"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?",
@@ -661,6 +677,12 @@
"integrations_title": "Integrationen",
"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.",
"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_saving": "Wird gespeichert…",
"prefs_saved": "Gespeichert",
@@ -782,6 +804,9 @@
"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_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.",
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
"share_title": "Logbuch teilen (Schreibgeschützt)",
+27 -2
View File
@@ -91,7 +91,15 @@
"use_localhost_link": "Switch to localhost",
"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_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": {
"title": "Install app",
@@ -178,6 +186,9 @@
"departure": "Departure Port (von)",
"destination": "Destination Port (nach)",
"route": "Route / Journey",
"tanks": "Tanks",
"customize_columns": "Customize columns",
"column_selector_title": "Columns to Show",
"freshwater": "Freshwater (Liters)",
"fuel": "Fuel (Liters)",
"greywater": "Greywater (Liters)",
@@ -289,6 +300,9 @@
"live_voice_entry_plain": "Voice memo",
"live_voice_caption_label": "Caption (optional)",
"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_comment_btn": "Comment",
"live_comment_placeholder": "Enter text…",
@@ -428,10 +442,12 @@
"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_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_placeholder": "e.g. Setting sails near harbor entrance",
"photo_btn": "Take Photo / Upload",
"photo_camera_btn": "Take Photo",
"photo_gallery_btn": "Choose from Gallery",
"photo_processing": "Processing...",
"no_photos": "No photos attached to this journal entry yet.",
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
@@ -661,6 +677,12 @@
"integrations_title": "Integrations",
"owm_key": "OpenWeatherMap API key",
"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_saving": "Saving…",
"prefs_saved": "Saved",
@@ -782,6 +804,9 @@
"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_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}}.",
"gps_error": "Please enter a location or fetch GPS coordinates first.",
"share_title": "Share Logbook (Read-Only)",
+27 -2
View File
@@ -91,7 +91,15 @@
"use_localhost_link": "Bytt til localhost",
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.",
"error_invalid_rp_id": "Passkey-domene stemmer ikke (RP ID). Bruk http://localhost:5173 med RP_ID=localhost i .env for lokal utvikling.",
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen."
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen.",
"restore_checking": "Sjekker økt…",
"restore_title": "Gjenopprett økt",
"restore_subtitle": "Du er fortsatt innlogget. Lås opp loggboken med passkey eller PIN.",
"restore_unlocking": "Låser opp…",
"restore_with_passkey": "Lås opp med passkey ({{name}})",
"restore_with_pin": "Lås opp med PIN",
"restore_pin_warning": "Skriv inn din lokale PIN for å låse opp loggboken etter omlasting.",
"restore_other_account": "Logg inn med en annen konto"
},
"pwa": {
"title": "Installer app",
@@ -178,6 +186,9 @@
"departure": "Starthavn (reise fra)",
"destination": "Destinasjonsport (til)",
"route": "Reise fra/til",
"tanks": "Tanker",
"customize_columns": "Tilpass kolonner",
"column_selector_title": "Kolonner å vise",
"freshwater": "Ferskvann (liter)",
"fuel": "Drivstoff / Drivstoff (liter)",
"greywater": "Gråvann (liter)",
@@ -289,6 +300,9 @@
"live_voice_entry_plain": "Talemelding",
"live_voice_caption_label": "Bildetekst (valgfritt)",
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnesjef",
"live_voice_transcribe_action": "Transkribere",
"live_voice_transcribing": "Transkriberer…",
"live_voice_transcribe_failed": "Taleopptak lagret, men transkribering mislyktes.",
"live_undo_voice_hint": "Talemelding lagret",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Skriv inn tekst…",
@@ -428,10 +442,12 @@
"ai_summary_error_rate_limited": "Maksimalt antall genereringer nådd for denne reisedagen.",
"ai_summary_error_forbidden": "Kun skipperen kan generere AI-sammendrag.",
"ai_summary_offline": "AI-sammendrag krever internettforbindelse. Du er frakoblet.",
"photos_title": "Bildevedlegg (E2E-kryptert)",
"photos_title": "Bildevedlegg",
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
"photo_btn": "Ta bilde / last opp",
"photo_camera_btn": "Ta bilde",
"photo_gallery_btn": "Velg fra galleri",
"photo_processing": "...blir behandlet...",
"no_photos": "Ingen bilder knyttet til denne reisedagen ennå.",
"photo_delete_confirm": "Er du sikker på at du vil slette dette bildet permanent?",
@@ -661,6 +677,12 @@
"integrations_title": "Integrasjoner",
"owm_key": "OpenWeatherMap API-nøkkel",
"owm_help": "Valgfritt: egen OpenWeatherMap API-nøkkel. Hvis ingen oppføring er gjort, brukes serverside-nøkkelen fra operatørkonfigurasjonen.",
"ai_title": "KI-funksjoner og personvern",
"ai_desc": "Autoriser integrasjoner av kunstig intelligens for loggbøkene dine.",
"ai_help": "Aktivering av KI-funksjoner gjør det mulig for appen å oppsummere reisedagene dine og transkribere innspilte talememoer. For å behandle disse forespørslene sendes rå stemmedata og reiselogger sikkert løpende til OpenRouter. Ingen data lagres permanent av KI-modellen.\n\nDisse nettskyressursene koster penger å drifte. Hvis du har glede av å bruke dem, kan du vurdere å støtte prosjektet frivillig med en donasjon via Ko-fi-lenken i bunnteksten for å holde dem gratis og bærekraftige for alle.",
"ai_enable_label": "Aktiver transkribering og oppsummeringer av reisedager",
"ai_unauthorized_alert_title": "KI-funktionen er ikke autorisert",
"ai_unauthorized_alert_desc": "For å bruke transkribering eller reisedagsoppsummeringer, må du autorisere dataoverføringen til OpenRouter i brukerprofilen din under 'KI-funksjoner og personvern'.",
"prefs_save": "Spar",
"prefs_saving": "...vil bli reddet...",
"prefs_saved": "Reddet",
@@ -782,6 +804,9 @@
"no_key": "Ingen OpenWeatherMap API-nøkkel tilgjengelig. Lagre din egen nøkkel i brukerprofilen, eller kontakt operatøren.",
"weather_success": "Værdata vellykket hentet!",
"weather_error": "Henting av værdata mislyktes. Kontroller API-nøkkelen og tilkoblingen.",
"weather_unauthorized": "Henting av værdata mislyktes. API-nøkkelen er ugyldig eller ikke autorisert.",
"weather_not_found": "Henting av værdata mislyktes. Den angitte posisjonen eller koordinatene ble ikke funnet.",
"weather_bad_request": "Henting av værdata mislyktes. Ingen posisjon eller GPS-koordinater ble angitt.",
"weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.",
"gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
"share_title": "Del loggbok (skrivebeskyttet)",
+27 -2
View File
@@ -91,7 +91,15 @@
"use_localhost_link": "Byt till localhost",
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.",
"error_invalid_rp_id": "Passkey-domänen matchar inte (RP ID). Använd http://localhost:5173 med RP_ID=localhost i .env för lokal utveckling.",
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen."
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen.",
"restore_checking": "Kontrollerar session…",
"restore_title": "Återställ session",
"restore_subtitle": "Du är fortfarande inloggad. Lås upp din loggbok med passkey eller PIN.",
"restore_unlocking": "Låser upp…",
"restore_with_passkey": "Lås upp med passkey ({{name}})",
"restore_with_pin": "Lås upp med PIN",
"restore_pin_warning": "Ange din lokala PIN för att låsa upp loggboken efter omladdning.",
"restore_other_account": "Logga in med ett annat konto"
},
"pwa": {
"title": "Installera app",
@@ -178,6 +186,9 @@
"departure": "Starthamn (resa från)",
"destination": "Destinationsport (till)",
"route": "Resa från/till",
"tanks": "Tankar",
"customize_columns": "Anpassa kolumner",
"column_selector_title": "Kolumner att visa",
"freshwater": "Färskvatten (liter)",
"fuel": "Treibstoff / Bränsle (liter)",
"greywater": "Gråvatten (liter)",
@@ -289,6 +300,9 @@
"live_voice_entry_plain": "Röstanteckning",
"live_voice_caption_label": "Bildtext (valfritt)",
"live_voice_caption_placeholder": "t.ex. radiokontakt med hamnmästare",
"live_voice_transcribe_action": "Transkribera",
"live_voice_transcribing": "Transkriberar…",
"live_voice_transcribe_failed": "Röstanteckning sparad, men transkribering misslyckades.",
"live_undo_voice_hint": "Röstanteckning sparad",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Ange text…",
@@ -428,10 +442,12 @@
"ai_summary_error_rate_limited": "Maximalt antal genereringar nått för denna resedag.",
"ai_summary_error_forbidden": "Endast skepparen får generera AI-sammanfattningar.",
"ai_summary_offline": "AI-sammanfattning kräver internetanslutning. Du är offline.",
"photos_title": "Fotobilagor (E2E-krypterade)",
"photos_title": "Fotobilagor",
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
"photo_btn": "Ta foto / ladda upp",
"photo_camera_btn": "Ta foto",
"photo_gallery_btn": "Välj från galleri",
"photo_processing": "Håller på att bearbetas...",
"no_photos": "Inga foton kopplade till denna resdag ännu.",
"photo_delete_confirm": "Är du säker på att du vill radera det här fotot permanent?",
@@ -661,6 +677,12 @@
"integrations_title": "Integrationer",
"owm_key": "OpenWeatherMap API-nyckel",
"owm_help": "Valfritt: egen OpenWeatherMap API-nyckel. Om inget anges används nyckeln på serversidan från operatörskonfigurationen.",
"ai_title": "AI-funktioner och integritet",
"ai_desc": "Auktorisera integrationer av artificiell intelligens för dina loggböcker.",
"ai_help": "Genom at aktivera AI-funktioner kan appen sammanfatta dina rejsdagar och transkribera röstmemon. För att bearbeta dessa förfrågningar skickas röstdata och rejsloggar säkert och tillfälligt till OpenRouter. Inga data sparas permanent av AI-modellen.\n\nDessa molnresurser kostar pengar att driva. Om du gillar att använda dem, överväg att frivilligt stödja projektet med en donation via Ko-fi-länken i sidfoten för att hålla dem gratis och hållbara för alla.",
"ai_enable_label": "Aktivera transkribering och sammanfattningar av rejsdagar",
"ai_unauthorized_alert_title": "AI-funktioner är inte auktoriserade",
"ai_unauthorized_alert_desc": "För att använda transkribering eller rejsdagsöversikter måste du auktorisera dataöverföringen till OpenRouter i din användarprofil under 'AI-funktioner och integritet'.",
"prefs_save": "Spara",
"prefs_saving": "Kommer att sparas...",
"prefs_saved": "Sparade",
@@ -782,6 +804,9 @@
"no_key": "Ingen OpenWeatherMap API-nyckel tillgänglig. Spara din egen nyckel i användarprofilen eller kontakta operatören.",
"weather_success": "Väderdata har hämtats framgångsrikt!",
"weather_error": "Hämtning av väderdata misslyckades. Kontrollera API-nyckeln och anslutningen.",
"weather_unauthorized": "Hämtning av väderdata misslyckades. API-nyckeln är ogiltig eller inte auktoriserad.",
"weather_not_found": "Hämtning av väderdata misslyckades. Den angivna platsen eller koordinaterna hittades inte.",
"weather_bad_request": "Hämtning av väderdata misslyckades. Ingen plats eller GPS-position angavs.",
"weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.",
"gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
"share_title": "Aktieloggbok (skrivskyddad)",
+1
View File
@@ -16,6 +16,7 @@ export interface AdminSummary {
totalCollaborations: number
totalInvitations: number
aiSummaryEntries: number
dbSize: number
}
export type AdminTimeBucket = 'day' | 'week' | 'month'
+1
View File
@@ -42,6 +42,7 @@ export const PlausibleEvents = {
LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
AI_SUMMARY_GENERATED: 'AI Summary Generated',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
+8 -4
View File
@@ -26,6 +26,7 @@ describe('appearancePrefs', () => {
await expect(fetchAppearancePrefs()).resolves.toEqual({
theme: 'auto',
colorScheme: 'auto',
aiAuthorized: false,
persisted: false
})
expect(mockedApiJson).not.toHaveBeenCalled()
@@ -36,6 +37,7 @@ describe('appearancePrefs', () => {
mockedApiJson.mockResolvedValueOnce({
theme: 'ocean',
colorScheme: 'dark',
aiAuthorized: true,
persisted: true
})
@@ -46,6 +48,7 @@ describe('appearancePrefs', () => {
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_ai_authorized_${USER_ID}`)).toBe('true')
expect(changed).toHaveBeenCalledTimes(1)
})
@@ -53,20 +56,20 @@ describe('appearancePrefs', () => {
localStorage.setItem('active_userid', USER_ID)
setThemePreference(USER_ID, 'material')
mockedApiJson
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false })
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true })
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false })
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', aiAuthorized: false, persisted: true })
await syncAppearancePrefs(USER_ID)
expect(mockedApiJson).toHaveBeenCalledTimes(2)
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
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 () => {
await saveAppearancePrefsToServer('ocean', 'light')
await saveAppearancePrefsToServer('ocean', 'light', true)
expect(mockedApiJson).not.toHaveBeenCalled()
})
@@ -76,6 +79,7 @@ describe('appearancePrefs', () => {
mockedApiJson.mockResolvedValue({
theme: 'material',
colorScheme: 'dark',
aiAuthorized: false,
persisted: true
})
+16 -5
View File
@@ -5,7 +5,9 @@ import {
getColorSchemePreference,
getThemePreference,
setColorSchemePreference,
setThemePreference
setThemePreference,
getAiAuthorized,
setAiAuthorized
} from './userPreferences.js'
const API_BASE = '/api/auth/appearance-prefs'
@@ -13,13 +15,15 @@ const API_BASE = '/api/auth/appearance-prefs'
export interface AppearancePrefs {
theme: string
colorScheme: string
aiAuthorized: boolean
persisted: boolean
}
function hasLocalAppearancePrefs(userId: string): boolean {
return (
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> {
if (!resolveSyncedUserId(userId)) {
return { theme: 'auto', colorScheme: 'auto', persisted: false }
return { theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false }
}
return apiJson<AppearancePrefs>(API_BASE)
@@ -44,13 +48,14 @@ export async function fetchAppearancePrefs(userId?: string | null): Promise<Appe
export async function saveAppearancePrefsToServer(
theme: string,
colorScheme: string,
aiAuthorized: boolean,
userId?: string | null
): Promise<void> {
if (!resolveSyncedUserId(userId)) return
await apiJson<AppearancePrefs>(API_BASE, {
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) {
setThemePreference(id, server.theme)
setColorSchemePreference(id, server.colorScheme)
setAiAuthorized(id, server.aiAuthorized)
} else if (hasLocalAppearancePrefs(id)) {
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id), id)
await saveAppearancePrefsToServer(
getThemePreference(id),
getColorSchemePreference(id),
getAiAuthorized(id),
id
)
}
} catch (err) {
console.warn('Failed to sync appearance preferences:', err)
+9
View File
@@ -64,6 +64,15 @@ export function persistSessionUserId(userId: string | undefined): void {
}
}
/** Username to use when re-unlocking after reload (active account or sole remembered user). */
export function resolveRestoreUsername(): string | null {
const stored = localStorage.getItem('active_username')
if (stored) return stored
const known = getKnownUsernames()
if (known.length === 1) return known[0]
return null
}
export async function reauthWithPasskey(): Promise<boolean> {
const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
method: 'POST'
+23
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import {
hasUnlockedLocalCrypto,
hasUnlockedLocalSession,
resolveRestoreUsername,
setActiveMasterKey
} 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', () => {
beforeEach(() => {
localStorage.clear()
+12 -1
View File
@@ -6,7 +6,9 @@ import {
getThemePreference,
setColorSchemePreference,
setOwmApiKey,
setThemePreference
setThemePreference,
getAiAuthorized,
setAiAuthorized
} from './userPreferences.js'
const USER_ID = 'test-user-123'
@@ -58,4 +60,13 @@ describe('userPreferences', () => {
expect(getThemePreference(USER_ID)).toBe('ocean')
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)
})
})
+17
View File
@@ -89,3 +89,20 @@ export function setOwmApiKey(userId: string, value: string): void {
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))
}
+57 -2
View File
@@ -1,7 +1,7 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { encryptJson, decryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
@@ -18,6 +18,7 @@ export async function saveEntryVoiceMemo(options: {
mimeType: string
durationSec: number
caption?: string
transcribed?: boolean
analyticsContext?: string
}): Promise<string> {
const {
@@ -27,6 +28,7 @@ export async function saveEntryVoiceMemo(options: {
mimeType,
durationSec,
caption = '',
transcribed = true,
analyticsContext = 'logbook'
} = options
const masterKey = await getEncryptionKey(logbookId)
@@ -35,7 +37,8 @@ export async function saveEntryVoiceMemo(options: {
audio: audioDataUrl,
mimeType,
durationSec,
caption: caption.trim()
caption: caption.trim(),
transcribed: !!transcribed
}
const encrypted = await encryptJson(voicePayload, masterKey)
@@ -98,3 +101,55 @@ export async function removeLastVoiceMemoForEntry(
await deleteEntryVoiceMemo(logbookId, 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))
}
+47
View File
@@ -69,4 +69,51 @@ describe('fetchOpenWeatherCurrent', () => {
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()
})
})
+15 -3
View File
@@ -7,9 +7,12 @@ import {
} from './analytics.js'
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)
this.name = 'WeatherApiError'
this.code = code
@@ -38,7 +41,7 @@ export async function fetchOpenWeatherCurrent(
} else if (params.q?.trim()) {
searchParams.set('q', params.q.trim())
} 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()
@@ -65,6 +68,15 @@ export async function fetchOpenWeatherCurrent(
if (res.status === 503) {
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()
if (!res.ok) {
+29
View File
@@ -33,8 +33,36 @@ function versionJsonPlugin(version: string): Plugin {
}
}
function readPlausibleConfig(): { plausibleEnabled: boolean; plausibleHost: string } {
const host = (process.env.PLAUSIBLE_HOST || 'https://plausible.elpatron.me').replace(/\/$/, '')
const flag = (process.env.PLAUSIBLE_ENABLED ?? 'true').trim().toLowerCase()
const plausibleEnabled = !['false', '0', 'no'].includes(flag)
return { plausibleEnabled, plausibleHost: host }
}
function runtimeConfigPlugin(): Plugin {
return {
name: 'runtime-config',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url !== '/runtime-config.json') return next()
res.setHeader('Content-Type', 'application/json')
res.end(`${JSON.stringify(readPlausibleConfig())}\n`)
})
},
writeBundle(options) {
const outDir = options.dir ?? resolve(__dirname, 'dist')
writeFileSync(
resolve(outDir, 'runtime-config.json'),
`${JSON.stringify(readPlausibleConfig(), null, 2)}\n`
)
}
}
}
// https://vite.dev/config/
export default defineConfig({
envDir: resolve(__dirname, '..'),
test: {
environment: 'happy-dom',
include: ['src/**/*.test.ts']
@@ -59,6 +87,7 @@ export default defineConfig({
plugins: [
react(),
versionJsonPlugin(readAppVersion()),
runtimeConfigPlugin(),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
+71
View File
@@ -0,0 +1,71 @@
services:
db:
image: postgres:16-alpine
container_name: daagbox-staging-db
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: ${POSTGRES_DB:-daagbox_staging}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
context: ./server
dockerfile: Dockerfile
container_name: daagbox-staging-backend
restart: always
environment:
PORT: 5000
DATABASE_URL: "postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-daagbox_staging}?schema=public"
RP_ID: ${RP_ID:-localhost}
ORIGIN: ${ORIGIN:-http://localhost}
TRUST_PROXY: ${TRUST_PROXY:-1}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
SESSION_SECRET: ${SESSION_SECRET:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
NTFY_TOPIC: ${NTFY_TOPIC:-}
NTFY_TOKEN: ${NTFY_TOKEN:-}
command: sh -c "npx prisma db push && node dist/index.js"
healthcheck:
test: ["CMD", "node", "-e", "const http = require('http'); http.get('http://localhost:5000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
interval: 15s
timeout: 5s
start_period: 60s
retries: 5
depends_on:
db:
condition: service_healthy
frontend:
build:
context: .
dockerfile: client/Dockerfile
args:
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
container_name: daagbox-staging-frontend
restart: always
environment:
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-false}
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
ports:
- "80:80"
depends_on:
backend:
condition: service_healthy
volumes:
pgdata:
name: daagbox-staging-pgdata
+10
View File
@@ -35,10 +35,17 @@ services:
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
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
@@ -51,6 +58,9 @@ services:
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
container_name: daagbox-prod-frontend
restart: always
environment:
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-true}
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
ports:
- "80:80"
depends_on:
+111
View File
@@ -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` |
+13 -6
View File
@@ -1,14 +1,14 @@
# 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
| Einstellung | Wert |
|-------------|------|
| Domain | `kapteins-daagbok.eu` |
| Domain | `kapteins-daagbok.eu` / `staging.kapteins-daagbok.eu` |
| 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) |
| Websockets | an, falls genutzt |
| Block Common Exploits | an |
@@ -40,13 +40,20 @@ TRUST_PROXY=1
## Security-Header
- **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
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
+1 -1
View File
@@ -8,7 +8,7 @@
## 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:
```bash
cd /opt/kapteins-daagbok
+3 -3
View File
@@ -31,12 +31,12 @@ cd server && npm test
## 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
./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)).
+106
View File
@@ -0,0 +1,106 @@
# Staging-Umgebung
Staging läuft auf **VM3** (`10.0.0.27`) unter **https://staging.kapteins-daagbok.eu/** — hinter Nginx Proxy Manager wie Produktion.
## Unterschiede zu Produktion
| | Staging | Produktion |
|---|---------|------------|
| Host | `10.0.0.27` | `10.0.0.25` |
| Verzeichnis | `/opt/kapteins-daagbok-staging` | `/opt/kapteins-daagbok` |
| Compose | `docker-compose.staging.yml` | `docker-compose.yml` |
| Deploy-Skript | `./scripts/update-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
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

After

Width:  |  Height:  |  Size: 359 KiB

+13 -2
View File
@@ -1,12 +1,21 @@
# 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.ä.).
## 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)
## 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`) | — |
| 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 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) |
| 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) |
@@ -152,6 +162,7 @@ trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
trackPlausibleEvent(PlausibleEvents.PHOTO_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.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
+255
View File
@@ -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"
+11
View File
@@ -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
+345
View File
@@ -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})."
+1 -1
View File
@@ -81,7 +81,7 @@ require_node_toolchain() {
echo ""
echo "On the production host, prefer updating the running stack:"
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
}
-254
View File
@@ -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
+432
View File
@@ -0,0 +1,432 @@
#!/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}"
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 origin..."
git fetch --tags origin
if [ $? -ne 0 ]; then
echo "Error: git fetch origin failed." >&2
exit 1
fi
if ! git rev-parse --verify "origin/${branch}" >/dev/null 2>&1; then
echo "Error: origin/${branch} does not exist." >&2
exit 1
fi
local_sha="$(git rev-parse HEAD)"
origin_sha="$(git rev-parse "origin/${branch}")"
if [ "$local_sha" = "$origin_sha" ]; then
echo "Local branch '$branch' matches origin/${branch} ($(git rev-parse --short HEAD))."
return 0
fi
echo "Error: Local '$branch' is not in sync with origin/${branch}." >&2
echo " local: $(git rev-parse --short HEAD) $(git log -1 --format='%s' HEAD)" >&2
echo " origin: $(git rev-parse --short "origin/${branch}") $(git log -1 --format='%s' "origin/${branch}")" >&2
if git merge-base --is-ancestor "$local_sha" "origin/${branch}" 2>/dev/null; then
echo "Hint: run 'git pull' to fast-forward." >&2
elif git merge-base --is-ancestor "origin/${branch}" "$local_sha" 2>/dev/null; then
echo "Hint: run 'git push origin ${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 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"
}
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" <<'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:-}"
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
echo "Warning: Local changes on deployment host will be discarded."
fi
if [[ "$DEST" == "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
+5 -4
View File
@@ -52,10 +52,11 @@ model UserNotificationPrefs {
}
model UserAppearancePrefs {
userId String @id
theme String @default("auto")
colorScheme String @default("auto")
updatedAt DateTime @updatedAt
userId String @id
theme String @default("auto")
colorScheme String @default("auto")
aiAuthorized Boolean @default(false)
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
+8
View File
@@ -59,4 +59,12 @@ describe('API smoke', () => {
expect(res.status).toBe(401)
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)
})
})
+76 -3
View File
@@ -23,6 +23,11 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
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,
@@ -31,7 +36,8 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
totalGpsTracks,
totalCollaborations,
totalInvitations,
aiSummaryEntries
aiSummaryEntries,
dbSize
})
} catch (error: unknown) {
console.error('admin/summary error', error)
@@ -91,7 +97,7 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
const since = new Date()
since.setUTCDate(since.getUTCDate() - windowDays)
const [users, logbooks, photos] = await Promise.all([
const [users, logbooks, photos, dbSizeRaw, photosSize, voiceSize, tracksSize, entriesSize] = await Promise.all([
prisma.user.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true }
@@ -103,9 +109,72 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
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) {
@@ -130,7 +199,11 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
aggregate(
photos.map((p) => p.updatedAt),
'photos_updated'
)
),
{
metric: 'database_size',
points: dbSizePoints
}
]
}
+74 -1
View File
@@ -3,7 +3,6 @@ import { prisma } from '../db.js'
import { requireUser } from '../middleware/auth.js'
const router = Router()
const MAX_ATTEMPTS_PER_ENTRY = 3
const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku'
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
+6
View File
@@ -63,6 +63,7 @@ function isMissingAppearancePrefsTable(error: unknown): boolean {
const DEFAULT_APPEARANCE_PREFS = {
theme: 'auto',
colorScheme: 'auto',
aiAuthorized: false,
persisted: false
} as const
@@ -454,6 +455,7 @@ router.get('/appearance-prefs', requireUser, async (req: any, res) => {
return res.json({
theme: prefs?.theme ?? 'auto',
colorScheme: prefs?.colorScheme ?? 'auto',
aiAuthorized: prefs?.aiAuthorized ?? false,
persisted: prefs != null
})
} catch (error: unknown) {
@@ -469,6 +471,7 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
try {
const theme = parseThemePreference(req.body?.theme)
const colorScheme = parseColorSchemePreference(req.body?.colorScheme)
const aiAuthorized = req.body?.aiAuthorized === true
if (!theme || !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,
theme,
colorScheme,
aiAuthorized,
updatedAt: new Date()
},
update: {
theme,
colorScheme,
aiAuthorized,
updatedAt: new Date()
}
})
@@ -491,6 +496,7 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
return res.json({
theme: prefs.theme,
colorScheme: prefs.colorScheme,
aiAuthorized: prefs.aiAuthorized,
persisted: true
})
} catch (error: unknown) {