Implement AC Nautik PDF Export, E2E Encrypted Photos, and Background GPS Route Tracking
This commit is contained in:
Generated
+246
-36
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
|
||||
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [weatherLoading, setWeatherLoading] = useState(false)
|
||||
|
||||
// GPS Tracking States
|
||||
const [waypoints, setWaypoints] = useState<GpsWaypoint[]>([])
|
||||
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 */}
|
||||
<div className="form-card" style={{ paddingBottom: '20px' }}>
|
||||
<div className="section-title-bar">
|
||||
<button className="btn-back" onClick={onBack} style={{ padding: '6px 12px' }}>
|
||||
<ChevronLeft size={16} />
|
||||
{t('logs.back_to_list')}
|
||||
</button>
|
||||
<div className="form-header" style={{ margin: 0 }}>
|
||||
<FileText size={24} className="form-icon" />
|
||||
<h2>
|
||||
{t('logs.route')}: {departure || '...'} → {destination || '...'} (Tag {dayOfTravel})
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
<button className="btn-back" onClick={onBack} style={{ padding: '6px 12px' }}>
|
||||
<ChevronLeft size={16} />
|
||||
{t('logs.back_to_list')}
|
||||
</button>
|
||||
<div className="form-header" style={{ margin: 0 }}>
|
||||
<FileText size={24} className="form-icon" />
|
||||
<h2>
|
||||
{t('logs.route')}: {departure || '...'} → {destination || '...'} (Tag {dayOfTravel})
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={saving || exporting}
|
||||
style={{ width: 'auto', padding: '8px 16px' }}
|
||||
>
|
||||
<Download size={16} />
|
||||
<span>{exporting ? t('logs.exporting_pdf') : t('logs.export_pdf')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -829,6 +950,77 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GPS Tracking Dashboard */}
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Navigation size={20} className={`form-icon ${trackingActive ? 'spin' : ''}`} style={{ color: trackingActive ? '#10b981' : '#f59e0b', animationDuration: '3s' }} />
|
||||
<h3>{t('logs.gps_tracking_title')}</h3>
|
||||
<span className={`sync-badge ${trackingActive ? 'synced' : 'local'}`} style={{ marginLeft: 'auto', background: trackingActive ? 'rgba(16, 185, 129, 0.15)' : 'rgba(148, 163, 184, 0.15)', color: trackingActive ? '#10b981' : '#94a3b8' }}>
|
||||
{trackingActive ? t('logs.gps_tracking_status_active') : t('logs.gps_tracking_status_inactive')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '16px', margin: '16px 0' }}>
|
||||
<div className="glass" style={{ padding: '12px', borderRadius: '8px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>{t('logs.gps_tracking_stat_duration')}</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', fontFamily: 'monospace', color: '#f8fafc' }}>
|
||||
{calculateDurationStr()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass" style={{ padding: '12px', borderRadius: '8px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>{t('logs.gps_tracking_stat_distance')}</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', color: '#f8fafc' }}>
|
||||
{calculateTotalDistanceSailed()} sm
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass" style={{ padding: '12px', borderRadius: '8px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>{t('logs.gps_tracking_stat_waypoints')}</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', color: '#f8fafc' }}>
|
||||
{waypoints.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{!trackingActive ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={handleStartTracking}
|
||||
style={{ width: 'auto', padding: '10px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
<Play size={16} />
|
||||
{t('logs.gps_tracking_btn_start')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={handleStopTracking}
|
||||
style={{ width: 'auto', padding: '10px 20px', display: 'flex', gap: '8px', alignItems: 'center', background: '#ef4444' }}
|
||||
>
|
||||
<Square size={16} />
|
||||
{t('logs.gps_tracking_btn_stop')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => downloadGpxFile(waypoints, date)}
|
||||
disabled={waypoints.length === 0}
|
||||
style={{ width: 'auto', padding: '10px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
<Download size={16} />
|
||||
{t('logs.gps_tracking_btn_gpx')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PhotoCapture entryId={entryId} logbookId={logbookId} />
|
||||
|
||||
{/* Section 4: Sign-Off Signatures */}
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="form-card mt-6">
|
||||
<div className="form-header mb-4">
|
||||
<Camera size={20} className="form-icon" />
|
||||
<h3>{t('logs.photos_title')}</h3>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
{/* Upload area */}
|
||||
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
||||
<label>{t('logs.photo_caption_label')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('logs.photo_caption_placeholder')}
|
||||
className="input-text"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={triggerSelect}
|
||||
disabled={uploading}
|
||||
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="spin">⏳</span>
|
||||
) : (
|
||||
<Camera size={16} />
|
||||
)}
|
||||
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Photo Grid */}
|
||||
{decryptedPhotos.length === 0 ? (
|
||||
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
|
||||
) : (
|
||||
<div className="photo-attachments-grid">
|
||||
{decryptedPhotos.map((photo) => (
|
||||
<div key={photo.payloadId} className="photo-card glass">
|
||||
<div className="photo-container">
|
||||
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
||||
<button
|
||||
type="button"
|
||||
className="photo-btn-delete"
|
||||
onClick={() => handleDelete(photo.payloadId)}
|
||||
title="Remove photo"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{photo.caption && (
|
||||
<div className="photo-caption-bar">
|
||||
<span>{photo.caption}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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<LocalCrew>
|
||||
deviations!: Table<LocalDeviation>
|
||||
entries!: Table<LocalEntry>
|
||||
photos!: Table<LocalPhoto>
|
||||
gpsTracks!: Table<LocalGpsTrack>
|
||||
syncQueue!: Table<SyncQueueItem>
|
||||
|
||||
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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<GpsWaypoint[]> {
|
||||
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<void> {
|
||||
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 ? `<course>${wp.heading}</course>` : ''
|
||||
const speedTag = wp.speedKnots !== undefined ? `<speed>${(wp.speedKnots / 1.94384).toFixed(2)}</speed>` : '' // speed back in m/s for GPX spec
|
||||
return ` <trkpt lat="${wp.lat}" lon="${wp.lng}">
|
||||
<time>${timeISO}</time>
|
||||
${courseTag}
|
||||
${speedTag}
|
||||
</trkpt>`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="Kapteins Daagbox" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<metadata>
|
||||
<time>${new Date().toISOString()}</time>
|
||||
</metadata>
|
||||
<trk>
|
||||
<name>Track Log ${dateStr}</name>
|
||||
<trkseg>
|
||||
${trkpts}
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>`
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -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<jsPDF> {
|
||||
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<void> {
|
||||
const doc = await generateLogbookPagePdf(logbookId, entryId);
|
||||
const filename = `logbook_${dateStr.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
|
||||
doc.save(filename);
|
||||
}
|
||||
@@ -91,7 +91,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Sync Photos
|
||||
const serverPhotoMap = new Map<string, any>()
|
||||
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<string, any>()
|
||||
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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user