Compare commits

...

6 Commits

Author SHA1 Message Date
elpatron 8bf89ed898 chore: release v0.1.0.34 2026-05-30 13:17:53 +02:00
elpatron adf8ee9929 fix(feedback): Ntfy in Docker, ASCII-Titel und Skipper-Badge
NTFY_* an den Backend-Container durchreichen; En-Dash im Ntfy-Header durch ASCII-Strich ersetzen (ByteString-Fehler). Skipper-Badge klar als Account-Anzeige kennzeichnen; start-dev.sh prüft npm vor dem Start.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:16:59 +02:00
elpatron 1055a12dad docs(marketing): Beta-Flyer (DIN A4) mit Playwright-Generierung
Skript und npm-Script zum Erzeugen des druckbaren Beta-Flyers als PDF.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 12:58:28 +02:00
elpatron f1f90da069 feat(feedback): Feedback-Formular mit Ntfy-Versand
Nutzer können Feedback aus dem Header senden; der Server leitet Nachrichten über Ntfy weiter (NTFY_* in .env).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 12:58:25 +02:00
elpatron 4541c81d3b chore: release v0.1.0.33 2026-05-30 12:39:29 +02:00
elpatron 03bb55f9a1 feat(weather): OWM-Fallback über Server-.env wenn kein User-Key
Wetter-Proxy auf /api/weather/current nutzt optionalen Nutzer-Key aus
den Einstellungen, sonst OpenWeatherMapAPIKey aus der Umgebung.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 12:37:58 +02:00
25 changed files with 1486 additions and 61 deletions
+7 -1
View File
@@ -10,4 +10,10 @@ ORIGIN=http://localhost
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
# Feedback via Ntfy (https://ntfy.sh or self-hosted)
# NTFY_TOPIC: topic name only (not the full URL)
NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=kapteins-daagbok-feedback
NTFY_TOKEN=tk_example_ntfy_access_token
+4 -2
View File
@@ -116,7 +116,7 @@ kapteins-daagbok/
- **Node.js** 20+
- **npm**
- **Docker** (für PostgreSQL in der Entwicklung oder den vollständigen Stack)
- Optional: OpenWeatherMap-API-Key (Wetter-Abruf in den Einstellungen)
- Optional: eigener OpenWeatherMap-API-Key in den Einstellungen (sonst serverseitiger Key aus `.env`)
- Optional: VAPID-Schlüssel für Web Push (siehe Abschnitt Push-Benachrichtigungen)
## Lokale Entwicklung
@@ -136,10 +136,11 @@ cp .env.example .env
Für lokale Passkeys: `RP_ID=localhost`, `ORIGIN=http://localhost:5173` (bzw. die tatsächliche Frontend-URL).
Im `server/`-Verzeichnis eine `.env` mit `DATABASE_URL` anlegen, z. B.:
Im `server/`-Verzeichnis eine `.env` mit `DATABASE_URL` anlegen — oder den Key in der **Projekt-`.env`** (`OpenWeatherMapAPIKey=...`); das Backend lädt beide Dateien.
```
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/daagbox?schema=public"
OpenWeatherMapAPIKey= # Fallback für Wetter-Abruf, wenn Nutzer keinen eigenen Key hat
RP_ID=localhost
ORIGIN=http://localhost:5173
# Optional — Web Push (npx web-push generate-vapid-keys)
@@ -198,6 +199,7 @@ Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABA
|----------|--------|
| [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/marketing/kapteins-daagbok-beta-flyer.pdf](docs/marketing/kapteins-daagbok-beta-flyer.pdf) | Beta-Flyer (DIN A4) zum Ausdrucken — Quelle: `docs/marketing/beta-flyer.html`, neu erzeugen: `cd client && npm run generate:flyer` |
| [.planning/PROJECT.md](.planning/PROJECT.md) | Produktvision und Anforderungen (GSD) |
## Analytics
+1 -1
View File
@@ -1 +1 @@
0.1.0.33
0.1.0.35
+373
View File
@@ -32,6 +32,8 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"playwright": "^1.51.0",
"qrcode": "^1.5.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
@@ -3109,6 +3111,32 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/array-buffer-byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
@@ -3379,6 +3407,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001793",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
@@ -3420,6 +3458,38 @@
"node": ">=10.0.0"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@@ -3584,6 +3654,16 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3663,6 +3743,13 @@
"react": ">=16"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"dev": true,
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
@@ -3711,6 +3798,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/es-abstract": {
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@@ -4385,6 +4479,16 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -4907,6 +5011,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -5861,6 +5975,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -5955,6 +6079,63 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.0.tgz",
"integrity": "sha512-442pTfGM0xxfCYxuBa/Pu6B2OqxqqaYq39JS8QDMGThUvIOCd6s0ANDog3uwA0cHavVlnTQzGCN7Id2YekDSXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.51.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.0.tgz",
"integrity": "sha512-x47yPE3Zwhlil7wlNU/iktF7t2r/URR3VLbH6EknJd/04Qc/PSJ0EY3CMXipmglLG+zyRxW6HNo2EGbKLHPWMg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -6027,6 +6208,24 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
@@ -6194,6 +6393,16 @@
"regjsparser": "bin/parser"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -6204,6 +6413,13 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true,
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -6403,6 +6619,13 @@
"node": ">=20.0.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"dev": true,
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -6643,6 +6866,21 @@
"node": ">= 0.4"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@@ -6745,6 +6983,19 @@
"node": ">=4"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz",
@@ -7410,6 +7661,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"dev": true,
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz",
@@ -7662,6 +7920,28 @@
"workbox-core": "7.4.1"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"dev": true,
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -7669,6 +7949,99 @@
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+5 -1
View File
@@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"generate:flyer": "node ../scripts/generate-beta-flyer.mjs",
"generate:flyer:setup": "playwright install chromium"
},
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
@@ -34,6 +36,8 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"playwright": "^1.51.0",
"qrcode": "^1.5.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
+65 -3
View File
@@ -388,6 +388,59 @@ html.scheme-dark .themed-select-option.is-selected {
max-height: min(90vh, 820px);
}
.feedback-modal .auth-actions {
margin-top: 0;
}
.feedback-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.feedback-form__field {
display: flex;
flex-direction: column;
gap: 6px;
text-align: left;
}
.feedback-form__field > span {
font-size: 13px;
font-weight: 600;
color: var(--app-text-heading, #f1f5f9);
}
.feedback-form__field select,
.feedback-form__field textarea {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--app-input-border, rgba(148, 163, 184, 0.25));
background: var(--app-input-bg, rgba(15, 23, 42, 0.6));
color: var(--app-text, #e2e8f0);
font: inherit;
resize: vertical;
}
.feedback-form__field select:focus,
.feedback-form__field textarea:focus {
outline: none;
border-color: var(--app-accent, #38bdf8);
}
.feedback-form__actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.feedback-form__actions .btn {
width: auto;
min-width: 100px;
margin: 0;
}
.registration-disclaimer__intro {
margin: 0 0 16px;
font-size: 14px;
@@ -619,9 +672,18 @@ html.scheme-dark .themed-select-option.is-selected {
font-size: 13px;
padding: 6px 12px;
border-radius: 20px;
background: var(--app-btn-secondary-bg);
border: 1px solid var(--app-btn-secondary-border);
color: var(--app-btn-secondary-text);
background: rgba(148, 163, 184, 0.08);
border: 1px solid rgba(148, 163, 184, 0.18);
color: var(--app-text-muted);
cursor: default;
user-select: none;
}
.skipper-badge__name {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-icon {
+6
View File
@@ -34,6 +34,7 @@ import type { LogbookAccessRole } from './services/logbook.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import { useTranslation } from 'react-i18next'
import {
getStoredDemoFirstEntryId,
@@ -439,6 +440,11 @@ function App() {
<DisclaimerHeaderButton />
<FeedbackHeaderButton
logbookId={activeLogbookId}
logbookTitle={activeLogbookTitle}
/>
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
<LogOut size={18} />
</button>
@@ -0,0 +1,37 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MessageSquarePlus } from 'lucide-react'
import FeedbackModal from './FeedbackModal.tsx'
interface FeedbackHeaderButtonProps {
logbookId?: string | null
logbookTitle?: string | null
}
export default function FeedbackHeaderButton({
logbookId,
logbookTitle
}: FeedbackHeaderButtonProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<>
<button
type="button"
className="btn-icon"
onClick={() => setOpen(true)}
title={t('feedback.button_title')}
aria-label={t('feedback.button_title')}
>
<MessageSquarePlus size={18} />
</button>
<FeedbackModal
open={open}
onClose={() => setOpen(false)}
logbookId={logbookId}
logbookTitle={logbookTitle}
/>
</>
)
}
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MessageSquarePlus, X } from 'lucide-react'
import { FeedbackApiError, sendFeedback, type FeedbackCategory } from '../services/feedback.js'
import { useDialog } from './ModalDialog.tsx'
interface FeedbackModalProps {
open: boolean
onClose: () => void
logbookId?: string | null
logbookTitle?: string | null
}
export default function FeedbackModal({
open,
onClose,
logbookId,
logbookTitle
}: FeedbackModalProps) {
const { t } = useTranslation()
const { showAlert } = useDialog()
const [category, setCategory] = useState<FeedbackCategory>('general')
const [message, setMessage] = useState('')
const [sending, setSending] = useState(false)
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !sending) onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [open, onClose, sending])
useEffect(() => {
if (!open) {
setCategory('general')
setMessage('')
setSending(false)
}
}, [open])
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (!message.trim() || sending) return
setSending(true)
try {
await sendFeedback({
category,
message: message.trim(),
logbookId,
logbookTitle
})
await showAlert(t('feedback.success'), t('feedback.title'))
onClose()
} catch (error) {
const msg =
error instanceof FeedbackApiError && error.code === 'NOT_CONFIGURED'
? t('feedback.error_not_configured')
: t('feedback.error_send')
await showAlert(msg, t('feedback.title'))
} finally {
setSending(false)
}
}
if (!open) return null
return (
<div className="disclaimer-modal-overlay" onClick={sending ? undefined : onClose}>
<div className="disclaimer-modal-panel" onClick={(event) => event.stopPropagation()}>
<div className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal">
<div className="auth-header">
<MessageSquarePlus className="auth-icon accent" size={48} />
<h2>{t('feedback.title')}</h2>
<button
type="button"
className="registration-disclaimer__close"
onClick={onClose}
disabled={sending}
aria-label={t('feedback.cancel')}
>
<X size={18} />
</button>
</div>
<p className="registration-disclaimer__intro">{t('feedback.intro')}</p>
<form className="feedback-form" onSubmit={handleSubmit}>
<label className="feedback-form__field">
<span>{t('feedback.category_label')}</span>
<select
value={category}
onChange={(event) => setCategory(event.target.value as FeedbackCategory)}
disabled={sending}
>
<option value="general">{t('feedback.category_general')}</option>
<option value="bug">{t('feedback.category_bug')}</option>
<option value="feature">{t('feedback.category_feature')}</option>
</select>
</label>
<label className="feedback-form__field">
<span>{t('feedback.message_label')}</span>
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder={t('feedback.message_placeholder')}
rows={6}
maxLength={2000}
required
disabled={sending}
/>
</label>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={onClose} disabled={sending}>
{t('feedback.cancel')}
</button>
<button type="submit" className="btn primary" disabled={sending || !message.trim()}>
{sending ? t('feedback.sending') : t('feedback.send')}
</button>
</div>
</form>
</div>
</div>
</div>
)
}
+33 -41
View File
@@ -25,6 +25,7 @@ import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import {
getDecryptedTrack,
saveUploadedTrack,
@@ -640,24 +641,19 @@ export default function LogEntryEditor({
return
}
const apiKey = localStorage.getItem('owm_api_key')
if (!apiKey) {
showAlert('GPS capturing failed, and no OpenWeatherMap API key is configured to perform location lookup.')
return
}
try {
const res = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(locationQuery)}&appid=${apiKey}&units=metric`
)
if (!res.ok) throw new Error('Location not found')
const data = await res.json()
if (data.coord) {
setEvGpsLat(Number(data.coord.lat).toFixed(6))
setEvGpsLng(Number(data.coord.lon).toFixed(6))
const data = await fetchOpenWeatherCurrent({ q: locationQuery })
const coord = data.coord as { lat?: number; lon?: number } | undefined
if (coord?.lat !== undefined && coord?.lon !== undefined) {
setEvGpsLat(Number(coord.lat).toFixed(6))
setEvGpsLng(Number(coord.lon).toFixed(6))
showAlert(`Coordinates loaded for "${locationQuery}" via OpenWeatherMap.`)
}
} catch (e) {
if (e instanceof WeatherApiError && e.code === 'NO_KEY') {
showAlert(t('settings.no_key'))
return
}
showAlert('Failed to retrieve GPS location or look up coordinates by location name.')
}
}
@@ -696,35 +692,26 @@ export default function LogEntryEditor({
return
}
const apiKey = localStorage.getItem('owm_api_key')
if (!apiKey) {
showAlert(t('settings.no_key'))
return
}
setWeatherLoading(true)
try {
let url = ''
if (hasGps) {
url = `https://api.openweathermap.org/data/2.5/weather?lat=${evGpsLat}&lon=${evGpsLng}&appid=${apiKey}&units=metric`
} else {
url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(fallbackLocation)}&appid=${apiKey}&units=metric`
}
const res = await fetch(url)
if (!res.ok) throw new Error('Weather API rejected the request')
const data = await res.json()
const data = await fetchOpenWeatherCurrent(
hasGps
? { lat: evGpsLat, lon: evGpsLng }
: { q: fallbackLocation }
)
const coord = data.coord as { lat?: number; lon?: number } | undefined
// If fetched by location, automatically pre-fill GPS coordinates
if (!hasGps && data.coord) {
setEvGpsLat(Number(data.coord.lat).toFixed(6))
setEvGpsLng(Number(data.coord.lon).toFixed(6))
if (!hasGps && coord?.lat !== undefined && coord?.lon !== undefined) {
setEvGpsLat(Number(coord.lat).toFixed(6))
setEvGpsLng(Number(coord.lon).toFixed(6))
}
const wind = data.wind as { speed?: number; deg?: number } | undefined
const main = data.main as { pressure?: number } | undefined
// Convert wind speed m/s to Beaufort scale
const mps = data.wind.speed || 0
const mps = wind?.speed || 0
let bft = 0
if (mps < 0.3) bft = 0
else if (mps < 1.6) bft = 1
@@ -741,22 +728,27 @@ export default function LogEntryEditor({
else bft = 12
setEvWindStrength(`${bft} Bft (${mps.toFixed(1)} m/s)`)
setEvWindPressure(String(data.main.pressure || ''))
setEvWindPressure(String(main?.pressure || ''))
// Calculate wind compass direction sector
if (data.wind.deg !== undefined) {
const deg = data.wind.deg
if (wind?.deg !== undefined) {
const deg = wind.deg
const sectors = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
const index = Math.round(deg / 22.5) % 16
setEvWindDirection(sectors[index])
}
if (data.weather && data.weather[0]) {
setEvWeatherIcon(data.weather[0].icon)
if (data.weather && Array.isArray(data.weather) && data.weather[0]) {
const first = data.weather[0] as { icon?: string }
if (first.icon) setEvWeatherIcon(first.icon)
}
showAlert(t('settings.weather_success'))
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
showAlert(t('settings.no_key'))
return
}
console.error('Weather prefilling failed:', err)
showAlert(t('settings.weather_error'))
} finally {
+10 -3
View File
@@ -10,6 +10,7 @@ import { useDialog } from './ModalDialog.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void
@@ -205,9 +206,13 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
</div>
{/* Skipper profile */}
<div className="skipper-badge">
<User size={16} />
<span>{username}</span>
<div
className="skipper-badge"
title={t('dashboard.logged_in_as', { name: username })}
aria-label={t('dashboard.logged_in_as', { name: username })}
>
<User size={16} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span>
</div>
{/* Lang toggle */}
@@ -217,6 +222,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
<DisclaimerHeaderButton />
<FeedbackHeaderButton />
{/* Logout */}
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
<LogOut size={18} />
+20 -2
View File
@@ -243,6 +243,7 @@
"create_btn": "Logbuch erstellen",
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
"logout": "Abmelden",
"logged_in_as": "Angemeldet als {{name}}",
"delete_confirm": "Sind Sie sicher, dass Sie dieses Logbuch unwiderruflich löschen möchten? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstellen Sie vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls Sie die Daten später behalten möchten.",
"no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr erstes Logbuch, um zu beginnen!",
"loading": "Logbücher werden geladen...",
@@ -300,8 +301,8 @@
"save": "Konfiguration speichern",
"saving": "Wird gespeichert...",
"saved": "Einstellungen erfolgreich gespeichert!",
"key_help": "Ein API-Schlüssel wird benötigt, um Wetterparameter und Seebedingungen automatisch anhand von GPS-Koordinaten abzurufen.",
"no_key": "Bitte hinterlegen Sie Ihren OpenWeatherMap API-Schlüssel in den Einstellungen, um Wetterdaten abzurufen.",
"key_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlegen Sie einen eigenen Schlüssel in den Einstellungen oder kontaktieren Sie den Betreiber.",
"weather_success": "Wetterdaten erfolgreich abgerufen!",
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfen Sie den API-Schlüssel und die Verbindung.",
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
@@ -397,6 +398,23 @@
"close": "Schließen",
"button_title": "Hinweise & Haftungsausschluss"
},
"feedback": {
"button_title": "Feedback senden",
"title": "Feedback",
"intro": "Teilen Sie Fehler, Ideen oder allgemeines Feedback. Ihre Nachricht wird über einen sicheren Benachrichtigungskanal an das Projektteam gesendet.",
"category_label": "Kategorie",
"category_general": "Allgemein",
"category_bug": "Fehler melden",
"category_feature": "Feature-Wunsch",
"message_label": "Nachricht",
"message_placeholder": "Beschreiben Sie Ihr Feedback…",
"send": "Senden",
"sending": "Wird gesendet…",
"cancel": "Abbrechen",
"success": "Vielen Dank! Ihr Feedback wurde gesendet.",
"error_send": "Feedback konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.",
"error_not_configured": "Feedback ist auf diesem Server nicht verfügbar."
},
"demo": {
"logbook_title": "Demo-Logbuch Ostsee",
"badge": "Demo",
+20 -2
View File
@@ -243,6 +243,7 @@
"create_btn": "Create Logbook",
"new_logbook_placeholder": "Logbook or Yacht Name",
"logout": "Logout",
"logged_in_as": "Signed in as {{name}}",
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok.json) if you may need the data later.",
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
"loading": "Loading logbooks...",
@@ -300,8 +301,8 @@
"save": "Save Configuration",
"saving": "Saving...",
"saved": "Settings saved successfully!",
"key_help": "An API key is required to automatically fetch real-time weather and sea state parameters based on your vessel's GPS coordinates.",
"no_key": "Please set your OpenWeatherMap API Key in settings to enable weather auto-fill.",
"key_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
"no_key": "No OpenWeatherMap API key available. Add your own key in settings or contact the operator.",
"weather_success": "Weather details fetched successfully!",
"weather_error": "Failed to fetch weather. Check your API key and connection.",
"weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.",
@@ -397,6 +398,23 @@
"close": "Close",
"button_title": "Legal notice & disclaimer"
},
"feedback": {
"button_title": "Send feedback",
"title": "Feedback",
"intro": "Share bugs, ideas or general feedback. Your message is sent to the project team via a secure notification channel.",
"category_label": "Category",
"category_general": "General",
"category_bug": "Bug report",
"category_feature": "Feature request",
"message_label": "Message",
"message_placeholder": "Describe your feedback…",
"send": "Send",
"sending": "Sending…",
"cancel": "Cancel",
"success": "Thank you! Your feedback has been sent.",
"error_send": "Could not send feedback. Please try again later.",
"error_not_configured": "Feedback is not available on this server."
},
"demo": {
"logbook_title": "Baltic Sea Demo Logbook",
"badge": "Demo",
+50
View File
@@ -0,0 +1,50 @@
export type FeedbackCategory = 'bug' | 'feature' | 'general'
export class FeedbackApiError extends Error {
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED'
constructor(message: string, code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
super(message)
this.name = 'FeedbackApiError'
this.code = code
}
}
function buildFeedbackHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
const userId = localStorage.getItem('active_userid')
if (userId) headers['X-User-Id'] = userId
return headers
}
export async function sendFeedback(payload: {
category: FeedbackCategory
message: string
logbookId?: string | null
logbookTitle?: string | null
}): Promise<void> {
const res = await fetch('/api/feedback', {
method: 'POST',
headers: buildFeedbackHeaders(),
body: JSON.stringify({
category: payload.category,
message: payload.message,
username: localStorage.getItem('active_username') || undefined,
logbookId: payload.logbookId || undefined,
logbookTitle: payload.logbookTitle || undefined,
appVersion: typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined,
pageUrl: window.location.href
})
})
if (res.status === 503) {
throw new FeedbackApiError('Feedback is not configured on this server', 'NOT_CONFIGURED')
}
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new FeedbackApiError(data.error || 'Failed to send feedback')
}
}
+52
View File
@@ -0,0 +1,52 @@
export class WeatherApiError extends Error {
code: 'NO_KEY' | 'REQUEST_FAILED'
constructor(message: string, code: 'NO_KEY' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
super(message)
this.name = 'WeatherApiError'
this.code = code
}
}
function buildWeatherHeaders(): Record<string, string> {
const headers: Record<string, string> = {}
const userId = localStorage.getItem('active_userid')
const userKey = localStorage.getItem('owm_api_key')?.trim()
if (userId) headers['X-User-Id'] = userId
if (userKey) headers['X-OWM-Api-Key'] = userKey
return headers
}
export async function fetchOpenWeatherCurrent(params: {
lat?: string
lon?: string
q?: string
}): Promise<Record<string, unknown>> {
const searchParams = new URLSearchParams()
if (params.lat && params.lon) {
searchParams.set('lat', params.lat)
searchParams.set('lon', params.lon)
} else if (params.q?.trim()) {
searchParams.set('q', params.q.trim())
} else {
throw new WeatherApiError('lat/lon or location query required')
}
const res = await fetch(`/api/weather/current?${searchParams.toString()}`, {
headers: buildWeatherHeaders()
})
if (res.status === 503) {
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
}
const data = await res.json()
if (!res.ok) {
throw new WeatherApiError('Weather API rejected the request')
}
return data
}
+4
View File
@@ -29,6 +29,10 @@ services:
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
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"
depends_on:
db:
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+290
View File
@@ -0,0 +1,290 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Kapteins Daagbok — Beta-Flyer</title>
<style>
@page {
size: A4 portrait;
margin: 0;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
width: 210mm;
height: 297mm;
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
color: #e2e8f0;
background: #0f172a;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.page {
width: 210mm;
height: 297mm;
padding: 14mm 16mm 12mm;
display: flex;
flex-direction: column;
background:
radial-gradient(ellipse 120% 80% at 100% 0%, rgba(56, 189, 248, 0.12) 0%, transparent 55%),
radial-gradient(ellipse 90% 60% at 0% 100%, rgba(134, 59, 255, 0.14) 0%, transparent 50%),
linear-gradient(165deg, #0f172a 0%, #1e293b 45%, #0f172a 100%);
position: relative;
overflow: hidden;
}
.page::before {
content: "";
position: absolute;
inset: 8mm;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 4mm;
pointer-events: none;
}
header {
display: flex;
align-items: center;
gap: 5mm;
margin-bottom: 6mm;
position: relative;
z-index: 1;
}
.logo {
width: 14mm;
height: 14mm;
flex-shrink: 0;
}
.title-block h1 {
font-size: 22pt;
font-weight: 700;
letter-spacing: -0.02em;
color: #f8fafc;
line-height: 1.1;
}
.title-block p {
font-size: 10.5pt;
color: #94a3b8;
margin-top: 1.5mm;
}
.badge {
margin-left: auto;
align-self: flex-start;
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #1e293b;
font-size: 9pt;
font-weight: 800;
letter-spacing: 0.12em;
padding: 2mm 4mm;
border-radius: 2mm;
text-transform: uppercase;
}
.intro {
font-size: 10.5pt;
line-height: 1.55;
color: #cbd5e1;
margin-bottom: 6mm;
max-width: 95%;
position: relative;
z-index: 1;
}
.intro strong {
color: #f8fafc;
}
.features {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3mm 6mm;
margin-bottom: 6mm;
position: relative;
z-index: 1;
}
.feature {
display: flex;
gap: 2.5mm;
align-items: flex-start;
font-size: 9.5pt;
line-height: 1.4;
color: #e2e8f0;
}
.feature-icon {
color: #38bdf8;
font-weight: 700;
flex-shrink: 0;
width: 4mm;
}
.beta-box {
background: rgba(30, 41, 59, 0.85);
border: 1px solid rgba(251, 191, 36, 0.35);
border-left: 3px solid #fbbf24;
border-radius: 3mm;
padding: 5mm 6mm;
margin-bottom: 6mm;
position: relative;
z-index: 1;
}
.beta-box h2 {
font-size: 11pt;
color: #fbbf24;
margin-bottom: 2mm;
font-weight: 700;
}
.beta-box p {
font-size: 9.5pt;
line-height: 1.5;
color: #cbd5e1;
}
.cta {
display: flex;
align-items: center;
gap: 8mm;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 4mm;
padding: 5mm 6mm;
margin-bottom: auto;
position: relative;
z-index: 1;
}
.qr {
width: 32mm;
height: 32mm;
background: #fff;
padding: 2mm;
border-radius: 2mm;
flex-shrink: 0;
}
.qr img {
width: 100%;
height: 100%;
display: block;
}
.cta-text h3 {
font-size: 13pt;
color: #38bdf8;
font-weight: 700;
margin-bottom: 2mm;
}
.cta-text p {
font-size: 9pt;
color: #94a3b8;
line-height: 1.45;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 2mm;
margin-top: 3mm;
}
.tag {
font-size: 7.5pt;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
border: 1px solid rgba(100, 116, 139, 0.4);
border-radius: 1.5mm;
padding: 1mm 2.5mm;
}
footer {
border-top: 1px solid rgba(148, 163, 184, 0.15);
padding-top: 3mm;
margin-top: 5mm;
font-size: 7.5pt;
line-height: 1.5;
color: #64748b;
position: relative;
z-index: 1;
}
footer strong {
color: #94a3b8;
font-weight: 600;
}
</style>
</head>
<body>
<article class="page">
<header>
<img class="logo" src="../../client/public/favicon.svg" alt="" />
<div class="title-block">
<h1>Kapteins Daagbok</h1>
<p>Digitales Yacht-Logbuch — kostenlos &amp; werbefrei</p>
</div>
<span class="badge">Beta</span>
</header>
<p class="intro">
Führen Sie Ihr Bordlogbuch digital: Reisetage, GPS-Tracks, Crew und Schiffsdaten —
<strong>End-to-End-verschlüsselt</strong>, als App installierbar und
<strong>auch offline</strong> auf See nutzbar.
</p>
<section class="features" aria-label="Funktionen">
<div class="feature"><span class="feature-icon"></span><span>Reisetage im nautischen Logbuch-Format (Hafen, Wetter, Tankstände)</span></div>
<div class="feature"><span class="feature-icon"></span><span>Offline-fähige PWA — installierbar auf Smartphone &amp; Tablet</span></div>
<div class="feature"><span class="feature-icon"></span><span>Passkey-Anmeldung &amp; clientseitige Verschlüsselung</span></div>
<div class="feature"><span class="feature-icon"></span><span>GPS-Tracks (GPX/KML), Karte &amp; Streckenstatistik</span></div>
<div class="feature"><span class="feature-icon"></span><span>Foto-Anhänge pro Reisetag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Crew einladen — gemeinsam am Logbuch arbeiten</span></div>
<div class="feature"><span class="feature-icon"></span><span>PDF- &amp; CSV-Export, verschlüsseltes Backup</span></div>
<div class="feature"><span class="feature-icon"></span><span>Mehrere Logbücher · Deutsch &amp; Englisch</span></div>
</section>
<section class="beta-box">
<h2>Beta-Phase — Ihr Feedback zählt</h2>
<p>
Kapteins Daagbok ist ein <strong>privates Hobbyprojekt ohne Gewinnabsicht</strong>.
Als Beta-Tester helfen Sie, die App für Skipper und Crew im Alltag zu verbessern —
Rückmeldungen sind ausdrücklich willkommen.
</p>
</section>
<section class="cta">
<div class="qr">
<img src="assets/qr-kapteins-daagbok.eu.png" alt="QR-Code: kapteins-daagbok.eu" />
</div>
<div class="cta-text">
<h3>kapteins-daagbok.eu</h3>
<p>Im Browser öffnen oder als App zum Home-Bildschirm hinzufügen. Registrierung mit Passkey — kein App-Store nötig.</p>
<div class="tags">
<span class="tag">Kostenlos</span>
<span class="tag">Werbefrei</span>
<span class="tag">E2E-verschlüsselt</span>
</div>
</div>
</section>
<footer>
<strong>Impressum</strong><br />
KnorrLabs · Markus F.J. Busche · Knorrstr. 16 · 24106 Kiel · elpatron+kd@mailbox.org
</footer>
</article>
</body>
</html>
Binary file not shown.
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/**
* Generates the beta flyer PDF from docs/marketing/beta-flyer.html
* Usage: npm run generate:flyer --prefix client
*/
import { execSync } from 'node:child_process'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { createRequire } from 'node:module'
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(__dirname, '..')
const clientDir = resolve(repoRoot, 'client')
const marketingDir = resolve(repoRoot, 'docs/marketing')
const assetsDir = resolve(marketingDir, 'assets')
const htmlPath = resolve(marketingDir, 'beta-flyer.html')
const qrPath = resolve(assetsDir, 'qr-kapteins-daagbok.eu.png')
const pdfPath = resolve(marketingDir, 'kapteins-daagbok-beta-flyer.pdf')
const appUrl = 'https://kapteins-daagbok.eu'
const require = createRequire(resolve(clientDir, 'package.json'))
function isMissingBrowserError(err) {
const msg = err instanceof Error ? err.message : String(err)
return msg.includes("Executable doesn't exist") || msg.includes('browserType.launch')
}
async function ensurePlaywrightChromium(playwright) {
try {
const browser = await playwright.chromium.launch({ headless: true })
await browser.close()
return
} catch (err) {
if (!isMissingBrowserError(err)) throw err
}
console.log('Playwright Chromium fehlt — installiere Browser (einmalig)…')
execSync('npx playwright install chromium', {
cwd: clientDir,
stdio: 'inherit'
})
}
async function ensureQrCode() {
let QRCode
try {
QRCode = require('qrcode')
} catch {
console.error('Fehlende Abhängigkeit: "npm install -D qrcode playwright" in client/ ausführen.')
process.exit(1)
}
await mkdir(assetsDir, { recursive: true })
const png = await QRCode.toBuffer(appUrl, {
type: 'png',
width: 512,
margin: 1,
color: { dark: '#0f172a', light: '#ffffff' }
})
await writeFile(qrPath, png)
console.log('QR code written:', qrPath)
}
async function renderPdf() {
let playwright
try {
playwright = require('playwright')
} catch {
console.error('Fehlende Abhängigkeit: "npm install -D playwright" in client/ ausführen.')
process.exit(1)
}
await ensurePlaywrightChromium(playwright)
const browser = await playwright.chromium.launch({ headless: true })
try {
const page = await browser.newPage()
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle' })
await page.pdf({
path: pdfPath,
format: 'A4',
printBackground: true,
preferCSSPageSize: true,
margin: { top: 0, right: 0, bottom: 0, left: 0 }
})
console.log('PDF written:', pdfPath)
} finally {
await browser.close()
}
}
await ensureQrCode()
await renderPdf()
+80 -4
View File
@@ -3,12 +3,66 @@
# Configuration
SERVER_PORT=5000
CLIENT_PORT=5173
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
resolve_node_toolchain() {
# Common install locations when login shell PATH is not loaded
if command -v npm >/dev/null 2>&1; then
return 0
fi
if [ -s "$HOME/.nvm/nvm.sh" ]; then
# shellcheck disable=SC1090
. "$HOME/.nvm/nvm.sh"
elif [ -s "/usr/local/nvm/nvm.sh" ]; then
# shellcheck disable=SC1090
. "/usr/local/nvm/nvm.sh"
fi
if [ -d "$HOME/.fnm" ] && command -v fnm >/dev/null 2>&1; then
eval "$(fnm env)"
fi
for candidate in \
/usr/local/bin/npm \
/usr/bin/npm \
"$HOME/.local/share/fnm/current/bin/npm" \
"$HOME/.nvm/versions/node/"*/bin/npm; do
if [ -x "$candidate" ]; then
export PATH="$(dirname "$candidate"):$PATH"
break
fi
done
command -v npm >/dev/null 2>&1
}
require_node_toolchain() {
if resolve_node_toolchain; then
echo "Using Node $(node -v), npm $(npm -v)"
return 0
fi
echo "Error: npm was not found in PATH."
echo ""
echo "This script starts local Vite/Express dev servers and requires Node.js 20+ with npm."
echo "Install Node.js on this machine, or use the Docker-based stack instead:"
echo " ./scripts/start-dev-docker.sh"
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"
exit 1
}
echo "========================================"
echo " Kapteins Daagbok Dev Environment "
echo "========================================"
echo "Preparing to (re)start services..."
require_node_toolchain
# Clean up processes running on ports
cleanup_port() {
local port=$1
@@ -77,18 +131,40 @@ fi
# Start backend server
echo "Starting backend API server..."
cd server
cd "$REPO_ROOT/server" || exit 1
if [ ! -d node_modules ]; then
echo "Error: server/node_modules missing. Run: cd server && npm ci"
exit 1
fi
npm run dev &
cd ..
BACKEND_PID=$!
cd "$REPO_ROOT" || exit 1
# Sleep briefly to let server start up
sleep 1.5
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
echo "Error: Backend dev server exited immediately. Check server logs above."
exit 1
fi
# Start frontend client
echo "Starting frontend dev server..."
cd client
cd "$REPO_ROOT/client" || exit 1
if [ ! -d node_modules ]; then
echo "Error: client/node_modules missing. Run: cd client && npm ci"
kill "$BACKEND_PID" 2>/dev/null
exit 1
fi
npm run dev &
cd ..
CLIENT_PID=$!
cd "$REPO_ROOT" || exit 1
sleep 1.5
if ! kill -0 "$CLIENT_PID" 2>/dev/null; then
echo "Error: Frontend dev server exited immediately. Check client logs above."
kill "$BACKEND_PID" 2>/dev/null
exit 1
fi
echo "========================================"
echo "Dev services are now running:"
+10 -1
View File
@@ -1,15 +1,22 @@
import express from 'express'
import cors from 'cors'
import dotenv from 'dotenv'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import authRouter from './routes/auth.js'
import logbooksRouter from './routes/logbooks.js'
import syncRouter from './routes/sync.js'
import collaborationRouter from './routes/collaboration.js'
import signRouter from './routes/sign.js'
import pushRouter from './routes/push.js'
import weatherRouter from './routes/weather.js'
import feedbackRouter from './routes/feedback.js'
import { prisma } from './db.js'
dotenv.config()
const __dirname = dirname(fileURLToPath(import.meta.url))
dotenv.config({ path: resolve(__dirname, '../../.env') })
dotenv.config({ path: resolve(__dirname, '../.env') })
const app = express()
const PORT = process.env.PORT || 5000
@@ -24,6 +31,8 @@ app.use('/api/sync', syncRouter)
app.use('/api/collaboration', collaborationRouter)
app.use('/api/sign', signRouter)
app.use('/api/push', pushRouter)
app.use('/api/weather', weatherRouter)
app.use('/api/feedback', feedbackRouter)
// Health check endpoint
app.get('/api/health', async (req, res) => {
+61
View File
@@ -0,0 +1,61 @@
import { Router } from 'express'
import { isNtfyConfigured, sendFeedbackViaNtfy } from '../services/ntfyNotify.js'
const router = Router()
const VALID_CATEGORIES = new Set(['bug', 'feature', 'general'])
const MAX_MESSAGE_LENGTH = 2000
const requireUser = (req: any, res: any, next: any) => {
const userId = req.headers['x-user-id']
if (!userId) {
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
}
req.userId = userId
next()
}
router.get('/status', requireUser, (_req, res) => {
res.json({ enabled: isNtfyConfigured() })
})
router.post('/', requireUser, async (req: any, res) => {
try {
if (!isNtfyConfigured()) {
return res.status(503).json({ error: 'Feedback is not configured on this server' })
}
const { category, message, username, logbookId, logbookTitle, appVersion, pageUrl } = req.body ?? {}
if (typeof category !== 'string' || !VALID_CATEGORIES.has(category)) {
return res.status(400).json({ error: 'Invalid category' })
}
if (typeof message !== 'string' || !message.trim()) {
return res.status(400).json({ error: 'Message is required' })
}
const trimmedMessage = message.trim()
if (trimmedMessage.length > MAX_MESSAGE_LENGTH) {
return res.status(400).json({ error: `Message must be at most ${MAX_MESSAGE_LENGTH} characters` })
}
await sendFeedbackViaNtfy({
category,
message: trimmedMessage,
username: typeof username === 'string' ? username.trim() : undefined,
userId: req.userId,
logbookId: typeof logbookId === 'string' ? logbookId.trim() : undefined,
logbookTitle: typeof logbookTitle === 'string' ? logbookTitle.trim() : undefined,
appVersion: typeof appVersion === 'string' ? appVersion.trim() : undefined,
pageUrl: typeof pageUrl === 'string' ? pageUrl.trim() : undefined
})
return res.json({ ok: true })
} catch (error: any) {
console.error('Error sending feedback via Ntfy:', error)
return res.status(502).json({ error: error.message || 'Failed to send feedback' })
}
})
export default router
+59
View File
@@ -0,0 +1,59 @@
import { Router } from 'express'
const router = Router()
const requireUser = (req: any, res: any, next: any) => {
const userId = req.headers['x-user-id']
if (!userId) {
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
}
req.userId = userId
next()
}
function resolveOwmApiKey(userProvidedKey: unknown): string | null {
if (typeof userProvidedKey === 'string' && userProvidedKey.trim()) {
return userProvidedKey.trim()
}
const fromEnv =
process.env.OpenWeatherMapAPIKey?.trim() ||
process.env.OPENWEATHERMAP_API_KEY?.trim()
return fromEnv || null
}
router.get('/current', requireUser, async (req: any, res) => {
try {
const { lat, lon, q } = req.query
const apiKey = resolveOwmApiKey(req.headers['x-owm-api-key'])
if (!apiKey) {
return res.status(503).json({
error: 'No OpenWeatherMap API key configured (user settings or server environment)'
})
}
let url: URL
if (lat && lon) {
url = new URL('https://api.openweathermap.org/data/2.5/weather')
url.searchParams.set('lat', String(lat))
url.searchParams.set('lon', String(lon))
} else if (q && typeof q === 'string' && q.trim()) {
url = new URL('https://api.openweathermap.org/data/2.5/weather')
url.searchParams.set('q', q.trim())
} else {
return res.status(400).json({ error: 'lat and lon, or q (location name) is required' })
}
url.searchParams.set('appid', apiKey)
url.searchParams.set('units', 'metric')
const owmRes = await fetch(url)
const data = await owmRes.json()
return res.status(owmRes.status).json(data)
} catch (error: any) {
console.error('Error fetching OpenWeatherMap data:', error)
return res.status(502).json({ error: error.message || 'Weather lookup failed' })
}
})
export default router
+74
View File
@@ -0,0 +1,74 @@
export interface FeedbackPayload {
category: string
message: string
username?: string
userId: string
logbookId?: string
logbookTitle?: string
appVersion?: string
pageUrl?: string
}
function resolveNtfyConfig(): { server: string; topic: string; token?: string } | null {
const server = (process.env.NTFY_SERVER || 'https://ntfy.sh').replace(/\/+$/, '')
const topic = process.env.NTFY_TOPIC?.trim()
const token = process.env.NTFY_TOKEN?.trim()
if (!topic) return null
return { server, topic, token: token || undefined }
}
export function isNtfyConfigured(): boolean {
return resolveNtfyConfig() !== null
}
export async function sendFeedbackViaNtfy(payload: FeedbackPayload): Promise<void> {
const config = resolveNtfyConfig()
if (!config) {
throw new Error('NTFY_TOPIC is not configured')
}
const categoryLabel = payload.category.charAt(0).toUpperCase() + payload.category.slice(1)
const title = `Kapteins Daagbok - ${categoryLabel}`
const lines = [
payload.message,
'',
'---',
`User: ${payload.username || '(unknown)'}`,
`User ID: ${payload.userId}`
]
if (payload.logbookTitle || payload.logbookId) {
lines.push(`Logbook: ${payload.logbookTitle || payload.logbookId}`)
}
if (payload.appVersion) {
lines.push(`App version: ${payload.appVersion}`)
}
if (payload.pageUrl) {
lines.push(`Page: ${payload.pageUrl}`)
}
const headers: Record<string, string> = {
Title: title,
Tags: 'speech_balloon,ship',
'Content-Type': 'text/plain; charset=utf-8'
}
if (config.token) {
headers.Authorization = `Bearer ${config.token}`
}
const url = `${config.server}/${encodeURIComponent(config.topic)}`
const res = await fetch(url, {
method: 'POST',
headers,
body: lines.join('\n')
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Ntfy request failed (${res.status})${body ? `: ${body}` : ''}`)
}
}