Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9fa8c0edf | |||
| adf02acd45 | |||
| 3992db9d61 | |||
| 51f6a1b291 | |||
| 0b07d8b3d3 | |||
| a07e033e62 | |||
| bbe63dfb47 | |||
| 57f63ad486 | |||
| 728c40f936 | |||
| 72cbad8d5e | |||
| 15f2172a38 | |||
| e2e038f2d6 | |||
| 634eb622fd | |||
| 04b822b263 | |||
| ee60d5fda3 | |||
| 3a7d244433 | |||
| 9e03fcda0a | |||
| 34c7d2d65c | |||
| 658bc6c0c9 | |||
| dee2f7b95b | |||
| 4eaf5d7f30 | |||
| 257bca14d1 | |||
| 917fb92d85 | |||
| b48b31580d | |||
| 7f0223c636 | |||
| 68af8c6361 | |||
| ad7e036ab7 | |||
| 12c02f6392 | |||
| 3698c6fbca | |||
| d4538ec06e | |||
| 86cb4d92ec | |||
| b72b20b66c | |||
| 6ad75ff947 | |||
| 75eba362d6 | |||
| afc5a1e200 |
+1
-1
@@ -36,7 +36,7 @@
|
||||
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
|
||||
<title>Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
|
||||
</head>
|
||||
<body>
|
||||
<body style="margin:0;background:#0b0c10;color:#e2e8f0">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
Generated
+522
-1
@@ -32,12 +32,14 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"playwright": "^1.51.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pwa": "^1.0.1"
|
||||
"vite-plugin-pwa": "^1.0.1",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -2896,6 +2898,24 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
@@ -2991,6 +3011,23 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/whatwg-mimetype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
|
||||
@@ -3255,6 +3292,131 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.4",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
||||
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
||||
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.4",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
||||
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
||||
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyspy": "^4.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
||||
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -3360,6 +3522,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
@@ -3541,6 +3713,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
@@ -3642,6 +3824,33 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assertion-error": "^2.0.1",
|
||||
"check-error": "^2.1.1",
|
||||
"deep-eql": "^5.0.1",
|
||||
"loupe": "^3.1.0",
|
||||
"pathval": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
@@ -3848,6 +4057,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -3979,6 +4198,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.24.2",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
||||
@@ -4068,6 +4300,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
@@ -4406,6 +4645,16 @@
|
||||
"url": "https://github.com/bgub/eta?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -4857,6 +5106,24 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/happy-dom": {
|
||||
"version": "20.9.0",
|
||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.9.0.tgz",
|
||||
"integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"entities": "^7.0.1",
|
||||
"whatwg-mimetype": "^3.0.0",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||
@@ -5710,6 +5977,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -6007,6 +6281,23 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathval": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
@@ -6705,6 +6996,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
@@ -6773,6 +7071,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
@@ -6783,6 +7088,13 @@
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
@@ -6937,6 +7249,26 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^9.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal/node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
@@ -7018,6 +7350,20 @@
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
@@ -7035,6 +7381,36 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinypool": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
||||
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyspy": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
|
||||
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
|
||||
@@ -7439,6 +7815,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
|
||||
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"debug": "^4.4.1",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"pathe": "^2.0.3",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"bin": {
|
||||
"vite-node": "vite-node.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-pwa": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz",
|
||||
@@ -7470,6 +7869,79 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
"@vitest/mocker": "3.2.4",
|
||||
"@vitest/pretty-format": "^3.2.4",
|
||||
"@vitest/runner": "3.2.4",
|
||||
"@vitest/snapshot": "3.2.4",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"chai": "^5.2.0",
|
||||
"debug": "^4.4.1",
|
||||
"expect-type": "^1.2.1",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.2",
|
||||
"std-env": "^3.9.0",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^0.3.2",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"tinypool": "^1.1.1",
|
||||
"tinyrainbow": "^2.0.0",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
|
||||
"vite-node": "3.2.4",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"@vitest/browser": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/debug": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
@@ -7486,6 +7958,16 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
|
||||
@@ -7610,6 +8092,23 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -7855,6 +8354,28 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
|
||||
+4
-1
@@ -7,6 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview",
|
||||
"generate:flyer": "node ../scripts/generate-beta-flyer.mjs",
|
||||
"generate:flyer:setup": "playwright install chromium"
|
||||
@@ -36,12 +37,14 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"playwright": "^1.51.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pwa": "^1.0.1"
|
||||
"vite-plugin-pwa": "^1.0.1",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
+617
-16
@@ -129,6 +129,209 @@ select.input-text {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.time-input-24h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.time-input-24h__select {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.time-input-24h__sep {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: var(--app-text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.course-dial-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.course-dial-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.course-dial-tab {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--app-border-subtle);
|
||||
background: var(--app-btn-secondary-bg);
|
||||
color: var(--app-text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.course-dial-tab.is-active {
|
||||
background: var(--app-accent-bg);
|
||||
border-color: var(--app-accent-border);
|
||||
color: var(--app-accent-light);
|
||||
}
|
||||
|
||||
.course-dial-tab:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.course-dial {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 260px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.course-dial--sm {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.course-dial--disabled {
|
||||
opacity: 0.65;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.course-dial__step-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.course-dial__step-btn {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--app-border-subtle);
|
||||
background: var(--app-surface-alt);
|
||||
color: var(--app-text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.course-dial__step-btn.is-active {
|
||||
border-color: var(--app-accent-border);
|
||||
background: var(--app-accent-bg);
|
||||
color: var(--app-accent-light);
|
||||
}
|
||||
|
||||
.course-dial__ring-wrap {
|
||||
width: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.course-dial__svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.course-dial__track {
|
||||
fill: none;
|
||||
stroke: var(--app-border);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.course-dial__tick {
|
||||
stroke: var(--app-text-subtle);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.course-dial__label {
|
||||
fill: var(--app-text-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.course-dial__needle line {
|
||||
stroke: var(--app-accent-light);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.course-dial__needle circle {
|
||||
fill: var(--app-accent-light);
|
||||
}
|
||||
|
||||
.course-dial__center {
|
||||
fill: var(--app-text);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.course-dial__hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--app-text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.course-dial__error {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
color: #f87171;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.course-dial__input {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.course-dial__mode-toggle {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--app-accent-light);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.course-dial__needle {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.course-dial {
|
||||
max-width: min(72vw, 220px);
|
||||
}
|
||||
|
||||
.course-dial--sm {
|
||||
max-width: min(68vw, 200px);
|
||||
}
|
||||
|
||||
.course-dial__label {
|
||||
font-size: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.themed-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -732,17 +935,13 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.skipper-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.skipper-badge.btn-icon {
|
||||
width: auto;
|
||||
border-radius: 18px;
|
||||
padding: 0 12px;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
color: var(--app-text-muted);
|
||||
cursor: default;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -800,6 +999,274 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.profile-main {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dashboard-header--profile .profile-header-brand {
|
||||
align-items: flex-start;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-back-btn {
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-dl {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-dl-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 200px) minmax(0, 1fr);
|
||||
gap: 8px 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.profile-dl-row dt {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
text-align: left;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.profile-dl-row dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
word-break: break-word;
|
||||
text-align: left;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.profile-user-id {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-user-id code {
|
||||
font-size: 12px;
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.profile-copy-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.profile-section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.profile-section-desc,
|
||||
.profile-pin-status,
|
||||
.profile-empty {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.profile-pin-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-pin-form .input-group label {
|
||||
display: block;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
color: var(--app-text-muted);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-main .form-actions:not(.account-danger-zone__actions) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.profile-passkey-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-passkey-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(148, 163, 184, 0.06);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.profile-passkey-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-passkey-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--app-text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.profile-passkey-rename {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.profile-passkey-rename .input-text {
|
||||
flex: 1 1 160px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-add-passkey {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.profile-add-passkey .input-group label {
|
||||
display: block;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
color: var(--app-text-muted);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-security-list {
|
||||
list-style: none;
|
||||
margin: 0 0 12px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-security-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.profile-security-item--ok {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.profile-security-item--warn {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.profile-recovery-hint {
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.profile-recovery-actions {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.profile-recovery-actions .btn {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.profile-recovery-card .phrase-grid {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.profile-recovery-warning {
|
||||
margin: 0 0 20px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #fbbf24;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.profile-device-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.account-danger-zone__hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.profile-passkey-id {
|
||||
display: block;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.profile-passkey-transports {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--app-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.profile-dl-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dashboard-header--profile .profile-header-brand {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-back-btn {
|
||||
margin-top: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.account-danger-zone {
|
||||
border-top: 1px solid rgba(239, 68, 68, 0.2);
|
||||
padding-top: 24px;
|
||||
@@ -984,7 +1451,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
border-radius: var(--app-radius-card);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
@@ -1028,10 +1495,65 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-title-row h3 {
|
||||
margin: 0;
|
||||
flex: 1 1 8rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-title-row .role-badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logbook-card-actions {
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.logbook-card-actions .btn-delete {
|
||||
position: static;
|
||||
top: auto;
|
||||
right: auto;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.logbook-card:hover .logbook-card-actions .btn-delete,
|
||||
.logbook-card:focus-within .logbook-card-actions .btn-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.logbook-card-actions .btn-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.logbook-title-editable {
|
||||
cursor: text;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.logbook-title-editable:hover {
|
||||
background: var(--app-accent-bg);
|
||||
}
|
||||
|
||||
.logbook-title-inline-edit {
|
||||
flex: 1 1 8rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 2px 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
@@ -1737,6 +2259,11 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skipper-badge.btn-icon {
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
padding: 8px 10px;
|
||||
flex-shrink: 0;
|
||||
@@ -1782,15 +2309,28 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
}
|
||||
|
||||
.logbook-card {
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logbook-card-actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.logbook-card-actions .btn-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-title-row h3,
|
||||
.logbook-title-inline-edit {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.card-info h3 {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
@@ -1893,6 +2433,32 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
.track-map-container {
|
||||
height: min(360px, 45svh);
|
||||
}
|
||||
|
||||
.sails-picker-pills {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sails-picker-container.is-collapsible .sails-picker-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.sail-pill {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
border-radius: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.sails-picker-container.is-collapsible.is-collapsed .sails-picker-pills {
|
||||
max-height: 3.25rem;
|
||||
}
|
||||
|
||||
.sail-pill {
|
||||
padding: 2px 7px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================== */
|
||||
@@ -2153,16 +2719,48 @@ html.theme-cupertino .events-scroll-container {
|
||||
|
||||
/* Event Editor Interactive Sails Picker */
|
||||
.sails-picker-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
grid-column: 1 / -1;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.sails-picker-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sails-picker-container.is-collapsible.is-collapsed .sails-picker-pills {
|
||||
max-height: 3.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sails-picker-container.is-collapsible.is-collapsed .sails-picker-pills::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1.25rem;
|
||||
background: linear-gradient(to bottom, transparent, var(--app-surface, #0f172a));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sails-picker-toggle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 4px auto 0;
|
||||
padding: 2px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--app-text-muted, #94a3b8);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sails-picker-toggle:hover {
|
||||
color: var(--app-accent, #fbbf24);
|
||||
}
|
||||
|
||||
.sail-pill {
|
||||
@@ -2175,6 +2773,7 @@ html.theme-cupertino .events-scroll-container {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sail-pill:hover {
|
||||
@@ -2202,7 +2801,9 @@ html.theme-cupertino .events-scroll-container {
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
border-color: #38bdf8;
|
||||
color: #38bdf8;
|
||||
}.grid-span-2 {
|
||||
}
|
||||
|
||||
.grid-span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
|
||||
+87
-21
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import './App.css'
|
||||
import { DialogProvider } from './components/ModalDialog.tsx'
|
||||
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||
import UserProfilePage from './components/UserProfilePage.tsx'
|
||||
import LogbookDashboard from './components/LogbookDashboard.tsx'
|
||||
import VesselForm from './components/VesselForm.tsx'
|
||||
import CrewForm from './components/CrewForm.tsx'
|
||||
@@ -14,7 +15,13 @@ import InvitationAcceptance from './components/InvitationAcceptance.tsx'
|
||||
import AppTourOverlay from './components/AppTourOverlay.tsx'
|
||||
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
|
||||
import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.tsx'
|
||||
import { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js'
|
||||
import {
|
||||
logoutUser,
|
||||
checkServerSession,
|
||||
hasUnlockedLocalSession,
|
||||
persistSessionUserId
|
||||
} from './services/auth.js'
|
||||
import AppErrorBoundary from './components/AppErrorBoundary.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
||||
import {
|
||||
applyAppearanceToDocument,
|
||||
@@ -61,6 +68,7 @@ function App() {
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||
const [showUserProfile, setShowUserProfile] = useState(false)
|
||||
|
||||
// Viewer mode for read-only shared links
|
||||
const [isViewerMode, setIsViewerMode] = useState(false)
|
||||
@@ -206,6 +214,53 @@ function App() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearAuthenticatedAppState = useCallback(() => {
|
||||
setIsAuthenticated(false)
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
setShowUserProfile(false)
|
||||
setTourSelectedEntryId(null)
|
||||
setDemoHighlightEntryId(null)
|
||||
}, [])
|
||||
|
||||
/** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */
|
||||
const enforceUnlockedSession = useCallback(() => {
|
||||
if (isViewerMode || isDemoMode || isAcceptingInvite) return
|
||||
// Require full local session (incl. userId) so API calls are not left headless.
|
||||
if (isAuthenticated && !hasUnlockedLocalSession()) {
|
||||
clearAuthenticatedAppState()
|
||||
}
|
||||
}, [
|
||||
isAuthenticated,
|
||||
isViewerMode,
|
||||
isDemoMode,
|
||||
isAcceptingInvite,
|
||||
clearAuthenticatedAppState
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
enforceUnlockedSession()
|
||||
}, [enforceUnlockedSession])
|
||||
|
||||
useEffect(() => {
|
||||
const onPageShow = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) {
|
||||
enforceUnlockedSession()
|
||||
}
|
||||
}
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
enforceUnlockedSession()
|
||||
}
|
||||
}
|
||||
window.addEventListener('pageshow', onPageShow)
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
return () => {
|
||||
window.removeEventListener('pageshow', onPageShow)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
}, [enforceUnlockedSession])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
@@ -214,13 +269,12 @@ function App() {
|
||||
const session = await checkServerSession()
|
||||
if (cancelled) return
|
||||
|
||||
if (session.authenticated && session.userId) {
|
||||
localStorage.setItem('active_userid', session.userId)
|
||||
if (session.authenticated) {
|
||||
persistSessionUserId(session.userId)
|
||||
}
|
||||
|
||||
const savedUser = localStorage.getItem('active_username')
|
||||
const key = getActiveMasterKey()
|
||||
if (session.authenticated && savedUser && key) {
|
||||
// Cookie alone is insufficient — need in-memory master key, username, and userId for API.
|
||||
if (session.authenticated && hasUnlockedLocalSession()) {
|
||||
setIsAuthenticated(true)
|
||||
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
||||
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
||||
@@ -229,6 +283,7 @@ function App() {
|
||||
setActiveLogbookTitle(savedLogbookTitle)
|
||||
}
|
||||
}
|
||||
// authenticated + crypto but no userId: stay on login (enforceUnlockedSession guards active UI)
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.warn('Session restore failed:', err)
|
||||
@@ -239,7 +294,7 @@ function App() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [clearAuthenticatedAppState])
|
||||
|
||||
useEffect(() => {
|
||||
syncRouteFromLocation()
|
||||
@@ -361,6 +416,7 @@ function App() {
|
||||
setIsAuthenticated(false)
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
setShowUserProfile(false)
|
||||
setTourSelectedEntryId(null)
|
||||
setDemoHighlightEntryId(null)
|
||||
localStorage.removeItem('active_logbook_id')
|
||||
@@ -442,10 +498,18 @@ function App() {
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
{pwaInstallBanner}
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={selectLogbook}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
{showUserProfile ? (
|
||||
<UserProfilePage
|
||||
onBack={() => setShowUserProfile(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
) : (
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={selectLogbook}
|
||||
onLogout={handleLogout}
|
||||
onOpenProfile={() => setShowUserProfile(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -619,15 +683,17 @@ function App() {
|
||||
|
||||
export default function AppWrapper() {
|
||||
return (
|
||||
<DialogProvider>
|
||||
<UnsavedChangesProvider>
|
||||
<AppTourProvider>
|
||||
<PwaUpdatePrompt />
|
||||
<App />
|
||||
<AppTourOverlay />
|
||||
</AppTourProvider>
|
||||
<AppFooter />
|
||||
</UnsavedChangesProvider>
|
||||
</DialogProvider>
|
||||
<AppErrorBoundary>
|
||||
<DialogProvider>
|
||||
<UnsavedChangesProvider>
|
||||
<AppTourProvider>
|
||||
<PwaUpdatePrompt />
|
||||
<App />
|
||||
<AppTourOverlay />
|
||||
</AppTourProvider>
|
||||
<AppFooter />
|
||||
</UnsavedChangesProvider>
|
||||
</DialogProvider>
|
||||
</AppErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function AccountDangerZone({ className = '' }: AccountDangerZoneP
|
||||
</div>
|
||||
|
||||
<p className="account-danger-zone__desc">{t('settings.danger_zone_desc')}</p>
|
||||
<p className="account-danger-zone__hint">{t('settings.delete_backup_hint')}</p>
|
||||
|
||||
<div className="form-actions account-danger-zone__actions">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export default class AppErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('Unhandled app error:', error, info.componentStack)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.error) {
|
||||
return this.props.children
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-screen">
|
||||
<div className="auth-card glass" role="alert">
|
||||
<h2 style={{ marginTop: 0 }}>Kapteins Daagbok</h2>
|
||||
<p style={{ color: 'var(--app-text-muted)', lineHeight: 1.5 }}>
|
||||
Die App ist nach dem Neustart in einen fehlerhaften Zustand geraten. Bitte neu laden
|
||||
oder die App vollständig beenden und erneut öffnen.
|
||||
</p>
|
||||
<button type="button" className="btn primary" style={{ width: '100%', marginTop: 16 }} onClick={() => window.location.reload()}>
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../services/auth.js'
|
||||
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||
import DisclaimerModal from './DisclaimerModal.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
|
||||
interface AuthOnboardingProps {
|
||||
@@ -50,6 +51,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
|
||||
const [isNewRegistration, setIsNewRegistration] = useState(false)
|
||||
const [showDisclaimer, setShowDisclaimer] = useState(false)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
const finishAuth = () => {
|
||||
if (isNewRegistration) {
|
||||
@@ -410,6 +412,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
|
||||
// Render 3: Standard Login / Registration options form
|
||||
return (
|
||||
<>
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-brand">
|
||||
<img src="/logo.png" alt="Kapteins Daagbok" className="auth-logo-img" />
|
||||
@@ -570,15 +573,23 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
</div>
|
||||
|
||||
<div className="auth-footer">
|
||||
<button className="btn-icon-text" onClick={toggleLanguage}>
|
||||
<button type="button" className="btn-icon-text" onClick={toggleLanguage}>
|
||||
<Languages size={18} />
|
||||
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
|
||||
</button>
|
||||
<a href="#help" className="btn-icon-text link-sec">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon-text link-sec"
|
||||
onClick={() => setShowHelp(true)}
|
||||
title={t('disclaimer.button_title')}
|
||||
aria-label={t('disclaimer.button_title')}
|
||||
>
|
||||
<HelpCircle size={18} />
|
||||
{t('auth.help')}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DisclaimerModal open={showHelp} onClose={() => setShowHelp(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useCallback, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
type CourseOutputMode,
|
||||
type CourseStep,
|
||||
dialDegreesToStorageValue,
|
||||
formatCourseAngle,
|
||||
formatCourseDisplay,
|
||||
isCardinalDirection,
|
||||
loadCourseDialStep,
|
||||
parseCourseAngle,
|
||||
pointerAngleToDegrees,
|
||||
resolveCourseOutputMode,
|
||||
saveCourseDialStep,
|
||||
snapDegrees,
|
||||
valueToDialDegrees
|
||||
} from '../utils/courseAngle.js'
|
||||
|
||||
interface CourseDialInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
step?: CourseStep
|
||||
allowCardinal?: boolean
|
||||
displayMode?: 'degrees' | 'cardinal' | 'auto'
|
||||
size?: 'md' | 'sm'
|
||||
'aria-label': string
|
||||
id?: string
|
||||
}
|
||||
|
||||
const TICK_DEGREES = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]
|
||||
|
||||
function polarPoint(degrees: number, radius: number): { x: number; y: number } {
|
||||
const rad = (degrees * Math.PI) / 180
|
||||
return {
|
||||
x: 100 + Math.sin(rad) * radius,
|
||||
y: 100 - Math.cos(rad) * radius
|
||||
}
|
||||
}
|
||||
|
||||
export default function CourseDialInput({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
step: stepProp,
|
||||
allowCardinal = false,
|
||||
displayMode = 'degrees',
|
||||
size = 'md',
|
||||
'aria-label': ariaLabel,
|
||||
id: idProp
|
||||
}: CourseDialInputProps) {
|
||||
const { t } = useTranslation()
|
||||
const generatedId = useId()
|
||||
const inputId = idProp ?? `${generatedId}-input`
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const [step, setStep] = useState<CourseStep>(() => stepProp ?? loadCourseDialStep())
|
||||
const [inputDraft, setInputDraft] = useState<string | null>(null)
|
||||
const [inputError, setInputError] = useState<string | null>(null)
|
||||
const [outputModeOverride, setOutputModeOverride] = useState<CourseOutputMode | null>(null)
|
||||
|
||||
const effectiveStep = stepProp ?? step
|
||||
const outputMode =
|
||||
outputModeOverride ??
|
||||
resolveCourseOutputMode(value, displayMode, allowCardinal)
|
||||
|
||||
const dialDegrees = useMemo(
|
||||
() => snapDegrees(valueToDialDegrees(value, allowCardinal), effectiveStep),
|
||||
[value, allowCardinal, effectiveStep]
|
||||
)
|
||||
|
||||
const centerLabel = useMemo(
|
||||
() => formatCourseDisplay(value, allowCardinal),
|
||||
[value, allowCardinal]
|
||||
)
|
||||
|
||||
const tickLabel = useCallback(
|
||||
(degrees: number) => {
|
||||
if (degrees === 0) return t('logs.compass_n')
|
||||
if (degrees === 90) return t('logs.compass_e')
|
||||
if (degrees === 180) return t('logs.compass_s')
|
||||
if (degrees === 270) return t('logs.compass_w')
|
||||
return String(degrees).padStart(3, '0')
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
const applyDegrees = useCallback(
|
||||
(degrees: number) => {
|
||||
onChange(dialDegreesToStorageValue(degrees, outputMode, effectiveStep))
|
||||
setInputDraft(null)
|
||||
setInputError(null)
|
||||
},
|
||||
[onChange, outputMode, effectiveStep]
|
||||
)
|
||||
|
||||
const updateFromPointer = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
const svg = svgRef.current
|
||||
if (!svg || disabled) return
|
||||
const rect = svg.getBoundingClientRect()
|
||||
const cx = rect.left + rect.width / 2
|
||||
const cy = rect.top + rect.height / 2
|
||||
const raw = pointerAngleToDegrees(clientX, clientY, cx, cy)
|
||||
applyDegrees(raw)
|
||||
},
|
||||
[applyDegrees, disabled]
|
||||
)
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (disabled) return
|
||||
e.preventDefault()
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
updateFromPointer(e.clientX, e.clientY)
|
||||
}
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (disabled || !e.currentTarget.hasPointerCapture(e.pointerId)) return
|
||||
updateFromPointer(e.clientX, e.clientY)
|
||||
}
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputDraft(e.target.value)
|
||||
}
|
||||
|
||||
const commitInput = () => {
|
||||
const draft = (inputDraft ?? value).trim()
|
||||
setInputDraft(null)
|
||||
if (!draft) {
|
||||
onChange('')
|
||||
setInputError(null)
|
||||
return
|
||||
}
|
||||
if (allowCardinal && outputMode === 'cardinal' && isCardinalDirection(draft)) {
|
||||
onChange(draft.toUpperCase())
|
||||
setInputError(null)
|
||||
return
|
||||
}
|
||||
const parsed = parseCourseAngle(draft)
|
||||
if (parsed === null) {
|
||||
setInputError(t('logs.course_invalid'))
|
||||
return
|
||||
}
|
||||
onChange(formatCourseAngle(snapDegrees(parsed, effectiveStep)))
|
||||
setInputError(null)
|
||||
}
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
commitInput()
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
const base = parseCourseAngle(value) ?? dialDegrees
|
||||
const delta = e.key === 'ArrowUp' ? effectiveStep : -effectiveStep
|
||||
applyDegrees(base + delta)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStepChange = (next: CourseStep) => {
|
||||
if (stepProp !== undefined) return
|
||||
setStep(next)
|
||||
saveCourseDialStep(next)
|
||||
const parsed = parseCourseAngle(value)
|
||||
if (parsed !== null) {
|
||||
onChange(formatCourseAngle(snapDegrees(parsed, next)))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleOutputMode = () => {
|
||||
const next: CourseOutputMode = outputMode === 'cardinal' ? 'degrees' : 'cardinal'
|
||||
setOutputModeOverride(next)
|
||||
const deg = valueToDialDegrees(value, allowCardinal)
|
||||
onChange(dialDegreesToStorageValue(deg, next, effectiveStep))
|
||||
}
|
||||
|
||||
const inputValue = inputDraft ?? value
|
||||
const sliderNow = dialDegrees
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`course-dial course-dial--${size}${disabled ? ' course-dial--disabled' : ''}`}
|
||||
>
|
||||
{!stepProp && (
|
||||
<div className="course-dial__step-toolbar" role="group" aria-label={t('logs.course_dial_step_label')}>
|
||||
{([1, 5, 10] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={`course-dial__step-btn${effectiveStep === s ? ' is-active' : ''}`}
|
||||
onClick={() => handleStepChange(s)}
|
||||
disabled={disabled}
|
||||
aria-pressed={effectiveStep === s}
|
||||
>
|
||||
{s === 1 ? t('logs.course_step_fine') : s === 5 ? t('logs.course_step_medium') : t('logs.course_step_coarse')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="course-dial__ring-wrap"
|
||||
role="slider"
|
||||
aria-label={ariaLabel}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={360}
|
||||
aria-valuenow={sliderNow}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="course-dial__svg"
|
||||
viewBox="0 0 200 200"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
<circle className="course-dial__track" cx="100" cy="100" r="88" />
|
||||
{TICK_DEGREES.map((deg) => {
|
||||
const inner = polarPoint(deg, 76)
|
||||
const outer = polarPoint(deg, 88)
|
||||
const label = polarPoint(deg, 64)
|
||||
return (
|
||||
<g key={deg}>
|
||||
<line className="course-dial__tick" x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} />
|
||||
<text className="course-dial__label" x={label.x} y={label.y} textAnchor="middle" dominantBaseline="middle">
|
||||
{tickLabel(deg)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
<g className="course-dial__needle" transform={`rotate(${dialDegrees} 100 100)`}>
|
||||
<line x1="100" y1="100" x2="100" y2="28" />
|
||||
<circle cx="100" cy="100" r="6" />
|
||||
</g>
|
||||
<text className="course-dial__center" x="100" y="100" textAnchor="middle" dominantBaseline="middle">
|
||||
{centerLabel}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p className="course-dial__hint">{t('logs.course_dial_hint')}</p>
|
||||
|
||||
<input
|
||||
id={inputId}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className="input-text course-dial__input"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={commitInput}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder={
|
||||
outputMode === 'cardinal'
|
||||
? t('logs.course_placeholder_cardinal')
|
||||
: t('logs.course_placeholder_degrees')
|
||||
}
|
||||
aria-label={ariaLabel}
|
||||
aria-invalid={inputError ? true : undefined}
|
||||
/>
|
||||
|
||||
{inputError && <p className="course-dial__error">{inputError}</p>}
|
||||
|
||||
{allowCardinal && displayMode === 'auto' && (
|
||||
<button
|
||||
type="button"
|
||||
className="course-dial__mode-toggle"
|
||||
onClick={toggleOutputMode}
|
||||
disabled={disabled}
|
||||
>
|
||||
{outputMode === 'cardinal' ? t('logs.wind_mode_degrees') : t('logs.wind_mode_cardinal')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useId, useMemo } from 'react'
|
||||
import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js'
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
|
||||
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
|
||||
|
||||
interface EventTimeInput24hProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
export default function EventTimeInput24h({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
'aria-label': ariaLabel
|
||||
}: EventTimeInput24hProps) {
|
||||
const baseId = useId()
|
||||
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
|
||||
|
||||
return (
|
||||
<div className="time-input-24h">
|
||||
<select
|
||||
id={`${baseId}-hours`}
|
||||
className="input-text time-input-24h__select"
|
||||
value={hours}
|
||||
onChange={(e) => onChange(joinTimeHHMM(e.target.value, minutes))}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel ? `${ariaLabel} (h)` : undefined}
|
||||
>
|
||||
{HOURS.map((hour) => (
|
||||
<option key={hour} value={hour}>
|
||||
{hour}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="time-input-24h__sep" aria-hidden="true">
|
||||
:
|
||||
</span>
|
||||
<select
|
||||
id={`${baseId}-minutes`}
|
||||
className="input-text time-input-24h__select"
|
||||
value={minutes}
|
||||
onChange={(e) => onChange(joinTimeHHMM(hours, e.target.value))}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel ? `${ariaLabel} (min)` : undefined}
|
||||
>
|
||||
{MINUTES.map((minute) => (
|
||||
<option key={minute} value={minute}>
|
||||
{minute}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X } from 'lucide-react'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import PhotoCapture from './PhotoCapture.tsx'
|
||||
import SignatureSection from './SignatureSection.tsx'
|
||||
import TrackMap from './TrackMap.tsx'
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
hasAnySignature
|
||||
} from '../utils/signatures.js'
|
||||
import type { SignatureValue } from '../types/signatures.js'
|
||||
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import EventTimeInput24h from './EventTimeInput24h.tsx'
|
||||
import CourseDialInput from './CourseDialInput.tsx'
|
||||
import { degreesToCardinal } from '../utils/courseAngle.js'
|
||||
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
||||
import { signLogEntry } from '../services/entrySigning.js'
|
||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||
@@ -162,7 +165,7 @@ export default function LogEntryEditor({
|
||||
const [events, setEvents] = useState<LogEvent[]>([])
|
||||
|
||||
// Add Event Form State
|
||||
const [evTime, setEvTime] = useState('')
|
||||
const [evTime, setEvTime] = useState(() => currentLocalTimeHHMM())
|
||||
const [evMgk, setEvMgk] = useState('')
|
||||
const [evRwk, setEvRwk] = useState('')
|
||||
const [evWindPressure, setEvWindPressure] = useState('')
|
||||
@@ -173,12 +176,14 @@ export default function LogEntryEditor({
|
||||
const [evCurrent, setEvCurrent] = useState('')
|
||||
const [evHeel, setEvHeel] = useState('')
|
||||
const [evSailsOrMotor, setEvSailsOrMotor] = useState('')
|
||||
const [sailsPickerExpanded, setSailsPickerExpanded] = useState(false)
|
||||
const [evLogReading, setEvLogReading] = useState('')
|
||||
const [evDistance, setEvDistance] = useState('')
|
||||
const [evGpsLat, setEvGpsLat] = useState('')
|
||||
const [evGpsLng, setEvGpsLng] = useState('')
|
||||
const [evRemarks, setEvRemarks] = useState('')
|
||||
const [evLocationName, setEvLocationName] = useState('')
|
||||
const [activeCourseTab, setActiveCourseTab] = useState<'mgk' | 'rwk'>('mgk')
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -813,10 +818,7 @@ export default function LogEntryEditor({
|
||||
|
||||
// Calculate wind compass direction sector
|
||||
if (wind?.deg !== undefined) {
|
||||
const deg = wind.deg
|
||||
const sectors = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
|
||||
const index = Math.round(deg / 22.5) % 16
|
||||
setEvWindDirection(sectors[index])
|
||||
setEvWindDirection(degreesToCardinal(wind.deg))
|
||||
}
|
||||
|
||||
if (data.weather && Array.isArray(data.weather) && data.weather[0]) {
|
||||
@@ -841,6 +843,9 @@ export default function LogEntryEditor({
|
||||
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
|
||||
: ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker']
|
||||
|
||||
const eventSailOptions = yachtSails.length > 0 ? yachtSails : defaultSails
|
||||
const showSailsPickerToggle = eventSailOptions.length + 1 > 6
|
||||
|
||||
const toggleSailOrMotor = (item: string) => {
|
||||
let currentItems = evSailsOrMotor
|
||||
.split(/\s*(?:\+|\bplus\b|,)\s*/i)
|
||||
@@ -864,8 +869,17 @@ export default function LogEntryEditor({
|
||||
return currentItems.includes(item.toLowerCase())
|
||||
}
|
||||
|
||||
const motorPropulsionLabel = t('logs.motor_propulsion')
|
||||
const sortedEventSailOptions = [...eventSailOptions].sort((a, b) => {
|
||||
const aActive = isItemActive(a)
|
||||
const bActive = isItemActive(b)
|
||||
if (aActive === bActive) return 0
|
||||
return aActive ? -1 : 1
|
||||
})
|
||||
const isMotorActive = isItemActive(motorPropulsionLabel)
|
||||
|
||||
const clearEventForm = () => {
|
||||
setEvTime('')
|
||||
setEvTime(currentLocalTimeHHMM())
|
||||
setEvMgk('')
|
||||
setEvRwk('')
|
||||
setEvWindPressure('')
|
||||
@@ -926,7 +940,7 @@ export default function LogEntryEditor({
|
||||
|
||||
const handleSaveEvent = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (readOnly || !evTime) return
|
||||
if (readOnly || !isValidTimeHHMM(evTime)) return
|
||||
|
||||
const eventData = buildEventFromForm()
|
||||
const isEdit = editingEventIndex !== null
|
||||
@@ -1368,36 +1382,46 @@ export default function LogEntryEditor({
|
||||
<Clock size={12} style={{ display: 'inline', marginRight: 4 }} />
|
||||
{t('logs.event_time')} *
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
className="input-text"
|
||||
<EventTimeInput24h
|
||||
value={evTime}
|
||||
onChange={(e) => setEvTime(e.target.value)}
|
||||
onChange={setEvTime}
|
||||
disabled={saving}
|
||||
aria-label={t('logs.event_time')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.event_mgk')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 180"
|
||||
className="input-text"
|
||||
value={evMgk}
|
||||
onChange={(e) => setEvMgk(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.event_rwk')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 185"
|
||||
className="input-text"
|
||||
value={evRwk}
|
||||
onChange={(e) => setEvRwk(e.target.value)}
|
||||
<div className="input-group course-dial-section">
|
||||
<label>
|
||||
<Compass size={12} style={{ display: 'inline', marginRight: 4 }} />
|
||||
{t('logs.event_course_section')}
|
||||
</label>
|
||||
<div className="course-dial-tabs" role="tablist" aria-label={t('logs.event_course_section')}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeCourseTab === 'mgk'}
|
||||
className={`course-dial-tab${activeCourseTab === 'mgk' ? ' is-active' : ''}`}
|
||||
onClick={() => setActiveCourseTab('mgk')}
|
||||
disabled={saving}
|
||||
>
|
||||
{t('logs.course_tab_mgk')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeCourseTab === 'rwk'}
|
||||
className={`course-dial-tab${activeCourseTab === 'rwk' ? ' is-active' : ''}`}
|
||||
onClick={() => setActiveCourseTab('rwk')}
|
||||
disabled={saving}
|
||||
>
|
||||
{t('logs.course_tab_rwk')}
|
||||
</button>
|
||||
</div>
|
||||
<CourseDialInput
|
||||
value={activeCourseTab === 'mgk' ? evMgk : evRwk}
|
||||
onChange={activeCourseTab === 'mgk' ? setEvMgk : setEvRwk}
|
||||
disabled={saving}
|
||||
aria-label={activeCourseTab === 'mgk' ? t('logs.event_mgk') : t('logs.event_rwk')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1476,15 +1500,15 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
|
||||
<div className="form-grid mb-4">
|
||||
<div className="input-group">
|
||||
<div className="input-group course-dial-section">
|
||||
<label>{t('logs.event_wind_direction')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. NNE"
|
||||
className="input-text"
|
||||
<CourseDialInput
|
||||
value={evWindDirection}
|
||||
onChange={(e) => setEvWindDirection(e.target.value)}
|
||||
onChange={setEvWindDirection}
|
||||
disabled={saving || weatherLoading}
|
||||
allowCardinal
|
||||
displayMode="auto"
|
||||
aria-label={t('logs.event_wind_direction')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1548,25 +1572,6 @@ export default function LogEntryEditor({
|
||||
onChange={(e) => setEvSailsOrMotor(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<div className="sails-picker-container">
|
||||
<div className="sails-picker-pills">
|
||||
{(yachtSails.length > 0 ? yachtSails : defaultSails).map((sail) => (
|
||||
<span
|
||||
key={sail}
|
||||
className={`sail-pill ${isItemActive(sail) ? 'active' : ''}`}
|
||||
onClick={() => toggleSailOrMotor(sail)}
|
||||
>
|
||||
{sail}
|
||||
</span>
|
||||
))}
|
||||
<span
|
||||
className={`sail-pill motor-pill ${isItemActive(t('logs.motor_propulsion')) ? 'active' : ''}`}
|
||||
onClick={() => toggleSailOrMotor(t('logs.motor_propulsion'))}
|
||||
>
|
||||
{t('logs.motor_propulsion')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
@@ -1581,7 +1586,63 @@ export default function LogEntryEditor({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group" style={{ gridColumn: 'span 2' }}>
|
||||
<div
|
||||
className={[
|
||||
'sails-picker-container grid-span-2',
|
||||
showSailsPickerToggle ? 'is-collapsible' : '',
|
||||
showSailsPickerToggle && !sailsPickerExpanded ? 'is-collapsed' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<div className="sails-picker-pills">
|
||||
{isMotorActive && (
|
||||
<span
|
||||
className={`sail-pill motor-pill active`}
|
||||
onClick={() => toggleSailOrMotor(motorPropulsionLabel)}
|
||||
>
|
||||
{motorPropulsionLabel}
|
||||
</span>
|
||||
)}
|
||||
{sortedEventSailOptions.map((sail) => (
|
||||
<span
|
||||
key={sail}
|
||||
className={`sail-pill ${isItemActive(sail) ? 'active' : ''}`}
|
||||
onClick={() => toggleSailOrMotor(sail)}
|
||||
>
|
||||
{sail}
|
||||
</span>
|
||||
))}
|
||||
{!isMotorActive && (
|
||||
<span
|
||||
className="sail-pill motor-pill"
|
||||
onClick={() => toggleSailOrMotor(motorPropulsionLabel)}
|
||||
>
|
||||
{motorPropulsionLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showSailsPickerToggle && (
|
||||
<button
|
||||
type="button"
|
||||
className="sails-picker-toggle"
|
||||
onClick={() => setSailsPickerExpanded((prev) => !prev)}
|
||||
aria-expanded={sailsPickerExpanded}
|
||||
>
|
||||
{sailsPickerExpanded ? (
|
||||
<>
|
||||
<ChevronUp size={14} aria-hidden="true" />
|
||||
{t('logs.sails_picker_show_less')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
{t('logs.sails_picker_show_more')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="input-group grid-span-2">
|
||||
<label>{t('logs.event_remarks')}</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -1611,7 +1672,7 @@ export default function LogEntryEditor({
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleSaveEvent}
|
||||
disabled={saving || !evTime}
|
||||
disabled={saving || !isValidTimeHHMM(evTime)}
|
||||
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
|
||||
>
|
||||
{editingEventIndex !== null ? <Save size={16} /> : <Plus size={16} />}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type LogbookBackupPreview
|
||||
} from '../services/logbookBackup.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||
|
||||
interface LogbookBackupPanelProps {
|
||||
logbookId: string
|
||||
@@ -41,7 +42,7 @@ function mapBackupError(code: string, t: (key: string) => string): string {
|
||||
}
|
||||
|
||||
export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBackupPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -334,7 +335,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
</ul>
|
||||
<p className="text-muted backup-preview-date">
|
||||
{t('settings.backup_exported_at', {
|
||||
date: new Date(importPreview.exportedAt).toLocaleString()
|
||||
date: formatAppDateTime(importPreview.exportedAt, i18n.language)
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { db } from '../services/db.js'
|
||||
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js'
|
||||
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
||||
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { logoutUser } from '../services/auth.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
|
||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||
@@ -16,13 +15,17 @@ import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||
interface LogbookDashboardProps {
|
||||
onSelectLogbook: (id: string, title: string) => void
|
||||
onLogout: () => void
|
||||
onOpenProfile: () => void
|
||||
}
|
||||
|
||||
export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookDashboardProps) {
|
||||
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [editingLogbookId, setEditingLogbookId] = useState<string | null>(null)
|
||||
const [editingTitleDraft, setEditingTitleDraft] = useState('')
|
||||
const titleInputRef = useRef<HTMLInputElement>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -99,6 +102,49 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (editingLogbookId) {
|
||||
titleInputRef.current?.focus()
|
||||
titleInputRef.current?.select()
|
||||
}
|
||||
}, [editingLogbookId])
|
||||
|
||||
const startTitleEdit = (lb: DecryptedLogbook, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setEditingLogbookId(lb.id)
|
||||
setEditingTitleDraft(lb.title)
|
||||
}
|
||||
|
||||
const cancelTitleEdit = () => {
|
||||
setEditingLogbookId(null)
|
||||
setEditingTitleDraft('')
|
||||
}
|
||||
|
||||
const commitTitleEdit = async (id: string) => {
|
||||
if (editingLogbookId !== id) return
|
||||
|
||||
const lb = logbooks.find((item) => item.id === id)
|
||||
const trimmedTitle = editingTitleDraft.trim()
|
||||
cancelTitleEdit()
|
||||
|
||||
if (!lb || !trimmedTitle || trimmedTitle === lb.title.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateLogbookTitle(id, trimmedTitle)
|
||||
setLogbooks((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id ? { ...item, title: trimmedTitle, updatedAt: new Date().toISOString() } : item
|
||||
)
|
||||
)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to update logbook title')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
void logoutUser()
|
||||
onLogout()
|
||||
@@ -112,7 +158,10 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
||||
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
||||
|
||||
const renderLogbookCard = (lb: DecryptedLogbook) => (
|
||||
const renderLogbookCard = (lb: DecryptedLogbook) => {
|
||||
const isEditingTitle = editingLogbookId === lb.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lb.id}
|
||||
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
|
||||
@@ -124,7 +173,36 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
|
||||
<div className="card-info">
|
||||
<div className="card-title-row">
|
||||
<h3>{lb.title}</h3>
|
||||
{isEditingTitle ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
className="logbook-title-inline-edit input-text"
|
||||
value={editingTitleDraft}
|
||||
onChange={(e) => setEditingTitleDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
void commitTitleEdit(lb.id)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelTitleEdit()
|
||||
}
|
||||
}}
|
||||
onBlur={() => void commitTitleEdit(lb.id)}
|
||||
disabled={loading}
|
||||
aria-label={t('dashboard.edit_title')}
|
||||
/>
|
||||
) : (
|
||||
<h3
|
||||
className={lb.isShared ? undefined : 'logbook-title-editable'}
|
||||
onClick={lb.isShared ? undefined : (e) => startTitleEdit(lb, e)}
|
||||
title={lb.isShared ? undefined : t('dashboard.edit_title')}
|
||||
>
|
||||
{lb.title}
|
||||
</h3>
|
||||
)}
|
||||
<LogbookRoleBadge role={lb.accessRole} />
|
||||
</div>
|
||||
<div className="card-meta">
|
||||
@@ -144,16 +222,22 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-delete"
|
||||
onClick={(e) => handleDelete(lb.id, e)}
|
||||
title={t('dashboard.delete_btn')}
|
||||
style={{ visibility: lb.isShared ? 'hidden' : 'visible' }}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
{!lb.isShared && (
|
||||
<div className="logbook-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-delete"
|
||||
onClick={(e) => handleDelete(lb.id, e)}
|
||||
title={t('dashboard.delete_btn')}
|
||||
aria-label={t('dashboard.delete_btn')}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const renderLogbookSection = (
|
||||
title: string,
|
||||
@@ -210,14 +294,16 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
</div>
|
||||
|
||||
{/* Skipper profile */}
|
||||
<div
|
||||
className="skipper-badge"
|
||||
title={t('dashboard.logged_in_as', { name: username })}
|
||||
aria-label={t('dashboard.logged_in_as', { name: username })}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon skipper-badge"
|
||||
onClick={onOpenProfile}
|
||||
title={t('dashboard.open_profile', { name: username })}
|
||||
aria-label={t('dashboard.open_profile', { name: username })}
|
||||
>
|
||||
<User size={16} aria-hidden="true" />
|
||||
<User size={18} aria-hidden="true" />
|
||||
<span className="skipper-badge__name">{username}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Lang toggle */}
|
||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||
@@ -289,10 +375,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section className="dashboard-account-section" aria-label={t('settings.danger_zone_title')}>
|
||||
<AccountDangerZone />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Fingerprint, Loader2, AlertTriangle } from 'lucide-react'
|
||||
import type { PasskeySignature } from '../types/signatures.js'
|
||||
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||
|
||||
interface PasskeySignButtonProps {
|
||||
label: string
|
||||
@@ -42,9 +43,7 @@ export default function PasskeySignButton({
|
||||
}
|
||||
}
|
||||
|
||||
const formattedDate = signature
|
||||
? new Date(signature.signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
|
||||
: ''
|
||||
const formattedDate = signature ? formatAppDateTime(signature.signedAt, i18n.language) : ''
|
||||
|
||||
return (
|
||||
<div className="passkey-sign-block">
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react'
|
||||
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
||||
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
@@ -541,8 +540,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Danger Zone / Account Deletion */}
|
||||
<AccountDangerZone className="mt-6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import SignaturePad from './SignaturePad.tsx'
|
||||
import PasskeySignButton from './PasskeySignButton.tsx'
|
||||
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
|
||||
import { isPasskeySignature, getSignaturePayload, getSignatureAttribution } from '../utils/signatures.js'
|
||||
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||
|
||||
type SignatureMode = 'passkey' | 'classic'
|
||||
|
||||
@@ -30,9 +31,7 @@ function SignerAttributionBadge({ value }: { value: SignatureValue | '' }) {
|
||||
const attribution = getSignatureAttribution(value)
|
||||
if (!attribution) return null
|
||||
|
||||
const formattedDate = new Date(attribution.signedAt).toLocaleString(
|
||||
i18n.language === 'de' ? 'de-DE' : 'en-GB'
|
||||
)
|
||||
const formattedDate = formatAppDateTime(attribution.signedAt, i18n.language)
|
||||
|
||||
return (
|
||||
<div className="passkey-sign-badge valid signature-attribution-badge">
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import {
|
||||
User,
|
||||
ChevronLeft,
|
||||
LogOut,
|
||||
KeyRound,
|
||||
Copy,
|
||||
Check,
|
||||
Plus,
|
||||
Trash2,
|
||||
BookOpen,
|
||||
Anchor,
|
||||
Gauge,
|
||||
Sailboat,
|
||||
Timer,
|
||||
Share2,
|
||||
Calendar,
|
||||
Lock,
|
||||
BarChart2,
|
||||
Shield,
|
||||
Smartphone,
|
||||
RefreshCw,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
CircleCheck,
|
||||
CircleAlert
|
||||
} from 'lucide-react'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import {
|
||||
addPasskey,
|
||||
fetchUserProfile,
|
||||
forgetUsername,
|
||||
getActiveMasterKey,
|
||||
getKnownUsernames,
|
||||
hasLocalPin,
|
||||
removeLocalPin,
|
||||
removePasskey,
|
||||
renamePasskey,
|
||||
rotateRecoveryPhrase,
|
||||
setLocalPin,
|
||||
type UserProfile
|
||||
} from '../services/auth.js'
|
||||
import {
|
||||
formatHours,
|
||||
formatNm,
|
||||
loadAccountStats,
|
||||
type AccountStatsSummary
|
||||
} from '../services/statsAggregation.js'
|
||||
import { db } from '../services/db.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
interface UserProfilePageProps {
|
||||
onBack: () => void
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
function formatAccountAge(createdAt: string, locale: string): string {
|
||||
const created = new Date(createdAt)
|
||||
if (Number.isNaN(created.getTime())) return createdAt
|
||||
return created.toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
unit
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: string
|
||||
unit?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="stats-kpi-card glass">
|
||||
<div className="stats-kpi-icon">{icon}</div>
|
||||
<div className="stats-kpi-body">
|
||||
<span className="stats-kpi-label">{label}</span>
|
||||
<span className="stats-kpi-value">
|
||||
{value}
|
||||
{unit ? <span className="stats-kpi-unit">{unit}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SecurityCheckItem({ ok, label }: { ok: boolean; label: string }) {
|
||||
return (
|
||||
<li className={`profile-security-item ${ok ? 'profile-security-item--ok' : 'profile-security-item--warn'}`}>
|
||||
{ok ? <CircleCheck size={18} aria-hidden="true" /> : <CircleAlert size={18} aria-hidden="true" />}
|
||||
<span>{label}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UserProfilePage({ onBack, onLogout }: UserProfilePageProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showConfirm, showAlert } = useDialog()
|
||||
const username = localStorage.getItem('active_username') || 'Skipper'
|
||||
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null)
|
||||
const [accountStats, setAccountStats] = useState<AccountStatsSummary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copiedUserId, setCopiedUserId] = useState(false)
|
||||
const [passkeyBusy, setPasskeyBusy] = useState(false)
|
||||
const [pinBusy, setPinBusy] = useState(false)
|
||||
const [pinInput, setPinInput] = useState('')
|
||||
const [pinConfirm, setPinConfirm] = useState('')
|
||||
const [pinActive, setPinActive] = useState(() => hasLocalPin(username))
|
||||
const [newPasskeyLabel, setNewPasskeyLabel] = useState('')
|
||||
const [passkeyLabels, setPasskeyLabels] = useState<Record<string, string>>({})
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const [isKnownDevice, setIsKnownDevice] = useState(() =>
|
||||
getKnownUsernames().some((u) => u.toLowerCase() === username.toLowerCase())
|
||||
)
|
||||
const [recoveryBusy, setRecoveryBusy] = useState(false)
|
||||
const [pendingRecoveryPhrase, setPendingRecoveryPhrase] = useState<string | null>(null)
|
||||
const [recoveryCopied, setRecoveryCopied] = useState(false)
|
||||
|
||||
const pendingSyncCount = useLiveQuery(() => db.syncQueue.count()) ?? 0
|
||||
|
||||
const sharedLogbookCount = useLiveQuery(
|
||||
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
|
||||
[]
|
||||
) ?? 0
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const profileData = await fetchUserProfile()
|
||||
setProfile(profileData)
|
||||
|
||||
try {
|
||||
const stats = await loadAccountStats(false)
|
||||
setAccountStats(stats)
|
||||
} catch (statsErr) {
|
||||
console.error('Failed to load account stats for profile:', statsErr)
|
||||
setAccountStats(null)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('profile.load_error'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
useEffect(() => {
|
||||
trackPlausibleEvent(PlausibleEvents.PROFILE_OPENED)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setOnline(true)
|
||||
const handleOffline = () => setOnline(false)
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile) return
|
||||
const labels: Record<string, string> = {}
|
||||
for (const cred of profile.credentials) {
|
||||
labels[cred.id] = cred.label ?? ''
|
||||
}
|
||||
setPasskeyLabels(labels)
|
||||
}, [profile])
|
||||
|
||||
const statsTotals = accountStats?.totals
|
||||
const logbookCount =
|
||||
accountStats?.logbooks.length ?? profile?.serverMeta.ownedLogbookCount ?? 0
|
||||
|
||||
const accountAgeLabel = useMemo(() => {
|
||||
if (!profile?.createdAt) return '—'
|
||||
return formatAccountAge(profile.createdAt, i18n.language)
|
||||
}, [profile?.createdAt, i18n.language])
|
||||
|
||||
const handleCopyUserId = async () => {
|
||||
if (!profile?.userId) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(profile.userId)
|
||||
setCopiedUserId(true)
|
||||
window.setTimeout(() => setCopiedUserId(false), 2000)
|
||||
} catch {
|
||||
showAlert(t('profile.copy_failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddPasskey = async () => {
|
||||
setPasskeyBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const hadLabel = Boolean(newPasskeyLabel.trim())
|
||||
await addPasskey(newPasskeyLabel)
|
||||
setNewPasskeyLabel('')
|
||||
await loadData()
|
||||
trackPlausibleEvent(PlausibleEvents.PASSKEY_ADDED, { labeled: hadLabel })
|
||||
showAlert(t('profile.add_passkey_success'))
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('profile.add_passkey_failed'))
|
||||
} finally {
|
||||
setPasskeyBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRenamePasskey = async (credentialId: string) => {
|
||||
setPasskeyBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await renamePasskey(credentialId, passkeyLabels[credentialId] ?? '')
|
||||
await loadData()
|
||||
trackPlausibleEvent(PlausibleEvents.PASSKEY_RENAMED)
|
||||
showAlert(t('profile.passkey_rename_success'))
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('profile.passkey_rename_failed'))
|
||||
} finally {
|
||||
setPasskeyBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleForgetDevice = async () => {
|
||||
const confirmed = await showConfirm(
|
||||
t('profile.device_forget_confirm_desc'),
|
||||
t('profile.device_forget_confirm_title'),
|
||||
t('profile.device_forget_confirm_yes'),
|
||||
t('profile.device_forget_confirm_no')
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
forgetUsername(username)
|
||||
setIsKnownDevice(false)
|
||||
trackPlausibleEvent(PlausibleEvents.DEVICE_FORGOTTEN)
|
||||
}
|
||||
|
||||
const handleRemovePasskey = async (credentialId: string) => {
|
||||
if (profile && profile.credentials.length <= 1) {
|
||||
trackPlausibleEvent(PlausibleEvents.LAST_PASSKEY_REMOVE_HINTED)
|
||||
await showAlert(
|
||||
t('profile.remove_passkey_last_desc'),
|
||||
t('profile.remove_passkey_last_title')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await showConfirm(
|
||||
t('profile.remove_passkey_confirm_desc'),
|
||||
t('profile.remove_passkey_confirm_title'),
|
||||
t('profile.remove_passkey_confirm_yes'),
|
||||
t('profile.remove_passkey_confirm_no')
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
setPasskeyBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await removePasskey(credentialId)
|
||||
await loadData()
|
||||
trackPlausibleEvent(PlausibleEvents.PASSKEY_REMOVED)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('profile.remove_passkey_failed'))
|
||||
} finally {
|
||||
setPasskeyBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSavePin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (pinInput.length < 4) {
|
||||
setError(t('profile.pin_length_error'))
|
||||
return
|
||||
}
|
||||
if (pinInput !== pinConfirm) {
|
||||
setError(t('profile.pin_mismatch'))
|
||||
return
|
||||
}
|
||||
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
setError(t('profile.pin_no_session'))
|
||||
return
|
||||
}
|
||||
|
||||
const pinAction = pinActive ? 'change' : 'set'
|
||||
|
||||
setPinBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await setLocalPin(pinInput.trim(), username, masterKey)
|
||||
setPinActive(true)
|
||||
setPinInput('')
|
||||
setPinConfirm('')
|
||||
trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_SET, { action: pinAction })
|
||||
showAlert(t('profile.pin_saved'))
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('profile.pin_save_failed'))
|
||||
} finally {
|
||||
setPinBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemovePin = async () => {
|
||||
const confirmed = await showConfirm(
|
||||
t('profile.remove_pin_confirm_desc'),
|
||||
t('profile.remove_pin_confirm_title'),
|
||||
t('profile.remove_pin_confirm_yes'),
|
||||
t('profile.remove_pin_confirm_no')
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
removeLocalPin(username)
|
||||
setPinActive(false)
|
||||
setPinInput('')
|
||||
setPinConfirm('')
|
||||
trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_REMOVED)
|
||||
}
|
||||
|
||||
const handleRotateRecovery = async () => {
|
||||
const confirmed = await showConfirm(
|
||||
t('profile.recovery_rotate_confirm_desc'),
|
||||
t('profile.recovery_rotate_confirm_title'),
|
||||
t('profile.recovery_rotate_confirm_yes'),
|
||||
t('profile.recovery_rotate_confirm_no')
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
if (!getActiveMasterKey()) {
|
||||
setError(t('profile.recovery_rotate_no_session'))
|
||||
return
|
||||
}
|
||||
|
||||
setRecoveryBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const phrase = await rotateRecoveryPhrase()
|
||||
setPendingRecoveryPhrase(phrase)
|
||||
trackPlausibleEvent(PlausibleEvents.RECOVERY_ROTATED)
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message === 'NO_ACTIVE_MASTER_KEY') {
|
||||
setError(t('profile.recovery_rotate_no_session'))
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : t('profile.recovery_rotate_failed'))
|
||||
}
|
||||
} finally {
|
||||
setRecoveryBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyRecoveryPhrase = async () => {
|
||||
if (!pendingRecoveryPhrase) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(pendingRecoveryPhrase)
|
||||
setRecoveryCopied(true)
|
||||
window.setTimeout(() => setRecoveryCopied(false), 2000)
|
||||
} catch {
|
||||
showAlert(t('profile.copy_failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmRecoverySaved = () => {
|
||||
setPendingRecoveryPhrase(null)
|
||||
setRecoveryCopied(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
<header className="dashboard-header dashboard-header--profile">
|
||||
<div className="header-brand profile-header-brand">
|
||||
<button className="btn-back profile-back-btn" onClick={onBack} title={t('profile.back')}>
|
||||
<ChevronLeft size={16} />
|
||||
<span>{t('profile.back')}</span>
|
||||
</button>
|
||||
<div>
|
||||
<div className="header-brand-title-row">
|
||||
<h1>{t('profile.title')}</h1>
|
||||
<BetaBadge />
|
||||
</div>
|
||||
<p className="subtitle">{t('profile.subtitle', { name: username })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
<button className="btn-icon logout" onClick={onLogout} title={t('dashboard.logout')}>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="profile-main">
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="tab-placeholder">
|
||||
<User className="header-logo spin" size={48} />
|
||||
<p>{t('profile.loading')}</p>
|
||||
</div>
|
||||
) : pendingRecoveryPhrase ? (
|
||||
<section className="form-card profile-recovery-card">
|
||||
<div className="form-header">
|
||||
<KeyRound size={24} className="form-icon" />
|
||||
<h2>{t('auth.recovery_title')}</h2>
|
||||
</div>
|
||||
<p className="profile-recovery-warning">{t('profile.recovery_rotate_new_warning')}</p>
|
||||
<div className="phrase-grid">
|
||||
{pendingRecoveryPhrase.split(' ').map((word, idx) => (
|
||||
<div key={idx} className="phrase-word">
|
||||
<span className="word-num">{idx + 1}</span>
|
||||
{word}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="form-actions profile-recovery-actions">
|
||||
<button type="button" className="btn secondary" onClick={() => void handleCopyRecoveryPhrase()}>
|
||||
{recoveryCopied ? t('auth.copied') : t('auth.copy_phrase')}
|
||||
</button>
|
||||
<button type="button" className="btn primary" onClick={handleConfirmRecoverySaved}>
|
||||
{t('auth.confirm_recovery')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : profile ? (
|
||||
<>
|
||||
<section className="form-card">
|
||||
<div className="form-header">
|
||||
<User size={24} className="form-icon" />
|
||||
<h2>{t('profile.identity_title')}</h2>
|
||||
</div>
|
||||
|
||||
<dl className="profile-dl">
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('profile.username')}</dt>
|
||||
<dd>{profile.username}</dd>
|
||||
</div>
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('profile.user_id')}</dt>
|
||||
<dd className="profile-user-id">
|
||||
<code>{profile.userId}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon profile-copy-btn"
|
||||
onClick={() => void handleCopyUserId()}
|
||||
title={t('profile.copy_user_id')}
|
||||
>
|
||||
{copiedUserId ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('profile.account_since')}</dt>
|
||||
<dd>{accountAgeLabel}</dd>
|
||||
</div>
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('profile.prf_status')}</dt>
|
||||
<dd>
|
||||
{profile.hasPrfEncryption
|
||||
? t('profile.prf_active')
|
||||
: t('profile.prf_inactive')}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<Shield size={20} />
|
||||
<h3>{t('profile.security_title')}</h3>
|
||||
</div>
|
||||
<p className="profile-section-desc">{t('profile.security_desc')}</p>
|
||||
<ul className="profile-security-list">
|
||||
<SecurityCheckItem
|
||||
ok={profile.credentials.length > 0}
|
||||
label={
|
||||
profile.credentials.length > 0
|
||||
? t('profile.security_passkeys_ok')
|
||||
: t('profile.security_passkeys_missing')
|
||||
}
|
||||
/>
|
||||
<SecurityCheckItem
|
||||
ok={profile.hasPrfEncryption}
|
||||
label={
|
||||
profile.hasPrfEncryption
|
||||
? t('profile.security_prf_ok')
|
||||
: t('profile.security_prf_missing')
|
||||
}
|
||||
/>
|
||||
<SecurityCheckItem
|
||||
ok={pinActive}
|
||||
label={pinActive ? t('profile.security_pin_ok') : t('profile.security_pin_missing')}
|
||||
/>
|
||||
<SecurityCheckItem ok label={t('profile.security_recovery_ok')} />
|
||||
</ul>
|
||||
<p className="profile-section-desc profile-recovery-hint">{t('profile.security_recovery_hint')}</p>
|
||||
<div className="form-actions profile-recovery-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => void handleRotateRecovery()}
|
||||
disabled={recoveryBusy || passkeyBusy || pinBusy}
|
||||
>
|
||||
{recoveryBusy ? t('profile.processing') : t('profile.recovery_rotate_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<Smartphone size={20} />
|
||||
<h3>{t('profile.device_title')}</h3>
|
||||
</div>
|
||||
<p className="profile-section-desc">{t('profile.device_desc')}</p>
|
||||
<div className={`profile-device-status conn-status ${online ? (pendingSyncCount > 0 ? 'warning' : 'online') : 'offline'}`}>
|
||||
{online ? (
|
||||
pendingSyncCount > 0 ? (
|
||||
<>
|
||||
<RefreshCw size={16} className="spin" aria-hidden="true" />
|
||||
<span>{t('profile.device_sync_pending', { count: pendingSyncCount })}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wifi size={16} aria-hidden="true" />
|
||||
<span>{t('profile.device_sync_ok')}</span>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<WifiOff size={16} aria-hidden="true" />
|
||||
<span>{t('sync.status_offline')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="profile-pin-status">
|
||||
{isKnownDevice ? t('profile.device_remembered') : t('profile.device_not_remembered')}
|
||||
</p>
|
||||
{isKnownDevice && (
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => void handleForgetDevice()}
|
||||
>
|
||||
{t('profile.device_forget_btn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<Lock size={20} />
|
||||
<h3>{t('profile.pin_title')}</h3>
|
||||
</div>
|
||||
<p className="profile-section-desc">{t('auth.setup_pin_warning')}</p>
|
||||
<p className="profile-pin-status">
|
||||
{t('profile.pin_status')}:{' '}
|
||||
<strong>{pinActive ? t('profile.pin_active') : t('profile.pin_inactive')}</strong>
|
||||
</p>
|
||||
|
||||
<form onSubmit={(e) => void handleSavePin(e)} className="profile-pin-form">
|
||||
<div className="input-group">
|
||||
<label htmlFor="profile-pin">{t('auth.pin_label')}</label>
|
||||
<input
|
||||
id="profile-pin"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
autoComplete="new-password"
|
||||
className="input-text"
|
||||
placeholder={t('auth.pin_placeholder')}
|
||||
value={pinInput}
|
||||
onChange={(e) => setPinInput(e.target.value)}
|
||||
disabled={pinBusy}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label htmlFor="profile-pin-confirm">{t('profile.pin_confirm_label')}</label>
|
||||
<input
|
||||
id="profile-pin-confirm"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
autoComplete="new-password"
|
||||
className="input-text"
|
||||
placeholder={t('profile.pin_confirm_placeholder')}
|
||||
value={pinConfirm}
|
||||
onChange={(e) => setPinConfirm(e.target.value)}
|
||||
disabled={pinBusy}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary"
|
||||
disabled={pinBusy || pinInput.length < 4 || pinConfirm.length < 4}
|
||||
>
|
||||
{pinActive ? t('profile.pin_change_btn') : t('profile.pin_set_btn')}
|
||||
</button>
|
||||
{pinActive && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => void handleRemovePin()}
|
||||
disabled={pinBusy}
|
||||
>
|
||||
{t('profile.pin_remove_btn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<KeyRound size={20} />
|
||||
<h3>{t('profile.passkeys_title')}</h3>
|
||||
</div>
|
||||
<p className="profile-section-desc">{t('profile.passkeys_desc')}</p>
|
||||
|
||||
{profile.credentials.length === 0 ? (
|
||||
<p className="profile-empty">{t('profile.passkeys_empty')}</p>
|
||||
) : (
|
||||
<ul className="profile-passkey-list">
|
||||
{profile.credentials.map((cred) => (
|
||||
<li key={cred.id} className="profile-passkey-item">
|
||||
<div className="profile-passkey-main">
|
||||
<span className="profile-passkey-label">
|
||||
{cred.label || t('profile.passkey_unnamed')}
|
||||
</span>
|
||||
<span className="profile-passkey-id">{cred.credentialIdPreview}</span>
|
||||
{cred.transports.length > 0 && (
|
||||
<span className="profile-passkey-transports">
|
||||
{cred.transports.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
<div className="profile-passkey-rename">
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={passkeyLabels[cred.id] ?? ''}
|
||||
onChange={(e) =>
|
||||
setPasskeyLabels((prev) => ({ ...prev, [cred.id]: e.target.value }))
|
||||
}
|
||||
placeholder={t('profile.passkey_label_placeholder')}
|
||||
disabled={passkeyBusy}
|
||||
maxLength={64}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => void handleRenamePasskey(cred.id)}
|
||||
disabled={passkeyBusy}
|
||||
>
|
||||
{t('profile.passkey_rename_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon danger"
|
||||
onClick={() => void handleRemovePasskey(cred.id)}
|
||||
disabled={passkeyBusy}
|
||||
title={t('profile.remove_passkey_btn')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="profile-add-passkey">
|
||||
<div className="input-group">
|
||||
<label htmlFor="profile-new-passkey-label">{t('profile.passkey_label')}</label>
|
||||
<input
|
||||
id="profile-new-passkey-label"
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={newPasskeyLabel}
|
||||
onChange={(e) => setNewPasskeyLabel(e.target.value)}
|
||||
placeholder={t('profile.passkey_label_placeholder')}
|
||||
disabled={passkeyBusy}
|
||||
maxLength={64}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions mt-4">
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => void handleAddPasskey()}
|
||||
disabled={passkeyBusy}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{passkeyBusy ? t('profile.processing') : t('profile.add_passkey_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="form-card">
|
||||
<div className="form-header">
|
||||
<BarChart2 size={24} className="form-icon" />
|
||||
<div>
|
||||
<h2>{t('profile.stats_title')}</h2>
|
||||
<p className="stats-subtitle">{t('profile.stats_subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(statsTotals || profile) && (
|
||||
<div className="stats-kpi-grid">
|
||||
<KpiCard
|
||||
icon={<BookOpen size={20} />}
|
||||
label={t('profile.stats_logbooks')}
|
||||
value={String(logbookCount)}
|
||||
/>
|
||||
{statsTotals && (
|
||||
<>
|
||||
<KpiCard
|
||||
icon={<Anchor size={20} />}
|
||||
label={t('stats.travel_days')}
|
||||
value={String(statsTotals.travelDayCount)}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Gauge size={20} />}
|
||||
label={t('stats.total_distance')}
|
||||
value={formatNm(statsTotals.totalDistanceNm)}
|
||||
unit={t('stats.unit_nm')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Sailboat size={20} />}
|
||||
label={t('stats.sail_distance')}
|
||||
value={formatNm(statsTotals.sailDistanceNm)}
|
||||
unit={t('stats.unit_nm')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Gauge size={20} />}
|
||||
label={t('stats.motor_distance')}
|
||||
value={formatNm(statsTotals.motorDistanceNm)}
|
||||
unit={t('stats.unit_nm')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Timer size={20} />}
|
||||
label={t('stats.motor_hours_total')}
|
||||
value={formatHours(statsTotals.totalMotorHours)}
|
||||
unit={t('stats.unit_h')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Share2 size={20} />}
|
||||
label={t('profile.stats_shared_logbooks')}
|
||||
value={String(sharedLogbookCount)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<KpiCard
|
||||
icon={<Calendar size={20} />}
|
||||
label={t('profile.stats_account_since')}
|
||||
value={accountAgeLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<AccountDangerZone className="mt-6" />
|
||||
</>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,8 @@ const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
|
||||
const UPDATE_SUPPRESS_MS = 30_000
|
||||
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
|
||||
const UPDATE_RELOAD_FALLBACK_MS = 2000
|
||||
/** Prevent Android PWA cold-start reload loops from onNeedReload. */
|
||||
const PWA_INITIAL_RELOAD_KEY = 'pwa_sw_initial_reload_done'
|
||||
|
||||
function isUpdateSuppressed(): boolean {
|
||||
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
||||
@@ -48,8 +50,13 @@ export function usePwaUpdate() {
|
||||
needRefresh: [needRefresh, setNeedRefresh],
|
||||
updateServiceWorker
|
||||
} = useRegisterSW({
|
||||
immediate: true,
|
||||
immediate: !import.meta.env.DEV,
|
||||
onNeedReload() {
|
||||
// First SW takeover requires one reload; guard against repeated reloads on Android PWA resume.
|
||||
if (sessionStorage.getItem(PWA_INITIAL_RELOAD_KEY)) {
|
||||
return
|
||||
}
|
||||
sessionStorage.setItem(PWA_INITIAL_RELOAD_KEY, '1')
|
||||
clearUpdateSuppression()
|
||||
setNeedRefresh(false)
|
||||
window.location.reload()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import deJson from './locales/de.json'
|
||||
import enJson from './locales/en.json'
|
||||
|
||||
const resources = {
|
||||
de: { translation: deJson.translation },
|
||||
en: { translation: enJson.translation }
|
||||
}
|
||||
|
||||
describe('course dial i18n keys', () => {
|
||||
it.each([
|
||||
'logs.event_course_section',
|
||||
'logs.course_tab_mgk',
|
||||
'logs.course_tab_rwk',
|
||||
'logs.course_dial_hint',
|
||||
'logs.course_step_fine',
|
||||
'logs.wind_mode_cardinal'
|
||||
])('resolves %s in de and en bundles', async (key) => {
|
||||
const { default: i18n } = await import('i18next')
|
||||
await i18n.init({ lng: 'de', resources, defaultNS: 'translation' })
|
||||
expect(i18n.t(key)).not.toBe(key)
|
||||
await i18n.changeLanguage('en')
|
||||
expect(i18n.t(key)).not.toBe(key)
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,26 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
import enTranslation from './locales/en.json'
|
||||
import deTranslation from './locales/de.json'
|
||||
import enJson from './locales/en.json'
|
||||
import deJson from './locales/de.json'
|
||||
import { initSeo } from '../utils/seo.js'
|
||||
|
||||
/** JSON files wrap strings in `translation` — register that namespace explicitly. */
|
||||
const resources = {
|
||||
en: { translation: enJson.translation },
|
||||
de: { translation: deJson.translation }
|
||||
}
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: enTranslation,
|
||||
de: deTranslation
|
||||
},
|
||||
resources,
|
||||
defaultNS: 'translation',
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['de', 'en'],
|
||||
nonExplicitSupportedLngs: true,
|
||||
load: 'languageOnly',
|
||||
interpolation: {
|
||||
escapeValue: false // React already escapes values (prevents XSS)
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Willkommen bei Kapteins Daagbok",
|
||||
"tagline": "Sicheres, E2E-verschlüsseltes maritimes Logbuch.",
|
||||
"tagline": "Dein sicheres, E2E-verschlüsseltes maritimes Logbuch.",
|
||||
"register": "Mit Passkey registrieren",
|
||||
"login": "Mit Passkey anmelden",
|
||||
"login_as": "Anmelden als {{name}}",
|
||||
@@ -190,6 +190,23 @@
|
||||
"event_time": "Uhrzeit",
|
||||
"event_mgk": "MgK Kurs",
|
||||
"event_rwk": "RwK Kurs",
|
||||
"event_course_section": "Kurs",
|
||||
"course_dial_hint": "Am Ring drehen oder Grad eingeben",
|
||||
"course_dial_step_label": "Schrittweite",
|
||||
"course_step_fine": "1°",
|
||||
"course_step_medium": "5°",
|
||||
"course_step_coarse": "10°",
|
||||
"course_tab_mgk": "MgK",
|
||||
"course_tab_rwk": "rwK",
|
||||
"course_invalid": "Ungültiger Kurs (0–360)",
|
||||
"course_placeholder_degrees": "z. B. 180",
|
||||
"course_placeholder_cardinal": "z. B. NW",
|
||||
"compass_n": "N",
|
||||
"compass_e": "O",
|
||||
"compass_s": "S",
|
||||
"compass_w": "W",
|
||||
"wind_mode_cardinal": "Kardinal",
|
||||
"wind_mode_degrees": "Als Grad",
|
||||
"event_wind_direction": "Wind-Richtung",
|
||||
"event_wind_strength": "Windstärke",
|
||||
"event_sea_state": "Seegang",
|
||||
@@ -205,6 +222,8 @@
|
||||
"event_heel": "Krängung (°)",
|
||||
"event_sails": "Segelführung / Motor",
|
||||
"motor_propulsion": "Maschinenfahrt",
|
||||
"sails_picker_show_more": "Alle Segel anzeigen",
|
||||
"sails_picker_show_less": "Weniger anzeigen",
|
||||
"motor_hours": "Maschinenstunden (gesamt)",
|
||||
"fuel_per_motor_hour": "Verbrauch pro Maschinenstunde",
|
||||
"event_distance": "Distanz (sm)",
|
||||
@@ -269,7 +288,101 @@
|
||||
"role_crew": "Crew-Zugang",
|
||||
"role_crew_hint": "Eingeladenes Logbuch — du kannst als Crew mitarbeiten und signieren",
|
||||
"role_read": "Nur Lesen",
|
||||
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung"
|
||||
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
|
||||
"open_profile": "Profil von {{name}} öffnen",
|
||||
"edit_title": "Logbuch umbenennen",
|
||||
"edit_placeholder": "Neuer Name des Logbuchs",
|
||||
"edit_success": "Logbuch erfolgreich umbenannt",
|
||||
"edit_btn": "Umbenennen"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Benutzerprofil",
|
||||
"subtitle": "Konto, Passkeys und Statistiken für {{name}}",
|
||||
"back": "Zurück zum Dashboard",
|
||||
"loading": "Profil wird geladen…",
|
||||
"load_error": "Profil konnte nicht geladen werden.",
|
||||
"copy_failed": "Kopieren fehlgeschlagen.",
|
||||
"processing": "Wird verarbeitet…",
|
||||
"identity_title": "Konto-Identität",
|
||||
"username": "Benutzername",
|
||||
"user_id": "Benutzer-ID",
|
||||
"copy_user_id": "Benutzer-ID kopieren",
|
||||
"account_since": "Konto seit",
|
||||
"prf_status": "Passkey-Schlüsselableitung (PRF)",
|
||||
"prf_active": "Aktiv",
|
||||
"prf_inactive": "Nicht eingerichtet",
|
||||
"passkeys_title": "Passkeys",
|
||||
"passkeys_desc": "Registriere auf jedem Gerät einen eigenen Passkey. So kannst du dich auch nach einem Plattformwechsel anmelden.",
|
||||
"passkeys_empty": "Keine Passkeys gefunden.",
|
||||
"add_passkey_btn": "Neuen Passkey hinzufügen",
|
||||
"add_passkey_success": "Passkey erfolgreich hinzugefügt.",
|
||||
"add_passkey_failed": "Passkey konnte nicht hinzugefügt werden.",
|
||||
"remove_passkey_btn": "Passkey entfernen",
|
||||
"remove_passkey_last_title": "Letzter Passkey",
|
||||
"remove_passkey_last_desc": "Der einzige Passkey kann nicht entfernt werden, ohne den Zugang zu deinem Konto zu verlieren. Um das Konto vollständig zu löschen, nutze die Gefahrenzone am Ende dieser Seite.",
|
||||
"remove_passkey_failed": "Passkey konnte nicht entfernt werden.",
|
||||
"remove_passkey_confirm_title": "Passkey entfernen?",
|
||||
"remove_passkey_confirm_desc": "Dieses Gerät kann sich danach nicht mehr mit diesem Passkey anmelden.",
|
||||
"remove_passkey_confirm_yes": "Entfernen",
|
||||
"remove_passkey_confirm_no": "Abbrechen",
|
||||
"pin_title": "Lokaler PIN",
|
||||
"pin_status": "Status",
|
||||
"pin_active": "Aktiv auf diesem Gerät",
|
||||
"pin_inactive": "Nicht eingerichtet",
|
||||
"pin_confirm_label": "PIN bestätigen",
|
||||
"pin_confirm_placeholder": "PIN erneut eingeben",
|
||||
"pin_set_btn": "PIN einrichten",
|
||||
"pin_change_btn": "PIN ändern",
|
||||
"pin_remove_btn": "PIN entfernen",
|
||||
"pin_saved": "PIN gespeichert.",
|
||||
"pin_save_failed": "PIN konnte nicht gespeichert werden.",
|
||||
"pin_mismatch": "Die PIN-Eingaben stimmen nicht überein.",
|
||||
"pin_length_error": "Die PIN muss mindestens 4 Zeichen haben.",
|
||||
"pin_no_session": "Sitzung abgelaufen — bitte erneut anmelden.",
|
||||
"remove_pin_confirm_title": "PIN entfernen?",
|
||||
"remove_pin_confirm_desc": "Du musst dich auf diesem Gerät wieder mit Passkey oder Wiederherstellungsschlüssel anmelden.",
|
||||
"remove_pin_confirm_yes": "PIN entfernen",
|
||||
"remove_pin_confirm_no": "Abbrechen",
|
||||
"security_title": "Sicherheits-Checkliste",
|
||||
"security_desc": "Überblick über die wichtigsten Schutzmechanismen deines Kontos.",
|
||||
"security_passkeys_ok": "Mindestens ein Passkey registriert",
|
||||
"security_passkeys_missing": "Kein Passkey registriert",
|
||||
"security_prf_ok": "PRF-Schlüsselableitung aktiv",
|
||||
"security_prf_missing": "PRF nicht eingerichtet",
|
||||
"security_pin_ok": "Lokaler PIN auf diesem Gerät",
|
||||
"security_pin_missing": "Kein lokaler PIN",
|
||||
"security_recovery_ok": "Wiederherstellungsschlüssel eingerichtet",
|
||||
"security_recovery_hint": "Die 12 Wörter wurden bei der Registrierung angezeigt. Bewahre sie offline und getrennt vom Gerät auf. Du kannst unten einen neuen Schlüssel erstellen — der alte wird dann ungültig.",
|
||||
"recovery_rotate_btn": "Neuen Wiederherstellungsschlüssel erstellen",
|
||||
"recovery_rotate_confirm_title": "Neuen Wiederherstellungsschlüssel erstellen?",
|
||||
"recovery_rotate_confirm_desc": "Der bisherige 12-Wörter-Schlüssel wird sofort ungültig. Stelle sicher, dass du den neuen Schlüssel sicher aufbewahrst, bevor du fortfährst.",
|
||||
"recovery_rotate_confirm_yes": "Neuen Schlüssel erstellen",
|
||||
"recovery_rotate_confirm_no": "Abbrechen",
|
||||
"recovery_rotate_new_warning": "WICHTIG: Schreib diese 12 Wörter auf und bewahre sie offline auf. Der bisherige Wiederherstellungsschlüssel ist ab sofort ungültig.",
|
||||
"recovery_rotate_failed": "Wiederherstellungsschlüssel konnte nicht erstellt werden.",
|
||||
"recovery_rotate_no_session": "Verschlüsselungssitzung abgelaufen — bitte abmelden und erneut anmelden, dann erneut versuchen.",
|
||||
"device_title": "Dieses Gerät",
|
||||
"device_desc": "Lokaler Cache, Sync-Status und Schnell-Login auf diesem Browser.",
|
||||
"device_sync_pending": "{{count}} ausstehende Sync-Einträge",
|
||||
"device_sync_ok": "Alle lokalen Änderungen synchronisiert",
|
||||
"device_remembered": "Account für Schnell-Login auf diesem Gerät gespeichert",
|
||||
"device_not_remembered": "Account nicht in der Schnell-Login-Liste",
|
||||
"device_forget_btn": "Account auf diesem Gerät vergessen",
|
||||
"device_forget_confirm_title": "Schnell-Login entfernen?",
|
||||
"device_forget_confirm_desc": "Der Account verschwindet aus der Schnell-Login-Liste auf diesem Gerät. Deine Session und lokalen Logbücher bleiben erhalten.",
|
||||
"device_forget_confirm_yes": "Entfernen",
|
||||
"device_forget_confirm_no": "Abbrechen",
|
||||
"passkey_label": "Name für neuen Passkey (optional)",
|
||||
"passkey_label_placeholder": "z. B. MacBook, iPhone",
|
||||
"passkey_rename_btn": "Name speichern",
|
||||
"passkey_rename_success": "Passkey-Name gespeichert.",
|
||||
"passkey_rename_failed": "Passkey-Name konnte nicht gespeichert werden.",
|
||||
"passkey_unnamed": "Unbenannter Passkey",
|
||||
"stats_title": "Statistiken",
|
||||
"stats_subtitle": "Über alle deine Logbücher auf diesem Gerät",
|
||||
"stats_logbooks": "Logbücher",
|
||||
"stats_account_since": "Konto seit",
|
||||
"stats_shared_logbooks": "Geteilte Logbücher"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- & Crew-Profile",
|
||||
@@ -344,6 +457,7 @@
|
||||
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
||||
"delete_account_confirm_no": "Abbrechen",
|
||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
||||
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
|
||||
"deleting_account": "Konto wird gelöscht…",
|
||||
"tour_title": "App-Tour",
|
||||
"tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.",
|
||||
|
||||
@@ -190,6 +190,23 @@
|
||||
"event_time": "Time",
|
||||
"event_mgk": "MgK Course",
|
||||
"event_rwk": "RwK Course",
|
||||
"event_course_section": "Course",
|
||||
"course_dial_hint": "Drag the ring or enter degrees",
|
||||
"course_dial_step_label": "Step size",
|
||||
"course_step_fine": "1°",
|
||||
"course_step_medium": "5°",
|
||||
"course_step_coarse": "10°",
|
||||
"course_tab_mgk": "MgK",
|
||||
"course_tab_rwk": "rwK",
|
||||
"course_invalid": "Invalid course (0–360)",
|
||||
"course_placeholder_degrees": "e.g. 180",
|
||||
"course_placeholder_cardinal": "e.g. NW",
|
||||
"compass_n": "N",
|
||||
"compass_e": "E",
|
||||
"compass_s": "S",
|
||||
"compass_w": "W",
|
||||
"wind_mode_cardinal": "Cardinal",
|
||||
"wind_mode_degrees": "As degrees",
|
||||
"event_wind_direction": "Wind Dir",
|
||||
"event_wind_strength": "Wind Str",
|
||||
"event_sea_state": "Sea State",
|
||||
@@ -205,6 +222,8 @@
|
||||
"event_heel": "Heel Angle (°)",
|
||||
"event_sails": "Sails / Motor Status",
|
||||
"motor_propulsion": "Engine Propulsion",
|
||||
"sails_picker_show_more": "Show all sails",
|
||||
"sails_picker_show_less": "Show less",
|
||||
"motor_hours": "Engine hours (total)",
|
||||
"fuel_per_motor_hour": "Consumption per engine hour",
|
||||
"event_distance": "Distance (nm)",
|
||||
@@ -269,7 +288,101 @@
|
||||
"role_crew": "Crew access",
|
||||
"role_crew_hint": "Invited logbook — you can collaborate and sign as crew",
|
||||
"role_read": "Read only",
|
||||
"role_read_hint": "Shared logbook — view only, no editing"
|
||||
"role_read_hint": "Shared logbook — view only, no editing",
|
||||
"open_profile": "Open profile for {{name}}",
|
||||
"edit_title": "Rename Logbook",
|
||||
"edit_placeholder": "New name of the logbook",
|
||||
"edit_success": "Logbook renamed successfully",
|
||||
"edit_btn": "Rename"
|
||||
},
|
||||
"profile": {
|
||||
"title": "User profile",
|
||||
"subtitle": "Account, passkeys and statistics for {{name}}",
|
||||
"back": "Back to dashboard",
|
||||
"loading": "Loading profile…",
|
||||
"load_error": "Could not load profile.",
|
||||
"copy_failed": "Copy failed.",
|
||||
"processing": "Processing…",
|
||||
"identity_title": "Account identity",
|
||||
"username": "Username",
|
||||
"user_id": "User ID",
|
||||
"copy_user_id": "Copy user ID",
|
||||
"account_since": "Account since",
|
||||
"prf_status": "Passkey key derivation (PRF)",
|
||||
"prf_active": "Active",
|
||||
"prf_inactive": "Not configured",
|
||||
"passkeys_title": "Passkeys",
|
||||
"passkeys_desc": "Register a passkey on each device you use. This helps when switching platforms or browsers.",
|
||||
"passkeys_empty": "No passkeys found.",
|
||||
"add_passkey_btn": "Add new passkey",
|
||||
"add_passkey_success": "Passkey added successfully.",
|
||||
"add_passkey_failed": "Could not add passkey.",
|
||||
"remove_passkey_btn": "Remove passkey",
|
||||
"remove_passkey_last_title": "Last passkey",
|
||||
"remove_passkey_last_desc": "The only passkey cannot be removed without losing access to your account. To delete the account entirely, use the danger zone at the bottom of this page.",
|
||||
"remove_passkey_failed": "Could not remove passkey.",
|
||||
"remove_passkey_confirm_title": "Remove passkey?",
|
||||
"remove_passkey_confirm_desc": "This device will no longer be able to sign in with this passkey.",
|
||||
"remove_passkey_confirm_yes": "Remove",
|
||||
"remove_passkey_confirm_no": "Cancel",
|
||||
"pin_title": "Local PIN",
|
||||
"pin_status": "Status",
|
||||
"pin_active": "Active on this device",
|
||||
"pin_inactive": "Not configured",
|
||||
"pin_confirm_label": "Confirm PIN",
|
||||
"pin_confirm_placeholder": "Re-enter PIN",
|
||||
"pin_set_btn": "Set PIN",
|
||||
"pin_change_btn": "Change PIN",
|
||||
"pin_remove_btn": "Remove PIN",
|
||||
"pin_saved": "PIN saved.",
|
||||
"pin_save_failed": "Could not save PIN.",
|
||||
"pin_mismatch": "PIN entries do not match.",
|
||||
"pin_length_error": "PIN must be at least 4 characters.",
|
||||
"pin_no_session": "Session expired — please sign in again.",
|
||||
"remove_pin_confirm_title": "Remove PIN?",
|
||||
"remove_pin_confirm_desc": "You will need to sign in on this device with passkey or recovery phrase again.",
|
||||
"remove_pin_confirm_yes": "Remove PIN",
|
||||
"remove_pin_confirm_no": "Cancel",
|
||||
"security_title": "Security checklist",
|
||||
"security_desc": "Overview of the most important protections for your account.",
|
||||
"security_passkeys_ok": "At least one passkey registered",
|
||||
"security_passkeys_missing": "No passkey registered",
|
||||
"security_prf_ok": "PRF key derivation active",
|
||||
"security_prf_missing": "PRF not configured",
|
||||
"security_pin_ok": "Local PIN on this device",
|
||||
"security_pin_missing": "No local PIN",
|
||||
"security_recovery_ok": "Recovery phrase configured",
|
||||
"security_recovery_hint": "The 12 words were shown at registration. Store them offline and separately from this device. You can create a new phrase below — the old one will then be invalidated.",
|
||||
"recovery_rotate_btn": "Create new recovery phrase",
|
||||
"recovery_rotate_confirm_title": "Create new recovery phrase?",
|
||||
"recovery_rotate_confirm_desc": "Your previous 12-word phrase will be invalidated immediately. Make sure you can store the new phrase securely before continuing.",
|
||||
"recovery_rotate_confirm_yes": "Create new phrase",
|
||||
"recovery_rotate_confirm_no": "Cancel",
|
||||
"recovery_rotate_new_warning": "IMPORTANT: Write down these 12 words and store them offline. Your previous recovery phrase is no longer valid.",
|
||||
"recovery_rotate_failed": "Could not create a new recovery phrase.",
|
||||
"recovery_rotate_no_session": "Encryption session expired — please sign out and sign in again, then retry.",
|
||||
"device_title": "This device",
|
||||
"device_desc": "Local cache, sync status, and quick login on this browser.",
|
||||
"device_sync_pending": "{{count}} pending sync items",
|
||||
"device_sync_ok": "All local changes synced",
|
||||
"device_remembered": "Account saved for quick login on this device",
|
||||
"device_not_remembered": "Account not in the quick-login list",
|
||||
"device_forget_btn": "Forget account on this device",
|
||||
"device_forget_confirm_title": "Remove quick login?",
|
||||
"device_forget_confirm_desc": "The account will be removed from the quick-login list on this device. Your session and local logbooks stay on this device.",
|
||||
"device_forget_confirm_yes": "Remove",
|
||||
"device_forget_confirm_no": "Cancel",
|
||||
"passkey_label": "Name for new passkey (optional)",
|
||||
"passkey_label_placeholder": "e.g. MacBook, iPhone",
|
||||
"passkey_rename_btn": "Save name",
|
||||
"passkey_rename_success": "Passkey name saved.",
|
||||
"passkey_rename_failed": "Could not save passkey name.",
|
||||
"passkey_unnamed": "Unnamed passkey",
|
||||
"stats_title": "Statistics",
|
||||
"stats_subtitle": "Across all your logbooks on this device",
|
||||
"stats_logbooks": "Logbooks",
|
||||
"stats_account_since": "Account since",
|
||||
"stats_shared_logbooks": "Shared logbooks"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper & Crew Profiles",
|
||||
@@ -344,6 +457,7 @@
|
||||
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
||||
"delete_account_confirm_no": "Cancel",
|
||||
"delete_account_failed": "Failed to delete account. Please try again.",
|
||||
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
|
||||
"deleting_account": "Deleting account…",
|
||||
"tour_title": "App tour",
|
||||
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
|
||||
|
||||
+48
-7
@@ -3,14 +3,55 @@ import { createRoot } from 'react-dom/client'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './themes.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import './i18n'
|
||||
import App from './App.tsx'
|
||||
import { applyAppearanceToDocument } from './services/appearance.ts'
|
||||
|
||||
applyAppearanceToDocument()
|
||||
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
||||
async function clearDevServiceWorkerCaches(): Promise<void> {
|
||||
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return
|
||||
const regs = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(regs.map((r) => r.unregister()))
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys()
|
||||
await Promise.all(keys.map((k) => caches.delete(k)))
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
function renderBootstrapError(message: string): void {
|
||||
const root = document.getElementById('root')
|
||||
if (!root) return
|
||||
root.innerHTML = `
|
||||
<div class="auth-screen">
|
||||
<div class="auth-card glass" role="alert" style="max-width:420px">
|
||||
<h2 style="margin-top:0">Kapteins Daagbok</h2>
|
||||
<p style="color:var(--app-text-muted);line-height:1.5">${message}</p>
|
||||
<button type="button" class="btn primary" style="width:100%;margin-top:16px" onclick="location.reload()">
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
applyAppearanceToDocument()
|
||||
await clearDevServiceWorkerCaches()
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element')
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
|
||||
void bootstrap().catch((err) => {
|
||||
console.error('App bootstrap failed:', err)
|
||||
renderBootstrapError(
|
||||
'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -25,7 +25,16 @@ export const PlausibleEvents = {
|
||||
DEMO_OPENED: 'Demo Opened',
|
||||
PUSH_ENABLED: 'Push Enabled',
|
||||
PUSH_DISABLED: 'Push Disabled',
|
||||
FOOTER_LINK_CLICKED: 'Footer Link Clicked'
|
||||
FOOTER_LINK_CLICKED: 'Footer Link Clicked',
|
||||
PROFILE_OPENED: 'Profile Opened',
|
||||
PASSKEY_ADDED: 'Passkey Added',
|
||||
PASSKEY_REMOVED: 'Passkey Removed',
|
||||
PASSKEY_RENAMED: 'Passkey Renamed',
|
||||
LAST_PASSKEY_REMOVE_HINTED: 'Last Passkey Remove Hinted',
|
||||
LOCAL_PIN_SET: 'Local PIN Set',
|
||||
LOCAL_PIN_REMOVED: 'Local PIN Removed',
|
||||
DEVICE_FORGOTTEN: 'Device Forgotten',
|
||||
RECOVERY_ROTATED: 'Recovery Rotated'
|
||||
} as const
|
||||
|
||||
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
||||
|
||||
+158
-1
@@ -33,10 +33,33 @@ export function setActiveMasterKey(key: ArrayBuffer | null) {
|
||||
}
|
||||
|
||||
export async function checkServerSession(): Promise<{ authenticated: boolean; userId?: string }> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 8_000)
|
||||
try {
|
||||
return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`)
|
||||
return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
} catch {
|
||||
return { authenticated: false }
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Master key + username in memory/storage — enough to stay in the unlocked UI. */
|
||||
export function hasUnlockedLocalCrypto(): boolean {
|
||||
return !!(getActiveMasterKey() && localStorage.getItem('active_username'))
|
||||
}
|
||||
|
||||
/** Crypto unlock plus user id for authenticated API calls (userId may already be in localStorage). */
|
||||
export function hasUnlockedLocalSession(): boolean {
|
||||
return hasUnlockedLocalCrypto() && !!localStorage.getItem('active_userid')
|
||||
}
|
||||
|
||||
/** Persist server session user id when the /session response includes it. */
|
||||
export function persistSessionUserId(userId: string | undefined): void {
|
||||
if (userId) {
|
||||
localStorage.setItem('active_userid', userId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,3 +566,137 @@ export async function deleteAccount(): Promise<boolean> {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export interface UserProfileCredential {
|
||||
id: string
|
||||
label: string | null
|
||||
credentialIdPreview: string
|
||||
transports: string[]
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
userId: string
|
||||
username: string
|
||||
createdAt: string
|
||||
hasPrfEncryption: boolean
|
||||
credentials: UserProfileCredential[]
|
||||
serverMeta: {
|
||||
ownedLogbookCount: number
|
||||
collaborationCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchUserProfile(): Promise<UserProfile> {
|
||||
return apiJson<UserProfile>(`${API_BASE}/profile`)
|
||||
}
|
||||
|
||||
async function enrollPrfFromMasterKey(masterKey: ArrayBuffer, prfFirst: ArrayBuffer): Promise<void> {
|
||||
const prfKey = await deriveKeyFromPrf(prfFirst)
|
||||
const encryptedPrf = await encryptBuffer(masterKey, prfKey)
|
||||
await apiJson(`${API_BASE}/enroll-prf`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
encryptedMasterKeyPrf: encryptedPrf.ciphertext,
|
||||
encryptedMasterKeyPrfIv: encryptedPrf.iv,
|
||||
encryptedMasterKeyPrfTag: encryptedPrf.tag
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function addPasskey(label?: string): Promise<void> {
|
||||
await reauthWithPasskey()
|
||||
|
||||
const options = await apiJson<any>(`${API_BASE}/add-credential-options`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (!options.extensions) {
|
||||
options.extensions = {}
|
||||
}
|
||||
options.extensions.prf = { eval: { first: PRF_SALT.buffer } }
|
||||
|
||||
let credentialResponse
|
||||
const prfRequested = !!options.extensions?.prf
|
||||
try {
|
||||
credentialResponse = await startRegistration({ optionsJSON: options })
|
||||
} catch (err: any) {
|
||||
const isOptionError = err.name === 'NotSupportedError' ||
|
||||
err.message?.toLowerCase().includes('options') ||
|
||||
err.message?.toLowerCase().includes('process') ||
|
||||
err.message?.toLowerCase().includes('unable to')
|
||||
if (prfRequested && isOptionError) {
|
||||
console.warn('Add passkey with PRF extension failed, retrying without PRF:', err)
|
||||
if (options.extensions) {
|
||||
delete options.extensions.prf
|
||||
}
|
||||
credentialResponse = await startRegistration({ optionsJSON: options })
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
await apiJson(`${API_BASE}/add-credential-verify`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
credentialResponse,
|
||||
challenge: options.challenge,
|
||||
...(label?.trim() ? { label: label.trim() } : {})
|
||||
})
|
||||
})
|
||||
|
||||
const masterKey = getActiveMasterKey()
|
||||
const prfFirstBuffer = extractPrfFirst(credentialResponse.clientExtensionResults || {})
|
||||
if (masterKey && prfFirstBuffer) {
|
||||
try {
|
||||
await enrollPrfFromMasterKey(masterKey, prfFirstBuffer)
|
||||
} catch (err) {
|
||||
console.error('Failed to enroll PRF after adding passkey:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function removePasskey(credentialDbId: string): Promise<void> {
|
||||
await reauthWithPasskey()
|
||||
|
||||
const res = await apiFetch(`${API_BASE}/credentials/${credentialDbId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || 'Failed to remove passkey')
|
||||
}
|
||||
}
|
||||
|
||||
export async function renamePasskey(credentialDbId: string, label: string): Promise<void> {
|
||||
await reauthWithPasskey()
|
||||
|
||||
await apiJson(`${API_BASE}/credentials/${credentialDbId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ label })
|
||||
})
|
||||
}
|
||||
|
||||
export async function rotateRecoveryPhrase(): Promise<string> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('NO_ACTIVE_MASTER_KEY')
|
||||
}
|
||||
|
||||
await reauthWithPasskey()
|
||||
|
||||
const recoveryPhrase = generateRecoveryPhrase()
|
||||
const recoveryKey = await deriveKeyFromPhrase(recoveryPhrase)
|
||||
const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey)
|
||||
|
||||
await apiJson(`${API_BASE}/rotate-recovery`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
encryptedMasterKeyRec: encryptedRecovery.ciphertext,
|
||||
encryptedMasterKeyRecIv: encryptedRecovery.iv,
|
||||
encryptedMasterKeyRecTag: encryptedRecovery.tag
|
||||
})
|
||||
})
|
||||
|
||||
return recoveryPhrase
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasUnlockedLocalCrypto,
|
||||
hasUnlockedLocalSession,
|
||||
setActiveMasterKey
|
||||
} from './auth.js'
|
||||
|
||||
describe('local session unlock checks', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActiveMasterKey(null)
|
||||
})
|
||||
|
||||
it('hasUnlockedLocalCrypto with master key and username only', () => {
|
||||
setActiveMasterKey(new ArrayBuffer(32))
|
||||
localStorage.setItem('active_username', 'skipper')
|
||||
expect(hasUnlockedLocalCrypto()).toBe(true)
|
||||
expect(hasUnlockedLocalSession()).toBe(false)
|
||||
})
|
||||
|
||||
it('hasUnlockedLocalSession when userId is present', () => {
|
||||
setActiveMasterKey(new ArrayBuffer(32))
|
||||
localStorage.setItem('active_username', 'skipper')
|
||||
localStorage.setItem('active_userid', 'user-1')
|
||||
expect(hasUnlockedLocalCrypto()).toBe(true)
|
||||
expect(hasUnlockedLocalSession()).toBe(true)
|
||||
})
|
||||
|
||||
it('hasUnlockedLocalCrypto false without master key', () => {
|
||||
localStorage.setItem('active_username', 'skipper')
|
||||
localStorage.setItem('active_userid', 'user-1')
|
||||
expect(hasUnlockedLocalCrypto()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistSessionUserId', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('stores userId when provided', async () => {
|
||||
const { persistSessionUserId } = await import('./auth.js')
|
||||
persistSessionUserId('user-42')
|
||||
expect(localStorage.getItem('active_userid')).toBe('user-42')
|
||||
})
|
||||
|
||||
it('does not clear existing userId when omitted', async () => {
|
||||
const { persistSessionUserId } = await import('./auth.js')
|
||||
localStorage.setItem('active_userid', 'user-1')
|
||||
persistSessionUserId(undefined)
|
||||
expect(localStorage.getItem('active_userid')).toBe('user-1')
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { decryptJson } from './crypto.js'
|
||||
import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js'
|
||||
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
|
||||
import i18n from '../i18n/index.js'
|
||||
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||
|
||||
function escapeCsvValue(val: string | number | undefined | null): string {
|
||||
if (val === null || val === undefined) return '';
|
||||
@@ -94,11 +95,11 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
const exportLabels = {
|
||||
imagePlaceholder: i18n.t('logs.sign_export_image'),
|
||||
passkeyLabel: (username: string, signedAt: string) => {
|
||||
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
|
||||
const date = formatAppDateTime(signedAt, i18n.language)
|
||||
return i18n.t('logs.sign_passkey_export', { username, date })
|
||||
},
|
||||
attributionLabel: (username: string, signedAt: string) => {
|
||||
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
|
||||
const date = formatAppDateTime(signedAt, i18n.language)
|
||||
return i18n.t('logs.sign_attribution_export', { username, date })
|
||||
}
|
||||
};
|
||||
|
||||
@@ -322,3 +322,64 @@ export async function deleteLogbook(id: string): Promise<void> {
|
||||
await deleteLocalLogbookCache(id)
|
||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
|
||||
}
|
||||
|
||||
// Update the title of a logbook. Encrypts the title and updates locally + on server
|
||||
export async function updateLogbookTitle(id: string, newTitle: string): Promise<void> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) {
|
||||
throw new Error('User not authenticated')
|
||||
}
|
||||
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Master key not found. User must log in.')
|
||||
}
|
||||
|
||||
const logbookKey = await getLogbookKey(id) || masterKey
|
||||
|
||||
// E2E Encrypt the new title using the Logbook Key (or master key fallback)
|
||||
const encrypted = await encryptJson(newTitle, logbookKey)
|
||||
const encryptedTitleStr = JSON.stringify(encrypted)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const payloadData = {
|
||||
encryptedTitle: encryptedTitleStr
|
||||
}
|
||||
|
||||
if (navigator.onLine) {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payloadData)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Update local IndexedDB cache as synced
|
||||
await db.logbooks.update(id, {
|
||||
encryptedTitle: encryptedTitleStr,
|
||||
updatedAt: now,
|
||||
isSynced: 1
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to update logbook on server, saving locally instead:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// If offline or request failed, store locally as unsynced and add to queue
|
||||
await db.logbooks.update(id, {
|
||||
encryptedTitle: encryptedTitleStr,
|
||||
updatedAt: now,
|
||||
isSynced: 0
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'logbook',
|
||||
payloadId: id,
|
||||
logbookId: id,
|
||||
data: JSON.stringify(payloadData),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ import { decryptJson } from './crypto.js'
|
||||
import { isSignatureImage, isPasskeySignature, isClassicSignature, getSignaturePayload } from '../utils/signatures.js'
|
||||
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
|
||||
import i18n from '../i18n/index.js'
|
||||
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||
|
||||
function formatPasskeySignDate(signedAt: string): string {
|
||||
const locale = i18n.language === 'de' ? 'de-DE' : 'en-GB'
|
||||
return new Date(signedAt).toLocaleString(locale)
|
||||
return formatAppDateTime(signedAt, i18n.language)
|
||||
}
|
||||
|
||||
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
cardinalToDegrees,
|
||||
degreesToCardinal,
|
||||
formatCourseAngle,
|
||||
isCardinalDirection,
|
||||
normalizeCourseAngleString,
|
||||
normalizeWindDirectionString,
|
||||
parseCourseAngle,
|
||||
pointerAngleToDegrees,
|
||||
snapDegrees
|
||||
} from './courseAngle.js'
|
||||
|
||||
describe('parseCourseAngle', () => {
|
||||
it('parses padded and plain degrees', () => {
|
||||
expect(parseCourseAngle('042')).toBe(42)
|
||||
expect(parseCourseAngle('185°')).toBe(185)
|
||||
expect(parseCourseAngle('360')).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects invalid values', () => {
|
||||
expect(parseCourseAngle('999')).toBeNull()
|
||||
expect(parseCourseAngle('abc')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses cardinal labels', () => {
|
||||
expect(parseCourseAngle('NW')).toBe(315)
|
||||
})
|
||||
})
|
||||
|
||||
describe('snapDegrees', () => {
|
||||
it('snaps to step', () => {
|
||||
expect(snapDegrees(47, 5)).toBe(45)
|
||||
expect(snapDegrees(358, 5)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cardinal helpers', () => {
|
||||
it('roundtrips cardinal through degrees', () => {
|
||||
expect(degreesToCardinal(225)).toBe('SW')
|
||||
expect(cardinalToDegrees('SW')).toBe(225)
|
||||
expect(isCardinalDirection('nne')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointerAngleToDegrees', () => {
|
||||
it('returns 0 for north', () => {
|
||||
expect(pointerAngleToDegrees(100, 50, 100, 100)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 90 for east', () => {
|
||||
expect(Math.round(pointerAngleToDegrees(150, 100, 100, 100))).toBe(90)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeCourseAngleString', () => {
|
||||
it('keeps empty when allowed', () => {
|
||||
expect(normalizeCourseAngleString('', { allowEmpty: true })).toBe('')
|
||||
})
|
||||
|
||||
it('normalizes numeric course', () => {
|
||||
expect(normalizeCourseAngleString('042')).toBe('42')
|
||||
expect(formatCourseAngle(42, true)).toBe('042')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeWindDirectionString', () => {
|
||||
it('preserves cardinal wind', () => {
|
||||
expect(normalizeWindDirectionString('nw')).toBe('NW')
|
||||
})
|
||||
|
||||
it('normalizes degree wind', () => {
|
||||
expect(normalizeWindDirectionString('090')).toBe('90')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
export const CARDINAL_DIRECTIONS = [
|
||||
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
|
||||
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'
|
||||
] as const
|
||||
|
||||
export type CardinalDirection = (typeof CARDINAL_DIRECTIONS)[number]
|
||||
export type CourseStep = 1 | 5 | 10
|
||||
|
||||
const CARDINAL_SET = new Set<string>(CARDINAL_DIRECTIONS)
|
||||
|
||||
export function isCardinalDirection(value: string): boolean {
|
||||
return CARDINAL_SET.has(value.trim().toUpperCase())
|
||||
}
|
||||
|
||||
export function cardinalToDegrees(label: string): number | null {
|
||||
const upper = label.trim().toUpperCase()
|
||||
const index = CARDINAL_DIRECTIONS.indexOf(upper as CardinalDirection)
|
||||
if (index < 0) return null
|
||||
return (index * 22.5) % 360
|
||||
}
|
||||
|
||||
export function degreesToCardinal(degrees: number): CardinalDirection {
|
||||
const normalized = ((degrees % 360) + 360) % 360
|
||||
const index = Math.round(normalized / 22.5) % 16
|
||||
return CARDINAL_DIRECTIONS[index]
|
||||
}
|
||||
|
||||
export function snapDegrees(degrees: number, step: CourseStep): number {
|
||||
const normalized = ((degrees % 360) + 360) % 360
|
||||
const snapped = Math.round(normalized / step) * step
|
||||
return snapped >= 360 ? 0 : snapped
|
||||
}
|
||||
|
||||
/** 0° = north, clockwise (maritime compass). */
|
||||
export function pointerAngleToDegrees(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
centerX: number,
|
||||
centerY: number
|
||||
): number {
|
||||
const dx = clientX - centerX
|
||||
const dy = centerY - clientY
|
||||
const radians = Math.atan2(dx, dy)
|
||||
let degrees = (radians * 180) / Math.PI
|
||||
if (degrees < 0) degrees += 360
|
||||
return degrees
|
||||
}
|
||||
|
||||
export function parseCourseAngle(value: string): number | null {
|
||||
const trimmed = value.trim().replace(/°/g, '')
|
||||
if (!trimmed) return null
|
||||
|
||||
const cardinalDeg = cardinalToDegrees(trimmed)
|
||||
if (cardinalDeg !== null) return Math.round(cardinalDeg)
|
||||
|
||||
if (!/^\d{1,3}$/.test(trimmed)) return null
|
||||
const degrees = parseInt(trimmed, 10)
|
||||
if (Number.isNaN(degrees)) return null
|
||||
if (degrees === 360) return 0
|
||||
if (degrees < 0 || degrees > 360) return null
|
||||
return degrees
|
||||
}
|
||||
|
||||
export function formatCourseAngle(degrees: number, pad = false): string {
|
||||
const normalized = ((Math.round(degrees) % 360) + 360) % 360
|
||||
const text = String(normalized)
|
||||
return pad ? text.padStart(3, '0') : text
|
||||
}
|
||||
|
||||
export function normalizeCourseAngleString(
|
||||
value: string,
|
||||
options?: { allowEmpty?: boolean }
|
||||
): string {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return options?.allowEmpty ? '' : ''
|
||||
|
||||
if (isCardinalDirection(trimmed)) {
|
||||
return trimmed.toUpperCase()
|
||||
}
|
||||
|
||||
const parsed = parseCourseAngle(trimmed)
|
||||
if (parsed === null) return trimmed
|
||||
return formatCourseAngle(parsed)
|
||||
}
|
||||
|
||||
export function normalizeWindDirectionString(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
|
||||
if (isCardinalDirection(trimmed)) {
|
||||
return trimmed.toUpperCase()
|
||||
}
|
||||
|
||||
const parsed = parseCourseAngle(trimmed)
|
||||
if (parsed === null) return trimmed
|
||||
return formatCourseAngle(parsed)
|
||||
}
|
||||
|
||||
export function valueToDialDegrees(value: string, allowCardinal = false): number {
|
||||
const parsed = parseCourseAngle(value)
|
||||
if (parsed !== null) return parsed
|
||||
if (allowCardinal && isCardinalDirection(value)) {
|
||||
return cardinalToDegrees(value) ?? 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export type CourseOutputMode = 'degrees' | 'cardinal'
|
||||
|
||||
export function resolveCourseOutputMode(
|
||||
value: string,
|
||||
displayMode: 'degrees' | 'cardinal' | 'auto',
|
||||
allowCardinal: boolean
|
||||
): CourseOutputMode {
|
||||
if (!allowCardinal || displayMode === 'degrees') return 'degrees'
|
||||
if (displayMode === 'cardinal') return 'cardinal'
|
||||
return isCardinalDirection(value) ? 'cardinal' : 'degrees'
|
||||
}
|
||||
|
||||
export function dialDegreesToStorageValue(
|
||||
degrees: number,
|
||||
mode: CourseOutputMode,
|
||||
step: CourseStep
|
||||
): string {
|
||||
const snapped = snapDegrees(degrees, step)
|
||||
if (mode === 'cardinal') return degreesToCardinal(snapped)
|
||||
return formatCourseAngle(snapped)
|
||||
}
|
||||
|
||||
export function formatCourseDisplay(
|
||||
value: string,
|
||||
allowCardinal: boolean
|
||||
): string {
|
||||
if (!value.trim()) return '—'
|
||||
if (allowCardinal && isCardinalDirection(value)) return value.toUpperCase()
|
||||
const parsed = parseCourseAngle(value)
|
||||
if (parsed === null) return value
|
||||
return `${formatCourseAngle(parsed, true)}°`
|
||||
}
|
||||
|
||||
const STEP_STORAGE_KEY = 'kaptein-course-dial-step'
|
||||
|
||||
export function loadCourseDialStep(): CourseStep {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STEP_STORAGE_KEY)
|
||||
if (raw === '5') return 5
|
||||
if (raw === '10') return 10
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
export function saveCourseDialStep(step: CourseStep): void {
|
||||
try {
|
||||
sessionStorage.setItem(STEP_STORAGE_KEY, String(step))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/** BCP 47 locales that use 24-hour clock for Intl formatting. */
|
||||
export function resolveIntlLocale(language?: string): string {
|
||||
const lng = (language ?? 'en').toLowerCase()
|
||||
return lng.startsWith('de') ? 'de-DE' : 'en-GB'
|
||||
}
|
||||
|
||||
const APP_DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}
|
||||
|
||||
const APP_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}
|
||||
|
||||
function toDate(value: Date | string | number): Date | null {
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
export function formatAppDateTime(value: Date | string | number, language?: string): string {
|
||||
const date = toDate(value)
|
||||
if (!date) return String(value)
|
||||
return date.toLocaleString(resolveIntlLocale(language), APP_DATE_TIME_OPTIONS)
|
||||
}
|
||||
|
||||
export function formatAppTime(value: Date | string | number, language?: string): string {
|
||||
const date = toDate(value)
|
||||
if (!date) return String(value)
|
||||
return date.toLocaleTimeString(resolveIntlLocale(language), APP_TIME_OPTIONS)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { i18n as I18nInstance } from 'i18next'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolveIntlLocale } from './dateTimeFormat.js'
|
||||
import { initSeo, normalizeSeoLang, updatePageSeo } from './seo.js'
|
||||
|
||||
const HTML_LANG = /^de|en$/
|
||||
|
||||
function createMockI18n(language: string): I18nInstance {
|
||||
return {
|
||||
isInitialized: true,
|
||||
language,
|
||||
t: (key: string) => key,
|
||||
on: vi.fn()
|
||||
} as unknown as I18nInstance
|
||||
}
|
||||
|
||||
describe('normalizeSeoLang', () => {
|
||||
it.each([
|
||||
['de', 'de'],
|
||||
['de-DE', 'de'],
|
||||
['en', 'en'],
|
||||
['en-US', 'en'],
|
||||
['en-GB', 'en']
|
||||
] as const)('maps %s to short code %s', (input, expected) => {
|
||||
expect(normalizeSeoLang(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updatePageSeo html lang', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.lang = 'de'
|
||||
window.history.replaceState({}, '', '/')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['de', 'de'],
|
||||
['en', 'en'],
|
||||
['en-GB', 'en']
|
||||
] as const)('sets html lang to %s when i18n language is %s', (i18nLanguage, expectedLang) => {
|
||||
initSeo(createMockI18n(i18nLanguage))
|
||||
updatePageSeo()
|
||||
|
||||
expect(document.documentElement.lang).toBe(expectedLang)
|
||||
expect(document.documentElement.lang).toMatch(HTML_LANG)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveIntlLocale', () => {
|
||||
it('uses full BCP 47 tags for Intl formatting only', () => {
|
||||
expect(resolveIntlLocale('de')).toBe('de-DE')
|
||||
expect(resolveIntlLocale('en')).toBe('en-GB')
|
||||
})
|
||||
|
||||
it('does not reuse Intl locale tags for html lang', () => {
|
||||
const intlLocale = resolveIntlLocale('en')
|
||||
const htmlLang = normalizeSeoLang('en')
|
||||
|
||||
expect(intlLocale).toBe('en-GB')
|
||||
expect(htmlLang).toBe('en')
|
||||
expect(htmlLang).not.toBe(intlLocale)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
normalizeCourseAngleString,
|
||||
normalizeWindDirectionString
|
||||
} from './courseAngle.js'
|
||||
|
||||
export interface LogEventPayload {
|
||||
time: string
|
||||
mgk: string
|
||||
@@ -17,6 +22,56 @@ export interface LogEventPayload {
|
||||
remarks: string
|
||||
}
|
||||
|
||||
/** Local time as HH:MM (24-hour). */
|
||||
export function currentLocalTimeHHMM(date: Date = new Date()): string {
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
|
||||
/** Parse 24h or 12h (AM/PM) time strings to HH:MM. */
|
||||
export function parseTimeToHHMM(value: string): string | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const amPm = trimmed.match(/^(\d{1,2}):(\d{2})(?::\d{2})?\s*(AM|PM)$/i)
|
||||
if (amPm) {
|
||||
let hours = parseInt(amPm[1], 10)
|
||||
const minutes = parseInt(amPm[2], 10)
|
||||
const isPm = amPm[3].toUpperCase() === 'PM'
|
||||
if (hours < 1 || hours > 12 || minutes < 0 || minutes > 59) return null
|
||||
if (hours === 12) hours = isPm ? 12 : 0
|
||||
else if (isPm) hours += 12
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const h24 = trimmed.match(/^(\d{1,2}):(\d{2})(?::\d{2})?$/)
|
||||
if (h24) {
|
||||
const hours = parseInt(h24[1], 10)
|
||||
const minutes = parseInt(h24[2], 10)
|
||||
if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function isValidTimeHHMM(value: string): boolean {
|
||||
return parseTimeToHHMM(value) !== null
|
||||
}
|
||||
|
||||
export function splitTimeHHMM(value: string): { hours: string; minutes: string } {
|
||||
const parsed = parseTimeToHHMM(value) ?? currentLocalTimeHHMM()
|
||||
return { hours: parsed.slice(0, 2), minutes: parsed.slice(3, 5) }
|
||||
}
|
||||
|
||||
export function joinTimeHHMM(hours: string, minutes: string): string {
|
||||
const h = Math.min(23, Math.max(0, parseInt(hours, 10) || 0))
|
||||
const m = Math.min(59, Math.max(0, parseInt(minutes, 10) || 0))
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
|
||||
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
|
||||
'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
|
||||
@@ -28,11 +83,11 @@ export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<strin
|
||||
const e = event as Record<string, unknown>
|
||||
const timeRaw = String(e.time ?? '').trim()
|
||||
const normalized: LogEventPayload = {
|
||||
time: timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw,
|
||||
mgk: '',
|
||||
rwk: '',
|
||||
time: parseTimeToHHMM(timeRaw) ?? (timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw),
|
||||
mgk: normalizeCourseAngleString(String(e.mgk ?? ''), { allowEmpty: true }),
|
||||
rwk: normalizeCourseAngleString(String(e.rwk ?? ''), { allowEmpty: true }),
|
||||
windPressure: '',
|
||||
windDirection: '',
|
||||
windDirection: normalizeWindDirectionString(String(e.windDirection ?? '')),
|
||||
windStrength: '',
|
||||
seaState: '',
|
||||
weatherIcon: '',
|
||||
@@ -46,7 +101,7 @@ export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<strin
|
||||
remarks: ''
|
||||
}
|
||||
for (const key of LOG_EVENT_FIELDS) {
|
||||
if (key === 'time') continue
|
||||
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection') continue
|
||||
normalized[key] = String(e[key] ?? '').trim()
|
||||
}
|
||||
return normalized
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
@@ -20,6 +21,10 @@ function readAppVersion(): string {
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/*.test.ts']
|
||||
},
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(readAppVersion())
|
||||
},
|
||||
@@ -42,6 +47,9 @@ export default defineConfig({
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
registerType: 'prompt',
|
||||
devOptions: {
|
||||
enabled: false
|
||||
},
|
||||
includeAssets: ['favicon.ico', 'logo.png'],
|
||||
injectManifest: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}']
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
# Implementierungsplan: 360°-Kompass-Dial für Kursangaben
|
||||
|
||||
**Status:** Implementiert (Branch `feat/compass-course-dial`)
|
||||
**Bezug:** Ereignisprotokoll (`LogEntryEditor`), Felder MgK / rwK / Windrichtung
|
||||
**Vorbild im Projekt:** `EventTimeInput24h` (spezialisierte Eingabe + Text-Fallback, keine API-Änderung)
|
||||
|
||||
---
|
||||
|
||||
## 1. Ziel und Nicht-Ziele
|
||||
|
||||
### Ziel
|
||||
- Eingabe von Kurswinkeln (0°–360°) über einen **mobil tauglichen Kompass-Ring** (Drag/Tap).
|
||||
- **Hybrid-Eingabe:** Dial + numerisches Feld (wie bei der Uhrzeit).
|
||||
- Einheitliche Normalisierung (`000`–`360`, Speicherung als String ohne `°`).
|
||||
- Wiederverwendbare Komponente für **MgK**, **rwK** und optional **Wind** (Gradmodus).
|
||||
|
||||
### Nicht-Ziele (v1)
|
||||
- Keine Änderung am Server-Schema oder Verschlüsselungsformat.
|
||||
- Keine Device-Orientation / echter Kompass des Geräts (optional Phase 2).
|
||||
- Kein Ersatz der Ablenkungstabelle (`DeviationForm`) – bleibt 10°-Raster.
|
||||
- Windrichtung bleibt **kompatibel** mit bestehenden Kardinalwerten (`N`, `NNE`, …) aus Wetter-API.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ist-Analyse
|
||||
|
||||
| Feld | Speicherformat | UI heute | Besonderheit |
|
||||
|------|----------------|----------|--------------|
|
||||
| `mgk` | String, z. B. `"042"` | Text `placeholder="e.g. 180"` | Grad, PDF/CSV mit `°` |
|
||||
| `rwk` | String, z. B. `"038"` | Text | Grad |
|
||||
| `windDirection` | String | Text | Oft **Kardinal** (`NW`) via OpenWeather; manuell auch Grad möglich |
|
||||
|
||||
**Betroffene Dateien (Lesen/Schreiben, unverändert speichern):**
|
||||
- `client/src/components/LogEntryEditor.tsx` – Formular + Tabelle
|
||||
- `client/src/utils/logEntryPayload.ts` – `normalizeLogEvent`
|
||||
- `client/src/services/pdfExport.ts`, `csvExport.ts` – Export
|
||||
- `client/src/services/demoLogbookData.ts` – Demo-Daten
|
||||
|
||||
**Referenz-Pattern:** `EventTimeInput24h.tsx` + `parseTimeToHHMM` / `joinTimeHHMM` in `logEntryPayload.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architektur
|
||||
|
||||
```
|
||||
client/src/utils/courseAngle.ts # Parsing, Normalisierung, Winkel-Mathe
|
||||
client/src/components/CourseDialInput.tsx # UI: SVG-Ring + Zahleneingabe
|
||||
client/src/components/CourseDialField.tsx # Label + Fehler + Modus (optional)
|
||||
client/src/App.css # .course-dial-* Styles
|
||||
client/src/components/LogEntryEditor.tsx # Integration MgK/rwk/Wind
|
||||
client/src/i18n/locales/{de,en}.json # Strings
|
||||
```
|
||||
|
||||
### 3.1 Utility-Schicht `courseAngle.ts`
|
||||
|
||||
| Funktion | Verhalten |
|
||||
|----------|-----------|
|
||||
| `parseCourseAngle(value)` | `"185"`, `"185°"`, `" 042 "` → `185` oder `null` |
|
||||
| `formatCourseAngle(degrees, pad?)` | `185` → `"185"` oder `"185"` / `"042"` (pad optional) |
|
||||
| `normalizeCourseAngleString(value)` | Parse oder Fallback; für `normalizeLogEvent` |
|
||||
| `pointerAngleToDegrees(clientX, clientY, cx, cy)` | `atan2`, 0° = Nord, Uhrzeigersinn maritim |
|
||||
| `degreesToCardinal(deg)` | 16-Sektoren (bestehende Logik aus Wetter-Import) |
|
||||
| `cardinalToDegrees(label)` | Reverse für Dial-Anzeige bei Kardinal-Strings |
|
||||
| `snapDegrees(deg, step)` | `step` 1, 5 oder 10 |
|
||||
|
||||
**Konvention:** 0° = Nord, Winkel im Uhrzeigersinn (Kompass/Navigation), konsistent mit `wind.deg` in `LogEntryEditor`.
|
||||
|
||||
### 3.2 Komponente `CourseDialInput`
|
||||
|
||||
**Props:**
|
||||
```ts
|
||||
interface CourseDialInputProps {
|
||||
value: string // roher Formularwert
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
step?: 1 | 5 | 10 // Standard: 1
|
||||
allowCardinal?: boolean // Wind: true → Anzeige/Export Kardinal optional
|
||||
displayMode?: 'degrees' | 'cardinal' | 'auto'
|
||||
'aria-label': string
|
||||
id?: string
|
||||
}
|
||||
```
|
||||
|
||||
**UI-Aufbau:**
|
||||
1. **SVG-Ring** (ca. 200–240 px Desktop, min. 160 px Mobile)
|
||||
- Gradmarken alle 30° (Labels 000, 030, … 330)
|
||||
- Zeiger / Highlight-Bogen bei aktuellem Wert
|
||||
- `touch-action: none` auf Ringfläche
|
||||
2. **Zentrum:** große Anzeige `185°` oder `NW`
|
||||
3. **Darunter:** `<input type="text" inputMode="numeric">` mit Validierung on blur
|
||||
4. **Fein/Grob-Toggle** (optional): 1° / 5° / 10° (lokal in `sessionStorage` merken)
|
||||
|
||||
**Interaktion:**
|
||||
- `pointerdown` → `setPointerCapture` → `pointermove` → Winkel berechnen → snappen → `onChange`
|
||||
- Tap auf Ring: Winkel zum Tap-Punkt
|
||||
- Tastatur am Zahleneingang: Pfeiltasten ±step (wenn fokussiert)
|
||||
|
||||
**Barrierefreiheit:**
|
||||
- `role="slider"`, `aria-valuemin={0}`, `aria-valuemax={360}`, `aria-valuenow`, `aria-label`
|
||||
- Zahleneingang bleibt voll bedienbar ohne Dial
|
||||
- Fokus-Reihenfolge: Input vor Dial oder umgekehrt (Input zuerst empfohlen)
|
||||
|
||||
### 3.3 Windrichtung: Modus-Entscheidung
|
||||
|
||||
**Empfehlung v1:** Zwei Darstellungsmodi, **ein Speicher-String**:
|
||||
|
||||
| Modus | Speicher | Dial |
|
||||
|-------|----------|------|
|
||||
| Grad | `"225"` | Standard-Dial |
|
||||
| Kardinal | `"SW"` | Dial zeigt Sektor-Mitte (225°), Änderung schreibt Kardinal |
|
||||
|
||||
- Wetter-Import (`handleFetchWeather`) setzt weiter Kardinal → Dial mappt auf Sektor.
|
||||
- Nutzer kann auf Grad umschalten (kleiner Link „Als Grad“ / Toggle).
|
||||
- `normalizeLogEvent`: erkennt Kardinal vs. Zahl, keine erzwungene Konvertierung beim Laden.
|
||||
|
||||
---
|
||||
|
||||
## 4. Integration `LogEntryEditor`
|
||||
|
||||
### 4.1 Layout (mobil-first)
|
||||
|
||||
**Problem:** Formular ist bereits dicht (`form-grid`).
|
||||
|
||||
**Lösung:** Kurs-Block als **eigene Sektion** „Kurs“ mit Tabs:
|
||||
|
||||
```
|
||||
[ MgK ] [ rwK ] ← Tab-Leiste (Segmented Control)
|
||||
┌─────────────────────────┐
|
||||
│ CourseDialInput │ ← ein Dial, Wert je Tab
|
||||
│ + Zahleneingang │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
- Ein Dial, State wechselt mit Tab (`activeCourseField: 'mgk' | 'rwk'`).
|
||||
- Spart Platz; MgK/rwk werden nacheinander gesetzt (typischer Workflow).
|
||||
|
||||
**Windrichtung:** eigene Zeile unter Wetter-Grid; kompakter Dial (kleinere `size="sm"`) oder ausklappbar „Wind am Kompass setzen“.
|
||||
|
||||
### 4.2 Ersetzungen
|
||||
|
||||
| Alt | Neu |
|
||||
|-----|-----|
|
||||
| `<input>` MgK | `<CourseDialInput value={evMgk} … />` |
|
||||
| `<input>` rwK | Tab + gleicher Dial |
|
||||
| `<input>` Wind | `<CourseDialInput allowCardinal displayMode="auto" … />` |
|
||||
|
||||
### 4.3 `normalizeLogEvent`
|
||||
|
||||
```ts
|
||||
mgk: normalizeCourseAngleString(e.mgk, { allowEmpty: true }),
|
||||
rwk: normalizeCourseAngleString(e.rwk, { allowEmpty: true }),
|
||||
windDirection: normalizeWindDirectionString(e.windDirection), // Kardinal ODER Grad-String
|
||||
```
|
||||
|
||||
Bestehende Demo- und Export-Daten bleiben gültig.
|
||||
|
||||
---
|
||||
|
||||
## 5. Styling (`App.css`)
|
||||
|
||||
- `.course-dial` – Container, max-width, zentriert
|
||||
- `.course-dial__svg` – `width: 100%; aspect-ratio: 1`
|
||||
- `.course-dial__ring` – stroke, hover/active
|
||||
- `.course-dial__needle` – transform `rotate(${deg}deg)`
|
||||
- `.course-dial__value` – tabular-nums, große Schrift
|
||||
- `.course-dial__input` – wie `.time-input-24h`
|
||||
- `.course-dial-tabs` – Segmented Control (bestehende `--app-accent-*` Tokens)
|
||||
- **Responsive:** `@media (max-width: 640px)` – Dial max min(72vw, 220px); Touch-Target Ring ≥ 44 px
|
||||
|
||||
**Theme:** `currentColor` / CSS-Variablen (`--app-text`, `--app-accent-light`) – Dark/Light via `themes.css`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Internationalisierung
|
||||
|
||||
Neue Keys unter `logs.*`:
|
||||
|
||||
| Key | DE | EN |
|
||||
|-----|----|----|
|
||||
| `course_dial_hint` | Am Ring drehen oder Grad eingeben | Drag the ring or enter degrees |
|
||||
| `course_step_fine` | 1° | 1° |
|
||||
| `course_step_medium` | 5° | 5° |
|
||||
| `course_step_coarse` | 10° | 10° |
|
||||
| `course_tab_mgk` | MgK | MgK |
|
||||
| `course_tab_rwk` | rwK | rwK |
|
||||
| `course_invalid` | Ungültiger Kurs (0–360) | Invalid course (0–360) |
|
||||
| `wind_mode_cardinal` | Kardinal | Cardinal |
|
||||
| `wind_mode_degrees` | Grad | Degrees |
|
||||
|
||||
---
|
||||
|
||||
## 7. Phasen und Aufwand
|
||||
|
||||
### Phase A – Fundament (1–1,5 Tage)
|
||||
- [ ] `courseAngle.ts` + Unit-Tests (Vitest einrichten falls noch nicht vorhanden)
|
||||
- [ ] `CourseDialInput` (nur Grad, step 1/5, Pointer + Input)
|
||||
- [ ] CSS Grundlayout
|
||||
- [ ] Story/manuell: isoliert in kleiner Demo-Route oder Storybook (optional)
|
||||
|
||||
**Akzeptanz:** Dial setzt 0–360, Input synchron, Mobile Chrome/Safari getestet.
|
||||
|
||||
### Phase B – LogEntryEditor MgK/rwk (1 Tag)
|
||||
- [ ] Tab-UI MgK / rwK
|
||||
- [ ] Integration, `normalizeLogEvent`
|
||||
- [ ] Read-only: Dial disabled, Wert nur Anzeige
|
||||
|
||||
**Akzeptanz:** Ereignis speichern/laden/PDF unverändert korrekt; Skipper-Signatur-Flow unberührt.
|
||||
|
||||
### Phase C – Windrichtung (0,5–1 Tag)
|
||||
- [ ] `allowCardinal` / `displayMode`
|
||||
- [ ] Wetter-Import kompatibel
|
||||
- [ ] Toggle Kardinal ↔ Grad
|
||||
|
||||
**Akzeptanz:** API-Wind `NW` zeigt Dial auf NW; manuelle Grad-Eingabe möglich.
|
||||
|
||||
### Phase D – Polish (1–1,5 Tage)
|
||||
- [ ] Fein/Grob-Schritte + Persistenz
|
||||
- [ ] Tastatur (Pfeiltasten), Fokus-Stile
|
||||
- [ ] Reduzierte Bewegung (`prefers-reduced-motion`: nur Input, Dial statisch)
|
||||
- [ ] Plausible-Event optional: `Course Dial Used` (nur wenn Analytics gewünscht)
|
||||
- [ ] Dokumentation in `docs/plausible-events.md` falls Event
|
||||
|
||||
### Phase E – QA & Edge Cases (0,5 Tag)
|
||||
- [ ] Leerer Wert, 360 → 0 oder 360 (festlegen: **360 als Eingabe → speichern `360` oder `000`** – Empfehlung: intern 0–359 speichern, Anzeige 360 = 0)
|
||||
- [ ] Sehr lange Formulare auf kleinen Screens (Scroll, kein Layout-Sprung)
|
||||
- [ ] Offline/PWA, kein Regression bei `buildLogEntryPayload` / Signatur-Hash
|
||||
|
||||
**Gesamtaufwand:** ca. **4–5 Entwicklertage** für vollständige Implementierung inkl. Wind + A11y + QA.
|
||||
|
||||
---
|
||||
|
||||
## 8. Tests
|
||||
|
||||
### Unit (`courseAngle.ts`)
|
||||
- Parse: `"042"`, `"360"`, `"999"` (invalid), `"NW"` (wind helper)
|
||||
- `pointerAngleToDegrees` mit festen Koordinaten
|
||||
- `snapDegrees(47, 5)` → 45
|
||||
- `degreesToCardinal` / `cardinalToDegrees` Roundtrip
|
||||
|
||||
### Komponente (Testing Library)
|
||||
- `onChange` bei simuliertem Pointer-Event (oder direktem `setValue` via Input)
|
||||
- Disabled-State
|
||||
- `aria-valuenow` aktualisiert
|
||||
|
||||
### Manuell / UAT
|
||||
| # | Schritt | Erwartung |
|
||||
|---|---------|-----------|
|
||||
| 1 | Neues Ereignis, MgK am Dial auf 090 | Tabelle zeigt `90°`, PDF/CSV `90` |
|
||||
| 2 | rwK per Tastatur `270` | Dial zeigt West |
|
||||
| 3 | Wetter laden | Wind `NW`, Dial passend |
|
||||
| 4 | iPhone Safari, Daumen-Drag | Kein Scroll-Leaken, Wert stabil |
|
||||
| 5 | Nur Tastatur | Input allein speicherbar |
|
||||
| 6 | Bestehenden Eintrag bearbeiten | Alte Werte korrekt im Dial |
|
||||
|
||||
---
|
||||
|
||||
## 9. Risiken und Mitigationen
|
||||
|
||||
| Risiko | Mitigation |
|
||||
|--------|------------|
|
||||
| Dial zu groß auf Mobile | Tabs + max-width; Wind einklappbar |
|
||||
| Scroll vs. Drag | `touch-action: none` nur am Ring |
|
||||
| Kardinal/Grad-Inkonsistenz | `displayMode="auto"`, kein Silent-Overwrite |
|
||||
| Signatur-Hash ändert sich | Nur Normalisierung die bereits gültige Strings erlaubt; keine Rundung beim Speichern ohne Nutzeraktion |
|
||||
| Performance bei vielen Events | Dial nur im Formular, nicht in Tabelle |
|
||||
|
||||
---
|
||||
|
||||
## 10. Optionale Erweiterungen (Post-v1)
|
||||
|
||||
1. **MgK → rwK aus Ablenkungstabelle** vorschlagen (Lookup `deviations[roundedMgK]`).
|
||||
2. **DeviceOrientation** für Ring-Ausrichtung (mit Permission-Hinweis).
|
||||
3. **Haptik** `navigator.vibrate(10)` bei Snap (Android).
|
||||
4. **DeviationForm:** visueller Kompass statt nur Grid (separate Story).
|
||||
|
||||
---
|
||||
|
||||
## 11. Abnahmekriterien (Definition of Done)
|
||||
|
||||
- [ ] MgK und rwK im Ereignisformular per Dial + Input editierbar (Desktop + Mobile).
|
||||
- [ ] Windrichtung: Dial + Kardinal/Grad kompatibel mit Wetter-Import.
|
||||
- [ ] Keine Backend-/Migrations-Änderung; bestehende Logbücher laden unverändert.
|
||||
- [ ] PDF/CSV/Signatur-Verhalten identisch zu heute (nur Darstellung/Eingabe verbessert).
|
||||
- [ ] WCAG: Slider + Input bedienbar, `prefers-reduced-motion` berücksichtigt.
|
||||
- [ ] DE/EN vollständig übersetzt.
|
||||
|
||||
---
|
||||
|
||||
## 12. Empfohlene Umsetzungsreihenfolge (Commits)
|
||||
|
||||
1. `feat(course): add courseAngle utilities and tests`
|
||||
2. `feat(course): add CourseDialInput component and styles`
|
||||
3. `feat(logs): integrate compass dial for MgK and rwK`
|
||||
4. `feat(logs): wind direction dial with cardinal support`
|
||||
5. `fix(logs): a11y and reduced-motion for course dial`
|
||||
6. `docs: compass course dial plan and plausible event` (optional)
|
||||
@@ -40,12 +40,23 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
||||
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
||||
| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — |
|
||||
| Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — |
|
||||
| Profile Opened | Profilseite geöffnet (`UserProfilePage.tsx`, einmal pro Mount) | — |
|
||||
| Passkey Added | Passkey erfolgreich registriert (`UserProfilePage.tsx`) | `labeled`: `true` \| `false` (optionaler Name gesetzt) |
|
||||
| Passkey Removed | Passkey entfernt, mindestens ein Key verbleibt (`UserProfilePage.tsx`) | — |
|
||||
| Passkey Renamed | Passkey-Name gespeichert (`UserProfilePage.tsx`) | — |
|
||||
| Last Passkey Remove Hinted | Löschen des einzigen Passkeys abgebrochen — Hinweisdialog zur Kontolöschung (`UserProfilePage.tsx`) | — |
|
||||
| Local PIN Set | Lokaler PIN gesetzt oder geändert (`UserProfilePage.tsx`) | `action`: `set` \| `change` |
|
||||
| Local PIN Removed | Lokaler PIN entfernt (`UserProfilePage.tsx`) | — |
|
||||
| Device Forgotten | Account aus Schnell-Login-Liste dieses Geräts entfernt (`UserProfilePage.tsx`) | — |
|
||||
| Recovery Rotated | Neuer 12-Wörter-Wiederherstellungsschlüssel erstellt (`UserProfilePage.tsx`) | — |
|
||||
|
||||
## Bewusst nicht getrackt
|
||||
|
||||
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen.
|
||||
- **Manuelle Signaturen:** Nur Passkey-Signaturen lösen `Entry Signed` aus.
|
||||
- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
|
||||
- **Profil-KPIs:** Statistik-Karten und User-ID-Kopieren werden nicht getrackt (reine Anzeige bzw. zu granular).
|
||||
- **Kontolöschung:** `Account Deleted` bleibt in `auth.ts` — unabhängig davon, ob die Gefahrenzone auf der Profilseite oder früher in den Einstellungen genutzt wurde.
|
||||
|
||||
## Typische Funnels (Plausible Goals)
|
||||
|
||||
@@ -57,6 +68,7 @@ Empfohlene Goal-Ketten für Auswertung:
|
||||
4. **Öffentliche Freigabe:** Logbook Shared → Public Link Opened
|
||||
5. **Export:** Travel Day Saved → PDF Exported / CSV Exported
|
||||
6. **Datensicherung:** Backup Exported → Backup Restored
|
||||
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
|
||||
|
||||
## Entwicklung
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ model Credential {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
credentialId String @unique
|
||||
label String?
|
||||
publicKey Bytes
|
||||
counter BigInt
|
||||
transports String[] // WebAuthn transports list
|
||||
|
||||
@@ -22,8 +22,22 @@ const rpID = process.env.RP_ID || 'localhost'
|
||||
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
||||
|
||||
const registrationChallenges = new Map<string, string>()
|
||||
/** WebAuthn registration challenges for add-credential flow: challenge -> userId */
|
||||
const addCredentialChallenges = new Map<string, string>()
|
||||
const activeChallenges = new Set<string>()
|
||||
|
||||
function previewCredentialId(credentialId: string): string {
|
||||
if (credentialId.length <= 16) return credentialId
|
||||
return `${credentialId.slice(0, 8)}…${credentialId.slice(-8)}`
|
||||
}
|
||||
|
||||
function normalizeCredentialLabel(label: unknown): string | null {
|
||||
if (typeof label !== 'string') return null
|
||||
const trimmed = label.trim()
|
||||
if (!trimmed) return null
|
||||
return trimmed.slice(0, 64)
|
||||
}
|
||||
|
||||
router.post('/register-options', async (req, res) => {
|
||||
try {
|
||||
const { username } = req.body
|
||||
@@ -381,4 +395,259 @@ router.post('/enroll-prf', requireReauth, async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/rotate-recovery', requireReauth, async (req: any, res) => {
|
||||
try {
|
||||
const { encryptedMasterKeyRec, encryptedMasterKeyRecIv, encryptedMasterKeyRecTag } = req.body
|
||||
if (!encryptedMasterKeyRec || !encryptedMasterKeyRecIv || !encryptedMasterKeyRecTag) {
|
||||
return res.status(400).json({ error: 'Missing required recovery key fields' })
|
||||
}
|
||||
|
||||
if (
|
||||
typeof encryptedMasterKeyRec !== 'string' ||
|
||||
typeof encryptedMasterKeyRecIv !== 'string' ||
|
||||
typeof encryptedMasterKeyRecTag !== 'string'
|
||||
) {
|
||||
return res.status(400).json({ error: 'Invalid recovery key fields format' })
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.userId },
|
||||
data: {
|
||||
encryptedMasterKeyRec,
|
||||
encryptedMasterKeyRecIv,
|
||||
encryptedMasterKeyRecTag
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error rotating recovery key:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/profile', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.userId },
|
||||
include: {
|
||||
credentials: {
|
||||
orderBy: { id: 'asc' }
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
logbooks: true,
|
||||
collaborations: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' })
|
||||
}
|
||||
|
||||
return res.json({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
hasPrfEncryption: user.encryptedMasterKeyPrf != null,
|
||||
credentials: user.credentials.map((cred) => ({
|
||||
id: cred.id,
|
||||
label: cred.label,
|
||||
credentialIdPreview: previewCredentialId(cred.credentialId),
|
||||
transports: cred.transports
|
||||
})),
|
||||
serverMeta: {
|
||||
ownedLogbookCount: user._count.logbooks,
|
||||
collaborationCount: user._count.collaborations
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching user profile:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/add-credential-options', requireReauth, async (req: any, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.userId },
|
||||
include: { credentials: true }
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' })
|
||||
}
|
||||
|
||||
const userID = Buffer.from(user.username, 'utf8').toString('base64url')
|
||||
const excludeCredentials = user.credentials.map((cred) => ({
|
||||
id: Buffer.from(cred.credentialId, 'base64url'),
|
||||
type: 'public-key' as const,
|
||||
transports: cred.transports as any[]
|
||||
}))
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userID,
|
||||
userName: user.username,
|
||||
userDisplayName: user.username,
|
||||
attestationType: 'none',
|
||||
authenticatorSelection: {
|
||||
residentKey: 'required',
|
||||
userVerification: 'preferred'
|
||||
},
|
||||
supportedAlgorithmIDs: [-7, -257],
|
||||
excludeCredentials
|
||||
})
|
||||
|
||||
addCredentialChallenges.set(options.challenge, req.userId)
|
||||
|
||||
return res.json(options)
|
||||
} catch (error: any) {
|
||||
console.error('Error generating add-credential options:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
|
||||
try {
|
||||
const { credentialResponse, challenge } = req.body
|
||||
if (!credentialResponse || !challenge) {
|
||||
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
|
||||
}
|
||||
|
||||
const label = normalizeCredentialLabel(req.body.label)
|
||||
|
||||
const challengeUserId = addCredentialChallenges.get(challenge)
|
||||
if (!challengeUserId) {
|
||||
return res.status(400).json({ error: 'Challenge not found or expired' })
|
||||
}
|
||||
|
||||
if (challengeUserId !== req.userId) {
|
||||
return res.status(403).json({ error: 'Challenge does not belong to this account' })
|
||||
}
|
||||
|
||||
// Single-use: invalidate before verification so failed attempts cannot be retried
|
||||
addCredentialChallenges.delete(challenge)
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.userId }
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' })
|
||||
}
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: credentialResponse,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID
|
||||
})
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return res.status(400).json({ error: 'WebAuthn verification failed' })
|
||||
}
|
||||
|
||||
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo
|
||||
const credentialId = Buffer.from(credentialID).toString('base64url')
|
||||
|
||||
const existing = await prisma.credential.findUnique({
|
||||
where: { credentialId }
|
||||
})
|
||||
if (existing) {
|
||||
return res.status(400).json({ error: 'Credential already registered' })
|
||||
}
|
||||
|
||||
const credential = await prisma.credential.create({
|
||||
data: {
|
||||
userId: req.userId,
|
||||
credentialId,
|
||||
label,
|
||||
publicKey: Buffer.from(credentialPublicKey),
|
||||
counter: BigInt(counter),
|
||||
transports: credentialResponse.response.transports || []
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({
|
||||
verified: true,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
credentialIdPreview: previewCredentialId(credential.credentialId),
|
||||
transports: credential.transports
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error verifying add-credential response:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.patch('/credentials/:id', requireReauth, async (req: any, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const label = normalizeCredentialLabel(req.body?.label)
|
||||
|
||||
const credential = await prisma.credential.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!credential || credential.userId !== req.userId) {
|
||||
return res.status(404).json({ error: 'Credential not found' })
|
||||
}
|
||||
|
||||
const updated = await prisma.credential.update({
|
||||
where: { id },
|
||||
data: { label }
|
||||
})
|
||||
|
||||
return res.json({
|
||||
credential: {
|
||||
id: updated.id,
|
||||
label: updated.label,
|
||||
credentialIdPreview: previewCredentialId(updated.credentialId),
|
||||
transports: updated.transports
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error updating credential label:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/credentials/:id', requireReauth, async (req: any, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const credential = await prisma.credential.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!credential || credential.userId !== req.userId) {
|
||||
return res.status(404).json({ error: 'Credential not found' })
|
||||
}
|
||||
|
||||
const credentialCount = await prisma.credential.count({
|
||||
where: { userId: req.userId }
|
||||
})
|
||||
|
||||
if (credentialCount <= 1) {
|
||||
return res.status(400).json({ error: 'Cannot remove the last passkey' })
|
||||
}
|
||||
|
||||
await prisma.credential.delete({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting credential:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -131,4 +131,41 @@ router.delete('/:id', async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 5. Update a logbook title
|
||||
router.put('/:id', async (req: any, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { encryptedTitle } = req.body
|
||||
|
||||
if (!encryptedTitle) {
|
||||
return res.status(400).json({ error: 'encryptedTitle is required' })
|
||||
}
|
||||
|
||||
const logbook = await prisma.logbook.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!logbook) {
|
||||
return res.status(404).json({ error: 'Logbook not found' })
|
||||
}
|
||||
|
||||
if (logbook.userId !== req.userId) {
|
||||
return res.status(403).json({ error: 'Forbidden: Access denied' })
|
||||
}
|
||||
|
||||
const updatedLogbook = await prisma.logbook.update({
|
||||
where: { id },
|
||||
data: {
|
||||
encryptedTitle,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
return res.json(updatedLogbook)
|
||||
} catch (error: any) {
|
||||
console.error('Error updating logbook:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -46,7 +46,7 @@ router.post('/push', async (req: any, res) => {
|
||||
// Authorize: Check if logbook belongs to user
|
||||
// Exception: If action is create logbook, the logbook might not exist yet,
|
||||
// so we authorize based on user creating a logbook with their userId.
|
||||
if (type === 'logbook' && action === 'create') {
|
||||
if (type === 'logbook' && (action === 'create' || action === 'update')) {
|
||||
const existing = await prisma.logbook.findUnique({
|
||||
where: { id: logbookId }
|
||||
})
|
||||
@@ -69,9 +69,9 @@ router.post('/push', async (req: any, res) => {
|
||||
},
|
||||
update: {
|
||||
encryptedTitle: parsed.encryptedTitle,
|
||||
encryptedKey: parsed.encryptedKey || null,
|
||||
iv: parsed.iv || null,
|
||||
tag: parsed.tag || null,
|
||||
...(parsed.encryptedKey !== undefined ? { encryptedKey: parsed.encryptedKey } : {}),
|
||||
...(parsed.iv !== undefined ? { iv: parsed.iv } : {}),
|
||||
...(parsed.tag !== undefined ? { tag: parsed.tag } : {}),
|
||||
updatedAt: itemUpdatedAt
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user