From 9a2052f623314bc0824158cc0712fac79bd2bea0 Mon Sep 17 00:00:00 2001 From: elpatron Date: Thu, 28 May 2026 12:19:33 +0200 Subject: [PATCH] Implement AC Nautik PDF Export, E2E Encrypted Photos, and Background GPS Route Tracking --- client/package-lock.json | 282 ++++++++++++++++++++--- client/package.json | 1 + client/src/App.css | 104 +++++++++ client/src/components/LogEntriesList.tsx | 19 ++ client/src/components/LogEntryEditor.tsx | 212 ++++++++++++++++- client/src/components/PhotoCapture.tsx | 266 +++++++++++++++++++++ client/src/i18n/locales/de.json | 18 ++ client/src/i18n/locales/en.json | 18 ++ client/src/services/db.ts | 34 ++- client/src/services/gpsTracker.ts | 271 ++++++++++++++++++++++ client/src/services/pdfExport.ts | 223 ++++++++++++++++++ client/src/services/sync.ts | 68 +++++- server/prisma/schema.prisma | 31 +++ server/src/routes/sync.ts | 41 +++- 14 files changed, 1539 insertions(+), 49 deletions(-) create mode 100644 client/src/components/PhotoCapture.tsx create mode 100644 client/src/services/gpsTracker.ts create mode 100644 client/src/services/pdfExport.ts diff --git a/client/package-lock.json b/client/package-lock.json index 2bcd32d..5230d5c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -14,6 +14,7 @@ "dexie-react-hooks": "^4.4.0", "i18next": "^26.3.0", "i18next-browser-languagedetector": "^8.2.1", + "jspdf": "^4.2.1", "lucide-react": "^1.16.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -2731,6 +2732,19 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", @@ -2762,7 +2776,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -3058,16 +3072,16 @@ } }, "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -3208,6 +3222,16 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.32", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", @@ -3355,6 +3379,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -3379,6 +3423,18 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", @@ -3418,6 +3474,16 @@ "node": ">=8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3576,6 +3642,16 @@ "react": ">=16" } }, + "node_modules/dompurify": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", + "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3891,6 +3967,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", @@ -3996,6 +4096,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -4031,6 +4142,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4509,6 +4626,20 @@ "void-elements": "3.1.0" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/i18next": { "version": "26.3.0", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz", @@ -4588,6 +4719,12 @@ "node": ">= 0.4" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5086,9 +5223,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, "license": "MIT" }, @@ -5135,6 +5272,23 @@ "node": ">=0.10.0" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5687,6 +5841,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5741,6 +5901,13 @@ "node": "20 || >=22" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5833,6 +6000,16 @@ "node": ">=6" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", @@ -5924,6 +6101,13 @@ "node": ">=4" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -6015,6 +6199,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rolldown": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", @@ -6398,6 +6592,16 @@ "node": ">=0.10.0" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6537,6 +6741,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", @@ -6585,6 +6799,16 @@ "node": ">=10" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -6741,7 +6965,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6929,6 +7153,16 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "8.0.14", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", @@ -7251,30 +7485,6 @@ "node": ">=20.0.0" } }, - "node_modules/workbox-build/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/workbox-build/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/workbox-build/node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", diff --git a/client/package.json b/client/package.json index 6d80fd1..fa572cb 100644 --- a/client/package.json +++ b/client/package.json @@ -16,6 +16,7 @@ "dexie-react-hooks": "^4.4.0", "i18next": "^26.3.0", "i18next-browser-languagedetector": "^8.2.1", + "jspdf": "^4.2.1", "lucide-react": "^1.16.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/client/src/App.css b/client/src/App.css index 44ed53d..126bb94 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -659,6 +659,32 @@ body { background: rgba(244, 63, 94, 0.1); } +.btn-pdf { + background: none; + border: none; + color: #64748b; + cursor: pointer; + padding: 6px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: all 0.2s ease; + position: absolute; + top: 12px; + right: 44px; +} + +.logbook-card:hover .btn-pdf { + opacity: 1; +} + +.btn-pdf:hover { + color: #38bdf8; + background: rgba(56, 189, 248, 0.1); +} + /* Active Logbook App Layout */ .app-layout { width: 100%; @@ -1359,3 +1385,81 @@ body:has(.theme-cupertino) { background: linear-gradient(90deg, #0a84ff, #007aff, #0a84ff) !important; } + +/* Photo Attachments Styling */ +.photo-attachments-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 16px; + width: 100%; +} + +.photo-card { + position: relative; + border-radius: 12px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(15, 23, 42, 0.4); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + flex-direction: column; +} + +.photo-card:hover { + transform: translateY(-2px) scale(1.02); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4); + border-color: rgba(212, 175, 55, 0.3); +} + +.photo-container { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + background: #0b0c10; + overflow: hidden; +} + +.photo-container img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.photo-btn-delete { + position: absolute; + top: 8px; + right: 8px; + background: rgba(15, 23, 42, 0.7); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #f43f5e; + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; +} + +.photo-btn-delete:hover { + background: #f43f5e; + color: #ffffff; + transform: scale(1.1); +} + +.photo-caption-bar { + padding: 10px 12px; + background: rgba(15, 23, 42, 0.55); + font-size: 13px; + color: #e2e8f0; + border-top: 1px solid rgba(255, 255, 255, 0.05); + text-align: center; + font-weight: 500; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} diff --git a/client/src/components/LogEntriesList.tsx b/client/src/components/LogEntriesList.tsx index c5334f4..7ab0490 100644 --- a/client/src/components/LogEntriesList.tsx +++ b/client/src/components/LogEntriesList.tsx @@ -5,6 +5,7 @@ import { getActiveMasterKey } from '../services/auth.js' import { decryptJson, encryptJson } from '../services/crypto.js' import { syncLogbook } from '../services/sync.js' import { downloadCsv, shareCsv } from '../services/csvExport.js' +import { downloadLogbookPagePdf } from '../services/pdfExport.js' import LogEntryEditor from './LogEntryEditor.tsx' import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react' @@ -110,6 +111,20 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) { } } + const handleDownloadPdf = async (entryId: string, date: string, e: React.MouseEvent) => { + e.stopPropagation() + setExporting(true) + setError(null) + try { + await downloadLogbookPagePdf(logbookId, entryId, date) + } catch (err: any) { + console.error('Failed to download PDF:', err) + setError(err.message || 'Failed to generate PDF export.') + } finally { + setExporting(false) + } + } + const handleCreate = async () => { setLoading(true) setError(null) @@ -269,6 +284,10 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) { + + diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 5bf6e19..39d77cc 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -4,7 +4,18 @@ import { db } from '../services/db.js' import { getActiveMasterKey } from '../services/auth.js' import { encryptJson, decryptJson } from '../services/crypto.js' import { syncLogbook } from '../services/sync.js' -import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock } from 'lucide-react' +import { downloadLogbookPagePdf } from '../services/pdfExport.js' +import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Play, Square, Navigation } from 'lucide-react' +import PhotoCapture from './PhotoCapture.tsx' +import { + startGpsTracking, + stopGpsTracking, + isGpsTrackingActive, + getDecryptedGpsTrack, + downloadGpxFile, + getDistanceMeters, + type GpsWaypoint +} from '../services/gpsTracker.js' interface LogEntryEditorProps { entryId: string @@ -79,10 +90,16 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) + const [exporting, setExporting] = useState(false) const [success, setSuccess] = useState(false) const [error, setError] = useState(null) const [weatherLoading, setWeatherLoading] = useState(false) + // GPS Tracking States + const [waypoints, setWaypoints] = useState([]) + const [trackingActive, setTrackingActive] = useState(false) + const [tick, setTick] = useState(0) + // Auto-calculate Freshwater Consumption useEffect(() => { const morning = parseFloat(fwMorning) || 0 @@ -146,6 +163,84 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE loadEntry() }, [entryId]) + // GPS Tracking logic + const loadGpsTrack = async () => { + try { + const track = await getDecryptedGpsTrack(entryId) + setWaypoints(track) + } catch (e) { + console.warn('Failed to load GPS track:', e) + } + } + + useEffect(() => { + loadGpsTrack() + setTrackingActive(isGpsTrackingActive(entryId)) + + const interval = setInterval(() => { + setTrackingActive(isGpsTrackingActive(entryId)) + if (isGpsTrackingActive(entryId)) { + loadGpsTrack() + } + }, 5000) + + return () => clearInterval(interval) + }, [entryId]) + + useEffect(() => { + if (!trackingActive) return + const timer = setInterval(() => { + setTick((t) => t + 1) + }, 1000) + return () => clearInterval(timer) + }, [trackingActive]) + + const handleStartTracking = async () => { + try { + await startGpsTracking(logbookId, entryId, (newWp) => { + setWaypoints((prev) => [...prev, newWp]) + }) + setTrackingActive(true) + } catch (err: any) { + alert(err.message || 'Failed to start GPS tracking') + } + } + + const handleStopTracking = () => { + stopGpsTracking() + setTrackingActive(false) + loadGpsTrack() + } + + const calculateTotalDistanceSailed = () => { + if (waypoints.length < 2) return 0 + let totalMeters = 0 + for (let i = 1; i < waypoints.length; i++) { + totalMeters += getDistanceMeters( + waypoints[i - 1].lat, + waypoints[i - 1].lng, + waypoints[i].lat, + waypoints[i].lng + ) + } + return Number((totalMeters / 1852).toFixed(2)) + } + + const calculateDurationStr = () => { + if (tick < 0 || waypoints.length < 2) return '00:00:00' + const first = waypoints[0].timestamp + const last = trackingActive ? Date.now() : waypoints[waypoints.length - 1].timestamp + const diffMs = last - first + if (diffMs <= 0) return '00:00:00' + + const secs = Math.floor(diffMs / 1000) % 60 + const mins = Math.floor(diffMs / (1000 * 60)) % 60 + const hours = Math.floor(diffMs / (1000 * 60 * 60)) + + const pad = (n: number) => String(n).padStart(2, '0') + return `${pad(hours)}:${pad(mins)}:${pad(secs)}` + } + const handleGetGps = () => { if (!navigator.geolocation) { alert('Geolocation is not supported by your browser') @@ -275,6 +370,19 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE setEvents((prev) => prev.filter((_, idx) => idx !== index)) } + const handleDownloadPdf = async () => { + setExporting(true) + setError(null) + try { + await downloadLogbookPagePdf(logbookId, entryId, date) + } catch (err: any) { + console.error('Failed to download PDF:', err) + setError(err.message || 'Failed to generate PDF export.') + } finally { + setExporting(false) + } + } + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setSaving(true) @@ -360,16 +468,29 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE {/* Top Header Controls */}
- -
- -

- {t('logs.route')}: {departure || '...'} → {destination || '...'} (Tag {dayOfTravel}) -

+
+ +
+ +

+ {t('logs.route')}: {departure || '...'} → {destination || '...'} (Tag {dayOfTravel}) +

+
+ +
@@ -829,6 +950,77 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
+ {/* GPS Tracking Dashboard */} +
+
+ +

{t('logs.gps_tracking_title')}

+ + {trackingActive ? t('logs.gps_tracking_status_active') : t('logs.gps_tracking_status_inactive')} + +
+ +
+
+
{t('logs.gps_tracking_stat_duration')}
+
+ {calculateDurationStr()} +
+
+ +
+
{t('logs.gps_tracking_stat_distance')}
+
+ {calculateTotalDistanceSailed()} sm +
+
+ +
+
{t('logs.gps_tracking_stat_waypoints')}
+
+ {waypoints.length} +
+
+
+ +
+ {!trackingActive ? ( + + ) : ( + + )} + + +
+
+ + + {/* Section 4: Sign-Off Signatures */}
diff --git a/client/src/components/PhotoCapture.tsx b/client/src/components/PhotoCapture.tsx new file mode 100644 index 0000000..03b5626 --- /dev/null +++ b/client/src/components/PhotoCapture.tsx @@ -0,0 +1,266 @@ +import React, { useState, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { db } from '../services/db.js' +import { getActiveMasterKey } from '../services/auth.js' +import { encryptJson, decryptJson } from '../services/crypto.js' +import { syncLogbook } from '../services/sync.js' +import { useLiveQuery } from 'dexie-react-hooks' +import { Camera, Trash2 } from 'lucide-react' + +interface PhotoCaptureProps { + entryId: string + logbookId: string +} + +interface DecryptedPhoto { + payloadId: string + image: string + caption: string + updatedAt: string +} + +export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps) { + const { t } = useTranslation() + const [caption, setCaption] = useState('') + const [uploading, setUploading] = useState(false) + const [error, setError] = useState(null) + const [decryptedPhotos, setDecryptedPhotos] = useState([]) + + const fileInputRef = useRef(null) + + // Reactively query local photos database + const localPhotos = useLiveQuery( + () => db.photos.where({ entryId }).toArray(), + [entryId] + ) + + // Decrypt photos on query updates + useEffect(() => { + async function decryptPhotosList() { + if (!localPhotos) return + + const masterKey = getActiveMasterKey() + if (!masterKey) return + + const list: DecryptedPhoto[] = [] + for (const p of localPhotos) { + try { + const decrypted = await decryptJson(p.encryptedData, p.iv, p.tag, masterKey) + if (decrypted) { + list.push({ + payloadId: p.payloadId, + image: decrypted.image, + caption: decrypted.caption || '', + updatedAt: p.updatedAt + }) + } + } catch (e) { + console.error('Failed to decrypt photo attachment:', e) + } + } + setDecryptedPhotos(list) + } + + decryptPhotosList() + }, [localPhotos]) + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + setUploading(true) + setError(null) + + const reader = new FileReader() + reader.onload = (event) => { + const img = new Image() + img.onload = async () => { + try { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Could not get canvas context') + + let width = img.width + let height = img.height + const MAX_WIDTH = 1280 + const MAX_HEIGHT = 720 + + // Calculate resizing conserving aspect ratio + if (width > MAX_WIDTH || height > MAX_HEIGHT) { + const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height) + width = Math.round(width * ratio) + height = Math.round(height * ratio) + } + + canvas.width = width + canvas.height = height + ctx.drawImage(img, 0, 0, width, height) + + // Compress to JPEG, 70% quality + const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7) + + // Encrypt + const masterKey = getActiveMasterKey() + if (!masterKey) throw new Error('Master key not found. Please log in.') + + const photoId = window.crypto.randomUUID() + const photoPayload = { + image: compressedBase64, + caption: caption.trim() + } + + const encrypted = await encryptJson(photoPayload, masterKey) + const now = new Date().toISOString() + + // Store locally + await db.photos.put({ + payloadId: photoId, + entryId, + logbookId, + encryptedData: encrypted.ciphertext, + iv: encrypted.iv, + tag: encrypted.tag, + caption: '', // stored encrypted inside payload + updatedAt: now + }) + + // Queue for background sync + await db.syncQueue.put({ + action: 'create', + type: 'photo', + payloadId: photoId, + logbookId, + data: JSON.stringify({ + encryptedData: encrypted.ciphertext, + iv: encrypted.iv, + tag: encrypted.tag, + entryId + }), + updatedAt: now + }) + + setCaption('') + if (fileInputRef.current) fileInputRef.current.value = '' + + syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) + } catch (err: any) { + console.error('Failed to process image:', err) + setError(err.message || 'Failed to process image') + } finally { + setUploading(false) + } + } + img.src = event.target?.result as string + } + reader.readAsDataURL(file) + } + + const handleDelete = async (photoId: string) => { + if (window.confirm(t('logs.photo_delete_confirm'))) { + try { + const now = new Date().toISOString() + + await db.photos.delete(photoId) + + await db.syncQueue.put({ + action: 'delete', + type: 'photo', + payloadId: photoId, + logbookId, + data: '', + updatedAt: now + }) + + syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) + } catch (err: any) { + console.error('Failed to delete photo:', err) + } + } + } + + const triggerSelect = () => { + if (fileInputRef.current) { + fileInputRef.current.click() + } + } + + return ( +
+
+ +

{t('logs.photos_title')}

+
+ + {error &&
{error}
} + + {/* Upload area */} +
+
+
+ + setCaption(e.target.value)} + disabled={uploading} + /> +
+ + + + +
+
+ + {/* Photo Grid */} + {decryptedPhotos.length === 0 ? ( +
{t('logs.no_photos')}
+ ) : ( +
+ {decryptedPhotos.map((photo) => ( +
+
+ {photo.caption + +
+ {photo.caption && ( +
+ {photo.caption} +
+ )} +
+ ))} +
+ )} +
+ ) +} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 2286843..f7de214 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -88,6 +88,24 @@ "event_distance": "Distanz (sm)", "export_csv": "CSV herunterladen", "share_csv": "CSV teilen", + "export_pdf": "PDF herunterladen", + "exporting_pdf": "PDF wird generiert...", + "photos_title": "Foto-Anhänge (E2E-verschlüsselt)", + "photo_caption_label": "Foto-Beschreibung / Label (Optional)", + "photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt", + "photo_btn": "Foto aufnehmen / Hochladen", + "photo_processing": "Wird verarbeitet...", + "no_photos": "Noch keine Fotos an diesen Reisetag angehängt.", + "photo_delete_confirm": "Sind Sie sicher, dass Sie dieses Foto unwiderruflich löschen möchten?", + "gps_tracking_title": "GPS-Routenaufzeichnung (E2E-verschlüsselt)", + "gps_tracking_status_active": "Aufzeichnung läuft", + "gps_tracking_status_inactive": "Aufzeichnung inaktiv", + "gps_tracking_btn_start": "Aufzeichnung starten", + "gps_tracking_btn_stop": "Aufzeichnung stoppen", + "gps_tracking_btn_gpx": "GPX herunterladen", + "gps_tracking_stat_duration": "Dauer", + "gps_tracking_stat_distance": "Distanz", + "gps_tracking_stat_waypoints": "Wegpunkte", "exporting": "Exportiere...", "share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen." }, diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 5e72946..d9975ac 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -88,6 +88,24 @@ "event_distance": "Distance (nm)", "export_csv": "Download CSV", "share_csv": "Share CSV", + "export_pdf": "Download PDF", + "exporting_pdf": "Generating PDF...", + "photos_title": "Photo Attachments (E2E Encrypted)", + "photo_caption_label": "Photo Caption / Label (Optional)", + "photo_caption_placeholder": "e.g. Setting sails near harbor entrance", + "photo_btn": "Take Photo / Upload", + "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?", + "gps_tracking_title": "GPS Route Tracking (E2E Encrypted)", + "gps_tracking_status_active": "Tracking Active", + "gps_tracking_status_inactive": "Tracking Inactive", + "gps_tracking_btn_start": "Start Tracking", + "gps_tracking_btn_stop": "Stop Tracking", + "gps_tracking_btn_gpx": "Download GPX", + "gps_tracking_stat_duration": "Duration", + "gps_tracking_stat_distance": "Distance", + "gps_tracking_stat_waypoints": "Waypoints", "exporting": "Exporting...", "share_unsupported": "Web sharing is not supported on this device. File downloaded instead." }, diff --git a/client/src/services/db.ts b/client/src/services/db.ts index 8a83014..447c7af 100644 --- a/client/src/services/db.ts +++ b/client/src/services/db.ts @@ -41,10 +41,30 @@ export interface LocalEntry { updatedAt: string } +export interface LocalPhoto { + payloadId: string + entryId: string + logbookId: string + encryptedData: string // encrypted base64 image data + iv: string + tag: string + caption: string // encrypted caption + updatedAt: string +} + +export interface LocalGpsTrack { + entryId: string // one track per daily journal entry + logbookId: string + encryptedData: string // encrypted waypoints JSON string + iv: string + tag: string + updatedAt: string +} + export interface SyncQueueItem { id?: number action: 'create' | 'update' | 'delete' - type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' + type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack' payloadId: string // payloadId or logbookId depending on the type logbookId: string data: string // JSON representation of the local record @@ -57,6 +77,8 @@ class DaagboxDatabase extends Dexie { crews!: Table deviations!: Table entries!: Table + photos!: Table + gpsTracks!: Table syncQueue!: Table constructor() { @@ -69,6 +91,16 @@ class DaagboxDatabase extends Dexie { entries: 'payloadId, logbookId, updatedAt', syncQueue: '++id, action, type, payloadId, logbookId' }) + this.version(2).stores({ + logbooks: 'id, encryptedTitle, updatedAt, isSynced', + yachts: 'logbookId, updatedAt', + crews: 'payloadId, logbookId, updatedAt', + deviations: 'logbookId, updatedAt', + entries: 'payloadId, logbookId, updatedAt', + syncQueue: '++id, action, type, payloadId, logbookId', + photos: 'payloadId, entryId, logbookId, updatedAt', + gpsTracks: 'entryId, logbookId, updatedAt' + }) } } diff --git a/client/src/services/gpsTracker.ts b/client/src/services/gpsTracker.ts new file mode 100644 index 0000000..228b115 --- /dev/null +++ b/client/src/services/gpsTracker.ts @@ -0,0 +1,271 @@ +import { db } from './db.js' +import { getActiveMasterKey } from './auth.js' +import { encryptJson, decryptJson } from './crypto.js' +import { syncLogbook } from './sync.js' + +export interface GpsWaypoint { + timestamp: number + lat: number + lng: number + speedKnots?: number + heading?: number +} + +let watchId: number | null = null +let wakeLock: any = null +let activeEntryId: string | null = null +let lastWaypoint: GpsWaypoint | null = null +let onWaypointAddedCallback: ((waypoint: GpsWaypoint) => void) | null = null + +// Haversine formula to compute distance in meters +export function getDistanceMeters(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371e3 // Earth's radius in meters + const phi1 = (lat1 * Math.PI) / 180 + const phi2 = (lat2 * Math.PI) / 180 + const deltaPhi = ((lat2 - lat1) * Math.PI) / 180 + const deltaLambda = ((lon2 - lon1) * Math.PI) / 180 + + const a = + Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) + + Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2) + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + + return R * c +} + +// Request Screen Wake Lock +async function requestWakeLock() { + try { + if ('wakeLock' in navigator) { + wakeLock = await (navigator as any).wakeLock.request('screen') + console.log('GPS Tracker: Screen Wake Lock acquired') + } + } catch (err) { + console.warn('GPS Tracker: Wake Lock request failed:', err) + } +} + +// Release Screen Wake Lock +function releaseWakeLock() { + if (wakeLock) { + wakeLock.release().then(() => { + wakeLock = null; + console.log('GPS Tracker: Screen Wake Lock released') + }) + } +} + +// Handle visibility changes to re-acquire wake lock if tab is minimized/restored +if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', async () => { + if (watchId !== null && document.visibilityState === 'visible') { + await requestWakeLock() + } + }) +} + +// Start GPS Tracking Run +export async function startGpsTracking( + logbookId: string, + entryId: string, + onWaypointAdded?: (waypoint: GpsWaypoint) => void +): Promise { + if (watchId !== null) { + throw new Error('Tracking is already active') + } + + if (!navigator.geolocation) { + throw new Error('Geolocation is not supported by your device') + } + + activeEntryId = entryId + onWaypointAddedCallback = onWaypointAdded || null + lastWaypoint = null + + // Acquire Screen Wake Lock to prevent standby/sleep + await requestWakeLock() + + // Load last waypoint from existing track to resume or filter correctly + try { + const existingTrack = await getDecryptedGpsTrack(entryId) + if (existingTrack && existingTrack.length > 0) { + lastWaypoint = existingTrack[existingTrack.length - 1] + } + } catch (e) { + console.warn('Could not read existing waypoints for filtering:', e) + } + + watchId = navigator.geolocation.watchPosition( + async (position) => { + const { latitude, longitude, speed, heading } = position.coords + const now = Date.now() + + // Convert speed from m/s to knots (1 m/s = 1.94384 knots) + const speedKnots = speed !== null && speed !== undefined && speed >= 0 ? speed * 1.94384 : undefined + const headingDeg = heading !== null && heading !== undefined && heading >= 0 ? heading : undefined + + const newWaypoint: GpsWaypoint = { + timestamp: now, + lat: Number(latitude.toFixed(6)), + lng: Number(longitude.toFixed(6)), + speedKnots: speedKnots !== undefined ? Number(speedKnots.toFixed(1)) : undefined, + heading: headingDeg !== undefined ? Number(headingDeg.toFixed(0)) : undefined + } + + // Filter: Only add if distance to last waypoint > 15 meters OR if 30 seconds elapsed + if (lastWaypoint) { + const distance = getDistanceMeters(lastWaypoint.lat, lastWaypoint.lng, newWaypoint.lat, newWaypoint.lng) + const timeElapsed = now - lastWaypoint.timestamp + + // Throttle check + if (distance < 15 && timeElapsed < 30000) { + // Skip insignificant waypoint + return + } + } + + // Save waypoint + try { + await saveWaypoint(logbookId, entryId, newWaypoint) + lastWaypoint = newWaypoint + if (onWaypointAddedCallback) { + onWaypointAddedCallback(newWaypoint) + } + } catch (err) { + console.error('GPS Tracker: Failed to save waypoint:', err) + } + }, + (error) => { + console.error('GPS Geolocation tracking error:', error) + }, + { + enableHighAccuracy: true, + maximumAge: 0 + } + ) +} + +// Stop GPS Tracking Run +export function stopGpsTracking(): void { + if (watchId !== null) { + navigator.geolocation.clearWatch(watchId) + watchId = null + } + releaseWakeLock() + activeEntryId = null + onWaypointAddedCallback = null + lastWaypoint = null + console.log('GPS Tracker: Stopped tracking') +} + +// Is Tracking currently running for this entry? +export function isGpsTrackingActive(entryId?: string): boolean { + if (entryId) { + return watchId !== null && activeEntryId === entryId + } + return watchId !== null +} + +// Get the decrypted waypoints array for a journal entry +export async function getDecryptedGpsTrack(entryId: string): Promise { + const masterKey = getActiveMasterKey() + if (!masterKey) { + throw new Error('Master key not found. Please log in.') + } + + const record = await db.gpsTracks.get(entryId) + if (!record) return [] + + try { + const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey) + return Array.isArray(decrypted) ? decrypted : [] + } catch (err) { + console.error('Failed to decrypt GPS track:', err) + return [] + } +} + +// Helper: append waypoint, encrypt, and save/queue sync +async function saveWaypoint(logbookId: string, entryId: string, waypoint: GpsWaypoint): Promise { + const masterKey = getActiveMasterKey() + if (!masterKey) throw new Error('Master key not found. Please log in.') + + // Fetch current waypoints + const waypoints = await getDecryptedGpsTrack(entryId) + waypoints.push(waypoint) + + // Encrypt array + const encrypted = await encryptJson(waypoints, masterKey) + const now = new Date().toISOString() + + // Save to Dexie + await db.gpsTracks.put({ + entryId, + logbookId, + encryptedData: encrypted.ciphertext, + iv: encrypted.iv, + tag: encrypted.tag, + updatedAt: now + }) + + // Add to Sync queue (payloadId is entryId here) + await db.syncQueue.put({ + action: 'create', // upsert mapping is used on server + type: 'gpsTrack', + payloadId: entryId, + logbookId, + data: JSON.stringify(encrypted), + updatedAt: now + }) + + // Trigger sync + syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) +} + +// Generate GPX file contents from Waypoints +export function generateGpxString(waypoints: GpsWaypoint[], dateStr: string): string { + const trkpts = waypoints + .map((wp) => { + const timeISO = new Date(wp.timestamp).toISOString() + const courseTag = wp.heading !== undefined ? `${wp.heading}` : '' + const speedTag = wp.speedKnots !== undefined ? `${(wp.speedKnots / 1.94384).toFixed(2)}` : '' // speed back in m/s for GPX spec + return ` + + ${courseTag} + ${speedTag} + ` + }) + .join('\n') + + return ` + + + + + + Track Log ${dateStr} + +${trkpts} + + +` +} + +// Download GPX file client-side +export function downloadGpxFile(waypoints: GpsWaypoint[], dateStr: string): void { + if (waypoints.length === 0) { + alert('No waypoints recorded to export.') + return + } + const gpxContent = generateGpxString(waypoints, dateStr) + const blob = new Blob([gpxContent], { type: 'application/gpx+xml;charset=utf-8' }) + const url = URL.createObjectURL(blob) + + const a = document.createElement('a') + a.href = url + a.download = `track_${dateStr.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.gpx` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} diff --git a/client/src/services/pdfExport.ts b/client/src/services/pdfExport.ts new file mode 100644 index 0000000..3d155e9 --- /dev/null +++ b/client/src/services/pdfExport.ts @@ -0,0 +1,223 @@ +import { jsPDF } from 'jspdf' +import { db } from './db.js' +import { getActiveMasterKey } from './auth.js' +import { decryptJson } from './crypto.js' + +export async function generateLogbookPagePdf(logbookId: string, entryId: string): Promise { + const masterKey = getActiveMasterKey() + if (!masterKey) { + throw new Error('Master key not found. Please log in.') + } + + // 1. Fetch Yacht details + let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = ''; + const yachtRecord = await db.yachts.get(logbookId); + if (yachtRecord) { + try { + const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey); + yachtName = yacht.name || ''; + homePort = yacht.port || ''; + // owner not needed in PDF layout + registration = yacht.registrationNumber || yacht.registration || ''; + callsign = yacht.callSign || ''; + atis = yacht.atis || ''; + mmsi = yacht.mmsi || ''; + } catch (e) { + console.error('Failed to decrypt yacht details for PDF:', e); + } + } + + // 2. Fetch active Entry + const entryRecord = await db.entries.get(entryId); + if (!entryRecord) { + throw new Error('Entry not found'); + } + + const entry = await decryptJson(entryRecord.encryptedData, entryRecord.iv, entryRecord.tag, masterKey); + if (!entry) { + throw new Error('Failed to decrypt entry'); + } + + // Create PDF landscape A4 + const doc = new jsPDF({ + orientation: 'landscape', + unit: 'mm', + format: 'a4' + }); + + // Setup Styles + doc.setFont('Helvetica', 'normal'); + + // --- DRAW HEADER SECTION --- + doc.setFontSize(14); + doc.setFont('Helvetica', 'bold'); + doc.text('OFFIZIELLES SCHIFFSLOGBUCH (AC NAUTIK STANDARD)', 10, 15); + + doc.setFontSize(8.5); + doc.setFont('Helvetica', 'normal'); + doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21); + doc.text(`Heimathafen: ${homePort || '—'}`, 60, 21); + doc.text(`Kennzeichen: ${registration || '—'}`, 110, 21); + doc.text(`Rufzeichen: ${callsign || '—'}`, 160, 21); + doc.text(`ATIS: ${atis || '—'}`, 210, 21); + doc.text(`MMSI: ${mmsi || '—'}`, 250, 21); + + doc.text(`Datum: ${entry.date || '—'}`, 10, 26); + doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 26); + doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 26); + doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 26); + + // Divider line + doc.setLineWidth(0.3); + doc.line(10, 29, 287, 29); + + // --- DRAW EVENTS TABLE --- + doc.setFont('Helvetica', 'bold'); + doc.setFontSize(9); + doc.text('CHRONOLOGISCHES EREIGNISPROTOKOLL / EVENT JOURNAL', 10, 34); + + // Table Headers + const colWidths = [12, 10, 10, 12, 12, 13, 10, 12, 10, 15, 12, 45, 94]; // Total = 277mm + const colHeaders = [ + 'Zeit', 'MgK', 'rwK', 'Wind Dir', 'Wind Str', 'Druck', 'See', + 'Strom', 'Lage', 'Segel/Motor', 'Log', 'GPS Position', 'Bemerkungen / Vorkommnisse' + ]; + + let startY = 37; + let rowHeight = 6; + doc.setFontSize(7.5); + + // Draw Header Row + let currentX = 10; + doc.setFillColor(240, 240, 240); + doc.rect(10, startY, 277, rowHeight, 'F'); + doc.rect(10, startY, 277, rowHeight, 'S'); + + for (let i = 0; i < colHeaders.length; i++) { + doc.text(colHeaders[i], currentX + 1, startY + 4.2); + currentX += colWidths[i]; + } + + // Draw Data Rows + const events = entry.events || []; + const maxRows = 16; + const sortedEvents = [...events].sort((a: any, b: any) => (a.time || '').localeCompare(b.time || '')); + + doc.setFont('Helvetica', 'normal'); + + for (let rowIndex = 0; rowIndex < maxRows; rowIndex++) { + const y = startY + rowHeight + (rowIndex * rowHeight); + + // Draw row outline + doc.rect(10, y, 277, rowHeight, 'S'); + + // Draw vertical column cell dividers + let cellX = 10; + for (let colIdx = 0; colIdx < colWidths.length - 1; colIdx++) { + cellX += colWidths[colIdx]; + doc.line(cellX, y, cellX, y + rowHeight); + } + + const ev = sortedEvents[rowIndex]; + if (ev) { + let writeX = 10; + doc.text(ev.time || '', writeX + 1, y + 4.2); + writeX += colWidths[0]; + doc.text(ev.mgk ? `${ev.mgk}°` : '—', writeX + 1, y + 4.2); + writeX += colWidths[1]; + doc.text(ev.rwk ? `${ev.rwk}°` : '—', writeX + 1, y + 4.2); + writeX += colWidths[2]; + doc.text(ev.windDirection || '—', writeX + 1, y + 4.2); + writeX += colWidths[3]; + doc.text(ev.windStrength || '—', writeX + 1, y + 4.2); + writeX += colWidths[4]; + doc.text(ev.windPressure ? `${ev.windPressure} hPa` : '—', writeX + 1, y + 4.2); + writeX += colWidths[5]; + doc.text(ev.seaState || '—', writeX + 1, y + 4.2); + writeX += colWidths[6]; + doc.text(ev.current || '—', writeX + 1, y + 4.2); + writeX += colWidths[7]; + doc.text(ev.heel ? `${ev.heel}°` : '—', writeX + 1, y + 4.2); + writeX += colWidths[8]; + doc.text(ev.sailsOrMotor || '—', writeX + 1, y + 4.2); + writeX += colWidths[9]; + doc.text(ev.logReading ? `${ev.logReading} sm` : '—', writeX + 1, y + 4.2); + writeX += colWidths[10]; + + const gps = ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'; + doc.text(gps, writeX + 1, y + 4.2); + writeX += colWidths[11]; + + // Clip remarks to fit within the 94mm bounds + const remarks = ev.remarks || ''; + const maxChars = 65; + const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks; + doc.text(clippedRemarks, writeX + 1, y + 4.2); + } + } + + // --- DRAW FOOTER SECTION --- + const footerY = startY + rowHeight + (maxRows * rowHeight) + 4; + + // Consumables (Water & Diesel) + doc.setFont('Helvetica', 'bold'); + doc.setFontSize(8.5); + doc.text('VERBRAUCHSWERTE / CONSUMPTON STATS', 10, footerY + 3); + + let fwY = footerY + 5; + doc.rect(10, fwY, 110, rowHeight * 3, 'S'); + doc.line(10, fwY + rowHeight, 120, fwY + rowHeight); + doc.line(10, fwY + rowHeight * 2, 120, fwY + rowHeight * 2); + doc.line(40, fwY, 40, fwY + rowHeight * 3); + doc.line(60, fwY, 60, fwY + rowHeight * 3); + doc.line(80, fwY, 80, fwY + rowHeight * 3); + doc.line(100, fwY, 100, fwY + rowHeight * 3); + + doc.setFont('Helvetica', 'bold'); + doc.setFontSize(7.5); + doc.text('Betriebsmittel', 11, fwY + 4.2); + doc.text('Morgen (L)', 41, fwY + 4.2); + doc.text('Nachgefüllt', 61, fwY + 4.2); + doc.text('Abend (L)', 81, fwY + 4.2); + doc.text('Verbrauch', 101, fwY + 4.2); + + doc.setFont('Helvetica', 'normal'); + doc.text('Frischwasser', 11, fwY + rowHeight + 4.2); + doc.text(String(entry.freshwater?.morning ?? '0'), 41, fwY + rowHeight + 4.2); + doc.text(String(entry.freshwater?.refilled ?? '0'), 61, fwY + rowHeight + 4.2); + doc.text(String(entry.freshwater?.evening ?? '0'), 81, fwY + rowHeight + 4.2); + doc.text(String(entry.freshwater?.consumption ?? '0'), 101, fwY + rowHeight + 4.2); + + doc.text('Treibstoff (Fuel)', 11, fwY + rowHeight * 2 + 4.2); + doc.text(String(entry.fuel?.morning ?? '0'), 41, fwY + rowHeight * 2 + 4.2); + doc.text(String(entry.fuel?.refilled ?? '0'), 61, fwY + rowHeight * 2 + 4.2); + doc.text(String(entry.fuel?.evening ?? '0'), 81, fwY + rowHeight * 2 + 4.2); + doc.text(String(entry.fuel?.consumption ?? '0'), 101, fwY + rowHeight * 2 + 4.2); + + // Signatures Box + let sigX = 130; + let sigY = footerY + 5; + doc.setFont('Helvetica', 'bold'); + doc.text('FREIGABE & UNTERSCHRIFTEN / SIGNATURES', sigX, footerY + 3); + + doc.rect(sigX, sigY, 157, rowHeight * 3, 'S'); + doc.line(sigX, sigY + rowHeight * 1.5, sigX + 157, sigY + rowHeight * 1.5); + doc.line(sigX + 78.5, sigY, sigX + 78.5, sigY + rowHeight * 3); + + doc.text('Skipper Unterschrift (in Blockschrift):', sigX + 2, sigY + 4.2); + doc.setFont('Helvetica', 'normal'); + doc.text(String(entry.signSkipper || '—').toUpperCase(), sigX + 2, sigY + 11.2); + + doc.setFont('Helvetica', 'bold'); + doc.text('Crew Unterschrift (in Blockschrift):', sigX + 80.5, sigY + 4.2); + doc.setFont('Helvetica', 'normal'); + doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2); + + return doc; +} + +export async function downloadLogbookPagePdf(logbookId: string, entryId: string, dateStr: string): Promise { + const doc = await generateLogbookPagePdf(logbookId, entryId); + const filename = `logbook_${dateStr.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`; + doc.save(filename); +} diff --git a/client/src/services/sync.ts b/client/src/services/sync.ts index cd3395c..aea952b 100644 --- a/client/src/services/sync.ts +++ b/client/src/services/sync.ts @@ -91,7 +91,7 @@ async function pullChanges(logbookId: string): Promise { return false } - const { yacht, deviation, crews, entries } = await response.json() + const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json() // 1. Sync Yacht Payload if (yacht) { @@ -186,6 +186,72 @@ async function pullChanges(logbookId: string): Promise { } } + // 5. Sync Photos + const serverPhotoMap = new Map() + if (photos && Array.isArray(photos)) { + for (const p of photos) { + serverPhotoMap.set(p.payloadId, p) + const local = await db.photos.get(p.payloadId) + if (!local || isNewer(p.updatedAt, local.updatedAt)) { + await db.photos.put({ + payloadId: p.payloadId, + entryId: p.entryId, + logbookId, + encryptedData: p.encryptedData, + iv: p.iv, + tag: p.tag, + caption: '', // caption is stored inside encryptedData JSON + updatedAt: p.updatedAt + }) + } + } + } + + // Deletions for Photos + const localPhotos = await db.photos.where({ logbookId }).toArray() + for (const lp of localPhotos) { + if (!serverPhotoMap.has(lp.payloadId)) { + const pendingCreate = await db.syncQueue + .where({ payloadId: lp.payloadId, action: 'create' }) + .first() + if (!pendingCreate) { + await db.photos.delete(lp.payloadId) + } + } + } + + // 6. Sync GPS Tracks + const serverGpsTrackMap = new Map() + if (gpsTracks && Array.isArray(gpsTracks)) { + for (const gt of gpsTracks) { + serverGpsTrackMap.set(gt.entryId, gt) + const local = await db.gpsTracks.get(gt.entryId) + if (!local || isNewer(gt.updatedAt, local.updatedAt)) { + await db.gpsTracks.put({ + entryId: gt.entryId, + logbookId, + encryptedData: gt.encryptedData, + iv: gt.iv, + tag: gt.tag, + updatedAt: gt.updatedAt + }) + } + } + } + + // Deletions for GPS Tracks + const localGpsTracks = await db.gpsTracks.where({ logbookId }).toArray() + for (const lgt of localGpsTracks) { + if (!serverGpsTrackMap.has(lgt.entryId)) { + const pendingCreate = await db.syncQueue + .where({ payloadId: lgt.entryId, action: 'create' }) + .first() + if (!pendingCreate) { + await db.gpsTracks.delete(lgt.entryId) + } + } + } + return true } catch (error) { console.error('Error during sync pull:', error) diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 5d26869..b480be3 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -44,6 +44,8 @@ model Logbook { crews CrewPayload[] deviations DeviationPayload[] entries EntryPayload[] + photos PhotoPayload[] + gpsTracks GpsTrackPayload[] @@index([userId]) } @@ -94,3 +96,32 @@ model EntryPayload { @@unique([logbookId, payloadId]) @@index([logbookId]) } + +model PhotoPayload { + id String @id @default(uuid()) + logbookId String + payloadId String + entryId String + encryptedData String + iv String + tag String + updatedAt DateTime @updatedAt + logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade) + + @@unique([logbookId, payloadId]) + @@index([logbookId]) + @@index([entryId]) +} + +model GpsTrackPayload { + id String @id @default(uuid()) + logbookId String + entryId String @unique + encryptedData String + iv String + tag String + updatedAt DateTime @updatedAt + logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade) + + @@index([logbookId]) +} diff --git a/server/src/routes/sync.ts b/server/src/routes/sync.ts index e299831..0bbef9f 100644 --- a/server/src/routes/sync.ts +++ b/server/src/routes/sync.ts @@ -152,6 +152,41 @@ router.post('/push', async (req: any, res) => { update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt } }) } + } else if (type === 'photo') { + if (action === 'delete') { + await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } }) + } else { + const existing = await prisma.photoPayload.findUnique({ + where: { logbookId_payloadId: { logbookId, payloadId } } + }) + if (existing && new Date(existing.updatedAt) > itemUpdatedAt) { + results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' }) + continue + } + const entryId = parsed.entryId || '' + await prisma.photoPayload.upsert({ + where: { logbookId_payloadId: { logbookId, payloadId } }, + create: { logbookId, payloadId, entryId, encryptedData, iv, tag, updatedAt: itemUpdatedAt }, + update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt } + }) + } + } else if (type === 'gpsTrack') { + if (action === 'delete') { + await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } }) + } else { + const existing = await prisma.gpsTrackPayload.findUnique({ + where: { entryId: payloadId } + }) + if (existing && new Date(existing.updatedAt) > itemUpdatedAt) { + results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' }) + continue + } + await prisma.gpsTrackPayload.upsert({ + where: { entryId: payloadId }, + create: { logbookId, entryId: payloadId, encryptedData, iv, tag, updatedAt: itemUpdatedAt }, + update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt } + }) + } } results.push({ payloadId, status: 'success' }) @@ -193,12 +228,16 @@ router.get('/pull', async (req: any, res) => { const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } }) const crews = await prisma.crewPayload.findMany({ where: { logbookId } }) const entries = await prisma.entryPayload.findMany({ where: { logbookId } }) + const photos = await prisma.photoPayload.findMany({ where: { logbookId } }) + const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } }) return res.json({ yacht, deviation, crews, - entries + entries, + photos, + gpsTracks }) } catch (error: any) { console.error('Error during sync pull:', error)