mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3f52ebd7b | |||
| 4974013995 | |||
| bc192d3106 | |||
| 4db6cbef22 | |||
| f79385cf2a | |||
| db2c11e4a5 | |||
| e57c6773fc | |||
| 4bdc032f97 | |||
| 777b68f87b | |||
| 66a7de09c1 | |||
| a19ae9e653 | |||
| 38f4c9aecb | |||
| 802d78b577 | |||
| 3f61e1ca38 | |||
| 8e04deb0f5 | |||
| 160bd02f13 | |||
| 68a3036909 | |||
| ec4aaa628f | |||
| 2c0894b330 | |||
| bd2bdebc33 | |||
| 2857ff594c | |||
| 4f01a10277 | |||
| ee805369d1 | |||
| 6a718fccea | |||
| 01ed60e2d5 | |||
| 8042db8d7a | |||
| 21649d3cf0 | |||
| b9395e1e36 | |||
| 10d1f8d428 | |||
| 0c00f8e0b3 | |||
| 71637a8483 | |||
| 189b257254 | |||
| cd2f50bc89 | |||
| 530550455d | |||
| 9a31fcac7b | |||
| 677157de1d | |||
| b5b1d32b31 | |||
| ae4dfc48cc | |||
| 3b487519a5 | |||
| 1425c4e05b | |||
| a84aedc3b4 | |||
| 4b7ba6cb3f | |||
| 5952e02971 | |||
| 8cd5aa0d23 | |||
| c0aa252f9a | |||
| 8a58ce51c0 | |||
| 9c2decb095 | |||
| 5e9c8d2c43 | |||
| 39f13881c5 | |||
| 3b94727c07 | |||
| 4a5a461d25 | |||
| 1963573db4 | |||
| 5046e1a2e0 | |||
| a1f3b4476e | |||
| 8defc90e95 | |||
| b2a39a3071 | |||
| 21511c2f68 | |||
| 0e5c819f7c | |||
| 0f44d7d264 | |||
| e078a9d9e1 | |||
| fef12b0e8b | |||
| df075630fb | |||
| bffb55d8c0 | |||
| 5c24213b0e | |||
| 12a457801a | |||
| ae4d317dc3 | |||
| f7c6854059 | |||
| bdb6b01765 | |||
| 129dfabaa3 | |||
| 8a6d1b2aaf | |||
| 465b78411a | |||
| 272b32b410 | |||
| 7945e752d6 | |||
| 6eb3ab38fb | |||
| c7a9210215 | |||
| d5d63aa979 | |||
| 84574020f2 | |||
| 1b7ea2c87d | |||
| 47b7678975 | |||
| da70388f4b | |||
| 6c1a795460 | |||
| 75d23eb6aa | |||
| 0c4de72356 | |||
| 5e8602c50a | |||
| 61b8070626 | |||
| 5caaeff67c | |||
| 92a1f9c448 | |||
| 58a8e97f94 | |||
| 815b725f87 | |||
| d80bbd5bed | |||
| 293506217e | |||
| 9739542a3a | |||
| 9f3a88223d | |||
| 409a63633c | |||
| 125436fa87 | |||
| 975846c236 | |||
| 7befb7d555 | |||
| 099255761c | |||
| c8fc21b8bd | |||
| 9186b8c850 | |||
| e38c5fed44 | |||
| 3b069bc543 | |||
| 618b1b8697 | |||
| e45a0efce3 | |||
| 597a5f7a1d | |||
| 42c216b00b | |||
| f3751ab9aa | |||
| 9e8d101d63 |
@@ -1,11 +1,6 @@
|
||||
name: Build & Push Docker Image (Prerelease)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ client/public/icons/*.png
|
||||
*.sqlite-wal
|
||||
|
||||
# User data
|
||||
server/data/
|
||||
server/data/*
|
||||
server/uploads/
|
||||
|
||||
# Environment
|
||||
|
||||
Generated
+40
-99
@@ -22,6 +22,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.22.2",
|
||||
"react-window": "^2.2.7",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
@@ -171,7 +172,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1807,7 +1807,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
@@ -1856,7 +1855,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
@@ -2369,9 +2367,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2389,9 +2384,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2409,9 +2401,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2429,9 +2418,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2449,9 +2435,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2469,9 +2452,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2489,9 +2469,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2515,9 +2492,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2541,9 +2515,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2567,9 +2538,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2593,9 +2561,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2619,9 +2584,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3401,9 +3363,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3418,9 +3377,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3435,9 +3391,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3452,9 +3405,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3469,9 +3419,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3486,9 +3433,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3503,9 +3447,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3520,9 +3461,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3537,9 +3475,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3554,9 +3489,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3571,9 +3503,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3588,9 +3517,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3605,9 +3531,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3825,7 +3748,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -3966,7 +3890,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -3978,7 +3901,6 @@
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
@@ -4221,7 +4143,6 @@
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -4249,6 +4170,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -4649,7 +4571,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -5397,7 +5318,8 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
@@ -6337,6 +6259,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-sanitize": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz",
|
||||
"integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@ungap/structured-clone": "^1.0.0",
|
||||
"unist-util-position": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-jsx-runtime": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
|
||||
@@ -7133,7 +7070,6 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -7261,8 +7197,7 @@
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/leaflet.markercluster": {
|
||||
"version": "1.5.3",
|
||||
@@ -7397,6 +7332,7 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -8437,7 +8373,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@inquirer/confirm": "^5.0.0",
|
||||
"@mswjs/interceptors": "^0.41.2",
|
||||
@@ -8823,7 +8758,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -8985,6 +8919,7 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -9085,7 +9020,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -9098,7 +9032,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -9138,14 +9071,14 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^2.1.0"
|
||||
},
|
||||
@@ -9388,6 +9321,20 @@
|
||||
"regjsparser": "bin/parser"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-sanitize": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz",
|
||||
"integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"hast-util-sanitize": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-breaks": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz",
|
||||
@@ -9540,7 +9487,6 @@
|
||||
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -10658,7 +10604,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10908,7 +10853,6 @@
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -11227,7 +11171,6 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -11356,7 +11299,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -11854,7 +11796,6 @@
|
||||
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.22.2",
|
||||
"react-window": "^2.2.7",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
|
||||
+23
-5
@@ -2,6 +2,7 @@ import React, { useEffect, ReactNode } from 'react'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import { useAddonStore } from './store/addonStore'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import TripPlannerPage from './pages/TripPlannerPage'
|
||||
@@ -24,17 +25,22 @@ import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
||||
import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
// Notice action registrations (side-effect imports):
|
||||
import './pages/Trips/noticeActions.js'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
adminRequired?: boolean
|
||||
addonId?: string
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
|
||||
function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedRouteProps) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isLoading = useAuthStore((s) => s.isLoading)
|
||||
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
|
||||
const addonStore = useAddonStore()
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
|
||||
@@ -67,6 +73,10 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
|
||||
if (addonId && addonStore.loaded && !addonStore.isEnabled(addonId)) {
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen md:block md:h-auto">
|
||||
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
|
||||
@@ -90,8 +100,9 @@ function RootRedirect() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
|
||||
@@ -105,7 +116,7 @@ export default function App() {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
if (config?.demo_mode) setDemoMode(true)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
|
||||
@@ -114,6 +125,9 @@ export default function App() {
|
||||
if (config?.timezone) setServerTimezone(config.timezone)
|
||||
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
||||
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
|
||||
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled)
|
||||
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
|
||||
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
|
||||
if (config?.version) {
|
||||
@@ -145,6 +159,7 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadSettings()
|
||||
loadAddons()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
@@ -182,8 +197,11 @@ export default function App() {
|
||||
applyDark(mode === true || mode === 'dark')
|
||||
}, [settings.dark_mode, isSharedPage])
|
||||
|
||||
const isAuthPage = location.pathname.startsWith('/login') || location.pathname.startsWith('/register')
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
{!isAuthPage && <SystemNoticeHost />}
|
||||
<ToastContainer />
|
||||
<OfflineBanner />
|
||||
<Routes>
|
||||
@@ -253,7 +271,7 @@ export default function App() {
|
||||
<Route
|
||||
path="/journey"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute addonId="journey">
|
||||
<JourneyPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
@@ -261,7 +279,7 @@ export default function App() {
|
||||
<Route
|
||||
path="/journey/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute addonId="journey">
|
||||
<JourneyDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
|
||||
@@ -190,18 +190,27 @@ export const placesApi = {
|
||||
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
||||
importGpx: (tripId: number | string, file: File) => {
|
||||
const fd = new FormData(); fd.append('file', file)
|
||||
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints))
|
||||
if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes))
|
||||
if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks))
|
||||
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importMapFile: (tripId: number | string, file: File) => {
|
||||
const fd = new FormData(); fd.append('file', file)
|
||||
importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (opts?.points !== undefined) fd.append('importPoints', String(opts.points))
|
||||
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importGoogleList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
@@ -272,6 +281,14 @@ export const adminApi = {
|
||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
|
||||
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
|
||||
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
|
||||
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
|
||||
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
|
||||
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
|
||||
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
|
||||
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
|
||||
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||
getPackingTemplate: (id: number) => apiClient.get(`/admin/packing-templates/${id}`).then(r => r.data),
|
||||
createPackingTemplate: (data: { name: string }) => apiClient.post('/admin/packing-templates', data).then(r => r.data),
|
||||
@@ -299,6 +316,8 @@ export const adminApi = {
|
||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
||||
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
||||
updateDefaultUserSettings: (settings: Record<string, unknown>) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data),
|
||||
}
|
||||
|
||||
export const addonsApi = {
|
||||
@@ -327,8 +346,8 @@ export const journeyApi = {
|
||||
|
||||
// Photos
|
||||
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption }).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
|
||||
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
|
||||
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
|
||||
@@ -361,6 +380,11 @@ export const mapsApi = {
|
||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
|
||||
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const budgetApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
|
||||
|
||||
@@ -4,12 +4,33 @@ import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react'
|
||||
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
|
||||
}
|
||||
|
||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
|
||||
<path d="M11.986.27c-2.409 0-5.207 1.09-5.207 3.894v.152c1.343.597 2.935 1.663 4.412 2.971 1.571 1.391 2.838 2.882 3.653 4.287 1.4-2.503 2.336-5.478 2.347-7.373V4.164c0-2.803-2.796-3.894-5.205-3.894m7.512 4.49c-.378-.008-.775.05-1.192.186l-.144.047c-.153 1.461-.676 3.304-1.463 5.113-.837 1.924-1.863 3.59-2.947 4.799 2.813.558 5.93.527 7.736-.047l.035-.01c2.667-.866 2.84-3.863 2.096-6.154-.628-1.933-2.081-3.89-4.121-3.934m-14.996.04c-2.04.043-3.493 1.997-4.121 3.93-.744 2.291-.571 5.288 2.096 6.155l.144.046c.982-1.092 2.488-2.276 4.188-3.277 1.809-1.065 3.619-1.808 5.207-2.148-1.949-2.105-4.489-3.914-6.287-4.51l-.036-.012c-.416-.135-.813-.193-1.191-.185m4.672 6.758c-2.604 1.202-5.109 3.06-6.233 4.586l-.021.029c-1.648 2.268-.027 4.795 1.922 6.211 1.949 1.416 4.852 2.177 6.5-.092.023-.031.054-.07.09-.121-.736-1.272-1.396-3.072-1.822-4.998-.454-2.05-.603-4-.436-5.615m1.072 3.338c.339 2.848 1.332 5.804 2.436 7.344l.021.029c1.648 2.268 4.551 1.508 6.5.092 1.949-1.416 3.57-3.943 1.922-6.211-.023-.031-.052-.073-.088-.123-1.437.307-3.352.38-5.316.19-2.089-.202-3.99-.663-5.475-1.321" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SynologyIcon({ size = 14 }: { size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
|
||||
<path d="M17.895 11.927a3.196 3.196 0 0 1 .394-1.53l-.008.017a2.677 2.677 0 0 1 1.075-1.108l.014-.007a3.181 3.181 0 0 1 1.523-.382h.05-.003q1.346 0 2.2.871.854.871.86 2.203c0 .895-.29 1.635-.867 2.226s-1.306.886-2.183.886c-.566 0-1.1-.137-1.571-.379l.019.009a2.535 2.535 0 0 1-1.115-1.067l-.007-.013q-.38-.708-.381-1.726zm1.593.083c0 .591.138 1.043.42 1.349a1.365 1.365 0 0 0 2.066.002l.001-.002c.275-.307.413-.764.413-1.357s-.138-1.033-.413-1.342a1.371 1.371 0 0 0-2.066-.001l-.001.002c-.281.306-.42.758-.42 1.345zm-1.602 2.941H16.33v-3.015c0-.635-.032-1.044-.101-1.234a.876.876 0 0 0-.328-.435l-.003-.002a.938.938 0 0 0-.521-.156h-.027.001-.012c-.27 0-.521.084-.727.228l.004-.003a1.115 1.115 0 0 0-.444.576l-.002.008c-.083.248-.121.696-.121 1.359v2.673H12.5V9.027h1.439v.867c.518-.656 1.167-.98 1.952-.98h.021c.335 0 .655.067.946.189l-.016-.006c.261.105.48.268.648.475l.002.003c.141.185.247.404.304.643l.002.012c.057.278.089.597.089.924l-.002.135v-.007zM6.413 9.028h1.654l1.412 4.204 1.376-4.204h1.611l-2.067 5.693-.38 1.038a4.158 4.158 0 0 1-.4.807l.01-.017a1.637 1.637 0 0 1-.422.443l-.005.003c-.17.113-.367.203-.578.26l-.014.003c-.232.064-.499.1-.774.1h-.025.001a4.13 4.13 0 0 1-.911-.105l.028.005-.129-1.229c.198.046.426.074.659.077h.002c.36 0 .628-.106.8-.318a2.27 2.27 0 0 0 .395-.807l.004-.016zM0 12.29l1.592-.149q.147.802.586 1.181.439.379 1.192.375c.528 0 .927-.113 1.197-.335.27-.222.4-.486.4-.782v-.024a.751.751 0 0 0-.167-.474l.001.001c-.113-.132-.309-.252-.59-.347-.193-.074-.631-.191-1.312-.365-.882-.216-1.496-.486-1.85-.804A2.147 2.147 0 0 1 .3 8.936v-.019V8.908c0-.431.132-.831.358-1.163l-.005.007a2.226 2.226 0 0 1 1.003-.826l.015-.005c.442-.184.973-.281 1.602-.281q1.529 0 2.304.676c.516.457.785 1.057.811 1.809l-1.649.055c-.073-.413-.219-.714-.452-.899-.233-.185-.579-.276-1.034-.276-.476 0-.85.098-1.118.298a.59.59 0 0 0-.261.49v.011-.001.002c0 .201.095.379.242.493l.001.001c.205.179.709.36 1.507.546.798.186 1.388.387 1.769.59.374.196.678.48.893.825l.006.01c.214.345.326.786.326 1.305 0 .489-.146.944-.396 1.325l.006-.009c-.264.408-.64.724-1.084.908l-.016.006c-.475.194-1.065.298-1.772.298-1.029 0-1.819-.241-2.373-.722-.554-.481-.879-1.177-.986-2.091z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const PROVIDER_ICONS: Record<string, React.FC<{ size?: number }>> = {
|
||||
immich: ImmichIcon,
|
||||
synologyphotos: SynologyIcon,
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
id: string
|
||||
name: string
|
||||
@@ -38,7 +59,16 @@ function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
||||
return <Icon size={size} />
|
||||
}
|
||||
|
||||
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void }) {
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
|
||||
|
||||
const COLLAB_SUB_FEATURES = [
|
||||
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
|
||||
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
|
||||
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
|
||||
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
|
||||
] as const
|
||||
|
||||
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
@@ -156,6 +186,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<Luggage size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('admin.bagTracking.title')}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.bagTracking.subtitle')}</div>
|
||||
@@ -173,6 +204,36 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
|
||||
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{COLLAB_SUB_FEATURES.map(feat => {
|
||||
const enabled = collabFeatures[feat.key]
|
||||
const Icon = feat.icon
|
||||
return (
|
||||
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t(feat.titleKey)}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t(feat.subtitleKey)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button onClick={() => onToggleCollabFeature(feat.key)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -194,8 +255,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
{addon.id === 'journey' && providerOptions.length > 0 && (
|
||||
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{providerOptions.map(provider => (
|
||||
{providerOptions.map(provider => {
|
||||
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
||||
return (
|
||||
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
{ProviderIcon && <span style={{ color: 'var(--text-faint)' }}><ProviderIcon size={14} /></span>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
|
||||
@@ -214,7 +278,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Settings2 } from 'lucide-react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import Section from '../Settings/Section'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
||||
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
type Defaults = {
|
||||
temperature_unit?: string
|
||||
dark_mode?: string | boolean
|
||||
time_format?: string
|
||||
route_calculation?: boolean
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
}
|
||||
|
||||
function OptionRow({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: React.ReactNode
|
||||
hint?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{label}
|
||||
</label>
|
||||
{hint && <p className="text-xs mb-2" style={{ color: 'var(--text-faint)' }}>{hint}</p>}
|
||||
<div className="flex gap-3 flex-wrap">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [defaults, setDefaults] = useState<Defaults>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
||||
setDefaults(data)
|
||||
setMapTileUrl(data.map_tile_url || '')
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
}, [])
|
||||
|
||||
const save = async (patch: Partial<Defaults>) => {
|
||||
try {
|
||||
const updated = await adminApi.updateDefaultUserSettings(patch as Record<string, unknown>)
|
||||
setDefaults(updated)
|
||||
toast.success(t('admin.defaultSettings.saved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const reset = async (key: keyof Defaults) => {
|
||||
try {
|
||||
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
|
||||
setDefaults(updated)
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
toast.success(t('admin.defaultSettings.reset'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const isSet = (key: keyof Defaults) => defaults[key] !== undefined
|
||||
|
||||
const ResetButton = ({ field }: { field: keyof Defaults }) =>
|
||||
isSet(field) ? (
|
||||
<button
|
||||
onClick={() => reset(field)}
|
||||
className="text-xs ml-2"
|
||||
style={{ color: 'var(--text-faint)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
const mapPreviewPlaces = useMemo((): Place[] => [{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
name: 'Preview center',
|
||||
description: null,
|
||||
notes: null,
|
||||
lat: 48.8566,
|
||||
lng: 2.3522,
|
||||
address: null,
|
||||
category_id: null,
|
||||
icon: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
duration_minutes: null,
|
||||
transport_mode: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
created_at: Date(),
|
||||
}], [])
|
||||
|
||||
if (!loaded) {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||
}
|
||||
|
||||
const darkMode = defaults.dark_mode
|
||||
|
||||
return (
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<p className="text-sm" style={{ color: 'var(--text-faint)', marginTop: -8 }}>
|
||||
{t('admin.defaultSettings.description')}
|
||||
</p>
|
||||
|
||||
{/* Color Mode */}
|
||||
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
{([
|
||||
{ value: 'light', label: t('settings.light') },
|
||||
{ value: 'dark', label: t('settings.dark') },
|
||||
{ value: 'auto', label: t('settings.auto') },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={darkMode === opt.value || (opt.value === 'light' && darkMode === false) || (opt.value === 'dark' && darkMode === true)}
|
||||
onClick={() => save({ dark_mode: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Temperature */}
|
||||
<OptionRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
|
||||
{([
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.temperature_unit === opt.value}
|
||||
onClick={() => save({ temperature_unit: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Time Format */}
|
||||
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||
{([
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.time_format === opt.value}
|
||||
onClick={() => save({ time_format: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Route Calculation */}
|
||||
<OptionRow label={<>{t('settings.routeCalculation')} <ResetButton field="route_calculation" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={String(opt.value)}
|
||||
active={defaults.route_calculation === opt.value}
|
||||
onClick={() => save({ route_calculation: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={String(opt.value)}
|
||||
active={defaults.blur_booking_codes === opt.value}
|
||||
onClick={() => save({ blur_booking_codes: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Map Tile URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('settings.mapTemplate')}
|
||||
<ResetButton field="map_tile_url" />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) { setMapTileUrl(value); save({ map_tile_url: value }) } }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
onBlur={() => save({ map_tile_url: mapTileUrl })}
|
||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{t('settings.mapDefaultHint')}</p>
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
places: mapPreviewPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: null,
|
||||
onMapContextMenu: null,
|
||||
center: [48.8566, 2.3522],
|
||||
zoom: 10,
|
||||
tileUrl: mapTileUrl,
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -148,7 +148,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -166,7 +166,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -187,7 +187,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -205,7 +205,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -223,7 +223,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://github.com/mauriceboe/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
|
||||
@@ -107,10 +107,12 @@ export default function PermissionsPanel(): React.ReactElement {
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
||||
title={t('perm.resetDefaults')}
|
||||
aria-label={t('perm.resetDefaults')}
|
||||
className="flex items-center justify-center gap-1.5 px-0 sm:px-3 py-1.5 text-sm w-8 sm:w-auto border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t('perm.resetDefaults')}
|
||||
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
|
||||
@@ -416,8 +416,8 @@ describe('BudgetPanel', () => {
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Flight');
|
||||
await screen.findByText('Hotel');
|
||||
// Grand total card shows 300.00
|
||||
expect(screen.getByText('300.00')).toBeInTheDocument();
|
||||
// Grand total card shows 300.00 (integer and decimal are rendered in separate spans)
|
||||
expect(document.body.textContent?.replace(/\s+/g, '')).toMatch(/300[,.]00/);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
|
||||
|
||||
@@ -4,7 +4,69 @@ import DOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical } from 'lucide-react'
|
||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react'
|
||||
|
||||
function useIsDark(): boolean {
|
||||
const [dark, setDark] = useState<boolean>(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark')))
|
||||
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
return () => mo.disconnect()
|
||||
}, [])
|
||||
return dark
|
||||
}
|
||||
|
||||
function widgetTheme(dark: boolean) {
|
||||
if (dark) return {
|
||||
bg: 'linear-gradient(180deg, #17171d 0%, #0d0d12 100%)',
|
||||
border: 'rgba(255,255,255,0.07)',
|
||||
text: '#ffffff',
|
||||
sub: 'rgba(255,255,255,0.6)',
|
||||
faint: 'rgba(255,255,255,0.4)',
|
||||
track: 'rgba(255,255,255,0.04)',
|
||||
divider: 'rgba(255,255,255,0.07)',
|
||||
iconBg: 'rgba(255,255,255,0.08)',
|
||||
iconBorder: 'rgba(255,255,255,0.12)',
|
||||
iconColor: 'rgba(255,255,255,0.9)',
|
||||
centerBg: '#17171d',
|
||||
flowBg: 'rgba(255,255,255,0.05)',
|
||||
flowBorder: 'rgba(255,255,255,0.07)',
|
||||
flowHoverBg: 'rgba(255,255,255,0.08)',
|
||||
flowHoverBorder: 'rgba(255,255,255,0.12)',
|
||||
rowHover: 'rgba(255,255,255,0.03)',
|
||||
shadow: '0 20px 50px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.04)',
|
||||
donutShadow: 'drop-shadow(0 0 20px rgba(0,0,0,0.3))',
|
||||
}
|
||||
return {
|
||||
bg: 'linear-gradient(180deg, #ffffff 0%, #f9fafb 100%)',
|
||||
border: 'rgba(15,23,42,0.08)',
|
||||
text: '#111827',
|
||||
sub: 'rgba(17,24,39,0.6)',
|
||||
faint: 'rgba(17,24,39,0.4)',
|
||||
track: 'rgba(15,23,42,0.05)',
|
||||
divider: 'rgba(15,23,42,0.08)',
|
||||
iconBg: 'rgba(15,23,42,0.05)',
|
||||
iconBorder: 'rgba(15,23,42,0.1)',
|
||||
iconColor: 'rgba(17,24,39,0.75)',
|
||||
centerBg: '#ffffff',
|
||||
flowBg: 'rgba(15,23,42,0.03)',
|
||||
flowBorder: 'rgba(15,23,42,0.08)',
|
||||
flowHoverBg: 'rgba(15,23,42,0.06)',
|
||||
flowHoverBorder: 'rgba(15,23,42,0.14)',
|
||||
rowHover: 'rgba(15,23,42,0.04)',
|
||||
shadow: '0 12px 32px rgba(15,23,42,0.08), 0 2px 6px rgba(0,0,0,0.04)',
|
||||
donutShadow: 'drop-shadow(0 4px 18px rgba(15,23,42,0.12))',
|
||||
}
|
||||
}
|
||||
|
||||
function hexLighten(hex: string, amount: number): string {
|
||||
const m = hex.replace('#', '').match(/.{2}/g)
|
||||
if (!m || m.length !== 3) return hex
|
||||
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount))
|
||||
const [r, g, b] = m.map(x => parseInt(x, 16))
|
||||
return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
@@ -361,9 +423,47 @@ interface PerPersonInlineProps {
|
||||
locale: string
|
||||
}
|
||||
|
||||
function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInlineProps) {
|
||||
const [data, setData] = useState(null)
|
||||
const fmt = (v) => fmtNum(v, locale, currency)
|
||||
const SPLIT_COLORS = [
|
||||
{ solid: '#6366f1', gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)' },
|
||||
{ solid: '#ec4899', gradient: 'linear-gradient(135deg, #ec4899, #f43f5e)' },
|
||||
{ solid: '#10b981', gradient: 'linear-gradient(135deg, #10b981, #22c55e)' },
|
||||
{ solid: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #f97316)' },
|
||||
{ solid: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #3b82f6)' },
|
||||
{ solid: '#a855f7', gradient: 'linear-gradient(135deg, #a855f7, #d946ef)' },
|
||||
]
|
||||
|
||||
export function splitColorFor(userId: number, order: number) {
|
||||
return SPLIT_COLORS[order % SPLIT_COLORS.length]
|
||||
}
|
||||
|
||||
function colorForUserId(userId: number) {
|
||||
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
|
||||
}
|
||||
|
||||
function RingAvatar({ userId, username, avatarUrl, size = 34, innerBg = '#17171d', textColor = '#fff' }: { userId: number; username?: string; avatarUrl?: string | null; size?: number; innerBg?: string; textColor?: string }) {
|
||||
const color = colorForUserId(userId)
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%', flexShrink: 0,
|
||||
padding: 2, background: color.gradient,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '100%', height: '100%', borderRadius: '50%',
|
||||
background: innerBg,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
fontSize: size < 28 ? 10 : 12, fontWeight: 600, color: textColor,
|
||||
}}>
|
||||
{avatarUrl ? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType<typeof widgetTheme> }) {
|
||||
const [data, setData] = useState<any[] | null>(null)
|
||||
const fmt = (v: number) => fmtNum(v, locale, currency)
|
||||
|
||||
useEffect(() => {
|
||||
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
|
||||
@@ -371,25 +471,38 @@ function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInl
|
||||
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
const people = data.map((p: any) => ({ ...p, color: colorForUserId(p.user_id) }))
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map(person => (
|
||||
<div key={person.user_id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{
|
||||
width: 22, height: 22, borderRadius: '50%', background: 'rgba(255,255,255,0.1)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, fontWeight: 700,
|
||||
color: 'rgba(255,255,255,0.7)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{person.avatar_url
|
||||
? <img src={person.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: person.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: 12, fontWeight: 500, color: 'rgba(255,255,255,0.7)' }}>{person.username}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#fff' }}>{fmt(person.total_assigned)}</span>
|
||||
<>
|
||||
{grandTotal > 0 && (
|
||||
<div style={{ display: 'flex', height: 6, borderRadius: 999, overflow: 'hidden', marginTop: 8, marginBottom: 4, gap: 3 }}>
|
||||
{people.map(p => (
|
||||
<div key={p.user_id} style={{
|
||||
height: '100%', borderRadius: 999,
|
||||
flex: Math.max(p.total_assigned || 0, 0.01),
|
||||
background: p.color.gradient,
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 14, paddingTop: 14, borderTop: `1px solid ${theme.divider}`, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{people.map(p => {
|
||||
const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0
|
||||
return (
|
||||
<div key={p.user_id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '6px 0' }}>
|
||||
<RingAvatar userId={p.user_id} username={p.username} avatarUrl={p.avatar_url} size={34} innerBg={theme.centerBg} textColor={theme.text} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div>
|
||||
<div style={{ fontSize: 11, color: theme.faint, marginTop: 1 }}>{percent}%</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -416,11 +529,14 @@ function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: `conic-gradient(${stops})`,
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
||||
}} />
|
||||
<div
|
||||
className="trek-pie-reveal"
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: `conic-gradient(${stops})`,
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute', top: '50%', left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
@@ -446,6 +562,8 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
|
||||
const can = useCanDo()
|
||||
const { t, locale } = useTranslation()
|
||||
const isDark = useIsDark()
|
||||
const theme = useMemo(() => widgetTheme(isDark), [isDark])
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
||||
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
|
||||
@@ -589,20 +707,69 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
}
|
||||
|
||||
// ── Main Layout ──────────────────────────────────────────────────────────
|
||||
const totalBudget = budgetItems.reduce((s, x) => s + (x.total_price || 0), 0)
|
||||
return (
|
||||
<div style={{ fontFamily: "'Poppins', -apple-system, BlinkMacSystemFont, system-ui, sans-serif" }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 16px 12px', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Calculator size={20} color="var(--text-primary)" />
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
|
||||
<div>
|
||||
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
|
||||
<div style={{
|
||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||
padding: '14px 16px 14px 22px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
{t('budget.title')}
|
||||
</h2>
|
||||
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
||||
<div style={{ width: 150 }}>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={setCurrency}
|
||||
disabled={!canEdit}
|
||||
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 6, width: 260 }}>
|
||||
<input
|
||||
value={newCategoryName}
|
||||
onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
||||
placeholder={t('budget.categoryName')}
|
||||
style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
title={t('budget.addCategory')}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
opacity: newCategoryName.trim() ? 1 : 0.4,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}>
|
||||
<Plus size={14} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Download size={14} strokeWidth={2.5} /> CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Download size={13} /> CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 20, padding: '24px 28px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }} className="max-md:!px-4">
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{categoryNames.map((cat, ci) => {
|
||||
const items = grouped.get(cat) || []
|
||||
@@ -811,61 +978,57 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-[240px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={setCurrency}
|
||||
disabled={!canEdit}
|
||||
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
|
||||
<input
|
||||
value={newCategoryName}
|
||||
onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
||||
placeholder={t('budget.categoryName')}
|
||||
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full md:w-[320px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
|
||||
borderRadius: 16, padding: '24px 20px', color: '#fff', marginBottom: 16,
|
||||
boxShadow: '0 8px 32px rgba(15,23,42,0.18)',
|
||||
background: theme.bg,
|
||||
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
|
||||
border: `1px solid ${theme.border}`,
|
||||
boxShadow: theme.shadow,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Wallet size={18} color="rgba(255,255,255,0.8)" />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 12,
|
||||
background: theme.iconBg,
|
||||
border: `1px solid ${theme.iconBorder}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: theme.iconColor, flexShrink: 0,
|
||||
}}>
|
||||
<Wallet size={20} strokeWidth={2} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
|
||||
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
|
||||
|
||||
{(() => {
|
||||
const decimals = currencyDecimals(currency)
|
||||
const full = Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
const sep = (0.1).toLocaleString(locale).replace(/\d/g, '')
|
||||
const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, '']
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
|
||||
<span style={{ fontSize: 38, fontWeight: 700 }}>{integerPart}</span>
|
||||
{decimalPart && <span style={{ fontSize: 22, fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
|
||||
<span style={{ fontSize: 22, fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div style={{ color: theme.faint, fontSize: 12, marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>{currency}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
|
||||
|
||||
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
|
||||
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
|
||||
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} grandTotal={grandTotal} theme={theme} />
|
||||
)}
|
||||
|
||||
{/* Settlement dropdown inside the total card */}
|
||||
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
|
||||
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 12 }}>
|
||||
<div style={{ marginTop: 16, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||
<button onClick={() => setSettlementOpen(v => !v)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
|
||||
color: 'rgba(255,255,255,0.6)', fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||
color: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||
}}>
|
||||
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
{t('budget.settlement')}
|
||||
@@ -890,53 +1053,60 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
</button>
|
||||
|
||||
{settlementOpen && (
|
||||
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{settlement.flows.map((flow, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||
padding: '8px 10px', borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
}}>
|
||||
<ChipWithTooltip label={flow.from.username} avatarUrl={flow.from.avatar_url} size={28} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>→</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#f87171', whiteSpace: 'nowrap' }}>
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '12px 14px', borderRadius: 14,
|
||||
background: theme.flowBg,
|
||||
border: `1px solid ${theme.flowBorder}`,
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = theme.flowHoverBg; e.currentTarget.style.borderColor = theme.flowHoverBorder }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = theme.flowBg; e.currentTarget.style.borderColor = theme.flowBorder }}
|
||||
>
|
||||
<RingAvatar userId={flow.from.user_id} username={flow.from.username} avatarUrl={flow.from.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
|
||||
{fmt(flow.amount, currency)}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>→</span>
|
||||
<div style={{ width: '100%', height: 2, borderRadius: 2, background: 'linear-gradient(90deg, rgba(239,68,68,0.1), rgba(239,68,68,0.55), rgba(239,68,68,0.3))', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', right: -1, top: '50%', transform: 'translateY(-50%)', width: 0, height: 0, borderLeft: '6px solid rgba(239,68,68,0.55)', borderTop: '4px solid transparent', borderBottom: '4px solid transparent' }} />
|
||||
</div>
|
||||
</div>
|
||||
<ChipWithTooltip label={flow.to.username} avatarUrl={flow.to.avatar_url} size={28} />
|
||||
<RingAvatar userId={flow.to.user_id} username={flow.to.username} avatarUrl={flow.to.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
|
||||
<div style={{ marginTop: 4, borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 8 }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'rgba(255,255,255,0.35)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
|
||||
<div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}>
|
||||
{t('budget.netBalances')}
|
||||
</div>
|
||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => (
|
||||
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 0' }}>
|
||||
<div style={{
|
||||
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
|
||||
background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 8, fontWeight: 700, color: 'rgba(255,255,255,0.6)', overflow: 'hidden',
|
||||
}}>
|
||||
{b.avatar_url
|
||||
? <img src={b.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: b.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: 11, color: 'rgba(255,255,255,0.6)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{b.username}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 600, flexShrink: 0,
|
||||
color: b.balance > 0 ? '#4ade80' : '#f87171',
|
||||
}}>
|
||||
{b.balance > 0 ? '+' : ''}{fmt(b.balance, currency)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => {
|
||||
const positive = b.balance > 0
|
||||
const Trend = positive ? TrendingUp : TrendingDown
|
||||
return (
|
||||
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 0' }}>
|
||||
<RingAvatar userId={b.user_id} username={b.username} avatarUrl={b.avatar_url} size={26} innerBg={theme.centerBg} textColor={theme.text} />
|
||||
<span style={{ flex: 1, fontSize: 13, color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{b.username}
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '4px 10px', borderRadius: 8,
|
||||
fontSize: 12, fontWeight: 700, letterSpacing: '-0.01em',
|
||||
background: positive ? 'rgba(16,185,129,0.13)' : 'rgba(239,68,68,0.13)',
|
||||
color: positive ? '#10b981' : '#ef4444',
|
||||
}}>
|
||||
<Trend size={11} strokeWidth={3} />
|
||||
{positive ? '+' : ''}{fmt(b.balance, currency)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -945,36 +1115,115 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pieSegments.length > 0 && (
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px',
|
||||
border: '1px solid var(--border-primary)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16, textAlign: 'center' }}>{t('budget.byCategory')}</div>
|
||||
{pieSegments.length > 0 && (() => {
|
||||
const decimals = currencyDecimals(currency)
|
||||
const total = pieSegments.reduce((s, x) => s + x.value, 0)
|
||||
const totalFmt = Number(total).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
const decimalSep = (0.1).toLocaleString(locale).replace(/\d/g, '')
|
||||
const [totalInt, totalDec] = decimals > 0 ? totalFmt.split(decimalSep) : [totalFmt, '']
|
||||
const R = 80
|
||||
const CIRC = 2 * Math.PI * R
|
||||
let dashOffset = 0
|
||||
return (
|
||||
<div style={{
|
||||
background: theme.bg,
|
||||
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
|
||||
border: `1px solid ${theme.border}`,
|
||||
boxShadow: theme.shadow,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
|
||||
<div style={{
|
||||
width: 38, height: 38, borderRadius: 11,
|
||||
background: theme.iconBg,
|
||||
border: `1px solid ${theme.iconBorder}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: theme.iconColor, flexShrink: 0,
|
||||
}}>
|
||||
<PieChartIcon size={18} strokeWidth={2} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
|
||||
|
||||
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{pieSegments.map((seg, i) => {
|
||||
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
|
||||
return (
|
||||
<div key={seg.name} style={{ padding: '8px 0', borderTop: i > 0 ? '1px solid var(--border-secondary)' : 'none' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>{seg.name}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 3, paddingLeft: 18 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 500 }}>{fmt(seg.value, currency)}</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 600, background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 99 }}>{pct}%</span>
|
||||
</div>
|
||||
<div style={{ position: 'relative', display: 'flex', justifyContent: 'center', margin: '4px 0 16px' }}>
|
||||
<svg width={200} height={200} viewBox="0 0 200 200" style={{ transform: 'rotate(-90deg)', filter: theme.donutShadow }}>
|
||||
<defs>
|
||||
{pieSegments.map((seg, i) => {
|
||||
const c2 = hexLighten(seg.color, 0.2)
|
||||
return (
|
||||
<linearGradient key={`grad-${i}`} id={`cat-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor={seg.color} />
|
||||
<stop offset="100%" stopColor={c2} />
|
||||
</linearGradient>
|
||||
)
|
||||
})}
|
||||
</defs>
|
||||
<circle cx={100} cy={100} r={R} fill="none" stroke={theme.track} strokeWidth={22} />
|
||||
{pieSegments.map((seg, i) => {
|
||||
const segLen = total > 0 ? (seg.value / total) * CIRC : 0
|
||||
const circle = (
|
||||
<circle key={i}
|
||||
cx={100} cy={100} r={R}
|
||||
fill="none" strokeLinecap="round" strokeWidth={22}
|
||||
stroke={`url(#cat-grad-${i})`}
|
||||
strokeDasharray={`${segLen} ${CIRC}`}
|
||||
strokeDashoffset={-dashOffset}
|
||||
/>
|
||||
)
|
||||
dashOffset += segLen
|
||||
return circle
|
||||
})}
|
||||
</svg>
|
||||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, pointerEvents: 'none' }}>
|
||||
<div style={{ fontSize: 10.5, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
|
||||
<span>{totalInt}</span>
|
||||
{totalDec && <span style={{ fontSize: 13, fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div style={{ fontSize: 10.5, color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: `1px solid ${theme.divider}`, paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{pieSegments.map((seg, i) => {
|
||||
const pct = total > 0 ? (seg.value / total) * 100 : 0
|
||||
const pctLabel = pct.toFixed(1).replace('.', decimalSep) + '%'
|
||||
const c2 = hexLighten(seg.color, 0.2)
|
||||
const chipColor = isDark ? hexLighten(seg.color, 0.35) : seg.color
|
||||
return (
|
||||
<div key={seg.name} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 8px', borderRadius: 12,
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = theme.rowHover}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<div style={{
|
||||
width: 10, height: 10, borderRadius: 3, flexShrink: 0,
|
||||
background: `linear-gradient(135deg, ${seg.color}, ${c2})`,
|
||||
boxShadow: `0 0 12px ${seg.color}80`,
|
||||
}} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{seg.name}</div>
|
||||
<div style={{ fontSize: 11.5, color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
|
||||
</div>
|
||||
<span style={{
|
||||
flexShrink: 0,
|
||||
padding: '4px 9px', borderRadius: 7,
|
||||
fontSize: 11, fontWeight: 700, letterSpacing: '-0.01em',
|
||||
background: `${seg.color}26`,
|
||||
border: `1px solid ${seg.color}40`,
|
||||
color: chipColor,
|
||||
}}>{pctLabel}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
|
||||
@@ -29,54 +29,142 @@ interface TripMember {
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
interface CollabFeatures {
|
||||
chat: boolean
|
||||
notes: boolean
|
||||
polls: boolean
|
||||
whatsnext: boolean
|
||||
}
|
||||
|
||||
interface CollabPanelProps {
|
||||
tripId: number
|
||||
tripMembers?: TripMember[]
|
||||
collabFeatures?: CollabFeatures
|
||||
}
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelProps) {
|
||||
const ALL_TABS = [
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
|
||||
]
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
|
||||
const { user } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const [mobileTab, setMobileTab] = useState('chat')
|
||||
const isDesktop = useIsDesktop()
|
||||
|
||||
const tabs = [
|
||||
{ id: 'chat', label: t('collab.tabs.chat') || 'Chat', icon: MessageCircle },
|
||||
{ id: 'notes', label: t('collab.tabs.notes') || 'Notes', icon: StickyNote },
|
||||
{ id: 'polls', label: t('collab.tabs.polls') || 'Polls', icon: BarChart3 },
|
||||
{ id: 'next', label: t('collab.whatsNext.title') || "What's Next", icon: Sparkles },
|
||||
]
|
||||
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true }
|
||||
|
||||
const tabs = useMemo(() =>
|
||||
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
|
||||
...tab,
|
||||
label: t(tab.labelKey) || tab.fallback,
|
||||
})),
|
||||
[features, t])
|
||||
|
||||
const [mobileTab, setMobileTab] = useState(() => tabs[0]?.id || 'chat')
|
||||
|
||||
// If active tab gets disabled, switch to first available
|
||||
useEffect(() => {
|
||||
if (tabs.length > 0 && !tabs.some(t => t.id === mobileTab)) {
|
||||
setMobileTab(tabs[0].id)
|
||||
}
|
||||
}, [tabs, mobileTab])
|
||||
|
||||
const chatOn = features.chat
|
||||
const rightPanels = [
|
||||
features.notes && 'notes',
|
||||
features.polls && 'polls',
|
||||
features.whatsnext && 'whatsnext',
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
if (isDesktop) {
|
||||
// Chat always 380px fixed when on. Right panels share remaining space.
|
||||
// If chat off, all panels share full width equally.
|
||||
if (chatOn && rightPanels.length === 0) {
|
||||
// Only chat
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (chatOn) {
|
||||
// Chat left (380px) + right panels
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: '0 0 380px' }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{rightPanels.length === 1 && (
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
)}
|
||||
{rightPanels.length === 2 && rightPanels.map(p => (
|
||||
<div key={p} style={{ ...card, flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
))}
|
||||
{rightPanels.length === 3 && (
|
||||
<>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabNotes tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabPolls tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<WhatsNextWidget tripMembers={tripMembers} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Chat off — remaining panels share full width
|
||||
const panels = rightPanels
|
||||
if (panels.length === 1) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{/* Chat — left, fixed width */}
|
||||
<div style={{ ...card, flex: '0 0 380px' }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
|
||||
{/* Right column: Notes top, Polls + What's Next bottom */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{/* Notes — top */}
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabNotes tripId={tripId} currentUser={user} />
|
||||
{panels.map(p => (
|
||||
<div key={p} style={{ ...card, flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
|
||||
{/* Polls + What's Next — bottom row */}
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabPolls tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<WhatsNextWidget tripMembers={tripMembers} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mobile: tab bar + single panel
|
||||
// Mobile: tab bar + single panel (only enabled tabs)
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'absolute', inset: 0 }}>
|
||||
<div style={{
|
||||
@@ -84,7 +172,6 @@ export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelPro
|
||||
background: 'var(--bg-card)', flexShrink: 0,
|
||||
}}>
|
||||
{tabs.map(tab => {
|
||||
const Icon = tab.icon
|
||||
const active = mobileTab === tab.id
|
||||
return (
|
||||
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
@@ -102,10 +189,10 @@ export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelPro
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
{mobileTab === 'chat' && <CollabChat tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'next' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
{mobileTab === 'chat' && features.chat && <CollabChat tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'notes' && features.notes && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'polls' && features.polls && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'next' && features.whatsnext && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -94,7 +94,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column' }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
|
||||
onTouchEnd={e => {
|
||||
@@ -779,25 +779,81 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}</h2>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
||||
{showTrash
|
||||
? `${trashFiles.length} ${trashFiles.length === 1 ? 'file' : 'files'}`
|
||||
: (files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length }))}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={toggleTrash} style={{
|
||||
padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||
background: showTrash ? 'var(--accent)' : 'var(--bg-card)',
|
||||
color: showTrash ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
fontSize: 12, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 5,
|
||||
fontFamily: 'inherit',
|
||||
{/* Toolbar */}
|
||||
<div style={{ padding: '24px 28px 0', flexShrink: 0 }} className="max-md:!px-4 max-md:!pt-4">
|
||||
<div style={{
|
||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||
padding: '14px 16px 14px 22px',
|
||||
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
||||
}}>
|
||||
<Trash2 size={13} /> {t('files.trash') || 'Trash'}
|
||||
</button>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}
|
||||
</h2>
|
||||
|
||||
{!showTrash && (
|
||||
<>
|
||||
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
|
||||
<div className="hidden md:inline-flex" style={{ gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
|
||||
{[
|
||||
{ id: 'all', label: t('files.filterAll') },
|
||||
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star } as const] : []),
|
||||
{ id: 'pdf', label: t('files.filterPdf') },
|
||||
{ id: 'image', label: t('files.filterImages') },
|
||||
{ id: 'doc', label: t('files.filterDocs') },
|
||||
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
|
||||
].map(tab => {
|
||||
const active = filterType === tab.id
|
||||
const TabIcon = 'icon' in tab ? tab.icon : null
|
||||
const count = tab.id === 'all' ? files.length
|
||||
: tab.id === 'starred' ? files.filter(f => f.starred).length
|
||||
: tab.id === 'pdf' ? files.filter(f => (f.mime_type || '').includes('pdf') || /\.pdf$/i.test(f.original_name)).length
|
||||
: tab.id === 'image' ? files.filter(f => (f.mime_type || '').startsWith('image/')).length
|
||||
: tab.id === 'doc' ? files.filter(f => /\.(docx?|xlsx?|txt|csv)$/i.test(f.original_name)).length
|
||||
: tab.id === 'collab' ? files.filter(f => f.note_id).length
|
||||
: 0
|
||||
return (
|
||||
<button key={tab.id} onClick={() => setFilterType(tab.id)}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
|
||||
background: active ? 'var(--bg-card)' : 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
fontWeight: active ? 500 : 400,
|
||||
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{TabIcon ? <TabIcon size={13} fill={active ? '#facc15' : 'none'} color={active ? '#facc15' : 'currentColor'} /> : null}
|
||||
{'label' in tab && tab.label}
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600,
|
||||
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
|
||||
color: 'var(--text-faint)',
|
||||
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
|
||||
}}>{count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button onClick={toggleTrash} style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
flexShrink: 0, marginLeft: 'auto',
|
||||
opacity: showTrash ? 1 : 0.88,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = showTrash ? '1' : '0.88'}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">{t('files.trash') || 'Trash'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showTrash ? (
|
||||
@@ -835,7 +891,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
{can('file_upload', trip) && <div
|
||||
{...getRootProps()}
|
||||
style={{
|
||||
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
||||
margin: '16px 28px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
||||
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
|
||||
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
|
||||
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
|
||||
@@ -860,7 +916,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
</div>}
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
<div className="md:!hidden" style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ id: 'all', label: t('files.filterAll') },
|
||||
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
|
||||
@@ -883,7 +939,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 28px 16px' }} className="max-md:!px-4">
|
||||
{filteredFiles.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
||||
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface MapMarkerItem {
|
||||
export interface JourneyMapHandle {
|
||||
highlightMarker: (id: string | null) => void
|
||||
focusMarker: (id: string) => void
|
||||
invalidateSize: () => void
|
||||
}
|
||||
|
||||
interface MapEntry {
|
||||
@@ -33,6 +34,8 @@ interface Props {
|
||||
dark?: boolean
|
||||
activeMarkerId?: string | null
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
paddingBottom?: number
|
||||
}
|
||||
|
||||
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||
@@ -57,15 +60,20 @@ const MARKER_W = 28
|
||||
const MARKER_H = 36
|
||||
|
||||
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
|
||||
// Highlighted: inverted colors for contrast (black on light, white on dark)
|
||||
const fill = dark
|
||||
? (highlighted ? '#FAFAFA' : '#FAFAFA')
|
||||
: (highlighted ? '#18181B' : '#18181B')
|
||||
? (highlighted ? '#FAFAFA' : '#A1A1AA')
|
||||
: (highlighted ? '#18181B' : '#52525B')
|
||||
const textColor = dark
|
||||
? (highlighted ? '#18181B' : '#18181B')
|
||||
: (highlighted ? '#fff' : '#fff')
|
||||
const stroke = dark ? '#3F3F46' : '#fff'
|
||||
const stroke = highlighted
|
||||
? (dark ? '#fff' : '#18181B')
|
||||
: (dark ? '#3F3F46' : '#fff')
|
||||
const shadow = highlighted
|
||||
? 'filter:drop-shadow(0 0 8px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))'
|
||||
? (dark
|
||||
? 'filter:drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||
: 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
|
||||
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||
const label = String(index + 1)
|
||||
const scale = highlighted ? 1.2 : 1
|
||||
@@ -82,7 +90,7 @@ function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
|
||||
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||||
|
||||
const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick },
|
||||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
|
||||
ref
|
||||
) {
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
@@ -138,11 +146,17 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
highlightMarker(id)
|
||||
const marker = markersRef.current.get(id)
|
||||
if (marker && mapRef.current) {
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
|
||||
try {
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
|
||||
} catch { /* map not yet initialized */ }
|
||||
}
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker }), [])
|
||||
const invalidateSize = useCallback(() => {
|
||||
try { mapRef.current?.invalidateSize() } catch { /* map not yet initialized */ }
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
@@ -156,7 +170,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
const map = L.map(containerRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: true,
|
||||
scrollWheelZoom: false,
|
||||
scrollWheelZoom: fullScreen ? true : false,
|
||||
dragging: true,
|
||||
touchZoom: true,
|
||||
})
|
||||
@@ -185,8 +199,8 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
coords.forEach(c => allCoords.push(c))
|
||||
}
|
||||
|
||||
// route polyline — subtle dashed connection
|
||||
if (items.length > 1) {
|
||||
// route polyline — only in non-fullscreen (sidebar map) mode
|
||||
if (!fullScreen && items.length > 1) {
|
||||
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
|
||||
L.polyline(routeCoords, {
|
||||
color: dark ? '#71717A' : '#A1A1AA',
|
||||
@@ -229,7 +243,8 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
try {
|
||||
map.invalidateSize()
|
||||
if (allCoords.length > 0) {
|
||||
map.fitBounds(L.latLngBounds(allCoords), { padding: [50, 50], maxZoom: 14 })
|
||||
const pb = paddingBottom || 50
|
||||
map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 14 })
|
||||
} else {
|
||||
map.setView([30, 0], 2)
|
||||
}
|
||||
@@ -245,7 +260,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
mapRef.current = null
|
||||
markersRef.current.clear()
|
||||
}
|
||||
}, [entries, stableTrail, dark, mapTileUrl])
|
||||
}, [entries, stableTrail, dark, mapTileUrl, fullScreen, paddingBottom])
|
||||
|
||||
// react to activeMarkerId prop changes — runs after map is built
|
||||
useEffect(() => {
|
||||
@@ -254,8 +269,14 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
const timer = setTimeout(() => {
|
||||
highlightMarker(activeMarkerId)
|
||||
const marker = markersRef.current.get(activeMarkerId)
|
||||
if (marker && mapRef.current) {
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
|
||||
if (!marker || !mapRef.current) return
|
||||
// fitBounds may still be pending when this fires — getZoom() throws
|
||||
// "Set map center and zoom first" until the map has a view. Guard it.
|
||||
try {
|
||||
const currentZoom = mapRef.current.getZoom()
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(currentZoom, 12), { duration: 0.5 })
|
||||
} catch {
|
||||
mapRef.current.setView(marker.getLatLng(), 12)
|
||||
}
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
|
||||
const MOOD_ICONS: Record<string, typeof Smile> = {
|
||||
amazing: Laugh,
|
||||
good: Smile,
|
||||
neutral: Meh,
|
||||
rough: Frown,
|
||||
}
|
||||
|
||||
const MOOD_COLORS: Record<string, string> = {
|
||||
amazing: 'text-pink-500',
|
||||
good: 'text-amber-500',
|
||||
neutral: 'text-zinc-400',
|
||||
rough: 'text-violet-500',
|
||||
}
|
||||
|
||||
const WEATHER_ICONS: Record<string, typeof Sun> = {
|
||||
sunny: Sun,
|
||||
partly: CloudSun,
|
||||
cloudy: Cloud,
|
||||
rainy: CloudRain,
|
||||
stormy: CloudLightning,
|
||||
cold: Snowflake,
|
||||
}
|
||||
|
||||
function photoUrl(p: JourneyPhoto): string {
|
||||
return `/api/photos/${p.photo_id}/thumbnail`
|
||||
}
|
||||
|
||||
function stripMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/[#*_~`>\[\]()!|-]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null }
|
||||
index: number
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
}
|
||||
|
||||
export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) {
|
||||
const hasLocation = !!(entry.location_lat && entry.location_lng)
|
||||
const hasPhotos = entry.photos && entry.photos.length > 0
|
||||
const firstPhoto = hasPhotos ? entry.photos![0] : null
|
||||
const MoodIcon = entry.mood ? MOOD_ICONS[entry.mood] : null
|
||||
const moodColor = entry.mood ? MOOD_COLORS[entry.mood] : ''
|
||||
const WeatherIcon = entry.weather ? WEATHER_ICONS[entry.weather] : null
|
||||
|
||||
const thumbSrc = firstPhoto
|
||||
? publicPhotoUrl
|
||||
? publicPhotoUrl((firstPhoto as any).photo_id ?? (firstPhoto as any).id)
|
||||
: photoUrl(firstPhoto as JourneyPhoto)
|
||||
: null
|
||||
|
||||
const date = new Date(entry.entry_date + 'T00:00:00')
|
||||
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
|
||||
const storyPreview = entry.story ? stripMarkdown(entry.story) : ''
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex-shrink-0 rounded-xl overflow-hidden text-left transition-all duration-100 ${
|
||||
isActive
|
||||
? 'w-[320px] sm:w-[340px] bg-white dark:bg-zinc-800 shadow-lg ring-2 ring-zinc-900/70 dark:ring-white/60'
|
||||
: 'w-[240px] sm:w-[260px] bg-white/90 dark:bg-zinc-800/90 shadow-md'
|
||||
} backdrop-blur-lg`}
|
||||
>
|
||||
<div className={`flex ${isActive ? 'h-[140px]' : 'h-[110px]'} transition-all duration-100`}>
|
||||
{/* Photo thumbnail */}
|
||||
{thumbSrc ? (
|
||||
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 relative overflow-hidden transition-all duration-100`}>
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{hasPhotos && entry.photos!.length > 1 && (
|
||||
<div className="absolute bottom-1 right-1 flex items-center gap-0.5 bg-black/60 text-white rounded px-1 py-0.5 text-[10px] font-medium">
|
||||
<Camera size={10} />
|
||||
{entry.photos!.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 bg-zinc-100 dark:bg-zinc-700 flex items-center justify-center transition-all duration-100`}>
|
||||
<MapPin size={20} className="text-zinc-300 dark:text-zinc-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-3 flex flex-col min-w-0">
|
||||
{/* Day number + date + mood/weather */}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="w-5 h-5 rounded bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] font-bold flex items-center justify-center flex-shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
|
||||
{entry.entry_time && (
|
||||
<span className="text-[11px] text-zinc-400">· {entry.entry_time.slice(0, 5)}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 ml-auto flex-shrink-0">
|
||||
{MoodIcon && (
|
||||
<span className={`inline-flex items-center justify-center w-5 h-5 rounded-full ${
|
||||
entry.mood === 'amazing' ? 'bg-pink-100 dark:bg-pink-900/30' :
|
||||
entry.mood === 'good' ? 'bg-amber-100 dark:bg-amber-900/30' :
|
||||
entry.mood === 'rough' ? 'bg-violet-100 dark:bg-violet-900/30' :
|
||||
'bg-zinc-100 dark:bg-zinc-700'
|
||||
}`}>
|
||||
<MoodIcon size={11} className={moodColor} />
|
||||
</span>
|
||||
)}
|
||||
{WeatherIcon && (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-zinc-100 dark:bg-zinc-700">
|
||||
<WeatherIcon size={11} className="text-zinc-500 dark:text-zinc-400" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="text-[13px] font-semibold text-zinc-900 dark:text-white leading-tight truncate">
|
||||
{entry.title || (entry.type === 'checkin' ? 'Check-in' : entry.type === 'skeleton' ? 'Add your story…' : 'Untitled')}
|
||||
</h4>
|
||||
|
||||
{/* Story preview (1-2 lines, only on active card) */}
|
||||
{isActive && storyPreview && (
|
||||
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 leading-snug mt-0.5 line-clamp-2">
|
||||
{storyPreview}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Location badge */}
|
||||
<div className="flex items-center gap-1 mt-auto">
|
||||
{hasLocation ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
|
||||
<MapPin size={10} className="flex-shrink-0" />
|
||||
<span className="truncate">{entry.location_name || 'On the map'}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-zinc-400 italic">No location</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
X, Pencil, Trash2, MapPin, Clock, Camera,
|
||||
Laugh, Smile, Meh, Frown,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
|
||||
ThumbsUp, ThumbsDown, ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import JournalBody from './JournalBody'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
|
||||
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
||||
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
|
||||
good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
|
||||
neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' },
|
||||
rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
|
||||
}
|
||||
|
||||
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
|
||||
sunny: { icon: Sun, label: 'Sunny' },
|
||||
partly: { icon: CloudSun, label: 'Partly cloudy' },
|
||||
cloudy: { icon: Cloud, label: 'Cloudy' },
|
||||
rainy: { icon: CloudRain, label: 'Rainy' },
|
||||
stormy: { icon: CloudLightning, label: 'Stormy' },
|
||||
cold: { icon: Snowflake, label: 'Cold' },
|
||||
}
|
||||
|
||||
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string {
|
||||
return `/api/photos/${p.photo_id}/${size}`
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JourneyEntry
|
||||
readOnly?: boolean
|
||||
onClose: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
||||
}
|
||||
|
||||
export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDelete, onPhotoClick }: Props) {
|
||||
const photos = entry.photos || []
|
||||
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
||||
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
||||
const prosArr = entry.pros_cons?.pros ?? []
|
||||
const consArr = entry.pros_cons?.cons ?? []
|
||||
const hasProscons = prosArr.length > 0 || consArr.length > 0
|
||||
|
||||
const date = new Date(entry.entry_date + 'T00:00:00')
|
||||
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => { onClose(); onEdit(); }}
|
||||
className="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onClose(); onDelete(); }}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain" style={{ WebkitOverflowScrolling: 'touch' }}>
|
||||
|
||||
{/* Hero photo(s) */}
|
||||
{photos.length > 0 && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={photoUrl(photos[0])}
|
||||
alt=""
|
||||
className="w-full max-h-[50vh] object-cover cursor-pointer"
|
||||
onClick={() => onPhotoClick(photos, 0)}
|
||||
/>
|
||||
{photos.length > 1 && (
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 backdrop-blur-sm text-white rounded-full px-2.5 py-1 text-[11px] font-medium">
|
||||
<Camera size={12} />
|
||||
{photos.length} photos
|
||||
</div>
|
||||
)}
|
||||
{/* Photo strip for multiple photos */}
|
||||
{photos.length > 1 && (
|
||||
<div className="flex gap-1 px-4 py-2 overflow-x-auto bg-zinc-50 dark:bg-zinc-900">
|
||||
{photos.map((p, i) => (
|
||||
<img
|
||||
key={p.id || i}
|
||||
src={photoUrl(p, 'thumbnail')}
|
||||
alt=""
|
||||
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
|
||||
onClick={() => onPhotoClick(photos, i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 py-5 pb-32">
|
||||
|
||||
{/* Date + time + location header */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<span className="text-[12px] font-medium text-zinc-500">{dateStr}</span>
|
||||
{entry.entry_time && (
|
||||
<span className="flex items-center gap-1 text-[12px] text-zinc-400">
|
||||
<Clock size={11} />
|
||||
{entry.entry_time.slice(0, 5)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{entry.location_name && (
|
||||
<div className="mb-3">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300">
|
||||
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
|
||||
{entry.location_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{entry.title && (
|
||||
<h1 className="text-[22px] font-bold text-zinc-900 dark:text-white tracking-tight leading-tight mb-4">
|
||||
{entry.title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{/* Mood + Weather chips */}
|
||||
{(mood || weather) && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{mood && (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold ${mood.bg} ${mood.text}`}>
|
||||
<mood.icon size={13} />
|
||||
{mood.label}
|
||||
</span>
|
||||
)}
|
||||
{weather && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
|
||||
<weather.icon size={13} />
|
||||
{weather.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Story */}
|
||||
{entry.story && (
|
||||
<div className="text-[14px] leading-relaxed text-zinc-700 dark:text-zinc-300 mb-5">
|
||||
<JournalBody text={entry.story} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{entry.tags && entry.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-5">
|
||||
{entry.tags.map((tag, i) => (
|
||||
<span key={i} className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pros & Cons */}
|
||||
{hasProscons && (
|
||||
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-5">
|
||||
{prosArr.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 uppercase tracking-wide mb-2">
|
||||
<ThumbsUp size={12} /> Pros
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{prosArr.map((p, i) => (
|
||||
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
|
||||
<span className="text-emerald-500 mt-0.5">+</span> {p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{prosArr.length > 0 && consArr.length > 0 && (
|
||||
<div className="border-t border-zinc-200 dark:border-zinc-700" />
|
||||
)}
|
||||
{consArr.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-red-500 dark:text-red-400 uppercase tracking-wide mb-2">
|
||||
<ThumbsDown size={12} /> Cons
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{consArr.map((c, i) => (
|
||||
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
|
||||
<span className="text-red-500 mt-0.5">−</span> {c}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import JourneyMap from './JourneyMap'
|
||||
import MobileEntryCard from './MobileEntryCard'
|
||||
import type { JourneyMapHandle } from './JourneyMap'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
title?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entries: JourneyEntry[] | any[]
|
||||
mapEntries: MapEntry[]
|
||||
trail?: { lat: number; lng: number }[]
|
||||
dark?: boolean
|
||||
readOnly?: boolean
|
||||
onEntryClick: (entry: any) => void
|
||||
onAddEntry?: () => void
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
}
|
||||
|
||||
export default function MobileMapTimeline({
|
||||
entries,
|
||||
mapEntries,
|
||||
trail,
|
||||
dark,
|
||||
readOnly,
|
||||
onEntryClick,
|
||||
onAddEntry,
|
||||
publicPhotoUrl,
|
||||
}: Props) {
|
||||
const mapRef = useRef<JourneyMapHandle>(null)
|
||||
const carouselRef = useRef<HTMLDivElement>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||
const activeIndexRef = useRef(activeIndex)
|
||||
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
|
||||
|
||||
// Sync map focus when carousel scrolls (with guard for uninitialized map)
|
||||
const syncMapToCarousel = useCallback((index: number) => {
|
||||
const entry = entries[index]
|
||||
if (!entry) return
|
||||
|
||||
const mapEntry = mapEntries.find(m => String(m.id) === String(entry.id))
|
||||
if (mapEntry) {
|
||||
try { mapRef.current?.focusMarker(String(mapEntry.id)) } catch {}
|
||||
} else {
|
||||
try { mapRef.current?.highlightMarker(null) } catch {}
|
||||
}
|
||||
}, [entries, mapEntries])
|
||||
|
||||
// Pick the card that's currently closest to the carousel horizontal center.
|
||||
// More stable than IntersectionObserver thresholds when the active card can
|
||||
// drift toward the viewport edge with proximity snapping.
|
||||
const pickNearestCard = useCallback(() => {
|
||||
const el = carouselRef.current
|
||||
if (!el) return
|
||||
const containerCenter = el.getBoundingClientRect().left + el.clientWidth / 2
|
||||
let bestIdx = 0
|
||||
let bestDist = Infinity
|
||||
cardRefs.current.forEach((node, idx) => {
|
||||
const r = node.getBoundingClientRect()
|
||||
const cardCenter = r.left + r.width / 2
|
||||
const d = Math.abs(cardCenter - containerCenter)
|
||||
if (d < bestDist) { bestDist = d; bestIdx = idx }
|
||||
})
|
||||
setActiveIndex(prev => {
|
||||
if (prev !== bestIdx) syncMapToCarousel(bestIdx)
|
||||
return bestIdx
|
||||
})
|
||||
}, [syncMapToCarousel])
|
||||
|
||||
// Track scroll; debounce to re-center the active card when the user stops.
|
||||
useEffect(() => {
|
||||
const el = carouselRef.current
|
||||
if (!el || entries.length === 0) return
|
||||
let rafId: number | null = null
|
||||
let settleTimer: number | null = null
|
||||
const onScroll = () => {
|
||||
if (rafId != null) return
|
||||
rafId = requestAnimationFrame(() => {
|
||||
pickNearestCard()
|
||||
rafId = null
|
||||
})
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||
settleTimer = window.setTimeout(() => {
|
||||
// Ensure the active card sits at the center once the user settles.
|
||||
const card = cardRefs.current.get(activeIndexRef.current)
|
||||
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
}, 180)
|
||||
}
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
if (rafId != null) cancelAnimationFrame(rafId)
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||
}
|
||||
}, [entries.length, pickNearestCard])
|
||||
|
||||
// Scroll a given card into the horizontal center of the carousel
|
||||
const scrollCardIntoCenter = useCallback((idx: number) => {
|
||||
const card = cardRefs.current.get(idx)
|
||||
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
}, [])
|
||||
|
||||
// Scroll carousel to entry when map marker is clicked
|
||||
const handleMarkerClick = useCallback((id: string) => {
|
||||
const idx = entries.findIndex((e: any) => String(e.id) === id)
|
||||
if (idx === -1) return
|
||||
setActiveIndex(idx)
|
||||
scrollCardIntoCenter(idx)
|
||||
}, [entries, scrollCardIntoCenter])
|
||||
|
||||
// Tap on a card: if it's already active, open the edit view; otherwise
|
||||
// activate + center it first (don't jump straight into the editor).
|
||||
const handleCardTap = useCallback((entry: any, idx: number) => {
|
||||
if (idx === activeIndex) {
|
||||
onEntryClick(entry)
|
||||
} else {
|
||||
setActiveIndex(idx)
|
||||
scrollCardIntoCenter(idx)
|
||||
}
|
||||
}, [activeIndex, onEntryClick, scrollCardIntoCenter])
|
||||
|
||||
// Initial map focus — delay to let Leaflet initialize and fitBounds
|
||||
useEffect(() => {
|
||||
if (entries.length > 0) {
|
||||
const timer = setTimeout(() => syncMapToCarousel(0), 500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [entries.length])
|
||||
|
||||
const activeEntryId = entries[activeIndex]
|
||||
? String(entries[activeIndex].id)
|
||||
: null
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
||||
<JourneyMap
|
||||
ref={mapRef}
|
||||
entries={mapEntries}
|
||||
checkins={[]}
|
||||
trail={trail}
|
||||
height={9999}
|
||||
dark={dark}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
fullScreen
|
||||
/>
|
||||
{!readOnly && onAddEntry && (
|
||||
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
|
||||
<button
|
||||
onClick={onAddEntry}
|
||||
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
||||
{/* Full-screen map */}
|
||||
<JourneyMap
|
||||
ref={mapRef}
|
||||
entries={mapEntries}
|
||||
checkins={[]}
|
||||
trail={trail}
|
||||
height={9999}
|
||||
dark={dark}
|
||||
activeMarkerId={activeEntryId}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
fullScreen
|
||||
paddingBottom={200}
|
||||
/>
|
||||
|
||||
{/* Bottom carousel */}
|
||||
<div
|
||||
className="fixed left-0 right-0 z-40"
|
||||
style={{ touchAction: 'pan-x', bottom: 'calc(var(--bottom-nav-h, 84px) + 8px)' }}
|
||||
>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
|
||||
style={{
|
||||
scrollSnapType: 'x proximity',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
}}
|
||||
>
|
||||
{entries.map((entry: any, i: number) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
data-idx={i}
|
||||
ref={node => { if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }}
|
||||
style={{ scrollSnapAlign: 'center' }}
|
||||
>
|
||||
<MobileEntryCard
|
||||
entry={entry}
|
||||
index={i}
|
||||
isActive={i === activeIndex}
|
||||
onClick={() => handleCardTap(entry, i)}
|
||||
publicPhotoUrl={publicPhotoUrl}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAB: add entry — bottom right, above the timeline carousel */}
|
||||
{!readOnly && onAddEntry && (
|
||||
<div
|
||||
className="fixed right-4 z-30"
|
||||
style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 168px)' }}
|
||||
>
|
||||
<button
|
||||
onClick={onAddEntry}
|
||||
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -69,6 +69,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
|
||||
position: 'fixed', inset: 0, zIndex: 500,
|
||||
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
paddingBottom: 'var(--bottom-nav-h)',
|
||||
}}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
|
||||
@@ -34,9 +34,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
||||
const [scrolled, setScrolled] = useState<boolean>(false)
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8 || (document.body.scrollTop || 0) > 8)
|
||||
onScroll()
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
document.body.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
document.body.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
|
||||
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled)
|
||||
|
||||
@@ -50,7 +62,11 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
}
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
document.documentElement.classList.add('trek-theme-transitioning')
|
||||
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
||||
window.setTimeout(() => {
|
||||
document.documentElement.classList.remove('trek-theme-transitioning')
|
||||
}, 360)
|
||||
}
|
||||
|
||||
const getAddonName = (addon: Addon): string => {
|
||||
@@ -61,23 +77,29 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
|
||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
background: dark
|
||||
? (scrolled ? 'rgba(9,9,11,0.78)' : 'rgba(9,9,11,0.95)')
|
||||
: (scrolled ? 'rgba(255,255,255,0.72)' : 'rgba(255,255,255,0.95)'),
|
||||
backdropFilter: scrolled ? 'blur(28px) saturate(180%)' : 'blur(20px)',
|
||||
WebkitBackdropFilter: scrolled ? 'blur(28px) saturate(180%)' : 'blur(20px)',
|
||||
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
|
||||
boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
|
||||
boxShadow: scrolled
|
||||
? (dark ? '0 4px 24px rgba(0,0,0,0.35)' : '0 4px 24px rgba(0,0,0,0.08)')
|
||||
: (dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)'),
|
||||
touchAction: 'manipulation',
|
||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||
height: 'var(--nav-h)',
|
||||
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
|
||||
{/* Left side */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{showBack && (
|
||||
<button onClick={onBack}
|
||||
className="p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0"
|
||||
className="trek-back-btn p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<ArrowLeft className="trek-back-icon w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('common.back')}</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -161,11 +183,14 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
|
||||
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
|
||||
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
||||
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex"
|
||||
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex relative w-8 h-8 items-center justify-center"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
<Sun className="w-4 h-4 absolute transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ opacity: dark ? 1 : 0, transform: dark ? 'rotate(0deg) scale(1)' : 'rotate(-90deg) scale(0.6)' }} />
|
||||
<Moon className="w-4 h-4 absolute transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ opacity: dark ? 0 : 1, transform: dark ? 'rotate(90deg) scale(0.6)' : 'rotate(0deg) scale(1)' }} />
|
||||
</button>
|
||||
|
||||
{/* Notification bell — only in trip view on mobile, everywhere on desktop */}
|
||||
@@ -196,7 +221,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
{userMenuOpen && ReactDOM.createPortal(
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
|
||||
<div className="w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="trek-menu-enter w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import { fireEvent, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import * as photoService from '../../services/photoService'
|
||||
@@ -16,10 +17,13 @@ vi.mock('react-leaflet', () => ({
|
||||
data-lng={position[1]}
|
||||
onClick={() => eventHandlers?.click?.()}
|
||||
>
|
||||
<button
|
||||
data-testid="marker-hover-trigger"
|
||||
onClick={() => eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } })}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Tooltip: ({ children }: any) => <div data-testid="tooltip">{children}</div>,
|
||||
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
|
||||
CircleMarker: () => <div data-testid="circle-marker" />,
|
||||
Circle: () => <div data-testid="circle" />,
|
||||
@@ -32,6 +36,7 @@ vi.mock('react-leaflet', () => ({
|
||||
off: vi.fn(),
|
||||
panBy: vi.fn(),
|
||||
}),
|
||||
useMapEvents: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('react-leaflet-cluster', () => ({
|
||||
@@ -100,22 +105,26 @@ describe('MapView', () => {
|
||||
expect(onMarkerClick).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-004: tooltip shows place name', () => {
|
||||
it('FE-COMP-MAPVIEW-004: tooltip shows place name', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip').textContent).toContain('Eiffel Tower')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', () => {
|
||||
it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [
|
||||
buildMapPlace({ name: 'Louvre', lat: 48.86, lng: 2.337, category_name: 'Museum', category_icon: null }),
|
||||
]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip').textContent).toContain('Museum')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
|
||||
render(<MapView route={[[48.0, 2.0], [49.0, 3.0]]} />)
|
||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
|
||||
expect(screen.getByTestId('polyline')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -125,7 +134,7 @@ describe('MapView', () => {
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-008: does not render polyline for single-point route', () => {
|
||||
render(<MapView route={[[48.0, 2.0]]} />)
|
||||
render(<MapView route={[[[48.0, 2.0]]]} />)
|
||||
expect(screen.queryByTestId('polyline')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -144,7 +153,7 @@ describe('MapView', () => {
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => {
|
||||
const route = [[48.0, 2.0], [49.0, 3.0]] as [number, number][]
|
||||
const route = [[[48.0, 2.0], [49.0, 3.0]]] as [number, number][][][]
|
||||
const routeSegments = [
|
||||
{ mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' },
|
||||
]
|
||||
@@ -190,11 +199,13 @@ describe('MapView', () => {
|
||||
vi.mocked(photoService.getCached).mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-016: tooltip shows address when present', () => {
|
||||
it('FE-COMP-MAPVIEW-016: tooltip shows address when present', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [
|
||||
buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, address: '5 Av. Anatole France' }),
|
||||
]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip').textContent).toContain('5 Av. Anatole France')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import ReservationOverlay from './ReservationOverlay'
|
||||
import type { Reservation } from '../../types'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
@@ -66,9 +68,9 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
">${label}</span>`
|
||||
}
|
||||
|
||||
// Base64 data URL thumbnails — no external image fetch during zoom
|
||||
// Only use base64 data URLs for markers — external URLs cause zoom lag
|
||||
if (place.image_url && place.image_url.startsWith('data:')) {
|
||||
// Prefer base64 data URLs (no zoom lag); also accept same-origin proxy URLs as a fallback
|
||||
// while the thumb is still being generated in the background
|
||||
if (place.image_url && (place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/'))) {
|
||||
const imgIcon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
@@ -275,6 +277,7 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
||||
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
|
||||
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
|
||||
function LocationTracker() {
|
||||
@@ -366,6 +369,35 @@ function LocationTracker() {
|
||||
)
|
||||
}
|
||||
|
||||
interface MemoMarkerProps {
|
||||
place: any
|
||||
isSelected: boolean
|
||||
orderNumbers: number[] | null
|
||||
photoUrl: string | null
|
||||
onClickPlace: (id: number) => void
|
||||
onHover: (place: any, x: number, y: number) => void
|
||||
onHoverOut: () => void
|
||||
}
|
||||
|
||||
const MemoMarker = memo(function MemoMarker({
|
||||
place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut,
|
||||
}: MemoMarkerProps) {
|
||||
const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected)
|
||||
return (
|
||||
<Marker
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onClickPlace(place.id),
|
||||
mouseover: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mousemove: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mouseout: onHoverOut,
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const MapView = memo(function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
@@ -384,7 +416,16 @@ export const MapView = memo(function MapView({
|
||||
rightWidth = 0,
|
||||
hasInspector = false,
|
||||
hasDayDetail = false,
|
||||
}) {
|
||||
reservations = [] as Reservation[],
|
||||
showReservationStats = false,
|
||||
visibleConnectionIds = [] as number[],
|
||||
onReservationClick,
|
||||
}: any) {
|
||||
const visibleReservations = useMemo(() => {
|
||||
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
|
||||
const set = new Set(visibleConnectionIds)
|
||||
return reservations.filter((r: Reservation) => set.has(r.id))
|
||||
}, [reservations, visibleConnectionIds])
|
||||
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
|
||||
const paddingOpts = useMemo(() => {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
@@ -396,22 +437,51 @@ export const MapView = memo(function MapView({
|
||||
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
|
||||
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
|
||||
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
|
||||
const [hoveredPlace, setHoveredPlace] = useState<any>(null)
|
||||
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
|
||||
setHoveredPlace(place)
|
||||
setTooltipPos({ x, y })
|
||||
}, [])
|
||||
|
||||
const handleMarkerHoverOut = useCallback(() => {
|
||||
setHoveredPlace(null)
|
||||
}, [])
|
||||
|
||||
const handleMarkerClick = useCallback((id: number) => {
|
||||
onMarkerClick?.(id)
|
||||
}, [onMarkerClick])
|
||||
|
||||
// photoUrls: only base64 thumbs for smooth map zoom
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
// Batch photo state updates through a RAF so N simultaneous photo loads
|
||||
// collapse into a single re-render instead of N separate renders.
|
||||
const pendingThumbsRef = useRef<Record<string, string>>({})
|
||||
const thumbRafRef = useRef<number | null>(null)
|
||||
|
||||
// Fetch photos via shared service — subscribe to thumb (base64) availability
|
||||
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
|
||||
useEffect(() => {
|
||||
if (!places || places.length === 0) return
|
||||
if (!places || places.length === 0 || !placesPhotosEnabled) return
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
const setThumb = (cacheKey: string, thumb: string) => {
|
||||
iconCache.clear()
|
||||
setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb })
|
||||
pendingThumbsRef.current[cacheKey] = thumb
|
||||
if (thumbRafRef.current !== null) return
|
||||
thumbRafRef.current = requestAnimationFrame(() => {
|
||||
thumbRafRef.current = null
|
||||
const pending = pendingThumbsRef.current
|
||||
pendingThumbsRef.current = {}
|
||||
setPhotoUrls(prev => {
|
||||
const hasChange = Object.entries(pending).some(([k, v]) => prev[k] !== v)
|
||||
return hasChange ? { ...prev, ...pending } : prev
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const place of places) {
|
||||
if (place.image_url && place.image_url.startsWith('data:')) continue
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
if (!cacheKey) continue
|
||||
|
||||
@@ -421,20 +491,24 @@ export const MapView = memo(function MapView({
|
||||
continue
|
||||
}
|
||||
|
||||
// Subscribe for when thumb becomes available
|
||||
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||
|
||||
// Always fetch through API — returns fresh URL + converts to base64
|
||||
if (!cached && !isLoading(cacheKey)) {
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
const photoId = place.image_url || place.google_place_id || place.osm_id
|
||||
if (photoId || (place.lat && place.lng)) {
|
||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => cleanups.forEach(fn => fn())
|
||||
}, [placeIds])
|
||||
return () => {
|
||||
cleanups.forEach(fn => fn())
|
||||
if (thumbRafRef.current !== null) {
|
||||
cancelAnimationFrame(thumbRafRef.current)
|
||||
thumbRafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [placeIds, placesPhotosEnabled])
|
||||
|
||||
const clusterIconCreateFunction = useCallback((cluster) => {
|
||||
const count = cluster.getChildCount()
|
||||
@@ -446,57 +520,49 @@ export const MapView = memo(function MapView({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
|
||||
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0
|
||||
|
||||
const markers = useMemo(() => places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const resolvedPhoto = (pck && photoUrls[pck]) || (place.image_url?.startsWith('data:') ? place.image_url : null) || null
|
||||
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
<MemoMarker
|
||||
key={place.id}
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick && onMarkerClick(place.id),
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
>
|
||||
<Tooltip
|
||||
direction="right"
|
||||
offset={[0, 0]}
|
||||
opacity={1}
|
||||
className="map-tooltip"
|
||||
permanent={isTouchDevice && isSelected}
|
||||
>
|
||||
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
||||
{place.name}
|
||||
</div>
|
||||
{place.category_name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category_icon)
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{place.address && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{place.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
place={place}
|
||||
isSelected={isSelected}
|
||||
orderNumbers={orderNumbers}
|
||||
photoUrl={photoUrl}
|
||||
onClickPlace={handleMarkerClick}
|
||||
onHover={handleMarkerHover}
|
||||
onHoverOut={handleMarkerHoverOut}
|
||||
/>
|
||||
)
|
||||
}), [places, selectedPlaceId, dayOrderMap, photoUrls, onMarkerClick, isTouchDevice])
|
||||
}), [places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut])
|
||||
|
||||
const gpxPolylines = useMemo(() => places.flatMap(place => {
|
||||
if (!place.route_geometry) return []
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||
if (!coords || coords.length < 2) return []
|
||||
return [(
|
||||
<Polyline
|
||||
key={`gpx-${place.id}`}
|
||||
positions={coords}
|
||||
color={place.category_color || '#3b82f6'}
|
||||
weight={3.5}
|
||||
opacity={0.75}
|
||||
/>
|
||||
)]
|
||||
} catch { return [] }
|
||||
}), [places])
|
||||
|
||||
const TooltipOverlay = hoveredPlace && tooltipPos && !isTouchDevice
|
||||
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<MapContainer
|
||||
id="trek-map"
|
||||
center={center}
|
||||
@@ -537,15 +603,18 @@ export const MapView = memo(function MapView({
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{route && route.length > 1 && (
|
||||
{route && route.length > 0 && (
|
||||
<>
|
||||
<Polyline
|
||||
positions={route}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
{route.map((seg, i) => seg.length > 1 && (
|
||||
<Polyline
|
||||
key={i}
|
||||
positions={seg}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
))}
|
||||
{routeSegments.map((seg, i) => (
|
||||
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
|
||||
))}
|
||||
@@ -553,22 +622,47 @@ export const MapView = memo(function MapView({
|
||||
)}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
{places.map((place) => {
|
||||
if (!place.route_geometry) return null
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||
if (!coords || coords.length < 2) return null
|
||||
return (
|
||||
<Polyline
|
||||
key={`gpx-${place.id}`}
|
||||
positions={coords}
|
||||
color={place.category_color || '#3b82f6'}
|
||||
weight={3.5}
|
||||
opacity={0.75}
|
||||
/>
|
||||
)
|
||||
} catch { return null }
|
||||
})}
|
||||
{gpxPolylines}
|
||||
|
||||
<ReservationOverlay
|
||||
reservations={visibleReservations}
|
||||
showConnections
|
||||
showStats={showReservationStats}
|
||||
onEndpointClick={onReservationClick}
|
||||
/>
|
||||
</MapContainer>
|
||||
|
||||
{TooltipOverlay && (
|
||||
<div data-testid="tooltip" style={{
|
||||
position: 'fixed',
|
||||
left: tooltipPos.x + 14,
|
||||
top: tooltipPos.y - 10,
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
background: 'white',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
|
||||
padding: '6px 10px',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
maxWidth: 220,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{hoveredPlace.name}
|
||||
</div>
|
||||
{hoveredPlace.category_name && CatIcon && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: hoveredPlace.category_color || '#6b7280', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: '#6b7280' }}>{hoveredPlace.category_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{hoveredPlace.address && (
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{hoveredPlace.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
import { createElement, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { Plane, Train, Ship, Car } from 'lucide-react'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
const ENDPOINT_PANE = 'reservation-endpoints'
|
||||
const AIRPORT_BADGE_HALF_PX = 16
|
||||
const BADGE_GAP_PX = 5
|
||||
|
||||
type TransportType = 'flight' | 'train' | 'cruise' | 'car'
|
||||
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car']
|
||||
|
||||
const TRANSPORT_COLOR = '#3b82f6'
|
||||
|
||||
const TYPE_META: Record<TransportType, { color: string; icon: typeof Plane; geodesic: boolean }> = {
|
||||
flight: { color: TRANSPORT_COLOR, icon: Plane, geodesic: true },
|
||||
train: { color: TRANSPORT_COLOR, icon: Train, geodesic: false },
|
||||
cruise: { color: TRANSPORT_COLOR, icon: Ship, geodesic: true },
|
||||
car: { color: TRANSPORT_COLOR, icon: Car, geodesic: false },
|
||||
}
|
||||
|
||||
function useEndpointPane() {
|
||||
const map = useMap()
|
||||
useMemo(() => {
|
||||
if (typeof map?.getPane !== 'function' || typeof map?.createPane !== 'function') return
|
||||
if (!map.getPane(ENDPOINT_PANE)) {
|
||||
const pane = map.createPane(ENDPOINT_PANE)
|
||||
pane.style.zIndex = '650'
|
||||
pane.style.pointerEvents = 'auto'
|
||||
}
|
||||
}, [map])
|
||||
}
|
||||
|
||||
function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
|
||||
const { icon: IconCmp, color } = TYPE_META[type]
|
||||
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
|
||||
const labelHtml = label ? `<span>${label}</span>` : ''
|
||||
const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26
|
||||
return L.divIcon({
|
||||
className: 'trek-endpoint-marker',
|
||||
html: `<div style="
|
||||
display:inline-flex;align-items:center;justify-content:center;gap:4px;
|
||||
padding:0 8px;border-radius:999px;
|
||||
background:${color};box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
||||
border:1.5px solid #fff;color:#fff;
|
||||
font-family:-apple-system,system-ui,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
|
||||
box-sizing:border-box;height:22px;white-space:nowrap;
|
||||
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''}</div>`,
|
||||
iconSize: [estWidth, 22],
|
||||
iconAnchor: [estWidth / 2, 11],
|
||||
popupAnchor: [0, -11],
|
||||
})
|
||||
}
|
||||
|
||||
function toRad(d: number) { return d * Math.PI / 180 }
|
||||
function toDeg(r: number) { return r * 180 / Math.PI }
|
||||
|
||||
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
|
||||
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
|
||||
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
|
||||
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
|
||||
if (d === 0) return [a, b]
|
||||
const pts: [number, number][] = []
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const f = i / steps
|
||||
const A = Math.sin((1 - f) * d) / Math.sin(d)
|
||||
const B = Math.sin(f * d) / Math.sin(d)
|
||||
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
|
||||
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
|
||||
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
|
||||
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
|
||||
const lng = Math.atan2(y, x)
|
||||
pts.push([toDeg(lat), toDeg(lng)])
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
function splitAntimeridian(points: [number, number][]): [number, number][][] {
|
||||
const segments: [number, number][][] = []
|
||||
let cur: [number, number][] = []
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
|
||||
if (cur.length > 1) segments.push(cur)
|
||||
cur = []
|
||||
}
|
||||
cur.push(points[i])
|
||||
}
|
||||
if (cur.length > 1) segments.push(cur)
|
||||
return segments
|
||||
}
|
||||
|
||||
function cleanName(name: string): string {
|
||||
return name.replace(/\s*\([^)]*\)/g, '').trim()
|
||||
}
|
||||
|
||||
function haversineKm(a: [number, number], b: [number, number]): number {
|
||||
const R = 6371
|
||||
const dLat = toRad(b[0] - a[0])
|
||||
const dLng = toRad(b[1] - a[1])
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(h))
|
||||
}
|
||||
|
||||
function parseInTz(isoLocal: string, tz: string): number {
|
||||
const [datePart, timePart] = isoLocal.split('T')
|
||||
const [y, mo, d] = datePart.split('-').map(Number)
|
||||
const [h, mi] = (timePart || '00:00').split(':').map(Number)
|
||||
const guess = Date.UTC(y, mo - 1, d, h, mi)
|
||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz, hour12: false,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
})
|
||||
const parts = Object.fromEntries(fmt.formatToParts(new Date(guess)).filter(p => p.type !== 'literal').map(p => [p.type, p.value]))
|
||||
const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute), Number(parts.second))
|
||||
return guess - (asUtc - guess)
|
||||
}
|
||||
|
||||
function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fallbackStart: string | null, fallbackEnd: string | null): string | null {
|
||||
let start = from.local_date && from.local_time ? `${from.local_date}T${from.local_time}` : fallbackStart
|
||||
let end = to.local_date && to.local_time ? `${to.local_date}T${to.local_time}` : fallbackEnd
|
||||
if (!start || !end) return null
|
||||
|
||||
if (!start.includes('T') && end.includes('T')) start = `${end.split('T')[0]}T${start}`
|
||||
if (!end.includes('T') && start.includes('T')) end = `${start.split('T')[0]}T${end}`
|
||||
if (!start.includes('T') || !end.includes('T')) return null
|
||||
|
||||
const fromTz = from.timezone || to.timezone
|
||||
const toTz = to.timezone || fromTz
|
||||
|
||||
let startMs: number, endMs: number
|
||||
if (fromTz && toTz) {
|
||||
startMs = parseInTz(start, fromTz)
|
||||
endMs = parseInTz(end, toTz)
|
||||
} else {
|
||||
startMs = new Date(start).getTime()
|
||||
endMs = new Date(end).getTime()
|
||||
}
|
||||
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null
|
||||
if (endMs <= startMs) endMs += 24 * 60 * 60000
|
||||
const minutes = Math.round((endMs - startMs) / 60000)
|
||||
if (minutes <= 0 || minutes > 48 * 60) return null
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
interface TransportItem {
|
||||
res: Reservation
|
||||
from: ReservationEndpoint
|
||||
to: ReservationEndpoint
|
||||
type: TransportType
|
||||
arcs: [number, number][][]
|
||||
primaryArc: [number, number][]
|
||||
fallback: [number, number]
|
||||
mainLabel: string | null
|
||||
subLabel: string | null
|
||||
}
|
||||
|
||||
function buildStatsHtml(color: string, mainLabel: string | null, subLabel: string | null): { html: string; width: number; height: number } {
|
||||
const estWidth = Math.max(
|
||||
mainLabel ? mainLabel.length * 6.5 : 0,
|
||||
subLabel ? subLabel.length * 5.5 : 0,
|
||||
) + 22
|
||||
const hasBoth = !!mainLabel && !!subLabel
|
||||
const height = hasBoth ? 36 : 22
|
||||
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${mainLabel}</span>` : ''
|
||||
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${subLabel}</span>` : ''
|
||||
const html = `<div class="trek-stats-inner" style="
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
width:100%;height:100%;
|
||||
padding:0 11px;border-radius:999px;
|
||||
background:rgba(17,24,39,0.92);color:#fff;
|
||||
box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
||||
border:1px solid ${color}aa;
|
||||
font-family:-apple-system,system-ui,'SF Pro Text',sans-serif;
|
||||
white-space:nowrap;box-sizing:border-box;
|
||||
transform-origin:center;
|
||||
will-change:transform;
|
||||
">${main}${sub}</div>`
|
||||
return { html, width: estWidth, height }
|
||||
}
|
||||
|
||||
function StatsLabel({ item }: { item: TransportItem }) {
|
||||
const map = useMap()
|
||||
const markerRef = useRef<L.Marker | null>(null)
|
||||
const innerRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const arc = item.primaryArc
|
||||
const color = TYPE_META[item.type].color
|
||||
|
||||
const { html, width, height } = useMemo(() => buildStatsHtml(color, item.mainLabel, item.subLabel), [color, item.mainLabel, item.subLabel])
|
||||
const buffer = AIRPORT_BADGE_HALF_PX + width / 2 + BADGE_GAP_PX
|
||||
|
||||
const compute = () => {
|
||||
if (arc.length < 2) return null
|
||||
const size = map.getSize()
|
||||
const pts = arc.map(p => map.latLngToContainerPoint(p as L.LatLngTuple))
|
||||
const cum: number[] = [0]
|
||||
let total = 0
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
total += pts[i].distanceTo(pts[i - 1])
|
||||
cum.push(total)
|
||||
}
|
||||
if (total <= 0) return null
|
||||
|
||||
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
||||
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
||||
|
||||
const isIn = (p: L.Point) => {
|
||||
if (p.x < -40 || p.x > size.x + 40 || p.y < -40 || p.y > size.y + 40) return false
|
||||
if (p.distanceTo(fromPx) < buffer) return false
|
||||
if (p.distanceTo(toPx) < buffer) return false
|
||||
return true
|
||||
}
|
||||
|
||||
let firstIdx = -1
|
||||
let lastIdx = -1
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
if (isIn(pts[i])) {
|
||||
if (firstIdx < 0) firstIdx = i
|
||||
lastIdx = i
|
||||
}
|
||||
}
|
||||
if (firstIdx < 0) {
|
||||
const target = total / 2
|
||||
let sIdx = 0
|
||||
while (sIdx < cum.length - 2 && cum[sIdx + 1] < target) sIdx++
|
||||
const span = cum[sIdx + 1] - cum[sIdx]
|
||||
const tm = span > 0 ? (target - cum[sIdx]) / span : 0
|
||||
const pA = pts[sIdx]
|
||||
const pB = pts[sIdx + 1]
|
||||
const mx = pA.x + (pB.x - pA.x) * tm
|
||||
const my = pA.y + (pB.y - pA.y) * tm
|
||||
const latlng = map.containerPointToLatLng([mx, my])
|
||||
let angle = Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180 / Math.PI
|
||||
if (angle > 90) angle -= 180
|
||||
if (angle < -90) angle += 180
|
||||
return { point: [latlng.lat, latlng.lng] as [number, number], angle }
|
||||
}
|
||||
|
||||
const bisectFraction = (a: L.Point, b: L.Point) => {
|
||||
let lo = 0, hi = 1
|
||||
for (let k = 0; k < 10; k++) {
|
||||
const mid = (lo + hi) / 2
|
||||
const mp = L.point(a.x + (b.x - a.x) * mid, a.y + (b.y - a.y) * mid)
|
||||
if (isIn(mp)) hi = mid
|
||||
else lo = mid
|
||||
}
|
||||
return (lo + hi) / 2
|
||||
}
|
||||
|
||||
let lowCum = cum[firstIdx]
|
||||
if (firstIdx > 0) {
|
||||
const t = bisectFraction(pts[firstIdx - 1], pts[firstIdx])
|
||||
lowCum = cum[firstIdx - 1] + (cum[firstIdx] - cum[firstIdx - 1]) * t
|
||||
}
|
||||
let highCum = cum[lastIdx]
|
||||
if (lastIdx < pts.length - 1) {
|
||||
const t = bisectFraction(pts[lastIdx + 1], pts[lastIdx])
|
||||
highCum = cum[lastIdx] + (cum[lastIdx + 1] - cum[lastIdx]) * (1 - t)
|
||||
}
|
||||
|
||||
const targetLen = (lowCum + highCum) / 2
|
||||
|
||||
let segIdx = 0
|
||||
while (segIdx < cum.length - 2 && cum[segIdx + 1] < targetLen) segIdx++
|
||||
const segSpan = cum[segIdx + 1] - cum[segIdx]
|
||||
const t = segSpan > 0 ? (targetLen - cum[segIdx]) / segSpan : 0
|
||||
const pA = pts[segIdx]
|
||||
const pB = pts[segIdx + 1]
|
||||
const px = pA.x + (pB.x - pA.x) * t
|
||||
const py = pA.y + (pB.y - pA.y) * t
|
||||
const latlng = map.containerPointToLatLng([px, py])
|
||||
|
||||
let angle = Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180 / Math.PI
|
||||
if (angle > 90) angle -= 180
|
||||
if (angle < -90) angle += 180
|
||||
|
||||
return { point: [latlng.lat, latlng.lng] as [number, number], angle }
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
const pose = compute()
|
||||
const marker = markerRef.current
|
||||
if (!marker) return
|
||||
const el = marker.getElement() as HTMLElement | null
|
||||
if (!pose) {
|
||||
if (el) el.style.display = 'none'
|
||||
return
|
||||
}
|
||||
if (el) el.style.display = ''
|
||||
marker.setLatLng(pose.point as L.LatLngTuple)
|
||||
if (!innerRef.current && el) innerRef.current = el.querySelector('.trek-stats-inner') as HTMLElement | null
|
||||
if (innerRef.current) innerRef.current.style.transform = `rotate(${pose.angle}deg)`
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const icon = L.divIcon({
|
||||
className: 'trek-endpoint-stats',
|
||||
html,
|
||||
iconSize: [width, height],
|
||||
iconAnchor: [width / 2, height / 2],
|
||||
})
|
||||
const marker = L.marker([0, 0], { icon, pane: ENDPOINT_PANE, interactive: false, keyboard: false })
|
||||
marker.addTo(map)
|
||||
markerRef.current = marker
|
||||
innerRef.current = null
|
||||
apply()
|
||||
return () => {
|
||||
marker.remove()
|
||||
markerRef.current = null
|
||||
innerRef.current = null
|
||||
}
|
||||
}, [map, html, width, height])
|
||||
|
||||
useMapEvents({
|
||||
move: apply,
|
||||
zoom: apply,
|
||||
viewreset: apply,
|
||||
resize: apply,
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
reservations: Reservation[]
|
||||
showConnections: boolean
|
||||
showStats: boolean
|
||||
onEndpointClick?: (reservationId: number) => void
|
||||
}
|
||||
|
||||
export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick }: Props) {
|
||||
useEndpointPane()
|
||||
const map = useMap()
|
||||
const [zoom, setZoom] = useState(() => map.getZoom())
|
||||
useMapEvents({
|
||||
zoomend: () => setZoom(map.getZoom()),
|
||||
})
|
||||
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
|
||||
|
||||
const items = useMemo<TransportItem[]>(() => {
|
||||
const out: TransportItem[] = []
|
||||
for (const r of reservations) {
|
||||
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
||||
const eps = r.endpoints || []
|
||||
const from = eps.find(e => e.role === 'from')
|
||||
const to = eps.find(e => e.role === 'to')
|
||||
if (!from || !to) continue
|
||||
const type = r.type as TransportType
|
||||
const isGeo = TYPE_META[type].geodesic
|
||||
const arcs = isGeo
|
||||
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
|
||||
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
|
||||
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
||||
const primaryArc = arcs[primaryIdx] ?? []
|
||||
const fallback: [number, number] = primaryArc.length > 0
|
||||
? (primaryArc[Math.floor(primaryArc.length / 2)] ?? [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2])
|
||||
: [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2]
|
||||
|
||||
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
|
||||
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
|
||||
const mainLabel = from.code && to.code ? `${from.code} → ${to.code}` : null
|
||||
const subParts = [duration, distance].filter(Boolean) as string[]
|
||||
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
||||
|
||||
out.push({ res: r, from, to, type, arcs, primaryArc, fallback, mainLabel, subLabel })
|
||||
}
|
||||
return out
|
||||
}, [reservations])
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
return items.filter(item => {
|
||||
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
||||
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
|
||||
return fromPx.distanceTo(toPx) >= minPx
|
||||
})
|
||||
}, [items, zoom, map])
|
||||
|
||||
const labelVisibleIds = useMemo(() => {
|
||||
const set = new Set<number>()
|
||||
for (const item of visibleItems) {
|
||||
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
||||
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : 400
|
||||
if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id)
|
||||
}
|
||||
return set
|
||||
}, [visibleItems, zoom, map])
|
||||
|
||||
if (!showConnections) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleItems.map(item => item.arcs.map((seg, segIdx) => (
|
||||
<Polyline
|
||||
key={`line-${item.res.id}-${segIdx}`}
|
||||
positions={seg}
|
||||
pathOptions={{
|
||||
color: TYPE_META[item.type].color,
|
||||
weight: 2.5,
|
||||
opacity: item.res.status === 'confirmed' ? 0.75 : 0.55,
|
||||
dashArray: item.res.status === 'confirmed' ? undefined : '6, 6',
|
||||
}}
|
||||
/>
|
||||
)))}
|
||||
|
||||
{visibleItems.flatMap(item => [
|
||||
<Marker
|
||||
key={`from-${item.res.id}`}
|
||||
position={[item.from.lat, item.from.lng]}
|
||||
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.from.code || cleanName(item.from.name)) : null)}
|
||||
pane={ENDPOINT_PANE}
|
||||
zIndexOffset={1000}
|
||||
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.from.name}</div>
|
||||
{item.res.title && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.res.title}</div>}
|
||||
</Tooltip>
|
||||
</Marker>,
|
||||
<Marker
|
||||
key={`to-${item.res.id}`}
|
||||
position={[item.to.lat, item.to.lng]}
|
||||
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.to.code || cleanName(item.to.name)) : null)}
|
||||
pane={ENDPOINT_PANE}
|
||||
zIndexOffset={1000}
|
||||
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.to.name}</div>
|
||||
{item.res.title && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.res.title}</div>}
|
||||
</Tooltip>
|
||||
</Marker>,
|
||||
])}
|
||||
|
||||
{showStats && visibleItems.map(item => item.type === 'flight' && (item.mainLabel || item.subLabel) && labelVisibleIds.has(item.res.id) && (
|
||||
<StatsLabel key={`stats-${item.res.id}`} item={item} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
|
||||
// Album linking
|
||||
const [showAlbumPicker, setShowAlbumPicker] = useState(false)
|
||||
const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([])
|
||||
const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number; passphrase?: string }[]>([])
|
||||
const [albumsLoading, setAlbumsLoading] = useState(false)
|
||||
const [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([])
|
||||
const [syncing, setSyncing] = useState<number | null>(null)
|
||||
@@ -141,7 +141,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
await loadAlbums(selectedProvider)
|
||||
}
|
||||
|
||||
const linkAlbum = async (albumId: string, albumName: string) => {
|
||||
const linkAlbum = async (albumId: string, albumName: string, passphrase?: string) => {
|
||||
if (!selectedProvider) {
|
||||
toast.error(t('memories.error.linkAlbum'))
|
||||
return
|
||||
@@ -152,6 +152,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
album_id: albumId,
|
||||
album_name: albumName,
|
||||
provider: selectedProvider,
|
||||
...(passphrase ? { passphrase } : {}),
|
||||
})
|
||||
setShowAlbumPicker(false)
|
||||
await loadAlbumLinks()
|
||||
@@ -489,7 +490,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
{albums.map(album => {
|
||||
const isLinked = linkedIds.has(album.id)
|
||||
return (
|
||||
<button key={album.id} onClick={() => !isLinked && linkAlbum(album.id, album.albumName)}
|
||||
<button key={album.id} onClick={() => !isLinked && linkAlbum(album.id, album.albumName, album.passphrase)}
|
||||
disabled={isLinked}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px',
|
||||
@@ -581,7 +582,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
borderColor: !pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: !pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>
|
||||
{t('memories.allPhotos')}
|
||||
<span className="hidden sm:inline">{t('memories.allPhotos')}</span>
|
||||
<span className="sm:hidden">{t('common.all')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{selectedIds.size > 0 && (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006
|
||||
//
|
||||
// JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)`
|
||||
// that opens a new browser window and writes a full HTML document into it.
|
||||
// It does NOT render a React component. Tests verify window.open behaviour.
|
||||
// that renders a PDF preview in an srcdoc iframe overlay (Safari-safe pattern).
|
||||
// Tests verify the overlay DOM structure and HTML content.
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
@@ -77,55 +77,57 @@ function buildJourney(overrides: Partial<JourneyDetail> = {}): JourneyDetail {
|
||||
} as unknown as JourneyDetail;
|
||||
}
|
||||
|
||||
// ── Mock window.open ─────────────────────────────────────────────────────────
|
||||
// ── Helpers to inspect the overlay ───────────────────────────────────────────
|
||||
|
||||
let mockWindow: {
|
||||
document: { write: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> };
|
||||
focus: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
function getOverlay(): HTMLElement | null {
|
||||
return document.getElementById('journey-pdf-overlay');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockWindow = {
|
||||
document: { write: vi.fn(), close: vi.fn() },
|
||||
focus: vi.fn(),
|
||||
};
|
||||
vi.spyOn(window, 'open').mockReturnValue(mockWindow as any);
|
||||
});
|
||||
function getIframe(): HTMLIFrameElement | null {
|
||||
return getOverlay()?.querySelector('iframe') ?? null;
|
||||
}
|
||||
|
||||
// ── Setup ────────────────────────────────────────────────────────────────────
|
||||
|
||||
afterEach(() => {
|
||||
document.getElementById('journey-pdf-overlay')?.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('downloadJourneyBookPDF', () => {
|
||||
it('FE-COMP-JOURNEYPDF-001: opens a new window', async () => {
|
||||
it('FE-COMP-JOURNEYPDF-001: appends overlay to document body', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(window.open).toHaveBeenCalledWith('', '_blank');
|
||||
expect(getOverlay()).not.toBeNull();
|
||||
expect(document.body.contains(getOverlay())).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-002: writes HTML to the new window', async () => {
|
||||
it('FE-COMP-JOURNEYPDF-002: overlay contains an iframe with srcdoc HTML', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(mockWindow.document.write).toHaveBeenCalledTimes(1);
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
const iframe = getIframe();
|
||||
expect(iframe).not.toBeNull();
|
||||
const html = iframe!.srcdoc;
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
expect(html).toContain('</html>');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-003: closes the document after writing', async () => {
|
||||
it('FE-COMP-JOURNEYPDF-003: overlay has close and save buttons', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(mockWindow.document.close).toHaveBeenCalledTimes(1);
|
||||
const overlay = getOverlay()!;
|
||||
expect(overlay.querySelector('#journey-pdf-close')).not.toBeNull();
|
||||
expect(overlay.querySelector('#journey-pdf-save')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Iceland Ring Road');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Golden Circle');
|
||||
// Story text is rendered via markdown
|
||||
expect(html).toContain('An incredible day of geysers and waterfalls.');
|
||||
@@ -137,8 +139,8 @@ describe('downloadJourneyBookPDF', () => {
|
||||
it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => {
|
||||
const journey = buildJourney({ entries: [] });
|
||||
await downloadJourneyBookPDF(journey);
|
||||
expect(window.open).toHaveBeenCalled();
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
expect(getOverlay()).not.toBeNull();
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Iceland Ring Road');
|
||||
// No entry pages, but cover and closing page are still present
|
||||
expect(html).toContain('Journey Book');
|
||||
|
||||
@@ -249,23 +249,9 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
.entry-photo-single, .entry-photo-duo, .entry-photo-trio { page-break-after: avoid; }
|
||||
}
|
||||
|
||||
.print-bar {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 9999;
|
||||
background: rgba(15,23,42,0.95); backdrop-filter: blur(12px);
|
||||
padding: 12px 24px; display: flex; align-items: center; justify-content: center; gap: 12px;
|
||||
}
|
||||
.print-bar button { padding: 8px 24px; border-radius: 10px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border: none; }
|
||||
.print-bar .btn-save { background: white; color: #0f172a; }
|
||||
.print-bar .btn-close { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border: 1px solid rgba(255,255,255,0.15); }
|
||||
.print-bar .info { font-size: 11px; color: rgba(255,255,255,0.4); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="print-bar">
|
||||
<span class="info">${esc(journey.title)} · ${totalPages} pages</span>
|
||||
<button class="btn-save" onclick="window.print()">Save as PDF</button>
|
||||
<button class="btn-close" onclick="window.close()">Close</button>
|
||||
</div>
|
||||
|
||||
<!-- Page 1: Cover -->
|
||||
<div class="cover-page">
|
||||
@@ -299,8 +285,37 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const win = window.open('', '_blank')
|
||||
if (!win) return
|
||||
win.document.write(html)
|
||||
win.document.close()
|
||||
// Render in a fixed overlay + srcdoc iframe — same pattern as TripPDF.
|
||||
// This avoids window.open() which Safari iOS blocks in async callbacks
|
||||
// and window.close() which doesn't work reliably in standalone PWA mode.
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'journey-pdf-overlay'
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
|
||||
|
||||
const card = document.createElement('div')
|
||||
card.style.cssText = 'width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35);'
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a;'
|
||||
header.innerHTML = `
|
||||
<span style="font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em">${esc(journey.title)} · ${totalPages} pages</span>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<button id="journey-pdf-save" style="min-height:44px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:none;background:#fff;color:#0f172a;">Save as PDF</button>
|
||||
<button id="journey-pdf-close" style="min-height:44px;padding:10px 16px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7);">Close</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
|
||||
iframe.srcdoc = html
|
||||
|
||||
card.appendChild(header)
|
||||
card.appendChild(iframe)
|
||||
overlay.appendChild(card)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
header.querySelector<HTMLButtonElement>('#journey-pdf-close')!.onclick = () => overlay.remove()
|
||||
header.querySelector<HTMLButtonElement>('#journey-pdf-save')!.onclick = () => { iframe.contentWindow?.print() }
|
||||
}
|
||||
|
||||
@@ -521,7 +521,7 @@ ${daysHtml}
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||
iframe.sandbox = 'allow-same-origin allow-modals'
|
||||
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
|
||||
iframe.srcdoc = html
|
||||
|
||||
card.appendChild(header)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Package } from 'lucide-react'
|
||||
import { adminApi, packingApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
interface Template {
|
||||
id: number
|
||||
name: string
|
||||
item_count: number
|
||||
}
|
||||
|
||||
interface ApplyTemplateButtonProps {
|
||||
tripId: number
|
||||
style: React.CSSProperties
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Dropdown-Button um ein Packing-Template auf den aktuellen Trip anzuwenden.
|
||||
// Rendert nichts wenn keine Templates existieren.
|
||||
export default function ApplyTemplateButton({ tripId, style, className }: ApplyTemplateButtonProps): React.ReactElement | null {
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [applying, setApplying] = useState(false)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.packingTemplates().then(d => setTemplates(d.templates || [])).catch(() => {})
|
||||
}, [tripId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (dropRef.current && !dropRef.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
const handleApply = async (templateId: number) => {
|
||||
setApplying(true)
|
||||
try {
|
||||
const data = await packingApi.applyTemplate(tripId, templateId)
|
||||
toast.success(t('packing.templateApplied', { count: data.count }))
|
||||
setOpen(false)
|
||||
window.location.reload()
|
||||
} catch {
|
||||
toast.error(t('packing.templateError'))
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (templates.length === 0) return null
|
||||
|
||||
return (
|
||||
<div ref={dropRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
disabled={applying}
|
||||
className={className ?? 'hover:opacity-[0.88]'}
|
||||
style={style}
|
||||
>
|
||||
<Package size={14} strokeWidth={2.5} />
|
||||
<span className="hidden sm:inline">{t('packing.applyTemplate')}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="trek-menu-enter"
|
||||
style={{
|
||||
position: 'absolute', right: 0, top: '100%', marginTop: 6, zIndex: 50,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220,
|
||||
transformOrigin: 'top right',
|
||||
}}
|
||||
>
|
||||
{templates.map(tmpl => (
|
||||
<button key={tmpl.id} onClick={() => handleApply(tmpl.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '8px 12px', borderRadius: 8, border: 'none', cursor: 'pointer',
|
||||
background: 'transparent', fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<Package size={13} style={{ color: 'var(--text-faint)' }} />
|
||||
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||
<div style={{ fontWeight: 600 }}>{tmpl.name}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-faint)' }}>
|
||||
{tmpl.item_count} {t('admin.packingTemplates.items')}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -253,10 +253,23 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
}}
|
||||
>
|
||||
<button onClick={handleToggle} style={{
|
||||
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex',
|
||||
color: item.checked ? '#10b981' : 'var(--text-faint)', transition: 'color 0.15s',
|
||||
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, position: 'relative',
|
||||
width: 18, height: 18,
|
||||
color: item.checked ? '#10b981' : 'var(--text-faint)',
|
||||
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}>
|
||||
{item.checked ? <CheckSquare size={18} /> : <Square size={18} />}
|
||||
<Square size={18} style={{
|
||||
position: 'absolute', inset: 0,
|
||||
opacity: item.checked ? 0 : 1,
|
||||
transform: item.checked ? 'scale(0.7)' : 'scale(1)',
|
||||
transition: 'opacity 180ms cubic-bezier(0.23,1,0.32,1), transform 180ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}} />
|
||||
<CheckSquare size={18} style={{
|
||||
position: 'absolute', inset: 0,
|
||||
opacity: item.checked ? 1 : 0,
|
||||
transform: item.checked ? 'scale(1)' : 'scale(0.5)',
|
||||
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 220ms cubic-bezier(0.34,1.56,0.64,1)',
|
||||
}} />
|
||||
</button>
|
||||
|
||||
{editing && canEdit ? (
|
||||
@@ -274,6 +287,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
flex: 1, fontSize: 13.5,
|
||||
cursor: !canEdit || item.checked ? 'default' : 'text',
|
||||
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
|
||||
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||
textDecoration: item.checked ? 'line-through' : 'none',
|
||||
}}
|
||||
>
|
||||
@@ -729,9 +743,13 @@ function MenuItem({ icon, label, onClick, danger }: MenuItemProps) {
|
||||
interface PackingListPanelProps {
|
||||
tripId: number
|
||||
items: PackingItem[]
|
||||
openImportSignal?: number
|
||||
clearCheckedSignal?: number
|
||||
saveTemplateSignal?: number
|
||||
inlineHeader?: boolean
|
||||
}
|
||||
|
||||
export default function PackingListPanel({ tripId, items }: PackingListPanelProps) {
|
||||
export default function PackingListPanel({ tripId, items, openImportSignal = 0, clearCheckedSignal = 0, saveTemplateSignal = 0, inlineHeader = true }: PackingListPanelProps) {
|
||||
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
@@ -896,6 +914,31 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
const [saveTemplateName, setSaveTemplateName] = useState('')
|
||||
const [showImportModal, setShowImportModal] = useState(false)
|
||||
const [importText, setImportText] = useState('')
|
||||
const lastHandledImportSignal = useRef(openImportSignal)
|
||||
const lastHandledClearSignal = useRef(clearCheckedSignal)
|
||||
const lastHandledSaveSignal = useRef(saveTemplateSignal)
|
||||
|
||||
useEffect(() => {
|
||||
if (openImportSignal !== lastHandledImportSignal.current && openImportSignal > 0) {
|
||||
setShowImportModal(true)
|
||||
}
|
||||
lastHandledImportSignal.current = openImportSignal
|
||||
}, [openImportSignal])
|
||||
|
||||
useEffect(() => {
|
||||
if (clearCheckedSignal !== lastHandledClearSignal.current && clearCheckedSignal > 0) {
|
||||
handleClearChecked()
|
||||
}
|
||||
lastHandledClearSignal.current = clearCheckedSignal
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [clearCheckedSignal])
|
||||
|
||||
useEffect(() => {
|
||||
if (saveTemplateSignal !== lastHandledSaveSignal.current && saveTemplateSignal > 0) {
|
||||
setShowSaveTemplate(true)
|
||||
}
|
||||
lastHandledSaveSignal.current = saveTemplateSignal
|
||||
}, [saveTemplateSignal])
|
||||
const csvInputRef = useRef<HTMLInputElement>(null)
|
||||
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -999,16 +1042,43 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
||||
{items.length === 0 ? t('packing.empty') : t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{canEdit && abgehakt > 0 && (
|
||||
<div style={{ padding: inlineHeader ? '20px 24px 16px' : '0 0 16px', flexShrink: 0, borderBottom: inlineHeader ? '1px solid rgba(0,0,0,0.06)' : undefined }}>
|
||||
<div style={{ display: 'flex', alignItems: inlineHeader ? 'flex-start' : 'center', justifyContent: 'space-between', gap: 14 }}>
|
||||
{inlineHeader ? (
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
|
||||
{items.length > 0 && (
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
||||
{t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : <span />}
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
{canEdit && items.length > 0 && showSaveTemplate && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<input
|
||||
type="text" autoFocus
|
||||
value={saveTemplateName}
|
||||
onChange={e => setSaveTemplateName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }}
|
||||
placeholder={t('packing.templateName')}
|
||||
style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
|
||||
<button onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
|
||||
</div>
|
||||
)}
|
||||
{inlineHeader && canEdit && (
|
||||
<button onClick={() => setShowImportModal(true)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
||||
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
|
||||
}}>
|
||||
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
|
||||
</button>
|
||||
)}
|
||||
{inlineHeader && canEdit && abgehakt > 0 && (
|
||||
<button onClick={handleClearChecked} style={{
|
||||
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
|
||||
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
|
||||
@@ -1017,16 +1087,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowImportModal(true)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
||||
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
|
||||
}}>
|
||||
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
|
||||
</button>
|
||||
)}
|
||||
{canEdit && availableTemplates.length > 0 && (
|
||||
{inlineHeader && canEdit && availableTemplates.length > 0 && (
|
||||
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
|
||||
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
@@ -1065,31 +1126,14 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canEdit && items.length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{showSaveTemplate ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<input
|
||||
type="text" autoFocus
|
||||
value={saveTemplateName}
|
||||
onChange={e => setSaveTemplateName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }}
|
||||
placeholder={t('packing.templateName')}
|
||||
style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
|
||||
<button onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowSaveTemplate(true)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: 'var(--bg-card)', color: 'var(--text-muted)',
|
||||
}}>
|
||||
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{inlineHeader && canEdit && items.length > 0 && !showSaveTemplate && (
|
||||
<button onClick={() => setShowSaveTemplate(true)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: 'var(--bg-card)', color: 'var(--text-muted)',
|
||||
}}>
|
||||
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
|
||||
</button>
|
||||
)}
|
||||
{bagTrackingEnabled && (
|
||||
<button onClick={() => setShowBagModal(true)} className="xl:!hidden"
|
||||
@@ -1107,17 +1151,69 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ height: 5, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
||||
<div className="hidden sm:block" style={{ marginTop: 14, marginBottom: 14 }}>
|
||||
<div className="flex items-center" style={{ gap: 14 }}>
|
||||
{fortschritt === 100 ? (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
fontSize: 16, fontWeight: 700, color: '#10b981',
|
||||
letterSpacing: '-0.01em', flexShrink: 0,
|
||||
}}>
|
||||
<CheckCheck size={18} strokeWidth={2.5} />
|
||||
<span>{t('packing.allPacked')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline' }}>
|
||||
<span style={{
|
||||
fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
|
||||
fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em',
|
||||
lineHeight: 1,
|
||||
}}>{abgehakt}</span>
|
||||
<span style={{
|
||||
fontSize: 14, fontWeight: 500, color: 'var(--text-faint)',
|
||||
fontVariantNumeric: 'tabular-nums', lineHeight: 1, marginLeft: 1,
|
||||
}}>/{items.length}</span>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 600, padding: '2px 7px',
|
||||
borderRadius: 99, background: 'var(--bg-tertiary)',
|
||||
color: 'var(--text-muted)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
lineHeight: 1.4,
|
||||
}}>{fortschritt}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 99, transition: 'width 0.4s ease',
|
||||
background: fortschritt === 100 ? '#10b981' : 'linear-gradient(90deg, var(--text-primary) 0%, var(--text-muted) 100%)',
|
||||
width: `${fortschritt}%`,
|
||||
}} />
|
||||
flex: 1,
|
||||
height: 8,
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: 99,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
borderRadius: 99,
|
||||
transition: 'width 600ms cubic-bezier(0.23, 1, 0.32, 1), background 400ms ease, box-shadow 400ms ease',
|
||||
background: fortschritt === 100
|
||||
? 'linear-gradient(90deg, #10b981 0%, #34d399 100%)'
|
||||
: 'var(--accent)',
|
||||
width: `${fortschritt}%`,
|
||||
boxShadow: fortschritt === 100 ? '0 0 14px rgba(16,185,129,0.45)' : 'none',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.22) 0%, rgba(255,255,255,0) 55%)',
|
||||
borderRadius: 99,
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{fortschritt === 100 && (
|
||||
<p style={{ fontSize: 11.5, color: '#10b981', marginTop: 4, fontWeight: 600, margin: '4px 0 0' }}>{t('packing.allPacked')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1151,7 +1247,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
|
||||
{/* ── Filter-Tabs ── */}
|
||||
{items.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 4, padding: '10px 16px 0', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 4, padding: '10px 0 0', flexShrink: 0 }}>
|
||||
{[['alle', t('packing.filterAll')], ['offen', t('packing.filterOpen')], ['erledigt', t('packing.filterDone')]].map(([id, label]) => (
|
||||
<button key={id} onClick={() => setFilter(id)} style={{
|
||||
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer',
|
||||
@@ -1165,7 +1261,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
|
||||
{/* ── Liste + Bags Sidebar ── */}
|
||||
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 12px 16px' }}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
|
||||
{items.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
<Luggage size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 10px' }} />
|
||||
@@ -1268,7 +1364,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
|
||||
{/* ── Bag Modal (mobile + click) ── */}
|
||||
{showBagModal && bagTrackingEnabled && (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, overflowY: 'auto' }}
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflowY: 'auto' }}
|
||||
onClick={() => setShowBagModal(false)}>
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
|
||||
@@ -79,6 +79,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
|
||||
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Main area */}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Plane, X } from 'lucide-react'
|
||||
import { airportsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export interface Airport {
|
||||
iata: string
|
||||
icao: string | null
|
||||
name: string
|
||||
city: string
|
||||
country: string
|
||||
lat: number
|
||||
lng: number
|
||||
tz: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: Airport | null
|
||||
onChange: (airport: Airport | null) => void
|
||||
placeholder?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
function formatLabel(a: Airport) {
|
||||
return `${a.city || a.name} (${a.iata})`
|
||||
}
|
||||
|
||||
export default function AirportSelect({ value, onChange, placeholder, style }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
const countryName = useMemo(() => {
|
||||
try { return new Intl.DisplayNames([locale || 'en'], { type: 'region' }) } catch { return null }
|
||||
}, [locale])
|
||||
const displayCountry = (code: string) => {
|
||||
if (!code) return ''
|
||||
try { return countryName?.of(code) || code } catch { return code }
|
||||
}
|
||||
const [query, setQuery] = useState(value ? formatLabel(value) : '')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [results, setResults] = useState<Airport[]>([])
|
||||
const [highlight, setHighlight] = useState(-1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(value ? formatLabel(value) : '')
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
const trimmed = query.trim()
|
||||
if (trimmed.length < 2 || (value && trimmed === formatLabel(value))) {
|
||||
setResults([])
|
||||
return
|
||||
}
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await airportsApi.search(trimmed, controller.signal)
|
||||
setResults(Array.isArray(data) ? data : [])
|
||||
setHighlight(-1)
|
||||
} catch (err: any) {
|
||||
if (err?.name !== 'AbortError' && err?.name !== 'CanceledError') {
|
||||
setResults([])
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, 220)
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||
}, [query, value])
|
||||
|
||||
const pick = (a: Airport) => {
|
||||
onChange(a)
|
||||
setQuery(formatLabel(a))
|
||||
setOpen(false)
|
||||
setResults([])
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
onChange(null)
|
||||
setQuery('')
|
||||
setResults([])
|
||||
}
|
||||
|
||||
const onKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!open || results.length === 0) return
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, results.length - 1)) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)) }
|
||||
else if (e.key === 'Enter' && highlight >= 0) { e.preventDefault(); pick(results[highlight]) }
|
||||
else if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} style={{ position: 'relative', ...style }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 10, border: '1px solid var(--border-primary)' }}>
|
||||
<Plane size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
placeholder={placeholder ?? t('airport.searchPlaceholder')}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (value) onChange(null) }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKey}
|
||||
style={{ flex: 1, minWidth: 0, background: 'transparent', border: 'none', outline: 'none', color: 'var(--text-primary)', fontSize: 13 }}
|
||||
/>
|
||||
{value && (
|
||||
<button type="button" onClick={clear} style={{ background: 'transparent', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }} aria-label="Clear">
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (loading || results.length > 0) && (
|
||||
<div style={{ position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', maxHeight: 260, overflowY: 'auto', zIndex: 1000 }}>
|
||||
{loading && results.length === 0 && (
|
||||
<div style={{ padding: 10, fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</div>
|
||||
)}
|
||||
{results.map((a, i) => (
|
||||
<button
|
||||
key={a.iata}
|
||||
type="button"
|
||||
onClick={() => pick(a)}
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '8px 12px', border: 'none', cursor: 'pointer', textAlign: 'left',
|
||||
background: i === highlight ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-primary)', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: 'ui-monospace, SFMono-Regular, monospace', fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', minWidth: 32 }}>{a.iata}</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{a.city || a.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{a.name}{a.country ? ` · ${displayCountry(a.country)}` : ''}</div>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
const [showHotelPicker, setShowHotelPicker] = useState(false)
|
||||
const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id })
|
||||
const [hotelCategoryFilter, setHotelCategoryFilter] = useState('')
|
||||
const [hotelForm, setHotelForm] = useState({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
const [hotelForm, setHotelForm] = useState({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
|
||||
|
||||
useEffect(() => {
|
||||
if (!day?.date || !lat || !lng) { setWeather(null); return }
|
||||
@@ -117,6 +117,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
start_day_id: hotelDayRange.start,
|
||||
end_day_id: hotelDayRange.end,
|
||||
check_in: hotelForm.check_in || null,
|
||||
check_in_end: hotelForm.check_in_end || null,
|
||||
check_out: hotelForm.check_out || null,
|
||||
confirmation: hotelForm.confirmation || null,
|
||||
})
|
||||
@@ -128,7 +129,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||
))
|
||||
setShowHotelPicker(false)
|
||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
|
||||
onAccommodationChange?.()
|
||||
} catch {}
|
||||
}
|
||||
@@ -356,7 +357,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</div>
|
||||
{acc.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_address}</div>}
|
||||
</div>
|
||||
{canEditDays && <button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
|
||||
{canEditDays && <button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_in_end: acc.check_in_end || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||
<Pencil size={12} style={{ color: 'var(--text-faint)' }} />
|
||||
</button>}
|
||||
@@ -368,7 +369,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<div style={{ display: 'flex', gap: 0, margin: '0 12px 8px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
|
||||
{acc.check_in && (
|
||||
<div style={{ flex: 1, padding: '8px 10px', borderRight: '1px solid var(--border-faint)', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(acc.check_in)}</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>
|
||||
{fmtTime(acc.check_in)}{acc.check_in_end ? ` – ${fmtTime(acc.check_in_end)}` : ''}
|
||||
</div>
|
||||
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
||||
<LogIn size={8} /> {t('day.checkIn')}
|
||||
</div>
|
||||
@@ -488,11 +491,15 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
|
||||
{/* Check-in / Check-out / Confirmation */}
|
||||
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: 100 }}>
|
||||
<div style={{ flex: 1, minWidth: 80 }}>
|
||||
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.checkIn')}</label>
|
||||
<CustomTimePicker value={hotelForm.check_in} onChange={v => setHotelForm(f => ({ ...f, check_in: v }))} placeholder="14:00" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 100 }}>
|
||||
<div style={{ flex: 1, minWidth: 80 }}>
|
||||
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.checkInUntil')}</label>
|
||||
<CustomTimePicker value={hotelForm.check_in_end} onChange={v => setHotelForm(f => ({ ...f, check_in_end: v }))} placeholder="22:00" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 80 }}>
|
||||
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.checkOut')}</label>
|
||||
<CustomTimePicker value={hotelForm.check_out} onChange={v => setHotelForm(f => ({ ...f, check_out: v }))} placeholder="11:00" />
|
||||
</div>
|
||||
@@ -570,11 +577,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
start_day_id: hotelDayRange.start,
|
||||
end_day_id: hotelDayRange.end,
|
||||
check_in: hotelForm.check_in || null,
|
||||
check_in_end: hotelForm.check_in_end || null,
|
||||
check_out: hotelForm.check_out || null,
|
||||
confirmation: hotelForm.confirmation || null,
|
||||
})
|
||||
setShowHotelPicker(false)
|
||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
|
||||
// Reload
|
||||
accommodationsApi.list(tripId).then(d => {
|
||||
const all = d.accommodations || []
|
||||
|
||||
@@ -187,7 +187,7 @@ describe('DayPlanSidebar', () => {
|
||||
const assignments = { '10': [assignment] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
|
||||
// The chevron button immediately follows the "Add Note" button (which has a title attribute)
|
||||
const addNoteBtn = screen.getByTitle('Add Note')
|
||||
const addNoteBtn = screen.getByLabelText('Add Note')
|
||||
const chevron = addNoteBtn.nextElementSibling as HTMLButtonElement
|
||||
expect(chevron).toBeTruthy()
|
||||
await user.click(chevron)
|
||||
@@ -201,7 +201,7 @@ describe('DayPlanSidebar', () => {
|
||||
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
||||
const assignments = { '10': [assignment] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
|
||||
const getChevron = () => screen.getByTitle('Add Note').nextElementSibling as HTMLButtonElement
|
||||
const getChevron = () => screen.getByLabelText('Add Note').nextElementSibling as HTMLButtonElement
|
||||
await user.click(getChevron()) // collapse
|
||||
expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument()
|
||||
await user.click(getChevron()) // re-expand
|
||||
@@ -362,28 +362,14 @@ describe('DayPlanSidebar', () => {
|
||||
const user = userEvent.setup()
|
||||
const onUndo = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ canUndo: true, lastActionLabel: 'Removed place', onUndo })} />)
|
||||
// Find the undo button — it has width 30, height 30 and is not disabled
|
||||
const buttons = screen.getAllByRole('button')
|
||||
// The undo button is the one with the Undo2 icon and is not disabled
|
||||
const undoBtn = buttons.find(btn => {
|
||||
const style = btn.getAttribute('style') || ''
|
||||
return style.includes('width: 30px') || style.includes('width:30px') || (style.includes('30') && !btn.disabled)
|
||||
})
|
||||
if (undoBtn) {
|
||||
await user.click(undoBtn)
|
||||
expect(onUndo).toHaveBeenCalled()
|
||||
}
|
||||
const undoBtn = screen.getByLabelText('Undo')
|
||||
await user.click(undoBtn)
|
||||
expect(onUndo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-024: undo button not present when onUndo not provided', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ canUndo: false })} />)
|
||||
// When onUndo is not provided, the undo section is not rendered at all
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const undoBtn = buttons.find(btn => {
|
||||
const style = btn.getAttribute('style') || ''
|
||||
return style.includes('width: 30px')
|
||||
})
|
||||
expect(undoBtn).toBeUndefined()
|
||||
expect(screen.queryByLabelText('Undo')).toBeNull()
|
||||
})
|
||||
|
||||
// ── PDF export ──────────────────────────────────────────────────────────
|
||||
@@ -440,26 +426,27 @@ describe('DayPlanSidebar', () => {
|
||||
type: 'flight',
|
||||
title: 'Paris to London',
|
||||
reservation_time: '2025-06-01T08:00:00',
|
||||
day_id: 10,
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation] })} />)
|
||||
expect(screen.getByText('Paris to London')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-031: clicking transport item shows detail modal', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-031: clicking transport item calls onEditTransport', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onEditTransport = vi.fn()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel Day' })
|
||||
const reservation = buildReservation({
|
||||
id: 200,
|
||||
type: 'flight',
|
||||
title: 'Air France 123',
|
||||
reservation_time: '2025-06-01T08:00:00',
|
||||
day_id: 10,
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation] })} />)
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation], onEditTransport })} />)
|
||||
await user.click(screen.getByText('Air France 123'))
|
||||
// Detail modal should appear (shows the title again in the modal)
|
||||
await waitFor(() => {
|
||||
const titles = screen.getAllByText('Air France 123')
|
||||
expect(titles.length).toBeGreaterThan(1)
|
||||
expect(onEditTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 200 }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -664,6 +651,7 @@ describe('DayPlanSidebar', () => {
|
||||
const reservation = buildReservation({
|
||||
id: 200, type: 'flight', title: 'CDG to LHR',
|
||||
reservation_time: '2025-06-01T08:00:00',
|
||||
day_id: 10,
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day],
|
||||
@@ -684,6 +672,8 @@ describe('DayPlanSidebar', () => {
|
||||
id: 201, type: 'flight', title: 'Transatlantic',
|
||||
reservation_time: '2025-06-01T22:00:00',
|
||||
reservation_end_time: '2025-06-02T06:00:00',
|
||||
day_id: 10,
|
||||
end_day_id: 11,
|
||||
} as any)
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day1, day2],
|
||||
@@ -704,6 +694,8 @@ describe('DayPlanSidebar', () => {
|
||||
id: 300, type: 'car', title: 'Renault Rental',
|
||||
reservation_time: '2025-06-01T09:00:00',
|
||||
reservation_end_time: '2025-06-03T17:00:00',
|
||||
day_id: 10,
|
||||
end_day_id: 12,
|
||||
} as any)
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day1, day2, day3],
|
||||
@@ -786,20 +778,22 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// ── Transport detail modal with metadata ───────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-051: transport detail modal shows flight metadata', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-051: clicking flight transport calls onEditTransport with reservation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onEditTransport = vi.fn()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel' })
|
||||
const reservation = {
|
||||
...buildReservation({
|
||||
id: 202, type: 'flight', title: 'Paris to Berlin',
|
||||
reservation_time: '2025-06-01T07:30:00',
|
||||
day_id: 10,
|
||||
}),
|
||||
metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH1234', departure_airport: 'CDG', arrival_airport: 'BER' }),
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation as any] })} />)
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation as any], onEditTransport })} />)
|
||||
await user.click(screen.getByText('Paris to Berlin'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Lufthansa')).toBeInTheDocument()
|
||||
expect(onEditTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 202, type: 'flight' }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -923,7 +917,7 @@ describe('DayPlanSidebar', () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
const addNoteBtn = screen.getByTitle('Add Note')
|
||||
const addNoteBtn = screen.getByLabelText('Add Note')
|
||||
await user.click(addNoteBtn)
|
||||
expect(mockDayNotesState.openAddNote).toHaveBeenCalled()
|
||||
})
|
||||
@@ -1124,6 +1118,7 @@ describe('DayPlanSidebar', () => {
|
||||
const flight = buildReservation({
|
||||
id: 201, type: 'flight', title: 'Afternoon Flight',
|
||||
reservation_time: '2025-06-01T14:00:00',
|
||||
day_id: 10,
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [flight],
|
||||
@@ -1683,4 +1678,42 @@ describe('DayPlanSidebar', () => {
|
||||
// Optimize button should not be visible when no day is selected
|
||||
expect(screen.queryByRole('button', { name: /optimize/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Edit reservation pencil button ───────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-097: pencil button on non-transport reservation calls onEditReservation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const place = buildPlace({ id: 1, name: 'Hotel du Lac' })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
||||
const res = buildReservation({ id: 77, trip_id: 1, type: 'hotel', status: 'pending', assignment_id: 99 } as any)
|
||||
const onEditReservation = vi.fn()
|
||||
const onEditTransport = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
|
||||
onEditReservation, onEditTransport,
|
||||
})} />)
|
||||
const pencil = screen.getByTitle(/edit/i)
|
||||
await user.click(pencil)
|
||||
expect(onEditReservation).toHaveBeenCalledWith(res)
|
||||
expect(onEditTransport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-098: pencil button on transport reservation calls onEditTransport', async () => {
|
||||
const user = userEvent.setup()
|
||||
const place = buildPlace({ id: 1, name: 'Geneva Airport' })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
||||
const res = buildReservation({ id: 88, trip_id: 1, type: 'flight', status: 'pending', assignment_id: 99 } as any)
|
||||
const onEditReservation = vi.fn()
|
||||
const onEditTransport = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
|
||||
onEditReservation, onEditTransport,
|
||||
})} />)
|
||||
const pencil = screen.getByTitle(/edit/i)
|
||||
await user.click(pencil)
|
||||
expect(onEditTransport).toHaveBeenCalledWith(res)
|
||||
expect(onEditReservation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
|
||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react'
|
||||
|
||||
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||
@@ -23,6 +23,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
|
||||
|
||||
const NOTE_ICONS = [
|
||||
@@ -170,6 +171,10 @@ interface DayPlanSidebarProps {
|
||||
onEditPlace: (place: Place) => void
|
||||
onDeletePlace: (placeId: number) => void
|
||||
reservations?: Reservation[]
|
||||
visibleConnectionIds?: number[]
|
||||
onToggleConnection?: (reservationId: number) => void
|
||||
externalTransportDetail?: Reservation | null
|
||||
onExternalTransportDetailHandled?: () => void
|
||||
onAddReservation: () => void
|
||||
onNavigateToFiles?: () => void
|
||||
onAddPlace?: () => void
|
||||
@@ -179,6 +184,11 @@ interface DayPlanSidebarProps {
|
||||
canUndo?: boolean
|
||||
lastActionLabel?: string | null
|
||||
onUndo?: () => void
|
||||
onRouteRefresh?: () => void
|
||||
onAddTransport?: (dayId: number) => void
|
||||
onEditTransport?: (reservation: Reservation) => void
|
||||
onEditReservation?: (reservation: Reservation) => void
|
||||
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
|
||||
}
|
||||
|
||||
const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
@@ -189,6 +199,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onReorder, onUpdateDayTitle, onRouteCalculated,
|
||||
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||
reservations = [],
|
||||
visibleConnectionIds = [],
|
||||
onToggleConnection,
|
||||
externalTransportDetail,
|
||||
onExternalTransportDetailHandled,
|
||||
onAddReservation,
|
||||
onAddPlace,
|
||||
onAddPlaceToDay,
|
||||
@@ -198,6 +212,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
canUndo = false,
|
||||
lastActionLabel = null,
|
||||
onUndo,
|
||||
onRouteRefresh,
|
||||
onAddTransport,
|
||||
onEditTransport,
|
||||
onEditReservation,
|
||||
onAddBookingToAssignment,
|
||||
}: DayPlanSidebarProps) {
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
@@ -227,13 +246,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const [undoHover, setUndoHover] = useState(false)
|
||||
const [pdfHover, setPdfHover] = useState(false)
|
||||
const [icsHover, setIcsHover] = useState(false)
|
||||
const [hoveredAssignmentId, setHoveredAssignmentId] = useState<number | null>(null)
|
||||
const [dropTargetKey, _setDropTargetKey] = useState(null)
|
||||
const dropTargetRef = useRef(null)
|
||||
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
|
||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [transportDetail, setTransportDetail] = useState(null)
|
||||
const [transportPosVersion, setTransportPosVersion] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (externalTransportDetail) {
|
||||
setTransportDetail(externalTransportDetail)
|
||||
onExternalTransportDetailHandled?.()
|
||||
}
|
||||
}, [externalTransportDetail, onExternalTransportDetailHandled])
|
||||
const [timeConfirm, setTimeConfirm] = useState<{
|
||||
dayId: number; fromId: number; time: string;
|
||||
// For drag & drop reorder
|
||||
@@ -250,19 +276,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
// Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren)
|
||||
const getDragData = (e) => {
|
||||
const dt = e?.dataTransfer
|
||||
// Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId gesetzt)
|
||||
// Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId/reservationId gesetzt)
|
||||
if (dragDataRef.current) {
|
||||
return {
|
||||
placeId: '',
|
||||
assignmentId: dragDataRef.current.assignmentId || '',
|
||||
noteId: dragDataRef.current.noteId || '',
|
||||
reservationId: dragDataRef.current.reservationId || '',
|
||||
fromDayId: parseInt(dragDataRef.current.fromDayId) || 0,
|
||||
phase: (dragDataRef.current.phase || 'single') as 'single' | 'start' | 'middle' | 'end',
|
||||
}
|
||||
}
|
||||
// Externer Drag (aus PlacesSidebar)
|
||||
const ext = window.__dragData || {}
|
||||
const placeId = dt?.getData('placeId') || ext.placeId || ''
|
||||
return { placeId, assignmentId: '', noteId: '', fromDayId: 0 }
|
||||
return { placeId, assignmentId: '', noteId: '', reservationId: '', fromDayId: 0, phase: 'single' as const }
|
||||
}
|
||||
|
||||
// Only auto-expand genuinely new days (not on initial load from storage)
|
||||
@@ -309,26 +337,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
|
||||
// Determine if a reservation's end_time represents a different date (multi-day)
|
||||
const getEndDate = (r: Reservation) => {
|
||||
const endStr = r.reservation_end_time || ''
|
||||
return endStr.includes('T') ? endStr.split('T')[0] : null
|
||||
}
|
||||
|
||||
// Get span phase: how a reservation relates to a specific day's date
|
||||
const getSpanPhase = (r: Reservation, dayDate: string): 'single' | 'start' | 'middle' | 'end' => {
|
||||
if (!r.reservation_time) return 'single'
|
||||
const startDate = r.reservation_time.split('T')[0]
|
||||
const endDate = getEndDate(r) || startDate
|
||||
if (startDate === endDate) return 'single'
|
||||
if (dayDate === startDate) return 'start'
|
||||
if (dayDate === endDate) return 'end'
|
||||
// Get span phase: how a reservation relates to a specific day (by id)
|
||||
const getSpanPhase = (r: Reservation, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
||||
const startDayId = r.day_id
|
||||
const endDayId = r.end_day_id ?? startDayId
|
||||
if (!startDayId || startDayId === endDayId) return 'single'
|
||||
if (dayId === startDayId) return 'start'
|
||||
if (dayId === endDayId) return 'end'
|
||||
return 'middle'
|
||||
}
|
||||
|
||||
// Get the appropriate display time for a reservation on a specific day
|
||||
const getDisplayTimeForDay = (r: Reservation, dayDate: string): string | null => {
|
||||
const phase = getSpanPhase(r, dayDate)
|
||||
const getDisplayTimeForDay = (r: Reservation, dayId: number): string | null => {
|
||||
const phase = getSpanPhase(r, dayId)
|
||||
if (phase === 'end') return r.reservation_end_time || null
|
||||
if (phase === 'middle') return null
|
||||
return r.reservation_time || null
|
||||
@@ -342,36 +363,56 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
return t(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
|
||||
}
|
||||
|
||||
const getDayOrder = (day: (typeof days)[number]) => (day as any).day_number ?? days.indexOf(day)
|
||||
|
||||
const computeMultiDayMove = (r: Reservation, targetDayId: number, phase: 'single' | 'start' | 'middle' | 'end') => {
|
||||
const startId = r.day_id ?? targetDayId
|
||||
const endId = r.end_day_id ?? startId
|
||||
const order = (id: number) => { const d = days.find(x => x.id === id); return d ? getDayOrder(d) : 0 }
|
||||
if (phase === 'single' || startId === endId) return { day_id: targetDayId, end_day_id: targetDayId }
|
||||
if (phase === 'start') {
|
||||
if (order(targetDayId) > order(endId)) return { day_id: targetDayId, end_day_id: targetDayId }
|
||||
return { day_id: targetDayId, end_day_id: endId }
|
||||
}
|
||||
// phase === 'end'
|
||||
if (order(targetDayId) < order(startId)) return { day_id: targetDayId, end_day_id: targetDayId }
|
||||
return { day_id: startId, end_day_id: targetDayId }
|
||||
}
|
||||
|
||||
const getTransportForDay = (dayId: number) => {
|
||||
const day = days.find(d => d.id === dayId)
|
||||
if (!day?.date) return []
|
||||
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
|
||||
return reservations.filter(r => {
|
||||
if (!r.reservation_time || r.type === 'hotel') return false
|
||||
if (r.type === 'hotel') return false
|
||||
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
||||
const startDate = r.reservation_time.split('T')[0]
|
||||
const endDate = getEndDate(r)
|
||||
|
||||
if (endDate && endDate !== startDate) {
|
||||
// Multi-day: show on any day in range (car middle handled elsewhere)
|
||||
return day.date >= startDate && day.date <= endDate
|
||||
} else {
|
||||
// Single-day: show all non-hotel reservations that match this day's date
|
||||
return startDate === day.date
|
||||
const startDayId = r.day_id
|
||||
const endDayId = r.end_day_id ?? startDayId
|
||||
|
||||
if (startDayId == null) return false
|
||||
|
||||
if (endDayId !== startDayId) {
|
||||
const startDay = days.find(d => d.id === startDayId)
|
||||
const endDay = days.find(d => d.id === endDayId)
|
||||
const thisDay = days.find(d => d.id === dayId)
|
||||
if (!startDay || !endDay || !thisDay) return false
|
||||
return getDayOrder(thisDay) >= getDayOrder(startDay) && getDayOrder(thisDay) <= getDayOrder(endDay)
|
||||
}
|
||||
return startDayId === dayId
|
||||
})
|
||||
}
|
||||
|
||||
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
|
||||
const getActiveRentalsForDay = (dayId: number) => {
|
||||
const day = days.find(d => d.id === dayId)
|
||||
if (!day?.date) return []
|
||||
return reservations.filter(r => {
|
||||
if (r.type !== 'car' || !r.reservation_time) return false
|
||||
const startDate = r.reservation_time.split('T')[0]
|
||||
const endDate = getEndDate(r)
|
||||
if (!endDate || endDate === startDate) return false
|
||||
return day.date > startDate && day.date < endDate
|
||||
if (r.type !== 'car') return false
|
||||
const startDayId = r.day_id
|
||||
const endDayId = r.end_day_id
|
||||
if (!startDayId || !endDayId || endDayId === startDayId) return false
|
||||
const startDay = days.find(d => d.id === startDayId)
|
||||
const endDay = days.find(d => d.id === endDayId)
|
||||
const thisDay = days.find(d => d.id === dayId)
|
||||
if (!startDay || !endDay || !thisDay) return false
|
||||
return getDayOrder(thisDay) > getDayOrder(startDay) && getDayOrder(thisDay) < getDayOrder(endDay)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -420,11 +461,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
day_plan_position: computeTransportPosition(r, da) + idx * 0.01,
|
||||
}))
|
||||
// Mark as initialized immediately to prevent re-entry
|
||||
for (const p of positions) {
|
||||
initedTransportIds.current.add(p.id)
|
||||
const res = reservations.find(x => x.id === p.id)
|
||||
if (res) res.day_plan_position = p.day_plan_position
|
||||
}
|
||||
for (const p of positions) initedTransportIds.current.add(p.id)
|
||||
// Update store so subscribers see the new positions
|
||||
useTripStore.setState(state => ({
|
||||
reservations: state.reservations.map(r => {
|
||||
const p = positions.find(x => x.id === r.id)
|
||||
if (!p) return r
|
||||
return { ...r, day_plan_position: p.day_plan_position }
|
||||
})
|
||||
}))
|
||||
// Persist to server (fire and forget)
|
||||
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
||||
}
|
||||
@@ -433,7 +478,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const da = getDayAssignments(dayId)
|
||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||
const transport = getTransportForDay(dayId)
|
||||
const dayDate = days.find(d => d.id === dayId)?.date || ''
|
||||
|
||||
// Initialize positions for transports that don't have one yet
|
||||
if (transport.some(r => r.day_plan_position == null)) {
|
||||
@@ -450,7 +494,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const timedTransports = transport.map(r => ({
|
||||
type: 'transport' as const,
|
||||
data: r,
|
||||
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayDate)) ?? 0,
|
||||
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayId)) ?? 0,
|
||||
})).sort((a, b) => a.minutes - b.minutes)
|
||||
|
||||
if (timedTransports.length === 0) return baseItems
|
||||
@@ -592,23 +636,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
}
|
||||
|
||||
try {
|
||||
// Update transport positions in store FIRST so the useEffect triggered by
|
||||
// onReorder's optimistic assignment update reads the correct positions.
|
||||
if (transportUpdates.length) {
|
||||
useTripStore.setState(state => ({
|
||||
reservations: state.reservations.map(r => {
|
||||
const tu = transportUpdates.find(u => u.id === r.id)
|
||||
if (!tu) return r
|
||||
const day_positions = { ...(r.day_positions || {}), [dayId]: tu.day_plan_position }
|
||||
return { ...r, day_plan_position: tu.day_plan_position, day_positions }
|
||||
})
|
||||
}))
|
||||
setTransportPosVersion(v => v + 1)
|
||||
}
|
||||
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
||||
if (transportUpdates.length) {
|
||||
onRouteRefresh?.()
|
||||
await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
|
||||
}
|
||||
for (const n of noteUpdates) {
|
||||
await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
|
||||
}
|
||||
if (transportUpdates.length) {
|
||||
for (const tu of transportUpdates) {
|
||||
const res = reservations.find(r => r.id === tu.id)
|
||||
if (res) {
|
||||
res.day_plan_position = tu.day_plan_position
|
||||
// Update per-day position for multi-day reservations
|
||||
if (!res.day_positions) res.day_positions = {}
|
||||
res.day_positions[dayId] = tu.day_plan_position
|
||||
}
|
||||
}
|
||||
setTransportPosVersion(v => v + 1)
|
||||
await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
|
||||
}
|
||||
if (prevAssignmentIds.length) {
|
||||
const capturedDayId = dayId
|
||||
const capturedPrevIds = prevAssignmentIds
|
||||
@@ -620,13 +668,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
}
|
||||
|
||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
||||
// Transport bookings themselves cannot be dragged
|
||||
if (fromType === 'transport') {
|
||||
toast.error(t('dayplan.cannotReorderTransport'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const m = getMergedItems(dayId)
|
||||
|
||||
// Check if a timed place is being moved → would it break chronological order?
|
||||
@@ -839,7 +880,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragOverDayId(null)
|
||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
if (fromReservationId && fromDayId !== dayId) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, dayId, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
|
||||
}
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), dayId)
|
||||
} else if (assignmentId && fromDayId !== dayId) {
|
||||
@@ -894,18 +940,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
{/* Reise-Titel */}
|
||||
<div style={{ padding: '16px 16px 12px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', lineHeight: '1.3' }}>{trip?.title}</div>
|
||||
{(trip?.start_date || trip?.end_date) && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>
|
||||
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })).join(' – ')}
|
||||
{days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Toolbar */}
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
@@ -987,11 +1024,57 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
|
||||
const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
|
||||
return (
|
||||
<Tooltip label={label} placement="bottom">
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = allExpanded ? new Set() : new Set(days.map(d => d.id))
|
||||
setExpandedDays(next)
|
||||
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...next])) } catch {}
|
||||
}}
|
||||
aria-label={label}
|
||||
aria-pressed={allExpanded}
|
||||
style={{
|
||||
position: 'relative', flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 30, height: 30, borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-primary)', cursor: 'pointer', fontFamily: 'inherit', padding: 0,
|
||||
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'opacity 0.2s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
opacity: allExpanded ? 0 : 1,
|
||||
transform: allExpanded ? 'translateY(-8px) scale(0.6)' : 'translateY(0) scale(1)',
|
||||
}}>
|
||||
<ChevronsUpDown size={14} strokeWidth={2} />
|
||||
</span>
|
||||
<span style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'opacity 0.2s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
opacity: allExpanded ? 1 : 0,
|
||||
transform: allExpanded ? 'translateY(0) scale(1)' : 'translateY(8px) scale(0.6)',
|
||||
}}>
|
||||
<ChevronsDownUp size={14} strokeWidth={2} />
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})()}
|
||||
{onUndo && (
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
aria-label={t('undo.button')}
|
||||
onMouseEnter={() => setUndoHover(true)}
|
||||
onMouseLeave={() => setUndoHover(false)}
|
||||
style={{
|
||||
@@ -1023,7 +1106,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
|
||||
{/* Tagesliste */}
|
||||
<div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0, scrollbarWidth: 'thin', scrollbarColor: 'var(--scrollbar-thumb) transparent' }}>
|
||||
<div className="scroll-container trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{days.map((day, index) => {
|
||||
const isSelected = selectedDayId === day.id
|
||||
const isExpanded = expandedDays.has(day.id)
|
||||
@@ -1097,6 +1180,29 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
>
|
||||
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>}
|
||||
{canEditDays && onAddTransport && (
|
||||
<Tooltip label={t('transport.addTransport')} placement="top">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAddTransport(day.id) }}
|
||||
aria-label={t('transport.addTransport')}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '4px',
|
||||
cursor: 'pointer',
|
||||
opacity: 0.45,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = '1' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = '0.45' }}
|
||||
>
|
||||
<Plus size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(() => {
|
||||
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
||||
// Sort: check-out first, then ongoing stays, then check-in last
|
||||
@@ -1151,15 +1257,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEditDays && <button
|
||||
{canEditDays && <Tooltip label={t('dayplan.addNote')} placement="top"><button
|
||||
onClick={e => openAddNote(day.id, e)}
|
||||
title={t('dayplan.addNote')}
|
||||
aria-label={t('dayplan.addNote')}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
||||
>
|
||||
<FileText size={16} strokeWidth={2} />
|
||||
</button>}
|
||||
</button></Tooltip>}
|
||||
<button
|
||||
onClick={e => toggleDay(day.id, e)}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
@@ -1176,7 +1282,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
// Drop on transport card (detected via dropTargetRef for sync accuracy)
|
||||
if (dropTargetRef.current?.startsWith('transport-')) {
|
||||
const isAfter = dropTargetRef.current.startsWith('transport-after-')
|
||||
@@ -1185,6 +1291,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
} else if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
} else if (fromReservationId) {
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter)
|
||||
} else if (assignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (assignmentId) {
|
||||
@@ -1198,6 +1309,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
return
|
||||
}
|
||||
|
||||
if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (!assignmentId && !noteId && !placeId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
@@ -1244,7 +1360,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const cat = categories.find(c => c.id === place.category_id)
|
||||
const isPlaceSelected = selectedAssignmentId ? assignment.id === selectedAssignmentId : place.id === selectedPlaceId
|
||||
const isDraggingThis = draggingId === assignment.id
|
||||
const isHovered = hoveredId === assignment.id
|
||||
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
|
||||
|
||||
const arrowMove = (direction: 'up' | 'down') => {
|
||||
@@ -1297,11 +1412,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); if (dropTargetKey !== `place-${assignment.id}`) setDropTargetKey(`place-${assignment.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
|
||||
const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
if (placeId) {
|
||||
const pos = placeItems.findIndex(i => i.data.id === assignment.id)
|
||||
onAssignToDay?.(parseInt(placeId), day.id, pos >= 0 ? pos : undefined)
|
||||
setDropTargetKey(null); window.__dragData = null
|
||||
} else if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (fromReservationId) {
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'place', assignment.id)
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
@@ -1328,15 +1449,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
{ divider: true },
|
||||
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
onMouseEnter={() => setHoveredId(assignment.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onMouseEnter={e => {
|
||||
if (!isPlaceSelected && !lockedIds.has(assignment.id))
|
||||
e.currentTarget.style.background = 'var(--bg-hover)'
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '1'
|
||||
setHoveredAssignmentId(assignment.id)
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
if (!isPlaceSelected && !lockedIds.has(assignment.id))
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '0.3'
|
||||
setHoveredAssignmentId(null)
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px 7px 10px',
|
||||
cursor: 'pointer',
|
||||
background: lockedIds.has(assignment.id)
|
||||
? 'rgba(220,38,38,0.08)'
|
||||
: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
|
||||
: isPlaceSelected ? 'var(--bg-hover)' : 'transparent',
|
||||
borderLeft: lockedIds.has(assignment.id)
|
||||
? '3px solid #dc2626'
|
||||
: '3px solid transparent',
|
||||
@@ -1344,7 +1477,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
opacity: isDraggingThis ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>}
|
||||
<div
|
||||
@@ -1405,26 +1538,74 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const res = reservations.find(r => r.assignment_id === assignment.id)
|
||||
if (!res) return null
|
||||
const confirmed = res.status === 'confirmed'
|
||||
const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2
|
||||
const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false
|
||||
return (
|
||||
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 9, fontWeight: 600,
|
||||
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
|
||||
color: confirmed ? '#16a34a' : '#d97706',
|
||||
}}>
|
||||
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||
{res.reservation_time?.includes('T') && (
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
||||
</span>
|
||||
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 9, fontWeight: 600,
|
||||
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
|
||||
color: confirmed ? '#16a34a' : '#d97706',
|
||||
}}>
|
||||
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||
{res.reservation_time?.includes('T') && (
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
if (!meta) return null
|
||||
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
|
||||
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
|
||||
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
{hasEndpoints && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
|
||||
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 20, height: 20, borderRadius: 4,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: active ? '#3b82f6' : 'transparent',
|
||||
color: active ? '#fff' : 'var(--text-faint)',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<RouteIcon size={11} />
|
||||
</button>
|
||||
)}
|
||||
{(() => {
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
if (!meta) return null
|
||||
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
|
||||
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
|
||||
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
|
||||
return null
|
||||
{canEditDays && (() => {
|
||||
const isTransport = ['flight','train','car','cruise','bus'].includes(res.type)
|
||||
const handler = isTransport ? onEditTransport : onEditReservation
|
||||
if (!handler) return null
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); handler(res) }}
|
||||
title={t('common.edit')}
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 20, height: 20, borderRadius: 4,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none', background: 'transparent',
|
||||
color: 'var(--text-faint)',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
@@ -1447,7 +1628,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={moveUp} disabled={idx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', color: idx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<ChevronUp size={12} strokeWidth={2} />
|
||||
</button>
|
||||
@@ -1455,6 +1636,32 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<ChevronDown size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>}
|
||||
{canEditDays && onAddBookingToAssignment && hoveredAssignmentId === assignment.id && (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onAddBookingToAssignment(day.id, assignment.id)
|
||||
}}
|
||||
title={t('reservations.addBooking')}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
background: 'none',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 5,
|
||||
padding: '2px 6px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-muted)',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Plus size={11} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
@@ -1463,7 +1670,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
// Transport booking (flight, train, bus, car, cruise)
|
||||
if (item.type === 'transport') {
|
||||
const res = item.data
|
||||
const spanPhase = getSpanPhase(res, day.date)
|
||||
const spanPhase = getSpanPhase(res, day.id)
|
||||
|
||||
// Car "active" (middle) days are shown in the day header, skip here
|
||||
if (res.type === 'car' && spanPhase === 'middle') return null
|
||||
@@ -1471,7 +1678,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const TransportIcon = RES_ICONS[res.type] || Ticket
|
||||
const color = '#3b82f6'
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
const isTransportHovered = hoveredId === `transport-${res.id}`
|
||||
|
||||
// Subtitle aus Metadaten zusammensetzen
|
||||
let subtitle = ''
|
||||
@@ -1486,13 +1692,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
// Multi-day span phase
|
||||
const spanLabel = getSpanLabel(res, spanPhase)
|
||||
const displayTime = getDisplayTimeForDay(res, day.date)
|
||||
const displayTime = getDisplayTimeForDay(res, day.id)
|
||||
|
||||
return (
|
||||
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
<div
|
||||
onClick={() => setTransportDetail(res)}
|
||||
onClick={() => canEditDays && onEditTransport?.(res)}
|
||||
onDragOver={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
@@ -1500,13 +1706,26 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}`
|
||||
if (dropTargetRef.current !== key) setDropTargetKey(key)
|
||||
}}
|
||||
draggable={canEditDays && spanPhase !== 'middle'}
|
||||
onDragStart={e => {
|
||||
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase }
|
||||
setDraggingId(res.id)
|
||||
}}
|
||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const insertAfter = e.clientY > rect.top + rect.height / 2
|
||||
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
|
||||
const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
} else if (fromReservationId && fromDayId !== day.id) {
|
||||
const r2 = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r2) { const update = computeMultiDayMove(r2, day.id, phase); tripActions.updateReservation(tripId, r2.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
} else if (fromReservationId) {
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter)
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (fromAssignmentId) {
|
||||
@@ -1518,20 +1737,25 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
}
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||
}}
|
||||
onMouseEnter={() => setHoveredId(`transport-${res.id}`)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = `${color}12` }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = `${color}08` }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px 7px 10px',
|
||||
margin: '1px 8px',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${color}33`,
|
||||
background: isTransportHovered ? `${color}12` : `${color}08`,
|
||||
cursor: 'pointer', userSelect: 'none',
|
||||
background: `${color}08`,
|
||||
cursor: canEditDays && onEditTransport ? 'pointer' : 'default', userSelect: 'none',
|
||||
transition: 'background 0.1s',
|
||||
opacity: spanPhase === 'middle' ? 0.65 : 1,
|
||||
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
|
||||
}}
|
||||
>
|
||||
{canEditDays && spanPhase !== 'middle' && (
|
||||
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: '50%', background: `${color}18`,
|
||||
@@ -1570,6 +1794,29 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onToggleConnection && (res.endpoints || []).length >= 2 && (() => {
|
||||
const active = visibleConnectionIds.includes(res.id)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onToggleConnection(res.id) }}
|
||||
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 26, height: 26, borderRadius: 6,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: active ? color : 'transparent',
|
||||
color: active ? '#fff' : 'var(--text-faint)',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<RouteIcon size={13} />
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
{showDropLineAfter && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
</React.Fragment>
|
||||
@@ -1578,7 +1825,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
// Notizkarte
|
||||
const note = item.data
|
||||
const isNoteHovered = hoveredId === `note-${note.id}`
|
||||
const NoteIcon = getNoteIcon(note.icon)
|
||||
const noteIdx = idx
|
||||
return (
|
||||
@@ -1591,8 +1837,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e)
|
||||
if (fromNoteId && fromDayId !== day.id) {
|
||||
const { noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (fromReservationId) {
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'note', note.id)
|
||||
} else if (fromNoteId && fromDayId !== day.id) {
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
@@ -1615,20 +1867,30 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
{ divider: true },
|
||||
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
|
||||
]) : undefined}
|
||||
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onMouseEnter={e => {
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '1'
|
||||
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
|
||||
if (editBtns) editBtns.style.opacity = '1'
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '0.3'
|
||||
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
|
||||
if (editBtns) editBtns.style.opacity = '0'
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px 7px 2px',
|
||||
margin: '1px 8px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--border-faint)',
|
||||
background: isNoteHovered ? 'var(--bg-hover)' : 'var(--bg-hover)',
|
||||
background: 'var(--bg-hover)',
|
||||
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
|
||||
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>}
|
||||
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
|
||||
@@ -1642,11 +1904,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<div className="collab-note-md" style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{note.time}</Markdown></div>
|
||||
)}
|
||||
</div>
|
||||
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: 0, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
|
||||
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
|
||||
</div>}
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
|
||||
</div>}
|
||||
@@ -1661,12 +1923,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `end-${day.id}`) setDropTargetKey(`end-${day.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
// Neuer Ort von der Orte-Liste
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
setDropTargetKey(null); window.__dragData = null; return
|
||||
}
|
||||
if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
|
||||
}
|
||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
|
||||
@@ -36,6 +36,8 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [summary, setSummary] = useState<PlacesImportSummary | null>(null)
|
||||
const [gpxOpts, setGpxOpts] = useState({ waypoints: true, routes: true, tracks: true })
|
||||
const [kmlOpts, setKmlOpts] = useState({ points: true, paths: true })
|
||||
|
||||
const validateFile = (f: File): string | null => {
|
||||
const ext = f.name.toLowerCase().split('.').pop()
|
||||
@@ -127,7 +129,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
|
||||
try {
|
||||
if (ext === 'gpx') {
|
||||
const result = await placesApi.importGpx(tripId, file)
|
||||
const result = await placesApi.importGpx(tripId, file, gpxOpts)
|
||||
await loadTrip(tripId)
|
||||
if (result.count === 0 && result.skipped > 0) {
|
||||
toast.warning(t('places.importAllSkipped'))
|
||||
@@ -137,15 +139,13 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t('undo.importGpx'), async () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
try { await placesApi.bulkDelete(tripId, importedIds) } catch {}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
handleClose()
|
||||
} else {
|
||||
const result = await placesApi.importMapFile(tripId, file)
|
||||
const result = await placesApi.importMapFile(tripId, file, kmlOpts)
|
||||
await loadTrip(tripId)
|
||||
setSummary(result.summary || null)
|
||||
if (result.count === 0 && (result.summary?.skippedCount ?? 0) > 0) {
|
||||
@@ -159,9 +159,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t('undo.importKeyholeMarkup'), async () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
try { await placesApi.bulkDelete(tripId, importedIds) } catch {}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
@@ -177,7 +175,12 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
}
|
||||
}
|
||||
|
||||
const canImport = !!file && !loading
|
||||
const fileExt = file?.name.toLowerCase().split('.').pop() ?? ''
|
||||
const isGpx = fileExt === 'gpx'
|
||||
const isKml = fileExt === 'kml' || fileExt === 'kmz'
|
||||
const gpxNoneSelected = isGpx && !gpxOpts.waypoints && !gpxOpts.routes && !gpxOpts.tracks
|
||||
const kmlNoneSelected = isKml && !kmlOpts.points && !kmlOpts.paths
|
||||
const canImport = !!file && !loading && !gpxNoneSelected && !kmlNoneSelected
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@@ -242,6 +245,58 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isGpx && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('places.gpxImportTypes')}
|
||||
</div>
|
||||
{(['waypoints', 'routes', 'tracks'] as const).map(key => (
|
||||
<label key={key} onClick={() => setGpxOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer' }}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: gpxOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
|
||||
background: gpxOpts[key] ? 'var(--accent)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{gpxOpts[key] && <svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2" stroke="white" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', userSelect: 'none' }}>
|
||||
{t(key === 'waypoints' ? 'places.gpxImportWaypoints' : key === 'routes' ? 'places.gpxImportRoutes' : 'places.gpxImportTracks')}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{gpxNoneSelected && (
|
||||
<div style={{ fontSize: 11, color: '#b45309', marginTop: 4 }}>{t('places.gpxImportNoneSelected')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isKml && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('places.kmlImportTypes')}
|
||||
</div>
|
||||
{(['points', 'paths'] as const).map(key => (
|
||||
<label key={key} onClick={() => setKmlOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer' }}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: kmlOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
|
||||
background: kmlOpts[key] ? 'var(--accent)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{kmlOpts[key] && <svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2" stroke="white" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', userSelect: 'none' }}>
|
||||
{t(key === 'points' ? 'places.kmlImportPoints' : 'places.kmlImportPaths')}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{kmlNoneSelected && (
|
||||
<div style={{ fontSize: 11, color: '#b45309', marginTop: 4 }}>{t('places.kmlImportNoneSelected')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary && (
|
||||
<div style={{
|
||||
border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { MapPin, X } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export interface LocationPoint {
|
||||
name: string
|
||||
lat: number
|
||||
lng: number
|
||||
address?: string | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: LocationPoint | null
|
||||
onChange: (loc: LocationPoint | null) => void
|
||||
placeholder?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export default function LocationSelect({ value, onChange, placeholder, style }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
const [query, setQuery] = useState(value?.name || '')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [results, setResults] = useState<any[]>([])
|
||||
const [highlight, setHighlight] = useState(-1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(value?.name || '')
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
const trimmed = query.trim()
|
||||
if (trimmed.length < 3 || (value && trimmed === value.name)) {
|
||||
setResults([])
|
||||
return
|
||||
}
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await mapsApi.search(trimmed, locale)
|
||||
setResults(data.places || [])
|
||||
setHighlight(-1)
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, 320)
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||
}, [query, value, locale])
|
||||
|
||||
const pick = (r: any) => {
|
||||
const lat = Number(r.lat)
|
||||
const lng = Number(r.lng)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
|
||||
const loc: LocationPoint = { name: r.name || r.address || 'Location', lat, lng, address: r.address || null }
|
||||
onChange(loc)
|
||||
setQuery(loc.name)
|
||||
setOpen(false)
|
||||
setResults([])
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
onChange(null)
|
||||
setQuery('')
|
||||
setResults([])
|
||||
}
|
||||
|
||||
const onKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!open || results.length === 0) return
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, results.length - 1)) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)) }
|
||||
else if (e.key === 'Enter' && highlight >= 0) { e.preventDefault(); pick(results[highlight]) }
|
||||
else if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} style={{ position: 'relative', ...style }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 10, border: '1px solid var(--border-primary)' }}>
|
||||
<MapPin size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
placeholder={placeholder ?? t('reservations.searchLocation')}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (value) onChange(null) }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKey}
|
||||
style={{ flex: 1, minWidth: 0, background: 'transparent', border: 'none', outline: 'none', color: 'var(--text-primary)', fontSize: 13 }}
|
||||
/>
|
||||
{value && (
|
||||
<button type="button" onClick={clear} style={{ background: 'transparent', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }} aria-label="Clear">
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (loading || results.length > 0) && (
|
||||
<div style={{ position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', maxHeight: 260, overflowY: 'auto', zIndex: 1000 }}>
|
||||
{loading && results.length === 0 && (
|
||||
<div style={{ padding: 10, fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</div>
|
||||
)}
|
||||
{results.map((r, i) => (
|
||||
<button
|
||||
key={`${r.osm_id || r.google_place_id || i}`}
|
||||
type="button"
|
||||
onClick={() => pick(r)}
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 8, width: '100%',
|
||||
padding: '8px 12px', border: 'none', cursor: 'pointer', textAlign: 'left',
|
||||
background: i === highlight ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-primary)', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<MapPin size={12} style={{ color: 'var(--text-faint)', marginTop: 2, flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.name || r.address}</div>
|
||||
{r.address && r.name !== r.address && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.address}</div>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -195,7 +195,7 @@ describe('Filter tabs', () => {
|
||||
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
|
||||
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
|
||||
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
|
||||
await user.click(screen.getByRole('button', { name: /^All$/i }));
|
||||
await user.click(screen.getByRole('button', { name: /^All/i }));
|
||||
expect(screen.getByText('Planned Place')).toBeInTheDocument();
|
||||
expect(screen.getByText('Unplanned Place')).toBeInTheDocument();
|
||||
});
|
||||
@@ -473,14 +473,14 @@ describe('Google Maps list import', () => {
|
||||
it('FE-PLANNER-SIDEBAR-040: "Google List" button opens the URL dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} />);
|
||||
await user.click(screen.getByText(/Google List/i));
|
||||
await user.click(screen.getByText(/List Import/i));
|
||||
expect(await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-041: import button disabled when URL input is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} />);
|
||||
await user.click(screen.getByText(/Google List/i));
|
||||
await user.click(screen.getByText(/List Import/i));
|
||||
await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i);
|
||||
const importBtn = screen.getByRole('button', { name: /^Import$/i });
|
||||
expect(importBtn).toBeDisabled();
|
||||
@@ -498,7 +498,7 @@ describe('Google Maps list import', () => {
|
||||
(window as any).__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} pushUndo={vi.fn()} />);
|
||||
await user.click(screen.getByText(/Google List/i));
|
||||
await user.click(screen.getByText(/List Import/i));
|
||||
const urlInput = await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i);
|
||||
await user.type(urlInput, 'https://maps.app.goo.gl/abc123');
|
||||
await user.click(screen.getByRole('button', { name: /^Import$/i }));
|
||||
@@ -527,7 +527,7 @@ describe('Google Maps list import', () => {
|
||||
(window as any).__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} pushUndo={vi.fn()} />);
|
||||
await user.click(screen.getByText(/Google List/i));
|
||||
await user.click(screen.getByText(/List Import/i));
|
||||
const urlInput = await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i);
|
||||
await user.type(urlInput, 'https://maps.app.goo.gl/xyz{Enter}');
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
|
||||
import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye, Route } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -10,9 +10,10 @@ import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import { placesApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
import FileImportModal from './FileImportModal'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
|
||||
interface PlacesSidebarProps {
|
||||
tripId: number
|
||||
@@ -26,6 +27,8 @@ interface PlacesSidebarProps {
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onEditPlace: (place: Place) => void
|
||||
onDeletePlace: (placeId: number) => void
|
||||
onBulkDeletePlaces?: (ids: number[]) => void
|
||||
onBulkDeleteConfirm?: (ids: number[]) => void
|
||||
days: Day[]
|
||||
isMobile: boolean
|
||||
onCategoryFilterChange?: (categoryIds: Set<string>) => void
|
||||
@@ -33,9 +36,115 @@ interface PlacesSidebarProps {
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
}
|
||||
|
||||
interface MemoPlaceRowProps {
|
||||
place: Place
|
||||
category: Category | undefined
|
||||
isSelected: boolean
|
||||
isPlanned: boolean
|
||||
inDay: boolean
|
||||
isChecked: boolean
|
||||
selectMode: boolean
|
||||
selectedDayId: number | null
|
||||
canEditPlaces: boolean
|
||||
isMobile: boolean
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
onPlaceClick: (id: number | null) => void
|
||||
onContextMenu: (e: React.MouseEvent, place: Place) => void
|
||||
onAssignToDay: (placeId: number, dayId?: number) => void
|
||||
toggleSelected: (id: number) => void
|
||||
setDayPickerPlace: (place: any) => void
|
||||
}
|
||||
|
||||
const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
||||
place, category: cat, isSelected, isPlanned, inDay, isChecked,
|
||||
selectMode, selectedDayId, canEditPlaces, isMobile, t,
|
||||
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace,
|
||||
}: MemoPlaceRowProps) {
|
||||
const hasGeometry = Boolean(place.route_geometry)
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
draggable={!selectMode}
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('placeId', String(place.id))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
}}
|
||||
onClick={() => {
|
||||
if (selectMode) {
|
||||
toggleSelected(place.id)
|
||||
} else if (isMobile) {
|
||||
setDayPickerPlace(place)
|
||||
} else {
|
||||
onPlaceClick(isSelected ? null : place.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={selectMode ? undefined : e => onContextMenu(e, place)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 14px 9px 16px',
|
||||
cursor: selectMode ? 'pointer' : 'grab',
|
||||
background: isChecked ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : isSelected ? 'var(--border-faint)' : 'transparent',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
transition: 'background 0.1s',
|
||||
contentVisibility: 'auto',
|
||||
containIntrinsicSize: '0 52px',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected && !isChecked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isSelected && !isChecked) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
{selectMode && (
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: isChecked ? 'none' : '1.5px solid var(--border-primary)',
|
||||
background: isChecked ? 'var(--accent)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{isChecked && <Check size={10} strokeWidth={3} color="white" />}
|
||||
</div>
|
||||
)}
|
||||
<PlaceAvatar place={place} category={cat} size={34} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden' }}>
|
||||
{hasGeometry && <Route size={11} strokeWidth={2} color="var(--text-faint)" style={{ flexShrink: 0 }} title="Track / Route" />}
|
||||
{cat && (() => {
|
||||
const CatIcon = getCategoryIcon(cat.icon)
|
||||
return <CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} style={{ flexShrink: 0 }} title={cat.name} />
|
||||
})()}
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{place.name}
|
||||
</span>
|
||||
</div>
|
||||
{(place.description || place.address || cat?.name) && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
|
||||
{place.description || place.address || cat?.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||
{!selectMode && !inDay && selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 20, height: 20, borderRadius: 6,
|
||||
background: 'var(--bg-hover)', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text-faint)', padding: 0, transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
><Plus size={12} strokeWidth={2.5} /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, onBulkDeletePlaces, onBulkDeleteConfirm, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo,
|
||||
}: PlacesSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
@@ -44,7 +153,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||
const can = useCanDo()
|
||||
const canEditPlaces = can('place_edit', trip)
|
||||
const isNaverListImportEnabled = useAddonStore((s) => s.isEnabled('naver_list_import'))
|
||||
const isNaverListImportEnabled = true
|
||||
|
||||
const [fileImportOpen, setFileImportOpen] = useState(false)
|
||||
const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
|
||||
@@ -111,9 +220,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t(provider === 'google' ? 'undo.importGoogleList' : 'undo.importNaverList'), async () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
try { await placesApi.bulkDelete(tripId, importedIds) } catch {}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
@@ -127,6 +234,28 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
|
||||
|
||||
const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) }
|
||||
|
||||
// Auto-exit when all selected places have been removed from the store (e.g. after bulk delete)
|
||||
useEffect(() => {
|
||||
if (!selectMode || selectedIds.size === 0) return
|
||||
const placeIdSet = new Set(places.map(p => p.id))
|
||||
if ([...selectedIds].every(id => !placeIdSet.has(id))) {
|
||||
setSelectMode(false)
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [places])
|
||||
|
||||
const toggleSelected = useCallback((id: number) => setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
}), [])
|
||||
|
||||
const toggleCategoryFilter = (catId: string) => {
|
||||
setCategoryFiltersLocal(prev => {
|
||||
@@ -141,13 +270,21 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const [mobileShowDays, setMobileShowDays] = useState(false)
|
||||
|
||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||
const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places])
|
||||
useEffect(() => { if (filter === 'tracks' && !hasTracks) setFilter('all') }, [hasTracks, filter])
|
||||
|
||||
const plannedIds = useMemo(() => new Set(
|
||||
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
||||
), [assignments])
|
||||
|
||||
const filtered = useMemo(() => places.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
if (categoryFilters.size > 0 && !categoryFilters.has(String(p.category_id))) return false
|
||||
if (filter === 'tracks' && !p.route_geometry) return false
|
||||
if (categoryFilters.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
if (!categoryFilters.has('uncategorized')) return false
|
||||
} else if (!categoryFilters.has(String(p.category_id))) return false
|
||||
}
|
||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||
return true
|
||||
@@ -156,6 +293,26 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const isAssignedToSelectedDay = (placeId) =>
|
||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||
|
||||
const selectedDayIdRef = useRef<number | null>(selectedDayId)
|
||||
useEffect(() => { selectedDayIdRef.current = selectedDayId }, [selectedDayId])
|
||||
|
||||
const inDaySet = useMemo(() => {
|
||||
if (!selectedDayId) return new Set<number>()
|
||||
return new Set<number>((assignments[String(selectedDayId)] || []).map((a: any) => a.place?.id).filter(Boolean))
|
||||
}, [assignments, selectedDayId])
|
||||
|
||||
const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => {
|
||||
const selDayId = selectedDayIdRef.current
|
||||
ctxMenu.open(e, [
|
||||
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selDayId) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${(place as any).google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + (place as any).google_place_id : place.lat + ',' + place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])
|
||||
}, [ctxMenu.open, canEditPlaces, t, onEditPlace, onAssignToDay, onDeletePlace])
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragEnter={handleSidebarDragEnter}
|
||||
@@ -217,19 +374,65 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ height: 1, background: 'var(--border-primary)', margin: '2px 0 10px' }} />
|
||||
</>}
|
||||
|
||||
{/* Filter-Tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
||||
{[{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }].map(f => (
|
||||
<button key={f.id} onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id) }} style={{
|
||||
padding: '4px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
|
||||
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
|
||||
background: filter === f.id ? 'var(--accent)' : 'var(--bg-tertiary)',
|
||||
color: filter === f.id ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
}}>{f.label}</button>
|
||||
))}
|
||||
</div>
|
||||
{(() => {
|
||||
const baseFiltered = places.filter(p => {
|
||||
if (categoryFilters.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
if (!categoryFilters.has('uncategorized')) return false
|
||||
} else if (!categoryFilters.has(String(p.category_id))) return false
|
||||
}
|
||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
const counts = {
|
||||
all: baseFiltered.length,
|
||||
unplanned: baseFiltered.filter(p => !plannedIds.has(p.id)).length,
|
||||
tracks: baseFiltered.filter(p => p.route_geometry).length,
|
||||
}
|
||||
const tabs = ([
|
||||
{ id: 'all', label: t('places.all') },
|
||||
{ id: 'unplanned', label: t('places.unplanned') },
|
||||
hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null,
|
||||
] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'tracks'; label: string }>
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
{tabs.map(f => {
|
||||
const active = filter === f.id
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id); setSelectedIds(new Set()) }}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
padding: '4px 9px', borderRadius: 99,
|
||||
fontSize: 11, fontWeight: 500, whiteSpace: 'nowrap',
|
||||
background: active ? 'var(--accent)' : 'var(--bg-card)',
|
||||
color: active ? 'var(--accent-text)' : 'var(--text-primary)',
|
||||
boxShadow: active ? 'none' : '0 1px 2px rgba(0,0,0,0.06)',
|
||||
transition: 'background 0.15s, color 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
<span style={{
|
||||
fontSize: 9, fontWeight: 600, lineHeight: 1,
|
||||
background: active ? 'color-mix(in srgb, var(--accent-text) 22%, transparent)' : 'var(--bg-tertiary)',
|
||||
color: active ? 'var(--accent-text)' : 'var(--text-faint)',
|
||||
padding: '1px 5px', borderRadius: 99, minWidth: 14, textAlign: 'center',
|
||||
}}>
|
||||
{counts[f.id]}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Suchfeld */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -237,7 +440,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onChange={e => { setSearch(e.target.value); if (selectMode) setSelectedIds(new Set()) }}
|
||||
placeholder={t('places.search')}
|
||||
style={{
|
||||
width: '100%', padding: '7px 30px 7px 30px', borderRadius: 10,
|
||||
@@ -257,12 +460,12 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const label = categoryFilters.size === 0
|
||||
? t('places.allCategories')
|
||||
: categoryFilters.size === 1
|
||||
? categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories')
|
||||
? (categoryFilters.has('uncategorized') ? t('places.noCategory') : categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories'))
|
||||
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
||||
return (
|
||||
<div style={{ marginTop: 6, position: 'relative' }}>
|
||||
<div style={{ marginTop: 6, position: 'relative', display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||
<button onClick={() => setCatDropOpen(v => !v)} style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
@@ -270,6 +473,41 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
<ChevronDown size={12} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
{canEditPlaces && (
|
||||
<Tooltip label={t('common.select')} placement="bottom">
|
||||
<button
|
||||
onClick={() => { setSelectMode(v => !v); setSelectedIds(new Set()) }}
|
||||
aria-label={t('common.select')}
|
||||
aria-pressed={selectMode}
|
||||
style={{
|
||||
position: 'relative', width: 30, flexShrink: 0, borderRadius: 8,
|
||||
border: `1px solid ${selectMode ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||
background: selectMode ? 'color-mix(in srgb, var(--accent) 14%, transparent)' : 'var(--bg-card)',
|
||||
color: selectMode ? 'var(--accent)' : 'var(--text-faint)',
|
||||
cursor: 'pointer', fontFamily: 'inherit', padding: 0,
|
||||
transition: 'background 0.18s, color 0.18s, border-color 0.18s',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'opacity 0.18s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
opacity: selectMode ? 0 : 1,
|
||||
transform: selectMode ? 'rotate(-90deg) scale(0.6)' : 'rotate(0) scale(1)',
|
||||
}}>
|
||||
<Check size={13} strokeWidth={2.4} />
|
||||
</span>
|
||||
<span style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'opacity 0.18s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
opacity: selectMode ? 1 : 0,
|
||||
transform: selectMode ? 'rotate(0) scale(1)' : 'rotate(90deg) scale(0.6)',
|
||||
}}>
|
||||
<X size={13} strokeWidth={2.4} />
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{catDropOpen && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
|
||||
@@ -300,6 +538,29 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{places.some(p => p.category_id == null) && (() => {
|
||||
const active = categoryFilters.has('uncategorized')
|
||||
return (
|
||||
<button onClick={() => toggleCategoryFilter('uncategorized')} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||
fontFamily: 'inherit', fontSize: 12, color: 'var(--text-muted)',
|
||||
textAlign: 'left', borderTop: '1px solid var(--border-faint)', marginTop: 2,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: active ? 'none' : '1.5px solid var(--border-primary)',
|
||||
background: active ? 'var(--text-faint)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{active && <Check size={10} strokeWidth={3} color="white" />}
|
||||
</div>
|
||||
<MapPin size={12} strokeWidth={2} color="var(--text-faint)" />
|
||||
<span style={{ flex: 1 }}>{t('places.noCategory')}</span>
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
{categoryFilters.size > 0 && (
|
||||
<button onClick={() => { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.(new Set()) }} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
@@ -317,13 +578,65 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Anzahl */}
|
||||
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
|
||||
</div>
|
||||
{/* Anzahl / Auswahl-Leiste */}
|
||||
{selectMode ? (
|
||||
<div style={{
|
||||
margin: '6px 16px', padding: '5px 8px 5px 10px', borderRadius: 8,
|
||||
background: 'color-mix(in srgb, var(--accent) 10%, transparent)',
|
||||
display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0, fontSize: 11,
|
||||
}}>
|
||||
<span style={{ flex: 1, color: 'var(--accent)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{t('places.selectionCount', { count: selectedIds.size })}
|
||||
</span>
|
||||
<Tooltip label={selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')} placement="bottom">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedIds.size === filtered.length) setSelectedIds(new Set())
|
||||
else setSelectedIds(new Set(filtered.map(p => p.id)))
|
||||
}}
|
||||
aria-label={selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 24, height: 24, borderRadius: 6, border: 'none',
|
||||
background: 'transparent', color: 'var(--text-muted)', cursor: 'pointer', padding: 0,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<Check size={13} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('places.deleteSelected')} placement="bottom">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedIds.size === 0) return
|
||||
if (isMobile) setPendingDeleteIds(Array.from(selectedIds))
|
||||
else onBulkDeletePlaces?.(Array.from(selectedIds))
|
||||
}}
|
||||
disabled={selectedIds.size === 0}
|
||||
aria-label={t('places.deleteSelected')}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 24, height: 24, borderRadius: 6, border: 'none',
|
||||
background: 'transparent',
|
||||
color: selectedIds.size > 0 ? '#ef4444' : 'var(--text-faint)',
|
||||
cursor: selectedIds.size > 0 ? 'pointer' : 'default', padding: 0,
|
||||
}}
|
||||
onMouseEnter={e => { if (selectedIds.size > 0) e.currentTarget.style.background = 'color-mix(in srgb, #ef4444 14%, transparent)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
|
||||
@@ -337,82 +650,29 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
filtered.map(place => {
|
||||
const cat = categories.find(c => c.id === place.category_id)
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const inDay = isAssignedToSelectedDay(place.id)
|
||||
const isPlanned = plannedIds.has(place.id)
|
||||
|
||||
const inDay = inDaySet.has(place.id)
|
||||
const isChecked = selectedIds.has(place.id)
|
||||
return (
|
||||
<div
|
||||
<MemoPlaceRow
|
||||
key={place.id}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('placeId', String(place.id))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
// Backup in window für Cross-Component Drag (dataTransfer geht bei Re-Render verloren)
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
setDayPickerPlace(place)
|
||||
} else {
|
||||
onPlaceClick(isSelected ? null : place.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 14px 9px 16px',
|
||||
cursor: 'grab',
|
||||
background: isSelected ? 'var(--border-faint)' : 'transparent',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
transition: 'background 0.1s',
|
||||
contentVisibility: 'auto',
|
||||
containIntrinsicSize: '0 52px',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<PlaceAvatar place={place} category={cat} size={34} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden' }}>
|
||||
{cat && (() => {
|
||||
const CatIcon = getCategoryIcon(cat.icon)
|
||||
return <CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} style={{ flexShrink: 0 }} title={cat.name} />
|
||||
})()}
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{place.name}
|
||||
</span>
|
||||
</div>
|
||||
{(place.description || place.address || cat?.name) && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
|
||||
{place.description || place.address || cat?.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||
{!inDay && selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 20, height: 20, borderRadius: 6,
|
||||
background: 'var(--bg-hover)', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text-faint)', padding: 0, transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
><Plus size={12} strokeWidth={2.5} /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
place={place}
|
||||
category={cat}
|
||||
isSelected={isSelected}
|
||||
isPlanned={isPlanned}
|
||||
inDay={inDay}
|
||||
isChecked={isChecked}
|
||||
selectMode={selectMode}
|
||||
selectedDayId={selectedDayId}
|
||||
canEditPlaces={canEditPlaces}
|
||||
isMobile={isMobile}
|
||||
t={t}
|
||||
onPlaceClick={onPlaceClick}
|
||||
onContextMenu={openContextMenu}
|
||||
onAssignToDay={onAssignToDay}
|
||||
toggleSelected={toggleSelected}
|
||||
setDayPickerPlace={setDayPickerPlace}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
@@ -576,6 +836,14 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
initialFile={sidebarDropFile}
|
||||
/>
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
{isMobile && (
|
||||
<ConfirmDialog
|
||||
isOpen={!!pendingDeleteIds?.length}
|
||||
onClose={() => setPendingDeleteIds(null)}
|
||||
onConfirm={() => { onBulkDeleteConfirm?.(pendingDeleteIds!); setPendingDeleteIds(null) }}
|
||||
message={t('trip.confirm.deletePlaces', { count: pendingDeleteIds?.length ?? 0 })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('ReservationModal', () => {
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-003: shows "Edit Reservation" title when editing', () => {
|
||||
const res = buildReservation({ title: 'Flight NY', type: 'flight' });
|
||||
const res = buildReservation({ title: 'Nice Dinner', type: 'restaurant' });
|
||||
render(<ReservationModal {...defaultProps} reservation={res} />);
|
||||
expect(screen.getByText(/Edit Reservation/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -101,49 +101,40 @@ describe('ReservationModal', () => {
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-005: all 9 type buttons are visible', () => {
|
||||
it('FE-PLANNER-RESMODAL-005: all 5 type buttons are visible (transport types removed)', () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: /Flight/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Accommodation/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Restaurant/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Train/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Rental Car/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Cruise/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Event/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Tour/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Other/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Flight$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Train$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Car$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Cruise$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Type selection ──────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESMODAL-006: clicking Flight type button shows flight-specific fields', async () => {
|
||||
it('FE-PLANNER-RESMODAL-006: clicking Event type button activates it', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
|
||||
// Flight-specific airline field has placeholder="Lufthansa" (exact, not the title placeholder)
|
||||
expect(screen.getByPlaceholderText('Lufthansa')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-007: flight type shows airline/airport fields', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
|
||||
expect(screen.getByText(/Airline/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/^From$/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/^To$/i)).toBeInTheDocument();
|
||||
const eventBtn = screen.getByRole('button', { name: /Event/i });
|
||||
await userEvent.click(eventBtn);
|
||||
expect(eventBtn).toHaveStyle({ background: 'var(--text-primary)' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-008: hotel type shows check-in/check-out time fields', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i }));
|
||||
expect(screen.getByText(/Check-in/i)).toBeInTheDocument();
|
||||
const checkInLabels = screen.getAllByText(/Check-in/i);
|
||||
expect(checkInLabels.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText(/Check-out/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-009: train type shows train number/platform/seat fields', async () => {
|
||||
it('FE-PLANNER-RESMODAL-009: restaurant type shows location field', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Train/i }));
|
||||
expect(screen.getByText(/Train No\./i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Platform/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Seat/i)).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restaurant/i }));
|
||||
expect(screen.getByPlaceholderText(/Address, Airport/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-010: hotel type hides assignment picker', async () => {
|
||||
@@ -182,13 +173,10 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByDisplayValue('Breakfast included')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-014: editing pre-fills type — train type does not show flight fields', () => {
|
||||
const res = buildReservation({ type: 'train' });
|
||||
it('FE-PLANNER-RESMODAL-014: editing pre-fills type — restaurant type shows location field', () => {
|
||||
const res = buildReservation({ type: 'restaurant', location: 'Via Roma 1' });
|
||||
render(<ReservationModal {...defaultProps} reservation={res} />);
|
||||
// Flight-specific airline input has placeholder="Lufthansa" (exact) — should NOT appear for train type
|
||||
expect(screen.queryByPlaceholderText('Lufthansa')).not.toBeInTheDocument();
|
||||
// Train fields should appear
|
||||
expect(screen.getByText(/Train No\./i)).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Via Roma 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Validation ──────────────────────────────────────────────────────────────
|
||||
@@ -231,18 +219,18 @@ describe('ReservationModal', () => {
|
||||
|
||||
// ── Submit flow ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESMODAL-016: submitting valid flight calls onSave with correct shape', async () => {
|
||||
it('FE-PLANNER-RESMODAL-016: submitting valid restaurant booking calls onSave with correct shape', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Air France 777');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restaurant/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Le Jules Verne');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Air France 777', type: 'flight' })
|
||||
expect.objectContaining({ title: 'Le Jules Verne', type: 'restaurant' })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -438,17 +426,17 @@ describe('ReservationModal', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-031: train type — saving calls onSave with train type', async () => {
|
||||
it('FE-PLANNER-RESMODAL-031: event type — saving calls onSave with event type', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Train/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Eurostar Paris');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Event/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Louvre Museum');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Eurostar Paris', type: 'train' })
|
||||
expect.objectContaining({ title: 'Louvre Museum', type: 'event' })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -472,7 +460,7 @@ describe('ReservationModal', () => {
|
||||
|
||||
it('FE-PLANNER-RESMODAL-036: file upload to existing reservation calls onFileUpload', async () => {
|
||||
const onFileUpload = vi.fn().mockResolvedValue(undefined);
|
||||
const res = buildReservation({ id: 10, title: 'My Trip', type: 'flight' });
|
||||
const res = buildReservation({ id: 10, title: 'My Trip', type: 'other' });
|
||||
render(
|
||||
<ReservationModal
|
||||
{...defaultProps}
|
||||
@@ -574,30 +562,18 @@ describe('ReservationModal', () => {
|
||||
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-042: flight type metadata saved with airline and airports', async () => {
|
||||
it('FE-PLANNER-RESMODAL-042: hotel type metadata saved with check-in time', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'AF 447');
|
||||
await userEvent.type(screen.getByPlaceholderText('Lufthansa'), 'Air France');
|
||||
await userEvent.type(screen.getByPlaceholderText('LH 123'), 'AF 447');
|
||||
await userEvent.type(screen.getByPlaceholderText('FRA'), 'CDG');
|
||||
await userEvent.type(screen.getByPlaceholderText('NRT'), 'JFK');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Grand Hotel');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'flight',
|
||||
metadata: expect.objectContaining({
|
||||
airline: 'Air France',
|
||||
flight_number: 'AF 447',
|
||||
departure_airport: 'CDG',
|
||||
arrival_airport: 'JFK',
|
||||
}),
|
||||
})
|
||||
expect.objectContaining({ title: 'Grand Hotel', type: 'hotel' })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -637,22 +613,21 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-045: car type shows date/time section', async () => {
|
||||
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Rental Car/i }));
|
||||
// Car type still shows date fields (not hotel which hides them)
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('date-picker').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-046: cruise type renders and saves correctly', async () => {
|
||||
it('FE-PLANNER-RESMODAL-046: other type renders and saves correctly', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Cruise/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Caribbean Cruise');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Other$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Misc item');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'cruise' })));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-047: clicking budget category select changes the value', async () => {
|
||||
@@ -733,23 +708,17 @@ describe('ReservationModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-035: flight with train number metadata saved correctly', async () => {
|
||||
it('FE-PLANNER-RESMODAL-035: hotel type saves correctly', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Train/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE 792');
|
||||
await userEvent.type(screen.getByPlaceholderText(/ICE 123/i), 'ICE 792');
|
||||
await userEvent.type(screen.getByPlaceholderText(/^12$/i), '5');
|
||||
await userEvent.type(screen.getByPlaceholderText(/42A/i), '14B');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Test');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'train',
|
||||
metadata: expect.objectContaining({ train_number: 'ICE 792', platform: '5', seat: '14B' }),
|
||||
})
|
||||
expect.objectContaining({ type: 'hotel' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
||||
import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
@@ -14,12 +14,8 @@ import { openFile } from '../../utils/fileDownload'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
||||
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils },
|
||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
||||
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
|
||||
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
|
||||
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket },
|
||||
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users },
|
||||
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
|
||||
@@ -33,7 +29,6 @@ function buildAssignmentOptions(days, assignments, t, locale) {
|
||||
const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number })
|
||||
const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : ''
|
||||
const groupLabel = `${dayLabel}${dateStr}`
|
||||
// Group header (non-selectable)
|
||||
options.push({ value: `_header_${day.id}`, label: groupLabel, disabled: true, isHeader: true })
|
||||
for (let i = 0; i < da.length; i++) {
|
||||
const place = da[i].place
|
||||
@@ -64,9 +59,10 @@ interface ReservationModalProps {
|
||||
onFileUpload?: (fd: FormData) => Promise<void>
|
||||
onFileDelete: (fileId: number) => Promise<void>
|
||||
accommodations?: Accommodation[]
|
||||
defaultAssignmentId?: number | null
|
||||
}
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) {
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null }: ReservationModalProps) {
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
const loadFiles = useTripStore(s => s.loadFiles)
|
||||
const toast = useToast()
|
||||
@@ -84,20 +80,16 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const [form, setForm] = useState({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: '', accommodation_id: '',
|
||||
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
||||
price: '', budget_category: '',
|
||||
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
|
||||
meta_departure_timezone: '', meta_arrival_timezone: '',
|
||||
meta_train_number: '', meta_platform: '', meta_seat: '',
|
||||
meta_check_in_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
||||
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
|
||||
|
||||
const assignmentOptions = useMemo(
|
||||
() => buildAssignmentOptions(days, assignments, t, locale),
|
||||
@@ -107,7 +99,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
useEffect(() => {
|
||||
if (reservation) {
|
||||
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
|
||||
// Parse end_date from reservation_end_time if it's a full ISO datetime
|
||||
const rawEnd = reservation.reservation_end_time || ''
|
||||
let endDate = ''
|
||||
let endTime = rawEnd
|
||||
@@ -130,16 +121,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
notes: reservation.notes || '',
|
||||
assignment_id: reservation.assignment_id || '',
|
||||
accommodation_id: reservation.accommodation_id || '',
|
||||
meta_airline: meta.airline || '',
|
||||
meta_flight_number: meta.flight_number || '',
|
||||
meta_departure_airport: meta.departure_airport || '',
|
||||
meta_arrival_airport: meta.arrival_airport || '',
|
||||
meta_departure_timezone: meta.departure_timezone || '',
|
||||
meta_arrival_timezone: meta.arrival_timezone || '',
|
||||
meta_train_number: meta.train_number || '',
|
||||
meta_platform: meta.platform || '',
|
||||
meta_seat: meta.seat || '',
|
||||
meta_check_in_time: meta.check_in_time || '',
|
||||
meta_check_in_end_time: meta.check_in_end_time || '',
|
||||
meta_check_out_time: meta.check_out_time || '',
|
||||
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
|
||||
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
|
||||
@@ -151,41 +134,22 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
setForm({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: '', accommodation_id: '',
|
||||
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
||||
price: '', budget_category: '',
|
||||
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
|
||||
meta_departure_timezone: '', meta_arrival_timezone: '',
|
||||
meta_train_number: '', meta_platform: '', meta_seat: '',
|
||||
meta_check_in_time: '', meta_check_out_time: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||
})
|
||||
setPendingFiles([])
|
||||
}
|
||||
}, [reservation, isOpen, selectedDayId])
|
||||
}, [reservation, isOpen, selectedDayId, defaultAssignmentId])
|
||||
|
||||
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
// Validate that end datetime is after start datetime
|
||||
const isEndBeforeStart = (() => {
|
||||
if (!form.end_date || !form.reservation_time) return false
|
||||
const startDate = form.reservation_time.split('T')[0]
|
||||
const startTime = form.reservation_time.split('T')[1] || '00:00'
|
||||
const endTime = form.reservation_end_time || '00:00'
|
||||
// For flights, compare in UTC using timezone offsets
|
||||
if (form.type === 'flight') {
|
||||
const parseOffset = (tz: string): number | null => {
|
||||
if (!tz) return null
|
||||
const m = tz.trim().match(/^(?:UTC|GMT)?\s*([+-])(\d{1,2})(?::(\d{2}))?$/i)
|
||||
if (!m) return null
|
||||
const sign = m[1] === '+' ? 1 : -1
|
||||
return sign * (parseInt(m[2]) * 60 + parseInt(m[3] || '0'))
|
||||
}
|
||||
const depOffset = parseOffset(form.meta_departure_timezone)
|
||||
const arrOffset = parseOffset(form.meta_arrival_timezone)
|
||||
if (depOffset === null || arrOffset === null) return false
|
||||
const depMinutes = new Date(`${startDate}T${startTime}`).getTime() - depOffset * 60000
|
||||
const arrMinutes = new Date(`${form.end_date}T${endTime}`).getTime() - arrOffset * 60000
|
||||
return arrMinutes <= depMinutes
|
||||
}
|
||||
const startFull = `${startDate}T${startTime}`
|
||||
const endFull = `${form.end_date}T${endTime}`
|
||||
return endFull <= startFull
|
||||
@@ -198,22 +162,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const metadata: Record<string, string> = {}
|
||||
if (form.type === 'flight') {
|
||||
if (form.meta_airline) metadata.airline = form.meta_airline
|
||||
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
|
||||
if (form.meta_departure_airport) metadata.departure_airport = form.meta_departure_airport
|
||||
if (form.meta_arrival_airport) metadata.arrival_airport = form.meta_arrival_airport
|
||||
if (form.meta_departure_timezone) metadata.departure_timezone = form.meta_departure_timezone
|
||||
if (form.meta_arrival_timezone) metadata.arrival_timezone = form.meta_arrival_timezone
|
||||
} else if (form.type === 'hotel') {
|
||||
if (form.type === 'hotel') {
|
||||
if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time
|
||||
if (form.meta_check_in_end_time) metadata.check_in_end_time = form.meta_check_in_end_time
|
||||
if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time
|
||||
} else if (form.type === 'train') {
|
||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||
}
|
||||
// Combine end_date + end_time into reservation_end_time
|
||||
let combinedEndTime = form.reservation_end_time
|
||||
if (form.end_date) {
|
||||
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
||||
@@ -222,29 +175,31 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
if (form.price) metadata.price = form.price
|
||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||
}
|
||||
|
||||
const saveData: Record<string, any> = {
|
||||
title: form.title, type: form.type, status: form.status,
|
||||
reservation_time: form.type === 'hotel' ? null : form.reservation_time,
|
||||
reservation_end_time: form.type === 'hotel' ? null : combinedEndTime,
|
||||
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
|
||||
reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null),
|
||||
location: form.location, confirmation_number: form.confirmation_number,
|
||||
notes: form.notes,
|
||||
assignment_id: form.assignment_id || null,
|
||||
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
endpoints: [],
|
||||
needs_review: false,
|
||||
}
|
||||
// Auto-create/update budget entry if price is set, or signal removal if cleared
|
||||
if (isBudgetEnabled) {
|
||||
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||
: { total_price: 0 }
|
||||
}
|
||||
// If hotel with place + days, pass hotel data for auto-creation or update
|
||||
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
|
||||
saveData.create_accommodation = {
|
||||
place_id: form.hotel_place_id,
|
||||
start_day_id: form.hotel_start_day,
|
||||
end_day_id: form.hotel_end_day,
|
||||
check_in: form.meta_check_in_time || null,
|
||||
check_in_end: form.meta_check_in_end_time || null,
|
||||
check_out: form.meta_check_out_time || null,
|
||||
confirmation: form.confirmation_number || null,
|
||||
}
|
||||
@@ -335,7 +290,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
|
||||
{/* Assignment Picker (hidden for hotels) */}
|
||||
{form.type !== 'hotel' && assignmentOptions.length > 0 && (
|
||||
<div>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
|
||||
@@ -362,126 +317,88 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Start Date/Time + End Date/Time + Status (hidden for hotels) */}
|
||||
{form.type !== 'hotel' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}</label>
|
||||
<CustomDatePicker
|
||||
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
|
||||
onChange={d => {
|
||||
const [, t] = (form.reservation_time || '').split('T')
|
||||
set('reservation_time', d ? (t ? `${d}T${t}` : d) : '')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
|
||||
onChange={t => {
|
||||
const [d] = (form.reservation_time || '').split('T')
|
||||
const selectedDay = days.find(dy => dy.id === selectedDayId)
|
||||
const date = d || selectedDay?.date || new Date().toISOString().split('T')[0]
|
||||
set('reservation_time', t ? `${date}T${t}` : date)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{form.type === 'flight' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.meta.departureTimezone')}</label>
|
||||
<input type="text" value={form.meta_departure_timezone} onChange={e => set('meta_departure_timezone', e.target.value)}
|
||||
placeholder="e.g. CET, UTC+1" style={inputStyle} />
|
||||
<label style={labelStyle}>{t('reservations.date')}</label>
|
||||
<CustomDatePicker
|
||||
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
|
||||
onChange={d => {
|
||||
const [, tm] = (form.reservation_time || '').split('T')
|
||||
set('reservation_time', d ? (tm ? `${d}T${tm}` : d) : '')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}</label>
|
||||
<CustomDatePicker
|
||||
value={form.end_date}
|
||||
onChange={d => set('end_date', d || '')}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label>
|
||||
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
|
||||
</div>
|
||||
{form.type === 'flight' && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
|
||||
<input type="text" value={form.meta_arrival_timezone} onChange={e => set('meta_arrival_timezone', e.target.value)}
|
||||
placeholder="e.g. JST, UTC+9" style={inputStyle} />
|
||||
<label style={labelStyle}>{t('reservations.startTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={(() => { const [, tm] = (form.reservation_time || '').split('T'); return tm || '' })()}
|
||||
onChange={tm => {
|
||||
const [d] = (form.reservation_time || '').split('T')
|
||||
const selectedDay = days.find(dy => dy.id === selectedDayId)
|
||||
const date = d || selectedDay?.date || new Date().toISOString().split('T')[0]
|
||||
set('reservation_time', tm ? `${date}T${tm}` : date)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isEndBeforeStart && (
|
||||
<div style={{ fontSize: 11, color: '#ef4444', marginTop: -6 }}>{t('reservations.validation.endBeforeStart')}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||
<CustomSelect
|
||||
value={form.status}
|
||||
onChange={value => set('status', value)}
|
||||
options={[
|
||||
{ value: 'pending', label: t('reservations.pending') },
|
||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.endDate')}</label>
|
||||
<CustomDatePicker
|
||||
value={form.end_date}
|
||||
onChange={d => set('end_date', d || '')}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.endTime')}</label>
|
||||
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
|
||||
</div>
|
||||
</div>
|
||||
{isEndBeforeStart && (
|
||||
<div style={{ fontSize: 11, color: '#ef4444', marginTop: -6 }}>{t('reservations.validation.endBeforeStart')}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Location + Booking Code */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* Location */}
|
||||
{form.type !== 'hotel' && (
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.locationAddress')}</label>
|
||||
<input type="text" value={form.location} onChange={e => set('location', e.target.value)}
|
||||
placeholder={t('reservations.locationPlaceholder')} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Booking Code + Status */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.confirmationCode')}</label>
|
||||
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
|
||||
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||
<CustomSelect
|
||||
value={form.status}
|
||||
onChange={value => set('status', value)}
|
||||
options={[
|
||||
{ value: 'pending', label: t('reservations.pending') },
|
||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type-specific fields */}
|
||||
{form.type === 'flight' && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.airline') || 'Airline'}</label>
|
||||
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
|
||||
placeholder="Lufthansa" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.flightNumber') || 'Flight No.'}</label>
|
||||
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
|
||||
placeholder="LH 123" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.from') || 'From'}</label>
|
||||
<input type="text" value={form.meta_departure_airport} onChange={e => set('meta_departure_airport', e.target.value)}
|
||||
placeholder="FRA" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.to') || 'To'}</label>
|
||||
<input type="text" value={form.meta_arrival_airport} onChange={e => set('meta_arrival_airport', e.target.value)}
|
||||
placeholder="NRT" style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hotel fields */}
|
||||
{form.type === 'hotel' && (
|
||||
<>
|
||||
{/* Hotel place + day range */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.hotelPlace')}</label>
|
||||
@@ -525,52 +442,23 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Check-in/out times + Status */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.checkIn')}</label>
|
||||
<CustomTimePicker value={form.meta_check_in_time} onChange={v => set('meta_check_in_time', v)} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.checkInUntil')}</label>
|
||||
<CustomTimePicker value={form.meta_check_in_end_time} onChange={v => set('meta_check_in_end_time', v)} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.checkOut')}</label>
|
||||
<CustomTimePicker value={form.meta_check_out_time} onChange={v => set('meta_check_out_time', v)} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||
<CustomSelect
|
||||
value={form.status}
|
||||
onChange={value => set('status', value)}
|
||||
options={[
|
||||
{ value: 'pending', label: t('reservations.pending') },
|
||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form.type === 'train' && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.trainNumber') || 'Train No.'}</label>
|
||||
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
|
||||
placeholder="ICE 123" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.platform') || 'Platform'}</label>
|
||||
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
|
||||
placeholder="12" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.seat') || 'Seat'}</label>
|
||||
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
|
||||
placeholder="42A" style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.notes')}</label>
|
||||
@@ -589,12 +477,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
|
||||
<button type="button" onClick={async () => {
|
||||
// Always unlink, never delete the file
|
||||
// Clear primary reservation_id if it points to this reservation
|
||||
if (f.reservation_id === reservation?.id) {
|
||||
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
|
||||
}
|
||||
// Remove from file_links if linked there
|
||||
try {
|
||||
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
|
||||
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
|
||||
@@ -627,7 +512,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>}
|
||||
{/* Link existing file picker */}
|
||||
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
|
||||
@@ -671,7 +555,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price + Budget Category — only shown when budget addon is enabled */}
|
||||
{/* Price + Budget Category */}
|
||||
{isBudgetEnabled && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
@@ -679,7 +563,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<label style={labelStyle}>{t('reservations.price')}</label>
|
||||
<input type="text" inputMode="decimal" value={form.price}
|
||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
||||
onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } set('price', t) }}
|
||||
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
||||
placeholder="0.00"
|
||||
style={inputStyle} />
|
||||
</div>
|
||||
|
||||
@@ -91,12 +91,12 @@ describe('ReservationsPanel', () => {
|
||||
expect(els.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-RES-010: shows summary text with confirmed and pending counts', () => {
|
||||
const r1 = buildReservation({ title: 'Flight', type: 'flight', status: 'confirmed' });
|
||||
const r2 = buildReservation({ title: 'Hotel', type: 'hotel', status: 'pending' });
|
||||
it('FE-COMP-RES-010: shows reservations title and cards', () => {
|
||||
const r1 = buildReservation({ title: 'My Flight Booking', type: 'flight', status: 'confirmed' });
|
||||
const r2 = buildReservation({ title: 'Grand Hotel', type: 'hotel', status: 'pending' });
|
||||
render(<ReservationsPanel {...defaultProps} reservations={[r1, r2]} />);
|
||||
// reservations.summary = "{confirmed} confirmed, {pending} pending"
|
||||
expect(screen.getByText(/1 confirmed, 1 pending/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('My Flight Booking')).toBeInTheDocument();
|
||||
expect(screen.getByText('Grand Hotel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-RES-011: hotel reservation renders', () => {
|
||||
@@ -288,27 +288,14 @@ describe('ReservationsPanel', () => {
|
||||
|
||||
// ── Status toggle (canEdit=true) ────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESP-030: status label is a button when canEdit=true', () => {
|
||||
// Default: permissions empty → canEdit=true
|
||||
it('FE-PLANNER-RESP-030: status label is always a span (not clickable)', () => {
|
||||
const res = buildReservation({ title: 'My Booking', status: 'pending' });
|
||||
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
||||
// Status badge in card header is a button
|
||||
const pendingEls = screen.getAllByText('Pending');
|
||||
const statusSpan = pendingEls.find(el => el.tagName === 'SPAN');
|
||||
expect(statusSpan).toBeDefined();
|
||||
const statusBtn = pendingEls.find(el => el.tagName === 'BUTTON');
|
||||
expect(statusBtn).toBeDefined();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESP-031: clicking status button calls toggleReservationStatus', async () => {
|
||||
const user = userEvent.setup();
|
||||
const toggleReservationStatus = vi.fn().mockResolvedValue(undefined);
|
||||
// Seed the store with a mock toggleReservationStatus function
|
||||
useTripStore.setState({ toggleReservationStatus } as any);
|
||||
const res = buildReservation({ id: 42, title: 'Toggle Me', status: 'pending' });
|
||||
render(<ReservationsPanel {...defaultProps} tripId={1} reservations={[res]} />);
|
||||
const pendingEls = screen.getAllByText('Pending');
|
||||
const statusBtn = pendingEls.find(el => el.tagName === 'BUTTON');
|
||||
await user.click(statusBtn!);
|
||||
await waitFor(() => expect(toggleReservationStatus).toHaveBeenCalledWith(1, 42));
|
||||
expect(statusBtn).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Status (canEdit=false) ──────────────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
@@ -8,7 +8,7 @@ import { useTranslation } from '../../i18n'
|
||||
import {
|
||||
Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin,
|
||||
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
|
||||
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
|
||||
ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
||||
@@ -50,6 +50,16 @@ function buildAssignmentLookup(days, assignments) {
|
||||
return map
|
||||
}
|
||||
|
||||
/* ── Shared field label style ── */
|
||||
const fieldLabelStyle: React.CSSProperties = {
|
||||
fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
color: 'var(--text-faint)', marginBottom: 5,
|
||||
}
|
||||
const fieldValueStyle: React.CSSProperties = {
|
||||
fontSize: 13, fontWeight: 500, color: 'var(--text-primary)',
|
||||
padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 10,
|
||||
}
|
||||
|
||||
interface ReservationCardProps {
|
||||
r: Reservation
|
||||
tripId: number
|
||||
@@ -59,9 +69,10 @@ interface ReservationCardProps {
|
||||
onNavigateToFiles: () => void
|
||||
assignmentLookup: Record<number, AssignmentLookupEntry>
|
||||
canEdit: boolean
|
||||
days?: Day[]
|
||||
}
|
||||
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit }: ReservationCardProps) {
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [] }: ReservationCardProps) {
|
||||
const { toggleReservationStatus } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -84,184 +95,269 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const fmtDate = (str) => {
|
||||
const dateOnly = str.includes('T') ? str.split('T')[0] : str
|
||||
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
const fmtTime = (str) => {
|
||||
const d = new Date(str)
|
||||
return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||
}
|
||||
|
||||
const hasDate = !!r.reservation_time
|
||||
const hasTime = r.reservation_time?.includes('T')
|
||||
const hasCode = !!r.confirmation_number
|
||||
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
|
||||
|
||||
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
|
||||
const startDay = r.day_id ? days.find(d => d.id === r.day_id) : undefined
|
||||
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id) : undefined
|
||||
const dayLabel = (day: typeof startDay): string => {
|
||||
if (!day) return ''
|
||||
const base = day.title || t('dayplan.dayN', { n: day.day_number })
|
||||
if (day.date) {
|
||||
const d = new Date(day.date + 'T00:00:00Z')
|
||||
const dateStr = d.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
return `${base} · ${dateStr}`
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
|
||||
{/* Header bar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)' }}>
|
||||
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
|
||||
{canEdit ? (
|
||||
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', padding: 0 }}>
|
||||
<div style={{
|
||||
borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
border: `1px solid ${confirmed ? 'rgba(22,163,74,0.25)' : 'rgba(217,119,6,0.25)'}`,
|
||||
background: 'var(--bg-card)',
|
||||
transition: 'box-shadow 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
|
||||
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
||||
padding: '12px 14px',
|
||||
background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)',
|
||||
}}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706',
|
||||
}}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
fontSize: 12, color: 'var(--text-muted)',
|
||||
padding: '3px 8px', borderRadius: 6,
|
||||
background: 'var(--bg-secondary)',
|
||||
}}>
|
||||
<TypeIcon size={12} style={{ color: typeInfo.color }} />
|
||||
{t(typeInfo.labelKey)}
|
||||
</span>
|
||||
{r.needs_review ? (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 11, fontWeight: 600, color: '#b45309',
|
||||
padding: '3px 8px', borderRadius: 6,
|
||||
background: 'rgba(245,158,11,0.12)',
|
||||
}} title={t('reservations.needsReviewHint')}>
|
||||
<AlertCircle size={11} />
|
||||
{t('reservations.needsReview')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginRight: 6,
|
||||
maxWidth: 140, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>{r.title}</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{
|
||||
appearance: 'none', border: 'none', background: 'transparent',
|
||||
width: 26, height: 26, borderRadius: 6, display: 'grid', placeItems: 'center',
|
||||
cursor: 'pointer', color: 'var(--text-faint)', flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0,0,0,0.05)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} style={{
|
||||
appearance: 'none', border: 'none', background: 'transparent',
|
||||
width: 26, height: 26, borderRadius: 6, display: 'grid', placeItems: 'center',
|
||||
cursor: 'pointer', color: 'var(--text-faint)', flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239,68,68,0.08)'; e.currentTarget.style.color = '#ef4444' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
|
||||
{/* Day label for transport reservations linked to a day */}
|
||||
{isTransportType && startDay && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{dayLabel(startDay)}{endDay && endDay.id !== startDay.id ? ` – ${dayLabel(endDay)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ width: 1, height: 10, background: 'var(--border-faint)' }} />
|
||||
<TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
{/* Date / Time row */}
|
||||
{hasDate && (
|
||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}>
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{fmtDate(r.reservation_time)}
|
||||
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
|
||||
<> – {fmtDate(r.reservation_end_time)}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasTime && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.time')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
{/* Booking code */}
|
||||
{hasCode && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.confirmationCode')}</div>
|
||||
<div
|
||||
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
|
||||
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
|
||||
onClick={() => blurCodes && setCodeRevealed(v => !v)}
|
||||
style={{
|
||||
...fieldValueStyle, textAlign: 'center',
|
||||
fontFamily: '"SF Mono", "JetBrains Mono", Menlo, monospace', fontSize: 12.5,
|
||||
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
|
||||
cursor: blurCodes ? 'pointer' : 'default',
|
||||
transition: 'filter 0.2s',
|
||||
}}
|
||||
>
|
||||
{r.confirmation_number}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const eps = r.endpoints || []
|
||||
const from = eps.find(e => e.role === 'from')
|
||||
const to = eps.find(e => e.role === 'to')
|
||||
if (!from || !to) return null
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
padding: '8px 12px', borderRadius: 10,
|
||||
background: 'var(--bg-tertiary)',
|
||||
fontSize: 12.5, color: 'var(--text-primary)',
|
||||
}}>
|
||||
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{from.name}</span>
|
||||
<TypeIcon size={14} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
||||
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{to.name}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Type-specific metadata */}
|
||||
{(() => {
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
if (!meta || Object.keys(meta).length === 0) return null
|
||||
const hasEndpoints = (r.endpoints || []).some(e => e.role === 'from') && (r.endpoints || []).some(e => e.role === 'to')
|
||||
const cells: { label: string; value: string }[] = []
|
||||
if (meta.airline) cells.push({ label: t('reservations.meta.airline'), value: meta.airline })
|
||||
if (meta.flight_number) cells.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number })
|
||||
if (!hasEndpoints && meta.departure_airport) cells.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
|
||||
if (!hasEndpoints && meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
|
||||
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) + (meta.check_in_end_time ? ` – ${fmtTime('2000-01-01T' + meta.check_in_end_time)}` : '') })
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) })
|
||||
if (cells.length === 0) return null
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: cells.length > 1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}>
|
||||
{cells.map((c, i) => (
|
||||
<div key={i}>
|
||||
<div style={fieldLabelStyle}>{c.label}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>{c.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Location / Accommodation / Assignment */}
|
||||
{r.location && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.locationAddress')}</div>
|
||||
<div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400 }}>
|
||||
<MapPin size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{r.accommodation_name && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.meta.linkAccommodation')}</div>
|
||||
<div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400 }}>
|
||||
<Hotel size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.accommodation_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{linked && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.linkAssignment')}</div>
|
||||
<div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400 }}>
|
||||
<Link2 size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{linked.dayTitle || t('dayplan.dayN', { n: linked.dayNumber })} — {linked.placeName}
|
||||
{linked.startTime ? ` · ${linked.startTime}${linked.endTime ? ' – ' + linked.endTime : ''}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{r.notes && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.notes')}</div>
|
||||
<div style={{ ...fieldValueStyle, fontWeight: 400, lineHeight: 1.5 }}>{r.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('files.title')}</div>
|
||||
<div style={{ ...fieldValueStyle, display: 'flex', flexDirection: 'column', gap: 4, padding: '6px 10px' }}>
|
||||
{attachedFiles.map(f => (
|
||||
<a key={f.id} href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ display: 'flex', alignItems: 'center', gap: 5, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<FileText size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
{(r.reservation_time || r.confirmation_number || r.location || linked || r.metadata) && (
|
||||
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{/* Row 1: Date, Time, Code */}
|
||||
{(r.reservation_time || r.confirmation_number) && (
|
||||
<div style={{ display: 'flex', gap: 0, borderRadius: 8, overflow: 'hidden', background: 'var(--bg-secondary)', boxShadow: '0 1px 6px rgba(0,0,0,0.08)' }}>
|
||||
{r.reservation_time && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||
{fmtDate(r.reservation_time)}
|
||||
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
|
||||
<> – {fmtDate(r.reservation_end_time)}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{r.reservation_time?.includes('T') && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{r.confirmation_number && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div>
|
||||
<div
|
||||
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
|
||||
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
|
||||
onClick={() => blurCodes && setCodeRevealed(v => !v)}
|
||||
style={{
|
||||
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1,
|
||||
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
|
||||
cursor: blurCodes ? 'pointer' : 'default',
|
||||
transition: 'filter 0.2s',
|
||||
}}
|
||||
>
|
||||
{r.confirmation_number}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Row 1b: Type-specific metadata */}
|
||||
{(() => {
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
if (!meta || Object.keys(meta).length === 0) return null
|
||||
const cells: { label: string; value: string }[] = []
|
||||
if (meta.airline) cells.push({ label: t('reservations.meta.airline'), value: meta.airline })
|
||||
if (meta.flight_number) cells.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number })
|
||||
if (meta.departure_airport) cells.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
|
||||
if (meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
|
||||
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) })
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) })
|
||||
if (cells.length === 0) return null
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 0, borderRadius: 8, overflow: 'hidden', background: 'var(--bg-secondary)', boxShadow: '0 1px 6px rgba(0,0,0,0.08)' }}>
|
||||
{cells.map((c, i) => (
|
||||
<div key={i} style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: i < cells.length - 1 ? '1px solid var(--border-faint)' : 'none' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{c.label}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{c.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{/* Row 2: Location + Assignment */}
|
||||
{(r.location || linked || r.accommodation_name) && (
|
||||
<div className={`grid grid-cols-1 ${r.location && linked ? 'sm:grid-cols-2' : ''} gap-2`} style={{ paddingTop: 6, borderTop: '1px solid var(--border-faint)' }}>
|
||||
{r.location && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.locationAddress')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<MapPin size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{r.accommodation_name && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.meta.linkAccommodation')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<Hotel size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.accommodation_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{linked && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.linkAssignment')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<Link2 size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{linked.dayTitle || t('dayplan.dayN', { n: linked.dayNumber })} — {linked.placeName}
|
||||
{linked.startTime ? ` · ${linked.startTime}${linked.endTime ? ' – ' + linked.endTime : ''}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{r.notes && (
|
||||
<div style={{ padding: '0 12px 8px' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
|
||||
<div style={{ padding: '5px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||
{r.notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div style={{ padding: '0 12px 8px' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('files.title')}</div>
|
||||
<div style={{ padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{attachedFiles.map(f => (
|
||||
<a key={f.id} href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<FileText size={9} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Delete confirmation popup */}
|
||||
{/* Delete confirmation */}
|
||||
{showDeleteConfirm && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
@@ -311,25 +407,40 @@ interface SectionProps {
|
||||
children: React.ReactNode
|
||||
defaultOpen?: boolean
|
||||
accent: 'green' | string
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
function Section({ title, count, children, defaultOpen = true, accent }: SectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
function Section({ title, count, children, defaultOpen = true, accent, storageKey }: SectionProps) {
|
||||
const [open, setOpen] = useState(() => {
|
||||
if (!storageKey || typeof window === 'undefined') return defaultOpen
|
||||
const stored = window.localStorage.getItem(storageKey)
|
||||
if (stored === null) return defaultOpen
|
||||
return stored === '1'
|
||||
})
|
||||
useEffect(() => {
|
||||
if (!storageKey || typeof window === 'undefined') return
|
||||
window.localStorage.setItem(storageKey, open ? '1' : '0')
|
||||
}, [open, storageKey])
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<button onClick={() => setOpen(o => !o)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 8, fontFamily: 'inherit',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 12, fontFamily: 'inherit',
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
{open ? <ChevronDown size={14} style={{ color: 'var(--text-faint)' }} /> : <ChevronRight size={14} style={{ color: 'var(--text-faint)' }} />}
|
||||
<span style={{ fontWeight: 700, fontSize: 12, color: 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{title}</span>
|
||||
<span style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>{title}</span>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, padding: '1px 7px', borderRadius: 99,
|
||||
background: accent === 'green' ? 'rgba(22,163,74,0.1)' : 'var(--bg-tertiary)',
|
||||
color: accent === 'green' ? '#16a34a' : 'var(--text-faint)',
|
||||
fontSize: 11, fontWeight: 600, padding: '2px 7px', borderRadius: 99,
|
||||
background: 'var(--bg-tertiary)', color: 'var(--text-faint)',
|
||||
minWidth: 20, textAlign: 'center',
|
||||
}}>{count}</span>
|
||||
</button>
|
||||
{open && <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{children}</div>}
|
||||
{open && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(max(33.33% - 14px, 340px), 1fr))', gap: 14, alignItems: 'stretch' }}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -344,64 +455,163 @@ interface ReservationsPanelProps {
|
||||
onEdit: (reservation: Reservation) => void
|
||||
onDelete: (id: number) => void
|
||||
onNavigateToFiles: () => void
|
||||
titleKey?: string
|
||||
addManualKey?: string
|
||||
}
|
||||
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('reservation_edit', trip)
|
||||
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
|
||||
|
||||
const storageKey = `trek-reservation-filters-${tripId}`
|
||||
const [typeFilters, setTypeFilters] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(storageKey)
|
||||
return saved ? new Set(JSON.parse(saved)) : new Set()
|
||||
} catch { return new Set() }
|
||||
})
|
||||
|
||||
const toggleTypeFilter = (type: string) => {
|
||||
setTypeFilters(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(type)) next.delete(type); else next.add(type)
|
||||
sessionStorage.setItem(storageKey, JSON.stringify([...next]))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
|
||||
|
||||
const allPending = reservations.filter(r => r.status !== 'confirmed')
|
||||
const allConfirmed = reservations.filter(r => r.status === 'confirmed')
|
||||
const total = reservations.length
|
||||
const filtered = useMemo(() =>
|
||||
typeFilters.size === 0 ? reservations : reservations.filter(r => typeFilters.has(r.type)),
|
||||
[reservations, typeFilters])
|
||||
|
||||
const allPending = filtered.filter(r => r.status !== 'confirmed')
|
||||
const allConfirmed = filtered.filter(r => r.status === 'confirmed')
|
||||
const total = filtered.length
|
||||
|
||||
const usedTypes = useMemo(() => new Set(reservations.map(r => r.type)), [reservations])
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const r of reservations) counts[r.type] = (counts[r.type] || 0) + 1
|
||||
return counts
|
||||
}, [reservations])
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid var(--border-faint)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.title')}</h2>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>
|
||||
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
|
||||
</p>
|
||||
{/* Unified toolbar */}
|
||||
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
|
||||
<div style={{
|
||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||
padding: '14px 16px 14px 22px',
|
||||
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
{t(titleKey)}
|
||||
</h2>
|
||||
|
||||
{reservations.length > 0 && (
|
||||
<>
|
||||
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
|
||||
<div className="hidden md:inline-flex" style={{ gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
|
||||
<button
|
||||
onClick={() => { setTypeFilters(new Set()); sessionStorage.removeItem(storageKey) }}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
|
||||
background: typeFilters.size === 0 ? 'var(--bg-card)' : 'transparent',
|
||||
color: typeFilters.size === 0 ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
fontWeight: typeFilters.size === 0 ? 500 : 400,
|
||||
boxShadow: typeFilters.size === 0 ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{t('common.all')}
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600,
|
||||
background: typeFilters.size === 0 ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
|
||||
color: 'var(--text-faint)',
|
||||
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
|
||||
}}>{reservations.length}</span>
|
||||
</button>
|
||||
{TYPE_OPTIONS.filter(opt => usedTypes.has(opt.value)).map(opt => {
|
||||
const active = typeFilters.has(opt.value)
|
||||
const Icon = opt.Icon
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => toggleTypeFilter(opt.value)}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
|
||||
background: active ? 'var(--bg-card)' : 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
fontWeight: active ? 500 : 400,
|
||||
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<Icon size={13} style={{ color: active ? opt.color : 'var(--text-faint)' }} />
|
||||
{t(opt.labelKey)}
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600,
|
||||
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
|
||||
color: 'var(--text-faint)',
|
||||
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
|
||||
}}>{typeCounts[opt.value] || 0}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<button onClick={onAdd} style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
marginLeft: 'auto',
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.5} />
|
||||
<span className="hidden sm:inline">{t(addManualKey)}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button onClick={onAdd} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
|
||||
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
|
||||
{total === 0 ? (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 28px 80px' }} className="max-md:!px-4 max-md:!pt-4">
|
||||
{total === 0 && reservations.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
<BookMarked size={36} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('reservations.empty')}</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p>
|
||||
</div>
|
||||
) : total === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-faint)' }}>{t('places.noneFound')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{allPending.length > 0 && (
|
||||
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||
</div>
|
||||
<Section title={t('reservations.pending')} count={allPending.length} accent="gray" storageKey={`trek:bookings-pending-open:${tripId}`}>
|
||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} />)}
|
||||
</Section>
|
||||
)}
|
||||
{allConfirmed.length > 0 && (
|
||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||
</div>
|
||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green" storageKey={`trek:bookings-confirmed-open:${tripId}`}>
|
||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} />)}
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plane, Train, Car, Ship } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import AirportSelect, { type Airport } from './AirportSelect'
|
||||
import LocationSelect, { type LocationPoint } from './LocationSelect'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import type { Day, Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const
|
||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||
|
||||
interface EndpointPick {
|
||||
airport?: Airport
|
||||
location?: LocationPoint
|
||||
}
|
||||
|
||||
function endpointFromAirport(a: Airport, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||
return {
|
||||
role, sequence,
|
||||
name: a.city ? `${a.city} (${a.iata})` : a.name,
|
||||
code: a.iata,
|
||||
lat: a.lat, lng: a.lng,
|
||||
timezone: a.tz,
|
||||
local_date: date,
|
||||
local_time: time,
|
||||
}
|
||||
}
|
||||
|
||||
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||
return {
|
||||
role, sequence,
|
||||
name: l.name,
|
||||
code: null,
|
||||
lat: l.lat, lng: l.lng,
|
||||
timezone: null,
|
||||
local_date: date,
|
||||
local_time: time,
|
||||
}
|
||||
}
|
||||
|
||||
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
|
||||
if (!e || !e.code) return null
|
||||
return {
|
||||
iata: e.code, icao: null,
|
||||
name: e.name, city: e.name.replace(/\s*\([A-Z]{3}\)\s*$/, ''),
|
||||
country: '',
|
||||
lat: e.lat, lng: e.lng,
|
||||
tz: e.timezone || '',
|
||||
}
|
||||
}
|
||||
|
||||
function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint | null {
|
||||
if (!e) return null
|
||||
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
||||
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
|
||||
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
|
||||
]
|
||||
|
||||
const defaultForm = {
|
||||
title: '',
|
||||
type: 'flight' as TransportType,
|
||||
status: 'pending' as 'pending' | 'confirmed',
|
||||
start_day_id: '' as string | number,
|
||||
end_day_id: '' as string | number,
|
||||
departure_time: '',
|
||||
arrival_time: '',
|
||||
confirmation_number: '',
|
||||
notes: '',
|
||||
meta_airline: '',
|
||||
meta_flight_number: '',
|
||||
meta_train_number: '',
|
||||
meta_platform: '',
|
||||
meta_seat: '',
|
||||
}
|
||||
|
||||
interface TransportModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, any>) => Promise<void>
|
||||
reservation: Reservation | null
|
||||
days: Day[]
|
||||
selectedDayId: number | null
|
||||
}
|
||||
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [form, setForm] = useState({ ...defaultForm })
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (reservation) {
|
||||
const meta = typeof reservation.metadata === 'string'
|
||||
? JSON.parse(reservation.metadata || '{}')
|
||||
: (reservation.metadata || {})
|
||||
const eps = reservation.endpoints || []
|
||||
const from = eps.find(e => e.role === 'from')
|
||||
const to = eps.find(e => e.role === 'to')
|
||||
const type = (TRANSPORT_TYPES as readonly string[]).includes(reservation.type)
|
||||
? reservation.type as TransportType
|
||||
: 'flight'
|
||||
setForm({
|
||||
title: reservation.title || '',
|
||||
type,
|
||||
status: reservation.status || 'pending',
|
||||
start_day_id: reservation.day_id ?? '',
|
||||
end_day_id: reservation.end_day_id ?? '',
|
||||
departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '',
|
||||
arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '',
|
||||
confirmation_number: reservation.confirmation_number || '',
|
||||
notes: reservation.notes || '',
|
||||
meta_airline: meta.airline || '',
|
||||
meta_flight_number: meta.flight_number || '',
|
||||
meta_train_number: meta.train_number || '',
|
||||
meta_platform: meta.platform || '',
|
||||
meta_seat: meta.seat || '',
|
||||
})
|
||||
if (type === 'flight') {
|
||||
setFromPick({ airport: airportFromEndpoint(from) || undefined })
|
||||
setToPick({ airport: airportFromEndpoint(to) || undefined })
|
||||
} else {
|
||||
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||
}
|
||||
} else {
|
||||
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '' })
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
}
|
||||
}, [isOpen, reservation, selectedDayId])
|
||||
|
||||
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.title.trim()) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const startDay = days.find(d => d.id === Number(form.start_day_id))
|
||||
const endDay = days.find(d => d.id === Number(form.end_day_id))
|
||||
|
||||
const buildTime = (day: Day | undefined, time: string): string | null => {
|
||||
if (!time) return null
|
||||
return day?.date ? `${day.date}T${time}` : `T${time}`
|
||||
}
|
||||
|
||||
const metadata: Record<string, string> = {}
|
||||
if (form.type === 'flight') {
|
||||
if (form.meta_airline) metadata.airline = form.meta_airline
|
||||
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
|
||||
if (fromPick.airport) {
|
||||
metadata.departure_airport = fromPick.airport.iata
|
||||
metadata.departure_timezone = fromPick.airport.tz
|
||||
}
|
||||
if (toPick.airport) {
|
||||
metadata.arrival_airport = toPick.airport.iata
|
||||
metadata.arrival_timezone = toPick.airport.tz
|
||||
}
|
||||
} else if (form.type === 'train') {
|
||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||
}
|
||||
|
||||
const startDate = startDay?.date ?? null
|
||||
const endDate = (endDay ?? startDay)?.date ?? null
|
||||
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
||||
if (form.type === 'flight') {
|
||||
if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, form.departure_time || null))
|
||||
if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, form.arrival_time || null))
|
||||
} else {
|
||||
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
|
||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, form.arrival_time || null))
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title: form.title,
|
||||
type: form.type,
|
||||
status: form.status,
|
||||
day_id: form.start_day_id ? Number(form.start_day_id) : null,
|
||||
end_day_id: form.end_day_id ? Number(form.end_day_id) : null,
|
||||
reservation_time: buildTime(startDay, form.departure_time),
|
||||
reservation_end_time: buildTime(endDay ?? startDay, form.arrival_time),
|
||||
location: null,
|
||||
confirmation_number: form.confirmation_number || null,
|
||||
notes: form.notes || null,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
endpoints,
|
||||
needs_review: false,
|
||||
}
|
||||
await onSave(payload)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
|
||||
outline: 'none', boxSizing: 'border-box' as const, color: 'var(--text-primary)', background: 'var(--bg-input)',
|
||||
}
|
||||
const labelStyle = {
|
||||
display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)',
|
||||
marginBottom: 5, textTransform: 'uppercase' as const, letterSpacing: '0.03em',
|
||||
}
|
||||
|
||||
const dayOptions = [
|
||||
{ value: '', label: '—' },
|
||||
...days.map(d => ({
|
||||
value: d.id,
|
||||
label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale) ?? ''}` : ''}`,
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
|
||||
size="2xl"
|
||||
>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
|
||||
{/* Type selector */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.bookingType')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
||||
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
|
||||
<button key={value} type="button" onClick={() => set('type', value)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
padding: '5px 10px', borderRadius: 99, border: '1px solid',
|
||||
fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
background: form.type === value ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: form.type === value ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: form.type === value ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>
|
||||
<Icon size={11} /> {t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.titleLabel')} *</label>
|
||||
<input type="text" value={form.title} onChange={e => set('title', e.target.value)} required
|
||||
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
|
||||
</div>
|
||||
|
||||
{/* From / To endpoints */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.from')}</label>
|
||||
{form.type === 'flight' ? (
|
||||
<AirportSelect value={fromPick.airport || null} onChange={a => setFromPick({ airport: a || undefined })} />
|
||||
) : (
|
||||
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.to')}</label>
|
||||
{form.type === 'flight' ? (
|
||||
<AirportSelect value={toPick.airport || null} onChange={a => setToPick({ airport: a || undefined })} />
|
||||
) : (
|
||||
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Departure row */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={form.start_day_id}
|
||||
onChange={value => set('start_day_id', value)}
|
||||
placeholder={t('dayplan.dayN', { n: '?' })}
|
||||
options={dayOptions}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}
|
||||
</label>
|
||||
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} />
|
||||
</div>
|
||||
{form.type === 'flight' && fromPick.airport && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.meta.departureTimezone')}</label>
|
||||
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
||||
{fromPick.airport.tz}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrival row */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={form.end_day_id}
|
||||
onChange={value => set('end_day_id', value)}
|
||||
placeholder={t('dayplan.dayN', { n: '?' })}
|
||||
options={dayOptions}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}
|
||||
</label>
|
||||
<CustomTimePicker value={form.arrival_time} onChange={v => set('arrival_time', v)} />
|
||||
</div>
|
||||
{form.type === 'flight' && toPick.airport && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
|
||||
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
||||
{toPick.airport.tz}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flight-specific fields */}
|
||||
{form.type === 'flight' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.airline')}</label>
|
||||
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
|
||||
placeholder="Lufthansa" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.flightNumber')}</label>
|
||||
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
|
||||
placeholder="LH 123" style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Train-specific fields */}
|
||||
{form.type === 'train' && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.trainNumber')}</label>
|
||||
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
|
||||
placeholder="ICE 123" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.platform')}</label>
|
||||
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
|
||||
placeholder="12" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.seat')}</label>
|
||||
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
|
||||
placeholder="42A" style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Booking Code + Status */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.confirmationCode')}</label>
|
||||
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
|
||||
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||
<CustomSelect
|
||||
value={form.status}
|
||||
onChange={value => set('status', value)}
|
||||
options={[
|
||||
{ value: 'pending', label: t('reservations.pending') },
|
||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.notes')}</label>
|
||||
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={2}
|
||||
placeholder={t('reservations.notesPlaceholder')}
|
||||
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
||||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Info, Coffee, Heart, ExternalLink, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
||||
import { Info, Coffee, Heart, ExternalLink, Bug, Lightbulb, BookOpen, Tent, Compass, Plane, Crown, Infinity as InfinityIcon } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Section from './Section'
|
||||
|
||||
@@ -7,8 +7,229 @@ interface Props {
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
type SupporterTierId = 'no_return_ticket' | 'lost_luggage_vip' | 'business_class_dreamer' | 'budget_traveller' | 'hostel_bunkmate'
|
||||
|
||||
interface SupporterTier {
|
||||
id: SupporterTierId
|
||||
labelKey: string
|
||||
price: string
|
||||
gradient: string
|
||||
glow: string
|
||||
icon: typeof Tent
|
||||
}
|
||||
|
||||
const SUPPORTER_TIERS: SupporterTier[] = [
|
||||
{ id: 'no_return_ticket', labelKey: 'settings.about.supporter.tier.noReturnTicket', price: '∞', gradient: 'linear-gradient(135deg, #fbbf24, #ec4899 55%, #6366f1)', glow: 'rgba(236,72,153,0.45)', icon: InfinityIcon },
|
||||
{ id: 'lost_luggage_vip', labelKey: 'settings.about.supporter.tier.lostLuggageVip', price: '$30', gradient: 'linear-gradient(135deg, #a855f7, #ec4899)', glow: 'rgba(168,85,247,0.35)', icon: Crown },
|
||||
{ id: 'business_class_dreamer', labelKey: 'settings.about.supporter.tier.businessClassDreamer', price: '$15', gradient: 'linear-gradient(135deg, #6366f1, #0ea5e9)', glow: 'rgba(99,102,241,0.35)', icon: Plane },
|
||||
{ id: 'budget_traveller', labelKey: 'settings.about.supporter.tier.budgetTraveller', price: '$10', gradient: 'linear-gradient(135deg, #14b8a6, #06b6d4)', glow: 'rgba(20,184,166,0.3)', icon: Compass },
|
||||
{ id: 'hostel_bunkmate', labelKey: 'settings.about.supporter.tier.hostelBunkmate', price: '$5', gradient: 'linear-gradient(135deg, #64748b, #94a3b8)', glow: 'rgba(100,116,139,0.25)', icon: Tent },
|
||||
]
|
||||
|
||||
interface Supporter {
|
||||
username: string
|
||||
tier: SupporterTierId
|
||||
since: string
|
||||
link?: string
|
||||
}
|
||||
|
||||
const SUPPORTERS: Supporter[] = [
|
||||
{ username: 'Someone', tier: 'hostel_bunkmate', since: '2026-04' },
|
||||
]
|
||||
|
||||
function SupporterSection({ t, locale }: { t: (key: string, vars?: Record<string, string | number>) => string; locale: string }) {
|
||||
if (SUPPORTERS.length === 0) return null
|
||||
|
||||
const formatSince = (yearMonth: string): string => {
|
||||
const [y, m] = yearMonth.split('-').map(Number)
|
||||
if (!y || !m) return yearMonth
|
||||
try {
|
||||
return new Date(y, m - 1, 1).toLocaleDateString(locale, { year: 'numeric', month: 'long' })
|
||||
} catch { return yearMonth }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="supporter-section">
|
||||
<style>{`
|
||||
.supporter-section { margin-top: 20px; }
|
||||
.supporter-card {
|
||||
position: relative;
|
||||
border-radius: 20px;
|
||||
padding: 22px 22px 18px;
|
||||
background: linear-gradient(180deg, rgba(99,102,241,0.06) 0%, rgba(236,72,153,0.04) 100%);
|
||||
border: 1px solid rgba(99,102,241,0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
.supporter-glow {
|
||||
position: absolute; inset: -60px; z-index: 0; pointer-events: none;
|
||||
background: radial-gradient(500px 240px at 15% -10%, rgba(99,102,241,0.18), transparent 60%), radial-gradient(400px 200px at 90% 110%, rgba(236,72,153,0.12), transparent 60%);
|
||||
animation: supporterGlow 6s ease-in-out infinite;
|
||||
}
|
||||
.supporter-header {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.supporter-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border-radius: 999px;
|
||||
background: linear-gradient(90deg, #6366f1, #ec4899, #fbbf24);
|
||||
background-size: 200% 100%;
|
||||
animation: supporterShimmer 4s ease-in-out infinite;
|
||||
color: #fff; font-weight: 700; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase;
|
||||
box-shadow: 0 4px 16px rgba(236,72,153,0.25);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.supporter-title {
|
||||
margin: 0; font-size: 16px; font-weight: 700;
|
||||
color: var(--text-primary); letter-spacing: -0.01em;
|
||||
}
|
||||
.supporter-subtitle {
|
||||
position: relative; z-index: 1;
|
||||
margin: 0 0 16px; font-size: 12.5px;
|
||||
color: var(--text-secondary); line-height: 1.55;
|
||||
}
|
||||
.supporter-tiers {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.supporter-tier {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
padding: 10px 12px; border-radius: 14px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
.supporter-tier-icon {
|
||||
width: 38px; height: 38px; border-radius: 11px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
.supporter-tier-body { flex: 1; min-width: 0; }
|
||||
.supporter-tier-head {
|
||||
display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
|
||||
}
|
||||
.supporter-tier-label {
|
||||
font-size: 13.5px; font-weight: 700; color: var(--text-primary);
|
||||
}
|
||||
.supporter-tier-price {
|
||||
font-size: 11px; font-weight: 600; color: var(--text-faint);
|
||||
padding: 1px 7px; border-radius: 6px; background: var(--bg-tertiary);
|
||||
}
|
||||
.supporter-tier-chips {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px;
|
||||
}
|
||||
.supporter-tier-empty {
|
||||
font-size: 11.5px; font-style: italic; color: var(--text-faint);
|
||||
}
|
||||
.supporter-chip {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 4px 10px; border-radius: 999px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
max-width: 100%;
|
||||
}
|
||||
.supporter-chip-name {
|
||||
font-size: 12px; font-weight: 600; color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.supporter-chip-since {
|
||||
font-size: 10.5px; font-weight: 500; color: var(--text-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.supporter-chip-since-short { display: none; }
|
||||
@keyframes supporterShimmer {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
@keyframes supporterGlow {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.75; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.supporter-card { border-radius: 16px; padding: 16px 14px 14px; }
|
||||
.supporter-glow { inset: -40px; }
|
||||
.supporter-header { gap: 8px; }
|
||||
.supporter-badge { font-size: 10px; padding: 3px 9px; letter-spacing: 0.03em; }
|
||||
.supporter-title { font-size: 15px; flex-basis: 100%; }
|
||||
.supporter-subtitle { font-size: 12px; margin-bottom: 14px; }
|
||||
.supporter-tier { padding: 10px; gap: 10px; border-radius: 12px; }
|
||||
.supporter-tier-icon { width: 34px; height: 34px; border-radius: 10px; }
|
||||
.supporter-tier-label { font-size: 13px; }
|
||||
.supporter-tier-chips { gap: 5px; margin-top: 7px; }
|
||||
.supporter-chip { padding: 3px 9px; }
|
||||
.supporter-chip-since { font-size: 10px; }
|
||||
.supporter-chip-since-full { display: none; }
|
||||
.supporter-chip-since-short { display: inline; }
|
||||
}
|
||||
`}</style>
|
||||
<div className="supporter-card">
|
||||
<div className="supporter-glow" />
|
||||
|
||||
<div className="supporter-header">
|
||||
<span className="supporter-badge">{t('settings.about.supporters.badge')}</span>
|
||||
<h3 className="supporter-title">{t('settings.about.supporters.title')}</h3>
|
||||
</div>
|
||||
<p className="supporter-subtitle">{t('settings.about.supporters.subtitle')}</p>
|
||||
|
||||
<div className="supporter-tiers">
|
||||
{SUPPORTER_TIERS.map(tier => {
|
||||
const members = SUPPORTERS.filter(s => s.tier === tier.id)
|
||||
const empty = members.length === 0
|
||||
const TierIcon = tier.icon
|
||||
return (
|
||||
<div key={tier.id} className="supporter-tier" style={{ opacity: empty ? 0.55 : 1 }}>
|
||||
<div className="supporter-tier-icon" style={{ background: tier.gradient, boxShadow: `0 6px 18px ${tier.glow}` }}>
|
||||
<TierIcon size={18} strokeWidth={2.2} />
|
||||
</div>
|
||||
<div className="supporter-tier-body">
|
||||
<div className="supporter-tier-head">
|
||||
<span className="supporter-tier-label">{t(tier.labelKey)}</span>
|
||||
<span className="supporter-tier-price">{tier.price}</span>
|
||||
</div>
|
||||
<div className="supporter-tier-chips">
|
||||
{empty && (
|
||||
<span className="supporter-tier-empty">
|
||||
{t('settings.about.supporters.tierEmpty')}
|
||||
</span>
|
||||
)}
|
||||
{members.map(m => {
|
||||
const chipContent = (
|
||||
<>
|
||||
<span className="supporter-chip-name">{m.username}</span>
|
||||
<span className="supporter-chip-since supporter-chip-since-full">
|
||||
· {t('settings.about.supporters.since', { date: formatSince(m.since) })}
|
||||
</span>
|
||||
<span className="supporter-chip-since supporter-chip-since-short">
|
||||
· {formatSince(m.since)}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
return m.link ? (
|
||||
<a key={m.username} href={m.link} target="_blank" rel="noopener noreferrer" className="supporter-chip"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.boxShadow = `0 2px 8px ${tier.glow}` }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
{chipContent}
|
||||
</a>
|
||||
) : (
|
||||
<div key={m.username} className="supporter-chip">{chipContent}</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
return (
|
||||
<Section title={t('settings.about')} icon={Info}>
|
||||
@@ -33,7 +254,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -51,7 +272,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -69,7 +290,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -90,7 +311,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -108,7 +329,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -126,7 +347,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
href="https://github.com/mauriceboe/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -141,6 +362,8 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<SupporterSection t={t} locale={locale} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('DisplaySettingsTab', () => {
|
||||
|
||||
it('FE-COMP-DISPLAY-005: shows Auto mode button', () => {
|
||||
render(<DisplaySettingsTab />);
|
||||
expect(screen.getByText('Auto')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Auto/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-006: shows Language section', () => {
|
||||
@@ -95,16 +95,16 @@ describe('DisplaySettingsTab', () => {
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'light' }), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
await user.click(screen.getByText('Auto'));
|
||||
await user.click(screen.getByRole('button', { name: /Auto/i }));
|
||||
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'auto');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-014: active color mode button has border with var(--text-primary)', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) });
|
||||
render(<DisplaySettingsTab />);
|
||||
const darkBtn = screen.getByText('Dark').closest('button')!;
|
||||
const lightBtn = screen.getByText('Light').closest('button')!;
|
||||
const autoBtn = screen.getByText('Auto').closest('button')!;
|
||||
const darkBtn = screen.getByRole('button', { name: /^Dark$/i });
|
||||
const lightBtn = screen.getByRole('button', { name: /^Light$/i });
|
||||
const autoBtn = screen.getByRole('button', { name: /Auto/i });
|
||||
expect(darkBtn.style.border).toContain('var(--text-primary)');
|
||||
expect(lightBtn.style.border).toContain('var(--border-primary)');
|
||||
expect(autoBtn.style.border).toContain('var(--border-primary)');
|
||||
@@ -122,8 +122,11 @@ describe('DisplaySettingsTab', () => {
|
||||
it('FE-COMP-DISPLAY-016: active language button is visually highlighted', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
|
||||
render(<DisplaySettingsTab />);
|
||||
const englishBtn = screen.getByText('English').closest('button')!;
|
||||
expect(englishBtn.style.border).toContain('var(--text-primary)');
|
||||
// Multiple elements contain "English" (desktop grid button + mobile dropdown trigger).
|
||||
// The desktop grid button is the one with the active border style.
|
||||
const englishMatches = screen.getAllByText('English').map(el => el.closest('button')!).filter(Boolean);
|
||||
const activeBtn = englishMatches.find(btn => (btn.style.border || '').includes('var(--text-primary)'));
|
||||
expect(activeBtn).toBeDefined();
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-017: shows Temperature section label', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Palette, Sun, Moon, Monitor } from 'lucide-react'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { Palette, Sun, Moon, Monitor, ChevronDown, Check } from 'lucide-react'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -10,6 +10,17 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
|
||||
const [langOpen, setLangOpen] = useState(false)
|
||||
const langDropdownRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!langOpen) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (langDropdownRef.current && !langDropdownRef.current.contains(e.target as Node)) setLangOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [langOpen])
|
||||
|
||||
useEffect(() => {
|
||||
setTempUnit(settings.temperature_unit || 'celsius')
|
||||
@@ -46,8 +57,13 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<opt.icon size={16} />
|
||||
{opt.label}
|
||||
<span className="hidden sm:inline-flex"><opt.icon size={16} /></span>
|
||||
{opt.value === 'auto' ? (
|
||||
<>
|
||||
<span className="hidden sm:inline">{opt.label}</span>
|
||||
<span className="sm:hidden">Auto</span>
|
||||
</>
|
||||
) : opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -57,7 +73,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
{/* Language */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Desktop: Button grid */}
|
||||
<div className="hidden sm:flex flex-wrap gap-3">
|
||||
{SUPPORTED_LANGUAGES.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@@ -79,6 +96,60 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Mobile: Custom dropdown */}
|
||||
<div ref={langDropdownRef} className="sm:hidden" style={{ position: 'relative' }}>
|
||||
{(() => {
|
||||
const current = SUPPORTED_LANGUAGES.find(o => o.value === settings.language) || SUPPORTED_LANGUAGES[0]
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLangOpen(v => !v)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '10px 14px', borderRadius: 10,
|
||||
border: '2px solid var(--border-primary)',
|
||||
background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
fontSize: 14, fontWeight: 500, fontFamily: 'inherit', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{current?.label}</span>
|
||||
<ChevronDown size={14} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: langOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
{langOpen && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, zIndex: 50,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.15)', padding: 4, maxHeight: 280, overflowY: 'auto',
|
||||
}}>
|
||||
{SUPPORTED_LANGUAGES.map(opt => {
|
||||
const active = settings.language === opt.value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setLangOpen(false)
|
||||
try { await updateSetting('language', opt.value) }
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '9px 12px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||
fontFamily: 'inherit', fontSize: 14, color: 'var(--text-primary)',
|
||||
textAlign: 'left', fontWeight: active ? 600 : 500,
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1 }}>{opt.label}</span>
|
||||
{active && <Check size={14} strokeWidth={2.5} color="var(--accent)" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Temperature */}
|
||||
@@ -172,6 +243,37 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking route labels */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.bookingLabels')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('map_booking_labels', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: (settings.map_booking_labels !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (settings.map_booking_labels !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{t('settings.bookingLabelsHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.blurBookingCodes')}</label>
|
||||
|
||||
@@ -11,6 +11,7 @@ interface PreferencesMatrix {
|
||||
available_channels: { email: boolean; webhook: boolean; inapp: boolean; ntfy: boolean }
|
||||
event_types: string[]
|
||||
implemented_combos: Record<string, string[]>
|
||||
defaults?: { ntfyServer: string | null }
|
||||
}
|
||||
|
||||
const CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||
@@ -233,7 +234,7 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
type="text"
|
||||
value={ntfyServer}
|
||||
onChange={e => setNtfyServer(e.target.value)}
|
||||
placeholder={t('settings.ntfyUrl.serverPlaceholder')}
|
||||
placeholder={matrix.defaults?.ntfyServer || t('settings.ntfyUrl.serverPlaceholder')}
|
||||
style={{ width: '100%', boxSizing: 'border-box', fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)', marginBottom: 6 }}
|
||||
/>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
@@ -253,7 +254,7 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
onClick={clearNtfyToken}
|
||||
style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', color: 'var(--color-danger, #e53e3e)', border: '1px solid var(--color-danger, #e53e3e)', borderRadius: 6, cursor: 'pointer' }}
|
||||
>
|
||||
{t('settings.ntfyUrl.clearToken')}
|
||||
{t('common.clear')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { act } from '@testing-library/react';
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore';
|
||||
import { BannerRenderer } from './SystemNoticeBanner';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore';
|
||||
|
||||
function makeBanner(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDTO {
|
||||
return {
|
||||
id: 'banner-1',
|
||||
display: 'banner',
|
||||
severity: 'info',
|
||||
titleKey: 'Maintenance notice',
|
||||
bodyKey: 'System will be down briefly.',
|
||||
dismissible: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BannerRenderer', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.post('/api/system-notices/:id/dismiss', () => {
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
useSystemNoticeStore.setState({ notices: [], loaded: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.documentElement.style.removeProperty('--banner-stack-h');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-001: renders banner with correct title and body', async () => {
|
||||
const notice = makeBanner();
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Maintenance notice')).toBeTruthy();
|
||||
expect(screen.getByText('System will be down briefly.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-002: dismiss button calls store.dismiss(id)', async () => {
|
||||
const notice = makeBanner();
|
||||
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
const dismissBtn = screen.getByLabelText(/Dismiss/);
|
||||
await act(async () => {
|
||||
fireEvent.click(dismissBtn);
|
||||
});
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith('banner-1');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-003: two banners stack correctly', async () => {
|
||||
const n1 = makeBanner({ id: 'banner-1', titleKey: 'First notice' });
|
||||
const n2 = makeBanner({ id: 'banner-2', titleKey: 'Second notice' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[n1, n2]} />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('First notice')).toBeTruthy();
|
||||
expect(screen.getByText('Second notice')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-004: third banner is not rendered (only top 2 shown)', async () => {
|
||||
// Server returns notices highest-priority first; BannerRenderer takes slice(0,2)
|
||||
const n1 = makeBanner({ id: 'banner-1', titleKey: 'Highest notice' });
|
||||
const n2 = makeBanner({ id: 'banner-2', titleKey: 'Second notice' });
|
||||
const n3 = makeBanner({ id: 'banner-3', titleKey: 'Lowest notice' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[n1, n2, n3]} />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Highest notice')).toBeTruthy();
|
||||
expect(screen.getByText('Second notice')).toBeTruthy();
|
||||
expect(screen.queryByText('Lowest notice')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-005: critical banner has aria-live="assertive"', async () => {
|
||||
const notice = makeBanner({ severity: 'critical', id: 'crit-1' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
const alertEl = screen.getByRole('alert');
|
||||
expect(alertEl.getAttribute('aria-live')).toBe('assertive');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-006: info banner has aria-live="polite"', async () => {
|
||||
const notice = makeBanner({ severity: 'info' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
const statusEl = screen.getByRole('status');
|
||||
expect(statusEl.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-007: warn banner has aria-live="polite"', async () => {
|
||||
const notice = makeBanner({ severity: 'warn', id: 'warn-1' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
const statusEl = screen.getByRole('status');
|
||||
expect(statusEl.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-008: renders nothing when notices array is empty', () => {
|
||||
const { container } = render(<BannerRenderer notices={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-009: non-dismissible banner hides dismiss button', async () => {
|
||||
const notice = makeBanner({ dismissible: false });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Maintenance notice')).toBeTruthy();
|
||||
expect(screen.queryByLabelText(/Dismiss/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Info, AlertTriangle, AlertOctagon, X } from 'lucide-react';
|
||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
|
||||
import { useTranslation } from '../../i18n/index.js';
|
||||
import { isRtlLanguage } from '../../i18n/index.js';
|
||||
import { runNoticeAction } from './noticeActions.js';
|
||||
|
||||
const SEVERITY_ICONS: Record<string, React.ElementType> = {
|
||||
info: Info,
|
||||
warn: AlertTriangle,
|
||||
critical: AlertOctagon,
|
||||
};
|
||||
|
||||
const SEVERITY = {
|
||||
info: {
|
||||
bg: 'bg-white dark:bg-slate-900',
|
||||
border: 'border-blue-500 dark:border-blue-400',
|
||||
text: 'text-slate-900 dark:text-slate-100',
|
||||
icon: 'text-blue-500 dark:text-blue-400',
|
||||
ariaLive: 'polite' as const,
|
||||
role: 'status' as const,
|
||||
},
|
||||
warn: {
|
||||
bg: 'bg-amber-50 dark:bg-amber-950',
|
||||
border: 'border-amber-500 dark:border-amber-400',
|
||||
text: 'text-amber-900 dark:text-amber-100',
|
||||
icon: 'text-amber-500 dark:text-amber-400',
|
||||
ariaLive: 'polite' as const,
|
||||
role: 'status' as const,
|
||||
},
|
||||
critical: {
|
||||
bg: 'bg-rose-50 dark:bg-rose-950',
|
||||
border: 'border-rose-600 dark:border-rose-400',
|
||||
text: 'text-rose-900 dark:text-rose-100',
|
||||
icon: 'text-rose-600 dark:text-rose-400',
|
||||
ariaLive: 'assertive' as const,
|
||||
role: 'alert' as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface BannerItemProps {
|
||||
notice: SystemNoticeDTO;
|
||||
onDismiss: () => void;
|
||||
language: string;
|
||||
}
|
||||
|
||||
function CTALink({
|
||||
notice,
|
||||
label,
|
||||
onDismiss,
|
||||
}: {
|
||||
notice: SystemNoticeDTO;
|
||||
label: string;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
function handleClick() {
|
||||
if (!notice.cta) return;
|
||||
if (notice.cta.kind === 'nav') {
|
||||
navigate(notice.cta.href);
|
||||
if (notice.dismissible) onDismiss();
|
||||
} else {
|
||||
runNoticeAction(notice.cta.actionId, { navigate });
|
||||
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
|
||||
if (actionCta.dismissOnAction !== false) onDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
if (!notice.cta) return null;
|
||||
|
||||
if (notice.cta.kind === 'nav') {
|
||||
return (
|
||||
<a
|
||||
href={notice.cta.href}
|
||||
onClick={e => { e.preventDefault(); handleClick(); }}
|
||||
className="underline hover:no-underline font-medium ml-3 shrink-0"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="underline hover:no-underline font-medium ml-3 shrink-0"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function BannerItem({ notice, onDismiss, language }: BannerItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = SEVERITY[notice.severity] ?? SEVERITY.info;
|
||||
const title = t(notice.titleKey);
|
||||
const body = t(notice.bodyKey);
|
||||
const ctaLabel = notice.cta ? t(notice.cta.labelKey) : null;
|
||||
|
||||
// Tailwind 3.3+ supports border-s-4 (logical, RTL-aware)
|
||||
const accentBorder = 'border-s-4';
|
||||
|
||||
return (
|
||||
<div
|
||||
role={s.role}
|
||||
aria-live={s.ariaLive}
|
||||
aria-atomic="true"
|
||||
className={`flex items-start gap-x-3 py-3 px-4 ${accentBorder} ${s.bg} ${s.border} ${s.text}`}
|
||||
>
|
||||
{React.createElement(
|
||||
(SEVERITY_ICONS[notice.severity] ?? Info) as React.ElementType,
|
||||
{ size: 20, className: `shrink-0 mt-0.5 ${s.icon}` },
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-semibold">{title}</span>
|
||||
{body !== title && (
|
||||
<span className="ml-2 opacity-80">{body}</span>
|
||||
)}
|
||||
{ctaLabel && notice.cta && (
|
||||
<CTALink notice={notice} label={ctaLabel} onDismiss={onDismiss} />
|
||||
)}
|
||||
</div>
|
||||
{notice.dismissible && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="shrink-0 p-2 -mr-2 rounded hover:bg-black/5 dark:hover:bg-white/10 transition"
|
||||
aria-label={`Dismiss: ${title}`}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AnimatedBannerItemProps {
|
||||
notice: SystemNoticeDTO;
|
||||
onDismiss: () => void;
|
||||
language: string;
|
||||
}
|
||||
|
||||
function AnimatedBannerItem({ notice, onDismiss, language }: AnimatedBannerItemProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const prefersReducedMotion =
|
||||
typeof window !== 'undefined' &&
|
||||
(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof requestAnimationFrame !== 'undefined') {
|
||||
const id = requestAnimationFrame(() => setMounted(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const transition = prefersReducedMotion
|
||||
? 'transition-opacity duration-[120ms]'
|
||||
: 'transition-all duration-200 ease-out';
|
||||
const state = mounted ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2';
|
||||
|
||||
return (
|
||||
<div className={`${transition} ${state}`}>
|
||||
<BannerItem notice={notice} onDismiss={onDismiss} language={language} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BannerRendererProps {
|
||||
notices: SystemNoticeDTO[];
|
||||
}
|
||||
|
||||
export function BannerRenderer({ notices }: BannerRendererProps) {
|
||||
const { dismiss } = useSystemNoticeStore();
|
||||
const { language } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Show at most 2 highest-priority banners
|
||||
const visible = notices.slice(0, 2);
|
||||
|
||||
// Report banner stack height for layout reflow
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
document.documentElement.style.setProperty('--banner-stack-h', el.offsetHeight + 'px');
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
document.documentElement.style.setProperty('--banner-stack-h', '0px');
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed left-0 right-0 z-40"
|
||||
style={{ top: 'var(--nav-h, 0px)' }}
|
||||
>
|
||||
{visible.map((notice, i) => (
|
||||
<React.Fragment key={notice.id}>
|
||||
{i > 0 && <div className="border-t border-black/10 dark:border-white/10" />}
|
||||
<AnimatedBannerItem
|
||||
notice={notice}
|
||||
onDismiss={() => dismiss(notice.id)}
|
||||
language={language}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToastRendererProps {
|
||||
notices: SystemNoticeDTO[];
|
||||
}
|
||||
|
||||
export function ToastRenderer({ notices }: ToastRendererProps) {
|
||||
const { dismiss } = useSystemNoticeStore();
|
||||
const { t } = useTranslation();
|
||||
const firedRef = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
for (const notice of notices) {
|
||||
if (firedRef.current.has(notice.id)) continue;
|
||||
firedRef.current.add(notice.id);
|
||||
|
||||
// Critical should not be a toast — log and skip
|
||||
if (notice.severity === 'critical') {
|
||||
console.warn(
|
||||
`[systemNotices] notice "${notice.id}" is critical but display=toast. ` +
|
||||
'Should be banner or modal.'
|
||||
);
|
||||
dismiss(notice.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const variantMap: Record<string, string> = { info: 'info', warn: 'warning' };
|
||||
const variant = variantMap[notice.severity] ?? 'info';
|
||||
const titleStr = t(notice.titleKey);
|
||||
const bodyStr = t(notice.bodyKey);
|
||||
const message = bodyStr !== titleStr ? `${titleStr}: ${bodyStr}` : titleStr;
|
||||
const duration = notice.severity === 'warn' ? 9000 : 6000;
|
||||
|
||||
// Fire the toast, retrying on the next frame if __addToast isn't registered yet
|
||||
// (race between ToastContainer mounting and SystemNoticeHost mounting on cold load).
|
||||
const fireToast = (attempt = 0) => {
|
||||
if (typeof window.__addToast === 'function') {
|
||||
window.__addToast(message, variant as 'info' | 'success' | 'error' | 'warning', duration);
|
||||
} else if (attempt < 10) {
|
||||
requestAnimationFrame(() => fireToast(attempt + 1));
|
||||
return; // don't schedule dismiss until the toast actually fires
|
||||
} else {
|
||||
console.warn(`[systemNotices] toast "${notice.id}" dropped — __addToast never registered`);
|
||||
}
|
||||
setTimeout(() => dismiss(notice.id), duration + 500);
|
||||
};
|
||||
fireToast();
|
||||
}
|
||||
}, [notices]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
|
||||
import { ModalRenderer } from './SystemNoticeModal.js';
|
||||
import { BannerRenderer, ToastRenderer } from './SystemNoticeBanner.js';
|
||||
|
||||
export function SystemNoticeHost() {
|
||||
const { notices, loaded } = useSystemNoticeStore();
|
||||
|
||||
// Notices are fetched by authStore after login (see App.tsx / authStore modification).
|
||||
// Cold-session fetch (page reload with valid session) is triggered here:
|
||||
useEffect(() => {
|
||||
// Only fetch if not already loaded (authStore may have already triggered)
|
||||
if (!loaded) {
|
||||
useSystemNoticeStore.getState().fetch();
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!loaded) return null;
|
||||
|
||||
const modals = notices.filter(n => n.display === 'modal');
|
||||
const banners = notices.filter(n => n.display === 'banner');
|
||||
const toasts = notices.filter(n => n.display === 'toast');
|
||||
|
||||
return (
|
||||
<>
|
||||
<BannerRenderer notices={banners} />
|
||||
<ModalRenderer notices={modals} />
|
||||
<ToastRenderer notices={toasts} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { act } from '@testing-library/react';
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore';
|
||||
import { ModalRenderer } from './SystemNoticeModal';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore';
|
||||
|
||||
// Stub react-markdown to avoid async chunk issues in tests
|
||||
vi.mock('react-markdown', () => ({
|
||||
default: ({ children }: { children: string }) => <span data-testid="md">{children}</span>,
|
||||
}));
|
||||
vi.mock('remark-gfm', () => ({ default: () => ({}) }));
|
||||
vi.mock('rehype-sanitize', () => ({ default: () => ({}) }));
|
||||
|
||||
function makeNotice(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDTO {
|
||||
return {
|
||||
id: 'test-notice-1',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
titleKey: 'Test Title',
|
||||
bodyKey: 'Test body text',
|
||||
dismissible: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance fake timers past the grace delay (2× rAF fallback → each is a
|
||||
* setTimeout(0), then 500ms). All three timers fire in sequence with
|
||||
* runAllTimers() — no need to advance exact milliseconds.
|
||||
*/
|
||||
async function flushGraceDelay() {
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
}
|
||||
|
||||
describe('ModalRenderer', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.post('/api/system-notices/:id/dismiss', () => {
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
useSystemNoticeStore.setState({ notices: [], loaded: true });
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-001: renders title and body after grace delay', async () => {
|
||||
const notice = makeNotice();
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
// Before delay fires: dialog present but body not yet visible (class-based)
|
||||
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Test Title')).toBeTruthy();
|
||||
expect(screen.getByText('Test body text')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-002: dismiss button calls store.dismiss(id)', async () => {
|
||||
const notice = makeNotice();
|
||||
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
const dismissBtn = screen.getByLabelText('Dismiss');
|
||||
await act(async () => {
|
||||
fireEvent.click(dismissBtn);
|
||||
});
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith('test-notice-1');
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-003: non-dismissible critical notice hides dismiss affordance', async () => {
|
||||
const notice = makeNotice({ severity: 'critical', dismissible: false });
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull();
|
||||
expect(screen.queryByText('Not now')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-004: ESC key does not close non-dismissible notice', async () => {
|
||||
const notice = makeNotice({ severity: 'critical', dismissible: false });
|
||||
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
});
|
||||
|
||||
expect(dismissSpy).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-005: CTA nav button dismisses all notices (not just current)', async () => {
|
||||
// CTA is only shown on the last page; navigate there first
|
||||
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
|
||||
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } });
|
||||
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
render(<ModalRenderer notices={[noticeA, noticeB]} />);
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
// Navigate to last page
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Go to notice 2'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
const ctaBtn = screen.getByRole('button', { name: 'Go to trips' });
|
||||
await act(async () => {
|
||||
fireEvent.click(ctaBtn);
|
||||
});
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-a');
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-b');
|
||||
expect(dismissSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-006: modal backdrop has opacity-0 class before grace delay fires', () => {
|
||||
const notice = makeNotice();
|
||||
const { container } = render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
// Dialog is in DOM, backdrop has opacity-0 before timers fire
|
||||
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||
const backdrop = container.querySelector('[role="presentation"]');
|
||||
expect(backdrop?.className).toContain('opacity-0');
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-007: body params are interpolated before rendering', async () => {
|
||||
const notice = makeNotice({
|
||||
bodyKey: 'Hello {name}, welcome to {app}',
|
||||
bodyParams: { name: 'Alice', app: 'TREK' },
|
||||
});
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Hello Alice, welcome to TREK')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-008: empty notices renders nothing', () => {
|
||||
const { container } = render(<ModalRenderer notices={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
// ── Multipage (pager) ──────────────────────────────────────────────────────
|
||||
|
||||
it('FE-SN-MODAL-009: pager is hidden when only one notice is present', async () => {
|
||||
const notice = makeNotice();
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.queryByLabelText('Previous notice')).toBeNull();
|
||||
expect(screen.queryByLabelText('Next notice')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-010: pager shows counter and dots for multiple notices', async () => {
|
||||
const notices = [
|
||||
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
|
||||
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
|
||||
makeNotice({ id: 'n3', titleKey: 'Notice C' }),
|
||||
];
|
||||
render(<ModalRenderer notices={notices} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('1 / 3')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Go to notice 1')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Go to notice 2')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Go to notice 3')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Previous notice')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Next notice')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-011: next button advances to the next notice; prev returns', async () => {
|
||||
const notices = [
|
||||
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
|
||||
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
|
||||
makeNotice({ id: 'n3', titleKey: 'Notice C' }),
|
||||
];
|
||||
render(<ModalRenderer notices={notices} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('1 / 3')).toBeTruthy();
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
|
||||
// Navigate to page 2
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Next notice'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('2 / 3')).toBeTruthy();
|
||||
expect(screen.getByText('Notice B')).toBeTruthy();
|
||||
|
||||
// Navigate back to page 1
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Previous notice'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('1 / 3')).toBeTruthy();
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-012: ArrowRight / ArrowLeft keys navigate between pages', async () => {
|
||||
const notices = [
|
||||
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
|
||||
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
|
||||
];
|
||||
render(<ModalRenderer notices={notices} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'ArrowRight' });
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Notice B')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'ArrowLeft' });
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-013: clicking a dot navigates directly to that page', async () => {
|
||||
const notices = [
|
||||
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
|
||||
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
|
||||
makeNotice({ id: 'n3', titleKey: 'Notice C' }),
|
||||
];
|
||||
render(<ModalRenderer notices={notices} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
|
||||
// Click third dot
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Go to notice 3'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('3 / 3')).toBeTruthy();
|
||||
expect(screen.getByText('Notice C')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-014: non-dismissible notice locks the pager (prev/next/dots disabled)', async () => {
|
||||
const notices = [
|
||||
makeNotice({ id: 'n1', titleKey: 'Notice A', dismissible: false }),
|
||||
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
|
||||
];
|
||||
render(<ModalRenderer notices={notices} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
const prevBtn = screen.getByLabelText('Previous notice') as HTMLButtonElement;
|
||||
const nextBtn = screen.getByLabelText('Next notice') as HTMLButtonElement;
|
||||
const dot2 = screen.getByLabelText('Go to notice 2') as HTMLButtonElement;
|
||||
|
||||
expect(prevBtn.disabled).toBe(true);
|
||||
expect(nextBtn.disabled).toBe(true);
|
||||
expect(dot2.disabled).toBe(true);
|
||||
|
||||
// Arrow keys should also be blocked
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'ArrowRight' });
|
||||
});
|
||||
// Still on page 1 (no grace delay needed because page didn't change)
|
||||
expect(screen.getByText('1 / 2')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-015: dismissing a notice does not skip the next one (regression)', async () => {
|
||||
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
|
||||
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
|
||||
const noticeC = makeNotice({ id: 'n-c', titleKey: 'Notice C' });
|
||||
|
||||
useSystemNoticeStore.setState({ notices: [noticeA, noticeB, noticeC], loaded: true });
|
||||
const { rerender } = render(<ModalRenderer notices={[noticeA, noticeB, noticeC]} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
expect(screen.getByText('1 / 3')).toBeTruthy();
|
||||
|
||||
// Navigate to last page where X button is available
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Go to notice 3'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
// Dismiss all from last page — store shrinks
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Dismiss'));
|
||||
useSystemNoticeStore.setState({ notices: [], loaded: true });
|
||||
rerender(<ModalRenderer notices={[]} />);
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
// All dismissed — modal should be gone
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-017: X button dismisses all notices, not just the current one', async () => {
|
||||
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
|
||||
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
|
||||
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
render(<ModalRenderer notices={[noticeA, noticeB]} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
// X button only appears on the last page — navigate there
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Go to notice 2'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Dismiss'));
|
||||
});
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-a');
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-b');
|
||||
expect(dismissSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-018: ESC key dismisses all notices when on last page', async () => {
|
||||
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
|
||||
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
|
||||
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
render(<ModalRenderer notices={[noticeA, noticeB]} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
// ESC only works on last page — navigate there first
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Go to notice 2'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
});
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-a');
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-b');
|
||||
expect(dismissSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-016: dismissing the only remaining notice closes the modal', async () => {
|
||||
const notice = makeNotice({ id: 'solo', titleKey: 'Solo Notice' });
|
||||
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
|
||||
|
||||
const { rerender, container } = render(<ModalRenderer notices={[notice]} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Solo Notice')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Dismiss'));
|
||||
useSystemNoticeStore.setState({ notices: [], loaded: true });
|
||||
rerender(<ModalRenderer notices={[]} />);
|
||||
});
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,830 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
|
||||
import { useTranslation, isRtlLanguage } from '../../i18n/index.js';
|
||||
import { runNoticeAction } from './noticeActions.js';
|
||||
|
||||
const ReactMarkdown = React.lazy(() =>
|
||||
import('react-markdown').then(m => ({ default: m.default }))
|
||||
);
|
||||
|
||||
/** Safe rAF shim — falls back to setTimeout(0) in environments without rAF (e.g. jsdom). */
|
||||
function scheduleFrame(cb: () => void): () => void {
|
||||
if (typeof requestAnimationFrame !== 'undefined') {
|
||||
const id = requestAnimationFrame(cb);
|
||||
return () => cancelAnimationFrame(id);
|
||||
}
|
||||
const id = setTimeout(cb, 0);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
|
||||
const SEVERITY_ICONS: Record<string, React.ElementType> = {
|
||||
info: Info,
|
||||
warn: AlertTriangle,
|
||||
critical: AlertOctagon,
|
||||
};
|
||||
|
||||
const SEVERITY_ACCENT: Record<string, string> = {
|
||||
info: 'text-blue-500 dark:text-blue-400 bg-blue-50 dark:bg-blue-950',
|
||||
warn: 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950',
|
||||
critical: 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-950',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
notices: SystemNoticeDTO[];
|
||||
}
|
||||
|
||||
// Inner content shared between desktop and mobile layouts
|
||||
interface ContentProps {
|
||||
notice: SystemNoticeDTO;
|
||||
title: string;
|
||||
body: string;
|
||||
ctaLabel: string | null;
|
||||
titleId: string;
|
||||
bodyId: string;
|
||||
isDark: boolean;
|
||||
onDismiss: () => void;
|
||||
onDismissAll: () => void;
|
||||
onCTA: () => void;
|
||||
// Pager
|
||||
total: number;
|
||||
currentPage: number;
|
||||
canPage: boolean;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onGoto: (i: number) => void;
|
||||
}
|
||||
|
||||
function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const isLastPage = total <= 1 || currentPage === total - 1;
|
||||
|
||||
const DefaultIcon = SEVERITY_ICONS[notice.severity] ?? Info;
|
||||
const LucideIcon: React.ElementType = notice.icon
|
||||
? ((LucideIcons as Record<string, unknown>)[notice.icon] as React.ElementType) ?? DefaultIcon
|
||||
: DefaultIcon;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col relative" style={{ flex: '1 1 0', minHeight: '100%' }}>
|
||||
{/* Dismiss X button — only on last page so users read all notices */}
|
||||
{notice.dismissible && isLastPage && (
|
||||
<button
|
||||
onClick={onDismissAll}
|
||||
className="absolute top-4 right-4 z-10 p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Scrollable content — vertically centered when shorter than available space */}
|
||||
<div className="flex flex-col justify-center" style={{ flex: '1 1 0' }}>
|
||||
{/* Hero image (not inline) */}
|
||||
{notice.media && notice.media.placement !== 'inline' && (
|
||||
<div
|
||||
className="w-full overflow-hidden"
|
||||
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
|
||||
>
|
||||
<img
|
||||
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
|
||||
alt={t(notice.media.altKey)}
|
||||
className="w-full h-full object-cover"
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Special warm header for Heart icon (thank-you notice) */}
|
||||
{notice.icon === 'Heart' && !notice.media && (
|
||||
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-5 text-center">
|
||||
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px), radial-gradient(circle at 60% 80%, white 1px, transparent 1px)', backgroundSize: '60px 60px, 80px 80px, 40px 40px' }} />
|
||||
<div className="relative flex items-center justify-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/10">
|
||||
<LucideIcon size={20} className="text-white" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h2 id={titleId} className="text-lg font-bold text-white leading-tight">{title}</h2>
|
||||
<p className="text-xs text-white/60 font-medium">TREK 3.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${notice.icon === 'Heart' && !notice.media ? 'px-8 py-6' : 'p-8'} flex flex-col`}>
|
||||
{/* Severity icon (when no hero and not Heart) */}
|
||||
{!notice.media && notice.icon !== 'Heart' && (
|
||||
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${SEVERITY_ACCENT[notice.severity] ?? ''}`}>
|
||||
<LucideIcon size={28} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title (not for Heart — rendered in gradient header) */}
|
||||
{(notice.icon !== 'Heart' || notice.media) && (
|
||||
<h2
|
||||
id={titleId}
|
||||
className="text-xl font-semibold text-center text-slate-900 dark:text-slate-100 mb-3"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Body — markdown */}
|
||||
<div
|
||||
id={bodyId}
|
||||
className="text-sm leading-relaxed text-slate-600 dark:text-slate-400 mx-auto mb-4 text-center"
|
||||
>
|
||||
<React.Suspense fallback={<p className="text-sm text-slate-500">{body}</p>}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-indigo-600 dark:text-indigo-400 underline decoration-indigo-300 dark:decoration-indigo-700 hover:decoration-indigo-500 dark:hover:decoration-indigo-400 underline-offset-2 transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
p: ({ children }) => {
|
||||
// Signature line styling (e.g. "— Maurice")
|
||||
const text = typeof children === 'string' ? children : Array.isArray(children) ? children.find(c => typeof c === 'string') : '';
|
||||
if (typeof text === 'string' && text.trim().startsWith('—') && text.trim().length < 30) {
|
||||
return <p className="mt-4 mb-3 text-base font-semibold text-slate-800 dark:text-slate-200 italic">{children}</p>;
|
||||
}
|
||||
return <p className="mb-3 last:mb-0">{children}</p>;
|
||||
},
|
||||
hr: () => (
|
||||
<div className="my-5 flex items-center gap-3">
|
||||
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
|
||||
<span className="text-slate-300 dark:text-slate-600 text-xs">♡</span>
|
||||
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
|
||||
</div>
|
||||
),
|
||||
strong: ({ children }) => <strong className="font-semibold text-slate-800 dark:text-slate-200">{children}</strong>,
|
||||
ul: ({ children }) => <ul className="list-disc list-inside text-left">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside text-left">{children}</ol>,
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</ReactMarkdown>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
|
||||
{/* Inline image */}
|
||||
{notice.media?.placement === 'inline' && (
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-lg mb-4 mx-auto"
|
||||
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
|
||||
>
|
||||
<img
|
||||
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
|
||||
alt={t(notice.media.altKey)}
|
||||
className="w-full h-full object-cover"
|
||||
decoding="async"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Highlights */}
|
||||
{notice.highlights && notice.highlights.length > 0 && (
|
||||
<ul className="mx-auto mb-4 space-y-2">
|
||||
{notice.highlights.map((h, i) => {
|
||||
const HIcon: React.ElementType | null = h.iconName
|
||||
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
|
||||
: null;
|
||||
return (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
{HIcon
|
||||
? <HIcon size={16} className="text-blue-500 shrink-0" />
|
||||
: <span className="text-blue-500 shrink-0">✓</span>
|
||||
}
|
||||
{t(h.labelKey)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky footer — pager + CTA, always anchored at the bottom of the slot */}
|
||||
<div
|
||||
className="sticky bottom-0 px-8 pt-4 flex flex-col gap-3 bg-white dark:bg-slate-900 border-t border-slate-100 dark:border-slate-800"
|
||||
style={{ paddingBottom: 'calc(var(--bottom-nav-h) + 1rem)' }}
|
||||
>
|
||||
{/* Pager — dots, arrows, counter (only when multiple notices) */}
|
||||
{total > 1 && (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={!canPage || currentPage === 0}
|
||||
aria-label={t('system_notice.pager.prev')}
|
||||
className="px-2 py-1 rounded border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => { if (canPage) onGoto(i); }}
|
||||
aria-label={t('system_notice.pager.goto').replace('{n}', String(i + 1))}
|
||||
aria-current={i === currentPage ? 'true' : undefined}
|
||||
disabled={!canPage && i !== currentPage}
|
||||
className={`w-2 h-2 rounded-full transition-colors ${
|
||||
i === currentPage
|
||||
? 'bg-blue-500 dark:bg-blue-400'
|
||||
: 'bg-slate-300 dark:bg-slate-600 hover:bg-slate-400 dark:hover:bg-slate-500 disabled:cursor-not-allowed'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!canPage || currentPage === total - 1}
|
||||
aria-label={t('system_notice.pager.next')}
|
||||
className="px-2 py-1 rounded border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-slate-400 tabular-nums">
|
||||
{t('system_notice.pager.counter')
|
||||
.replace('{current}', String(currentPage + 1))
|
||||
.replace('{total}', String(total))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA + dismiss link */}
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{ctaLabel && isLastPage ? (
|
||||
<button
|
||||
id={`notice-cta-${notice.id}`}
|
||||
onClick={onCTA}
|
||||
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
|
||||
>
|
||||
{ctaLabel}
|
||||
</button>
|
||||
) : (notice.dismissible || isLastPage) && (
|
||||
<button
|
||||
id={`notice-cta-${notice.id}`}
|
||||
onClick={isLastPage ? onDismissAll : onNext}
|
||||
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
|
||||
>
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
)}
|
||||
{notice.dismissible && isLastPage && ctaLabel && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalRenderer({ notices }: Props) {
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [pageAnnouncement, setPageAnnouncement] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const { dismiss } = useSystemNoticeStore();
|
||||
const { t, language } = useTranslation();
|
||||
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => typeof window !== 'undefined' && (window.matchMedia?.('(max-width: 639px)')?.matches ?? false)
|
||||
);
|
||||
|
||||
const [isDark, setIsDark] = useState(
|
||||
() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
const prefersReducedMotion =
|
||||
typeof window !== 'undefined' &&
|
||||
(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false);
|
||||
|
||||
const notice = notices[idx] ?? null;
|
||||
|
||||
// Non-dismissible notices lock the pager so users must act before advancing.
|
||||
const canPage = notice?.dismissible !== false;
|
||||
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const touchStartY = useRef<number | null>(null);
|
||||
// 'h' once we classify the gesture as horizontal, 'v' for vertical, null = unclassified
|
||||
const dragLockRef = useRef<'h' | 'v' | null>(null);
|
||||
// Sheet scroll offset at the moment the touch began — used to suppress dismiss-drag
|
||||
// when the user is scrolled into content and pans down to scroll back up.
|
||||
const scrollTopAtTouchStart = useRef(0);
|
||||
// Keep a ref to the current notice id so dismiss/CTA handlers see the latest value
|
||||
const noticeIdRef = useRef<string | null>(null);
|
||||
noticeIdRef.current = notice?.id ?? null;
|
||||
|
||||
// Page-slide animation refs.
|
||||
// isPageNavRef: set to true just before a user-initiated page change so the
|
||||
// grace-delay effect knows to run a slide instead of hide+show.
|
||||
// slideDirRef: 'right' = new content enters from the right (Next), 'left' = from the left (Prev).
|
||||
// contentWrapperRef: the div wrapping NoticeContent — we animate its transform directly.
|
||||
const isPageNavRef = useRef(false);
|
||||
const slideDirRef = useRef<'left' | 'right'>('right');
|
||||
// Mobile drag strip — wraps all 3 slots and is translated to reveal prev/current/next
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
// The sheet element itself — animated on vertical drag-to-dismiss
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
const clipRef = useRef<HTMLDivElement>(null);
|
||||
// Individual slot scroll containers (prev / center / next)
|
||||
const prevSlotRef = useRef<HTMLDivElement>(null);
|
||||
const contentWrapperRef = useRef<HTMLDivElement>(null); // center slot
|
||||
const nextSlotRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Mobile breakpoint
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia?.('(max-width: 639px)');
|
||||
if (!mq) return;
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
// Dark mode observer
|
||||
useEffect(() => {
|
||||
const obs = new MutationObserver(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
});
|
||||
obs.observe(document.documentElement, { attributeFilter: ['class'] });
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
// Clamp idx when notices array shrinks (e.g. after dismiss of the last page)
|
||||
useEffect(() => {
|
||||
if (notices.length > 0 && idx >= notices.length) {
|
||||
setIdx(notices.length - 1);
|
||||
}
|
||||
}, [notices.length, idx]);
|
||||
|
||||
// Fires on every notice-id change. Branches on whether this is a user-initiated
|
||||
// page navigation (slide the content wrapper) or a modal appear/dismiss-advance
|
||||
// (grace-delay the whole modal).
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
|
||||
// ── Page navigation: slide new content in, keep modal visible ────────────
|
||||
if (isPageNavRef.current) {
|
||||
isPageNavRef.current = false;
|
||||
const el = contentWrapperRef.current;
|
||||
if (el && !prefersReducedMotion) {
|
||||
// The handler already set el.style.transform to the start position
|
||||
// synchronously before setIdx was called. Trigger the transition here.
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transition = 'transform 260ms ease-out';
|
||||
el.style.transform = 'translateX(0)';
|
||||
const onEnd = () => {
|
||||
el.style.transition = '';
|
||||
el.style.transform = '';
|
||||
el.removeEventListener('transitionend', onEnd);
|
||||
};
|
||||
el.addEventListener('transitionend', onEnd);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Modal appearing / dismiss-advance: grace delay ────────────────────────
|
||||
setVisible(false);
|
||||
let cancelled = false;
|
||||
let timerId: ReturnType<typeof setTimeout> | undefined;
|
||||
const cancel1 = scheduleFrame(() => {
|
||||
const cancel2 = scheduleFrame(() => {
|
||||
timerId = setTimeout(() => {
|
||||
if (!cancelled) setVisible(true);
|
||||
}, 500);
|
||||
});
|
||||
if (cancelled) cancel2();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancel1();
|
||||
if (timerId !== undefined) clearTimeout(timerId);
|
||||
};
|
||||
}, [notice?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ESC key — closes all modal notices (only on last page so users read all notices)
|
||||
const isLastPage = notices.length <= 1 || idx === notices.length - 1;
|
||||
useEffect(() => {
|
||||
if (!visible || !notice?.dismissible || !isLastPage) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleDismissAll();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [visible, notice?.dismissible, isLastPage]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Arrow-key pager navigation
|
||||
useEffect(() => {
|
||||
if (!visible || notices.length <= 1) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
if (!canPage) return;
|
||||
// In RTL layouts the directional meaning of arrows is flipped
|
||||
const forward = isRtlLanguage(language) ? e.key === 'ArrowLeft' : e.key === 'ArrowRight';
|
||||
if (forward && idx < notices.length - 1) {
|
||||
triggerPageSlide('right');
|
||||
setIdx(idx + 1);
|
||||
announceIndex(idx + 1, notices.length);
|
||||
} else if (!forward && idx > 0) {
|
||||
triggerPageSlide('left');
|
||||
setIdx(idx - 1);
|
||||
announceIndex(idx - 1, notices.length);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [visible, idx, notices.length, canPage, language]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Body scroll lock
|
||||
useEffect(() => {
|
||||
if (visible && notice) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [visible, notice]);
|
||||
|
||||
// Reset center slot scroll to top on navigation (keyboard / pager buttons).
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
contentWrapperRef.current?.scrollTo({ top: 0 });
|
||||
}, [idx, isMobile]);
|
||||
|
||||
function announceIndex(newIdx: number, total: number) {
|
||||
setPageAnnouncement(
|
||||
t('system_notice.pager.position')
|
||||
.replace('{current}', String(newIdx + 1))
|
||||
.replace('{total}', String(total)),
|
||||
);
|
||||
}
|
||||
|
||||
// Dismiss current notice. The store removes it from the array, and the next
|
||||
// notice naturally shifts into notices[idx]. The clamp effect handles the
|
||||
// edge case where idx was pointing at the last item.
|
||||
function handleDismissById(id: string) {
|
||||
setVisible(false);
|
||||
dismiss(id);
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
const id = noticeIdRef.current;
|
||||
if (id) handleDismissById(id);
|
||||
}
|
||||
|
||||
// Dismiss every notice in the current modal list — used by the X button and ESC.
|
||||
function handleDismissAll() {
|
||||
setVisible(false);
|
||||
notices.forEach(n => dismiss(n.id));
|
||||
}
|
||||
|
||||
function handleCTA() {
|
||||
if (!notice) return;
|
||||
if (!notice.cta) {
|
||||
handleDismissAll();
|
||||
return;
|
||||
}
|
||||
if (notice.cta.kind === 'nav') {
|
||||
navigate(notice.cta.href);
|
||||
if (notice.dismissible !== false) handleDismissAll();
|
||||
} else {
|
||||
runNoticeAction(notice.cta.actionId, { navigate });
|
||||
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
|
||||
if (actionCta.dismissOnAction !== false) handleDismissAll();
|
||||
}
|
||||
}
|
||||
|
||||
function animatedDismissAll() {
|
||||
const sheet = sheetRef.current;
|
||||
if (!sheet || prefersReducedMotion) { handleDismissAll(); return; }
|
||||
sheet.style.transition = 'transform 300ms ease-out';
|
||||
sheet.style.transform = 'translateY(110%)';
|
||||
sheet.addEventListener('transitionend', function onDone() {
|
||||
sheet.removeEventListener('transitionend', onDone);
|
||||
handleDismissAll();
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
// Sets up the content wrapper's start transform SYNCHRONOUSLY (before React
|
||||
// re-renders with the new notice), then flags the grace-delay effect to slide
|
||||
// rather than hide+show.
|
||||
function triggerPageSlide(dir: 'left' | 'right') {
|
||||
isPageNavRef.current = true;
|
||||
slideDirRef.current = dir;
|
||||
if (!prefersReducedMotion) {
|
||||
const el = contentWrapperRef.current;
|
||||
if (el) {
|
||||
el.style.transition = 'none';
|
||||
el.style.transform = dir === 'right' ? 'translateX(100%)' : 'translateX(-100%)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrev() {
|
||||
if (!canPage || idx <= 0) return;
|
||||
const next = idx - 1;
|
||||
triggerPageSlide('left');
|
||||
setIdx(next);
|
||||
announceIndex(next, notices.length);
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (!canPage || idx >= notices.length - 1) return;
|
||||
const next = idx + 1;
|
||||
triggerPageSlide('right');
|
||||
setIdx(next);
|
||||
announceIndex(next, notices.length);
|
||||
}
|
||||
|
||||
function handleGoto(i: number) {
|
||||
if (!canPage || i === idx) return;
|
||||
triggerPageSlide(i > idx ? 'right' : 'left');
|
||||
setIdx(i);
|
||||
announceIndex(i, notices.length);
|
||||
}
|
||||
|
||||
// No notice to show
|
||||
if (!notice) return null;
|
||||
|
||||
// Pre-compute body with params interpolated
|
||||
const rawBody = t(notice.bodyKey);
|
||||
const body = notice.bodyParams
|
||||
? Object.entries(notice.bodyParams).reduce(
|
||||
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
|
||||
rawBody
|
||||
)
|
||||
: rawBody;
|
||||
|
||||
const title = t(notice.titleKey);
|
||||
const ctaLabel = notice.cta ? t(notice.cta.labelKey) : null;
|
||||
|
||||
const titleId = `notice-title-${notice.id}`;
|
||||
const bodyId = `notice-body-${notice.id}`;
|
||||
|
||||
// Animation classes
|
||||
const dur = prefersReducedMotion ? 'duration-[120ms]' : 'duration-[260ms]';
|
||||
const ease = visible ? 'ease-out' : 'ease-in';
|
||||
|
||||
const contentProps: ContentProps = {
|
||||
notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
||||
onDismiss: handleDismiss,
|
||||
onDismissAll: handleDismissAll,
|
||||
onCTA: handleCTA,
|
||||
total: notices.length,
|
||||
currentPage: idx,
|
||||
canPage,
|
||||
onPrev: handlePrev,
|
||||
onNext: handleNext,
|
||||
onGoto: handleGoto,
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
const mobileMotion = prefersReducedMotion
|
||||
? (visible ? 'opacity-100' : 'opacity-0')
|
||||
: (visible ? 'opacity-100 translate-y-0' : 'opacity-100 translate-y-full');
|
||||
|
||||
// Build ContentProps for an adjacent slot so NoticeContent renders correctly
|
||||
function buildSlotProps(n: SystemNoticeDTO, slotIdx: number): ContentProps {
|
||||
const slotRawBody = t(n.bodyKey);
|
||||
const slotBody = n.bodyParams
|
||||
? Object.entries(n.bodyParams).reduce(
|
||||
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
|
||||
slotRawBody
|
||||
)
|
||||
: slotRawBody;
|
||||
return {
|
||||
notice: n,
|
||||
title: t(n.titleKey),
|
||||
body: slotBody,
|
||||
ctaLabel: n.cta ? t(n.cta.labelKey) : null,
|
||||
titleId: `notice-title-${n.id}`,
|
||||
bodyId: `notice-body-${n.id}`,
|
||||
isDark,
|
||||
onDismiss: handleDismiss,
|
||||
onDismissAll: handleDismissAll,
|
||||
onCTA: handleCTA,
|
||||
total: notices.length,
|
||||
currentPage: slotIdx,
|
||||
canPage,
|
||||
onPrev: handlePrev,
|
||||
onNext: handleNext,
|
||||
onGoto: handleGoto,
|
||||
};
|
||||
}
|
||||
|
||||
const prevNotice = notices[idx - 1] ?? null;
|
||||
const nextNotice = notices[idx + 1] ?? null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="presentation">
|
||||
{/* Screen-reader page announcements */}
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||
onClick={notice.dismissible ? animatedDismissAll : undefined}
|
||||
/>
|
||||
{/* Bottom sheet */}
|
||||
<div
|
||||
ref={sheetRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={bodyId}
|
||||
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden h-[85dvh] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-[opacity,transform] ${dur} ${ease} ${mobileMotion}`}
|
||||
style={{ touchAction: 'pan-y' }}
|
||||
onTouchStart={e => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
dragLockRef.current = null;
|
||||
scrollTopAtTouchStart.current = contentWrapperRef.current?.scrollTop ?? 0;
|
||||
}}
|
||||
onTouchMove={e => {
|
||||
if (prefersReducedMotion) return;
|
||||
const startX = touchStartX.current;
|
||||
const startY = touchStartY.current;
|
||||
if (startX === null || startY === null) return;
|
||||
const dx = e.touches[0].clientX - startX;
|
||||
const dy = e.touches[0].clientY - startY;
|
||||
// Classify gesture direction on first significant movement
|
||||
if (!dragLockRef.current) {
|
||||
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
|
||||
dragLockRef.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
|
||||
// Reset adjacent slots to top before they slide into view.
|
||||
if (dragLockRef.current === 'h') {
|
||||
prevSlotRef.current?.scrollTo({ top: 0 });
|
||||
nextSlotRef.current?.scrollTo({ top: 0 });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (dragLockRef.current === 'h') {
|
||||
const strip = stripRef.current;
|
||||
if (!strip) return;
|
||||
strip.style.transition = 'none';
|
||||
// Strip base = -33.333% (center slot visible); dx offsets from there
|
||||
strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`;
|
||||
} else if (dragLockRef.current === 'v' && notice.dismissible) {
|
||||
// Only intercept downward drag for dismiss when the sheet is scrolled to the top.
|
||||
// If scrolled into content, let native pan-y scroll it back up.
|
||||
if (scrollTopAtTouchStart.current > 0) return;
|
||||
const sheet = sheetRef.current;
|
||||
if (!sheet || dy <= 0) return;
|
||||
sheet.style.transition = 'none';
|
||||
sheet.style.transform = `translateY(${dy}px)`;
|
||||
}
|
||||
}}
|
||||
onTouchEnd={e => {
|
||||
const startX = touchStartX.current;
|
||||
const startY = touchStartY.current;
|
||||
touchStartX.current = null;
|
||||
touchStartY.current = null;
|
||||
const lock = dragLockRef.current;
|
||||
dragLockRef.current = null;
|
||||
|
||||
if (lock === 'h') {
|
||||
if (startX === null) return;
|
||||
const deltaX = e.changedTouches[0].clientX - startX;
|
||||
const strip = stripRef.current;
|
||||
if (!strip) return;
|
||||
|
||||
const goNext = isRtlLanguage(language) ? deltaX > 50 : deltaX < -50;
|
||||
const goPrev = isRtlLanguage(language) ? deltaX < -50 : deltaX > 50;
|
||||
const canGoNext = canPage && idx < notices.length - 1;
|
||||
const canGoPrev = canPage && idx > 0;
|
||||
|
||||
if ((goNext && canGoNext) || (goPrev && canGoPrev)) {
|
||||
// Animate strip to the adjacent slot (-66.666% = next, 0% = prev)
|
||||
strip.style.transition = 'transform 200ms ease-out';
|
||||
strip.style.transform = goNext ? 'translateX(-66.666%)' : 'translateX(0%)';
|
||||
strip.addEventListener('transitionend', function onDone() {
|
||||
strip.removeEventListener('transitionend', onDone);
|
||||
strip.style.transition = 'none';
|
||||
// Render new content into the center slot BEFORE moving the strip,
|
||||
// so the browser never paints old content at the center position.
|
||||
const newIdx = goNext ? idx + 1 : idx - 1;
|
||||
flushSync(() => {
|
||||
isPageNavRef.current = true;
|
||||
setIdx(newIdx);
|
||||
announceIndex(newIdx, notices.length);
|
||||
});
|
||||
// Reset all slot scrolls so the new center starts at top.
|
||||
prevSlotRef.current?.scrollTo({ top: 0 });
|
||||
contentWrapperRef.current?.scrollTo({ top: 0 });
|
||||
nextSlotRef.current?.scrollTo({ top: 0 });
|
||||
strip.style.transform = 'translateX(-33.333%)';
|
||||
}, { once: true });
|
||||
} else {
|
||||
// Spring back to center
|
||||
strip.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
|
||||
strip.style.transform = 'translateX(-33.333%)';
|
||||
strip.addEventListener('transitionend', function onSnap() {
|
||||
strip.removeEventListener('transitionend', onSnap);
|
||||
strip.style.transition = '';
|
||||
strip.style.transform = 'translateX(-33.333%)';
|
||||
}, { once: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Vertical drag — animated dismiss or spring back (only when at scroll top)
|
||||
if (lock === 'v' && startY !== null && scrollTopAtTouchStart.current === 0) {
|
||||
const deltaY = e.changedTouches[0].clientY - startY;
|
||||
const sheet = sheetRef.current;
|
||||
if (deltaY > 80 && notice.dismissible) {
|
||||
animatedDismissAll();
|
||||
} else if (sheet && deltaY > 0) {
|
||||
sheet.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
|
||||
sheet.style.transform = 'translateY(0)';
|
||||
sheet.addEventListener('transitionend', function onSnap() {
|
||||
sheet.removeEventListener('transitionend', onSnap);
|
||||
sheet.style.transition = '';
|
||||
sheet.style.transform = '';
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Drag handle — fixed, does not scroll */}
|
||||
<div className="pt-3 pb-1 flex justify-center shrink-0">
|
||||
<div className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
|
||||
</div>
|
||||
{/* Clip container — fills remaining sheet height, hides adjacent slots */}
|
||||
<div style={{ flex: '1 1 0', minHeight: 0, overflow: 'hidden', width: '100%' }}>
|
||||
{/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */}
|
||||
<div
|
||||
ref={stripRef}
|
||||
style={{ display: 'flex', width: '300%', height: '100%', alignItems: 'stretch', transform: 'translateX(-33.333%)' }}
|
||||
>
|
||||
<div ref={prevSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{prevNotice && <NoticeContent {...buildSlotProps(prevNotice, idx - 1)} />}
|
||||
</div>
|
||||
<div ref={contentWrapperRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
<NoticeContent {...contentProps} />
|
||||
</div>
|
||||
<div ref={nextSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{nextNotice && <NoticeContent {...buildSlotProps(nextNotice, idx + 1)} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop centered modal
|
||||
const maxWidth = notice.severity === 'critical' ? 'max-w-[680px]' : 'max-w-[620px]';
|
||||
const desktopMotion = prefersReducedMotion
|
||||
? (visible ? 'opacity-100' : 'opacity-0')
|
||||
: (visible ? 'opacity-100 scale-100' : 'opacity-0 scale-[0.97]');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||
role="presentation"
|
||||
onClick={notice.dismissible && isLastPage ? handleDismissAll : undefined}
|
||||
>
|
||||
{/* Screen-reader page announcements */}
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={bodyId}
|
||||
className={`w-full ${maxWidth} rounded-2xl overflow-hidden overflow-y-auto max-h-[90vh] shadow-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 transition-all ${dur} ${ease} ${desktopMotion}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div ref={contentWrapperRef}>
|
||||
<NoticeContent {...contentProps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
export interface NoticeActionContext {
|
||||
navigate: NavigateFunction;
|
||||
}
|
||||
type NoticeActionHandler = (ctx: NoticeActionContext) => void | Promise<void>;
|
||||
|
||||
const actions = new Map<string, NoticeActionHandler>();
|
||||
|
||||
export function registerNoticeAction(id: string, handler: NoticeActionHandler): void {
|
||||
actions.set(id, handler);
|
||||
}
|
||||
|
||||
export function runNoticeAction(id: string, ctx: NoticeActionContext): void {
|
||||
const handler = actions.get(id);
|
||||
if (!handler) {
|
||||
console.error(`[systemNotices] unknown action CTA id: "${id}". Register it via registerNoticeAction().`);
|
||||
return;
|
||||
}
|
||||
void handler(ctx);
|
||||
}
|
||||
@@ -37,9 +37,10 @@ describe('TodoListPanel', () => {
|
||||
expect(screen.getByText('Buy tickets')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TODO-002: shows Add new task button', () => {
|
||||
render(<TodoListPanel tripId={1} items={[]} />);
|
||||
expect(screen.getByText('Add new task...')).toBeInTheDocument();
|
||||
it('FE-COMP-TODO-002: raising addItemSignal opens the new task form', async () => {
|
||||
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />);
|
||||
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />);
|
||||
await screen.findByText('Create task');
|
||||
});
|
||||
|
||||
it('FE-COMP-TODO-003: sidebar filter buttons are rendered', () => {
|
||||
@@ -119,11 +120,9 @@ describe('TodoListPanel', () => {
|
||||
expect(screen.getByText(/1 \/ 2 completed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TODO-011: clicking Add new task opens detail form', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TodoListPanel tripId={1} items={[]} />);
|
||||
await user.click(screen.getByText('Add new task...'));
|
||||
// The detail pane shows "Create task" button
|
||||
it('FE-COMP-TODO-011: raising addItemSignal opens detail form with Create task button', async () => {
|
||||
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />);
|
||||
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />);
|
||||
await screen.findByText('Create task');
|
||||
});
|
||||
|
||||
@@ -398,15 +397,12 @@ describe('TodoListPanel', () => {
|
||||
return HttpResponse.json({ item: buildTodoItem({ id: 99, name: 'Brand New Task' }) });
|
||||
}),
|
||||
);
|
||||
render(<TodoListPanel tripId={1} items={[]} />);
|
||||
// Open the new task pane
|
||||
await user.click(screen.getByText('Add new task...'));
|
||||
// Wait for "Create task" button to appear
|
||||
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />);
|
||||
// Raising the signal opens the new task pane (simulates the toolbar button click)
|
||||
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />);
|
||||
await screen.findByText('Create task');
|
||||
// Type a task name in the autoFocus input (Task name placeholder)
|
||||
const nameInput = screen.getByPlaceholderText('Task name');
|
||||
await user.type(nameInput, 'Brand New Task');
|
||||
// Click the Create task button
|
||||
await user.click(screen.getByText('Create task'));
|
||||
await waitFor(() => expect(postCalled).toBe(true));
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -37,7 +38,7 @@ type FilterType = 'all' | 'my' | 'overdue' | 'done' | string
|
||||
|
||||
interface Member { id: number; username: string; avatar: string | null }
|
||||
|
||||
export default function TodoListPanel({ tripId, items }: { tripId: number; items: TodoItem[] }) {
|
||||
export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tripId: number; items: TodoItem[]; addItemSignal?: number }) {
|
||||
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
|
||||
const canEdit = useCanDo('packing_edit')
|
||||
const toast = useToast()
|
||||
@@ -55,6 +56,15 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
||||
const [filter, setFilter] = useState<FilterType>('all')
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [isAddingNew, setIsAddingNew] = useState(false)
|
||||
const lastHandledAddSignal = useRef(addItemSignal)
|
||||
|
||||
useEffect(() => {
|
||||
if (addItemSignal !== lastHandledAddSignal.current && addItemSignal > 0) {
|
||||
setSelectedId(null)
|
||||
setIsAddingNew(true)
|
||||
}
|
||||
lastHandledAddSignal.current = addItemSignal
|
||||
}, [addItemSignal])
|
||||
const [sortByPrio, setSortByPrio] = useState(false)
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
@@ -160,12 +170,12 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
||||
{/* ── Left Sidebar ── */}
|
||||
<div style={{
|
||||
width: isMobile ? 52 : 220, flexShrink: 0, borderRight: '1px solid var(--border-faint)',
|
||||
padding: isMobile ? '12px 6px' : '16px 10px', display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto',
|
||||
padding: isMobile ? '12px 6px' : '16px 12px 16px 0', display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto',
|
||||
transition: 'width 0.2s',
|
||||
}}>
|
||||
{/* Progress Card */}
|
||||
{!isMobile && <div style={{
|
||||
margin: '0 6px 12px', padding: '14px 14px 12px', borderRadius: 14,
|
||||
margin: '0 0 12px', padding: '14px 14px 12px', borderRadius: 14,
|
||||
background: 'var(--bg-hover)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.02)',
|
||||
@@ -192,9 +202,12 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
||||
<SidebarItem id="overdue" icon={AlertCircle} label={t('todo.filter.overdue')} count={overdueCount} />
|
||||
<SidebarItem id="done" icon={CheckCheck} label={t('todo.filter.done')} count={doneCount} />
|
||||
|
||||
{/* Sort by priority */}
|
||||
{/* Sort by */}
|
||||
{!isMobile && <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', padding: '16px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('todo.sidebar.sortBy')}
|
||||
</div>}
|
||||
<button onClick={() => setSortByPrio(v => !v)}
|
||||
title={isMobile ? t('todo.sortByPrio') : undefined}
|
||||
title={isMobile ? t('todo.priority') : undefined}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
|
||||
gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px',
|
||||
@@ -206,7 +219,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
||||
onMouseEnter={e => { if (!sortByPrio) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!sortByPrio) e.currentTarget.style.background = 'transparent' }}>
|
||||
<Flag size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
|
||||
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.sortByPrio')}</span>}
|
||||
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.priority')}</span>}
|
||||
</button>
|
||||
|
||||
{/* Categories */}
|
||||
@@ -251,27 +264,6 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add task */}
|
||||
{canEdit && (
|
||||
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<button
|
||||
onClick={() => { setSelectedId(null); setIsAddingNew(true) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '9px 16px', borderRadius: 8,
|
||||
background: isAddingNew ? 'var(--text-primary)' : 'var(--bg-hover)',
|
||||
color: isAddingNew ? 'var(--bg-primary)' : 'var(--text-primary)',
|
||||
border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 13, fontWeight: 600, transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--text-primary)'; e.currentTarget.style.color = 'var(--bg-primary)'; e.currentTarget.style.borderColor = 'var(--text-primary)' } }}
|
||||
onMouseLeave={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-primary)'; e.currentTarget.style.borderColor = 'var(--border-primary)' } }}>
|
||||
<Plus size={14} />
|
||||
{t('todo.addItem')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
|
||||
{filtered.length === 0 ? null : (
|
||||
@@ -394,7 +386,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
||||
)}
|
||||
{selectedItem && !isAddingNew && isMobile && (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setSelectedId(null) }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
|
||||
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
|
||||
<DetailPane
|
||||
@@ -407,19 +399,28 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isAddingNew && !selectedItem && !isMobile && (
|
||||
<NewTaskPane
|
||||
tripId={tripId}
|
||||
categories={categories}
|
||||
members={members}
|
||||
defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
|
||||
onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
|
||||
onClose={() => setIsAddingNew(false)}
|
||||
/>
|
||||
)}
|
||||
{isAddingNew && !selectedItem && isMobile && (
|
||||
{isAddingNew && !selectedItem && !isMobile && ReactDOM.createPortal(
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
|
||||
className="modal-backdrop"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(15,23,42,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'flex-start', paddingTop: 'calc(var(--nav-h) + 60px)', paddingBottom: 40 }}>
|
||||
<div style={{ width: 'min(520px, 92vw)', maxHeight: 'calc(100vh - var(--nav-h) - 120px)', overflow: 'auto', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.25)' }}
|
||||
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px' } } }}>
|
||||
<NewTaskPane
|
||||
tripId={tripId}
|
||||
categories={categories}
|
||||
members={members}
|
||||
defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
|
||||
onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
|
||||
onClose={() => setIsAddingNew(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{isAddingNew && !selectedItem && isMobile && ReactDOM.createPortal(
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
||||
className="modal-backdrop"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
|
||||
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
|
||||
<NewTaskPane
|
||||
@@ -431,7 +432,8 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
||||
onClose={() => setIsAddingNew(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -647,6 +649,7 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
|
||||
const [desc, setDesc] = useState('')
|
||||
const [dueDate, setDueDate] = useState('')
|
||||
const [category, setCategory] = useState(defaultCategory || '')
|
||||
const [addingCategory, setAddingCategoryInline] = useState(false)
|
||||
const [assignedUserId, setAssignedUserId] = useState<number | null>(null)
|
||||
const [priority, setPriority] = useState(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -657,9 +660,10 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
|
||||
if (!name.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const trimmedCategory = category.trim()
|
||||
const item = await addTodoItem(tripId, {
|
||||
name: name.trim(), description: desc || null, priority,
|
||||
due_date: dueDate || null, category: category || null,
|
||||
due_date: dueDate || null, category: trimmedCategory || null,
|
||||
assigned_user_id: assignedUserId,
|
||||
} as any)
|
||||
if (item?.id) onCreated(item.id)
|
||||
@@ -696,19 +700,49 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.category')}</label>
|
||||
<CustomSelect
|
||||
value={category}
|
||||
onChange={v => setCategory(v)}
|
||||
options={[
|
||||
{ value: '', label: t('todo.noCategory') },
|
||||
...categories.map(c => ({
|
||||
value: c, label: c,
|
||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
||||
})),
|
||||
]}
|
||||
placeholder={t('todo.noCategory')}
|
||||
size="sm"
|
||||
/>
|
||||
{addingCategory ? (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
autoFocus
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') setAddingCategoryInline(false); if (e.key === 'Escape') { setCategory(''); setAddingCategoryInline(false) } }}
|
||||
placeholder={t('todo.newCategory')}
|
||||
style={{ flex: 1, fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
|
||||
/>
|
||||
<button type="button" onClick={() => setAddingCategoryInline(false)}
|
||||
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-primary)' }}>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<CustomSelect
|
||||
value={category}
|
||||
onChange={v => setCategory(v)}
|
||||
options={[
|
||||
{ value: '', label: t('todo.noCategory') },
|
||||
...categories.map(c => ({
|
||||
value: c, label: c,
|
||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
||||
})),
|
||||
...(category && !categories.includes(category) ? [{
|
||||
value: category, label: `${category} (${t('todo.newCategoryLabel') || 'new'})`,
|
||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#9ca3af', display: 'inline-block' }} />,
|
||||
}] : []),
|
||||
]}
|
||||
placeholder={t('todo.noCategory')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={() => { setCategory(''); setAddingCategoryInline(true) }}
|
||||
title={t('todo.newCategory')}
|
||||
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function VacayCalendar() {
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
|
||||
<button
|
||||
onClick={() => setCompanyMode(false)}
|
||||
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
|
||||
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-[background-color,color,border-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{
|
||||
background: !companyMode ? 'var(--text-primary)' : 'transparent',
|
||||
color: !companyMode ? 'var(--bg-card)' : 'var(--text-muted)',
|
||||
@@ -107,7 +107,7 @@ export default function VacayCalendar() {
|
||||
{companyHolidaysEnabled && (
|
||||
<button
|
||||
onClick={() => setCompanyMode(true)}
|
||||
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
|
||||
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-[background-color,color,border-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{
|
||||
background: companyMode ? '#d97706' : 'transparent',
|
||||
color: companyMode ? '#fff' : 'var(--text-muted)',
|
||||
|
||||
@@ -121,9 +121,9 @@ export default function VacayPersons() {
|
||||
|
||||
{/* Invite Modal — Portal to body to avoid z-index issues */}
|
||||
{showInvite && ReactDOM.createPortal(
|
||||
<div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
|
||||
<div className="fixed inset-0 flex items-center justify-center px-4 trek-backdrop-enter" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
|
||||
onClick={() => setShowInvite(false)}>
|
||||
<div className="rounded-2xl shadow-2xl w-full max-w-sm" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
|
||||
<div className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm" style={{ background: 'var(--bg-card)' }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.inviteUser')}</h2>
|
||||
@@ -164,9 +164,9 @@ export default function VacayPersons() {
|
||||
|
||||
{/* Color Picker Modal — Portal to body */}
|
||||
{showColorPicker && ReactDOM.createPortal(
|
||||
<div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
|
||||
<div className="fixed inset-0 flex items-center justify-center px-4 trek-backdrop-enter" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
|
||||
onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }}>
|
||||
<div className="rounded-2xl shadow-2xl w-full max-w-xs" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
|
||||
<div className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-xs" style={{ background: 'var(--bg-card)' }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.changeColor')}</h2>
|
||||
@@ -178,7 +178,7 @@ export default function VacayPersons() {
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button key={c} onClick={() => handleColorChange(c)}
|
||||
className={`w-8 h-8 rounded-full transition-all ${editingUserColor === c ? 'ring-2 ring-offset-2 scale-110' : 'hover:scale-110'}`}
|
||||
className={`w-8 h-8 rounded-full transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${editingUserColor === c ? 'ring-2 ring-offset-2 scale-110' : 'hover:scale-110'}`}
|
||||
style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -87,7 +87,10 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
|
||||
<span className="text-[10px] tabular-nums" style={{ color: 'var(--text-faint)' }}>{s.used}/{s.total_available}</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: s.person_color }} />
|
||||
<div
|
||||
className="trek-bar-fill h-full rounded-full transition-[width] duration-500 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ width: `${pct}%`, backgroundColor: s.person_color }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{/* Days — editable */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind } from 'lucide-react'
|
||||
import { weatherApi } from '../../api/client'
|
||||
import { fetchWeather } from '../../services/weatherQueue'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
const WEATHER_ICON_MAP = {
|
||||
@@ -61,7 +61,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath
|
||||
// Climate data: use from cache but re-fetch in background to upgrade to forecast
|
||||
else if (cached.type === 'climate') {
|
||||
setWeather(cached)
|
||||
weatherApi.get(lat, lng, date)
|
||||
fetchWeather(lat, lng, date)
|
||||
.then(data => {
|
||||
if (!data.error && data.temp !== undefined && data.type === 'forecast') {
|
||||
setWeatherCache(cacheKey, data)
|
||||
@@ -77,7 +77,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
weatherApi.get(lat, lng, date)
|
||||
fetchWeather(lat, lng, date)
|
||||
.then(data => {
|
||||
if (data.error || data.temp === undefined) {
|
||||
setFailed(true)
|
||||
|
||||
@@ -40,16 +40,13 @@ export default function ConfirmDialog({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center px-4"
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="rounded-2xl shadow-2xl w-full max-w-sm p-6"
|
||||
style={{
|
||||
animation: 'modalIn 0.2s ease-out forwards',
|
||||
background: 'var(--bg-card)',
|
||||
}}
|
||||
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm p-6"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
@@ -90,12 +87,6 @@ export default function ConfirmDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||
if (!menu) return null
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div ref={ref} style={{
|
||||
<div ref={ref} className="trek-popover-enter" style={{
|
||||
position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999,
|
||||
background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
|
||||
border: '1px solid var(--border-primary)',
|
||||
@@ -73,7 +73,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
minWidth: 160,
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
animation: 'ctxIn 0.1s ease-out',
|
||||
transformOrigin: 'top left',
|
||||
}}>
|
||||
{menu.items.filter(Boolean).map((item, i) => {
|
||||
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
|
||||
@@ -95,7 +95,6 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<style>{`@keyframes ctxIn { from { opacity: 0; transform: scale(0.95) } to { opacity: 1; transform: scale(1) } }`}</style>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
|
||||
interface CopyButtonProps {
|
||||
value: string
|
||||
size?: number
|
||||
title?: string
|
||||
className?: string
|
||||
onCopy?: () => void
|
||||
}
|
||||
|
||||
// Button that morphs between copy icon and check icon for 1.5s after click.
|
||||
export function CopyButton({ value, size = 14, title, className, onCopy }: CopyButtonProps): React.ReactElement {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleClick = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
setCopied(true)
|
||||
onCopy?.()
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}, [value, onCopy])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
title={title}
|
||||
className={className}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: size + 12,
|
||||
height: size + 12,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: copied ? '#22c55e' : 'var(--text-muted)',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<Copy size={size} style={{
|
||||
position: 'absolute',
|
||||
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||
opacity: copied ? 0 : 1,
|
||||
transform: copied ? 'scale(0.6) rotate(-45deg)' : 'scale(1) rotate(0)',
|
||||
}} />
|
||||
<Check size={size} style={{
|
||||
position: 'absolute',
|
||||
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||
opacity: copied ? 1 : 0,
|
||||
transform: copied ? 'scale(1) rotate(0)' : 'scale(0.6) rotate(45deg)',
|
||||
strokeWidth: 2.5,
|
||||
}} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyButton
|
||||
@@ -104,7 +104,7 @@ export default function CustomSelect({
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'none' }} />
|
||||
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 200ms cubic-bezier(0.23,1,0.32,1)', transform: open ? 'rotate(180deg)' : 'none' }} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
@@ -128,7 +128,9 @@ export default function CustomSelect({
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
||||
overflow: 'hidden',
|
||||
animation: 'selectIn 0.15s ease-out',
|
||||
animation: 'trek-menu-enter 200ms cubic-bezier(0.23, 1, 0.32, 1)',
|
||||
transformOrigin: 'top center',
|
||||
willChange: 'transform, opacity',
|
||||
}}>
|
||||
{/* Search */}
|
||||
{searchable && (
|
||||
@@ -194,12 +196,6 @@ export default function CustomSelect({
|
||||
document.body
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes selectIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { useState, type ImgHTMLAttributes } from 'react'
|
||||
|
||||
interface LoadingImageProps extends ImgHTMLAttributes<HTMLImageElement> {
|
||||
containerClassName?: string
|
||||
containerStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
// Image with shimmer-placeholder until loaded. Drops the shimmer once native load fires.
|
||||
export function LoadingImage({
|
||||
containerClassName, containerStyle, className, style, onLoad, ...imgProps
|
||||
}: LoadingImageProps): React.ReactElement {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
return (
|
||||
<div className={containerClassName} style={{ position: 'relative', overflow: 'hidden', ...containerStyle }}>
|
||||
{!loaded && (
|
||||
<div
|
||||
className="trek-skeleton"
|
||||
style={{ position: 'absolute', inset: 0, borderRadius: 0 }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
{...imgProps}
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
opacity: loaded ? 1 : 0,
|
||||
transition: 'opacity 300ms cubic-bezier(0.23, 1, 0.32, 1)',
|
||||
}}
|
||||
onLoad={e => { setLoaded(true); onLoad?.(e) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadingImage
|
||||
@@ -50,8 +50,8 @@ export default function Modal({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20, overflow: 'hidden' }}
|
||||
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop trek-backdrop-enter"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
|
||||
@@ -60,14 +60,11 @@ export default function Modal({
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
trek-modal-enter
|
||||
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||
flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
|
||||
animate-in fade-in zoom-in-95 duration-200
|
||||
`}
|
||||
style={{
|
||||
animation: 'modalIn 0.2s ease-out forwards',
|
||||
background: 'var(--bg-card)',
|
||||
}}
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
@@ -96,12 +93,6 @@ export default function Modal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { getCategoryIcon } from './categoryIcons'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
interface Category {
|
||||
@@ -18,10 +19,12 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
|
||||
// Observe visibility — fetch photo only when avatar enters viewport
|
||||
useEffect(() => {
|
||||
if (place.image_url) { setVisible(true); return }
|
||||
if (!placesPhotosEnabled) return
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
// Check if already cached — show immediately without waiting for intersection
|
||||
@@ -37,6 +40,7 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
if (place.image_url) { setPhotoSrc(place.image_url); return }
|
||||
if (!placesPhotosEnabled) return
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
|
||||
// Simple skeleton placeholder with shimmer. Size via className or props.
|
||||
export function Skeleton({
|
||||
width, height, radius, className, style,
|
||||
}: {
|
||||
width?: number | string
|
||||
height?: number | string
|
||||
radius?: number | string
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={`trek-skeleton ${className ?? ''}`.trim()}
|
||||
style={{
|
||||
width,
|
||||
height: height ?? 14,
|
||||
borderRadius: radius,
|
||||
...style,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Trip card skeleton matching SpotlightCard layout
|
||||
export function SpotlightSkeleton(): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-3xl overflow-hidden mb-8"
|
||||
style={{ minHeight: 340, background: 'var(--bg-tertiary)' }}
|
||||
>
|
||||
<div className="trek-skeleton absolute inset-0" style={{ borderRadius: 24 }} />
|
||||
<div className="relative p-6 flex flex-col justify-end" style={{ minHeight: 340 }}>
|
||||
<Skeleton width={160} height={40} radius={8} style={{ marginBottom: 8 }} />
|
||||
<Skeleton width={220} height={16} radius={4} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Trip list item skeleton
|
||||
export function TripCardSkeleton(): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
>
|
||||
<Skeleton height={140} radius={0} />
|
||||
<div className="p-4 flex flex-col gap-2">
|
||||
<Skeleton width="60%" height={18} />
|
||||
<Skeleton width="40%" height={12} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Day sidebar skeleton row
|
||||
export function DaySkeleton(): React.ReactElement {
|
||||
return (
|
||||
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Skeleton width={120} height={16} />
|
||||
<Skeleton width="80%" height={12} />
|
||||
<Skeleton width="60%" height={12} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Skeleton
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
|
||||
export interface SlidingTab<T extends string> {
|
||||
id: T
|
||||
label: React.ReactNode
|
||||
title?: string
|
||||
icon?: React.ComponentType<{ size?: number; className?: string }>
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface SlidingTabsProps<T extends string> {
|
||||
tabs: readonly SlidingTab<T>[]
|
||||
activeTab: T
|
||||
onChange: (id: T) => void
|
||||
size?: 'sm' | 'md'
|
||||
fullWidth?: boolean
|
||||
className?: string
|
||||
indicatorColor?: string
|
||||
indicatorTextColor?: string
|
||||
}
|
||||
|
||||
// Stripe-style sliding indicator — der aktive Pill gleitet zwischen Tabs.
|
||||
// Nutzt gemessene Offsets der Buttons + CSS transform.
|
||||
export function SlidingTabs<T extends string>({
|
||||
tabs, activeTab, onChange, size = 'md', fullWidth, className,
|
||||
indicatorColor = 'var(--accent)', indicatorTextColor = 'var(--accent-text)',
|
||||
}: SlidingTabsProps<T>): React.ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const tabRefs = useRef<Map<T, HTMLButtonElement | null>>(new Map())
|
||||
const [indicator, setIndicator] = useState<{ left: number; width: number; ready: boolean }>({ left: 0, width: 0, ready: false })
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const active = tabRefs.current.get(activeTab)
|
||||
const container = containerRef.current
|
||||
if (!active || !container) return
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const activeRect = active.getBoundingClientRect()
|
||||
setIndicator({
|
||||
left: activeRect.left - containerRect.left + container.scrollLeft,
|
||||
width: activeRect.width,
|
||||
ready: true,
|
||||
})
|
||||
}, [activeTab, tabs.length])
|
||||
|
||||
const padding = size === 'sm' ? '5px 12px' : '6px 14px'
|
||||
const fontSize = size === 'sm' ? 12 : 13
|
||||
const borderRadius = size === 'sm' ? 18 : 20
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{
|
||||
position: 'relative', display: 'flex', alignItems: 'center',
|
||||
gap: 2, overflowX: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none',
|
||||
width: fullWidth ? '100%' : undefined,
|
||||
}}
|
||||
>
|
||||
{/* Sliding indicator */}
|
||||
{indicator.ready && (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: indicator.left,
|
||||
width: indicator.width,
|
||||
height: size === 'sm' ? 26 : 30,
|
||||
background: indicatorColor,
|
||||
borderRadius,
|
||||
transform: 'translateY(-50%)',
|
||||
transition: 'left 320ms cubic-bezier(0.77, 0, 0.175, 1), width 320ms cubic-bezier(0.77, 0, 0.175, 1)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
willChange: 'left, width',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tabs.map(tab => {
|
||||
const isActive = tab.id === activeTab
|
||||
const Icon = tab.icon
|
||||
const btnStyle: CSSProperties = {
|
||||
position: 'relative', zIndex: 1,
|
||||
flexShrink: 0,
|
||||
padding,
|
||||
borderRadius,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
background: 'transparent',
|
||||
color: isActive ? indicatorTextColor : 'var(--text-muted)',
|
||||
fontFamily: 'inherit',
|
||||
transition: 'color 220ms cubic-bezier(0.23, 1, 0.32, 1)',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
flex: fullWidth ? 1 : undefined,
|
||||
justifyContent: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={el => { tabRefs.current.set(tab.id, el) }}
|
||||
onClick={() => onChange(tab.id)}
|
||||
style={btnStyle}
|
||||
title={tab.title ?? (typeof tab.label === 'string' ? tab.label : undefined)}
|
||||
>
|
||||
{Icon && <Icon size={size === 'sm' ? 13 : 15} />}
|
||||
{tab.label}
|
||||
{tab.count != null && (
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600,
|
||||
padding: '1px 6px', borderRadius: 99, minWidth: 16,
|
||||
background: isActive ? 'rgba(255,255,255,0.22)' : 'var(--bg-tertiary)',
|
||||
color: isActive ? 'inherit' : 'var(--text-faint)',
|
||||
textAlign: 'center',
|
||||
}}>{tab.count}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SlidingTabs
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
|
||||
type Placement = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
interface TooltipProps {
|
||||
label: string
|
||||
placement?: Placement
|
||||
delay?: number
|
||||
disabled?: boolean
|
||||
children: React.ReactElement
|
||||
}
|
||||
|
||||
export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, children }: TooltipProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
|
||||
const triggerRef = useRef<HTMLElement | null>(null)
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null)
|
||||
const timerRef = useRef<number | null>(null)
|
||||
|
||||
const show = () => {
|
||||
if (disabled || !label) return
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||
timerRef.current = window.setTimeout(() => setOpen(true), delay)
|
||||
}
|
||||
const hide = () => {
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => () => { if (timerRef.current) window.clearTimeout(timerRef.current) }, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !triggerRef.current) return
|
||||
const r = triggerRef.current.getBoundingClientRect()
|
||||
const tipW = tooltipRef.current?.offsetWidth ?? 0
|
||||
const tipH = tooltipRef.current?.offsetHeight ?? 0
|
||||
const gap = 6
|
||||
let top = 0, left = 0
|
||||
if (placement === 'top') { top = r.top - tipH - gap; left = r.left + r.width / 2 - tipW / 2 }
|
||||
else if (placement === 'bottom') { top = r.bottom + gap; left = r.left + r.width / 2 - tipW / 2 }
|
||||
else if (placement === 'left') { top = r.top + r.height / 2 - tipH / 2; left = r.left - tipW - gap }
|
||||
else { top = r.top + r.height / 2 - tipH / 2; left = r.right + gap }
|
||||
const pad = 6
|
||||
left = Math.max(pad, Math.min(left, window.innerWidth - tipW - pad))
|
||||
top = Math.max(pad, Math.min(top, window.innerHeight - tipH - pad))
|
||||
setCoords({ top, left })
|
||||
}, [open, placement, label])
|
||||
|
||||
const child = React.Children.only(children)
|
||||
const trigger = React.cloneElement(child, {
|
||||
ref: (node: HTMLElement | null) => {
|
||||
triggerRef.current = node
|
||||
const r = (child as any).ref
|
||||
if (typeof r === 'function') r(node)
|
||||
else if (r && typeof r === 'object') r.current = node
|
||||
},
|
||||
onMouseEnter: (e: any) => { show(); child.props.onMouseEnter?.(e) },
|
||||
onMouseLeave: (e: any) => { hide(); child.props.onMouseLeave?.(e) },
|
||||
onFocus: (e: any) => { show(); child.props.onFocus?.(e) },
|
||||
onBlur: (e: any) => { hide(); child.props.onBlur?.(e) },
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
{open && ReactDOM.createPortal(
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
role="tooltip"
|
||||
className="trek-popover-enter"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: coords?.top ?? -9999,
|
||||
left: coords?.left ?? -9999,
|
||||
visibility: coords ? 'visible' : 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 100000,
|
||||
background: 'var(--bg-card, #ffffff)',
|
||||
color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
padding: '5px 10px',
|
||||
borderRadius: 8,
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tooltip
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
// Zählt beim Mount von 0 auf target hoch. Feste Dauer mit ease-out-quint.
|
||||
export function useCountUp(target: number, duration = 800): number {
|
||||
const [value, setValue] = useState(0)
|
||||
const startRef = useRef<number | null>(null)
|
||||
const frameRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
|
||||
const isJsdom = typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent ?? '')
|
||||
if (reduced || isJsdom || target <= 0) { setValue(target); return }
|
||||
|
||||
startRef.current = null
|
||||
const step = (now: number) => {
|
||||
if (startRef.current == null) startRef.current = now
|
||||
const elapsed = now - startRef.current
|
||||
const t = Math.min(elapsed / duration, 1)
|
||||
// ease-out-quint
|
||||
const eased = 1 - Math.pow(1 - t, 5)
|
||||
setValue(Math.round(target * eased))
|
||||
if (t < 1) frameRef.current = requestAnimationFrame(step)
|
||||
}
|
||||
frameRef.current = requestAnimationFrame(step)
|
||||
return () => { if (frameRef.current) cancelAnimationFrame(frameRef.current) }
|
||||
}, [target, duration])
|
||||
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
/** Returns true when the viewport is below the lg breakpoint (1024px). */
|
||||
export function useIsMobile(): boolean {
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => typeof window !== 'undefined' && window.innerWidth < 1024,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 1023px)')
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||
setIsMobile(mq.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
return isMobile
|
||||
}
|
||||
@@ -1,50 +1,123 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { calculateSegments } from '../components/Map/RouteCalculator'
|
||||
import type { TripStoreState } from '../store/tripStore'
|
||||
import type { RouteSegment, RouteResult } from '../types'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'cruise']
|
||||
|
||||
/**
|
||||
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
||||
* day assignments, draws a straight-line route, and optionally fetches per-segment
|
||||
* driving/walking durations via OSRM. Aborts in-flight requests when the day changes.
|
||||
*/
|
||||
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null) {
|
||||
const [route, setRoute] = useState<[number, number][] | null>(null)
|
||||
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
||||
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
|
||||
const routeAbortRef = useRef<AbortController | null>(null)
|
||||
// Keep a ref to the latest tripStore so updateRouteForDay never has a stale closure
|
||||
const tripStoreRef = useRef(tripStore)
|
||||
tripStoreRef.current = tripStore
|
||||
const reservationsForSignature = useTripStore((s) => s.reservations)
|
||||
|
||||
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||
if (!dayId) { setRoute(null); setRouteSegments([]); return }
|
||||
const currentAssignments = tripStoreRef.current.assignments || {}
|
||||
// Read directly from store (not a render-phase ref) so callers after optimistic
|
||||
// updates or non-optimistic deletes always see the latest assignments.
|
||||
const currentAssignments = useTripStore.getState().assignments || {}
|
||||
const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const waypoints = da.map((a) => a.place).filter((p) => p?.lat && p?.lng)
|
||||
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
|
||||
setRoute(waypoints.map((p) => [p.lat!, p.lng!]))
|
||||
const allReservations = useTripStore.getState().reservations || []
|
||||
const allDays = useTripStore.getState().days || []
|
||||
const dayOrder = (id: number | null | undefined): number | null => {
|
||||
if (id == null) return null
|
||||
const d = allDays.find(x => x.id === id)
|
||||
return d ? ((d as any).day_number ?? allDays.indexOf(d)) : null
|
||||
}
|
||||
const thisOrder = dayOrder(dayId)
|
||||
|
||||
// Transport reservations for this day with a known position — mirrors getTransportForDay semantics
|
||||
const dayTransports = thisOrder == null ? [] : allReservations.filter(r => {
|
||||
if (!TRANSPORT_TYPES.includes(r.type)) return false
|
||||
const startId = r.day_id
|
||||
if (startId == null) return false
|
||||
const endId = r.end_day_id ?? startId
|
||||
if (startId === endId) {
|
||||
if (startId !== dayId) return false
|
||||
} else {
|
||||
const startOrder = dayOrder(startId)
|
||||
const endOrder = dayOrder(endId)
|
||||
if (startOrder == null || endOrder == null) return false
|
||||
if (thisOrder < startOrder || thisOrder > endOrder) return false
|
||||
}
|
||||
const pos = r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position
|
||||
return pos != null
|
||||
})
|
||||
|
||||
// Build a unified list of places + transports sorted by effective position,
|
||||
// then derive segments by resetting whenever a transport appears — mirrors getMergedItems order.
|
||||
type Entry = { kind: 'place'; lat: number; lng: number } | { kind: 'transport' }
|
||||
const entries: (Entry & { pos: number })[] = [
|
||||
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
|
||||
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
|
||||
})),
|
||||
...dayTransports.map(r => ({
|
||||
kind: 'transport' as const,
|
||||
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
||||
})),
|
||||
].sort((a, b) => a.pos - b.pos)
|
||||
|
||||
const segments: [number, number][][] = []
|
||||
let currentSeg: [number, number][] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'place') {
|
||||
currentSeg.push([entry.lat, entry.lng])
|
||||
} else {
|
||||
if (currentSeg.length >= 2) segments.push([...currentSeg])
|
||||
currentSeg = []
|
||||
}
|
||||
}
|
||||
if (currentSeg.length >= 2) segments.push(currentSeg)
|
||||
|
||||
const geocodedWaypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng) as { lat: number; lng: number }[]
|
||||
|
||||
if (segments.length === 0 && geocodedWaypoints.length < 2) {
|
||||
setRoute(null); setRouteSegments([]); return
|
||||
}
|
||||
setRoute(segments.length > 0 ? segments : null)
|
||||
if (!routeCalcEnabled) { setRouteSegments([]); return }
|
||||
const controller = new AbortController()
|
||||
routeAbortRef.current = controller
|
||||
try {
|
||||
const segments = await calculateSegments(waypoints as { lat: number; lng: number }[], { signal: controller.signal })
|
||||
if (!controller.signal.aborted) setRouteSegments(segments)
|
||||
const calcSegments = await calculateSegments(geocodedWaypoints, { signal: controller.signal })
|
||||
if (!controller.signal.aborted) setRouteSegments(calcSegments)
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
|
||||
else if (!(err instanceof Error)) setRouteSegments([])
|
||||
}
|
||||
}, [routeCalcEnabled])
|
||||
|
||||
// Only recalculate when assignments for the SELECTED day change
|
||||
// Stable signature for transport reservations on the selected day — changes when a transport
|
||||
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
||||
const transportSignature = useMemo(() => {
|
||||
if (!selectedDayId) return ''
|
||||
return reservationsForSignature
|
||||
.filter(r => TRANSPORT_TYPES.includes(r.type))
|
||||
.map(r => {
|
||||
const pos = r.day_positions?.[selectedDayId] ?? r.day_positions?.[String(selectedDayId)] ?? r.day_plan_position
|
||||
return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}`
|
||||
})
|
||||
.sort()
|
||||
.join('|')
|
||||
}, [reservationsForSignature, selectedDayId])
|
||||
|
||||
// Recalculate when assignments or transport positions for the SELECTED day change
|
||||
const selectedDayAssignments = selectedDayId ? tripStore.assignments?.[String(selectedDayId)] : null
|
||||
useEffect(() => {
|
||||
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
||||
updateRouteForDay(selectedDayId)
|
||||
}, [selectedDayId, selectedDayAssignments])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDayId, selectedDayAssignments, transportSignature])
|
||||
|
||||
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||
}
|
||||
|
||||
@@ -8,11 +8,15 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'عرض المزيد',
|
||||
'common.showLess': 'عرض أقل',
|
||||
'common.cancel': 'إلغاء',
|
||||
'common.clear': 'مسح',
|
||||
'common.delete': 'حذف',
|
||||
'common.edit': 'تعديل',
|
||||
'common.add': 'إضافة',
|
||||
'common.loading': 'جارٍ التحميل...',
|
||||
'common.import': 'استيراد',
|
||||
'common.select': 'تحديد',
|
||||
'common.selectAll': 'تحديد الكل',
|
||||
'common.deselectAll': 'إلغاء تحديد الكل',
|
||||
'common.error': 'خطأ',
|
||||
'common.unknownError': 'خطأ غير معروف',
|
||||
'common.tooManyAttempts': 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
|
||||
@@ -312,6 +316,16 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'اقتراح ميزة',
|
||||
'settings.about.featureRequestHint': 'اقترح ميزة جديدة',
|
||||
'settings.about.wikiHint': 'التوثيق والأدلة',
|
||||
'settings.about.supporters.badge': 'الداعمون الشهريون',
|
||||
'settings.about.supporters.title': 'رفاق رحلة TREK',
|
||||
'settings.about.supporters.subtitle': 'بينما تخطّط لمسارك التالي، يساعد هؤلاء الأشخاص في التخطيط لمستقبل TREK. تذهب مساهمتهم الشهرية مباشرةً إلى التطوير والساعات الفعلية المبذولة — حتى يظلّ TREK مفتوح المصدر.',
|
||||
'settings.about.supporters.since': 'داعم منذ {date}',
|
||||
'settings.about.supporters.tierEmpty': 'كن الأول',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK هو مخطط سفر مستضاف ذاتيًا يساعدك على تنظيم رحلاتك من أول فكرة حتى آخر ذكرى. تخطيط يومي، ميزانية، قوائم تعبئة، صور والمزيد — كل شيء في مكان واحد، على خادمك الخاص.',
|
||||
'settings.about.madeWith': 'صُنع بـ',
|
||||
'settings.about.madeBy': 'بواسطة موريس ومجتمع مفتوح المصدر متنامٍ.',
|
||||
@@ -463,6 +477,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.audit': 'تدقيق',
|
||||
'admin.tabs.settings': 'الإعدادات',
|
||||
'admin.tabs.config': 'التخصيص',
|
||||
'admin.tabs.defaults': 'الإعدادات الافتراضية',
|
||||
'admin.defaultSettings.title': 'إعدادات المستخدم الافتراضية',
|
||||
'admin.defaultSettings.description': 'تعيين الإعدادات الافتراضية على مستوى النظام. سيرى المستخدمون الذين لم يغيروا إعدادًا هذه القيم. تحظى تغييراتهم دائمًا بالأولوية.',
|
||||
'admin.defaultSettings.saved': 'تم حفظ الإعداد الافتراضي',
|
||||
'admin.defaultSettings.reset': 'إعادة التعيين إلى الإعداد الافتراضي المدمج',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'إعادة تعيين',
|
||||
'admin.tabs.templates': 'قوالب التعبئة',
|
||||
'admin.tabs.addons': 'الإضافات',
|
||||
'admin.tabs.mcpTokens': 'وصول MCP',
|
||||
@@ -581,8 +601,22 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesSaved': 'تم حفظ إعدادات أنواع الملفات',
|
||||
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.placesPhotos.title': 'صور الأماكن',
|
||||
'admin.placesPhotos.subtitle': 'جلب الصور من Google Places API. عطّلها للحفاظ على حصة API. صور Wikimedia غير متأثرة.',
|
||||
'admin.placesAutocomplete.title': 'الإكمال التلقائي للأماكن',
|
||||
'admin.placesAutocomplete.subtitle': 'استخدام Google Places API لاقتراحات البحث. عطّلها للحفاظ على حصة API.',
|
||||
'admin.placesDetails.title': 'تفاصيل الأماكن',
|
||||
'admin.placesDetails.subtitle': 'جلب معلومات تفصيلية عن الأماكن (الساعات، التقييم، الموقع) من Google Places API. عطّلها للحفاظ على حصة API.',
|
||||
'admin.bagTracking.title': 'تتبع الأمتعة',
|
||||
'admin.bagTracking.subtitle': 'تفعيل الوزن وتعيين الأمتعة للعناصر',
|
||||
'admin.collab.chat.title': 'الدردشة',
|
||||
'admin.collab.chat.subtitle': 'المراسلة في الوقت الفعلي للتعاون',
|
||||
'admin.collab.notes.title': 'الملاحظات',
|
||||
'admin.collab.notes.subtitle': 'ملاحظات ومستندات مشتركة',
|
||||
'admin.collab.polls.title': 'الاستطلاعات',
|
||||
'admin.collab.polls.subtitle': 'استطلاعات وتصويت جماعي',
|
||||
'admin.collab.whatsnext.title': 'ما التالي',
|
||||
'admin.collab.whatsnext.subtitle': 'اقتراحات الأنشطة والخطوات التالية',
|
||||
'admin.packingTemplates.title': 'قوالب التعبئة',
|
||||
'admin.packingTemplates.subtitle': 'إنشاء قوائم تعبئة قابلة لإعادة الاستخدام',
|
||||
'admin.packingTemplates.create': 'قالب جديد',
|
||||
@@ -836,6 +870,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'الخطة',
|
||||
'trip.tabs.transports': 'المواصلات',
|
||||
'trip.tabs.reservations': 'الحجوزات',
|
||||
'trip.tabs.reservationsShort': 'حجز',
|
||||
'trip.tabs.packing': 'قائمة التجهيز',
|
||||
@@ -858,6 +893,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'تمت إضافة الحجز',
|
||||
'trip.toast.deleted': 'تم الحذف',
|
||||
'trip.confirm.deletePlace': 'هل تريد حذف هذا المكان؟',
|
||||
'trip.confirm.deletePlaces': 'حذف {count} أماكن؟',
|
||||
'trip.toast.placesDeleted': 'تم حذف {count} أماكن',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'لا توجد أماكن مخططة لهذا اليوم',
|
||||
@@ -902,6 +939,17 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'فشل الاستيراد',
|
||||
'places.importAllSkipped': 'جميع الأماكن موجودة بالفعل في الرحلة.',
|
||||
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
|
||||
'places.gpxImportTypes': 'ما الذي تريد استيراده؟',
|
||||
'places.gpxImportWaypoints': 'نقاط الطريق',
|
||||
'places.gpxImportRoutes': 'المسارات',
|
||||
'places.gpxImportTracks': 'المسارات (مع هندسة الطريق)',
|
||||
'places.gpxImportNoneSelected': 'اختر نوعاً واحداً على الأقل للاستيراد.',
|
||||
'places.kmlImportTypes': 'ما الذي تريد استيراده؟',
|
||||
'places.kmlImportPoints': 'نقاط (Placemarks)',
|
||||
'places.kmlImportPaths': 'مسارات (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'اختر نوعًا واحدًا على الأقل.',
|
||||
'places.selectionCount': '{count} محدد',
|
||||
'places.deleteSelected': 'حذف المحدد',
|
||||
'places.kmlKmzImported': 'تم استيراد {count} مكان من KMZ/KML',
|
||||
'places.urlResolved': 'تم استيراد المكان من الرابط',
|
||||
'places.importList': 'استيراد قائمة',
|
||||
@@ -918,6 +966,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
|
||||
'places.all': 'الكل',
|
||||
'places.unplanned': 'غير مخطط',
|
||||
'places.filterTracks': 'المسارات',
|
||||
'places.search': 'ابحث عن أماكن...',
|
||||
'places.allCategories': 'كل الفئات',
|
||||
'places.categoriesSelected': 'فئات',
|
||||
@@ -1002,10 +1051,20 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.flightNumber': 'رقم الرحلة',
|
||||
'reservations.meta.from': 'من',
|
||||
'reservations.meta.to': 'إلى',
|
||||
'reservations.needsReview': 'مراجعة',
|
||||
'reservations.needsReviewHint': 'تعذّر مطابقة المطار تلقائياً — يرجى تأكيد الموقع.',
|
||||
'reservations.searchLocation': 'ابحث عن محطة، ميناء، عنوان...',
|
||||
'airport.searchPlaceholder': 'رمز المطار أو المدينة (مثل FRA)',
|
||||
'map.connections': 'الاتصالات',
|
||||
'map.showConnections': 'عرض مسارات الحجوزات',
|
||||
'map.hideConnections': 'إخفاء مسارات الحجوزات',
|
||||
'settings.bookingLabels': 'تسميات مسارات الحجوزات',
|
||||
'settings.bookingLabelsHint': 'عرض أسماء المحطات/المطارات على الخريطة. عند الإيقاف، يتم عرض الرمز فقط.',
|
||||
'reservations.meta.trainNumber': 'رقم القطار',
|
||||
'reservations.meta.platform': 'المنصة',
|
||||
'reservations.meta.seat': 'المقعد',
|
||||
'reservations.meta.checkIn': 'تسجيل الوصول',
|
||||
'reservations.meta.checkInUntil': 'تسجيل الدخول حتى',
|
||||
'reservations.meta.checkOut': 'تسجيل المغادرة',
|
||||
'reservations.meta.linkAccommodation': 'الإقامة',
|
||||
'reservations.meta.pickAccommodation': 'ربط بالإقامة',
|
||||
@@ -1019,7 +1078,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.type.hotel': 'إقامة',
|
||||
'reservations.type.restaurant': 'مطعم',
|
||||
'reservations.type.train': 'قطار',
|
||||
'reservations.type.car': 'سيارة مستأجرة',
|
||||
'reservations.type.car': 'سيارة',
|
||||
'reservations.type.cruise': 'رحلة بحرية',
|
||||
'reservations.type.event': 'فعالية',
|
||||
'reservations.type.tour': 'جولة',
|
||||
@@ -1080,6 +1139,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'النهاية',
|
||||
'reservations.span.ongoing': 'جارٍ',
|
||||
'reservations.validation.endBeforeStart': 'يجب أن يكون تاريخ/وقت الانتهاء بعد تاريخ/وقت البدء',
|
||||
'reservations.addBooking': 'إضافة حجز',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'الميزانية',
|
||||
@@ -1490,6 +1550,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'أضف أماكن إلى رحلتك أولًا',
|
||||
'day.allDays': 'الكل',
|
||||
'day.checkIn': 'تسجيل الوصول',
|
||||
'day.checkInUntil': 'حتى',
|
||||
'day.checkOut': 'تسجيل المغادرة',
|
||||
'day.confirmation': 'التأكيد',
|
||||
'day.editAccommodation': 'تعديل الإقامة',
|
||||
@@ -1516,6 +1577,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'كلمة المرور',
|
||||
'memories.providerOTP': 'رمز MFA (إذا كان مفعلاً)',
|
||||
'memories.skipSSLVerification': 'تخطي التحقق من شهادة SSL',
|
||||
'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع',
|
||||
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
|
||||
'memories.testConnection': 'اختبار الاتصال',
|
||||
'memories.testFirst': 'اختبر الاتصال أولاً',
|
||||
@@ -1553,6 +1615,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟',
|
||||
'memories.confirmShareHint': '{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
|
||||
'memories.confirmShareButton': 'مشاركة الصور',
|
||||
'journey.search.placeholder': 'البحث في الرحلات…',
|
||||
'journey.search.noResults': 'لا توجد رحلات تطابق "{query}"',
|
||||
'journey.status.archived': 'مؤرشف',
|
||||
'journey.settings.endJourney': 'أرشفة الرحلة',
|
||||
'journey.settings.reopenJourney': 'استعادة الرحلة',
|
||||
'journey.settings.archived': 'تم أرشفة الرحلة',
|
||||
'journey.settings.reopened': 'تمت إعادة فتح الرحلة',
|
||||
'journey.settings.endDescription': 'يخفي شارة البث المباشر. يمكنك إعادة الفتح في أي وقت.',
|
||||
'journey.settings.failedToDelete': 'فشل في الحذف',
|
||||
'journey.entries.deleteTitle': 'حذف الإدخال',
|
||||
'journey.photosUploaded': 'تم رفع {count} صورة',
|
||||
@@ -1586,6 +1656,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.invite.inviting': 'جارٍ الدعوة...',
|
||||
|
||||
// Journey Entry Editor
|
||||
'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
|
||||
'journey.editor.uploadPhotos': 'رفع صور',
|
||||
'journey.editor.uploading': '...جارٍ الرفع',
|
||||
'journey.editor.fromGallery': 'من المعرض',
|
||||
@@ -1723,6 +1794,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'تمت إعادة ترتيب الأماكن',
|
||||
'undo.optimize': 'تم تحسين المسار',
|
||||
'undo.deletePlace': 'تم حذف المكان',
|
||||
'undo.deletePlaces': 'تم حذف الأماكن',
|
||||
'undo.moveDay': 'تم نقل المكان إلى يوم آخر',
|
||||
'undo.lock': 'تم تبديل قفل المكان',
|
||||
'undo.importGpx': 'استيراد GPX',
|
||||
@@ -1782,7 +1854,11 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'todo.unassigned': 'غير مُسنَد',
|
||||
'todo.noCategory': 'بدون فئة',
|
||||
'todo.hasDescription': 'له وصف',
|
||||
'todo.addItem': 'إضافة مهمة جديدة...',
|
||||
'todo.addItem': 'إضافة مهمة جديدة',
|
||||
'todo.sidebar.sortBy': 'ترتيب حسب',
|
||||
'todo.priority': 'الأولوية',
|
||||
'todo.newCategoryLabel': 'جديد',
|
||||
'budget.categoriesLabel': 'فئات',
|
||||
'todo.newCategory': 'اسم الفئة',
|
||||
'todo.addCategory': 'إضافة فئة',
|
||||
'todo.newItem': 'مهمة جديدة',
|
||||
@@ -1824,7 +1900,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.ntfyUrl.test': 'اختبار',
|
||||
'settings.ntfyUrl.testSuccess': 'تم إرسال إشعار Ntfy التجريبي بنجاح',
|
||||
'settings.ntfyUrl.testFailed': 'فشل إشعار Ntfy التجريبي',
|
||||
'settings.ntfyUrl.clearToken': 'مسح',
|
||||
'settings.ntfyUrl.tokenCleared': 'تم مسح رمز الوصول',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
@@ -1841,22 +1916,29 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'فشل إرسال Webhook الاختباري',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'يُرسل Webhook المسؤول تلقائيًا عند تعيين رابط URL',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': 'تسمح للمستخدمين بإعداد موضوعات ntfy الخاصة لتلقي إشعارات الدفع. قم بتعيين الخادم الافتراضي أدناه لملء إعدادات المستخدم مسبقًا.',
|
||||
'admin.notifications.testNtfy': 'إرسال Ntfy تجريبي',
|
||||
'admin.notifications.testNtfySuccess': 'تم إرسال Ntfy التجريبي بنجاح',
|
||||
'admin.notifications.testNtfyFailed': 'فشل إرسال Ntfy التجريبي',
|
||||
'admin.notifications.adminNtfyPanel.title': 'Ntfy المسؤول',
|
||||
'admin.notifications.adminNtfyPanel.hint': 'يُستخدم موضوع Ntfy هذا حصريًا لإشعارات المسؤول (مثل تنبيهات الإصدارات). وهو مستقل عن مواضيع المستخدمين ويُرسل دائمًا عند تهيئته.',
|
||||
'admin.notifications.adminNtfyPanel.serverLabel': 'عنوان URL خادم Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.serverHint': 'يُستخدم أيضًا كخادم افتراضي لإشعارات ntfy للمستخدمين. اتركه فارغًا لاستخدام ntfy.sh. يمكن للمستخدمين تغييره في إعداداتهم الخاصة.',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': 'موضوع المسؤول',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': 'رمز الوصول (اختياري)',
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': 'تم مسح رمز وصول المسؤول',
|
||||
'admin.notifications.adminNtfyPanel.saved': 'تم حفظ إعدادات Ntfy للمسؤول',
|
||||
'admin.notifications.adminNtfyPanel.test': 'إرسال Ntfy تجريبي',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess': 'تم إرسال Ntfy التجريبي بنجاح',
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'فشل إرسال Ntfy التجريبي',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'يُرسل Ntfy للمسؤول دائمًا عند تهيئة موضوع',
|
||||
'admin.notifications.adminNotificationsHint': 'حدد القنوات التي تُسلّم إشعارات المسؤول (مثل تنبيهات الإصدارات). يُرسل الـ Webhook تلقائيًا عند تعيين رابط URL لـ Webhook المسؤول.',
|
||||
'admin.notifications.tripReminders.title': 'تذكيرات الرحلات',
|
||||
'admin.notifications.tripReminders.hint': 'إرسال تذكير قبل بدء الرحلة (يتطلب تعيين أيام التذكير على الرحلة).',
|
||||
'admin.notifications.tripReminders.enabled': 'تم تفعيل تذكيرات الرحلات',
|
||||
'admin.notifications.tripReminders.disabled': 'تم تعطيل تذكيرات الرحلات',
|
||||
'admin.tabs.notifications': 'الإشعارات',
|
||||
'notifications.versionAvailable.title': 'تحديث متاح',
|
||||
'notifications.versionAvailable.text': 'TREK {version} متاح الآن.',
|
||||
@@ -1960,6 +2042,55 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'البحث عن مواقع وحل عناوين الخرائط والترميز الجغرافي العكسي للإحداثيات',
|
||||
'oauth.scope.weather:read.label': 'توقعات الطقس',
|
||||
'oauth.scope.weather:read.description': 'جلب توقعات الطقس لمواقع الرحلة وتواريخها',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'مرحبًا بك في TREK',
|
||||
'system_notice.welcome_v1.body': 'مخطط رحلاتك الشامل. أنشئ جداول السفر، وشارك رحلاتك مع الأصدقاء، وابقَ منظمًا — سواء كنت متصلاً بالإنترنت أم لا.',
|
||||
'system_notice.welcome_v1.cta_label': 'خطط لرحلة',
|
||||
'system_notice.welcome_v1.hero_alt': 'وجهة سفر خلابة مع واجهة تطبيق TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'جداول رحلات يومية لكل سفرة',
|
||||
'system_notice.welcome_v1.highlight_share': 'تعاون مع شركاء السفر',
|
||||
'system_notice.welcome_v1.highlight_offline': 'يعمل بلا إنترنت على الهاتف',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'الإشعار السابق',
|
||||
'system_notice.pager.next': 'الإشعار التالي',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'الانتقال إلى الإشعار {n}',
|
||||
'system_notice.pager.position': 'الإشعار {current} من {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'تم نقل الصور في الإصدار 3.0',
|
||||
'system_notice.v3_photos.body': 'تمت إزالة تبويب **الصور** من مخطط الرحلة. صورك آمنة — لم يعدّل TREK مكتبتك على Immich أو Synology قطّ.\n\nتعيش الصور الآن في إضافة **Journey**. Journey اختيارية — إن لم تكن متاحة بعد، اطلب من المسؤول تفعيلها عبر Admin ← الإضافات.',
|
||||
'system_notice.v3_journey.title': 'تعرّف على Journey — مذكرة سفر',
|
||||
'system_notice.v3_journey.body': 'وثّق رحلاتك كقصص غنية بخطوط زمنية ومعارض صور وخرائط تفاعلية.',
|
||||
'system_notice.v3_journey.cta_label': 'فتح Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'جدول زمني يومي ومعرض',
|
||||
'system_notice.v3_journey.highlight_photos': 'استيراد من Immich أو Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'مشاركة علنية — دون تسجيل دخول',
|
||||
'system_notice.v3_journey.highlight_export': 'تصدير كألبوم صور PDF',
|
||||
'system_notice.v3_features.title': 'مزيد من مميزات 3.0',
|
||||
'system_notice.v3_features.body': 'بعض الجديد الآخر الجدير بالمعرفة في هذا الإصدار.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'إعادة تصميم لوحة التحكم mobile-first',
|
||||
'system_notice.v3_features.highlight_offline': 'وضع لا اتصال كامل كتطبيق PWA',
|
||||
'system_notice.v3_features.highlight_search': 'إكمال تلقائي في الوقت الفعلي',
|
||||
'system_notice.v3_features.highlight_import': 'استيراد أماكن من ملفات KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: ترقية OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'تمت إعادة تصميم تكامل MCP بالكامل. OAuth 2.1 هو الآن طريقة المصادقة الموصى بها. الرموز الثابتة (trek_…) مهملة وستُزال في إصدار مستقبلي.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 موصى به (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 نطاق أذونات دقيق',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'الرموز الثابتة trek_ مهملة',
|
||||
'system_notice.v3_mcp.highlight_tools': 'مجموعة أدوات وإرشادات موسعة',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'كلمة شخصية مني',
|
||||
'system_notice.v3_thankyou.body': 'قبل أن تمضي — أريد أن أتوقف لحظة.\n\nبدأ TREK كمشروع جانبي بنيته لرحلاتي الخاصة. لم أتخيل يومًا أنه سيكبر ليصبح شيئًا يعتمد عليه 4,000 منكم لتخطيط مغامراتهم. كل نجمة، كل مشكلة، كل طلب ميزة — أقرأها جميعًا، وهي ما يبقيني مستمرًا في الليالي المتأخرة بين عمل بدوام كامل والجامعة.\n\nأريدكم أن تعرفوا: TREK سيبقى دائمًا مفتوح المصدر، دائمًا مستضافًا ذاتيًا، دائمًا ملككم. لا تتبع، لا اشتراكات، لا شروط خفية. مجرد أداة بناها شخص يحب السفر بقدر ما تحبونه.\n\nشكر خاص لـ [jubnl](https://github.com/jubnl) — لقد أصبحت متعاونًا رائعًا. الكثير مما يجعل الإصدار 3.0 عظيمًا يحمل بصماتك. شكرًا لإيمانك بهذا المشروع عندما كان لا يزال في بداياته.\n\nولكل واحد منكم ممن أبلغ عن خطأ، أو ترجم نصًا، أو شارك TREK مع صديق، أو ببساطة استخدمه لتخطيط رحلة — **شكرًا لكم**. أنتم السبب في وجود هذا.\n\nإلى المزيد من المغامرات معًا.\n\n— Maurice\n\n---\n\n[انضم إلى المجتمع على Discord](https://discord.gg/7Q6M6jDwzf)\n\nإذا جعل TREK رحلاتك أفضل، [فنجان قهوة صغير](https://ko-fi.com/mauriceboe) يبقي الأضواء مشتعلة.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'المواصلات',
|
||||
'transport.addManual': 'نقل يدوي',
|
||||
}
|
||||
|
||||
export default ar
|
||||
|
||||
@@ -4,11 +4,15 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'Mostrar mais',
|
||||
'common.showLess': 'Mostrar menos',
|
||||
'common.cancel': 'Cancelar',
|
||||
'common.clear': 'Limpar',
|
||||
'common.delete': 'Excluir',
|
||||
'common.edit': 'Editar',
|
||||
'common.add': 'Adicionar',
|
||||
'common.loading': 'Carregando...',
|
||||
'common.import': 'Importar',
|
||||
'common.select': 'Selecionar',
|
||||
'common.selectAll': 'Selecionar tudo',
|
||||
'common.deselectAll': 'Desmarcar tudo',
|
||||
'common.error': 'Erro',
|
||||
'common.unknownError': 'Erro desconhecido',
|
||||
'common.tooManyAttempts': 'Muitas tentativas. Tente novamente mais tarde.',
|
||||
@@ -239,6 +243,16 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'Solicitar recurso',
|
||||
'settings.about.featureRequestHint': 'Sugira um novo recurso',
|
||||
'settings.about.wikiHint': 'Documentação e guias',
|
||||
'settings.about.supporters.badge': 'Apoiadores Mensais',
|
||||
'settings.about.supporters.title': 'Companheiros de viagem do TREK',
|
||||
'settings.about.supporters.subtitle': 'Enquanto você planeja sua próxima rota, essas pessoas planejam junto o futuro do TREK. A contribuição mensal delas vai direto para o desenvolvimento e horas reais investidas — para o TREK continuar Open Source.',
|
||||
'settings.about.supporters.since': 'apoiador desde {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Seja o primeiro',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK é um planejador de viagens auto-hospedado que ajuda você a organizar suas viagens da primeira ideia à última lembrança. Planejamento diário, orçamento, listas de bagagem, fotos e muito mais — tudo em um só lugar, no seu próprio servidor.',
|
||||
'settings.about.madeWith': 'Feito com',
|
||||
'settings.about.madeBy': 'por Maurice e uma crescente comunidade open-source.',
|
||||
@@ -545,9 +559,29 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesSaved': 'Configurações de tipos de arquivo salvas',
|
||||
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.placesPhotos.title': 'Fotos de Locais',
|
||||
'admin.placesPhotos.subtitle': 'Busca fotos da Google Places API. Desative para economizar cota da API. Fotos do Wikimedia não são afetadas.',
|
||||
'admin.placesAutocomplete.title': 'Autocompletar de Locais',
|
||||
'admin.placesAutocomplete.subtitle': 'Usa a Google Places API para sugestões de pesquisa. Desative para economizar cota da API.',
|
||||
'admin.placesDetails.title': 'Detalhes do Local',
|
||||
'admin.placesDetails.subtitle': 'Busca informações detalhadas do local (horários, avaliação, site) da Google Places API. Desative para economizar cota da API.',
|
||||
'admin.bagTracking.title': 'Rastreamento de malas',
|
||||
'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Mensagens em tempo real para colaboração',
|
||||
'admin.collab.notes.title': 'Notas',
|
||||
'admin.collab.notes.subtitle': 'Notas e documentos compartilhados',
|
||||
'admin.collab.polls.title': 'Enquetes',
|
||||
'admin.collab.polls.subtitle': 'Enquetes e votações em grupo',
|
||||
'admin.collab.whatsnext.title': 'Próximos passos',
|
||||
'admin.collab.whatsnext.subtitle': 'Sugestões de atividades e próximos passos',
|
||||
'admin.tabs.config': 'Personalização',
|
||||
'admin.tabs.defaults': 'Padrões do usuário',
|
||||
'admin.defaultSettings.title': 'Configurações padrão do usuário',
|
||||
'admin.defaultSettings.description': 'Defina padrões para toda a instância. Usuários que não alteraram uma configuração verão esses valores. As próprias alterações deles sempre têm prioridade.',
|
||||
'admin.defaultSettings.saved': 'Padrão salvo',
|
||||
'admin.defaultSettings.reset': 'Redefinir para o padrão integrado',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'redefinir',
|
||||
'admin.tabs.templates': 'Modelos de mala',
|
||||
'admin.packingTemplates.title': 'Modelos de mala',
|
||||
'admin.packingTemplates.subtitle': 'Crie listas de mala reutilizáveis para suas viagens',
|
||||
@@ -806,6 +840,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plano',
|
||||
'trip.tabs.transports': 'Transportes',
|
||||
'trip.tabs.reservations': 'Reservas',
|
||||
'trip.tabs.reservationsShort': 'Reservas',
|
||||
'trip.tabs.packing': 'Lista de mala',
|
||||
@@ -827,6 +862,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Reserva adicionada',
|
||||
'trip.toast.deleted': 'Excluído',
|
||||
'trip.confirm.deletePlace': 'Tem certeza de que deseja excluir este lugar?',
|
||||
'trip.confirm.deletePlaces': 'Excluir {count} lugares?',
|
||||
'trip.toast.placesDeleted': '{count} lugares excluídos',
|
||||
'trip.loadingPhotos': 'Carregando fotos dos lugares...',
|
||||
|
||||
// Day Plan Sidebar
|
||||
@@ -872,6 +909,17 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Importação falhou',
|
||||
'places.importAllSkipped': 'Todos os lugares já estavam na viagem.',
|
||||
'places.gpxImported': '{count} lugares importados do GPX',
|
||||
'places.gpxImportTypes': 'O que deseja importar?',
|
||||
'places.gpxImportWaypoints': 'Pontos de caminho',
|
||||
'places.gpxImportRoutes': 'Rotas',
|
||||
'places.gpxImportTracks': 'Trilhas (com geometria de percurso)',
|
||||
'places.gpxImportNoneSelected': 'Selecione pelo menos um tipo para importar.',
|
||||
'places.kmlImportTypes': 'O que deseja importar?',
|
||||
'places.kmlImportPoints': 'Pontos (Placemarks)',
|
||||
'places.kmlImportPaths': 'Caminhos (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Selecione pelo menos um tipo.',
|
||||
'places.selectionCount': '{count} selecionado(s)',
|
||||
'places.deleteSelected': 'Excluir seleção',
|
||||
'places.kmlKmzImported': '{count} lugares importados de KMZ/KML',
|
||||
'places.urlResolved': 'Lugar importado da URL',
|
||||
'places.importList': 'Importar lista',
|
||||
@@ -888,6 +936,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Adicionar a qual dia?',
|
||||
'places.all': 'Todos',
|
||||
'places.unplanned': 'Não planejados',
|
||||
'places.filterTracks': 'Trilhas',
|
||||
'places.search': 'Buscar lugares...',
|
||||
'places.allCategories': 'Todas as categorias',
|
||||
'places.categoriesSelected': 'categorias',
|
||||
@@ -971,10 +1020,20 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.flightNumber': 'Nº do voo',
|
||||
'reservations.meta.from': 'De',
|
||||
'reservations.meta.to': 'Para',
|
||||
'reservations.needsReview': 'Verificar',
|
||||
'reservations.needsReviewHint': 'Aeroporto não pôde ser identificado automaticamente — confirme o local.',
|
||||
'reservations.searchLocation': 'Buscar estação, porto, endereço...',
|
||||
'airport.searchPlaceholder': 'Código ou cidade do aeroporto (ex. FRA)',
|
||||
'map.connections': 'Conexões',
|
||||
'map.showConnections': 'Mostrar rotas de reservas',
|
||||
'map.hideConnections': 'Ocultar rotas de reservas',
|
||||
'settings.bookingLabels': 'Rótulos das rotas de reservas',
|
||||
'settings.bookingLabelsHint': 'Mostra nomes de estações / aeroportos no mapa. Desativado, apenas o ícone aparece.',
|
||||
'reservations.meta.trainNumber': 'Nº do trem',
|
||||
'reservations.meta.platform': 'Plataforma',
|
||||
'reservations.meta.seat': 'Assento',
|
||||
'reservations.meta.checkIn': 'Check-in',
|
||||
'reservations.meta.checkInUntil': 'Check-in até',
|
||||
'reservations.meta.checkOut': 'Check-out',
|
||||
'reservations.meta.linkAccommodation': 'Hospedagem',
|
||||
'reservations.meta.pickAccommodation': 'Vincular à hospedagem',
|
||||
@@ -988,7 +1047,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.type.hotel': 'Hospedagem',
|
||||
'reservations.type.restaurant': 'Restaurante',
|
||||
'reservations.type.train': 'Trem',
|
||||
'reservations.type.car': 'Carro alugado',
|
||||
'reservations.type.car': 'Carro',
|
||||
'reservations.type.cruise': 'Cruzeiro',
|
||||
'reservations.type.event': 'Evento',
|
||||
'reservations.type.tour': 'Passeio',
|
||||
@@ -1049,6 +1108,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Fim',
|
||||
'reservations.span.ongoing': 'Em andamento',
|
||||
'reservations.validation.endBeforeStart': 'A data/hora final deve ser posterior à data/hora inicial',
|
||||
'reservations.addBooking': 'Adicionar reserva',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Orçamento',
|
||||
@@ -1459,6 +1519,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'Adicione lugares à viagem primeiro',
|
||||
'day.allDays': 'Todos',
|
||||
'day.checkIn': 'Check-in',
|
||||
'day.checkInUntil': 'Até',
|
||||
'day.checkOut': 'Check-out',
|
||||
'day.confirmation': 'Confirmação',
|
||||
'day.editAccommodation': 'Editar hospedagem',
|
||||
@@ -1555,6 +1616,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'Senha',
|
||||
'memories.providerOTP': 'Código MFA (se habilitado)',
|
||||
'memories.skipSSLVerification': 'Pular verificação de certificado SSL',
|
||||
'memories.immichAutoUpload': 'Espelhar fotos da jornada no Immich ao enviar',
|
||||
'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Testar conexão',
|
||||
'memories.testFirst': 'Teste a conexão primeiro',
|
||||
@@ -1672,6 +1734,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Locais reordenados',
|
||||
'undo.optimize': 'Rota otimizada',
|
||||
'undo.deletePlace': 'Local excluído',
|
||||
'undo.deletePlaces': 'Lugares excluídos',
|
||||
'undo.moveDay': 'Local movido para outro dia',
|
||||
'undo.lock': 'Bloqueio do local alternado',
|
||||
'undo.importGpx': 'Importação de GPX',
|
||||
@@ -1731,7 +1794,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'todo.unassigned': 'Não atribuído',
|
||||
'todo.noCategory': 'Sem categoria',
|
||||
'todo.hasDescription': 'Com descrição',
|
||||
'todo.addItem': 'Adicionar nova tarefa...',
|
||||
'todo.addItem': 'Nova tarefa',
|
||||
'todo.sidebar.sortBy': 'Ordenar por',
|
||||
'todo.priority': 'Prioridade',
|
||||
'todo.newCategoryLabel': 'nova',
|
||||
'budget.categoriesLabel': 'categorias',
|
||||
'todo.newCategory': 'Nome da categoria',
|
||||
'todo.addCategory': 'Adicionar categoria',
|
||||
'todo.newItem': 'Nova tarefa',
|
||||
@@ -1773,7 +1840,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.ntfyUrl.test': 'Testar',
|
||||
'settings.ntfyUrl.testSuccess': 'Notificação de teste do Ntfy enviada com sucesso',
|
||||
'settings.ntfyUrl.testFailed': 'Falha na notificação de teste do Ntfy',
|
||||
'settings.ntfyUrl.clearToken': 'Limpar',
|
||||
'settings.ntfyUrl.tokenCleared': 'Token de acesso removido',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
@@ -1790,22 +1856,29 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Falha no webhook de teste',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'O webhook de admin dispara automaticamente quando uma URL está configurada',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': 'Permite que os usuários configurem seus próprios tópicos ntfy para notificações push. Configure o servidor padrão abaixo para preencher as configurações do usuário.',
|
||||
'admin.notifications.testNtfy': 'Enviar Ntfy de teste',
|
||||
'admin.notifications.testNtfySuccess': 'Ntfy de teste enviado com sucesso',
|
||||
'admin.notifications.testNtfyFailed': 'Falha ao enviar Ntfy de teste',
|
||||
'admin.notifications.adminNtfyPanel.title': 'Ntfy de admin',
|
||||
'admin.notifications.adminNtfyPanel.hint': 'Este tópico Ntfy é usado exclusivamente para notificações de admin (ex. alertas de versão). É independente dos tópicos por usuário e sempre dispara quando configurado.',
|
||||
'admin.notifications.adminNtfyPanel.serverLabel': 'URL do servidor Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.serverHint': 'Também usado como servidor padrão para notificações ntfy dos usuários. Deixe em branco para usar ntfy.sh. Os usuários podem substituir isso em suas próprias configurações.',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': 'Tópico de admin',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token de acesso (opcional)',
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': 'Token de acesso admin removido',
|
||||
'admin.notifications.adminNtfyPanel.saved': 'Configurações de Ntfy de admin salvas',
|
||||
'admin.notifications.adminNtfyPanel.test': 'Enviar Ntfy de teste',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess': 'Ntfy de teste enviado com sucesso',
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Falha ao enviar Ntfy de teste',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'O Ntfy de admin sempre dispara quando um tópico está configurado',
|
||||
'admin.notifications.adminNotificationsHint': 'Configure quais canais entregam notificações de admin (ex. alertas de versão). O webhook dispara automaticamente se uma URL de webhook de admin estiver definida.',
|
||||
'admin.notifications.tripReminders.title': 'Lembretes de viagem',
|
||||
'admin.notifications.tripReminders.hint': 'Envia uma notificação de lembrete antes do início de uma viagem (requer dias de lembrete definidos na viagem).',
|
||||
'admin.notifications.tripReminders.enabled': 'Lembretes de viagem ativados',
|
||||
'admin.notifications.tripReminders.disabled': 'Lembretes de viagem desativados',
|
||||
'admin.tabs.notifications': 'Notificações',
|
||||
'notifications.versionAvailable.title': 'Atualização disponível',
|
||||
'notifications.versionAvailable.text': 'TREK {version} já está disponível.',
|
||||
@@ -1853,6 +1926,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.saveRouteNotConfigured': 'A rota de salvamento não está configurada para este provedor',
|
||||
'memories.testRouteNotConfigured': 'A rota de teste não está configurada para este provedor',
|
||||
'memories.fillRequiredFields': 'Por favor preencha todos os campos obrigatórios',
|
||||
'journey.search.placeholder': 'Buscar jornadas…',
|
||||
'journey.search.noResults': 'Nenhuma jornada corresponde a "{query}"',
|
||||
'journey.title': 'Jornada',
|
||||
'journey.subtitle': 'Registre suas viagens em tempo real',
|
||||
'journey.new': 'Nova jornada',
|
||||
@@ -1874,6 +1949,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Ativa',
|
||||
'journey.status.completed': 'Concluída',
|
||||
'journey.status.upcoming': 'Próxima',
|
||||
'journey.status.archived': 'Arquivado',
|
||||
'journey.checkin.add': 'Fazer check-in',
|
||||
'journey.checkin.namePlaceholder': 'Nome do local',
|
||||
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
|
||||
@@ -1950,6 +2026,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.verdict.couldBeBetter': 'Poderia ser melhor',
|
||||
'journey.synced.places': 'lugares',
|
||||
'journey.synced.synced': 'sincronizado',
|
||||
'journey.editor.discardChangesConfirm': 'Você tem alterações não salvas. Descartá-las?',
|
||||
'journey.editor.uploadPhotos': 'Enviar fotos',
|
||||
'journey.editor.uploading': 'Enviando...',
|
||||
'journey.editor.fromGallery': 'Da galeria',
|
||||
@@ -2027,6 +2104,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Nome',
|
||||
'journey.settings.subtitle': 'Subtítulo',
|
||||
'journey.settings.subtitlePlaceholder': 'ex. Tailândia, Vietnã e Camboja',
|
||||
'journey.settings.endJourney': 'Arquivar Jornada',
|
||||
'journey.settings.reopenJourney': 'Restaurar Jornada',
|
||||
'journey.settings.archived': 'Jornada arquivada',
|
||||
'journey.settings.reopened': 'Jornada reaberta',
|
||||
'journey.settings.endDescription': 'Oculta o selo Ao Vivo. Você pode reabrir a qualquer momento.',
|
||||
'journey.settings.delete': 'Excluir',
|
||||
'journey.settings.deleteJourney': 'Excluir jornada',
|
||||
'journey.settings.deleteMessage': 'Excluir "{title}"? Todas as entradas e fotos serão perdidas.',
|
||||
@@ -2163,6 +2245,55 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Pesquisar locais, resolver URLs de mapa e geocodificar coordenadas',
|
||||
'oauth.scope.weather:read.label': 'Previsão do tempo',
|
||||
'oauth.scope.weather:read.description': 'Obter previsão do tempo para locais e datas da viagem',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Bem-vindo ao TREK',
|
||||
'system_notice.welcome_v1.body': 'Seu planejador de viagens tudo-em-um. Crie roteiros, compartilhe viagens com amigos e fique organizado — online ou offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Planejar uma viagem',
|
||||
'system_notice.welcome_v1.hero_alt': 'Destino de viagem pitoresco com a interface do TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Roteiros dia a dia para qualquer viagem',
|
||||
'system_notice.welcome_v1.highlight_share': 'Colabore com seus companheiros de viagem',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Funciona offline no celular',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Aviso anterior',
|
||||
'system_notice.pager.next': 'Próximo aviso',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Ir para o aviso {n}',
|
||||
'system_notice.pager.position': 'Aviso {current} de {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Fotos foram movidas na versão 3.0',
|
||||
'system_notice.v3_photos.body': '**Fotos** no Planejador de Viagens foram removidas. Suas fotos estão seguras — o TREK nunca modificou sua biblioteca Immich ou Synology.\n\nAs fotos agora vivem no addon **Journey**. Journey é opcional — se ainda não estiver disponível, peça ao seu admin para ativá-lo em Admin → Addons.',
|
||||
'system_notice.v3_journey.title': 'Conheça o Journey — diário de viagem',
|
||||
'system_notice.v3_journey.body': 'Documente suas viagens como histórias ricas com cronologias, galerias de fotos e mapas interativos.',
|
||||
'system_notice.v3_journey.cta_label': 'Abrir Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Linha do tempo e galeria diária',
|
||||
'system_notice.v3_journey.highlight_photos': 'Importar do Immich ou Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Compartilhar publicamente — sem login',
|
||||
'system_notice.v3_journey.highlight_export': 'Exportar como álbum de fotos PDF',
|
||||
'system_notice.v3_features.title': 'Mais destaques na versão 3.0',
|
||||
'system_notice.v3_features.body': 'Algumas outras novidades que vale a pena conhecer nesta versão.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Redesign do painel mobile-first',
|
||||
'system_notice.v3_features.highlight_offline': 'Modo offline completo como PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autocompleção de lugares em tempo real',
|
||||
'system_notice.v3_features.highlight_import': 'Importar lugares de arquivos KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: atualização OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'A integração MCP foi completamente reformulada. OAuth 2.1 agora é o método de autenticação recomendado. Tokens estáticos (trek_…) foram descontinuados e serão removidos em uma versão futura.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recomendado (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 escopos de permissão granulares',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Tokens estáticos trek_ descontinuados',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Conjunto de ferramentas e prompts expandido',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Uma nota pessoal minha',
|
||||
'system_notice.v3_thankyou.body': 'Antes de seguir em frente — quero fazer uma pausa.\n\nO TREK começou como um projeto paralelo que criei para minhas próprias viagens. Nunca imaginei que cresceria a ponto de 4.000 de vocês confiarem nele para planejar suas aventuras. Cada estrela, cada issue, cada pedido de recurso — eu leio todos, e eles me mantêm firme nas noites longas entre um trabalho em tempo integral e a universidade.\n\nQuero que saibam: o TREK sempre será open source, sempre self-hosted, sempre de vocês. Sem rastreamento, sem assinaturas, sem pegadinhas. Apenas uma ferramenta feita por alguém que ama viajar tanto quanto vocês.\n\nAgradecimento especial ao [jubnl](https://github.com/jubnl) — você se tornou um colaborador incrível. Muito do que torna a versão 3.0 especial tem a sua marca. Obrigado por acreditar neste projeto quando ele ainda era bem cru.\n\nE a cada um de vocês que reportou um bug, traduziu uma string, compartilhou o TREK com um amigo ou simplesmente o usou para planejar uma viagem — **obrigado**. Vocês são a razão de tudo isso existir.\n\nQue venham muitas mais aventuras juntos.\n\n— Maurice\n\n---\n\n[Junte-se à comunidade no Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe o TREK torna suas viagens melhores, um [cafezinho](https://ko-fi.com/mauriceboe) sempre mantém as luzes acesas.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transportes',
|
||||
'transport.addManual': 'Transporte Manual',
|
||||
}
|
||||
|
||||
export default br
|
||||
|
||||
@@ -4,11 +4,15 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'Zobrazit více',
|
||||
'common.showLess': 'Zobrazit méně',
|
||||
'common.cancel': 'Zrušit',
|
||||
'common.clear': 'Vymazat',
|
||||
'common.delete': 'Smazat',
|
||||
'common.edit': 'Upravit',
|
||||
'common.add': 'Přidat',
|
||||
'common.loading': 'Načítání...',
|
||||
'common.import': 'Importovat',
|
||||
'common.select': 'Vybrat',
|
||||
'common.selectAll': 'Vybrat vše',
|
||||
'common.deselectAll': 'Zrušit výběr všeho',
|
||||
'common.error': 'Chyba',
|
||||
'common.unknownError': 'Neznámá chyba',
|
||||
'common.tooManyAttempts': 'Příliš mnoho pokusů. Zkuste to prosím znovu.',
|
||||
@@ -263,6 +267,16 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'Navrhnout funkci',
|
||||
'settings.about.featureRequestHint': 'Navrhněte novou funkci',
|
||||
'settings.about.wikiHint': 'Dokumentace a návody',
|
||||
'settings.about.supporters.badge': 'Měsíční podporovatelé',
|
||||
'settings.about.supporters.title': 'Společníci na cestě s TREK',
|
||||
'settings.about.supporters.subtitle': 'Zatímco plánuješ další trasu, tihle lidé plánují společně se mnou budoucnost TREK. Jejich měsíční příspěvek jde přímo na vývoj a reálně strávené hodiny — aby TREK zůstal Open Source.',
|
||||
'settings.about.supporters.since': 'podporovatel od {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Buď první',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK je samohostovaný plánovač cest, který vám pomůže organizovat výlety od prvního nápadu po poslední vzpomínku. Denní plánování, rozpočet, balicí seznamy, fotky a mnoho dalšího — vše na jednom místě, na vašem vlastním serveru.',
|
||||
'settings.about.madeWith': 'Vytvořeno s',
|
||||
'settings.about.madeBy': 'Mauricem a rostoucí open-source komunitou.',
|
||||
@@ -545,9 +559,29 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesSaved': 'Nastavení souborů uloženo',
|
||||
|
||||
// Šablony balení (Packing Templates)
|
||||
'admin.placesPhotos.title': 'Fotografie míst',
|
||||
'admin.placesPhotos.subtitle': 'Načítání fotografií z Google Places API. Zakázáním ušetříte kvótu API. Fotografie z Wikimedia nejsou ovlivněny.',
|
||||
'admin.placesAutocomplete.title': 'Automatické doplňování míst',
|
||||
'admin.placesAutocomplete.subtitle': 'Použití Google Places API pro návrhy vyhledávání. Zakázáním ušetříte kvótu API.',
|
||||
'admin.placesDetails.title': 'Podrobnosti o místě',
|
||||
'admin.placesDetails.subtitle': 'Načítání podrobných informací o místě (hodiny, hodnocení, web) z Google Places API. Zakázáním ušetříte kvótu API.',
|
||||
'admin.bagTracking.title': 'Sledování zavazadel',
|
||||
'admin.bagTracking.subtitle': 'Povolit váhu a přiřazení k zavazadlům u položek balení',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Zasílání zpráv v reálném čase',
|
||||
'admin.collab.notes.title': 'Poznámky',
|
||||
'admin.collab.notes.subtitle': 'Sdílené poznámky a dokumenty',
|
||||
'admin.collab.polls.title': 'Ankety',
|
||||
'admin.collab.polls.subtitle': 'Skupinové ankety a hlasování',
|
||||
'admin.collab.whatsnext.title': 'Co dál',
|
||||
'admin.collab.whatsnext.subtitle': 'Návrhy aktivit a další kroky',
|
||||
'admin.tabs.config': 'Personalizace',
|
||||
'admin.tabs.defaults': 'Výchozí nastavení uživatele',
|
||||
'admin.defaultSettings.title': 'Výchozí nastavení uživatele',
|
||||
'admin.defaultSettings.description': 'Nastavte výchozí hodnoty pro celou instanci. Uživatelé, kteří nezměnili nastavení, uvidí tyto hodnoty. Jejich vlastní změny mají vždy přednost.',
|
||||
'admin.defaultSettings.saved': 'Výchozí nastavení uloženo',
|
||||
'admin.defaultSettings.reset': 'Obnovit na vestavěnou výchozí hodnotu',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'obnovit',
|
||||
'admin.tabs.templates': 'Šablony seznamů',
|
||||
'admin.packingTemplates.title': 'Šablony pro balení',
|
||||
'admin.packingTemplates.subtitle': 'Vytvářejte opakovaně použitelné seznamy pro své cesty',
|
||||
@@ -834,6 +868,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Plánovač cesty (Trip Planner)
|
||||
'trip.tabs.plan': 'Plán',
|
||||
'trip.tabs.transports': 'Doprava',
|
||||
'trip.tabs.reservations': 'Rezervace',
|
||||
'trip.tabs.reservationsShort': 'Rez.',
|
||||
'trip.tabs.packing': 'Seznam věcí',
|
||||
@@ -856,6 +891,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Rezervace přidána',
|
||||
'trip.toast.deleted': 'Smazáno',
|
||||
'trip.confirm.deletePlace': 'Opravdu chcete toto místo smazat?',
|
||||
'trip.confirm.deletePlaces': 'Smazat {count} míst?',
|
||||
'trip.toast.placesDeleted': '{count} míst smazáno',
|
||||
|
||||
// Denní plán (Day Plan)
|
||||
'dayplan.emptyDay': 'Na tento den nejsou naplánována žádná místa',
|
||||
@@ -900,6 +937,17 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Import se nezdařil',
|
||||
'places.importAllSkipped': 'Všechna místa již byla v cestě.',
|
||||
'places.gpxImported': '{count} míst importováno z GPX',
|
||||
'places.gpxImportTypes': 'Co chcete importovat?',
|
||||
'places.gpxImportWaypoints': 'Trasové body',
|
||||
'places.gpxImportRoutes': 'Trasy',
|
||||
'places.gpxImportTracks': 'Trasy GPS (s geometrií)',
|
||||
'places.gpxImportNoneSelected': 'Vyberte alespoň jeden typ k importu.',
|
||||
'places.kmlImportTypes': 'Co chcete importovat?',
|
||||
'places.kmlImportPoints': 'Body (Placemarks)',
|
||||
'places.kmlImportPaths': 'Trasy (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Vyberte alespoň jeden typ.',
|
||||
'places.selectionCount': '{count} vybráno',
|
||||
'places.deleteSelected': 'Smazat vybrané',
|
||||
'places.kmlKmzImported': 'Importováno {count} míst z KMZ/KML',
|
||||
'places.urlResolved': 'Místo importováno z URL',
|
||||
'places.importList': 'Import seznamu',
|
||||
@@ -916,6 +964,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Přidat do kterého dne?',
|
||||
'places.all': 'Vše',
|
||||
'places.unplanned': 'Nezařazené',
|
||||
'places.filterTracks': 'Trasy',
|
||||
'places.search': 'Hledat místa...',
|
||||
'places.allCategories': 'Všechny kategorie',
|
||||
'places.categoriesSelected': 'kategorií',
|
||||
@@ -1000,10 +1049,20 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.flightNumber': 'Číslo letu',
|
||||
'reservations.meta.from': 'Z',
|
||||
'reservations.meta.to': 'Do',
|
||||
'reservations.needsReview': 'Zkontrolovat',
|
||||
'reservations.needsReviewHint': 'Letiště nebylo možné automaticky rozpoznat — potvrďte prosím místo.',
|
||||
'reservations.searchLocation': 'Hledat stanici, přístav, adresu...',
|
||||
'airport.searchPlaceholder': 'Kód letiště nebo město (např. FRA)',
|
||||
'map.connections': 'Spojení',
|
||||
'map.showConnections': 'Zobrazit trasy rezervací',
|
||||
'map.hideConnections': 'Skrýt trasy rezervací',
|
||||
'settings.bookingLabels': 'Popisky tras rezervací',
|
||||
'settings.bookingLabelsHint': 'Zobrazuje názvy stanic / letišť na mapě. Pokud je vypnuto, zobrazí se pouze ikona.',
|
||||
'reservations.meta.trainNumber': 'Číslo vlaku',
|
||||
'reservations.meta.platform': 'Nástupiště',
|
||||
'reservations.meta.seat': 'Sedadlo',
|
||||
'reservations.meta.checkIn': 'Check-in',
|
||||
'reservations.meta.checkInUntil': 'Check-in do',
|
||||
'reservations.meta.checkOut': 'Check-out',
|
||||
'reservations.meta.linkAccommodation': 'Ubytování',
|
||||
'reservations.meta.pickAccommodation': 'Propojit s ubytováním',
|
||||
@@ -1017,7 +1076,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.type.hotel': 'Ubytování',
|
||||
'reservations.type.restaurant': 'Restaurace',
|
||||
'reservations.type.train': 'Vlak',
|
||||
'reservations.type.car': 'Pronájem auta',
|
||||
'reservations.type.car': 'Auto',
|
||||
'reservations.type.cruise': 'Plavba',
|
||||
'reservations.type.event': 'Událost',
|
||||
'reservations.type.tour': 'Prohlídka',
|
||||
@@ -1078,6 +1137,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Konec',
|
||||
'reservations.span.ongoing': 'Probíhá',
|
||||
'reservations.validation.endBeforeStart': 'Datum/čas konce musí být po datu/čase začátku',
|
||||
'reservations.addBooking': 'Přidat rezervaci',
|
||||
|
||||
// Rozpočet (Budget)
|
||||
'budget.title': 'Rozpočet',
|
||||
@@ -1488,6 +1548,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'Nejprve přidejte místa ke své cestě',
|
||||
'day.allDays': 'Vše',
|
||||
'day.checkIn': 'Check-in',
|
||||
'day.checkInUntil': 'Do',
|
||||
'day.checkOut': 'Check-out',
|
||||
'day.confirmation': 'Potvrzení',
|
||||
'day.editAccommodation': 'Upravit ubytování',
|
||||
@@ -1514,6 +1575,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'Heslo',
|
||||
'memories.providerOTP': 'MFA kód (pokud je povoleno)',
|
||||
'memories.skipSSLVerification': 'Přeskočit ověření SSL certifikátu',
|
||||
'memories.immichAutoUpload': 'Zrcadlit fotky journey při nahrávání také do Immich',
|
||||
'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Otestovat připojení',
|
||||
'memories.testFirst': 'Nejprve otestujte připojení',
|
||||
@@ -1675,6 +1737,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Místa přeseřazena',
|
||||
'undo.optimize': 'Trasa optimalizována',
|
||||
'undo.deletePlace': 'Místo smazáno',
|
||||
'undo.deletePlaces': 'Místa smazána',
|
||||
'undo.moveDay': 'Místo přesunuto na jiný den',
|
||||
'undo.lock': 'Zámek místa přepnut',
|
||||
'undo.importGpx': 'Import GPX',
|
||||
@@ -1736,7 +1799,11 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'todo.unassigned': 'Nepřiřazeno',
|
||||
'todo.noCategory': 'Bez kategorie',
|
||||
'todo.hasDescription': 'Má popis',
|
||||
'todo.addItem': 'Přidat nový úkol...',
|
||||
'todo.addItem': 'Přidat nový úkol',
|
||||
'todo.sidebar.sortBy': 'Řadit podle',
|
||||
'todo.priority': 'Priorita',
|
||||
'todo.newCategoryLabel': 'nová',
|
||||
'budget.categoriesLabel': 'kategorie',
|
||||
'todo.newCategory': 'Název kategorie',
|
||||
'todo.addCategory': 'Přidat kategorii',
|
||||
'todo.newItem': 'Nový úkol',
|
||||
@@ -1778,7 +1845,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.ntfyUrl.test': 'Otestovat',
|
||||
'settings.ntfyUrl.testSuccess': 'Testovací notifikace Ntfy byla úspěšně odeslána',
|
||||
'settings.ntfyUrl.testFailed': 'Testovací notifikace Ntfy selhala',
|
||||
'settings.ntfyUrl.clearToken': 'Vymazat',
|
||||
'settings.ntfyUrl.tokenCleared': 'Přístupový token byl vymazán',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
@@ -1795,22 +1861,29 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Testovací webhook selhal',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook odesílá automaticky, pokud je nastavena URL',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': 'Umožňuje uživatelům nakonfigurovat vlastní témata ntfy pro přijímání push notifikací. Níže nastavte výchozí server pro předvyplnění nastavení uživatelů.',
|
||||
'admin.notifications.testNtfy': 'Odeslat testovací Ntfy',
|
||||
'admin.notifications.testNtfySuccess': 'Testovací Ntfy bylo úspěšně odesláno',
|
||||
'admin.notifications.testNtfyFailed': 'Testovací Ntfy selhalo',
|
||||
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.hint': 'Toto téma Ntfy se používá výhradně pro admin oznámení (např. upozornění na verze). Je nezávislé na tématech uživatelů a odesílá vždy, když je nakonfigurováno.',
|
||||
'admin.notifications.adminNtfyPanel.serverLabel': 'URL serveru Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.serverHint': 'Slouží také jako výchozí server pro ntfy notifikace uživatelů. Ponechte prázdné pro použití ntfy.sh. Uživatelé si to mohou změnit ve vlastním nastavení.',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin téma',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': 'Přístupový token (volitelné)',
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': 'Přístupový token admina byl vymazán',
|
||||
'admin.notifications.adminNtfyPanel.saved': 'Nastavení admin Ntfy uloženo',
|
||||
'admin.notifications.adminNtfyPanel.test': 'Odeslat testovací Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess': 'Testovací Ntfy bylo úspěšně odesláno',
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Testovací Ntfy selhalo',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy odesílá vždy, když je nakonfigurováno téma',
|
||||
'admin.notifications.adminNotificationsHint': 'Nastavte, které kanály doručují admin oznámení (např. upozornění na verze). Webhook odesílá automaticky, pokud je nastavena URL admin webhooku.',
|
||||
'admin.notifications.tripReminders.title': 'Připomínky výletů',
|
||||
'admin.notifications.tripReminders.hint': 'Odešle upozornění před začátkem výletu (vyžaduje nastavené dny připomínky na výletu).',
|
||||
'admin.notifications.tripReminders.enabled': 'Připomínky výletů aktivovány',
|
||||
'admin.notifications.tripReminders.disabled': 'Připomínky výletů deaktivovány',
|
||||
'admin.tabs.notifications': 'Oznámení',
|
||||
'notifications.versionAvailable.title': 'Dostupná aktualizace',
|
||||
'notifications.versionAvailable.text': 'TREK {version} je nyní k dispozici.',
|
||||
@@ -1858,6 +1931,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.saveRouteNotConfigured': 'Trasa uložení není nakonfigurována pro tohoto poskytovatele',
|
||||
'memories.testRouteNotConfigured': 'Testovací trasa není nakonfigurována pro tohoto poskytovatele',
|
||||
'memories.fillRequiredFields': 'Prosím vyplňte všechna povinná pole',
|
||||
'journey.search.placeholder': 'Hledat cesty…',
|
||||
'journey.search.noResults': 'Žádné cesty neodpovídají „{query}"',
|
||||
'journey.title': 'Cestovní deník',
|
||||
'journey.subtitle': 'Zaznamenávejte své cesty průběžně',
|
||||
'journey.new': 'Nový cestovní deník',
|
||||
@@ -1879,6 +1954,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Aktivní',
|
||||
'journey.status.completed': 'Dokončeno',
|
||||
'journey.status.upcoming': 'Nadcházející',
|
||||
'journey.status.archived': 'Archivováno',
|
||||
'journey.checkin.add': 'Odbavit se',
|
||||
'journey.checkin.namePlaceholder': 'Název místa',
|
||||
'journey.checkin.notesPlaceholder': 'Poznámky (volitelné)',
|
||||
@@ -1955,6 +2031,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.verdict.couldBeBetter': 'Mohlo by být lepší',
|
||||
'journey.synced.places': 'místa',
|
||||
'journey.synced.synced': 'synchronizováno',
|
||||
'journey.editor.discardChangesConfirm': 'Máte neuložené změny. Zahodit?',
|
||||
'journey.editor.uploadPhotos': 'Nahrát fotky',
|
||||
'journey.editor.uploading': 'Nahrávání...',
|
||||
'journey.editor.fromGallery': 'Z galerie',
|
||||
@@ -2032,6 +2109,11 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Název',
|
||||
'journey.settings.subtitle': 'Podtitul',
|
||||
'journey.settings.subtitlePlaceholder': 'např. Thajsko, Vietnam a Kambodža',
|
||||
'journey.settings.endJourney': 'Archivovat cestu',
|
||||
'journey.settings.reopenJourney': 'Obnovit cestu',
|
||||
'journey.settings.archived': 'Cesta archivována',
|
||||
'journey.settings.reopened': 'Cesta znovu otevřena',
|
||||
'journey.settings.endDescription': 'Skryje odznak Živě. Kdykoli jej lze znovu otevřít.',
|
||||
'journey.settings.delete': 'Smazat',
|
||||
'journey.settings.deleteJourney': 'Smazat cestovní deník',
|
||||
'journey.settings.deleteMessage': 'Smazat „{title}"? Všechny záznamy a fotky budou ztraceny.',
|
||||
@@ -2167,6 +2249,55 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Vyhledávat místa, řešit URL map a zpětně geokódovat souřadnice',
|
||||
'oauth.scope.weather:read.label': 'Předpovědi počasí',
|
||||
'oauth.scope.weather:read.description': 'Získávat předpovědi počasí pro místa a data výletu',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Vítejte v TREK',
|
||||
'system_notice.welcome_v1.body': 'Váš kompletní plánovač cest. Vytvářejte itineráře, sdílejte výlety s přáteli a zůstaňte organizovaní — online i offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Naplánovat cestu',
|
||||
'system_notice.welcome_v1.hero_alt': 'Malebné cestovní místo s rozhraním TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Denní itineráře pro každou cestu',
|
||||
'system_notice.welcome_v1.highlight_share': 'Spolupráce s cestovními partnery',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Funguje offline na mobilu',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Předchozí oznámení',
|
||||
'system_notice.pager.next': 'Další oznámení',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Přejít na oznámení {n}',
|
||||
'system_notice.pager.position': 'Oznámení {current} z {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Fotografie přesunuty ve verzi 3.0',
|
||||
'system_notice.v3_photos.body': '**Fotografie** v Plánovacím nástroji byly odebrány. Vaše fotografie jsou v bezpečí — TREK nikdy neupravoval vaši knihovnu Immich nebo Synology.\n\nFotografie jsou nyní dostupné v doplňku **Journey**. Journey je volitelný — pokud ještě není k dispozici, požádejte svého správce, aby ho aktivoval v Admin → Doplňky.',
|
||||
'system_notice.v3_journey.title': 'Poznejte Journey — cest. denník',
|
||||
'system_notice.v3_journey.body': 'Dokumentujte své cesty jako bohaté příběhy s časovnicemi, galeriemi fotek a interaktivními mapami.',
|
||||
'system_notice.v3_journey.cta_label': 'Otevřít Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Denní časovnice a galerie',
|
||||
'system_notice.v3_journey.highlight_photos': 'Import z Immich nebo Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Sdílet veřejně — bez přihlašování',
|
||||
'system_notice.v3_journey.highlight_export': 'Export jako PDF fotokniha',
|
||||
'system_notice.v3_features.title': 'Další novinky ve verzi 3.0',
|
||||
'system_notice.v3_features.body': 'Několik dalších změn, které stojí za pozornost.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Předesign dashboardu mobile-first',
|
||||
'system_notice.v3_features.highlight_offline': 'Plný offline režim jako PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autodoplňování vyhledávání míst',
|
||||
'system_notice.v3_features.highlight_import': 'Import míst ze souborů KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: aktualizace OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'Integrace MCP byla kompletně přepracována. OAuth 2.1 je nyní doporučenou metodou ověřování. Statické tokeny (trek_…) jsou zastaralé a budou v budoucí verzi odstraněny.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 doporučeno (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 jemnozrnných oprávnění',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statické tokeny trek_ zastaralé',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Rozšířená sada nástrojů a promptů',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Osobní slovo ode mě',
|
||||
'system_notice.v3_thankyou.body': 'Než budete pokračovat — chci se na chvíli zastavit.\n\nTREK začal jako vedlejší projekt, který jsem vytvořil pro své vlastní cesty. Nikdy jsem si nepředstavoval, že vyroste v něco, čemu 4 000 z vás důvěřuje při plánování svých dobrodružství. Každou hvězdičku, každý issue, každý požadavek na funkci — všechny čtu a právě ony mě drží při životě během pozdních nocí mezi prací na plný úvazek a univerzitou.\n\nChci, abyste věděli: TREK bude vždy open source, vždy self-hosted, vždy váš. Žádné sledování, žádná předplatná, žádné háčky. Jen nástroj vytvořený někým, kdo miluje cestování stejně jako vy.\n\nZvláštní poděkování patří [jubnl](https://github.com/jubnl) — stal ses neuvěřitelným spolupracovníkem. Tolik z toho, co dělá verzi 3.0 skvělou, nese tvůj rukopis. Děkuji, že jsi věřil tomuto projektu, když byl ještě v plenkách.\n\nA každému z vás, kdo nahlásil chybu, přeložil řetězec, sdílel TREK s přítelem nebo ho jednoduše použil k plánování cesty — **děkuji**. Vy jste důvod, proč tohle existuje.\n\nNa mnoho dalších dobrodružství společně.\n\n— Maurice\n\n---\n\n[Přidej se ke komunitě na Discordu](https://discord.gg/7Q6M6jDwzf)\n\nPokud ti TREK zlepšuje cestování, [malá káva](https://ko-fi.com/mauriceboe) vždy pomůže udržet světla rozsvícená.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Doprava',
|
||||
'transport.addManual': 'Ruční doprava',
|
||||
}
|
||||
|
||||
export default cs
|
||||
|
||||
@@ -4,11 +4,15 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'Mehr anzeigen',
|
||||
'common.showLess': 'Weniger anzeigen',
|
||||
'common.cancel': 'Abbrechen',
|
||||
'common.clear': 'Löschen',
|
||||
'common.delete': 'Löschen',
|
||||
'common.edit': 'Bearbeiten',
|
||||
'common.add': 'Hinzufügen',
|
||||
'common.loading': 'Laden...',
|
||||
'common.import': 'Importieren',
|
||||
'common.select': 'Auswählen',
|
||||
'common.selectAll': 'Alle auswählen',
|
||||
'common.deselectAll': 'Alle abwählen',
|
||||
'common.error': 'Fehler',
|
||||
'common.unknownError': 'Unbekannter Fehler',
|
||||
'common.tooManyAttempts': 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
|
||||
@@ -144,7 +148,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.subtitle': 'Konfigurieren Sie Ihre persönlichen Einstellungen',
|
||||
'settings.tabs.display': 'Anzeige',
|
||||
'settings.tabs.map': 'Karte',
|
||||
'settings.tabs.notifications': 'Benachrichtigungen',
|
||||
'settings.tabs.notifications': 'Mitteilungen',
|
||||
'settings.tabs.integrations': 'Integrationen',
|
||||
'settings.tabs.account': 'Konto',
|
||||
'settings.tabs.offline': 'Offline',
|
||||
@@ -175,8 +179,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.temperature': 'Temperatureinheit',
|
||||
'settings.timeFormat': 'Zeitformat',
|
||||
'settings.routeCalculation': 'Routenberechnung',
|
||||
'settings.bookingLabels': 'Orts-Labels auf Buchungsrouten',
|
||||
'settings.bookingLabelsHint': 'Zeigt Bahnhofs-/Flughafennamen auf der Karte. Wenn aus, wird nur das Icon angezeigt.',
|
||||
'settings.blurBookingCodes': 'Buchungscodes verbergen',
|
||||
'settings.notifications': 'Benachrichtigungen',
|
||||
'settings.notifications': 'Mitteilungen',
|
||||
'settings.notifyTripInvite': 'Trip-Einladungen',
|
||||
'settings.notifyBookingChange': 'Buchungsänderungen',
|
||||
'settings.notifyTripReminder': 'Trip-Erinnerungen',
|
||||
@@ -310,6 +316,16 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'Feature vorschlagen',
|
||||
'settings.about.featureRequestHint': 'Schlage ein neues Feature vor',
|
||||
'settings.about.wikiHint': 'Dokumentation & Anleitungen',
|
||||
'settings.about.supporters.badge': 'Monatliche Unterstützer',
|
||||
'settings.about.supporters.title': 'Reisebegleitung für TREK',
|
||||
'settings.about.supporters.subtitle': 'Während du deine nächste Route planst, planen diese Leute mit, wie TREK weitergeht. Ihr monatlicher Beitrag fließt direkt in Entwicklung und echten Zeitaufwand — damit TREK Open Source bleibt.',
|
||||
'settings.about.supporters.since': 'Unterstützer seit {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Sei die/der Erste',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK ist ein selbst gehosteter Reiseplaner, der dir hilft, deine Trips von der ersten Idee bis zur letzten Erinnerung zu organisieren. Tagesplanung, Budget, Packlisten, Fotos und vieles mehr — alles an einem Ort, auf deinem eigenen Server.',
|
||||
'settings.about.madeWith': 'Entwickelt mit',
|
||||
'settings.about.madeBy': 'von Maurice und einer wachsenden Open-Source-Community.',
|
||||
@@ -548,10 +564,30 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.',
|
||||
'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert',
|
||||
|
||||
'admin.placesPhotos.title': 'Ortsfotos',
|
||||
'admin.placesPhotos.subtitle': 'Fotos von der Google Places API laden. Deaktivieren, um API-Kontingent zu sparen. Wikimedia-Fotos sind davon nicht betroffen.',
|
||||
'admin.placesAutocomplete.title': 'Orts-Autovervollständigung',
|
||||
'admin.placesAutocomplete.subtitle': 'Google Places API für Suchvorschläge nutzen. Deaktivieren, um API-Kontingent zu sparen.',
|
||||
'admin.placesDetails.title': 'Ortsdetails',
|
||||
'admin.placesDetails.subtitle': 'Detaillierte Ortsinformationen (Öffnungszeiten, Bewertung, Website) von der Google Places API laden. Deaktivieren, um API-Kontingent zu sparen.',
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.bagTracking.title': 'Gepäck-Tracking',
|
||||
'admin.bagTracking.subtitle': 'Gewicht und Gepäckstück-Zuordnung für Packlisteneinträge aktivieren',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Echtzeit-Nachrichten für die Reiseplanung',
|
||||
'admin.collab.notes.title': 'Notizen',
|
||||
'admin.collab.notes.subtitle': 'Gemeinsame Notizen und Dokumente',
|
||||
'admin.collab.polls.title': 'Umfragen',
|
||||
'admin.collab.polls.subtitle': 'Gruppen-Umfragen und Abstimmungen',
|
||||
'admin.collab.whatsnext.title': 'Was kommt als Nächstes',
|
||||
'admin.collab.whatsnext.subtitle': 'Aktivitätsvorschläge und nächste Schritte',
|
||||
'admin.tabs.config': 'Personalisierung',
|
||||
'admin.tabs.defaults': 'Benutzer-Standards',
|
||||
'admin.defaultSettings.title': 'Standard-Benutzereinstellungen',
|
||||
'admin.defaultSettings.description': 'Instanzweite Standards festlegen. Benutzer, die eine Einstellung nicht geändert haben, sehen diese Werte. Eigene Änderungen haben immer Vorrang.',
|
||||
'admin.defaultSettings.saved': 'Standard gespeichert',
|
||||
'admin.defaultSettings.reset': 'Auf eingebauten Standard zurücksetzen',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'zurücksetzen',
|
||||
'admin.tabs.templates': 'Packvorlagen',
|
||||
'admin.packingTemplates.title': 'Packvorlagen',
|
||||
'admin.packingTemplates.subtitle': 'Wiederverwendbare Packlisten für deine Reisen erstellen',
|
||||
@@ -837,6 +873,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Karte',
|
||||
'trip.tabs.transports': 'Transport',
|
||||
'trip.tabs.reservations': 'Buchungen',
|
||||
'trip.tabs.reservationsShort': 'Buchung',
|
||||
'trip.tabs.packing': 'Liste',
|
||||
@@ -859,6 +896,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Reservierung hinzugefügt',
|
||||
'trip.toast.deleted': 'Gelöscht',
|
||||
'trip.confirm.deletePlace': 'Möchtest du diesen Ort wirklich löschen?',
|
||||
'trip.confirm.deletePlaces': '{count} Orte löschen?',
|
||||
'trip.toast.placesDeleted': '{count} Orte gelöscht',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant',
|
||||
@@ -869,6 +908,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dayplan.cannotDropOnTimed': 'Orte können nicht zwischen zeitgebundene Einträge geschoben werden',
|
||||
'dayplan.cannotBreakChronology': 'Die zeitliche Reihenfolge von Uhrzeiten und Buchungen darf nicht verletzt werden',
|
||||
'dayplan.addNote': 'Notiz hinzufügen',
|
||||
'dayplan.expandAll': 'Alle Tage ausklappen',
|
||||
'dayplan.collapseAll': 'Alle Tage einklappen',
|
||||
'dayplan.editNote': 'Notiz bearbeiten',
|
||||
'dayplan.noteAdd': 'Notiz hinzufügen',
|
||||
'dayplan.noteEdit': 'Notiz bearbeiten',
|
||||
@@ -893,7 +934,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Ort/Aktivität hinzufügen',
|
||||
'places.importFile': 'Datei importieren',
|
||||
'places.importFile': 'Dateimport',
|
||||
'places.sidebarDrop': 'Ablegen zum Importieren',
|
||||
'places.importFileHint': '.gpx-, .kml- oder .kmz-Dateien aus Tools wie Google My Maps, Google Earth oder einem GPS-Tracker importieren.',
|
||||
'places.importFileDropHere': 'Datei auswählen oder hierher ziehen und ablegen',
|
||||
@@ -903,6 +944,17 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Import fehlgeschlagen',
|
||||
'places.importAllSkipped': 'Alle Orte waren bereits in der Reise.',
|
||||
'places.gpxImported': '{count} Orte aus GPX importiert',
|
||||
'places.gpxImportTypes': 'Was soll importiert werden?',
|
||||
'places.gpxImportWaypoints': 'Wegpunkte',
|
||||
'places.gpxImportRoutes': 'Routen',
|
||||
'places.gpxImportTracks': 'Tracks (mit Streckenverlauf)',
|
||||
'places.gpxImportNoneSelected': 'Wähle mindestens einen Typ zum Importieren.',
|
||||
'places.kmlImportTypes': 'Was möchtest du importieren?',
|
||||
'places.kmlImportPoints': 'Punkte (Placemarks)',
|
||||
'places.kmlImportPaths': 'Pfade (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Wähle mindestens einen Typ aus.',
|
||||
'places.selectionCount': '{count} ausgewählt',
|
||||
'places.deleteSelected': 'Auswahl löschen',
|
||||
'places.kmlKmzImported': '{count} Orte aus KMZ/KML importiert',
|
||||
'places.urlResolved': 'Ort aus URL importiert',
|
||||
'places.importList': 'Listenimport',
|
||||
@@ -919,6 +971,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
|
||||
'places.all': 'Alle',
|
||||
'places.unplanned': 'Ungeplant',
|
||||
'places.filterTracks': 'Tracks',
|
||||
'places.search': 'Orte suchen...',
|
||||
'places.allCategories': 'Alle Kategorien',
|
||||
'places.categoriesSelected': 'Kategorien',
|
||||
@@ -1002,10 +1055,18 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.flightNumber': 'Flugnr.',
|
||||
'reservations.meta.from': 'Von',
|
||||
'reservations.meta.to': 'Nach',
|
||||
'reservations.needsReview': 'Prüfen',
|
||||
'reservations.needsReviewHint': 'Flughafen konnte nicht automatisch erkannt werden — bitte Ort bestätigen.',
|
||||
'reservations.searchLocation': 'Bahnhof, Hafen, Adresse suchen…',
|
||||
'airport.searchPlaceholder': 'Flughafencode oder Stadt (z. B. FRA)',
|
||||
'map.connections': 'Verbindungen',
|
||||
'map.showConnections': 'Buchungsrouten anzeigen',
|
||||
'map.hideConnections': 'Buchungsrouten ausblenden',
|
||||
'reservations.meta.trainNumber': 'Zugnr.',
|
||||
'reservations.meta.platform': 'Gleis',
|
||||
'reservations.meta.seat': 'Sitzplatz',
|
||||
'reservations.meta.checkIn': 'Check-in',
|
||||
'reservations.meta.checkInUntil': 'Check-in bis',
|
||||
'reservations.meta.checkOut': 'Check-out',
|
||||
'reservations.meta.linkAccommodation': 'Unterkunft',
|
||||
'reservations.meta.pickAccommodation': 'Mit Unterkunft verknüpfen',
|
||||
@@ -1019,7 +1080,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.type.hotel': 'Unterkunft',
|
||||
'reservations.type.restaurant': 'Restaurant',
|
||||
'reservations.type.train': 'Zug',
|
||||
'reservations.type.car': 'Mietwagen',
|
||||
'reservations.type.car': 'Auto',
|
||||
'reservations.type.cruise': 'Kreuzfahrt',
|
||||
'reservations.type.event': 'Veranstaltung',
|
||||
'reservations.type.tour': 'Tour',
|
||||
@@ -1080,6 +1141,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Ende',
|
||||
'reservations.span.ongoing': 'Laufend',
|
||||
'reservations.validation.endBeforeStart': 'Enddatum/-zeit muss nach dem Startdatum/-zeit liegen',
|
||||
'reservations.addBooking': 'Buchung hinzufügen',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
@@ -1490,6 +1552,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'Füge zuerst Orte zu deiner Reise hinzu',
|
||||
'day.allDays': 'Alle',
|
||||
'day.checkIn': 'Check-in',
|
||||
'day.checkInUntil': 'Bis',
|
||||
'day.checkOut': 'Check-out',
|
||||
'day.confirmation': 'Bestätigung',
|
||||
'day.editAccommodation': 'Unterkunft bearbeiten',
|
||||
@@ -1516,6 +1579,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'Passwort',
|
||||
'memories.providerOTP': 'MFA-Code (falls aktiviert)',
|
||||
'memories.skipSSLVerification': 'SSL-Zertifikatsprüfung überspringen',
|
||||
'memories.immichAutoUpload': 'Journey-Fotos beim Upload auch zu Immich spiegeln',
|
||||
'memories.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Verbindung testen',
|
||||
'memories.testFirst': 'Verbindung zuerst testen',
|
||||
@@ -1680,6 +1744,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Orte neu sortiert',
|
||||
'undo.optimize': 'Route optimiert',
|
||||
'undo.deletePlace': 'Ort gelöscht',
|
||||
'undo.deletePlaces': 'Orte gelöscht',
|
||||
'undo.moveDay': 'Ort zu anderem Tag verschoben',
|
||||
'undo.lock': 'Ortssperre umgeschaltet',
|
||||
'undo.importGpx': 'GPX-Import',
|
||||
@@ -1739,7 +1804,11 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'todo.unassigned': 'Nicht zugewiesen',
|
||||
'todo.noCategory': 'Keine Kategorie',
|
||||
'todo.hasDescription': 'Hat Beschreibung',
|
||||
'todo.addItem': 'Neue Aufgabe hinzufügen...',
|
||||
'todo.addItem': 'Neue Aufgabe hinzufügen',
|
||||
'todo.sidebar.sortBy': 'Sortieren nach',
|
||||
'todo.priority': 'Priorität',
|
||||
'todo.newCategoryLabel': 'neu',
|
||||
'budget.categoriesLabel': 'Kategorien',
|
||||
'todo.newCategory': 'Kategoriename',
|
||||
'todo.addCategory': 'Kategorie hinzufügen',
|
||||
'todo.newItem': 'Neue Aufgabe',
|
||||
@@ -1781,7 +1850,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.ntfyUrl.test': 'Testen',
|
||||
'settings.ntfyUrl.testSuccess': 'Test-Ntfy-Benachrichtigung erfolgreich gesendet',
|
||||
'settings.ntfyUrl.testFailed': 'Test-Ntfy-Benachrichtigung fehlgeschlagen',
|
||||
'settings.ntfyUrl.clearToken': 'Löschen',
|
||||
'settings.ntfyUrl.tokenCleared': 'Zugriffstoken gelöscht',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
@@ -1798,22 +1866,29 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Test-Webhook fehlgeschlagen',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-Webhook sendet automatisch, wenn eine URL konfiguriert ist',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': 'Erlaubt Benutzern, eigene ntfy-Themen für Push-Benachrichtigungen zu konfigurieren. Legen Sie unten den Standardserver fest, um die Benutzereinstellungen vorauszufüllen.',
|
||||
'admin.notifications.testNtfy': 'Test-Ntfy senden',
|
||||
'admin.notifications.testNtfySuccess': 'Test-Ntfy erfolgreich gesendet',
|
||||
'admin.notifications.testNtfyFailed': 'Test-Ntfy fehlgeschlagen',
|
||||
'admin.notifications.adminNtfyPanel.title': 'Admin-Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.hint': 'Dieses Ntfy-Thema wird ausschließlich für Admin-Benachrichtigungen verwendet (z. B. Versions-Updates). Es ist unabhängig von Benutzer-Themen und sendet immer, wenn es konfiguriert ist.',
|
||||
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy-Server-URL',
|
||||
'admin.notifications.adminNtfyPanel.serverHint': 'Wird auch als Standardserver für Benutzer-ntfy-Benachrichtigungen verwendet. Leer lassen für ntfy.sh. Benutzer können dies in ihren eigenen Einstellungen überschreiben.',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin-Thema',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': 'Zugriffstoken (optional)',
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': 'Admin-Zugriffstoken gelöscht',
|
||||
'admin.notifications.adminNtfyPanel.saved': 'Admin-Ntfy-Einstellungen gespeichert',
|
||||
'admin.notifications.adminNtfyPanel.test': 'Test-Ntfy senden',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess': 'Test-Ntfy erfolgreich gesendet',
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy fehlgeschlagen',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin-Ntfy sendet immer, wenn ein Thema konfiguriert ist',
|
||||
'admin.notifications.adminNotificationsHint': 'Konfiguriere, welche Kanäle Admin-Benachrichtigungen liefern (z. B. Versions-Updates). Der Webhook sendet automatisch, wenn eine Admin-Webhook-URL gesetzt ist.',
|
||||
'admin.notifications.tripReminders.title': 'Reiseerinnerungen',
|
||||
'admin.notifications.tripReminders.hint': 'Sendet eine Erinnerungsbenachrichtigung vor Reisebeginn (erfordert gesetzte Erinnerungstage bei der Reise).',
|
||||
'admin.notifications.tripReminders.enabled': 'Reiseerinnerungen aktiviert',
|
||||
'admin.notifications.tripReminders.disabled': 'Reiseerinnerungen deaktiviert',
|
||||
'admin.tabs.notifications': 'Benachrichtigungen',
|
||||
'notifications.versionAvailable.title': 'Update verfügbar',
|
||||
'notifications.versionAvailable.text': 'TREK {version} ist jetzt verfügbar.',
|
||||
@@ -1855,6 +1930,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.dev.unknown_event.text': 'Ereignistyp "{event}" ist nicht in EVENT_NOTIFICATION_CONFIG registriert',
|
||||
|
||||
// Journey Addon
|
||||
'journey.search.placeholder': 'Reisen suchen…',
|
||||
'journey.search.noResults': 'Keine Reisen passen zu „{query}"',
|
||||
'journey.title': 'Journey',
|
||||
'journey.subtitle': 'Dokumentiere deine Reisen unterwegs',
|
||||
'journey.new': 'Neue Journey',
|
||||
@@ -1876,6 +1953,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Aktiv',
|
||||
'journey.status.completed': 'Abgeschlossen',
|
||||
'journey.status.upcoming': 'Anstehend',
|
||||
'journey.status.archived': 'Archiviert',
|
||||
'journey.checkin.add': 'Einchecken',
|
||||
'journey.checkin.namePlaceholder': 'Ortsname',
|
||||
'journey.checkin.notesPlaceholder': 'Notizen (optional)',
|
||||
@@ -1956,6 +2034,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.verdict.couldBeBetter': 'Verbesserungswürdig',
|
||||
'journey.synced.places': 'Orte',
|
||||
'journey.synced.synced': 'synchronisiert',
|
||||
'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?',
|
||||
'journey.editor.uploadPhotos': 'Fotos hochladen',
|
||||
'journey.editor.uploading': 'Hochladen...',
|
||||
'journey.editor.fromGallery': 'Aus Galerie',
|
||||
@@ -2006,6 +2085,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.contributors.role': 'Rolle',
|
||||
'journey.contributors.added': 'Mitwirkender hinzugefügt',
|
||||
'journey.contributors.addFailed': 'Hinzufügen fehlgeschlagen',
|
||||
'journey.contributors.remove': 'Mitwirkenden entfernen',
|
||||
'journey.contributors.removeConfirm': '{username} aus dieser Journey entfernen?',
|
||||
'journey.contributors.removed': 'Mitwirkender entfernt',
|
||||
'journey.contributors.removeFailed': 'Entfernen fehlgeschlagen',
|
||||
'journey.share.publicShare': 'Öffentlicher Link',
|
||||
'journey.share.createLink': 'Link erstellen',
|
||||
'journey.share.linkCreated': 'Link erstellt',
|
||||
@@ -2033,6 +2116,11 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Name',
|
||||
'journey.settings.subtitle': 'Untertitel',
|
||||
'journey.settings.subtitlePlaceholder': 'z.B. Thailand, Vietnam & Kambodscha',
|
||||
'journey.settings.endJourney': 'Reise archivieren',
|
||||
'journey.settings.reopenJourney': 'Reise wiederherstellen',
|
||||
'journey.settings.archived': 'Reise archiviert',
|
||||
'journey.settings.reopened': 'Reise erneut geöffnet',
|
||||
'journey.settings.endDescription': 'Blendet das Live-Abzeichen aus. Sie können jederzeit wieder öffnen.',
|
||||
'journey.settings.delete': 'Löschen',
|
||||
'journey.settings.deleteJourney': 'Journey löschen',
|
||||
'journey.settings.deleteMessage': '"{title}" löschen? Alle Einträge und Fotos gehen verloren.',
|
||||
@@ -2167,6 +2255,55 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Orte suchen, Karten-URLs auflösen und Koordinaten rückwärts geokodieren',
|
||||
'oauth.scope.weather:read.label': 'Wettervorhersagen',
|
||||
'oauth.scope.weather:read.description': 'Wettervorhersagen für Reiseorte und -daten abrufen',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Willkommen bei TREK',
|
||||
'system_notice.welcome_v1.body': 'Dein All-in-one-Reiseplaner. Erstelle Reisepläne, teile sie mit Freunden und bleib organisiert – online und offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Reise planen',
|
||||
'system_notice.welcome_v1.hero_alt': 'Malerisches Reiseziel mit TREK-Planungs-UI',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Tagesweise Reisepläne für jede Reise',
|
||||
'system_notice.welcome_v1.highlight_share': 'Gemeinsam mit Reisepartnern planen',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Funktioniert offline auf dem Handy',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Vorherige Meldung',
|
||||
'system_notice.pager.next': 'Nächste Meldung',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Zu Meldung {n}',
|
||||
'system_notice.pager.position': 'Meldung {current} von {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Fotos wurden in 3.0 verschoben',
|
||||
'system_notice.v3_photos.body': '**Fotos** im Trip-Planer wurden entfernt. Deine Fotos sind sicher — TREK hat deine Immich- oder Synology-Bibliothek nie verändert.\n\nFotos befinden sich jetzt im **Journey**-Addon. Journey ist optional — falls es noch nicht verfügbar ist, bitte deinen Admin, es unter Admin → Addons zu aktivieren.',
|
||||
'system_notice.v3_journey.title': 'Neu: Journey — dein Reisetagebuch',
|
||||
'system_notice.v3_journey.body': 'Dokumentiere deine Reisen als lebendige Geschichten mit Zeitachsen, Fotogalerien und interaktiven Karten.',
|
||||
'system_notice.v3_journey.cta_label': 'Journey öffnen',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Zeitleiste und Galerie',
|
||||
'system_notice.v3_journey.highlight_photos': 'Import von Immich oder Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Öffentlich teilen — kein Login nötig',
|
||||
'system_notice.v3_journey.highlight_export': 'Als PDF-Fotobuch exportieren',
|
||||
'system_notice.v3_features.title': 'Weitere Highlights in 3.0',
|
||||
'system_notice.v3_features.body': 'Ein paar weitere Neuerungen in diesem Release.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Mobile-first Dashboard-Redesign',
|
||||
'system_notice.v3_features.highlight_offline': 'Vollständiger Offline-Modus als PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Echtzeit-Autovervollständigung für Orte',
|
||||
'system_notice.v3_features.highlight_import': 'Orte aus KMZ/KML-Dateien importieren',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1-Upgrade',
|
||||
'system_notice.v3_mcp.body': 'Die MCP-Integration wurde vollständig überarbeitet. OAuth 2.1 ist jetzt die empfohlene Authentifizierungsmethode. Statische Tokens (trek_…) sind veraltet und werden in einer zukünftigen Version entfernt.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 empfohlen (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 feingranulare Berechtigungs-Scopes',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statische trek_-Tokens veraltet',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Erweitertes Toolset & Prompts',
|
||||
|
||||
// System notices — persönlicher Dank
|
||||
'system_notice.v3_thankyou.title': 'Ein persönliches Wort von mir',
|
||||
'system_notice.v3_thankyou.body': 'Bevor du weiterklickst — einen Moment noch.\n\nTREK hat als Nebenprojekt für meine eigenen Reisen angefangen. Ich hätte nie gedacht, dass es jemals so weit kommt, dass 4.000 von euch damit ihre Abenteuer planen. Jeder Stern, jedes Issue, jeder Feature-Wunsch — ich lese sie alle, und sie halten mich am Laufen durch die späten Nächte zwischen Vollzeitjob und Studium.\n\nEins will ich euch sagen: TREK wird immer Open Source bleiben, immer self-hosted, immer eures. Kein Tracking, keine Abos, keine versteckten Haken. Einfach ein Tool, gebaut von jemandem, der das Reisen genauso liebt wie ihr.\n\nBesonderer Dank an [jubnl](https://github.com/jubnl) — du bist ein unglaublicher Mitstreiter geworden. So vieles, was 3.0 großartig macht, trägt deine Handschrift. Danke, dass du an dieses Projekt geglaubt hast, als es noch holprig war.\n\nUnd an jeden einzelnen von euch, der einen Bug gemeldet, einen String übersetzt, TREK mit Freunden geteilt oder einfach damit eine Reise geplant hat — **danke**. Ihr seid der Grund, warum es das hier gibt.\n\nAuf viele weitere Abenteuer zusammen.\n\n— Maurice\n\n---\n\n[Tritt der Community auf Discord bei](https://discord.gg/7Q6M6jDwzf)\n\nWenn TREK deine Reisen besser macht, hält ein [kleiner Kaffee](https://ko-fi.com/mauriceboe) die Lichter an.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transporte',
|
||||
'transport.addManual': 'Manuelles Transportmittel',
|
||||
}
|
||||
|
||||
export default de
|
||||
|
||||
@@ -4,11 +4,15 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'Show more',
|
||||
'common.showLess': 'Show less',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.clear': 'Clear',
|
||||
'common.delete': 'Delete',
|
||||
'common.edit': 'Edit',
|
||||
'common.add': 'Add',
|
||||
'common.loading': 'Loading...',
|
||||
'common.import': 'Import',
|
||||
'common.select': 'Select',
|
||||
'common.selectAll': 'Select all',
|
||||
'common.deselectAll': 'Deselect all',
|
||||
'common.error': 'Error',
|
||||
'common.unknownError': 'Unknown error',
|
||||
'common.tooManyAttempts': 'Too many attempts. Please try again later.',
|
||||
@@ -175,6 +179,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.temperature': 'Temperature Unit',
|
||||
'settings.timeFormat': 'Time Format',
|
||||
'settings.routeCalculation': 'Route Calculation',
|
||||
'settings.bookingLabels': 'Booking route labels',
|
||||
'settings.bookingLabelsHint': 'Show station / airport names on the map. When off, only the icon is shown.',
|
||||
'settings.blurBookingCodes': 'Blur Booking Codes',
|
||||
'settings.notifications': 'Notifications',
|
||||
'settings.notifyTripInvite': 'Trip invitations',
|
||||
@@ -209,7 +215,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.ntfyUrl.test': 'Test',
|
||||
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
|
||||
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
|
||||
'settings.ntfyUrl.clearToken': 'Clear',
|
||||
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
|
||||
'admin.notifications.title': 'Notifications',
|
||||
'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.',
|
||||
@@ -217,6 +222,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.email': 'Email (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': 'Allow users to configure their own ntfy topics for push notifications. Set the default server below to pre-fill user settings.',
|
||||
'admin.notifications.save': 'Save notification settings',
|
||||
'admin.notifications.saved': 'Notification settings saved',
|
||||
'admin.notifications.testWebhook': 'Send test webhook',
|
||||
@@ -238,16 +244,22 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
|
||||
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
|
||||
'admin.notifications.adminNtfyPanel.serverHint': 'Also used as the default server for user ntfy notifications. Leave blank to default to ntfy.sh. Users can override this in their own settings.',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': 'Admin access token cleared',
|
||||
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
|
||||
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||
'admin.notifications.adminNotificationsHint': 'Configure which channels deliver admin-only notifications (e.g. version alerts).',
|
||||
'admin.notifications.tripReminders.title': 'Trip Reminders',
|
||||
'admin.notifications.tripReminders.hint': 'Send a reminder notification before a trip starts (requires reminder days to be set on the trip).',
|
||||
'admin.notifications.tripReminders.enabled': 'Trip reminders enabled',
|
||||
'admin.notifications.tripReminders.disabled': 'Trip reminders disabled',
|
||||
'admin.smtp.title': 'Email & Notifications',
|
||||
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
|
||||
'admin.smtp.testButton': 'Send test email',
|
||||
@@ -363,6 +375,16 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'Feature Request',
|
||||
'settings.about.featureRequestHint': 'Suggest a new feature',
|
||||
'settings.about.wikiHint': 'Documentation & guides',
|
||||
'settings.about.supporters.badge': 'Monthly Supporters',
|
||||
'settings.about.supporters.title': 'Travel companions for TREK',
|
||||
'settings.about.supporters.subtitle': "While you're planning your next route, these folks are helping plan TREK's future. Their monthly contribution goes straight into development and real hours spent — so TREK stays Open Source.",
|
||||
'settings.about.supporters.since': 'supporter since {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Be the first',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK is a self-hosted travel planner that helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place, on your own server.',
|
||||
'settings.about.madeWith': 'Made with',
|
||||
'settings.about.madeBy': 'by Maurice and a growing open-source community.',
|
||||
@@ -602,10 +624,30 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.',
|
||||
'admin.fileTypesSaved': 'File type settings saved',
|
||||
|
||||
'admin.placesPhotos.title': 'Place Photos',
|
||||
'admin.placesPhotos.subtitle': 'Fetch photos from the Google Places API. Disable to save API quota. Wikimedia photos are unaffected.',
|
||||
'admin.placesAutocomplete.title': 'Place Autocomplete',
|
||||
'admin.placesAutocomplete.subtitle': 'Use the Google Places API for search suggestions. Disable to save API quota.',
|
||||
'admin.placesDetails.title': 'Place Details',
|
||||
'admin.placesDetails.subtitle': 'Fetch detailed place information (hours, rating, website) from the Google Places API. Disable to save API quota.',
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.bagTracking.title': 'Bag Tracking',
|
||||
'admin.bagTracking.subtitle': 'Enable weight and bag assignment for packing items',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Real-time messaging for trip collaboration',
|
||||
'admin.collab.notes.title': 'Notes',
|
||||
'admin.collab.notes.subtitle': 'Shared notes and documents',
|
||||
'admin.collab.polls.title': 'Polls',
|
||||
'admin.collab.polls.subtitle': 'Group polls and voting',
|
||||
'admin.collab.whatsnext.title': "What's Next",
|
||||
'admin.collab.whatsnext.subtitle': 'Activity suggestions and next steps',
|
||||
'admin.tabs.config': 'Personalization',
|
||||
'admin.tabs.defaults': 'User Defaults',
|
||||
'admin.defaultSettings.title': 'Default User Settings',
|
||||
'admin.defaultSettings.description': 'Set instance-wide defaults. Users who have not changed a setting will see these values. Their own changes always take priority.',
|
||||
'admin.defaultSettings.saved': 'Default saved',
|
||||
'admin.defaultSettings.reset': 'Reset to built-in default',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'reset',
|
||||
'admin.tabs.templates': 'Packing Templates',
|
||||
'admin.packingTemplates.title': 'Packing Templates',
|
||||
'admin.packingTemplates.subtitle': 'Create reusable packing lists for your trips',
|
||||
@@ -888,6 +930,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.transports': 'Transports',
|
||||
'trip.tabs.reservations': 'Bookings',
|
||||
'trip.tabs.reservationsShort': 'Book',
|
||||
'trip.tabs.packing': 'Packing List',
|
||||
@@ -910,6 +953,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Reservation added',
|
||||
'trip.toast.deleted': 'Deleted',
|
||||
'trip.confirm.deletePlace': 'Are you sure you want to delete this place?',
|
||||
'trip.confirm.deletePlaces': 'Delete {count} places?',
|
||||
'trip.toast.placesDeleted': '{count} places deleted',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'No places planned for this day',
|
||||
@@ -920,6 +965,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dayplan.cannotDropOnTimed': 'Items cannot be placed between time-bound entries',
|
||||
'dayplan.cannotBreakChronology': 'This would break the chronological order of timed items and bookings',
|
||||
'dayplan.addNote': 'Add Note',
|
||||
'dayplan.expandAll': 'Expand all days',
|
||||
'dayplan.collapseAll': 'Collapse all days',
|
||||
'dayplan.editNote': 'Edit Note',
|
||||
'dayplan.noteAdd': 'Add Note',
|
||||
'dayplan.noteEdit': 'Edit Note',
|
||||
@@ -954,6 +1001,17 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Import failed',
|
||||
'places.importAllSkipped': 'All places were already in the trip.',
|
||||
'places.gpxImported': '{count} places imported from GPX',
|
||||
'places.gpxImportTypes': 'What do you want to import?',
|
||||
'places.gpxImportWaypoints': 'Waypoints',
|
||||
'places.gpxImportRoutes': 'Routes',
|
||||
'places.gpxImportTracks': 'Tracks (with path geometry)',
|
||||
'places.gpxImportNoneSelected': 'Select at least one type to import.',
|
||||
'places.kmlImportTypes': 'What do you want to import?',
|
||||
'places.kmlImportPoints': 'Points (Placemarks)',
|
||||
'places.kmlImportPaths': 'Paths (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Select at least one type to import.',
|
||||
'places.selectionCount': '{count} selected',
|
||||
'places.deleteSelected': 'Delete selected',
|
||||
'places.kmlKmzImported': '{count} places imported from KMZ/KML',
|
||||
'places.urlResolved': 'Place imported from URL',
|
||||
'places.importList': 'List Import',
|
||||
@@ -970,6 +1028,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Add to which day?',
|
||||
'places.all': 'All',
|
||||
'places.unplanned': 'Unplanned',
|
||||
'places.filterTracks': 'Tracks',
|
||||
'places.search': 'Search places...',
|
||||
'places.allCategories': 'All Categories',
|
||||
'places.categoriesSelected': 'categories',
|
||||
@@ -1053,10 +1112,18 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.flightNumber': 'Flight No.',
|
||||
'reservations.meta.from': 'From',
|
||||
'reservations.meta.to': 'To',
|
||||
'reservations.needsReview': 'Review',
|
||||
'reservations.needsReviewHint': 'Airport could not be matched automatically — please confirm the location.',
|
||||
'reservations.searchLocation': 'Search station, port, address…',
|
||||
'airport.searchPlaceholder': 'Airport code or city (e.g. FRA)',
|
||||
'map.connections': 'Connections',
|
||||
'map.showConnections': 'Show booking routes',
|
||||
'map.hideConnections': 'Hide booking routes',
|
||||
'reservations.meta.trainNumber': 'Train No.',
|
||||
'reservations.meta.platform': 'Platform',
|
||||
'reservations.meta.seat': 'Seat',
|
||||
'reservations.meta.checkIn': 'Check-in',
|
||||
'reservations.meta.checkInUntil': 'Check-in until',
|
||||
'reservations.meta.checkOut': 'Check-out',
|
||||
'reservations.meta.linkAccommodation': 'Accommodation',
|
||||
'reservations.meta.pickAccommodation': 'Link to accommodation',
|
||||
@@ -1070,7 +1137,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.type.hotel': 'Accommodation',
|
||||
'reservations.type.restaurant': 'Restaurant',
|
||||
'reservations.type.train': 'Train',
|
||||
'reservations.type.car': 'Rental Car',
|
||||
'reservations.type.car': 'Car',
|
||||
'reservations.type.cruise': 'Cruise',
|
||||
'reservations.type.event': 'Event',
|
||||
'reservations.type.tour': 'Tour',
|
||||
@@ -1131,6 +1198,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'End',
|
||||
'reservations.span.ongoing': 'Ongoing',
|
||||
'reservations.validation.endBeforeStart': 'End date/time must be after start date/time',
|
||||
'reservations.addBooking': 'Add booking',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
@@ -1541,6 +1609,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'Add places to your trip first',
|
||||
'day.allDays': 'All',
|
||||
'day.checkIn': 'Check-in',
|
||||
'day.checkInUntil': 'Until',
|
||||
'day.checkOut': 'Check-out',
|
||||
'day.confirmation': 'Confirmation',
|
||||
'day.editAccommodation': 'Edit accommodation',
|
||||
@@ -1569,6 +1638,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'Password',
|
||||
'memories.providerOTP': 'MFA code (if enabled)',
|
||||
'memories.skipSSLVerification': 'Skip SSL certificate verification',
|
||||
'memories.immichAutoUpload': 'Mirror journey photos to Immich on upload',
|
||||
'memories.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Test connection',
|
||||
'memories.testFirst': 'Test connection first',
|
||||
@@ -1743,6 +1813,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Places reordered',
|
||||
'undo.optimize': 'Route optimized',
|
||||
'undo.deletePlace': 'Place deleted',
|
||||
'undo.deletePlaces': 'Places deleted',
|
||||
'undo.moveDay': 'Place moved to another day',
|
||||
'undo.lock': 'Place lock toggled',
|
||||
'undo.importGpx': 'GPX import',
|
||||
@@ -1799,7 +1870,11 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'todo.unassigned': 'Unassigned',
|
||||
'todo.noCategory': 'No category',
|
||||
'todo.hasDescription': 'Has description',
|
||||
'todo.addItem': 'Add new task...',
|
||||
'todo.addItem': 'Add new task',
|
||||
'todo.sidebar.sortBy': 'Sort by',
|
||||
'todo.priority': 'Priority',
|
||||
'todo.newCategoryLabel': 'new',
|
||||
'budget.categoriesLabel': 'categories',
|
||||
'todo.newCategory': 'Category name',
|
||||
'todo.addCategory': 'Add category',
|
||||
'todo.newItem': 'New task',
|
||||
@@ -1858,6 +1933,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG',
|
||||
|
||||
// Journey addon
|
||||
'journey.search.placeholder': 'Search journeys…',
|
||||
'journey.search.noResults': 'No journeys match "{query}"',
|
||||
'journey.title': 'Journey',
|
||||
'journey.subtitle': 'Track your travels as they happen',
|
||||
'journey.new': 'New Journey',
|
||||
@@ -1879,6 +1956,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Active',
|
||||
'journey.status.completed': 'Completed',
|
||||
'journey.status.upcoming': 'Upcoming',
|
||||
'journey.status.archived': 'Archived',
|
||||
'journey.checkin.add': 'Check in',
|
||||
'journey.checkin.namePlaceholder': 'Location name',
|
||||
'journey.checkin.notesPlaceholder': 'Notes (optional)',
|
||||
@@ -1939,6 +2017,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.detail.noEntriesHint': 'Add a trip to get started with skeleton entries',
|
||||
'journey.detail.noPhotos': 'No photos yet',
|
||||
'journey.detail.noPhotosHint': 'Upload photos to entries or browse your Immich/Synology library',
|
||||
'journey.detail.journeyTab': 'Journey',
|
||||
'journey.detail.journeyStats': 'Journey Stats',
|
||||
'journey.detail.syncedTrips': 'Synced Trips',
|
||||
'journey.detail.noTripsLinked': 'No trips linked yet',
|
||||
@@ -1967,6 +2046,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.synced': 'synced',
|
||||
|
||||
// Journey Entry Editor
|
||||
'journey.editor.discardChangesConfirm': 'You have unsaved changes. Discard them?',
|
||||
'journey.editor.uploadPhotos': 'Upload photos',
|
||||
'journey.editor.uploading': 'Uploading...',
|
||||
'journey.editor.fromGallery': 'From Gallery',
|
||||
@@ -2025,6 +2105,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.contributors.role': 'Role',
|
||||
'journey.contributors.added': 'Contributor added',
|
||||
'journey.contributors.addFailed': 'Failed to add contributor',
|
||||
'journey.contributors.remove': 'Remove contributor',
|
||||
'journey.contributors.removeConfirm': 'Remove {username} from this journey?',
|
||||
'journey.contributors.removed': 'Contributor removed',
|
||||
'journey.contributors.removeFailed': 'Failed to remove contributor',
|
||||
|
||||
// Journey — Share
|
||||
'journey.share.publicShare': 'Public Share',
|
||||
@@ -2056,6 +2140,11 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Name',
|
||||
'journey.settings.subtitle': 'Subtitle',
|
||||
'journey.settings.subtitlePlaceholder': 'e.g. Thailand, Vietnam & Cambodia',
|
||||
'journey.settings.endJourney': 'Archive Journey',
|
||||
'journey.settings.reopenJourney': 'Restore Journey',
|
||||
'journey.settings.archived': 'Journey archived',
|
||||
'journey.settings.reopened': 'Journey reopened',
|
||||
'journey.settings.endDescription': 'Hides the Live badge. You can reopen anytime.',
|
||||
'journey.settings.delete': 'Delete',
|
||||
'journey.settings.deleteJourney': 'Delete Journey',
|
||||
'journey.settings.deleteMessage': 'Delete "{title}"? All entries and photos will be lost.',
|
||||
@@ -2203,6 +2292,56 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Search locations, resolve map URLs, and reverse geocode coordinates',
|
||||
'oauth.scope.weather:read.label': 'Weather forecasts',
|
||||
'oauth.scope.weather:read.description': 'Fetch weather forecasts for trip locations and dates',
|
||||
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Photos have moved in 3.0',
|
||||
'system_notice.v3_photos.body': '**Photos** in the Trip Planner have been removed. Your photos are safe — TREK never modified your Immich or Synology library.\n\nPhotos now live in the **Journey** addon. Journey is optional — if it is not yet available, ask your admin to enable it under Admin → Addons.',
|
||||
'system_notice.v3_journey.title': 'Meet Journey — travel journal',
|
||||
'system_notice.v3_journey.body': 'Document your trips as rich travel stories with timelines, photo galleries, and interactive maps.',
|
||||
'system_notice.v3_journey.cta_label': 'Open Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Day-by-day timeline & gallery',
|
||||
'system_notice.v3_journey.highlight_photos': 'Import from Immich or Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Share publicly — no login needed',
|
||||
'system_notice.v3_journey.highlight_export': 'Export as a PDF photo book',
|
||||
'system_notice.v3_features.title': 'More highlights in 3.0',
|
||||
'system_notice.v3_features.body': 'A few more things worth knowing about this release.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Mobile-first dashboard redesign',
|
||||
'system_notice.v3_features.highlight_offline': 'Full offline mode as a PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Real-time place search autocomplete',
|
||||
'system_notice.v3_features.highlight_import': 'Import places from KMZ/KML files',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1 upgrade',
|
||||
'system_notice.v3_mcp.body': 'The MCP integration has been fully overhauled. OAuth 2.1 is now the recommended auth method. Legacy static tokens (trek_\u2026) are deprecated and will be removed in a future release.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recommended (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 fine-grained permission scopes',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Static trek_ tokens deprecated',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Expanded toolset & prompts',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'A personal note from me',
|
||||
'system_notice.v3_thankyou.body': 'Before you go — I want to take a moment.\n\nTREK started as a side project I built for my own trips. I never imagined it would grow into something that 4,000 of you now trust to plan your adventures. Every star, every issue, every feature request — I read them all, and they keep me going through late nights between a full-time job and university.\n\nI want you to know: TREK will always be open source, always self-hosted, always yours. No tracking, no subscriptions, no strings attached. Just a tool built by someone who loves traveling as much as you do.\n\nSpecial thanks to [jubnl](https://github.com/jubnl) — you have become an incredible collaborator. So much of what makes 3.0 great carries your fingerprints. Thank you for believing in this project when it was still rough around the edges.\n\nAnd to every single one of you who filed a bug, translated a string, shared TREK with a friend, or simply used it to plan a trip — **thank you**. You are the reason this exists.\n\nHere\'s to many more adventures together.\n\n— Maurice\n\n---\n\n[Join the community on Discord](https://discord.gg/7Q6M6jDwzf)\n\nIf TREK makes your travels better, a [small coffee](https://ko-fi.com/mauriceboe) always keeps the lights on.',
|
||||
|
||||
// System notices — onboarding
|
||||
'system_notice.welcome_v1.title': 'Welcome to TREK',
|
||||
'system_notice.welcome_v1.body': 'Your all-in-one travel planner. Build itineraries, share trips with friends, and stay organized — online or offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Plan a trip',
|
||||
'system_notice.welcome_v1.hero_alt': 'A scenic travel destination with TREK planning UI overlay',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Day-by-day itineraries for any trip',
|
||||
'system_notice.welcome_v1.highlight_share': 'Collaborate with travel partners',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Works offline on mobile',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Previous notice',
|
||||
'system_notice.pager.next': 'Next notice',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Go to notice {n}',
|
||||
'system_notice.pager.position': 'Notice {current} of {total}',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transports',
|
||||
'transport.addManual': 'Manual Transport',
|
||||
}
|
||||
|
||||
export default en
|
||||
|
||||
@@ -4,11 +4,15 @@ const es: Record<string, string> = {
|
||||
'common.showMore': 'Ver más',
|
||||
'common.showLess': 'Ver menos',
|
||||
'common.cancel': 'Cancelar',
|
||||
'common.clear': 'Borrar',
|
||||
'common.delete': 'Eliminar',
|
||||
'common.edit': 'Editar',
|
||||
'common.add': 'Añadir',
|
||||
'common.loading': 'Cargando...',
|
||||
'common.import': 'Importar',
|
||||
'common.select': 'Seleccionar',
|
||||
'common.selectAll': 'Seleccionar todo',
|
||||
'common.deselectAll': 'Deseleccionar todo',
|
||||
'common.error': 'Error',
|
||||
'common.unknownError': 'Error desconocido',
|
||||
'common.tooManyAttempts': 'Demasiados intentos. Inténtelo de nuevo más tarde.',
|
||||
@@ -308,6 +312,16 @@ const es: Record<string, string> = {
|
||||
'settings.about.featureRequest': 'Solicitar función',
|
||||
'settings.about.featureRequestHint': 'Sugiere una nueva función',
|
||||
'settings.about.wikiHint': 'Documentación y guías',
|
||||
'settings.about.supporters.badge': 'Patrocinadores Mensuales',
|
||||
'settings.about.supporters.title': 'Compañía de viaje para TREK',
|
||||
'settings.about.supporters.subtitle': 'Mientras planeas tu próxima ruta, estas personas ayudan a planear el futuro de TREK. Su aporte mensual va directo al desarrollo y a las horas reales invertidas — para que TREK siga siendo Open Source.',
|
||||
'settings.about.supporters.since': 'patrocinador desde {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Sé el primero',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK es un planificador de viajes autoalojado que te ayuda a organizar tus viajes desde la primera idea hasta el último recuerdo. Planificación diaria, presupuesto, listas de equipaje, fotos y mucho más — todo en un solo lugar, en tu propio servidor.',
|
||||
'settings.about.madeWith': 'Hecho con',
|
||||
'settings.about.madeBy': 'por Maurice y una creciente comunidad de código abierto.',
|
||||
@@ -540,9 +554,29 @@ const es: Record<string, string> = {
|
||||
'admin.fileTypesFormat': 'Extensiones separadas por comas (p. ej. jpg,png,pdf,doc). Usa * para permitir todos los tipos.',
|
||||
'admin.fileTypesSaved': 'Ajustes de tipos de archivo guardados',
|
||||
|
||||
'admin.placesPhotos.title': 'Fotos de Lugares',
|
||||
'admin.placesPhotos.subtitle': 'Obtiene fotos de la Google Places API. Desactiva para ahorrar cuota de API. Las fotos de Wikimedia no se ven afectadas.',
|
||||
'admin.placesAutocomplete.title': 'Autocompletado de Lugares',
|
||||
'admin.placesAutocomplete.subtitle': 'Usa la Google Places API para sugerencias de búsqueda. Desactiva para ahorrar cuota de API.',
|
||||
'admin.placesDetails.title': 'Detalles del Lugar',
|
||||
'admin.placesDetails.subtitle': 'Obtiene información detallada del lugar (horarios, valoración, web) de la Google Places API. Desactiva para ahorrar cuota de API.',
|
||||
'admin.bagTracking.title': 'Seguimiento de equipaje',
|
||||
'admin.bagTracking.subtitle': 'Activar peso y asignación de equipaje para artículos de la lista',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Mensajería en tiempo real para la colaboración',
|
||||
'admin.collab.notes.title': 'Notas',
|
||||
'admin.collab.notes.subtitle': 'Notas y documentos compartidos',
|
||||
'admin.collab.polls.title': 'Encuestas',
|
||||
'admin.collab.polls.subtitle': 'Encuestas y votaciones grupales',
|
||||
'admin.collab.whatsnext.title': 'Qué sigue',
|
||||
'admin.collab.whatsnext.subtitle': 'Sugerencias de actividades y próximos pasos',
|
||||
'admin.tabs.config': 'Personalización',
|
||||
'admin.tabs.defaults': 'Valores predeterminados',
|
||||
'admin.defaultSettings.title': 'Configuración predeterminada de usuarios',
|
||||
'admin.defaultSettings.description': 'Establece valores predeterminados para toda la instancia. Los usuarios que no hayan cambiado una opción verán estos valores. Sus propios cambios siempre tienen prioridad.',
|
||||
'admin.defaultSettings.saved': 'Predeterminado guardado',
|
||||
'admin.defaultSettings.reset': 'Restaurar al valor predeterminado integrado',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'restaurar',
|
||||
'admin.tabs.templates': 'Plantillas de equipaje',
|
||||
'admin.packingTemplates.title': 'Plantillas de equipaje',
|
||||
'admin.packingTemplates.subtitle': 'Crear listas de equipaje reutilizables para tus viajes',
|
||||
@@ -809,6 +843,7 @@ const es: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.transports': 'Transportes',
|
||||
'trip.tabs.reservations': 'Reservas',
|
||||
'trip.tabs.reservationsShort': 'Reservas',
|
||||
'trip.tabs.packing': 'Lista de equipaje',
|
||||
@@ -831,6 +866,8 @@ const es: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': 'Reserva añadida',
|
||||
'trip.toast.deleted': 'Eliminado',
|
||||
'trip.confirm.deletePlace': '¿Seguro que quieres eliminar este lugar?',
|
||||
'trip.confirm.deletePlaces': '¿Eliminar {count} lugares?',
|
||||
'trip.toast.placesDeleted': '{count} lugares eliminados',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'No hay lugares planificados para este día',
|
||||
@@ -875,6 +912,17 @@ const es: Record<string, string> = {
|
||||
'places.importFileError': 'Importación fallida',
|
||||
'places.importAllSkipped': 'Todos los lugares ya estaban en el viaje.',
|
||||
'places.gpxImported': '{count} lugares importados desde GPX',
|
||||
'places.gpxImportTypes': '¿Qué deseas importar?',
|
||||
'places.gpxImportWaypoints': 'Puntos de ruta',
|
||||
'places.gpxImportRoutes': 'Rutas',
|
||||
'places.gpxImportTracks': 'Tracks (con geometría de ruta)',
|
||||
'places.gpxImportNoneSelected': 'Selecciona al menos un tipo para importar.',
|
||||
'places.kmlImportTypes': '¿Qué deseas importar?',
|
||||
'places.kmlImportPoints': 'Puntos (Placemarks)',
|
||||
'places.kmlImportPaths': 'Rutas (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Selecciona al menos un tipo.',
|
||||
'places.selectionCount': '{count} seleccionado(s)',
|
||||
'places.deleteSelected': 'Eliminar selección',
|
||||
'places.kmlKmzImported': '{count} lugares importados desde KMZ/KML',
|
||||
'places.urlResolved': 'Lugar importado desde URL',
|
||||
'places.importList': 'Importar lista',
|
||||
@@ -891,6 +939,7 @@ const es: Record<string, string> = {
|
||||
'places.assignToDay': '¿A qué día añadirlo?',
|
||||
'places.all': 'Todo',
|
||||
'places.unplanned': 'Sin planificar',
|
||||
'places.filterTracks': 'Rutas',
|
||||
'places.search': 'Buscar lugares...',
|
||||
'places.allCategories': 'Todas las categorías',
|
||||
'places.categoriesSelected': 'categorías',
|
||||
@@ -975,7 +1024,7 @@ const es: Record<string, string> = {
|
||||
'reservations.type.hotel': 'Alojamiento',
|
||||
'reservations.type.restaurant': 'Restaurante',
|
||||
'reservations.type.train': 'Tren',
|
||||
'reservations.type.car': 'Coche de alquiler',
|
||||
'reservations.type.car': 'Coche',
|
||||
'reservations.type.cruise': 'Crucero',
|
||||
'reservations.type.event': 'Evento',
|
||||
'reservations.type.tour': 'Excursión',
|
||||
@@ -1036,6 +1085,7 @@ const es: Record<string, string> = {
|
||||
'reservations.span.end': 'Fin',
|
||||
'reservations.span.ongoing': 'En curso',
|
||||
'reservations.validation.endBeforeStart': 'La fecha/hora de fin debe ser posterior a la de inicio',
|
||||
'reservations.addBooking': 'Añadir reserva',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Presupuesto',
|
||||
@@ -1439,6 +1489,7 @@ const es: Record<string, string> = {
|
||||
'day.noPlacesForHotel': 'Añade primero lugares al viaje',
|
||||
'day.allDays': 'Todos',
|
||||
'day.checkIn': 'Registro de entrada',
|
||||
'day.checkInUntil': 'Hasta',
|
||||
'day.checkOut': 'Registro de salida',
|
||||
'day.confirmation': 'Confirmación',
|
||||
'day.editAccommodation': 'Editar alojamiento',
|
||||
@@ -1465,6 +1516,7 @@ const es: Record<string, string> = {
|
||||
'memories.providerPassword': 'Contraseña',
|
||||
'memories.providerOTP': 'Código MFA (si está habilitado)',
|
||||
'memories.skipSSLVerification': 'Omitir verificación del certificado SSL',
|
||||
'memories.immichAutoUpload': 'Duplicar las fotos del journey en Immich al subirlas',
|
||||
'memories.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Probar conexión',
|
||||
'memories.testFirst': 'Probar conexión primero',
|
||||
@@ -1602,10 +1654,20 @@ const es: Record<string, string> = {
|
||||
'reservations.meta.flightNumber': 'N° de vuelo',
|
||||
'reservations.meta.from': 'Desde',
|
||||
'reservations.meta.to': 'Hasta',
|
||||
'reservations.needsReview': 'Revisar',
|
||||
'reservations.needsReviewHint': 'No se pudo identificar el aeropuerto automáticamente — por favor confirma la ubicación.',
|
||||
'reservations.searchLocation': 'Buscar estación, puerto, dirección...',
|
||||
'airport.searchPlaceholder': 'Código o ciudad del aeropuerto (ej. FRA)',
|
||||
'map.connections': 'Conexiones',
|
||||
'map.showConnections': 'Mostrar rutas de reservas',
|
||||
'map.hideConnections': 'Ocultar rutas de reservas',
|
||||
'settings.bookingLabels': 'Etiquetas de rutas de reservas',
|
||||
'settings.bookingLabelsHint': 'Muestra nombres de estaciones / aeropuertos en el mapa. Desactivado, solo se muestra el icono.',
|
||||
'reservations.meta.trainNumber': 'N° de tren',
|
||||
'reservations.meta.platform': 'Andén',
|
||||
'reservations.meta.seat': 'Asiento',
|
||||
'reservations.meta.checkIn': 'Registro de entrada',
|
||||
'reservations.meta.checkInUntil': 'Check-in hasta',
|
||||
'reservations.meta.checkOut': 'Registro de salida',
|
||||
'reservations.meta.linkAccommodation': 'Alojamiento',
|
||||
'reservations.meta.pickAccommodation': 'Vincular con alojamiento',
|
||||
@@ -1682,6 +1744,7 @@ const es: Record<string, string> = {
|
||||
'undo.reorder': 'Lugares reordenados',
|
||||
'undo.optimize': 'Ruta optimizada',
|
||||
'undo.deletePlace': 'Lugar eliminado',
|
||||
'undo.deletePlaces': 'Lugares eliminados',
|
||||
'undo.moveDay': 'Lugar movido a otro día',
|
||||
'undo.lock': 'Bloqueo de lugar activado/desactivado',
|
||||
'undo.importGpx': 'Importación GPX',
|
||||
@@ -1741,7 +1804,11 @@ const es: Record<string, string> = {
|
||||
'todo.unassigned': 'Sin asignar',
|
||||
'todo.noCategory': 'Sin categoría',
|
||||
'todo.hasDescription': 'Con descripción',
|
||||
'todo.addItem': 'Añadir nueva tarea...',
|
||||
'todo.addItem': 'Nueva tarea',
|
||||
'todo.sidebar.sortBy': 'Ordenar por',
|
||||
'todo.priority': 'Prioridad',
|
||||
'todo.newCategoryLabel': 'nueva',
|
||||
'budget.categoriesLabel': 'categorías',
|
||||
'todo.newCategory': 'Nombre de la categoría',
|
||||
'todo.addCategory': 'Añadir categoría',
|
||||
'todo.newItem': 'Nueva tarea',
|
||||
@@ -1783,7 +1850,6 @@ const es: Record<string, string> = {
|
||||
'settings.ntfyUrl.test': 'Probar',
|
||||
'settings.ntfyUrl.testSuccess': 'Notificación de prueba de Ntfy enviada correctamente',
|
||||
'settings.ntfyUrl.testFailed': 'Error en la notificación de prueba de Ntfy',
|
||||
'settings.ntfyUrl.clearToken': 'Borrar',
|
||||
'settings.ntfyUrl.tokenCleared': 'Token de acceso eliminado',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
@@ -1800,22 +1866,29 @@ const es: Record<string, string> = {
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Error al enviar el webhook de prueba',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'El webhook de admin se activa automáticamente si hay una URL configurada',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': 'Permite a los usuarios configurar sus propios temas ntfy para notificaciones push. Establece el servidor predeterminado a continuación para rellenar automáticamente los ajustes del usuario.',
|
||||
'admin.notifications.testNtfy': 'Enviar Ntfy de prueba',
|
||||
'admin.notifications.testNtfySuccess': 'Ntfy de prueba enviado correctamente',
|
||||
'admin.notifications.testNtfyFailed': 'Error al enviar el Ntfy de prueba',
|
||||
'admin.notifications.adminNtfyPanel.title': 'Ntfy de admin',
|
||||
'admin.notifications.adminNtfyPanel.hint': 'Este tema Ntfy se usa exclusivamente para notificaciones de admin (ej. alertas de versión). Es independiente de los temas por usuario y siempre se activa cuando está configurado.',
|
||||
'admin.notifications.adminNtfyPanel.serverLabel': 'URL del servidor Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.serverHint': 'También se usa como servidor predeterminado para las notificaciones ntfy de los usuarios. Déjalo en blanco para usar ntfy.sh. Los usuarios pueden cambiarlo en sus propios ajustes.',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': 'Tema de admin',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token de acceso (opcional)',
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': 'Token de acceso de admin eliminado',
|
||||
'admin.notifications.adminNtfyPanel.saved': 'Configuración de Ntfy de admin guardada',
|
||||
'admin.notifications.adminNtfyPanel.test': 'Enviar Ntfy de prueba',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess': 'Ntfy de prueba enviado correctamente',
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Error al enviar el Ntfy de prueba',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'El Ntfy de admin siempre se activa cuando hay un tema configurado',
|
||||
'admin.notifications.adminNotificationsHint': 'Configura qué canales entregan notificaciones de admin (ej. alertas de versión). El webhook se activa automáticamente si hay una URL de webhook de admin configurada.',
|
||||
'admin.notifications.tripReminders.title': 'Recordatorios de viaje',
|
||||
'admin.notifications.tripReminders.hint': 'Envía una notificación de recordatorio antes de que comience un viaje (requiere días de recordatorio configurados en el viaje).',
|
||||
'admin.notifications.tripReminders.enabled': 'Recordatorios de viaje activados',
|
||||
'admin.notifications.tripReminders.disabled': 'Recordatorios de viaje desactivados',
|
||||
'admin.tabs.notifications': 'Notificaciones',
|
||||
'notifications.versionAvailable.title': 'Actualización disponible',
|
||||
'notifications.versionAvailable.text': 'TREK {version} ya está disponible.',
|
||||
@@ -1860,6 +1933,8 @@ const es: Record<string, string> = {
|
||||
'common.justNow': 'justo ahora',
|
||||
'common.hoursAgo': 'hace {count}h',
|
||||
'common.daysAgo': 'hace {count}d',
|
||||
'journey.search.placeholder': 'Buscar viajes…',
|
||||
'journey.search.noResults': 'Ningún viaje coincide con "{query}"',
|
||||
'journey.title': 'Travesía',
|
||||
'journey.subtitle': 'Registra tus viajes en tiempo real',
|
||||
'journey.new': 'Nueva travesía',
|
||||
@@ -1881,6 +1956,7 @@ const es: Record<string, string> = {
|
||||
'journey.status.active': 'Activa',
|
||||
'journey.status.completed': 'Completada',
|
||||
'journey.status.upcoming': 'Próxima',
|
||||
'journey.status.archived': 'Archivado',
|
||||
'journey.checkin.add': 'Registrar ubicación',
|
||||
'journey.checkin.namePlaceholder': 'Nombre del lugar',
|
||||
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
|
||||
@@ -1957,6 +2033,7 @@ const es: Record<string, string> = {
|
||||
'journey.verdict.couldBeBetter': 'Podría mejorar',
|
||||
'journey.synced.places': 'lugares',
|
||||
'journey.synced.synced': 'sincronizado',
|
||||
'journey.editor.discardChangesConfirm': 'Tienes cambios sin guardar. ¿Descartarlos?',
|
||||
'journey.editor.uploadPhotos': 'Subir fotos',
|
||||
'journey.editor.uploading': 'Subiendo...',
|
||||
'journey.editor.fromGallery': 'Desde galería',
|
||||
@@ -2034,6 +2111,11 @@ const es: Record<string, string> = {
|
||||
'journey.settings.name': 'Nombre',
|
||||
'journey.settings.subtitle': 'Subtítulo',
|
||||
'journey.settings.subtitlePlaceholder': 'p. ej. Tailandia, Vietnam y Camboya',
|
||||
'journey.settings.endJourney': 'Archivar viaje',
|
||||
'journey.settings.reopenJourney': 'Restaurar viaje',
|
||||
'journey.settings.archived': 'Viaje archivado',
|
||||
'journey.settings.reopened': 'Viaje reabierto',
|
||||
'journey.settings.endDescription': 'Oculta la insignia En Vivo. Puedes reabrirlo en cualquier momento.',
|
||||
'journey.settings.delete': 'Eliminar',
|
||||
'journey.settings.deleteJourney': 'Eliminar travesía',
|
||||
'journey.settings.deleteMessage': '¿Eliminar "{title}"? Todas las entradas y fotos se perderán.',
|
||||
@@ -2169,6 +2251,55 @@ const es: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': 'Buscar lugares, resolver URLs de mapa y geocodificar coordenadas',
|
||||
'oauth.scope.weather:read.label': 'Previsiones meteorológicas',
|
||||
'oauth.scope.weather:read.description': 'Obtener previsiones meteorológicas para lugares y fechas del viaje',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Bienvenido a TREK',
|
||||
'system_notice.welcome_v1.body': 'Tu planificador de viajes todo en uno. Crea itinerarios, comparte viajes con amigos y mantente organizado, online o sin conexión.',
|
||||
'system_notice.welcome_v1.cta_label': 'Planificar un viaje',
|
||||
'system_notice.welcome_v1.hero_alt': 'Destino de viaje pintoresco con la interfaz de TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Itinerarios día a día para cualquier viaje',
|
||||
'system_notice.welcome_v1.highlight_share': 'Colabora con tus compañeros de viaje',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Funciona sin conexión en móvil',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Aviso anterior',
|
||||
'system_notice.pager.next': 'Siguiente aviso',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Ir al aviso {n}',
|
||||
'system_notice.pager.position': 'Aviso {current} de {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Las fotos se han movido en 3.0',
|
||||
'system_notice.v3_photos.body': '**Fotos** en el Planificador de Viajes han sido eliminadas. Tus fotos están a salvo — TREK nunca modificó tu biblioteca de Immich o Synology.\n\nLas fotos ahora viven en el addon **Journey**. Journey es opcional — si aún no está disponible, pide a tu admin que lo active en Admin → Complementos.',
|
||||
'system_notice.v3_journey.title': 'Conoce Journey — diario de viaje',
|
||||
'system_notice.v3_journey.body': 'Documenta tus viajes como historias enriquecidas con cronologías, galerías de fotos y mapas interactivos.',
|
||||
'system_notice.v3_journey.cta_label': 'Abrir Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Cronología y galería por día',
|
||||
'system_notice.v3_journey.highlight_photos': 'Importar desde Immich o Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Compartir públicamente — sin inicio de sesión',
|
||||
'system_notice.v3_journey.highlight_export': 'Exportar como libro de fotos PDF',
|
||||
'system_notice.v3_features.title': 'Más novedades en 3.0',
|
||||
'system_notice.v3_features.body': 'Otras cosas que vale la pena conocer de esta versión.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Rediseño del panel mobile-first',
|
||||
'system_notice.v3_features.highlight_offline': 'Modo sin conexión completo como PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autocompletado de lugares en tiempo real',
|
||||
'system_notice.v3_features.highlight_import': 'Importar lugares desde archivos KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: actualización OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'La integración MCP ha sido completamente renovada. OAuth 2.1 es ahora el método de autenticación recomendado. Los tokens estáticos (trek_…) están obsoletos y se eliminarán en una versión futura.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recomendado (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 ámbitos de permisos granulares',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Tokens estáticos trek_ obsoletos',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Herramientas y prompts ampliados',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Una nota personal de mi parte',
|
||||
'system_notice.v3_thankyou.body': 'Antes de seguir — quiero tomarme un momento.\n\nTREK empezó como un proyecto personal que construí para mis propios viajes. Nunca imaginé que crecería hasta convertirse en algo en lo que 4.000 de vosotros confían para planificar sus aventuras. Cada estrella, cada issue, cada solicitud de funcionalidad — los leo todos, y son lo que me mantiene en pie durante las noches largas entre un trabajo a jornada completa y la universidad.\n\nQuiero que sepáis: TREK siempre será open source, siempre self-hosted, siempre vuestro. Sin rastreo, sin suscripciones, sin letra pequeña. Solo una herramienta hecha por alguien que ama viajar tanto como vosotros.\n\nUn agradecimiento especial a [jubnl](https://github.com/jubnl) — te has convertido en un colaborador increíble. Mucho de lo que hace grande la versión 3.0 lleva tu huella. Gracias por creer en este proyecto cuando todavía era un borrador.\n\nY a cada uno de vosotros que reportó un bug, tradujo un texto, compartió TREK con un amigo o simplemente lo usó para planificar un viaje — **gracias**. Vosotros sois la razón de que esto exista.\n\nPor muchas más aventuras juntos.\n\n— Maurice\n\n---\n\n[Únete a la comunidad en Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK mejora tus viajes, un [pequeño café](https://ko-fi.com/mauriceboe) siempre mantiene las luces encendidas.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transportes',
|
||||
'transport.addManual': 'Transporte manual',
|
||||
}
|
||||
|
||||
export default es
|
||||
|
||||
@@ -4,11 +4,15 @@ const fr: Record<string, string> = {
|
||||
'common.showMore': 'Voir plus',
|
||||
'common.showLess': 'Voir moins',
|
||||
'common.cancel': 'Annuler',
|
||||
'common.clear': 'Effacer',
|
||||
'common.delete': 'Supprimer',
|
||||
'common.edit': 'Modifier',
|
||||
'common.add': 'Ajouter',
|
||||
'common.loading': 'Chargement…',
|
||||
'common.import': 'Importer',
|
||||
'common.select': 'Sélectionner',
|
||||
'common.selectAll': 'Tout sélectionner',
|
||||
'common.deselectAll': 'Tout désélectionner',
|
||||
'common.error': 'Erreur',
|
||||
'common.unknownError': 'Erreur inconnue',
|
||||
'common.tooManyAttempts': 'Trop de tentatives. Veuillez réessayer plus tard.',
|
||||
@@ -307,6 +311,16 @@ const fr: Record<string, string> = {
|
||||
'settings.about.featureRequest': 'Proposer une fonctionnalité',
|
||||
'settings.about.featureRequestHint': 'Suggérez une nouvelle fonctionnalité',
|
||||
'settings.about.wikiHint': 'Documentation et guides',
|
||||
'settings.about.supporters.badge': 'Soutiens Mensuels',
|
||||
'settings.about.supporters.title': 'Compagnons de voyage pour TREK',
|
||||
'settings.about.supporters.subtitle': 'Pendant que tu planifies ton prochain itinéraire, ces personnes aident à planifier l\'avenir de TREK. Leur contribution mensuelle va directement au développement et aux heures réellement passées — pour que TREK reste Open Source.',
|
||||
'settings.about.supporters.since': 'soutien depuis {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Sois le premier',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK est un planificateur de voyage auto-hébergé qui vous aide à organiser vos voyages de la première idée au dernier souvenir. Planification journalière, budget, listes de bagages, photos et bien plus — le tout au même endroit, sur votre propre serveur.',
|
||||
'settings.about.madeWith': 'Fait avec',
|
||||
'settings.about.madeBy': 'par Maurice et une communauté open-source grandissante.',
|
||||
@@ -544,9 +558,29 @@ const fr: Record<string, string> = {
|
||||
'admin.fileTypesFormat': 'Extensions séparées par des virgules (ex. jpg,png,pdf,doc). Utilisez * pour autoriser tous les types.',
|
||||
'admin.fileTypesSaved': 'Paramètres des types de fichiers enregistrés',
|
||||
|
||||
'admin.placesPhotos.title': 'Photos de lieux',
|
||||
'admin.placesPhotos.subtitle': "Récupère les photos depuis l'API Google Places. Désactivez pour économiser le quota API. Les photos Wikimedia ne sont pas affectées.",
|
||||
'admin.placesAutocomplete.title': 'Autocomplétion des lieux',
|
||||
'admin.placesAutocomplete.subtitle': "Utilise l'API Google Places pour les suggestions de recherche. Désactivez pour économiser le quota API.",
|
||||
'admin.placesDetails.title': 'Détails du lieu',
|
||||
'admin.placesDetails.subtitle': "Récupère les informations détaillées du lieu (horaires, note, site web) depuis l'API Google Places. Désactivez pour économiser le quota API.",
|
||||
'admin.bagTracking.title': 'Suivi des bagages',
|
||||
'admin.bagTracking.subtitle': 'Activer le poids et l\'attribution de bagages pour les articles',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Messagerie en temps réel pour la collaboration',
|
||||
'admin.collab.notes.title': 'Notes',
|
||||
'admin.collab.notes.subtitle': 'Notes et documents partagés',
|
||||
'admin.collab.polls.title': 'Sondages',
|
||||
'admin.collab.polls.subtitle': 'Sondages et votes de groupe',
|
||||
'admin.collab.whatsnext.title': 'Et ensuite',
|
||||
'admin.collab.whatsnext.subtitle': "Suggestions d'activités et prochaines étapes",
|
||||
'admin.tabs.config': 'Personnalisation',
|
||||
'admin.tabs.defaults': 'Valeurs par défaut',
|
||||
'admin.defaultSettings.title': 'Paramètres utilisateur par défaut',
|
||||
'admin.defaultSettings.description': "Définissez des valeurs par défaut pour toute l'instance. Les utilisateurs n'ayant pas modifié un paramètre verront ces valeurs. Leurs propres modifications ont toujours la priorité.",
|
||||
'admin.defaultSettings.saved': 'Valeur par défaut enregistrée',
|
||||
'admin.defaultSettings.reset': 'Réinitialiser à la valeur par défaut intégrée',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'réinitialiser',
|
||||
'admin.tabs.templates': 'Modèles de bagages',
|
||||
'admin.packingTemplates.title': 'Modèles de bagages',
|
||||
'admin.packingTemplates.subtitle': 'Créer des listes de bagages réutilisables pour vos voyages',
|
||||
@@ -833,6 +867,7 @@ const fr: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.transports': 'Transports',
|
||||
'trip.tabs.reservations': 'Réservations',
|
||||
'trip.tabs.reservationsShort': 'Résa',
|
||||
'trip.tabs.packing': 'Liste de bagages',
|
||||
@@ -855,6 +890,8 @@ const fr: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': 'Réservation ajoutée',
|
||||
'trip.toast.deleted': 'Supprimé',
|
||||
'trip.confirm.deletePlace': 'Voulez-vous vraiment supprimer ce lieu ?',
|
||||
'trip.confirm.deletePlaces': 'Supprimer {count} lieux?',
|
||||
'trip.toast.placesDeleted': '{count} lieux supprimés',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Aucun lieu prévu pour ce jour',
|
||||
@@ -899,6 +936,17 @@ const fr: Record<string, string> = {
|
||||
'places.importFileError': 'Importation échouée',
|
||||
'places.importAllSkipped': 'Tous les lieux étaient déjà dans le voyage.',
|
||||
'places.gpxImported': '{count} lieux importés depuis GPX',
|
||||
'places.gpxImportTypes': 'Que voulez-vous importer?',
|
||||
'places.gpxImportWaypoints': 'Points de passage',
|
||||
'places.gpxImportRoutes': 'Itinéraires',
|
||||
'places.gpxImportTracks': 'Traces (avec géométrie)',
|
||||
'places.gpxImportNoneSelected': 'Sélectionnez au moins un type à importer.',
|
||||
'places.kmlImportTypes': 'Que souhaitez-vous importer ?',
|
||||
'places.kmlImportPoints': 'Points (Placemarks)',
|
||||
'places.kmlImportPaths': 'Chemins (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Sélectionnez au moins un type.',
|
||||
'places.selectionCount': '{count} sélectionné(s)',
|
||||
'places.deleteSelected': 'Supprimer la sélection',
|
||||
'places.kmlKmzImported': '{count} lieux importés depuis KMZ/KML',
|
||||
'places.urlResolved': 'Lieu importé depuis l\'URL',
|
||||
'places.importList': 'Import de liste',
|
||||
@@ -915,6 +963,7 @@ const fr: Record<string, string> = {
|
||||
'places.assignToDay': 'Ajouter à quel jour ?',
|
||||
'places.all': 'Tous',
|
||||
'places.unplanned': 'Non planifiés',
|
||||
'places.filterTracks': 'Traces',
|
||||
'places.search': 'Rechercher des lieux…',
|
||||
'places.allCategories': 'Toutes les catégories',
|
||||
'places.categoriesSelected': 'catégories',
|
||||
@@ -998,10 +1047,20 @@ const fr: Record<string, string> = {
|
||||
'reservations.meta.flightNumber': 'N° de vol',
|
||||
'reservations.meta.from': 'De',
|
||||
'reservations.meta.to': 'À',
|
||||
'reservations.needsReview': 'Vérifier',
|
||||
'reservations.needsReviewHint': 'L\'aéroport n\'a pas pu être identifié automatiquement — veuillez confirmer l\'emplacement.',
|
||||
'reservations.searchLocation': 'Rechercher une gare, un port, une adresse…',
|
||||
'airport.searchPlaceholder': 'Code ou ville de l\'aéroport (ex. FRA)',
|
||||
'map.connections': 'Connexions',
|
||||
'map.showConnections': 'Afficher les itinéraires',
|
||||
'map.hideConnections': 'Masquer les itinéraires',
|
||||
'settings.bookingLabels': 'Étiquettes des itinéraires',
|
||||
'settings.bookingLabelsHint': 'Affiche les noms des gares / aéroports sur la carte. Si désactivé, seule l\'icône est affichée.',
|
||||
'reservations.meta.trainNumber': 'N° de train',
|
||||
'reservations.meta.platform': 'Quai',
|
||||
'reservations.meta.seat': 'Place',
|
||||
'reservations.meta.checkIn': 'Arrivée',
|
||||
'reservations.meta.checkInUntil': "Check-in jusqu'à",
|
||||
'reservations.meta.checkOut': 'Départ',
|
||||
'reservations.meta.linkAccommodation': 'Hébergement',
|
||||
'reservations.meta.pickAccommodation': 'Lier à un hébergement',
|
||||
@@ -1015,7 +1074,7 @@ const fr: Record<string, string> = {
|
||||
'reservations.type.hotel': 'Hébergement',
|
||||
'reservations.type.restaurant': 'Restaurant',
|
||||
'reservations.type.train': 'Train',
|
||||
'reservations.type.car': 'Voiture de location',
|
||||
'reservations.type.car': 'Voiture',
|
||||
'reservations.type.cruise': 'Croisière',
|
||||
'reservations.type.event': 'Événement',
|
||||
'reservations.type.tour': 'Visite',
|
||||
@@ -1076,6 +1135,7 @@ const fr: Record<string, string> = {
|
||||
'reservations.span.end': 'Fin',
|
||||
'reservations.span.ongoing': 'En cours',
|
||||
'reservations.validation.endBeforeStart': 'La date/heure de fin doit être postérieure à la date/heure de début',
|
||||
'reservations.addBooking': 'Ajouter une réservation',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
@@ -1486,6 +1546,7 @@ const fr: Record<string, string> = {
|
||||
'day.noPlacesForHotel': 'Ajoutez d\'abord des lieux à votre voyage',
|
||||
'day.allDays': 'Tous',
|
||||
'day.checkIn': 'Arrivée',
|
||||
'day.checkInUntil': "Jusqu'à",
|
||||
'day.checkOut': 'Départ',
|
||||
'day.confirmation': 'Confirmation',
|
||||
'day.editAccommodation': 'Modifier l\'hébergement',
|
||||
@@ -1512,6 +1573,7 @@ const fr: Record<string, string> = {
|
||||
'memories.providerPassword': 'Mot de passe',
|
||||
'memories.providerOTP': 'Code MFA (si activé)',
|
||||
'memories.skipSSLVerification': 'Ignorer la vérification du certificat SSL',
|
||||
'memories.immichAutoUpload': 'Répliquer les photos du journey vers Immich au téléversement',
|
||||
'memories.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Tester la connexion',
|
||||
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
|
||||
@@ -1676,6 +1738,7 @@ const fr: Record<string, string> = {
|
||||
'undo.reorder': 'Lieux réorganisés',
|
||||
'undo.optimize': 'Itinéraire optimisé',
|
||||
'undo.deletePlace': 'Lieu supprimé',
|
||||
'undo.deletePlaces': 'Lieux supprimés',
|
||||
'undo.moveDay': 'Lieu déplacé vers un autre jour',
|
||||
'undo.lock': 'Verrouillage du lieu modifié',
|
||||
'undo.importGpx': 'Import GPX',
|
||||
@@ -1735,7 +1798,11 @@ const fr: Record<string, string> = {
|
||||
'todo.unassigned': 'Non assigné',
|
||||
'todo.noCategory': 'Aucune catégorie',
|
||||
'todo.hasDescription': 'Avec description',
|
||||
'todo.addItem': 'Ajouter une tâche...',
|
||||
'todo.addItem': 'Nouvelle tâche',
|
||||
'todo.sidebar.sortBy': 'Trier par',
|
||||
'todo.priority': 'Priorité',
|
||||
'todo.newCategoryLabel': 'nouvelle',
|
||||
'budget.categoriesLabel': 'catégories',
|
||||
'todo.newCategory': 'Nom de la catégorie',
|
||||
'todo.addCategory': 'Ajouter une catégorie',
|
||||
'todo.newItem': 'Nouvelle tâche',
|
||||
@@ -1777,7 +1844,6 @@ const fr: Record<string, string> = {
|
||||
'settings.ntfyUrl.test': 'Tester',
|
||||
'settings.ntfyUrl.testSuccess': 'Notification de test Ntfy envoyée avec succès',
|
||||
'settings.ntfyUrl.testFailed': 'Échec de la notification de test Ntfy',
|
||||
'settings.ntfyUrl.clearToken': 'Effacer',
|
||||
'settings.ntfyUrl.tokenCleared': "Jeton d'accès effacé",
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
@@ -1794,22 +1860,29 @@ const fr: Record<string, string> = {
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Échec du webhook de test',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Le webhook admin s\'active automatiquement si une URL est configurée',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': 'Permet aux utilisateurs de configurer leurs propres sujets ntfy pour les notifications push. Définissez le serveur par défaut ci-dessous pour pré-remplir les paramètres utilisateur.',
|
||||
'admin.notifications.testNtfy': 'Envoyer un Ntfy de test',
|
||||
'admin.notifications.testNtfySuccess': 'Ntfy de test envoyé avec succès',
|
||||
'admin.notifications.testNtfyFailed': 'Échec de l\'envoi du Ntfy de test',
|
||||
'admin.notifications.adminNtfyPanel.title': 'Ntfy admin',
|
||||
'admin.notifications.adminNtfyPanel.hint': 'Ce sujet Ntfy est utilisé exclusivement pour les notifications admin (ex. alertes de version). Il est séparé des sujets par utilisateur et s\'active toujours lorsqu\'il est configuré.',
|
||||
'admin.notifications.adminNtfyPanel.serverLabel': 'URL du serveur Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.serverHint': 'Utilisé également comme serveur par défaut pour les notifications ntfy des utilisateurs. Laisser vide pour utiliser ntfy.sh. Les utilisateurs peuvent le modifier dans leurs propres paramètres.',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': 'Sujet admin',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': "Jeton d'accès (optionnel)",
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': "Jeton d'accès admin effacé",
|
||||
'admin.notifications.adminNtfyPanel.saved': 'Paramètres Ntfy admin enregistrés',
|
||||
'admin.notifications.adminNtfyPanel.test': 'Envoyer un Ntfy de test',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess': 'Ntfy de test envoyé avec succès',
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Échec de l\'envoi du Ntfy de test',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Le Ntfy admin s\'active toujours lorsqu\'un sujet est configuré',
|
||||
'admin.notifications.adminNotificationsHint': 'Configurez quels canaux envoient les notifications admin (ex. alertes de version). Le webhook s\'active automatiquement si une URL webhook admin est définie.',
|
||||
'admin.notifications.tripReminders.title': 'Rappels de voyage',
|
||||
'admin.notifications.tripReminders.hint': 'Envoie une notification de rappel avant le début d\'un voyage (nécessite des jours de rappel définis sur le voyage).',
|
||||
'admin.notifications.tripReminders.enabled': 'Rappels de voyage activés',
|
||||
'admin.notifications.tripReminders.disabled': 'Rappels de voyage désactivés',
|
||||
'admin.tabs.notifications': 'Notifications',
|
||||
'notifications.versionAvailable.title': 'Mise à jour disponible',
|
||||
'notifications.versionAvailable.text': 'TREK {version} est maintenant disponible.',
|
||||
@@ -1854,6 +1927,8 @@ const fr: Record<string, string> = {
|
||||
'common.justNow': 'à l\'instant',
|
||||
'common.hoursAgo': 'il y a {count}h',
|
||||
'common.daysAgo': 'il y a {count}j',
|
||||
'journey.search.placeholder': 'Rechercher des journaux…',
|
||||
'journey.search.noResults': 'Aucun journal ne correspond à « {query} »',
|
||||
'journey.title': 'Journal de voyage',
|
||||
'journey.subtitle': 'Suivez vos voyages en temps réel',
|
||||
'journey.new': 'Nouveau journal',
|
||||
@@ -1875,6 +1950,7 @@ const fr: Record<string, string> = {
|
||||
'journey.status.active': 'Actif',
|
||||
'journey.status.completed': 'Terminé',
|
||||
'journey.status.upcoming': 'À venir',
|
||||
'journey.status.archived': 'Archivé',
|
||||
'journey.checkin.add': 'Check-in',
|
||||
'journey.checkin.namePlaceholder': 'Nom du lieu',
|
||||
'journey.checkin.notesPlaceholder': 'Notes (facultatif)',
|
||||
@@ -1951,6 +2027,7 @@ const fr: Record<string, string> = {
|
||||
'journey.verdict.couldBeBetter': 'Pourrait être mieux',
|
||||
'journey.synced.places': 'lieux',
|
||||
'journey.synced.synced': 'synchronisé',
|
||||
'journey.editor.discardChangesConfirm': 'Vous avez des modifications non enregistrées. Les ignorer ?',
|
||||
'journey.editor.uploadPhotos': 'Téléverser des photos',
|
||||
'journey.editor.uploading': 'Envoi...',
|
||||
'journey.editor.fromGallery': 'Depuis la galerie',
|
||||
@@ -2028,6 +2105,11 @@ const fr: Record<string, string> = {
|
||||
'journey.settings.name': 'Nom',
|
||||
'journey.settings.subtitle': 'Sous-titre',
|
||||
'journey.settings.subtitlePlaceholder': 'ex. Thaïlande, Vietnam et Cambodge',
|
||||
'journey.settings.endJourney': 'Archiver le journal',
|
||||
'journey.settings.reopenJourney': 'Restaurer le journal',
|
||||
'journey.settings.archived': 'Journal archivé',
|
||||
'journey.settings.reopened': 'Journal rouvert',
|
||||
'journey.settings.endDescription': 'Masque l\'indicateur En direct. Vous pouvez rouvrir à tout moment.',
|
||||
'journey.settings.delete': 'Supprimer',
|
||||
'journey.settings.deleteJourney': 'Supprimer le journal',
|
||||
'journey.settings.deleteMessage': 'Supprimer « {title} » ? Toutes les entrées et photos seront perdues.',
|
||||
@@ -2163,6 +2245,55 @@ const fr: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': 'Chercher des lieux, résoudre des URL cartographiques et géocoder des coordonnées',
|
||||
'oauth.scope.weather:read.label': 'Prévisions météo',
|
||||
'oauth.scope.weather:read.description': 'Obtenir les prévisions météo pour les lieux et dates de voyage',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Bienvenue sur TREK',
|
||||
'system_notice.welcome_v1.body': 'Votre planificateur de voyage tout-en-un. Créez des itinéraires, partagez vos voyages et restez organisé — en ligne ou hors ligne.',
|
||||
'system_notice.welcome_v1.cta_label': 'Planifier un voyage',
|
||||
'system_notice.welcome_v1.hero_alt': 'Destination de voyage pittoresque avec l\'interface TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Itinéraires jour par jour',
|
||||
'system_notice.welcome_v1.highlight_share': 'Collaborez avec vos partenaires',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Fonctionne hors ligne sur mobile',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Avis précédent',
|
||||
'system_notice.pager.next': 'Avis suivant',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': "Aller à l'avis {n}",
|
||||
'system_notice.pager.position': 'Avis {current} sur {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Les photos ont bougé dans 3.0',
|
||||
'system_notice.v3_photos.body': "**Photos** dans le planificateur ont été supprimées. Tes photos sont en sécurité — TREK n'a jamais modifié ta bibliothèque Immich ou Synology.\n\nLes photos vivent désormais dans l'addon **Journey**. Journey est optionnel — s'il n'est pas encore disponible, demande à ton admin de l'activer dans Admin → Modules.",
|
||||
'system_notice.v3_journey.title': 'Découvrez Journey — journal de voyage',
|
||||
'system_notice.v3_journey.body': 'Documente tes voyages sous forme de récits enrichis avec chronologies, galeries photos et cartes interactives.',
|
||||
'system_notice.v3_journey.cta_label': 'Ouvrir Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Chronologie et galerie par jour',
|
||||
'system_notice.v3_journey.highlight_photos': 'Import depuis Immich ou Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Partage public — sans connexion requise',
|
||||
'system_notice.v3_journey.highlight_export': 'Export en livre photo PDF',
|
||||
'system_notice.v3_features.title': 'Plus de nouveautés en 3.0',
|
||||
'system_notice.v3_features.body': 'Quelques autres choses à savoir sur cette version.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Tableau de bord repensé mobile-first',
|
||||
'system_notice.v3_features.highlight_offline': 'Mode hors ligne complet en PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autocomplétion des lieux en temps réel',
|
||||
'system_notice.v3_features.highlight_import': 'Importer des lieux depuis KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP : mise à niveau OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': "L'intégration MCP a été entièrement repensée. OAuth 2.1 est désormais la méthode d'authentification recommandée. Les tokens statiques (trek_\u2026) sont dépréciés et seront supprimés dans une future version.",
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recommandé (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 scopes de permissions granulaires',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Tokens statiques trek_ dépréciés',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Outils et prompts étendus',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Un mot personnel de ma part',
|
||||
'system_notice.v3_thankyou.body': 'Avant de continuer — je veux prendre un instant.\n\nTREK a commencé comme un projet perso que j\'ai construit pour mes propres voyages. Je n\'aurais jamais imaginé qu\'il grandirait au point que 4 000 d\'entre vous lui fassent confiance pour planifier vos aventures. Chaque étoile, chaque issue, chaque demande de fonctionnalité — je les lis toutes, et ce sont elles qui me font tenir pendant les nuits blanches entre un travail à temps plein et l\'université.\n\nJe veux que vous sachiez : TREK sera toujours open source, toujours auto-hébergé, toujours à vous. Pas de tracking, pas d\'abonnements, pas de conditions cachées. Juste un outil construit par quelqu\'un qui aime voyager autant que vous.\n\nUn merci tout particulier à [jubnl](https://github.com/jubnl) — tu es devenu un collaborateur incroyable. Une grande partie de ce qui rend la 3.0 géniale porte ton empreinte. Merci d\'avoir cru en ce projet quand il était encore brut.\n\nEt à chacun d\'entre vous qui a signalé un bug, traduit une chaîne, partagé TREK avec un ami ou simplement l\'a utilisé pour planifier un voyage — **merci**. Vous êtes la raison pour laquelle tout ceci existe.\n\nÀ de nombreuses autres aventures ensemble.\n\n— Maurice\n\n---\n\n[Rejoins la communauté sur Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK rend tes voyages meilleurs, un [petit café](https://ko-fi.com/mauriceboe) aide toujours à garder les lumières allumées.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transports',
|
||||
'transport.addManual': 'Transport manuel',
|
||||
}
|
||||
|
||||
export default fr
|
||||
|
||||
@@ -4,11 +4,15 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'Továbbiak',
|
||||
'common.showLess': 'Kevesebb',
|
||||
'common.cancel': 'Mégse',
|
||||
'common.clear': 'Törlés',
|
||||
'common.delete': 'Törlés',
|
||||
'common.edit': 'Szerkesztés',
|
||||
'common.add': 'Hozzáadás',
|
||||
'common.loading': 'Betöltés...',
|
||||
'common.import': 'Importálás',
|
||||
'common.select': 'Kiválaszt',
|
||||
'common.selectAll': 'Mindet kiválaszt',
|
||||
'common.deselectAll': 'Összes kijelölés megszüntetése',
|
||||
'common.error': 'Hiba',
|
||||
'common.unknownError': 'Ismeretlen hiba',
|
||||
'common.tooManyAttempts': 'Túl sok próbálkozás. Kérjük, próbálja újra később.',
|
||||
@@ -262,6 +266,16 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'Funkció javaslat',
|
||||
'settings.about.featureRequestHint': 'Javasolj egy új funkciót',
|
||||
'settings.about.wikiHint': 'Dokumentáció és útmutatók',
|
||||
'settings.about.supporters.badge': 'Havi támogatók',
|
||||
'settings.about.supporters.title': 'Útitársak a TREK mellett',
|
||||
'settings.about.supporters.subtitle': 'Miközben te a következő útvonaladat tervezed, ők a TREK jövőjét tervezik velem együtt. Havi hozzájárulásuk közvetlenül fejlesztésre és valódi órákra fordítódik — hogy a TREK Open Source maradhasson.',
|
||||
'settings.about.supporters.since': 'támogató {date} óta',
|
||||
'settings.about.supporters.tierEmpty': 'Légy az első',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'A TREK egy saját szerveren üzemeltetett útitervező, amely segít az utazásaid megszervezésében az első ötlettől az utolsó emlékig. Napi tervezés, költségvetés, csomagolási listák, fotók és még sok más — minden egy helyen, a saját szervereden.',
|
||||
'settings.about.madeWith': 'Készítve',
|
||||
'settings.about.madeBy': 'Maurice és egy növekvő nyílt forráskódú közösség által.',
|
||||
@@ -545,9 +559,29 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesSaved': 'Fájltípus-beállítások mentve',
|
||||
|
||||
// Csomagolási sablonok és poggyászkövetés
|
||||
'admin.placesPhotos.title': 'Helyfotók',
|
||||
'admin.placesPhotos.subtitle': 'Fotók lekérése a Google Places API-ból. Tiltsa le az API-kvóta megtakarításához. A Wikimedia-fotók nem érintettek.',
|
||||
'admin.placesAutocomplete.title': 'Hely automatikus kiegészítése',
|
||||
'admin.placesAutocomplete.subtitle': 'A Google Places API használata keresési javaslatokhoz. Tiltsa le az API-kvóta megtakarításához.',
|
||||
'admin.placesDetails.title': 'Hely részletei',
|
||||
'admin.placesDetails.subtitle': 'Részletes helyinformációk lekérése (nyitvatartás, értékelés, weboldal) a Google Places API-ból. Tiltsa le az API-kvóta megtakarításához.',
|
||||
'admin.bagTracking.title': 'Poggyászkövetés',
|
||||
'admin.bagTracking.subtitle': 'Súly- és táskahozzárendelés engedélyezése csomagolási tételeknél',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Valós idejű üzenetküldés az együttműködéshez',
|
||||
'admin.collab.notes.title': 'Jegyzetek',
|
||||
'admin.collab.notes.subtitle': 'Megosztott jegyzetek és dokumentumok',
|
||||
'admin.collab.polls.title': 'Szavazások',
|
||||
'admin.collab.polls.subtitle': 'Csoportos szavazások',
|
||||
'admin.collab.whatsnext.title': 'Mi következik',
|
||||
'admin.collab.whatsnext.subtitle': 'Tevékenységjavaslatok és következő lépések',
|
||||
'admin.tabs.config': 'Személyre szabás',
|
||||
'admin.tabs.defaults': 'Alapértelmezett beállítások',
|
||||
'admin.defaultSettings.title': 'Alapértelmezett felhasználói beállítások',
|
||||
'admin.defaultSettings.description': 'Állítson be alapértelmezett értékeket az egész példányra. Azok a felhasználók, akik nem módosítottak egy beállítást, ezeket az értékeket fogják látni. A saját módosításaik mindig elsőbbséget élveznek.',
|
||||
'admin.defaultSettings.saved': 'Alapértelmezett mentve',
|
||||
'admin.defaultSettings.reset': 'Visszaállítás a beépített alapértelmezésre',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'visszaállítás',
|
||||
'admin.tabs.templates': 'Csomagolási sablonok',
|
||||
'admin.packingTemplates.title': 'Csomagolási sablonok',
|
||||
'admin.packingTemplates.subtitle': 'Újrafelhasználható csomagolási listák létrehozása utazásaidhoz',
|
||||
@@ -834,6 +868,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Utazástervező
|
||||
'trip.tabs.plan': 'Terv',
|
||||
'trip.tabs.transports': 'Közlekedés',
|
||||
'trip.tabs.reservations': 'Foglalások',
|
||||
'trip.tabs.reservationsShort': 'Foglalás',
|
||||
'trip.tabs.packing': 'Csomagolási lista',
|
||||
@@ -855,6 +890,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Foglalás hozzáadva',
|
||||
'trip.toast.deleted': 'Törölve',
|
||||
'trip.confirm.deletePlace': 'Biztosan törölni szeretnéd ezt a helyet?',
|
||||
'trip.confirm.deletePlaces': '{count} helyet töröl?',
|
||||
'trip.toast.placesDeleted': '{count} hely törölve',
|
||||
'trip.loadingPhotos': 'Helyek fotóinak betöltése...',
|
||||
|
||||
// Napi terv oldalsáv
|
||||
@@ -900,6 +937,17 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Importálás sikertelen',
|
||||
'places.importAllSkipped': 'Minden hely már szerepel az utazásban.',
|
||||
'places.gpxImported': '{count} hely importálva GPX-ből',
|
||||
'places.gpxImportTypes': 'Mit szeretnél importálni?',
|
||||
'places.gpxImportWaypoints': 'Útpontok',
|
||||
'places.gpxImportRoutes': 'Útvonalak',
|
||||
'places.gpxImportTracks': 'Nyomvonalak (útvonalgeometriával)',
|
||||
'places.gpxImportNoneSelected': 'Válassz legalább egy típust az importáláshoz.',
|
||||
'places.kmlImportTypes': 'Mit szeretnél importálni?',
|
||||
'places.kmlImportPoints': 'Pontok (Placemarks)',
|
||||
'places.kmlImportPaths': 'Útvonalak (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Válassz legalább egy típust.',
|
||||
'places.selectionCount': '{count} kiválasztva',
|
||||
'places.deleteSelected': 'Kijelöltek törlése',
|
||||
'places.kmlKmzImported': '{count} hely importálva KMZ/KML-ből',
|
||||
'places.urlResolved': 'Hely importálva URL-ből',
|
||||
'places.importList': 'Lista importálás',
|
||||
@@ -916,6 +964,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Melyik naphoz adod?',
|
||||
'places.all': 'Összes',
|
||||
'places.unplanned': 'Nem tervezett',
|
||||
'places.filterTracks': 'Nyomvonalak',
|
||||
'places.search': 'Helyek keresése...',
|
||||
'places.allCategories': 'Összes kategória',
|
||||
'places.categoriesSelected': 'kategória',
|
||||
@@ -1000,10 +1049,20 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.flightNumber': 'Járatszám',
|
||||
'reservations.meta.from': 'Honnan',
|
||||
'reservations.meta.to': 'Hová',
|
||||
'reservations.needsReview': 'Ellenőrzés',
|
||||
'reservations.needsReviewHint': 'A repülőteret nem sikerült automatikusan azonosítani — erősítsd meg a helyet.',
|
||||
'reservations.searchLocation': 'Állomás, kikötő, cím keresése...',
|
||||
'airport.searchPlaceholder': 'Repülőtér kódja vagy város (pl. FRA)',
|
||||
'map.connections': 'Kapcsolatok',
|
||||
'map.showConnections': 'Foglalási útvonalak megjelenítése',
|
||||
'map.hideConnections': 'Foglalási útvonalak elrejtése',
|
||||
'settings.bookingLabels': 'Útvonal-címkék a foglalásokhoz',
|
||||
'settings.bookingLabelsHint': 'Állomás- / repülőtér-nevek megjelenítése a térképen. Ha ki van kapcsolva, csak az ikon látszik.',
|
||||
'reservations.meta.trainNumber': 'Vonatszám',
|
||||
'reservations.meta.platform': 'Vágány',
|
||||
'reservations.meta.seat': 'Ülés',
|
||||
'reservations.meta.checkIn': 'Bejelentkezés',
|
||||
'reservations.meta.checkInUntil': 'Bejelentkezés eddig',
|
||||
'reservations.meta.checkOut': 'Kijelentkezés',
|
||||
'reservations.meta.linkAccommodation': 'Szállás',
|
||||
'reservations.meta.pickAccommodation': 'Szállás hozzárendelése',
|
||||
@@ -1017,7 +1076,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.type.hotel': 'Szálloda',
|
||||
'reservations.type.restaurant': 'Étterem',
|
||||
'reservations.type.train': 'Vonat',
|
||||
'reservations.type.car': 'Autóbérlés',
|
||||
'reservations.type.car': 'Autó',
|
||||
'reservations.type.cruise': 'Hajóút',
|
||||
'reservations.type.event': 'Esemény',
|
||||
'reservations.type.tour': 'Túra',
|
||||
@@ -1077,6 +1136,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Vége',
|
||||
'reservations.span.ongoing': 'Folyamatban',
|
||||
'reservations.validation.endBeforeStart': 'A befejezés dátuma/időpontja a kezdés utáni kell legyen',
|
||||
'reservations.addBooking': 'Foglalás hozzáadása',
|
||||
|
||||
// Költségvetés
|
||||
'budget.title': 'Költségvetés',
|
||||
@@ -1487,6 +1547,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'Először adj hozzá helyeket az utazásodhoz',
|
||||
'day.allDays': 'Összes',
|
||||
'day.checkIn': 'Bejelentkezés',
|
||||
'day.checkInUntil': 'Eddig',
|
||||
'day.checkOut': 'Kijelentkezés',
|
||||
'day.confirmation': 'Visszaigazolás',
|
||||
'day.editAccommodation': 'Szállás szerkesztése',
|
||||
@@ -1583,6 +1644,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'Jelszó',
|
||||
'memories.providerOTP': 'MFA kód (ha engedélyezve van)',
|
||||
'memories.skipSSLVerification': 'SSL tanúsítvány ellenőrzésének kihagyása',
|
||||
'memories.immichAutoUpload': 'Journey-fotók feltöltésekor másolat Immich-be is',
|
||||
'memories.providerUrlHintSynology': 'Adja meg a Photos alkalmazás elérési útját az URL-ben, pl. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Kapcsolat tesztelése',
|
||||
'memories.testFirst': 'Először teszteld a kapcsolatot',
|
||||
@@ -1674,6 +1736,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Helyek átrendezve',
|
||||
'undo.optimize': 'Útvonal optimalizálva',
|
||||
'undo.deletePlace': 'Hely törölve',
|
||||
'undo.deletePlaces': 'Helyek törölve',
|
||||
'undo.moveDay': 'Hely áthelyezve másik napra',
|
||||
'undo.lock': 'Hely zárolása váltva',
|
||||
'undo.importGpx': 'GPX importálás',
|
||||
@@ -1733,7 +1796,11 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'todo.unassigned': 'Nem hozzárendelt',
|
||||
'todo.noCategory': 'Nincs kategória',
|
||||
'todo.hasDescription': 'Van leírás',
|
||||
'todo.addItem': 'Új feladat hozzáadása...',
|
||||
'todo.addItem': 'Új feladat',
|
||||
'todo.sidebar.sortBy': 'Rendezés',
|
||||
'todo.priority': 'Prioritás',
|
||||
'todo.newCategoryLabel': 'új',
|
||||
'budget.categoriesLabel': 'kategóriák',
|
||||
'todo.newCategory': 'Kategória neve',
|
||||
'todo.addCategory': 'Kategória hozzáadása',
|
||||
'todo.newItem': 'Új feladat',
|
||||
@@ -1775,7 +1842,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.ntfyUrl.test': 'Teszt',
|
||||
'settings.ntfyUrl.testSuccess': 'Teszt Ntfy értesítés sikeresen elküldve',
|
||||
'settings.ntfyUrl.testFailed': 'Teszt Ntfy értesítés sikertelen',
|
||||
'settings.ntfyUrl.clearToken': 'Törlés',
|
||||
'settings.ntfyUrl.tokenCleared': 'Hozzáférési token törölve',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
@@ -1792,22 +1858,29 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Teszt webhook sikertelen',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Az admin webhook automatikusan küld, ha URL van beállítva',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': 'Lehetővé teszi a felhasználóknak, hogy saját ntfy-témáikat konfigurálják push értesítésekhez. Állítsa be az alapértelmezett szervert alább a felhasználói beállítások előre kitöltéséhez.',
|
||||
'admin.notifications.testNtfy': 'Teszt Ntfy küldése',
|
||||
'admin.notifications.testNtfySuccess': 'Teszt Ntfy sikeresen elküldve',
|
||||
'admin.notifications.testNtfyFailed': 'Teszt Ntfy sikertelen',
|
||||
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.hint': 'Ez az Ntfy téma kizárólag admin értesítésekhez használatos (pl. verziófrissítési figyelmeztetések). Független a felhasználói témáktól, és mindig küld, ha konfigurálva van.',
|
||||
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy szerver URL',
|
||||
'admin.notifications.adminNtfyPanel.serverHint': 'Alapértelmezett szerverként is szolgál a felhasználói ntfy értesítésekhez. Üresen hagyva ntfy.sh-t használ. A felhasználók felülírhatják saját beállításaikban.',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin téma',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': 'Hozzáférési token (opcionális)',
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': 'Admin hozzáférési token törölve',
|
||||
'admin.notifications.adminNtfyPanel.saved': 'Admin Ntfy beállítások mentve',
|
||||
'admin.notifications.adminNtfyPanel.test': 'Teszt Ntfy küldése',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess': 'Teszt Ntfy sikeresen elküldve',
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Teszt Ntfy sikertelen',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Az admin Ntfy mindig küld, ha egy téma konfigurálva van',
|
||||
'admin.notifications.adminNotificationsHint': 'Állítsa be, hogy mely csatornák szállítsák az admin értesítéseket (pl. verziófrissítési figyelmeztetések). A webhook automatikusan küld, ha admin webhook URL van megadva.',
|
||||
'admin.notifications.tripReminders.title': 'Utazási emlékeztetők',
|
||||
'admin.notifications.tripReminders.hint': 'Emlékeztető értesítést küld az utazás kezdete előtt (az utazásnál megadott emlékeztető napok szükségesek).',
|
||||
'admin.notifications.tripReminders.enabled': 'Utazási emlékeztetők engedélyezve',
|
||||
'admin.notifications.tripReminders.disabled': 'Utazási emlékeztetők letiltva',
|
||||
'admin.tabs.notifications': 'Értesítések',
|
||||
'notifications.versionAvailable.title': 'Elérhető frissítés',
|
||||
'notifications.versionAvailable.text': 'A TREK {version} már elérhető.',
|
||||
@@ -1855,6 +1928,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.saveRouteNotConfigured': 'A mentési útvonal nincs konfigurálva ehhez a szolgáltatóhoz',
|
||||
'memories.testRouteNotConfigured': 'A tesztútvonal nincs konfigurálva ehhez a szolgáltatóhoz',
|
||||
'memories.fillRequiredFields': 'Kérjük töltse ki az összes kötelező mezőt',
|
||||
'journey.search.placeholder': 'Utak keresése…',
|
||||
'journey.search.noResults': 'Nincs „{query}" kifejezéssel egyező út',
|
||||
'journey.title': 'Útinaplók',
|
||||
'journey.subtitle': 'Kövesse nyomon utazásait valós időben',
|
||||
'journey.new': 'Új útinapló',
|
||||
@@ -1876,6 +1951,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Aktív',
|
||||
'journey.status.completed': 'Befejezett',
|
||||
'journey.status.upcoming': 'Közelgő',
|
||||
'journey.status.archived': 'Archivált',
|
||||
'journey.checkin.add': 'Bejelentkezés',
|
||||
'journey.checkin.namePlaceholder': 'Helyszín neve',
|
||||
'journey.checkin.notesPlaceholder': 'Jegyzetek (opcionális)',
|
||||
@@ -1952,6 +2028,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.verdict.couldBeBetter': 'Lehetne jobb',
|
||||
'journey.synced.places': 'helyszín',
|
||||
'journey.synced.synced': 'szinkronizálva',
|
||||
'journey.editor.discardChangesConfirm': 'Mentetlen módosításaid vannak. Elveted?',
|
||||
'journey.editor.uploadPhotos': 'Fotók feltöltése',
|
||||
'journey.editor.uploading': 'Feltöltés...',
|
||||
'journey.editor.fromGallery': 'Galériából',
|
||||
@@ -2029,6 +2106,11 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Név',
|
||||
'journey.settings.subtitle': 'Alcím',
|
||||
'journey.settings.subtitlePlaceholder': 'pl. Thaiföld, Vietnam és Kambodzsa',
|
||||
'journey.settings.endJourney': 'Út archiválása',
|
||||
'journey.settings.reopenJourney': 'Út visszaállítása',
|
||||
'journey.settings.archived': 'Út archiválva',
|
||||
'journey.settings.reopened': 'Út újranyitva',
|
||||
'journey.settings.endDescription': 'Elrejti az Élő jelzést. Bármikor újranyitható.',
|
||||
'journey.settings.delete': 'Törlés',
|
||||
'journey.settings.deleteJourney': 'Útinapló törlése',
|
||||
'journey.settings.deleteMessage': 'Törlöd a(z) „{title}" útinaplót? Minden bejegyzés és fotó elveszik.',
|
||||
@@ -2164,6 +2246,55 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Helyek keresése, térkép URL-ek feloldása és koordináták fordított geokódolása',
|
||||
'oauth.scope.weather:read.label': 'Időjárás-előrejelzések',
|
||||
'oauth.scope.weather:read.description': 'Időjárás-előrejelzések lekérése az utazási helyszínekre és dátumokra',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Üdvözöl a TREK',
|
||||
'system_notice.welcome_v1.body': 'Az összes az egyben utazástervező. Készítsen útvonalakat, ossza meg az utakat barátaival, és maradjon szervezett — online és offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Utazás tervezése',
|
||||
'system_notice.welcome_v1.hero_alt': 'Festői úticél TREK tervező felülettel',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Napi útvonalak minden utazáshoz',
|
||||
'system_notice.welcome_v1.highlight_share': 'Együttműködés utazótársakkal',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Mobilon offline is működik',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Előző értesítés',
|
||||
'system_notice.pager.next': 'Következő értesítés',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': '{n}. értesítésre ugrás',
|
||||
'system_notice.pager.position': '{current}/{total}. értesítés',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'A fotók helye megváltozott 3.0-ban',
|
||||
'system_notice.v3_photos.body': 'Az útiterv-tervező **Fényképek** lapja eltávolításra került. Fényképeid biztonságban vannak — TREK soha nem módosította Immich vagy Synology könyvtáradat.\n\nA fényképek mostantól a **Journey** bővítményben élnek. A Journey opcionális — ha még nem elérhető, kérd meg a rendszergazdát, hogy engedélyezze Admin → Bővítmények alatt.',
|
||||
'system_notice.v3_journey.title': 'Ismerje meg a Journey-t — útinnapló',
|
||||
'system_notice.v3_journey.body': 'Dokumentáld utazazsaid gazdag történetekként idővonalakkal, fotgáriákkal és interaktív térképekkel.',
|
||||
'system_notice.v3_journey.cta_label': 'Journey megnyitása',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Napi idővonal és galéria',
|
||||
'system_notice.v3_journey.highlight_photos': 'Import Immich-ből vagy Synology-ból',
|
||||
'system_notice.v3_journey.highlight_share': 'Nyilvános megosztás — bejelentkezés nélkül',
|
||||
'system_notice.v3_journey.highlight_export': 'Exportálás PDF fotkönyvként',
|
||||
'system_notice.v3_features.title': 'További újdonságok a 3.0-ban',
|
||||
'system_notice.v3_features.body': 'Néhány további dolog, amit érdemes tudni erről a kiadásról.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Mobile-first irmütébla újratervezve',
|
||||
'system_notice.v3_features.highlight_offline': 'Teljes offline mód PWA-ként',
|
||||
'system_notice.v3_features.highlight_search': 'Valós idejű helykeresés-kiegészítés',
|
||||
'system_notice.v3_features.highlight_import': 'Helyek importálása KMZ/KML fájlokból',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1 frissítés',
|
||||
'system_notice.v3_mcp.body': 'Az MCP integráció teljesen megújult. Az OAuth 2.1 mostantól az ajánlott hitelesítési módszer. A statikus tokenek (trek_…) elavultak és egy jövőbeli kiadásban eltávolításra kerülnek.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 ajánlott (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 részletes engedélyezési hatókör',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statikus trek_ tokenek elavultak',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Bővített eszközkészlet és promptok',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Egy személyes gondolat tőlem',
|
||||
'system_notice.v3_thankyou.body': 'Mielőtt továbbmennél — szeretnék egy pillanatra megállni.\n\nA TREK egy hobbiprojektként indult, amit a saját utazásaimhoz építettem. Sosem gondoltam volna, hogy valami olyanná nő, amire 4000-en bízzátok a kalandjaitok tervezését. Minden csillagot, minden issue-t, minden funkciókérést — mindet elolvasom, és ezek tartanak életben a késő éjszakákon a teljes állás és az egyetem között.\n\nSzeretnétek, ha tudnátok: a TREK mindig nyílt forráskódú marad, mindig self-hosted, mindig a tiétek. Nincs nyomkövetés, nincs előfizetés, nincsenek rejtett feltételek. Csak egy eszköz, amit valaki épített, aki ugyanúgy szereti az utazást, mint ti.\n\nKülönleges köszönet [jubnl](https://github.com/jubnl)-nek — hihetetlen társsá váltál. A 3.0 nagyszerűségének nagy része a te kézjegyedet viseli. Köszönöm, hogy hittél ebben a projektben, amikor még nyers volt.\n\nÉs mindannyiótoknak, akik hibát jelentettetek, szöveget fordítottatok, megosztottátok a TREK-et egy baráttal, vagy egyszerűen csak egy utazást terveztetek vele — **köszönöm**. Ti vagytok az ok, amiért ez létezik.\n\nSok további közös kalandért.\n\n— Maurice\n\n---\n\n[Csatlakozz a közösséghez a Discordon](https://discord.gg/7Q6M6jDwzf)\n\nHa a TREK jobbá teszi az utazásaidat, egy [kis kávé](https://ko-fi.com/mauriceboe) mindig segít, hogy égve maradjanak a fények.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Közlekedés',
|
||||
'transport.addManual': 'Kézi közlekedés',
|
||||
}
|
||||
|
||||
export default hu
|
||||
|
||||
@@ -4,11 +4,15 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'Tampilkan lebih banyak',
|
||||
'common.showLess': 'Tampilkan lebih sedikit',
|
||||
'common.cancel': 'Batal',
|
||||
'common.clear': 'Hapus',
|
||||
'common.delete': 'Hapus',
|
||||
'common.edit': 'Sunting',
|
||||
'common.add': 'Tambah',
|
||||
'common.loading': 'Memuat...',
|
||||
'common.import': 'Impor',
|
||||
'common.select': 'Pilih',
|
||||
'common.selectAll': 'Pilih semua',
|
||||
'common.deselectAll': 'Batalkan semua pilihan',
|
||||
'common.error': 'Kesalahan',
|
||||
'common.unknownError': 'Kesalahan tidak diketahui',
|
||||
'common.tooManyAttempts': 'Terlalu banyak percobaan. Coba lagi nanti.',
|
||||
@@ -209,7 +213,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.ntfyUrl.test': 'Uji',
|
||||
'settings.ntfyUrl.testSuccess': 'Notifikasi uji Ntfy berhasil dikirim',
|
||||
'settings.ntfyUrl.testFailed': 'Notifikasi uji Ntfy gagal',
|
||||
'settings.ntfyUrl.clearToken': 'Hapus',
|
||||
'settings.ntfyUrl.tokenCleared': 'Token akses dihapus',
|
||||
'admin.notifications.title': 'Notifikasi',
|
||||
'admin.notifications.hint': 'Pilih satu saluran notifikasi. Hanya satu yang bisa aktif sekaligus.',
|
||||
@@ -232,22 +235,29 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Test webhook gagal',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook selalu berjalan jika URL dikonfigurasi',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': 'Memungkinkan pengguna mengonfigurasi topik ntfy mereka sendiri untuk notifikasi push. Tetapkan server default di bawah untuk mengisi setelan pengguna secara otomatis.',
|
||||
'admin.notifications.testNtfy': 'Kirim uji Ntfy',
|
||||
'admin.notifications.testNtfySuccess': 'Uji Ntfy berhasil dikirim',
|
||||
'admin.notifications.testNtfyFailed': 'Uji Ntfy gagal',
|
||||
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.hint': 'Topik Ntfy ini digunakan khusus untuk notifikasi admin (mis. peringatan versi). Terpisah dari topik per pengguna dan selalu berjalan jika dikonfigurasi.',
|
||||
'admin.notifications.adminNtfyPanel.serverLabel': 'URL Server Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.serverHint': 'Juga digunakan sebagai server default untuk notifikasi ntfy pengguna. Kosongkan untuk menggunakan ntfy.sh. Pengguna dapat menggantinya di pengaturan mereka sendiri.',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': 'Topik Admin',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token Akses (opsional)',
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': 'Token akses admin dihapus',
|
||||
'admin.notifications.adminNtfyPanel.saved': 'Pengaturan Ntfy admin tersimpan',
|
||||
'admin.notifications.adminNtfyPanel.test': 'Kirim uji Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess': 'Uji Ntfy berhasil dikirim',
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Uji Ntfy gagal',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy selalu berjalan jika topik dikonfigurasi',
|
||||
'admin.notifications.adminNotificationsHint': 'Atur saluran mana yang mengirimkan notifikasi khusus admin (mis. peringatan versi).',
|
||||
'admin.notifications.tripReminders.title': 'Pengingat Perjalanan',
|
||||
'admin.notifications.tripReminders.hint': 'Mengirim notifikasi pengingat sebelum perjalanan dimulai (memerlukan hari pengingat yang diatur pada perjalanan).',
|
||||
'admin.notifications.tripReminders.enabled': 'Pengingat perjalanan diaktifkan',
|
||||
'admin.notifications.tripReminders.disabled': 'Pengingat perjalanan dinonaktifkan',
|
||||
'admin.smtp.title': 'Email & Notifikasi',
|
||||
'admin.smtp.hint': 'Konfigurasi SMTP untuk pengiriman notifikasi email.',
|
||||
'admin.smtp.testButton': 'Kirim email uji',
|
||||
@@ -363,6 +373,16 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'Permintaan Fitur',
|
||||
'settings.about.featureRequestHint': 'Sarankan fitur baru',
|
||||
'settings.about.wikiHint': 'Dokumentasi & panduan',
|
||||
'settings.about.supporters.badge': 'Pendukung Bulanan',
|
||||
'settings.about.supporters.title': 'Rekan perjalanan untuk TREK',
|
||||
'settings.about.supporters.subtitle': 'Saat kamu merencanakan rute berikutnya, orang-orang ini ikut merencanakan masa depan TREK. Kontribusi bulanan mereka langsung masuk ke pengembangan dan jam kerja nyata — supaya TREK tetap Open Source.',
|
||||
'settings.about.supporters.since': 'pendukung sejak {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Jadilah yang pertama',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK adalah perencana perjalanan self-hosted yang membantu kamu mengatur perjalanan dari ide pertama hingga kenangan terakhir. Perencanaan harian, anggaran, daftar bawaan, foto dan masih banyak lagi — semua di satu tempat, di servermu sendiri.',
|
||||
'settings.about.madeWith': 'Dibuat dengan',
|
||||
'settings.about.madeBy': 'oleh Maurice dan komunitas open-source yang terus berkembang.',
|
||||
@@ -603,9 +623,29 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesSaved': 'Pengaturan jenis file disimpan',
|
||||
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.placesPhotos.title': 'Foto Tempat',
|
||||
'admin.placesPhotos.subtitle': 'Mengambil foto dari Google Places API. Nonaktifkan untuk menghemat kuota API. Foto Wikimedia tidak terpengaruh.',
|
||||
'admin.placesAutocomplete.title': 'Pelengkapan Otomatis Tempat',
|
||||
'admin.placesAutocomplete.subtitle': 'Menggunakan Google Places API untuk saran pencarian. Nonaktifkan untuk menghemat kuota API.',
|
||||
'admin.placesDetails.title': 'Detail Tempat',
|
||||
'admin.placesDetails.subtitle': 'Mengambil informasi detail tempat (jam, penilaian, situs web) dari Google Places API. Nonaktifkan untuk menghemat kuota API.',
|
||||
'admin.bagTracking.title': 'Pelacak Tas',
|
||||
'admin.bagTracking.subtitle': 'Aktifkan berat dan penugasan tas untuk item packing',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Pesan real-time untuk kolaborasi',
|
||||
'admin.collab.notes.title': 'Catatan',
|
||||
'admin.collab.notes.subtitle': 'Catatan dan dokumen bersama',
|
||||
'admin.collab.polls.title': 'Jajak Pendapat',
|
||||
'admin.collab.polls.subtitle': 'Jajak pendapat dan voting grup',
|
||||
'admin.collab.whatsnext.title': 'Selanjutnya',
|
||||
'admin.collab.whatsnext.subtitle': 'Saran aktivitas dan langkah selanjutnya',
|
||||
'admin.tabs.config': 'Personalisasi',
|
||||
'admin.tabs.defaults': 'Pengaturan Default Pengguna',
|
||||
'admin.defaultSettings.title': 'Pengaturan Default Pengguna',
|
||||
'admin.defaultSettings.description': 'Tetapkan nilai default untuk seluruh instance. Pengguna yang belum mengubah pengaturan akan melihat nilai-nilai ini. Perubahan mereka sendiri selalu diprioritaskan.',
|
||||
'admin.defaultSettings.saved': 'Default disimpan',
|
||||
'admin.defaultSettings.reset': 'Atur ulang ke default bawaan',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'atur ulang',
|
||||
'admin.tabs.templates': 'Template Packing',
|
||||
'admin.packingTemplates.title': 'Template Packing',
|
||||
'admin.packingTemplates.subtitle': 'Buat daftar packing yang bisa digunakan ulang untuk perjalananmu',
|
||||
@@ -888,6 +928,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Rencana',
|
||||
'trip.tabs.transports': 'Transportasi',
|
||||
'trip.tabs.reservations': 'Pemesanan',
|
||||
'trip.tabs.reservationsShort': 'Pesan',
|
||||
'trip.tabs.packing': 'Daftar Perlengkapan',
|
||||
@@ -910,6 +951,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Reservasi ditambahkan',
|
||||
'trip.toast.deleted': 'Dihapus',
|
||||
'trip.confirm.deletePlace': 'Apakah kamu yakin ingin menghapus tempat ini?',
|
||||
'trip.confirm.deletePlaces': 'Hapus {count} tempat?',
|
||||
'trip.toast.placesDeleted': '{count} tempat dihapus',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Belum ada tempat yang direncanakan untuk hari ini',
|
||||
@@ -954,6 +997,17 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Impor gagal',
|
||||
'places.importAllSkipped': 'Semua tempat sudah ada di perjalanan.',
|
||||
'places.gpxImported': '{count} tempat diimpor dari GPX',
|
||||
'places.gpxImportTypes': 'Apa yang ingin diimpor?',
|
||||
'places.gpxImportWaypoints': 'Titik jalan',
|
||||
'places.gpxImportRoutes': 'Rute',
|
||||
'places.gpxImportTracks': 'Trek (dengan geometri jalur)',
|
||||
'places.gpxImportNoneSelected': 'Pilih setidaknya satu jenis untuk diimpor.',
|
||||
'places.kmlImportTypes': 'Apa yang ingin diimpor?',
|
||||
'places.kmlImportPoints': 'Titik (Placemarks)',
|
||||
'places.kmlImportPaths': 'Jalur (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Pilih setidaknya satu jenis.',
|
||||
'places.selectionCount': '{count} dipilih',
|
||||
'places.deleteSelected': 'Hapus yang dipilih',
|
||||
'places.kmlKmzImported': '{count} tempat diimpor dari KMZ/KML',
|
||||
'places.urlResolved': 'Tempat diimpor dari URL',
|
||||
'places.importList': 'Impor Daftar',
|
||||
@@ -970,6 +1024,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Tambah ke hari mana?',
|
||||
'places.all': 'Semua',
|
||||
'places.unplanned': 'Belum direncanakan',
|
||||
'places.filterTracks': 'Trek',
|
||||
'places.search': 'Cari tempat...',
|
||||
'places.allCategories': 'Semua Kategori',
|
||||
'places.categoriesSelected': 'kategori',
|
||||
@@ -1053,10 +1108,20 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.flightNumber': 'No. Penerbangan',
|
||||
'reservations.meta.from': 'Dari',
|
||||
'reservations.meta.to': 'Ke',
|
||||
'reservations.needsReview': 'Tinjau',
|
||||
'reservations.needsReviewHint': 'Bandara tidak dapat dicocokkan otomatis — konfirmasi lokasi.',
|
||||
'reservations.searchLocation': 'Cari stasiun, pelabuhan, alamat...',
|
||||
'airport.searchPlaceholder': 'Kode bandara atau kota (mis. FRA)',
|
||||
'map.connections': 'Koneksi',
|
||||
'map.showConnections': 'Tampilkan rute pemesanan',
|
||||
'map.hideConnections': 'Sembunyikan rute pemesanan',
|
||||
'settings.bookingLabels': 'Label rute pemesanan',
|
||||
'settings.bookingLabelsHint': 'Menampilkan nama stasiun / bandara di peta. Jika mati, hanya ikon ditampilkan.',
|
||||
'reservations.meta.trainNumber': 'No. Kereta',
|
||||
'reservations.meta.platform': 'Peron',
|
||||
'reservations.meta.seat': 'Kursi',
|
||||
'reservations.meta.checkIn': 'Check-in',
|
||||
'reservations.meta.checkInUntil': 'Check-in sampai',
|
||||
'reservations.meta.checkOut': 'Check-out',
|
||||
'reservations.meta.linkAccommodation': 'Akomodasi',
|
||||
'reservations.meta.pickAccommodation': 'Hubungkan ke akomodasi',
|
||||
@@ -1070,7 +1135,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.type.hotel': 'Akomodasi',
|
||||
'reservations.type.restaurant': 'Restoran',
|
||||
'reservations.type.train': 'Kereta',
|
||||
'reservations.type.car': 'Mobil Sewa',
|
||||
'reservations.type.car': 'Mobil',
|
||||
'reservations.type.cruise': 'Kapal Pesiar',
|
||||
'reservations.type.event': 'Acara',
|
||||
'reservations.type.tour': 'Tur',
|
||||
@@ -1131,6 +1196,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Selesai',
|
||||
'reservations.span.ongoing': 'Berlangsung',
|
||||
'reservations.validation.endBeforeStart': 'Tanggal/waktu selesai harus setelah tanggal/waktu mulai',
|
||||
'reservations.addBooking': 'Tambah pemesanan',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Anggaran',
|
||||
@@ -1541,6 +1607,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'Tambahkan tempat ke perjalananmu terlebih dahulu',
|
||||
'day.allDays': 'Semua',
|
||||
'day.checkIn': 'Check-in',
|
||||
'day.checkInUntil': 'Sampai',
|
||||
'day.checkOut': 'Check-out',
|
||||
'day.confirmation': 'Konfirmasi',
|
||||
'day.editAccommodation': 'Edit akomodasi',
|
||||
@@ -1569,6 +1636,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'Kata sandi',
|
||||
'memories.providerOTP': 'Kode MFA (jika diaktifkan)',
|
||||
'memories.skipSSLVerification': 'Lewati verifikasi sertifikat SSL',
|
||||
'memories.immichAutoUpload': 'Salin foto journey ke Immich saat diunggah',
|
||||
'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Uji koneksi',
|
||||
'memories.testFirst': 'Uji koneksi terlebih dahulu',
|
||||
@@ -1743,6 +1811,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Tempat diurutkan ulang',
|
||||
'undo.optimize': 'Rute dioptimalkan',
|
||||
'undo.deletePlace': 'Tempat dihapus',
|
||||
'undo.deletePlaces': 'Tempat dihapus',
|
||||
'undo.moveDay': 'Tempat dipindah ke hari lain',
|
||||
'undo.lock': 'Kunci tempat diubah',
|
||||
'undo.importGpx': 'Impor GPX',
|
||||
@@ -1799,7 +1868,11 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'todo.unassigned': 'Belum ditugaskan',
|
||||
'todo.noCategory': 'Tanpa kategori',
|
||||
'todo.hasDescription': 'Ada deskripsi',
|
||||
'todo.addItem': 'Tambah tugas baru...',
|
||||
'todo.addItem': 'Tugas baru',
|
||||
'todo.sidebar.sortBy': 'Urutkan',
|
||||
'todo.priority': 'Prioritas',
|
||||
'todo.newCategoryLabel': 'baru',
|
||||
'budget.categoriesLabel': 'kategori',
|
||||
'todo.newCategory': 'Nama kategori',
|
||||
'todo.addCategory': 'Tambah kategori',
|
||||
'todo.newItem': 'Tugas baru',
|
||||
@@ -1858,6 +1931,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.dev.unknown_event.text': 'Tipe event "{event}" tidak terdaftar di EVENT_NOTIFICATION_CONFIG',
|
||||
|
||||
// Journey addon
|
||||
'journey.search.placeholder': 'Cari perjalanan…',
|
||||
'journey.search.noResults': 'Tidak ada perjalanan yang cocok dengan "{query}"',
|
||||
'journey.title': 'Journey',
|
||||
'journey.subtitle': 'Lacak perjalananmu saat terjadi',
|
||||
'journey.new': 'Journey Baru',
|
||||
@@ -1879,6 +1954,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Aktif',
|
||||
'journey.status.completed': 'Selesai',
|
||||
'journey.status.upcoming': 'Mendatang',
|
||||
'journey.status.archived': 'Diarsipkan',
|
||||
'journey.checkin.add': 'Check in',
|
||||
'journey.checkin.namePlaceholder': 'Nama lokasi',
|
||||
'journey.checkin.notesPlaceholder': 'Catatan (opsional)',
|
||||
@@ -1967,6 +2043,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.synced': 'tersinkron',
|
||||
|
||||
// Journey Entry Editor
|
||||
'journey.editor.discardChangesConfirm': 'Anda memiliki perubahan yang belum disimpan. Buang?',
|
||||
'journey.editor.uploadPhotos': 'Unggah foto',
|
||||
'journey.editor.uploading': 'Mengunggah...',
|
||||
'journey.editor.fromGallery': 'Dari Galeri',
|
||||
@@ -2056,6 +2133,11 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Nama',
|
||||
'journey.settings.subtitle': 'Subjudul',
|
||||
'journey.settings.subtitlePlaceholder': 'mis. Thailand, Vietnam & Kamboja',
|
||||
'journey.settings.endJourney': 'Arsipkan Perjalanan',
|
||||
'journey.settings.reopenJourney': 'Pulihkan Perjalanan',
|
||||
'journey.settings.archived': 'Perjalanan diarsipkan',
|
||||
'journey.settings.reopened': 'Perjalanan dibuka kembali',
|
||||
'journey.settings.endDescription': 'Menyembunyikan lencana Langsung. Anda dapat membuka kembali kapan saja.',
|
||||
'journey.settings.delete': 'Hapus',
|
||||
'journey.settings.deleteJourney': 'Hapus Journey',
|
||||
'journey.settings.deleteMessage': 'Hapus "{title}"? Semua entri dan foto akan hilang.',
|
||||
@@ -2205,6 +2287,55 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.weather:read.description': 'Ambil prakiraan cuaca untuk lokasi dan tanggal perjalanan',
|
||||
|
||||
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Selamat datang di TREK',
|
||||
'system_notice.welcome_v1.body': 'Perencana perjalanan lengkap Anda. Buat itinerari, bagikan perjalanan dengan teman, dan tetap terorganisir — online maupun offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Rencanakan perjalanan',
|
||||
'system_notice.welcome_v1.hero_alt': 'Destinasi wisata indah dengan antarmuka TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Itinerari harian untuk setiap perjalanan',
|
||||
'system_notice.welcome_v1.highlight_share': 'Berkolaborasi dengan teman perjalanan',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Bekerja offline di ponsel',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Pemberitahuan sebelumnya',
|
||||
'system_notice.pager.next': 'Pemberitahuan berikutnya',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Pergi ke pemberitahuan {n}',
|
||||
'system_notice.pager.position': 'Pemberitahuan {current} dari {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Foto dipindahkan di 3.0',
|
||||
'system_notice.v3_photos.body': '**Foto** di Perencana Perjalanan telah dihapus. Foto Anda aman — TREK tidak pernah mengubah perpustakaan Immich atau Synology Anda.\n\nFoto kini ada di addon **Journey**. Journey bersifat opsional — jika belum tersedia, minta admin untuk mengaktifkannya di Admin → Addon.',
|
||||
'system_notice.v3_journey.title': 'Kenali Journey — jurnal perjalanan',
|
||||
'system_notice.v3_journey.body': 'Dokumentasikan perjalanan Anda sebagai cerita hidup dengan linimasa, galeri foto, dan peta interaktif.',
|
||||
'system_notice.v3_journey.cta_label': 'Buka Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Linimasa & galeri',
|
||||
'system_notice.v3_journey.highlight_photos': 'Impor dari Immich atau Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Bagikan secara publik — tanpa login',
|
||||
'system_notice.v3_journey.highlight_export': 'Ekspor sebagai buku foto PDF',
|
||||
'system_notice.v3_features.title': 'Sorotan lain di 3.0',
|
||||
'system_notice.v3_features.body': 'Beberapa pembaruan lain dalam rilis ini.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Desain ulang dashboard mobile-first',
|
||||
'system_notice.v3_features.highlight_offline': 'Mode offline penuh sebagai PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Pelengkapan otomatis tempat secara real-time',
|
||||
'system_notice.v3_features.highlight_import': 'Impor tempat dari file KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: pembaruan OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'Integrasi MCP telah sepenuhnya diperbarui. OAuth 2.1 kini menjadi metode autentikasi yang direkomendasikan. Token statis (trek_…) sudah usang dan akan dihapus pada versi mendatang.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 direkomendasikan (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 cakupan izin yang terperinci',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Token statis trek_ sudah usang',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Perangkat dan prompt yang diperluas',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Catatan pribadi dari saya',
|
||||
'system_notice.v3_thankyou.body': 'Sebelum kamu lanjut — saya ingin berhenti sejenak.\n\nTREK dimulai sebagai proyek sampingan yang saya buat untuk perjalanan saya sendiri. Saya tidak pernah membayangkan ia akan tumbuh menjadi sesuatu yang dipercaya oleh 4.000 dari kalian untuk merencanakan petualangan. Setiap bintang, setiap issue, setiap permintaan fitur — saya membaca semuanya, dan itulah yang membuat saya terus bertahan di malam-malam larut antara pekerjaan penuh waktu dan kuliah.\n\nSaya ingin kalian tahu: TREK akan selalu open source, selalu self-hosted, selalu milik kalian. Tanpa pelacakan, tanpa langganan, tanpa syarat tersembunyi. Hanya sebuah alat yang dibuat oleh seseorang yang mencintai traveling sama seperti kalian.\n\nTerima kasih khusus untuk [jubnl](https://github.com/jubnl) — kamu telah menjadi kolaborator yang luar biasa. Begitu banyak hal yang membuat versi 3.0 hebat memiliki jejakmu. Terima kasih telah percaya pada proyek ini ketika masih kasar.\n\nDan untuk setiap dari kalian yang melaporkan bug, menerjemahkan string, membagikan TREK kepada teman, atau sekadar menggunakannya untuk merencanakan perjalanan — **terima kasih**. Kalianlah alasan semua ini ada.\n\nUntuk lebih banyak petualangan bersama.\n\n— Maurice\n\n---\n\n[Bergabunglah dengan komunitas di Discord](https://discord.gg/7Q6M6jDwzf)\n\nJika TREK membuat perjalananmu lebih baik, [secangkir kopi kecil](https://ko-fi.com/mauriceboe) selalu membantu menjaga lampu tetap menyala.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transportasi',
|
||||
'transport.addManual': 'Transportasi Manual',
|
||||
};
|
||||
|
||||
export default id;
|
||||
|
||||
@@ -4,11 +4,15 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'Mostra di più',
|
||||
'common.showLess': 'Mostra meno',
|
||||
'common.cancel': 'Annulla',
|
||||
'common.clear': 'Cancella',
|
||||
'common.delete': 'Elimina',
|
||||
'common.edit': 'Modifica',
|
||||
'common.add': 'Aggiungi',
|
||||
'common.loading': 'Caricamento...',
|
||||
'common.import': 'Importa',
|
||||
'common.select': 'Seleziona',
|
||||
'common.selectAll': 'Seleziona tutto',
|
||||
'common.deselectAll': 'Deseleziona tutto',
|
||||
'common.error': 'Errore',
|
||||
'common.unknownError': 'Errore sconosciuto',
|
||||
'common.tooManyAttempts': 'Troppi tentativi. Riprova più tardi.',
|
||||
@@ -262,6 +266,16 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'Richiedi funzionalità',
|
||||
'settings.about.featureRequestHint': 'Suggerisci una nuova funzionalità',
|
||||
'settings.about.wikiHint': 'Documentazione e guide',
|
||||
'settings.about.supporters.badge': 'Sostenitori Mensili',
|
||||
'settings.about.supporters.title': 'Compagni di viaggio per TREK',
|
||||
'settings.about.supporters.subtitle': 'Mentre pianifichi il tuo prossimo itinerario, queste persone aiutano a pianificare il futuro di TREK. Il loro contributo mensile va direttamente allo sviluppo e alle ore realmente investite — per mantenere TREK Open Source.',
|
||||
'settings.about.supporters.since': 'sostenitore da {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Sii il primo',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK è un pianificatore di viaggi self-hosted che ti aiuta a organizzare i tuoi viaggi dalla prima idea all\'ultimo ricordo. Pianificazione giornaliera, budget, liste bagagli, foto e molto altro — tutto in un unico posto, sul tuo server.',
|
||||
'settings.about.madeWith': 'Fatto con',
|
||||
'settings.about.madeBy': 'da Maurice e una crescente comunità open-source.',
|
||||
@@ -544,9 +558,29 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesFormat': 'Estensioni separate da virgola (es. jpg,png,pdf,doc). Usa * per consentire tutti i tipi.',
|
||||
'admin.fileTypesSaved': 'Impostazioni dei tipi di file salvate',
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.placesPhotos.title': 'Foto dei luoghi',
|
||||
'admin.placesPhotos.subtitle': "Recupera le foto dall'API Google Places. Disabilita per risparmiare la quota API. Le foto di Wikimedia non sono interessate.",
|
||||
'admin.placesAutocomplete.title': 'Completamento automatico dei luoghi',
|
||||
'admin.placesAutocomplete.subtitle': "Utilizza l'API Google Places per i suggerimenti di ricerca. Disabilita per risparmiare la quota API.",
|
||||
'admin.placesDetails.title': 'Dettagli del luogo',
|
||||
'admin.placesDetails.subtitle': "Recupera informazioni dettagliate sul luogo (orari, valutazione, sito web) dall'API Google Places. Disabilita per risparmiare la quota API.",
|
||||
'admin.bagTracking.title': 'Tracciamento valigia',
|
||||
'admin.bagTracking.subtitle': 'Abilita il peso e l\'assegnazione della valigia per gli elementi della lista valigia',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Messaggistica in tempo reale per la collaborazione',
|
||||
'admin.collab.notes.title': 'Note',
|
||||
'admin.collab.notes.subtitle': 'Note e documenti condivisi',
|
||||
'admin.collab.polls.title': 'Sondaggi',
|
||||
'admin.collab.polls.subtitle': 'Sondaggi e votazioni di gruppo',
|
||||
'admin.collab.whatsnext.title': 'Prossimi passi',
|
||||
'admin.collab.whatsnext.subtitle': 'Suggerimenti attività e prossimi passi',
|
||||
'admin.tabs.config': 'Personalizzazione',
|
||||
'admin.tabs.defaults': 'Impostazioni predefinite',
|
||||
'admin.defaultSettings.title': 'Impostazioni predefinite utente',
|
||||
'admin.defaultSettings.description': "Imposta i valori predefiniti per l'intera istanza. Gli utenti che non hanno modificato un'impostazione vedranno questi valori. Le loro modifiche hanno sempre la priorità.",
|
||||
'admin.defaultSettings.saved': 'Predefinito salvato',
|
||||
'admin.defaultSettings.reset': 'Ripristina il predefinito integrato',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'ripristina',
|
||||
'admin.tabs.templates': 'Modelli lista valigia',
|
||||
'admin.packingTemplates.title': 'Modelli lista valigia',
|
||||
'admin.packingTemplates.subtitle': 'Crea liste valigia riutilizzabili per i tuoi viaggi',
|
||||
@@ -834,6 +868,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Programma',
|
||||
'trip.tabs.transports': 'Trasporti',
|
||||
'trip.tabs.reservations': 'Prenotazioni',
|
||||
'trip.tabs.reservationsShort': 'Pren.',
|
||||
'trip.tabs.packing': 'Lista valigia',
|
||||
@@ -855,6 +890,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Prenotazione aggiunta',
|
||||
'trip.toast.deleted': 'Eliminato',
|
||||
'trip.confirm.deletePlace': 'Sei sicuro di voler eliminare questo luogo?',
|
||||
'trip.confirm.deletePlaces': 'Eliminare {count} luoghi?',
|
||||
'trip.toast.placesDeleted': '{count} luoghi eliminati',
|
||||
'trip.loadingPhotos': 'Caricamento foto dei luoghi...',
|
||||
|
||||
// Day Plan Sidebar
|
||||
@@ -900,6 +937,17 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Importazione non riuscita',
|
||||
'places.importAllSkipped': 'Tutti i luoghi erano già nel viaggio.',
|
||||
'places.gpxImported': '{count} luoghi importati da GPX',
|
||||
'places.gpxImportTypes': 'Cosa vuoi importare?',
|
||||
'places.gpxImportWaypoints': 'Waypoint',
|
||||
'places.gpxImportRoutes': 'Percorsi',
|
||||
'places.gpxImportTracks': 'Tracce (con geometria percorso)',
|
||||
'places.gpxImportNoneSelected': 'Seleziona almeno un tipo da importare.',
|
||||
'places.kmlImportTypes': 'Cosa vuoi importare?',
|
||||
'places.kmlImportPoints': 'Punti (Placemarks)',
|
||||
'places.kmlImportPaths': 'Percorsi (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Seleziona almeno un tipo.',
|
||||
'places.selectionCount': '{count} selezionato/i',
|
||||
'places.deleteSelected': 'Elimina selezionati',
|
||||
'places.kmlKmzImported': '{count} luoghi importati da KMZ/KML',
|
||||
'places.urlResolved': 'Luogo importato dall\'URL',
|
||||
'places.importList': 'Importa lista',
|
||||
@@ -916,6 +964,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'A quale giorno aggiungere?',
|
||||
'places.all': 'Tutti',
|
||||
'places.unplanned': 'Non pianificati',
|
||||
'places.filterTracks': 'Tracce',
|
||||
'places.search': 'Cerca luoghi...',
|
||||
'places.allCategories': 'Tutte le categorie',
|
||||
'places.categoriesSelected': 'categorie',
|
||||
@@ -999,10 +1048,20 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.flightNumber': 'N. volo',
|
||||
'reservations.meta.from': 'Da',
|
||||
'reservations.meta.to': 'A',
|
||||
'reservations.needsReview': 'Verifica',
|
||||
'reservations.needsReviewHint': 'L\'aeroporto non è stato riconosciuto automaticamente — conferma la posizione.',
|
||||
'reservations.searchLocation': 'Cerca stazione, porto, indirizzo...',
|
||||
'airport.searchPlaceholder': 'Codice o città dell\'aeroporto (es. FRA)',
|
||||
'map.connections': 'Connessioni',
|
||||
'map.showConnections': 'Mostra percorsi prenotati',
|
||||
'map.hideConnections': 'Nascondi percorsi prenotati',
|
||||
'settings.bookingLabels': 'Etichette percorsi prenotati',
|
||||
'settings.bookingLabelsHint': 'Mostra i nomi di stazioni / aeroporti sulla mappa. Se disattivato, viene mostrata solo l\'icona.',
|
||||
'reservations.meta.trainNumber': 'N. treno',
|
||||
'reservations.meta.platform': 'Binario',
|
||||
'reservations.meta.seat': 'Posto',
|
||||
'reservations.meta.checkIn': 'Check-in',
|
||||
'reservations.meta.checkInUntil': 'Check-in fino a',
|
||||
'reservations.meta.checkOut': 'Check-out',
|
||||
'reservations.meta.linkAccommodation': 'Alloggio',
|
||||
'reservations.meta.pickAccommodation': 'Collega a un alloggio',
|
||||
@@ -1016,7 +1075,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.type.hotel': 'Alloggio',
|
||||
'reservations.type.restaurant': 'Ristorante',
|
||||
'reservations.type.train': 'Treno',
|
||||
'reservations.type.car': 'Auto a noleggio',
|
||||
'reservations.type.car': 'Auto',
|
||||
'reservations.type.cruise': 'Crociera',
|
||||
'reservations.type.event': 'Evento',
|
||||
'reservations.type.tour': 'Tour',
|
||||
@@ -1077,6 +1136,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Fine',
|
||||
'reservations.span.ongoing': 'In corso',
|
||||
'reservations.validation.endBeforeStart': 'La data/ora di fine deve essere successiva alla data/ora di inizio',
|
||||
'reservations.addBooking': 'Aggiungi prenotazione',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
@@ -1487,6 +1547,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'Aggiungi prima i luoghi al tuo viaggio',
|
||||
'day.allDays': 'Tutti',
|
||||
'day.checkIn': 'Check-in',
|
||||
'day.checkInUntil': 'Fino a',
|
||||
'day.checkOut': 'Check-out',
|
||||
'day.confirmation': 'Conferma',
|
||||
'day.editAccommodation': 'Modifica alloggio',
|
||||
@@ -1513,6 +1574,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'Password',
|
||||
'memories.providerOTP': 'Codice MFA (se abilitato)',
|
||||
'memories.skipSSLVerification': 'Ignora la verifica del certificato SSL',
|
||||
'memories.immichAutoUpload': 'Rispecchia le foto del journey su Immich al caricamento',
|
||||
'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Test connessione',
|
||||
'memories.testFirst': 'Testa prima la connessione',
|
||||
@@ -1678,6 +1740,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Luoghi riordinati',
|
||||
'undo.optimize': 'Percorso ottimizzato',
|
||||
'undo.deletePlace': 'Luogo eliminato',
|
||||
'undo.deletePlaces': 'Luoghi eliminati',
|
||||
'undo.moveDay': 'Luogo spostato in altro giorno',
|
||||
'undo.lock': 'Blocco luogo modificato',
|
||||
'undo.importGpx': 'Importazione GPX',
|
||||
@@ -1736,7 +1799,11 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'todo.unassigned': 'Non assegnato',
|
||||
'todo.noCategory': 'Nessuna categoria',
|
||||
'todo.hasDescription': 'Ha descrizione',
|
||||
'todo.addItem': 'Aggiungi nuova attività...',
|
||||
'todo.addItem': 'Nuova attività',
|
||||
'todo.sidebar.sortBy': 'Ordina per',
|
||||
'todo.priority': 'Priorità',
|
||||
'todo.newCategoryLabel': 'nuova',
|
||||
'budget.categoriesLabel': 'categorie',
|
||||
'todo.newCategory': 'Nome categoria',
|
||||
'todo.addCategory': 'Aggiungi categoria',
|
||||
'todo.newItem': 'Nuova attività',
|
||||
@@ -1778,7 +1845,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.ntfyUrl.test': 'Testa',
|
||||
'settings.ntfyUrl.testSuccess': 'Notifica di test Ntfy inviata con successo',
|
||||
'settings.ntfyUrl.testFailed': 'Notifica di test Ntfy fallita',
|
||||
'settings.ntfyUrl.clearToken': 'Cancella',
|
||||
'settings.ntfyUrl.tokenCleared': 'Token di accesso rimosso',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
@@ -1795,22 +1861,29 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Invio webhook di test fallito',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Il webhook admin si attiva automaticamente quando è configurato un URL',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': 'Consente agli utenti di configurare i propri argomenti ntfy per le notifiche push. Imposta il server predefinito di seguito per precompilare le impostazioni utente.',
|
||||
'admin.notifications.testNtfy': 'Invia Ntfy di test',
|
||||
'admin.notifications.testNtfySuccess': 'Ntfy di test inviato con successo',
|
||||
'admin.notifications.testNtfyFailed': 'Invio Ntfy di test fallito',
|
||||
'admin.notifications.adminNtfyPanel.title': 'Ntfy admin',
|
||||
'admin.notifications.adminNtfyPanel.hint': 'Questo argomento Ntfy viene usato esclusivamente per le notifiche admin (es. avvisi di versione). È separato dagli argomenti per utente e si attiva sempre quando è configurato.',
|
||||
'admin.notifications.adminNtfyPanel.serverLabel': 'URL server Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.serverHint': 'Usato anche come server predefinito per le notifiche ntfy degli utenti. Lasciare vuoto per usare ntfy.sh. Gli utenti possono sovrascriverlo nelle proprie impostazioni.',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': 'Argomento admin',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token di accesso (opzionale)',
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': 'Token di accesso admin rimosso',
|
||||
'admin.notifications.adminNtfyPanel.saved': 'Impostazioni Ntfy admin salvate',
|
||||
'admin.notifications.adminNtfyPanel.test': 'Invia Ntfy di test',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess': 'Ntfy di test inviato con successo',
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Invio Ntfy di test fallito',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Il Ntfy admin si attiva sempre quando un argomento è configurato',
|
||||
'admin.notifications.adminNotificationsHint': 'Configura quali canali consegnano le notifiche admin (es. avvisi di versione). Il webhook si attiva automaticamente se è impostato un URL webhook admin.',
|
||||
'admin.notifications.tripReminders.title': 'Promemoria viaggio',
|
||||
'admin.notifications.tripReminders.hint': 'Invia una notifica promemoria prima dell\'inizio di un viaggio (richiede giorni di promemoria impostati sul viaggio).',
|
||||
'admin.notifications.tripReminders.enabled': 'Promemoria viaggio attivati',
|
||||
'admin.notifications.tripReminders.disabled': 'Promemoria viaggio disattivati',
|
||||
'admin.tabs.notifications': 'Notifiche',
|
||||
'notifications.versionAvailable.title': 'Aggiornamento disponibile',
|
||||
'notifications.versionAvailable.text': 'TREK {version} è ora disponibile.',
|
||||
@@ -1855,6 +1928,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.justNow': 'proprio ora',
|
||||
'common.hoursAgo': '{count}h fa',
|
||||
'common.daysAgo': '{count}g fa',
|
||||
'journey.search.placeholder': 'Cerca viaggi…',
|
||||
'journey.search.noResults': 'Nessun viaggio corrisponde a "{query}"',
|
||||
'journey.title': 'Diario di viaggio',
|
||||
'journey.subtitle': 'Segui i tuoi viaggi in tempo reale',
|
||||
'journey.new': 'Nuovo diario',
|
||||
@@ -1876,6 +1951,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Attivo',
|
||||
'journey.status.completed': 'Completato',
|
||||
'journey.status.upcoming': 'In arrivo',
|
||||
'journey.status.archived': 'Archiviato',
|
||||
'journey.checkin.add': 'Check-in',
|
||||
'journey.checkin.namePlaceholder': 'Nome del luogo',
|
||||
'journey.checkin.notesPlaceholder': 'Note (facoltativo)',
|
||||
@@ -1952,6 +2028,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.verdict.couldBeBetter': 'Potrebbe essere meglio',
|
||||
'journey.synced.places': 'luoghi',
|
||||
'journey.synced.synced': 'sincronizzato',
|
||||
'journey.editor.discardChangesConfirm': 'Hai modifiche non salvate. Vuoi scartarle?',
|
||||
'journey.editor.uploadPhotos': 'Carica foto',
|
||||
'journey.editor.uploading': 'Caricamento...',
|
||||
'journey.editor.fromGallery': 'Dalla galleria',
|
||||
@@ -2029,6 +2106,11 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Nome',
|
||||
'journey.settings.subtitle': 'Sottotitolo',
|
||||
'journey.settings.subtitlePlaceholder': 'es. Thailandia, Vietnam e Cambogia',
|
||||
'journey.settings.endJourney': 'Archivia il viaggio',
|
||||
'journey.settings.reopenJourney': 'Ripristina il viaggio',
|
||||
'journey.settings.archived': 'Viaggio archiviato',
|
||||
'journey.settings.reopened': 'Viaggio riaperto',
|
||||
'journey.settings.endDescription': 'Nasconde il badge In diretta. Puoi riaprire in qualsiasi momento.',
|
||||
'journey.settings.delete': 'Elimina',
|
||||
'journey.settings.deleteJourney': 'Elimina diario',
|
||||
'journey.settings.deleteMessage': 'Eliminare "{title}"? Tutte le voci e le foto andranno perse.',
|
||||
@@ -2164,6 +2246,55 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Cerca luoghi, risolvi URL mappa e geocodifica inversa coordinate',
|
||||
'oauth.scope.weather:read.label': 'Previsioni meteo',
|
||||
'oauth.scope.weather:read.description': 'Ottieni previsioni meteo per luoghi e date del viaggio',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Benvenuto su TREK',
|
||||
'system_notice.welcome_v1.body': 'Il tuo pianificatore di viaggi tutto in uno. Crea itinerari, condividi viaggi con gli amici e rimani organizzato — online e offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Pianifica un viaggio',
|
||||
'system_notice.welcome_v1.hero_alt': 'Destinazione di viaggio panoramica con l\'interfaccia TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Itinerari giorno per giorno',
|
||||
'system_notice.welcome_v1.highlight_share': 'Collabora con i tuoi compagni di viaggio',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Funziona offline su mobile',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Avviso precedente',
|
||||
'system_notice.pager.next': 'Avviso successivo',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': "Vai all'avviso {n}",
|
||||
'system_notice.pager.position': 'Avviso {current} di {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Le foto sono spostate nella 3.0',
|
||||
'system_notice.v3_photos.body': '**Foto** nel Pianificatore di Viaggio sono state rimosse. Le tue foto sono al sicuro — TREK non ha mai modificato la tua libreria Immich o Synology.\n\nLe foto ora si trovano nel componente aggiuntivo **Journey**. Journey è opzionale — se non è ancora disponibile, chiedi al tuo admin di abilitarlo in Admin → Addon.',
|
||||
'system_notice.v3_journey.title': 'Scopri Journey — diario di viaggio',
|
||||
'system_notice.v3_journey.body': 'Documenta i tuoi viaggi come storie ricche con cronologie, gallerie fotografiche e mappe interattive.',
|
||||
'system_notice.v3_journey.cta_label': 'Apri Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Cronologia e galleria giornaliera',
|
||||
'system_notice.v3_journey.highlight_photos': 'Importa da Immich o Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Condividi pubblicamente — senza accesso',
|
||||
'system_notice.v3_journey.highlight_export': 'Esporta come libro fotografico PDF',
|
||||
'system_notice.v3_features.title': 'Altri punti salienti nel 3.0',
|
||||
'system_notice.v3_features.body': 'Altre novità da conoscere in questa versione.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Dashboard ridisegnata mobile-first',
|
||||
'system_notice.v3_features.highlight_offline': 'Modalità offline completa come PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Completamento automatico luoghi in tempo reale',
|
||||
'system_notice.v3_features.highlight_import': 'Importa luoghi da file KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: aggiornamento OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': "L'integrazione MCP è stata completamente rinnovata. OAuth 2.1 è ora il metodo di autenticazione consigliato. I token statici (trek_\u2026) sono deprecati e verranno rimossi in una versione futura.",
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 consigliato (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 scope di autorizzazione granulari',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Token statici trek_ deprecati',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Strumenti e prompt estesi',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Una nota personale da parte mia',
|
||||
'system_notice.v3_thankyou.body': 'Prima di andare avanti — voglio prendermi un momento.\n\nTREK è nato come un progetto secondario che ho costruito per i miei viaggi. Non avrei mai immaginato che sarebbe cresciuto fino a diventare qualcosa di cui 4.000 di voi si fidano per pianificare le proprie avventure. Ogni stella, ogni issue, ogni richiesta di funzionalità — le leggo tutte, e sono loro a tenermi in piedi nelle notti tarde tra un lavoro a tempo pieno e l\'università.\n\nVoglio che sappiate: TREK sarà sempre open source, sempre self-hosted, sempre vostro. Nessun tracciamento, nessun abbonamento, nessuna fregatura. Solo uno strumento creato da qualcuno che ama viaggiare tanto quanto voi.\n\nUn ringraziamento speciale a [jubnl](https://github.com/jubnl) — sei diventato un collaboratore incredibile. Molto di ciò che rende la 3.0 fantastica porta la tua impronta. Grazie per aver creduto in questo progetto quando era ancora acerbo.\n\nE a ognuno di voi che ha segnalato un bug, tradotto una stringa, condiviso TREK con un amico o semplicemente lo ha usato per pianificare un viaggio — **grazie**. Voi siete il motivo per cui tutto questo esiste.\n\nA molte altre avventure insieme.\n\n— Maurice\n\n---\n\n[Unisciti alla community su Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe TREK rende i tuoi viaggi migliori, un [piccolo caffè](https://ko-fi.com/mauriceboe) aiuta sempre a tenere le luci accese.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Trasporti',
|
||||
'transport.addManual': 'Trasporto manuale',
|
||||
}
|
||||
|
||||
export default it
|
||||
|
||||
@@ -4,11 +4,15 @@ const nl: Record<string, string> = {
|
||||
'common.showMore': 'Meer tonen',
|
||||
'common.showLess': 'Minder tonen',
|
||||
'common.cancel': 'Annuleren',
|
||||
'common.clear': 'Wissen',
|
||||
'common.delete': 'Verwijderen',
|
||||
'common.edit': 'Bewerken',
|
||||
'common.add': 'Toevoegen',
|
||||
'common.loading': 'Laden...',
|
||||
'common.import': 'Importeren',
|
||||
'common.select': 'Selecteren',
|
||||
'common.selectAll': 'Alles selecteren',
|
||||
'common.deselectAll': 'Alles deselecteren',
|
||||
'common.error': 'Fout',
|
||||
'common.unknownError': 'Onbekende fout',
|
||||
'common.tooManyAttempts': 'Te veel pogingen. Probeer het later opnieuw.',
|
||||
@@ -307,6 +311,16 @@ const nl: Record<string, string> = {
|
||||
'settings.about.featureRequest': 'Feature aanvragen',
|
||||
'settings.about.featureRequestHint': 'Stel een nieuwe functie voor',
|
||||
'settings.about.wikiHint': 'Documentatie en handleidingen',
|
||||
'settings.about.supporters.badge': 'Maandelijkse Steuners',
|
||||
'settings.about.supporters.title': 'Reisgezelschap voor TREK',
|
||||
'settings.about.supporters.subtitle': 'Terwijl jij je volgende route plant, plannen deze mensen mee aan de toekomst van TREK. Hun maandelijkse bijdrage gaat rechtstreeks naar ontwikkeling en echte uren — zodat TREK Open Source blijft.',
|
||||
'settings.about.supporters.since': 'steuner sinds {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Wees de eerste',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK is een zelf-gehoste reisplanner die je helpt je reizen te organiseren van het eerste idee tot de laatste herinnering. Dagplanning, budget, paklijsten, foto\'s en nog veel meer — alles op één plek, op je eigen server.',
|
||||
'settings.about.madeWith': 'Gemaakt met',
|
||||
'settings.about.madeBy': 'door Maurice en een groeiende open-source community.',
|
||||
@@ -545,9 +559,29 @@ const nl: Record<string, string> = {
|
||||
'admin.fileTypesFormat': 'Kommagescheiden extensies (bijv. jpg,png,pdf,doc). Gebruik * om alle typen toe te staan.',
|
||||
'admin.fileTypesSaved': 'Bestandstype-instellingen opgeslagen',
|
||||
|
||||
'admin.placesPhotos.title': "Plaatsfoto's",
|
||||
'admin.placesPhotos.subtitle': "Haalt foto's op via de Google Places API. Schakel uit om API-quota te besparen. Wikimedia-foto's worden niet beïnvloed.",
|
||||
'admin.placesAutocomplete.title': 'Plaatsautocomplete',
|
||||
'admin.placesAutocomplete.subtitle': 'Gebruikt de Google Places API voor zoeksuggesties. Schakel uit om API-quota te besparen.',
|
||||
'admin.placesDetails.title': 'Plaatsdetails',
|
||||
'admin.placesDetails.subtitle': 'Haalt gedetailleerde plaatsinformatie (openingstijden, beoordeling, website) op via de Google Places API. Schakel uit om API-quota te besparen.',
|
||||
'admin.bagTracking.title': 'Bagagetracking',
|
||||
'admin.bagTracking.subtitle': 'Gewicht en bagagetoewijzing inschakelen voor paklijstitems',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Realtime berichten voor reissamenwerking',
|
||||
'admin.collab.notes.title': 'Notities',
|
||||
'admin.collab.notes.subtitle': 'Gedeelde notities en documenten',
|
||||
'admin.collab.polls.title': 'Peilingen',
|
||||
'admin.collab.polls.subtitle': 'Groepspeilingen en stemmen',
|
||||
'admin.collab.whatsnext.title': 'Wat nu',
|
||||
'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen',
|
||||
'admin.tabs.config': 'Personalisatie',
|
||||
'admin.tabs.defaults': 'Standaardinstellingen',
|
||||
'admin.defaultSettings.title': 'Standaard gebruikersinstellingen',
|
||||
'admin.defaultSettings.description': 'Stel instantiebrede standaardwaarden in. Gebruikers die een instelling niet hebben gewijzigd, zien deze waarden. Hun eigen wijzigingen hebben altijd voorrang.',
|
||||
'admin.defaultSettings.saved': 'Standaard opgeslagen',
|
||||
'admin.defaultSettings.reset': 'Terugzetten naar ingebouwde standaard',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'terugzetten',
|
||||
'admin.tabs.templates': 'Paksjablonen',
|
||||
'admin.packingTemplates.title': 'Paksjablonen',
|
||||
'admin.packingTemplates.subtitle': 'Herbruikbare paklijsten maken voor je reizen',
|
||||
@@ -833,6 +867,7 @@ const nl: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.transports': 'Transport',
|
||||
'trip.tabs.reservations': 'Boekingen',
|
||||
'trip.tabs.reservationsShort': 'Boek',
|
||||
'trip.tabs.packing': 'Paklijst',
|
||||
@@ -855,6 +890,8 @@ const nl: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': 'Reservering toegevoegd',
|
||||
'trip.toast.deleted': 'Verwijderd',
|
||||
'trip.confirm.deletePlace': 'Weet je zeker dat je deze plaats wilt verwijderen?',
|
||||
'trip.confirm.deletePlaces': '{count} plaatsen verwijderen?',
|
||||
'trip.toast.placesDeleted': '{count} plaatsen verwijderd',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Geen plaatsen gepland voor deze dag',
|
||||
@@ -899,6 +936,17 @@ const nl: Record<string, string> = {
|
||||
'places.importFileError': 'Importeren mislukt',
|
||||
'places.importAllSkipped': 'Alle plaatsen waren al in de reis.',
|
||||
'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
|
||||
'places.gpxImportTypes': 'Wat wil je importeren?',
|
||||
'places.gpxImportWaypoints': 'Waypoints',
|
||||
'places.gpxImportRoutes': 'Routes',
|
||||
'places.gpxImportTracks': 'Tracks (met routegeometrie)',
|
||||
'places.gpxImportNoneSelected': 'Selecteer minstens één type om te importeren.',
|
||||
'places.kmlImportTypes': 'Wat wil je importeren?',
|
||||
'places.kmlImportPoints': 'Punten (Placemarks)',
|
||||
'places.kmlImportPaths': 'Paden (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Selecteer minstens één type.',
|
||||
'places.selectionCount': '{count} geselecteerd',
|
||||
'places.deleteSelected': 'Selectie verwijderen',
|
||||
'places.kmlKmzImported': '{count} plaatsen geïmporteerd uit KMZ/KML',
|
||||
'places.urlResolved': 'Plaats geïmporteerd van URL',
|
||||
'places.importList': 'Lijst importeren',
|
||||
@@ -915,6 +963,7 @@ const nl: Record<string, string> = {
|
||||
'places.assignToDay': 'Aan welke dag toevoegen?',
|
||||
'places.all': 'Alle',
|
||||
'places.unplanned': 'Ongepland',
|
||||
'places.filterTracks': 'Tracks',
|
||||
'places.search': 'Plaatsen zoeken...',
|
||||
'places.allCategories': 'Alle categorieën',
|
||||
'places.categoriesSelected': 'categorieën',
|
||||
@@ -998,10 +1047,20 @@ const nl: Record<string, string> = {
|
||||
'reservations.meta.flightNumber': 'Vluchtnr.',
|
||||
'reservations.meta.from': 'Van',
|
||||
'reservations.meta.to': 'Naar',
|
||||
'reservations.needsReview': 'Controleren',
|
||||
'reservations.needsReviewHint': 'Luchthaven kon niet automatisch worden herkend — bevestig de locatie.',
|
||||
'reservations.searchLocation': 'Station, haven, adres zoeken...',
|
||||
'airport.searchPlaceholder': 'Luchthavencode of stad (bijv. FRA)',
|
||||
'map.connections': 'Verbindingen',
|
||||
'map.showConnections': 'Boekingsroutes tonen',
|
||||
'map.hideConnections': 'Boekingsroutes verbergen',
|
||||
'settings.bookingLabels': 'Routelabels voor boekingen',
|
||||
'settings.bookingLabelsHint': 'Toon station- / luchthavennamen op de kaart. Indien uit, alleen het icoon.',
|
||||
'reservations.meta.trainNumber': 'Treinnr.',
|
||||
'reservations.meta.platform': 'Perron',
|
||||
'reservations.meta.seat': 'Stoel',
|
||||
'reservations.meta.checkIn': 'Inchecken',
|
||||
'reservations.meta.checkInUntil': 'Check-in tot',
|
||||
'reservations.meta.checkOut': 'Uitchecken',
|
||||
'reservations.meta.linkAccommodation': 'Accommodatie',
|
||||
'reservations.meta.pickAccommodation': 'Koppel aan accommodatie',
|
||||
@@ -1015,7 +1074,7 @@ const nl: Record<string, string> = {
|
||||
'reservations.type.hotel': 'Accommodatie',
|
||||
'reservations.type.restaurant': 'Restaurant',
|
||||
'reservations.type.train': 'Trein',
|
||||
'reservations.type.car': 'Huurauto',
|
||||
'reservations.type.car': 'Auto',
|
||||
'reservations.type.cruise': 'Cruise',
|
||||
'reservations.type.event': 'Evenement',
|
||||
'reservations.type.tour': 'Rondleiding',
|
||||
@@ -1076,6 +1135,7 @@ const nl: Record<string, string> = {
|
||||
'reservations.span.end': 'Einde',
|
||||
'reservations.span.ongoing': 'Lopend',
|
||||
'reservations.validation.endBeforeStart': 'Einddatum/-tijd moet na de startdatum/-tijd liggen',
|
||||
'reservations.addBooking': 'Boeking toevoegen',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
@@ -1486,6 +1546,7 @@ const nl: Record<string, string> = {
|
||||
'day.noPlacesForHotel': 'Voeg eerst plaatsen toe aan je reis',
|
||||
'day.allDays': 'Alle',
|
||||
'day.checkIn': 'Inchecken',
|
||||
'day.checkInUntil': 'Tot',
|
||||
'day.checkOut': 'Uitchecken',
|
||||
'day.confirmation': 'Bevestiging',
|
||||
'day.editAccommodation': 'Accommodatie bewerken',
|
||||
@@ -1512,6 +1573,7 @@ const nl: Record<string, string> = {
|
||||
'memories.providerPassword': 'Wachtwoord',
|
||||
'memories.providerOTP': 'MFA-code (indien ingeschakeld)',
|
||||
'memories.skipSSLVerification': 'SSL-certificaatverificatie overslaan',
|
||||
'memories.immichAutoUpload': 'Journey-foto\'s bij upload ook naar Immich spiegelen',
|
||||
'memories.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Verbinding testen',
|
||||
'memories.testFirst': 'Test eerst de verbinding',
|
||||
@@ -1676,6 +1738,7 @@ const nl: Record<string, string> = {
|
||||
'undo.reorder': 'Locaties hergeordend',
|
||||
'undo.optimize': 'Route geoptimaliseerd',
|
||||
'undo.deletePlace': 'Locatie verwijderd',
|
||||
'undo.deletePlaces': 'Plaatsen verwijderd',
|
||||
'undo.moveDay': 'Locatie naar andere dag verplaatst',
|
||||
'undo.lock': 'Vergrendeling locatie gewijzigd',
|
||||
'undo.importGpx': 'GPX-import',
|
||||
@@ -1735,7 +1798,11 @@ const nl: Record<string, string> = {
|
||||
'todo.unassigned': 'Niet toegewezen',
|
||||
'todo.noCategory': 'Geen categorie',
|
||||
'todo.hasDescription': 'Heeft beschrijving',
|
||||
'todo.addItem': 'Nieuwe taak toevoegen...',
|
||||
'todo.addItem': 'Nieuwe taak',
|
||||
'todo.sidebar.sortBy': 'Sorteren op',
|
||||
'todo.priority': 'Prioriteit',
|
||||
'todo.newCategoryLabel': 'nieuw',
|
||||
'budget.categoriesLabel': 'categorieën',
|
||||
'todo.newCategory': 'Categorienaam',
|
||||
'todo.addCategory': 'Categorie toevoegen',
|
||||
'todo.newItem': 'Nieuwe taak',
|
||||
@@ -1777,7 +1844,6 @@ const nl: Record<string, string> = {
|
||||
'settings.ntfyUrl.test': 'Testen',
|
||||
'settings.ntfyUrl.testSuccess': 'Test-Ntfy-melding succesvol verzonden',
|
||||
'settings.ntfyUrl.testFailed': 'Test-Ntfy-melding mislukt',
|
||||
'settings.ntfyUrl.clearToken': 'Wissen',
|
||||
'settings.ntfyUrl.tokenCleared': 'Toegangstoken gewist',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
@@ -1794,22 +1860,29 @@ const nl: Record<string, string> = {
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Test-webhook mislukt',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-webhook verstuurt automatisch als er een URL is ingesteld',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': 'Hiermee kunnen gebruikers hun eigen ntfy-onderwerpen instellen voor pushmeldingen. Stel de standaardserver hieronder in om de gebruikersinstellingen vooraf in te vullen.',
|
||||
'admin.notifications.testNtfy': 'Test-Ntfy verzenden',
|
||||
'admin.notifications.testNtfySuccess': 'Test-Ntfy succesvol verzonden',
|
||||
'admin.notifications.testNtfyFailed': 'Test-Ntfy mislukt',
|
||||
'admin.notifications.adminNtfyPanel.title': 'Admin-Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.hint': 'Dit Ntfy-onderwerp wordt uitsluitend gebruikt voor admin-meldingen (bijv. versie-updates). Het staat los van onderwerpen per gebruiker en verstuurt altijd wanneer het geconfigureerd is.',
|
||||
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy-server-URL',
|
||||
'admin.notifications.adminNtfyPanel.serverHint': 'Wordt ook gebruikt als standaardserver voor ntfy-meldingen van gebruikers. Laat leeg om ntfy.sh te gebruiken. Gebruikers kunnen dit aanpassen in hun eigen instellingen.',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin-onderwerp',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': 'Toegangstoken (optioneel)',
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': 'Admin-toegangstoken gewist',
|
||||
'admin.notifications.adminNtfyPanel.saved': 'Admin-Ntfy-instellingen opgeslagen',
|
||||
'admin.notifications.adminNtfyPanel.test': 'Test-Ntfy verzenden',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess': 'Test-Ntfy succesvol verzonden',
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy mislukt',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin-Ntfy verstuurt altijd wanneer een onderwerp is geconfigureerd',
|
||||
'admin.notifications.adminNotificationsHint': 'Stel in via welke kanalen admin-meldingen worden bezorgd (bijv. versie-updates). De webhook verstuurt automatisch als er een admin-webhook-URL is ingesteld.',
|
||||
'admin.notifications.tripReminders.title': 'Reisherinneringen',
|
||||
'admin.notifications.tripReminders.hint': 'Stuurt een herinneringsmelding voor de start van een reis (vereist ingestelde herinneringsdagen bij de reis).',
|
||||
'admin.notifications.tripReminders.enabled': 'Reisherinneringen ingeschakeld',
|
||||
'admin.notifications.tripReminders.disabled': 'Reisherinneringen uitgeschakeld',
|
||||
'admin.tabs.notifications': 'Meldingen',
|
||||
'notifications.versionAvailable.title': 'Update beschikbaar',
|
||||
'notifications.versionAvailable.text': 'TREK {version} is nu beschikbaar.',
|
||||
@@ -1854,6 +1927,8 @@ const nl: Record<string, string> = {
|
||||
'common.justNow': 'zojuist',
|
||||
'common.hoursAgo': '{count}u geleden',
|
||||
'common.daysAgo': '{count}d geleden',
|
||||
'journey.search.placeholder': 'Reizen zoeken…',
|
||||
'journey.search.noResults': 'Geen reizen komen overeen met "{query}"',
|
||||
'journey.title': 'Reisverslag',
|
||||
'journey.subtitle': 'Leg je reizen vast terwijl je onderweg bent',
|
||||
'journey.new': 'Nieuw reisverslag',
|
||||
@@ -1875,6 +1950,7 @@ const nl: Record<string, string> = {
|
||||
'journey.status.active': 'Actief',
|
||||
'journey.status.completed': 'Voltooid',
|
||||
'journey.status.upcoming': 'Gepland',
|
||||
'journey.status.archived': 'Gearchiveerd',
|
||||
'journey.checkin.add': 'Inchecken',
|
||||
'journey.checkin.namePlaceholder': 'Locatienaam',
|
||||
'journey.checkin.notesPlaceholder': 'Notities (optioneel)',
|
||||
@@ -1951,6 +2027,7 @@ const nl: Record<string, string> = {
|
||||
'journey.verdict.couldBeBetter': 'Kan beter',
|
||||
'journey.synced.places': 'plaatsen',
|
||||
'journey.synced.synced': 'gesynchroniseerd',
|
||||
'journey.editor.discardChangesConfirm': 'Je hebt niet-opgeslagen wijzigingen. Verwerpen?',
|
||||
'journey.editor.uploadPhotos': 'Foto\'s uploaden',
|
||||
'journey.editor.uploading': 'Uploaden...',
|
||||
'journey.editor.fromGallery': 'Uit galerij',
|
||||
@@ -2028,6 +2105,11 @@ const nl: Record<string, string> = {
|
||||
'journey.settings.name': 'Naam',
|
||||
'journey.settings.subtitle': 'Ondertitel',
|
||||
'journey.settings.subtitlePlaceholder': 'bijv. Thailand, Vietnam & Cambodja',
|
||||
'journey.settings.endJourney': 'Reis archiveren',
|
||||
'journey.settings.reopenJourney': 'Reis herstellen',
|
||||
'journey.settings.archived': 'Reis gearchiveerd',
|
||||
'journey.settings.reopened': 'Reis heropend',
|
||||
'journey.settings.endDescription': 'Verbergt het Live-badge. Je kunt het altijd heropenen.',
|
||||
'journey.settings.delete': 'Verwijderen',
|
||||
'journey.settings.deleteJourney': 'Reisverslag verwijderen',
|
||||
'journey.settings.deleteMessage': '"{title}" verwijderen? Alle vermeldingen en foto\'s gaan verloren.',
|
||||
@@ -2163,6 +2245,55 @@ const nl: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': 'Locaties zoeken, kaart-URL\'s oplossen en coördinaten omgekeerd geocoderen',
|
||||
'oauth.scope.weather:read.label': 'Weersverwachtingen',
|
||||
'oauth.scope.weather:read.description': 'Weersverwachtingen ophalen voor reislocaties en -datums',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Welkom bij TREK',
|
||||
'system_notice.welcome_v1.body': 'Jouw alles-in-één reisplanner. Maak reisschema\'s, deel trips met vrienden en blijf georganiseerd — online en offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Reis plannen',
|
||||
'system_notice.welcome_v1.hero_alt': 'Schilderachtige reisbestemming met TREK interface',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Dag-voor-dag reisschema\'s',
|
||||
'system_notice.welcome_v1.highlight_share': 'Samenwerken met reisgezelschap',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Werkt offline op mobiel',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Vorige melding',
|
||||
'system_notice.pager.next': 'Volgende melding',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Ga naar melding {n}',
|
||||
'system_notice.pager.position': 'Melding {current} van {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': "Foto's zijn verplaatst in 3.0",
|
||||
'system_notice.v3_photos.body': "**Foto's** in de Reisplanner zijn verwijderd. Je foto's zijn veilig — TREK heeft je Immich- of Synology-bibliotheek nooit gewijzigd.\n\nFoto's leven nu in de **Journey**-addon. Journey is optioneel — als het nog niet beschikbaar is, vraag je admin het te activeren via Admin → Addons.",
|
||||
'system_notice.v3_journey.title': 'Maak kennis met Journey — reisdagboek',
|
||||
'system_notice.v3_journey.body': 'Documenteer je reizen als rijke verhalen met tijdlijnen, fotogalerijen en interactieve kaarten.',
|
||||
'system_notice.v3_journey.cta_label': 'Journey openen',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Dag-voor-dag tijdlijn & galerij',
|
||||
'system_notice.v3_journey.highlight_photos': 'Importeer van Immich of Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Openbaar delen — geen login vereist',
|
||||
'system_notice.v3_journey.highlight_export': 'Exporteer als PDF-fotoboek',
|
||||
'system_notice.v3_features.title': 'Meer hoogtepunten in 3.0',
|
||||
'system_notice.v3_features.body': 'Nog een paar dingen die het weten waard zijn in deze release.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Mobile-first dashboard herontwerp',
|
||||
'system_notice.v3_features.highlight_offline': 'Volledige offline modus als PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Realtime plaatsautocomplete',
|
||||
'system_notice.v3_features.highlight_import': 'Importeer plaatsen uit KMZ/KML-bestanden',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1-upgrade',
|
||||
'system_notice.v3_mcp.body': 'De MCP-integratie is volledig vernieuwd. OAuth 2.1 is nu de aanbevolen authenticatiemethode. Statische tokens (trek_…) zijn verouderd en worden verwijderd in een toekomstige versie.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 aanbevolen (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 gedetailleerde toestemmingsscopes',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statische trek_-tokens verouderd',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Uitgebreide tools & prompts',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Een persoonlijk woord van mij',
|
||||
'system_notice.v3_thankyou.body': 'Voordat je verdergaat — ik wil even stilstaan.\n\nTREK begon als een zijproject dat ik bouwde voor mijn eigen reizen. Ik had nooit gedacht dat het zou uitgroeien tot iets waar 4.000 van jullie op vertrouwen om avonturen te plannen. Elke ster, elke issue, elk functieverzoek — ik lees ze allemaal, en ze houden me op de been tijdens de late avonden tussen een fulltime baan en de universiteit.\n\nIk wil dat jullie weten: TREK zal altijd open source zijn, altijd self-hosted, altijd van jullie. Geen tracking, geen abonnementen, geen addertjes. Gewoon een tool gebouwd door iemand die net zo veel van reizen houdt als jullie.\n\nSpeciale dank aan [jubnl](https://github.com/jubnl) — je bent een ongelooflijke medewerker geworden. Zo veel van wat 3.0 geweldig maakt draagt jouw vingerafdruk. Bedankt dat je in dit project geloofde toen het nog ruw was.\n\nEn aan ieder van jullie die een bug meldde, een string vertaalde, TREK deelde met een vriend of het simpelweg gebruikte om een reis te plannen — **bedankt**. Jullie zijn de reden dat dit bestaat.\n\nOp nog vele avonturen samen.\n\n— Maurice\n\n---\n\n[Sluit je aan bij de community op Discord](https://discord.gg/7Q6M6jDwzf)\n\nAls TREK je reizen beter maakt, houdt een [klein kopje koffie](https://ko-fi.com/mauriceboe) altijd de lichten aan.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transport',
|
||||
'transport.addManual': 'Handmatig transport',
|
||||
}
|
||||
|
||||
export default nl
|
||||
|
||||
@@ -4,6 +4,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'Pokaż więcej',
|
||||
'common.showLess': 'Pokaż mniej',
|
||||
'common.cancel': 'Anuluj',
|
||||
'common.clear': 'Wyczyść',
|
||||
'common.delete': 'Usuń',
|
||||
'common.edit': 'Edytuj',
|
||||
'common.add': 'Dodaj',
|
||||
@@ -280,6 +281,16 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'Zaproponuj funkcję',
|
||||
'settings.about.featureRequestHint': 'Zaproponuj nową funkcję',
|
||||
'settings.about.wikiHint': 'Dokumentacja i poradniki',
|
||||
'settings.about.supporters.badge': 'Miesięczni Patroni',
|
||||
'settings.about.supporters.title': 'Towarzystwo podróży dla TREK',
|
||||
'settings.about.supporters.subtitle': 'Gdy planujesz kolejną trasę, te osoby planują razem ze mną przyszłość TREK. Ich comiesięczny wkład idzie bezpośrednio na rozwój i realnie przepracowane godziny — aby TREK pozostał Open Source.',
|
||||
'settings.about.supporters.since': 'patron od {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Bądź pierwszy',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK to samodzielnie hostowany planer podróży, który pomaga organizować wyprawy od pierwszego pomysłu po ostatnie wspomnienie. Planowanie dzienne, budżet, listy pakowania, zdjęcia i wiele więcej — wszystko w jednym miejscu, na własnym serwerze.',
|
||||
'settings.about.madeWith': 'Stworzone z',
|
||||
'settings.about.madeBy': 'przez Maurice\'a i rosnącą społeczność open-source.',
|
||||
@@ -517,9 +528,29 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesSaved': 'Ustawienia typów plików zostały zapisane',
|
||||
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.placesPhotos.title': 'Zdjęcia miejsc',
|
||||
'admin.placesPhotos.subtitle': 'Pobiera zdjęcia z Google Places API. Wyłącz, aby zaoszczędzić limit API. Zdjęcia z Wikimedia nie są objęte.',
|
||||
'admin.placesAutocomplete.title': 'Autouzupełnianie miejsc',
|
||||
'admin.placesAutocomplete.subtitle': 'Używa Google Places API do sugestii wyszukiwania. Wyłącz, aby zaoszczędzić limit API.',
|
||||
'admin.placesDetails.title': 'Szczegóły miejsca',
|
||||
'admin.placesDetails.subtitle': 'Pobiera szczegółowe informacje o miejscu (godziny, ocena, strona) z Google Places API. Wyłącz, aby zaoszczędzić limit API.',
|
||||
'admin.bagTracking.title': 'Kontrola bagażu',
|
||||
'admin.bagTracking.subtitle': 'Włącz wagę i przypisywanie do toreb dla przedmiotów do pakowania',
|
||||
'admin.collab.chat.title': 'Czat',
|
||||
'admin.collab.chat.subtitle': 'Wiadomości w czasie rzeczywistym',
|
||||
'admin.collab.notes.title': 'Notatki',
|
||||
'admin.collab.notes.subtitle': 'Wspólne notatki i dokumenty',
|
||||
'admin.collab.polls.title': 'Ankiety',
|
||||
'admin.collab.polls.subtitle': 'Ankiety grupowe i głosowania',
|
||||
'admin.collab.whatsnext.title': 'Co dalej',
|
||||
'admin.collab.whatsnext.subtitle': 'Sugestie aktywności i następne kroki',
|
||||
'admin.tabs.config': 'Personalizacja',
|
||||
'admin.tabs.defaults': 'Domyślne ustawienia',
|
||||
'admin.defaultSettings.title': 'Domyślne ustawienia użytkownika',
|
||||
'admin.defaultSettings.description': 'Ustaw domyślne wartości dla całej instancji. Użytkownicy, którzy nie zmienili ustawienia, zobaczą te wartości. Ich własne zmiany zawsze mają pierwszeństwo.',
|
||||
'admin.defaultSettings.saved': 'Domyślne zapisane',
|
||||
'admin.defaultSettings.reset': 'Przywróć wbudowaną wartość domyślną',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'przywróć',
|
||||
'admin.tabs.templates': 'Szablony pakowania',
|
||||
'admin.packingTemplates.title': 'Szablony pakowania',
|
||||
'admin.packingTemplates.subtitle': 'Twórz szablony list pakowania do wielokrotnego użycia dla swoich podróży',
|
||||
@@ -801,6 +832,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.transports': 'Transport',
|
||||
'trip.tabs.reservations': 'Rezerwacje',
|
||||
'trip.tabs.reservationsShort': 'Rezerwacje',
|
||||
'trip.tabs.packing': 'Lista pakowania',
|
||||
@@ -822,6 +854,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Rezerwacja została dodana',
|
||||
'trip.toast.deleted': 'Usunięto',
|
||||
'trip.confirm.deletePlace': 'Czy na pewno chcesz usunąć to miejsce?',
|
||||
'trip.confirm.deletePlaces': 'Usunąć {count} miejsc?',
|
||||
'trip.toast.placesDeleted': '{count} miejsc usunięto',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Brak miejsc zaplanowanych na ten dzień',
|
||||
@@ -866,6 +900,17 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Import nie powiódł się',
|
||||
'places.importAllSkipped': 'Wszystkie miejsca były już w podróży.',
|
||||
'places.gpxImported': '{count} miejsc zaimportowanych z GPX',
|
||||
'places.gpxImportTypes': 'Co chcesz zaimportować?',
|
||||
'places.gpxImportWaypoints': 'Punkty trasy',
|
||||
'places.gpxImportRoutes': 'Trasy',
|
||||
'places.gpxImportTracks': 'Trasy GPS (ze śladem)',
|
||||
'places.gpxImportNoneSelected': 'Wybierz co najmniej jeden typ do importu.',
|
||||
'places.kmlImportTypes': 'Co chcesz zaimportować?',
|
||||
'places.kmlImportPoints': 'Punkty (Placemarks)',
|
||||
'places.kmlImportPaths': 'Ścieżki (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Wybierz co najmniej jeden typ.',
|
||||
'places.selectionCount': '{count} zaznaczono',
|
||||
'places.deleteSelected': 'Usuń wybrane',
|
||||
'places.kmlKmzImported': 'Zaimportowano {count} miejsc z KMZ/KML',
|
||||
'places.urlResolved': 'Miejsce zaimportowane z URL',
|
||||
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Zaimportowano: {created} • Pominięto: {skipped}',
|
||||
@@ -873,6 +918,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Do którego dnia dodać?',
|
||||
'places.all': 'Wszystkie',
|
||||
'places.unplanned': 'Niezaplanowane',
|
||||
'places.filterTracks': 'Trasy',
|
||||
'places.search': 'Szukaj miejsc...',
|
||||
'places.allCategories': 'Wszystkie kategorie',
|
||||
'places.categoriesSelected': 'kategorii',
|
||||
@@ -959,6 +1005,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.platform': 'Peron',
|
||||
'reservations.meta.seat': 'Miejsce',
|
||||
'reservations.meta.checkIn': 'Zameldowanie',
|
||||
'reservations.meta.checkInUntil': 'Check-in do',
|
||||
'reservations.meta.checkOut': 'Wymeldowanie',
|
||||
'reservations.meta.linkAccommodation': 'Zakwaterowanie',
|
||||
'reservations.meta.pickAccommodation': 'Link do zakwaterowania',
|
||||
@@ -973,6 +1020,15 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.type.restaurant': 'Restauracja',
|
||||
'reservations.type.train': 'Pociąg',
|
||||
'reservations.type.car': 'Samochód',
|
||||
'reservations.needsReview': 'Sprawdź',
|
||||
'reservations.needsReviewHint': 'Nie udało się automatycznie dopasować lotniska — potwierdź lokalizację.',
|
||||
'reservations.searchLocation': 'Szukaj stacji, portu, adresu...',
|
||||
'airport.searchPlaceholder': 'Kod lotniska lub miasto (np. FRA)',
|
||||
'map.connections': 'Połączenia',
|
||||
'map.showConnections': 'Pokaż trasy rezerwacji',
|
||||
'map.hideConnections': 'Ukryj trasy rezerwacji',
|
||||
'settings.bookingLabels': 'Etykiety tras rezerwacji',
|
||||
'settings.bookingLabelsHint': 'Pokazuje nazwy stacji / lotnisk na mapie. Gdy wyłączone, wyświetlana jest tylko ikona.',
|
||||
'reservations.type.cruise': 'Rejs',
|
||||
'reservations.type.event': 'Wydarzenie',
|
||||
'reservations.type.tour': 'Wycieczka',
|
||||
@@ -1033,6 +1089,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Koniec',
|
||||
'reservations.span.ongoing': 'W trakcie',
|
||||
'reservations.validation.endBeforeStart': 'Data/godzina zakończenia musi być późniejsza niż data/godzina rozpoczęcia',
|
||||
'reservations.addBooking': 'Dodaj rezerwację',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budżet',
|
||||
@@ -1441,6 +1498,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'Najpierw dodaj miejsca do swojej podróży',
|
||||
'day.allDays': 'Wszystkie',
|
||||
'day.checkIn': 'Zameldowanie',
|
||||
'day.checkInUntil': 'Do',
|
||||
'day.checkOut': 'Wymeldowanie',
|
||||
'day.confirmation': 'Potwierdzenie',
|
||||
'day.editAccommodation': 'Edytuj zakwaterowanie',
|
||||
@@ -1467,6 +1525,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'Hasło',
|
||||
'memories.providerOTP': 'Kod MFA (jeśli włączony)',
|
||||
'memories.skipSSLVerification': 'Pomiń weryfikację certyfikatu SSL',
|
||||
'memories.immichAutoUpload': 'Przy przesyłaniu kopiuj zdjęcia journey także do Immich',
|
||||
'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Test',
|
||||
'memories.connected': 'Połączono',
|
||||
@@ -1563,6 +1622,9 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'collab.polls.delete': 'Usuń',
|
||||
'collab.polls.closedSection': 'Zamknięte',
|
||||
'common.import': 'Importuj',
|
||||
'common.select': 'Wybierz',
|
||||
'common.selectAll': 'Zaznacz wszystko',
|
||||
'common.deselectAll': 'Odznacz wszystko',
|
||||
'common.saved': 'Zapisano',
|
||||
'trips.reminder': 'Przypomnienie',
|
||||
'trips.reminderNone': 'Brak',
|
||||
@@ -1597,22 +1659,29 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Wysyłanie testowego webhooka nie powiodło się',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Webhook admina wysyła automatycznie, gdy URL jest skonfigurowany',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': 'Pozwala użytkownikom skonfigurować własne tematy ntfy dla powiadomień push. Ustaw domyślny serwer poniżej, aby wstępnie wypełnić ustawienia użytkownika.',
|
||||
'admin.notifications.testNtfy': 'Wyślij testowe Ntfy',
|
||||
'admin.notifications.testNtfySuccess': 'Testowe Ntfy wysłane pomyślnie',
|
||||
'admin.notifications.testNtfyFailed': 'Wysyłanie testowego Ntfy nie powiodło się',
|
||||
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.hint': 'Ten temat Ntfy jest używany wyłącznie do powiadomień admina (np. alertów o wersjach). Jest niezależny od tematów użytkowników i zawsze wysyła po skonfigurowaniu.',
|
||||
'admin.notifications.adminNtfyPanel.serverLabel': 'URL serwera Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.serverHint': 'Używany również jako domyślny serwer dla powiadomień ntfy użytkowników. Pozostaw puste, aby użyć ntfy.sh. Użytkownicy mogą to nadpisać w swoich ustawieniach.',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': 'Temat admina',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token dostępu (opcjonalne)',
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': 'Token dostępu admina wyczyszczony',
|
||||
'admin.notifications.adminNtfyPanel.saved': 'Ustawienia admin Ntfy zapisane',
|
||||
'admin.notifications.adminNtfyPanel.test': 'Wyślij testowe Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess': 'Testowe Ntfy wysłane pomyślnie',
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Wysyłanie testowego Ntfy nie powiodło się',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy zawsze wysyła po skonfigurowaniu tematu',
|
||||
'admin.notifications.adminNotificationsHint': 'Skonfiguruj, które kanały dostarczają powiadomienia admina (np. alerty o wersjach). Webhook wysyła automatycznie, gdy ustawiony jest URL webhooka admina.',
|
||||
'admin.notifications.tripReminders.title': 'Przypomnienia o podróżach',
|
||||
'admin.notifications.tripReminders.hint': 'Wysyła powiadomienie z przypomnieniem przed rozpoczęciem podróży (wymaga ustawienia dni przypomnienia dla podróży).',
|
||||
'admin.notifications.tripReminders.enabled': 'Przypomnienia o podróżach włączone',
|
||||
'admin.notifications.tripReminders.disabled': 'Przypomnienia o podróżach wyłączone',
|
||||
'admin.webhook.hint': 'Pozwól użytkownikom konfigurować własne adresy URL webhooka dla powiadomień (Discord, Slack itp.).',
|
||||
'settings.notificationsDisabled': 'Powiadomienia nie są skonfigurowane.',
|
||||
'settings.notificationPreferences.noChannels': 'Brak skonfigurowanych kanałów powiadomień. Poproś administratora o skonfigurowanie powiadomień e-mail lub webhook.',
|
||||
@@ -1634,7 +1703,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.ntfyUrl.test': 'Testuj',
|
||||
'settings.ntfyUrl.testSuccess': 'Testowe powiadomienie Ntfy wysłane pomyślnie',
|
||||
'settings.ntfyUrl.testFailed': 'Testowe powiadomienie Ntfy nie powiodło się',
|
||||
'settings.ntfyUrl.clearToken': 'Wyczyść',
|
||||
'settings.ntfyUrl.tokenCleared': 'Token dostępu wyczyszczony',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
@@ -1730,6 +1798,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Kolejność zmieniona',
|
||||
'undo.optimize': 'Trasa zoptymalizowana',
|
||||
'undo.deletePlace': 'Miejsce usunięte',
|
||||
'undo.deletePlaces': 'Miejsca usunięte',
|
||||
'undo.moveDay': 'Miejsce przeniesione',
|
||||
'undo.lock': 'Blokada przełączona',
|
||||
'undo.importGpx': 'Import GPX',
|
||||
@@ -1782,7 +1851,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'todo.unassigned': 'Nieprzypisane',
|
||||
'todo.noCategory': 'Brak kategorii',
|
||||
'todo.hasDescription': 'Ma opis',
|
||||
'todo.addItem': 'Dodaj nowe zadanie...',
|
||||
'todo.addItem': 'Nowe zadanie',
|
||||
'todo.sidebar.sortBy': 'Sortuj wg',
|
||||
'todo.priority': 'Priorytet',
|
||||
'todo.newCategoryLabel': 'nowa',
|
||||
'budget.categoriesLabel': 'kategorie',
|
||||
'todo.newCategory': 'Nazwa kategorii',
|
||||
'todo.addCategory': 'Dodaj kategorię',
|
||||
'todo.newItem': 'Nowe zadanie',
|
||||
@@ -1847,6 +1920,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.saveRouteNotConfigured': 'Trasa zapisu nie jest skonfigurowana dla tego dostawcy',
|
||||
'memories.testRouteNotConfigured': 'Trasa testowa nie jest skonfigurowana dla tego dostawcy',
|
||||
'memories.fillRequiredFields': 'Proszę wypełnić wszystkie wymagane pola',
|
||||
'journey.search.placeholder': 'Szukaj podróży…',
|
||||
'journey.search.noResults': 'Brak podróży pasujących do „{query}"',
|
||||
'journey.title': 'Dziennik podróży',
|
||||
'journey.subtitle': 'Dokumentuj swoje podróże na bieżąco',
|
||||
'journey.new': 'Nowy dziennik podróży',
|
||||
@@ -1868,6 +1943,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Aktywny',
|
||||
'journey.status.completed': 'Zakończony',
|
||||
'journey.status.upcoming': 'Nadchodzący',
|
||||
'journey.status.archived': 'Zarchiwizowano',
|
||||
'journey.checkin.add': 'Zamelduj się',
|
||||
'journey.checkin.namePlaceholder': 'Nazwa miejsca',
|
||||
'journey.checkin.notesPlaceholder': 'Notatki (opcjonalnie)',
|
||||
@@ -1944,6 +2020,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.verdict.couldBeBetter': 'Mogłoby być lepiej',
|
||||
'journey.synced.places': 'miejsca',
|
||||
'journey.synced.synced': 'zsynchronizowane',
|
||||
'journey.editor.discardChangesConfirm': 'Masz niezapisane zmiany. Odrzucić?',
|
||||
'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
|
||||
'journey.editor.uploading': 'Przesyłanie...',
|
||||
'journey.editor.fromGallery': 'Z galerii',
|
||||
@@ -2021,6 +2098,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Nazwa',
|
||||
'journey.settings.subtitle': 'Podtytuł',
|
||||
'journey.settings.subtitlePlaceholder': 'np. Tajlandia, Wietnam i Kambodża',
|
||||
'journey.settings.endJourney': 'Archiwizuj podróż',
|
||||
'journey.settings.reopenJourney': 'Przywróć podróż',
|
||||
'journey.settings.archived': 'Podróż zarchiwizowana',
|
||||
'journey.settings.reopened': 'Podróż wznowiona',
|
||||
'journey.settings.endDescription': 'Ukrywa odznakę Na żywo. Możesz wznowić w dowolnym momencie.',
|
||||
'journey.settings.delete': 'Usuń',
|
||||
'journey.settings.deleteJourney': 'Usuń dziennik podróży',
|
||||
'journey.settings.deleteMessage': 'Usunąć „{title}"? Wszystkie wpisy i zdjęcia zostaną utracone.',
|
||||
@@ -2156,6 +2238,55 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Wyszukuj miejsca, rozwiązuj adresy URL map i odwrotnie geokoduj współrzędne',
|
||||
'oauth.scope.weather:read.label': 'Prognozy pogody',
|
||||
'oauth.scope.weather:read.description': 'Pobieraj prognozy pogody dla miejsc i dat podróży',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Witaj w TREK',
|
||||
'system_notice.welcome_v1.body': 'Twój kompleksowy planer podróży. Twórz trasy, dziel się wycieczkami ze znajomymi i bądź zorganizowany — online i offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Zaplanuj podróż',
|
||||
'system_notice.welcome_v1.hero_alt': 'Malownicze miejsce z interfejsem planowania TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Trasy dzień po dniu',
|
||||
'system_notice.welcome_v1.highlight_share': 'Współpraca z partnerami podróży',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Działa offline na telefonie',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Poprzednie powiadomienie',
|
||||
'system_notice.pager.next': 'Następne powiadomienie',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Przejdź do powiadomienia {n}',
|
||||
'system_notice.pager.position': 'Powiadomienie {current} z {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Zdjęcia zostały przeniesione w 3.0',
|
||||
'system_notice.v3_photos.body': '**Zdjęcia** w Planerze Podróży zostały usunięte. Twoje zdjęcia są bezpieczne — TREK nigdy nie modyfikował Twojej biblioteki Immich lub Synology.\n\nZdjęcia są teraz dostępne w dodatku **Journey**. Journey jest opcjonalny — jeśli jeszcze nie jest dostępny, poproś administratora o jego włączenie w Admin → Dodatki.',
|
||||
'system_notice.v3_journey.title': 'Poznaj Journey — dziennik podróży',
|
||||
'system_notice.v3_journey.body': 'Dokumentuj swoje podróże jako bogatrze opowieści z osami czasu, galeriami i mapami interaktywnymi.',
|
||||
'system_notice.v3_journey.cta_label': 'Otwórz Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Dzienna oś czasu i galeria',
|
||||
'system_notice.v3_journey.highlight_photos': 'Import z Immich lub Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Udostępnij publicznie — bez logowania',
|
||||
'system_notice.v3_journey.highlight_export': 'Eksportuj jako książkę fotograficzną PDF',
|
||||
'system_notice.v3_features.title': 'Więcej nowości w 3.0',
|
||||
'system_notice.v3_features.body': 'Kilka innych rzeczy wartych uwagi w tym wydaniu.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Przeprojektowany pulpit mobile-first',
|
||||
'system_notice.v3_features.highlight_offline': 'Pełny tryb offline jako PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autouzupełnianie wyszukiwania miejsc',
|
||||
'system_notice.v3_features.highlight_import': 'Import miejsc z plików KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: aktualizacja OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'Integracja MCP została całkowicie przeprojektowana. OAuth 2.1 jest teraz zalecaną metodą uwierzytelniania. Statyczne tokeny (trek_…) są przestarzałe i zostaną usunięte w przyszłej wersji.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 zalecany (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 szczegółowe zakresy uprawnień',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statyczne tokeny trek_ przestarzałe',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Rozszerzony zestaw narzędzi i promptów',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Osobiste słowo ode mnie',
|
||||
'system_notice.v3_thankyou.body': 'Zanim pójdziesz dalej — chcę się na chwilę zatrzymać.\n\nTREK zaczął się jako poboczny projekt, który zbudowałem na własne podróże. Nigdy nie wyobrażałem sobie, że wyrośnie na coś, czemu 4000 z was ufa przy planowaniu swoich przygód. Każda gwiazdka, każdy issue, każda prośba o funkcję — czytam je wszystkie i to one trzymają mnie na nogach podczas późnych nocy między pracą na pełny etat a uczelnią.\n\nChcę, żebyście wiedzieli: TREK zawsze będzie open source, zawsze self-hosted, zawsze wasz. Bez śledzenia, bez subskrypcji, bez haczyków. Po prostu narzędzie zbudowane przez kogoś, kto kocha podróżowanie tak samo jak wy.\n\nSzczególne podziękowania dla [jubnl](https://github.com/jubnl) — stałeś się niesamowitym współpracownikiem. Tak wiele z tego, co czyni wersję 3.0 wspaniałą, nosi twój ślad. Dziękuję, że uwierzyłeś w ten projekt, gdy był jeszcze surowy.\n\nI każdemu z was, kto zgłosił błąd, przetłumaczył tekst, podzielił się TREK z przyjacielem lub po prostu użył go do zaplanowania podróży — **dziękuję**. To wy jesteście powodem, dla którego to istnieje.\n\nZa wiele kolejnych wspólnych przygód.\n\n— Maurice\n\n---\n\n[Dołącz do społeczności na Discordzie](https://discord.gg/7Q6M6jDwzf)\n\nJeśli TREK sprawia, że Twoje podróże są lepsze, [mała kawa](https://ko-fi.com/mauriceboe) zawsze pomaga utrzymać światła włączone.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transport',
|
||||
'transport.addManual': 'Ręczny transport',
|
||||
}
|
||||
|
||||
export default pl
|
||||
|
||||
@@ -4,11 +4,15 @@ const ru: Record<string, string> = {
|
||||
'common.showMore': 'Показать больше',
|
||||
'common.showLess': 'Показать меньше',
|
||||
'common.cancel': 'Отмена',
|
||||
'common.clear': 'Очистить',
|
||||
'common.delete': 'Удалить',
|
||||
'common.edit': 'Редактировать',
|
||||
'common.add': 'Добавить',
|
||||
'common.loading': 'Загрузка...',
|
||||
'common.import': 'Импорт',
|
||||
'common.select': 'Выбрать',
|
||||
'common.selectAll': 'Выбрать всё',
|
||||
'common.deselectAll': 'Снять выделение со всех',
|
||||
'common.error': 'Ошибка',
|
||||
'common.unknownError': 'Неизвестная ошибка',
|
||||
'common.tooManyAttempts': 'Слишком много попыток. Попробуйте позже.',
|
||||
@@ -307,6 +311,16 @@ const ru: Record<string, string> = {
|
||||
'settings.about.featureRequest': 'Предложить функцию',
|
||||
'settings.about.featureRequestHint': 'Предложите новую функцию',
|
||||
'settings.about.wikiHint': 'Документация и руководства',
|
||||
'settings.about.supporters.badge': 'Ежемесячные спонсоры',
|
||||
'settings.about.supporters.title': 'Спутники TREK',
|
||||
'settings.about.supporters.subtitle': 'Пока ты планируешь следующий маршрут, эти люди планируют вместе со мной будущее TREK. Их ежемесячный взнос идёт напрямую в разработку и реально потраченные часы — чтобы TREK оставался Open Source.',
|
||||
'settings.about.supporters.since': 'спонсор с {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Стань первым',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK — это самостоятельно размещаемый планировщик путешествий, который помогает организовать поездки от первой идеи до последнего воспоминания. Планирование по дням, бюджет, списки вещей, фото и многое другое — всё в одном месте, на вашем собственном сервере.',
|
||||
'settings.about.madeWith': 'Сделано с',
|
||||
'settings.about.madeBy': 'Морисом и растущим open-source сообществом.',
|
||||
@@ -545,9 +559,29 @@ const ru: Record<string, string> = {
|
||||
'admin.fileTypesFormat': 'Расширения через запятую (напр. jpg,png,pdf,doc). Используйте * для разрешения всех типов.',
|
||||
'admin.fileTypesSaved': 'Настройки типов файлов сохранены',
|
||||
|
||||
'admin.placesPhotos.title': 'Фотографии мест',
|
||||
'admin.placesPhotos.subtitle': 'Загрузка фотографий из Google Places API. Отключите для экономии квоты API. Фотографии Wikimedia не затронуты.',
|
||||
'admin.placesAutocomplete.title': 'Автодополнение мест',
|
||||
'admin.placesAutocomplete.subtitle': 'Использование Google Places API для поисковых подсказок. Отключите для экономии квоты API.',
|
||||
'admin.placesDetails.title': 'Сведения о месте',
|
||||
'admin.placesDetails.subtitle': 'Загрузка подробной информации о месте (часы работы, рейтинг, веб-сайт) из Google Places API. Отключите для экономии квоты API.',
|
||||
'admin.bagTracking.title': 'Отслеживание багажа',
|
||||
'admin.bagTracking.subtitle': 'Включить вес и привязку к багажу для вещей',
|
||||
'admin.collab.chat.title': 'Чат',
|
||||
'admin.collab.chat.subtitle': 'Обмен сообщениями для совместной работы',
|
||||
'admin.collab.notes.title': 'Заметки',
|
||||
'admin.collab.notes.subtitle': 'Общие заметки и документы',
|
||||
'admin.collab.polls.title': 'Опросы',
|
||||
'admin.collab.polls.subtitle': 'Групповые опросы и голосования',
|
||||
'admin.collab.whatsnext.title': 'Что дальше',
|
||||
'admin.collab.whatsnext.subtitle': 'Предложения активностей и следующие шаги',
|
||||
'admin.tabs.config': 'Персонализация',
|
||||
'admin.tabs.defaults': 'Настройки по умолчанию',
|
||||
'admin.defaultSettings.title': 'Настройки пользователей по умолчанию',
|
||||
'admin.defaultSettings.description': 'Задайте значения по умолчанию для всего экземпляра. Пользователи, не изменившие параметр, увидят эти значения. Их собственные изменения всегда имеют приоритет.',
|
||||
'admin.defaultSettings.saved': 'Значение по умолчанию сохранено',
|
||||
'admin.defaultSettings.reset': 'Сбросить до встроенного значения',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'сбросить',
|
||||
'admin.tabs.templates': 'Шаблоны упаковки',
|
||||
'admin.packingTemplates.title': 'Шаблоны упаковки',
|
||||
'admin.packingTemplates.subtitle': 'Создавайте многоразовые списки вещей для поездок',
|
||||
@@ -833,6 +867,7 @@ const ru: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'План',
|
||||
'trip.tabs.transports': 'Транспорт',
|
||||
'trip.tabs.reservations': 'Бронирования',
|
||||
'trip.tabs.reservationsShort': 'Брони',
|
||||
'trip.tabs.packing': 'Список вещей',
|
||||
@@ -855,6 +890,8 @@ const ru: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': 'Бронирование добавлено',
|
||||
'trip.toast.deleted': 'Удалено',
|
||||
'trip.confirm.deletePlace': 'Вы уверены, что хотите удалить это место?',
|
||||
'trip.confirm.deletePlaces': 'Удалить {count} мест?',
|
||||
'trip.toast.placesDeleted': '{count} мест удалено',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'На этот день мест не запланировано',
|
||||
@@ -899,6 +936,17 @@ const ru: Record<string, string> = {
|
||||
'places.importFileError': 'Ошибка импорта',
|
||||
'places.importAllSkipped': 'Все места уже были в поездке.',
|
||||
'places.gpxImported': '{count} мест импортировано из GPX',
|
||||
'places.gpxImportTypes': 'Что импортировать?',
|
||||
'places.gpxImportWaypoints': 'Путевые точки',
|
||||
'places.gpxImportRoutes': 'Маршруты',
|
||||
'places.gpxImportTracks': 'Треки (с геометрией пути)',
|
||||
'places.gpxImportNoneSelected': 'Выберите хотя бы один тип для импорта.',
|
||||
'places.kmlImportTypes': 'Что вы хотите импортировать?',
|
||||
'places.kmlImportPoints': 'Точки (Placemarks)',
|
||||
'places.kmlImportPaths': 'Маршруты (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Выберите хотя бы один тип.',
|
||||
'places.selectionCount': '{count} выбрано',
|
||||
'places.deleteSelected': 'Удалить выбранные',
|
||||
'places.kmlKmzImported': '{count} мест импортировано из KMZ/KML',
|
||||
'places.urlResolved': 'Место импортировано из URL',
|
||||
'places.importList': 'Импорт списка',
|
||||
@@ -915,6 +963,7 @@ const ru: Record<string, string> = {
|
||||
'places.assignToDay': 'Добавить в какой день?',
|
||||
'places.all': 'Все',
|
||||
'places.unplanned': 'Незапланированные',
|
||||
'places.filterTracks': 'Треки',
|
||||
'places.search': 'Поиск мест...',
|
||||
'places.allCategories': 'Все категории',
|
||||
'places.categoriesSelected': 'категорий',
|
||||
@@ -998,10 +1047,20 @@ const ru: Record<string, string> = {
|
||||
'reservations.meta.flightNumber': 'Номер рейса',
|
||||
'reservations.meta.from': 'Откуда',
|
||||
'reservations.meta.to': 'Куда',
|
||||
'reservations.needsReview': 'Проверить',
|
||||
'reservations.needsReviewHint': 'Аэропорт не удалось определить автоматически — подтвердите местоположение.',
|
||||
'reservations.searchLocation': 'Искать станцию, порт, адрес...',
|
||||
'airport.searchPlaceholder': 'Код аэропорта или город (напр. FRA)',
|
||||
'map.connections': 'Соединения',
|
||||
'map.showConnections': 'Показать маршруты бронирований',
|
||||
'map.hideConnections': 'Скрыть маршруты бронирований',
|
||||
'settings.bookingLabels': 'Подписи маршрутов бронирований',
|
||||
'settings.bookingLabelsHint': 'Отображает названия станций / аэропортов на карте. Если выключено, показывается только значок.',
|
||||
'reservations.meta.trainNumber': 'Номер поезда',
|
||||
'reservations.meta.platform': 'Платформа',
|
||||
'reservations.meta.seat': 'Место',
|
||||
'reservations.meta.checkIn': 'Заезд',
|
||||
'reservations.meta.checkInUntil': 'Заселение до',
|
||||
'reservations.meta.checkOut': 'Выезд',
|
||||
'reservations.meta.linkAccommodation': 'Жильё',
|
||||
'reservations.meta.pickAccommodation': 'Привязать к жилью',
|
||||
@@ -1015,7 +1074,7 @@ const ru: Record<string, string> = {
|
||||
'reservations.type.hotel': 'Жильё',
|
||||
'reservations.type.restaurant': 'Ресторан',
|
||||
'reservations.type.train': 'Поезд',
|
||||
'reservations.type.car': 'Аренда авто',
|
||||
'reservations.type.car': 'Автомобиль',
|
||||
'reservations.type.cruise': 'Круиз',
|
||||
'reservations.type.event': 'Мероприятие',
|
||||
'reservations.type.tour': 'Экскурсия',
|
||||
@@ -1076,6 +1135,7 @@ const ru: Record<string, string> = {
|
||||
'reservations.span.end': 'Конец',
|
||||
'reservations.span.ongoing': 'Продолжается',
|
||||
'reservations.validation.endBeforeStart': 'Дата/время окончания должны быть позже даты/времени начала',
|
||||
'reservations.addBooking': 'Добавить бронирование',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Бюджет',
|
||||
@@ -1486,6 +1546,7 @@ const ru: Record<string, string> = {
|
||||
'day.noPlacesForHotel': 'Сначала добавьте места в поездку',
|
||||
'day.allDays': 'Все',
|
||||
'day.checkIn': 'Заезд',
|
||||
'day.checkInUntil': 'До',
|
||||
'day.checkOut': 'Выезд',
|
||||
'day.confirmation': 'Подтверждение',
|
||||
'day.editAccommodation': 'Редактировать жильё',
|
||||
@@ -1512,6 +1573,7 @@ const ru: Record<string, string> = {
|
||||
'memories.providerPassword': 'Пароль',
|
||||
'memories.providerOTP': 'Код MFA (если включён)',
|
||||
'memories.skipSSLVerification': 'Пропустить проверку SSL-сертификата',
|
||||
'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке',
|
||||
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
|
||||
'memories.testConnection': 'Проверить подключение',
|
||||
'memories.testFirst': 'Сначала проверьте подключение',
|
||||
@@ -1673,6 +1735,7 @@ const ru: Record<string, string> = {
|
||||
'undo.reorder': 'Места переупорядочены',
|
||||
'undo.optimize': 'Маршрут оптимизирован',
|
||||
'undo.deletePlace': 'Место удалено',
|
||||
'undo.deletePlaces': 'Места удалены',
|
||||
'undo.moveDay': 'Место перемещено в другой день',
|
||||
'undo.lock': 'Блокировка места изменена',
|
||||
'undo.importGpx': 'Импорт GPX',
|
||||
@@ -1732,7 +1795,11 @@ const ru: Record<string, string> = {
|
||||
'todo.unassigned': 'Не назначено',
|
||||
'todo.noCategory': 'Без категории',
|
||||
'todo.hasDescription': 'Есть описание',
|
||||
'todo.addItem': 'Добавить новую задачу...',
|
||||
'todo.addItem': 'Новая задача',
|
||||
'todo.sidebar.sortBy': 'Сортировать по',
|
||||
'todo.priority': 'Приоритет',
|
||||
'todo.newCategoryLabel': 'новая',
|
||||
'budget.categoriesLabel': 'категорий',
|
||||
'todo.newCategory': 'Название категории',
|
||||
'todo.addCategory': 'Добавить категорию',
|
||||
'todo.newItem': 'Новая задача',
|
||||
@@ -1774,7 +1841,6 @@ const ru: Record<string, string> = {
|
||||
'settings.ntfyUrl.test': 'Тест',
|
||||
'settings.ntfyUrl.testSuccess': 'Тестовое уведомление Ntfy успешно отправлено',
|
||||
'settings.ntfyUrl.testFailed': 'Ошибка отправки тестового уведомления Ntfy',
|
||||
'settings.ntfyUrl.clearToken': 'Очистить',
|
||||
'settings.ntfyUrl.tokenCleared': 'Токен доступа очищен',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
@@ -1791,22 +1857,29 @@ const ru: Record<string, string> = {
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Ошибка тестового вебхука',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Вебхук администратора отправляется автоматически при наличии URL',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': 'Позволяет пользователям настраивать собственные темы ntfy для push-уведомлений. Установите сервер по умолчанию ниже, чтобы предварительно заполнить настройки пользователей.',
|
||||
'admin.notifications.testNtfy': 'Отправить тестовое Ntfy',
|
||||
'admin.notifications.testNtfySuccess': 'Тестовое Ntfy успешно отправлено',
|
||||
'admin.notifications.testNtfyFailed': 'Ошибка отправки тестового Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.title': 'Ntfy администратора',
|
||||
'admin.notifications.adminNtfyPanel.hint': 'Эта тема Ntfy используется исключительно для уведомлений администратора (например, оповещения о версиях). Она независима от тем пользователей и всегда отправляется при наличии настройки.',
|
||||
'admin.notifications.adminNtfyPanel.serverLabel': 'URL сервера Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.serverHint': 'Также используется как сервер по умолчанию для ntfy-уведомлений пользователей. Оставьте пустым, чтобы использовать ntfy.sh. Пользователи могут изменить это в своих настройках.',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': 'Тема администратора',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': 'Токен доступа (необязательно)',
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': 'Токен доступа администратора очищен',
|
||||
'admin.notifications.adminNtfyPanel.saved': 'Настройки Ntfy администратора сохранены',
|
||||
'admin.notifications.adminNtfyPanel.test': 'Отправить тестовое Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess': 'Тестовое Ntfy успешно отправлено',
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Ошибка отправки тестового Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Ntfy администратора всегда отправляется при наличии настроенной темы',
|
||||
'admin.notifications.adminNotificationsHint': 'Настройте, какие каналы доставляют уведомления администратора (например, оповещения о версиях). Вебхук отправляется автоматически, если задан URL вебхука администратора.',
|
||||
'admin.notifications.tripReminders.title': 'Напоминания о поездках',
|
||||
'admin.notifications.tripReminders.hint': 'Отправляет напоминание перед началом поездки (необходимо указать дни напоминания в параметрах поездки).',
|
||||
'admin.notifications.tripReminders.enabled': 'Напоминания о поездках включены',
|
||||
'admin.notifications.tripReminders.disabled': 'Напоминания о поездках отключены',
|
||||
'admin.tabs.notifications': 'Уведомления',
|
||||
'notifications.versionAvailable.title': 'Доступно обновление',
|
||||
'notifications.versionAvailable.text': 'TREK {version} теперь доступен.',
|
||||
@@ -1854,6 +1927,8 @@ const ru: Record<string, string> = {
|
||||
'memories.saveRouteNotConfigured': 'Маршрут сохранения не настроен для этого провайдера',
|
||||
'memories.testRouteNotConfigured': 'Маршрут тестирования не настроен для этого провайдера',
|
||||
'memories.fillRequiredFields': 'Пожалуйста, заполните все обязательные поля',
|
||||
'journey.search.placeholder': 'Поиск путешествий…',
|
||||
'journey.search.noResults': 'Путешествий по запросу «{query}» не найдено',
|
||||
'journey.title': 'Путешествие',
|
||||
'journey.subtitle': 'Отслеживайте свои путешествия в реальном времени',
|
||||
'journey.new': 'Новое путешествие',
|
||||
@@ -1875,6 +1950,7 @@ const ru: Record<string, string> = {
|
||||
'journey.status.active': 'Активно',
|
||||
'journey.status.completed': 'Завершено',
|
||||
'journey.status.upcoming': 'Предстоящее',
|
||||
'journey.status.archived': 'В архиве',
|
||||
'journey.checkin.add': 'Отметиться',
|
||||
'journey.checkin.namePlaceholder': 'Название места',
|
||||
'journey.checkin.notesPlaceholder': 'Заметки (необязательно)',
|
||||
@@ -1951,6 +2027,7 @@ const ru: Record<string, string> = {
|
||||
'journey.verdict.couldBeBetter': 'Могло быть лучше',
|
||||
'journey.synced.places': 'мест',
|
||||
'journey.synced.synced': 'синхронизировано',
|
||||
'journey.editor.discardChangesConfirm': 'У вас есть несохранённые изменения. Отменить?',
|
||||
'journey.editor.uploadPhotos': 'Загрузить фото',
|
||||
'journey.editor.uploading': 'Загрузка...',
|
||||
'journey.editor.fromGallery': 'Из галереи',
|
||||
@@ -2028,6 +2105,11 @@ const ru: Record<string, string> = {
|
||||
'journey.settings.name': 'Название',
|
||||
'journey.settings.subtitle': 'Подзаголовок',
|
||||
'journey.settings.subtitlePlaceholder': 'напр. Таиланд, Вьетнам и Камбоджа',
|
||||
'journey.settings.endJourney': 'Архивировать путешествие',
|
||||
'journey.settings.reopenJourney': 'Восстановить путешествие',
|
||||
'journey.settings.archived': 'Путешествие архивировано',
|
||||
'journey.settings.reopened': 'Путешествие возобновлено',
|
||||
'journey.settings.endDescription': 'Скрывает значок «В эфире». Вы можете возобновить в любое время.',
|
||||
'journey.settings.delete': 'Удалить',
|
||||
'journey.settings.deleteJourney': 'Удалить путешествие',
|
||||
'journey.settings.deleteMessage': 'Удалить «{title}»? Все записи и фото будут потеряны.',
|
||||
@@ -2163,6 +2245,55 @@ const ru: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': 'Поиск мест, разрешение URL карт и обратное геокодирование координат',
|
||||
'oauth.scope.weather:read.label': 'Прогнозы погоды',
|
||||
'oauth.scope.weather:read.description': 'Получение прогнозов погоды для мест и дат поездки',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Добро пожаловать в TREK',
|
||||
'system_notice.welcome_v1.body': 'Ваш универсальный планировщик путешествий. Создавайте маршруты, делитесь поездками с друзьями и оставайтесь организованными — онлайн и офлайн.',
|
||||
'system_notice.welcome_v1.cta_label': 'Спланировать поездку',
|
||||
'system_notice.welcome_v1.hero_alt': 'Живописное место назначения с интерфейсом TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Маршруты по дням',
|
||||
'system_notice.welcome_v1.highlight_share': 'Совместное планирование с партнёрами',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Работает офлайн на мобильном',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Предыдущее уведомление',
|
||||
'system_notice.pager.next': 'Следующее уведомление',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Перейти к уведомлению {n}',
|
||||
'system_notice.pager.position': 'Уведомление {current} из {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Фото перемещены в версии 3.0',
|
||||
'system_notice.v3_photos.body': 'Вкладка **Фото** в Планировщике путешествий удалена. Ваши фото в безопасности — TREK никогда не изменял вашу библиотеку Immich или Synology.\n\nФото теперь доступны в дополнении **Journey**. Journey необязателен — если он ещё недоступен, попросите администратора включить его в разделе Admin → Дополнения.',
|
||||
'system_notice.v3_journey.title': 'Знакомьтесь с Journey',
|
||||
'system_notice.v3_journey.body': 'Документируйте путешествия в виде рассказов с хронологиями, фотогалереями и интерактивными картами.',
|
||||
'system_notice.v3_journey.cta_label': 'Открыть Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Ежедневная хронология и галерея',
|
||||
'system_notice.v3_journey.highlight_photos': 'Импорт из Immich или Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Общий доступ — без входа',
|
||||
'system_notice.v3_journey.highlight_export': 'Экспорт в PDF-фотокнигу',
|
||||
'system_notice.v3_features.title': 'Ещё нового в версии 3.0',
|
||||
'system_notice.v3_features.body': 'Несколько других важных новшеств в этом релизе.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Переработанная панель в mobile-first стиле',
|
||||
'system_notice.v3_features.highlight_offline': 'Полный офлайн-режим как PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Автодополнение поиска мест в реальном времени',
|
||||
'system_notice.v3_features.highlight_import': 'Импорт мест из KMZ/KML-файлов',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: обновление OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'Интеграция MCP была полностью переработана. OAuth 2.1 теперь является рекомендуемым методом аутентификации. Статические токены (trek_…) устарели и будут удалены в будущей версии.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 рекомендуется (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 детальных области разрешений',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Статические токены trek_ устарели',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Расширенный набор инструментов',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Личное слово от меня',
|
||||
'system_notice.v3_thankyou.body': 'Прежде чем продолжить — хочу остановиться на мгновение.\n\nTREK начинался как сторонний проект, который я создал для собственных поездок. Я никогда не думал, что он вырастет во что-то, чему 4 000 из вас доверяют планирование своих приключений. Каждая звёздочка, каждый issue, каждый запрос на фичу — я читаю их все, и именно они поддерживают меня в поздние ночи между основной работой и университетом.\n\nХочу, чтобы вы знали: TREK всегда будет open source, всегда self-hosted, всегда вашим. Никакого отслеживания, никаких подписок, никаких подвохов. Просто инструмент, созданный человеком, который любит путешествовать так же, как и вы.\n\nОсобая благодарность [jubnl](https://github.com/jubnl) — ты стал невероятным соратником. Многое из того, что делает версию 3.0 великолепной, несёт твой отпечаток. Спасибо, что поверил в этот проект, когда он был ещё сырым.\n\nИ каждому из вас, кто сообщил об ошибке, перевёл строку, поделился TREK с другом или просто использовал его для планирования поездки — **спасибо**. Вы — причина, по которой всё это существует.\n\nЗа множество новых приключений вместе.\n\n— Maurice\n\n---\n\n[Присоединяйся к сообществу в Discord](https://discord.gg/7Q6M6jDwzf)\n\nЕсли TREK делает твои путешествия лучше, [маленький кофе](https://ko-fi.com/mauriceboe) всегда помогает держать свет включённым.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Транспорт',
|
||||
'transport.addManual': 'Ручной транспорт',
|
||||
}
|
||||
|
||||
export default ru
|
||||
|
||||
@@ -4,11 +4,15 @@ const zh: Record<string, string> = {
|
||||
'common.showMore': '显示更多',
|
||||
'common.showLess': '收起',
|
||||
'common.cancel': '取消',
|
||||
'common.clear': '清除',
|
||||
'common.delete': '删除',
|
||||
'common.edit': '编辑',
|
||||
'common.add': '添加',
|
||||
'common.loading': '加载中...',
|
||||
'common.import': '导入',
|
||||
'common.select': '选择',
|
||||
'common.selectAll': '全选',
|
||||
'common.deselectAll': '取消全选',
|
||||
'common.error': '错误',
|
||||
'common.unknownError': '未知错误',
|
||||
'common.tooManyAttempts': '尝试次数过多,请稍后再试。',
|
||||
@@ -307,6 +311,16 @@ const zh: Record<string, string> = {
|
||||
'settings.about.featureRequest': '功能建议',
|
||||
'settings.about.featureRequestHint': '建议一个新功能',
|
||||
'settings.about.wikiHint': '文档和指南',
|
||||
'settings.about.supporters.badge': '月度支持者',
|
||||
'settings.about.supporters.title': '与 TREK 同行的伙伴',
|
||||
'settings.about.supporters.subtitle': '当你在规划下一段路线时,这些人也在一起规划 TREK 的未来。他们每月的支持直接用于开发与真实投入的时间——让 TREK 保持开源。',
|
||||
'settings.about.supporters.since': '{date} 起的支持者',
|
||||
'settings.about.supporters.tierEmpty': '成为第一个',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK 是一个自托管的旅行规划工具,帮助你从最初的想法到最后的回忆,全程组织你的旅行。日程规划、预算、行李清单、照片等——一切尽在一处,在你自己的服务器上。',
|
||||
'settings.about.madeWith': '用',
|
||||
'settings.about.madeBy': '由 Maurice 和不断壮大的开源社区打造。',
|
||||
@@ -545,9 +559,29 @@ const zh: Record<string, string> = {
|
||||
'admin.fileTypesFormat': '以逗号分隔的扩展名(如 jpg,png,pdf,doc)。使用 * 允许所有类型。',
|
||||
'admin.fileTypesSaved': '文件类型设置已保存',
|
||||
|
||||
'admin.placesPhotos.title': '地点照片',
|
||||
'admin.placesPhotos.subtitle': '从 Google Places API 获取照片。禁用可节省 API 配额。Wikimedia 照片不受影响。',
|
||||
'admin.placesAutocomplete.title': '地点自动补全',
|
||||
'admin.placesAutocomplete.subtitle': '使用 Google Places API 提供搜索建议。禁用可节省 API 配额。',
|
||||
'admin.placesDetails.title': '地点详情',
|
||||
'admin.placesDetails.subtitle': '从 Google Places API 获取地点详细信息(营业时间、评分、网站)。禁用可节省 API 配额。',
|
||||
'admin.bagTracking.title': '行李追踪',
|
||||
'admin.bagTracking.subtitle': '为打包物品启用重量和行李分配',
|
||||
'admin.collab.chat.title': '聊天',
|
||||
'admin.collab.chat.subtitle': '实时消息协作',
|
||||
'admin.collab.notes.title': '笔记',
|
||||
'admin.collab.notes.subtitle': '共享笔记和文档',
|
||||
'admin.collab.polls.title': '投票',
|
||||
'admin.collab.polls.subtitle': '群组投票和表决',
|
||||
'admin.collab.whatsnext.title': '下一步',
|
||||
'admin.collab.whatsnext.subtitle': '活动建议和后续步骤',
|
||||
'admin.tabs.config': '个性化',
|
||||
'admin.tabs.defaults': '用户默认设置',
|
||||
'admin.defaultSettings.title': '用户默认设置',
|
||||
'admin.defaultSettings.description': '设置实例范围的默认值。未更改设置的用户将看到这些值。用户自己的更改始终优先。',
|
||||
'admin.defaultSettings.saved': '默认值已保存',
|
||||
'admin.defaultSettings.reset': '重置为内置默认值',
|
||||
'admin.defaultSettings.resetToBuiltIn': '重置',
|
||||
'admin.tabs.templates': '打包模板',
|
||||
'admin.packingTemplates.title': '打包模板',
|
||||
'admin.packingTemplates.subtitle': '创建可复用的旅行打包清单',
|
||||
@@ -833,6 +867,7 @@ const zh: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': '计划',
|
||||
'trip.tabs.transports': '交通',
|
||||
'trip.tabs.reservations': '预订',
|
||||
'trip.tabs.reservationsShort': '预订',
|
||||
'trip.tabs.packing': '行李清单',
|
||||
@@ -855,6 +890,8 @@ const zh: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': '预订已添加',
|
||||
'trip.toast.deleted': '已删除',
|
||||
'trip.confirm.deletePlace': '确定要删除这个地点吗?',
|
||||
'trip.confirm.deletePlaces': '删除 {count} 个地点?',
|
||||
'trip.toast.placesDeleted': '已删除 {count} 个地点',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': '当天暂无计划',
|
||||
@@ -899,6 +936,17 @@ const zh: Record<string, string> = {
|
||||
'places.importFileError': '导入失败',
|
||||
'places.importAllSkipped': '所有地点已在行程中。',
|
||||
'places.gpxImported': '已从 GPX 导入 {count} 个地点',
|
||||
'places.gpxImportTypes': '要导入什么?',
|
||||
'places.gpxImportWaypoints': '路点',
|
||||
'places.gpxImportRoutes': '路线',
|
||||
'places.gpxImportTracks': '轨迹(含路径几何)',
|
||||
'places.gpxImportNoneSelected': '请至少选择一种导入类型。',
|
||||
'places.kmlImportTypes': '要导入什么?',
|
||||
'places.kmlImportPoints': '点(Placemarks)',
|
||||
'places.kmlImportPaths': '路径(LineStrings)',
|
||||
'places.kmlImportNoneSelected': '请至少选择一种类型。',
|
||||
'places.selectionCount': '已选 {count} 项',
|
||||
'places.deleteSelected': '删除所选',
|
||||
'places.kmlKmzImported': '已从 KMZ/KML 导入 {count} 个地点',
|
||||
'places.urlResolved': '已从 URL 导入地点',
|
||||
'places.importList': '列表导入',
|
||||
@@ -915,6 +963,7 @@ const zh: Record<string, string> = {
|
||||
'places.assignToDay': '添加到哪一天?',
|
||||
'places.all': '全部',
|
||||
'places.unplanned': '未规划',
|
||||
'places.filterTracks': '路线',
|
||||
'places.search': '搜索地点...',
|
||||
'places.allCategories': '所有分类',
|
||||
'places.categoriesSelected': '个分类',
|
||||
@@ -998,10 +1047,20 @@ const zh: Record<string, string> = {
|
||||
'reservations.meta.flightNumber': '航班号',
|
||||
'reservations.meta.from': '出发',
|
||||
'reservations.meta.to': '到达',
|
||||
'reservations.needsReview': '待确认',
|
||||
'reservations.needsReviewHint': '无法自动匹配机场 — 请确认位置。',
|
||||
'reservations.searchLocation': '搜索车站、港口、地址...',
|
||||
'airport.searchPlaceholder': '机场代码或城市(如 FRA)',
|
||||
'map.connections': '连接',
|
||||
'map.showConnections': '显示预订路线',
|
||||
'map.hideConnections': '隐藏预订路线',
|
||||
'settings.bookingLabels': '预订路线标签',
|
||||
'settings.bookingLabelsHint': '在地图上显示车站 / 机场名称。关闭时仅显示图标。',
|
||||
'reservations.meta.trainNumber': '车次',
|
||||
'reservations.meta.platform': '站台',
|
||||
'reservations.meta.seat': '座位',
|
||||
'reservations.meta.checkIn': '入住',
|
||||
'reservations.meta.checkInUntil': '入住截止',
|
||||
'reservations.meta.checkOut': '退房',
|
||||
'reservations.meta.linkAccommodation': '住宿',
|
||||
'reservations.meta.pickAccommodation': '关联住宿',
|
||||
@@ -1015,7 +1074,7 @@ const zh: Record<string, string> = {
|
||||
'reservations.type.hotel': '住宿',
|
||||
'reservations.type.restaurant': '餐厅',
|
||||
'reservations.type.train': '火车',
|
||||
'reservations.type.car': '租车',
|
||||
'reservations.type.car': '汽车',
|
||||
'reservations.type.cruise': '邮轮',
|
||||
'reservations.type.event': '活动',
|
||||
'reservations.type.tour': '旅游团',
|
||||
@@ -1076,6 +1135,7 @@ const zh: Record<string, string> = {
|
||||
'reservations.span.end': '结束',
|
||||
'reservations.span.ongoing': '进行中',
|
||||
'reservations.validation.endBeforeStart': '结束日期/时间必须晚于开始日期/时间',
|
||||
'reservations.addBooking': '添加预订',
|
||||
|
||||
// Budget
|
||||
'budget.title': '预算',
|
||||
@@ -1486,6 +1546,7 @@ const zh: Record<string, string> = {
|
||||
'day.noPlacesForHotel': '请先在旅行中添加地点',
|
||||
'day.allDays': '全部',
|
||||
'day.checkIn': '入住',
|
||||
'day.checkInUntil': '截止',
|
||||
'day.checkOut': '退房',
|
||||
'day.confirmation': '确认号',
|
||||
'day.editAccommodation': '编辑住宿',
|
||||
@@ -1512,6 +1573,7 @@ const zh: Record<string, string> = {
|
||||
'memories.providerPassword': '密码',
|
||||
'memories.providerOTP': 'MFA 验证码(如已启用)',
|
||||
'memories.skipSSLVerification': '跳过 SSL 证书验证',
|
||||
'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich',
|
||||
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
|
||||
'memories.testConnection': '测试连接',
|
||||
'memories.testFirst': '请先测试连接',
|
||||
@@ -1673,6 +1735,7 @@ const zh: Record<string, string> = {
|
||||
'undo.reorder': '地点已重新排序',
|
||||
'undo.optimize': '路线已优化',
|
||||
'undo.deletePlace': '地点已删除',
|
||||
'undo.deletePlaces': '地点已删除',
|
||||
'undo.moveDay': '地点已移至另一天',
|
||||
'undo.lock': '地点锁定已切换',
|
||||
'undo.importGpx': 'GPX 导入',
|
||||
@@ -1732,7 +1795,11 @@ const zh: Record<string, string> = {
|
||||
'todo.unassigned': '未分配',
|
||||
'todo.noCategory': '无分类',
|
||||
'todo.hasDescription': '有描述',
|
||||
'todo.addItem': '添加新任务...',
|
||||
'todo.addItem': '新建任务',
|
||||
'todo.sidebar.sortBy': '排序方式',
|
||||
'todo.priority': '优先级',
|
||||
'todo.newCategoryLabel': '新建',
|
||||
'budget.categoriesLabel': '类别',
|
||||
'todo.newCategory': '分类名称',
|
||||
'todo.addCategory': '添加分类',
|
||||
'todo.newItem': '新任务',
|
||||
@@ -1774,7 +1841,6 @@ const zh: Record<string, string> = {
|
||||
'settings.ntfyUrl.test': '测试',
|
||||
'settings.ntfyUrl.testSuccess': '测试 Ntfy 通知发送成功',
|
||||
'settings.ntfyUrl.testFailed': '测试 Ntfy 通知失败',
|
||||
'settings.ntfyUrl.clearToken': '清除',
|
||||
'settings.ntfyUrl.tokenCleared': '访问令牌已清除',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
@@ -1791,22 +1857,29 @@ const zh: Record<string, string> = {
|
||||
'admin.notifications.adminWebhookPanel.testFailed': '测试 Webhook 失败',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 后管理员 Webhook 自动触发',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': '允许用户配置自己的 ntfy 主题以接收推送通知。在下方设置默认服务器以预填充用户设置。',
|
||||
'admin.notifications.testNtfy': '发送测试 Ntfy',
|
||||
'admin.notifications.testNtfySuccess': '测试 Ntfy 发送成功',
|
||||
'admin.notifications.testNtfyFailed': '测试 Ntfy 失败',
|
||||
'admin.notifications.adminNtfyPanel.title': '管理员 Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.hint': '此 Ntfy 主题专用于管理员通知(如版本更新提醒)。它与每用户主题相互独立,配置后始终触发。',
|
||||
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy 服务器 URL',
|
||||
'admin.notifications.adminNtfyPanel.serverHint': '同时用作用户 ntfy 通知的默认服务器。留空则默认使用 ntfy.sh。用户可在其自己的设置中覆盖此项。',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': '管理员主题',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': '访问令牌(可选)',
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': '管理员访问令牌已清除',
|
||||
'admin.notifications.adminNtfyPanel.saved': '管理员 Ntfy 设置已保存',
|
||||
'admin.notifications.adminNtfyPanel.test': '发送测试 Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess': '测试 Ntfy 发送成功',
|
||||
'admin.notifications.adminNtfyPanel.testFailed': '测试 Ntfy 失败',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': '配置主题后管理员 Ntfy 始终触发',
|
||||
'admin.notifications.adminNotificationsHint': '配置哪些渠道发送管理员通知(如版本更新提醒)。设置管理员 Webhook URL 后,Webhook 将自动触发。',
|
||||
'admin.notifications.tripReminders.title': '行程提醒',
|
||||
'admin.notifications.tripReminders.hint': '在行程开始前发送提醒通知(需要在行程中设置提醒天数)。',
|
||||
'admin.notifications.tripReminders.enabled': '行程提醒已启用',
|
||||
'admin.notifications.tripReminders.disabled': '行程提醒已禁用',
|
||||
'admin.tabs.notifications': '通知',
|
||||
'notifications.versionAvailable.title': '有可用更新',
|
||||
'notifications.versionAvailable.text': 'TREK {version} 现已可用。',
|
||||
@@ -1854,6 +1927,8 @@ const zh: Record<string, string> = {
|
||||
'memories.saveRouteNotConfigured': '此提供商未配置保存路由',
|
||||
'memories.testRouteNotConfigured': '此提供商未配置测试路由',
|
||||
'memories.fillRequiredFields': '请填写所有必填字段',
|
||||
'journey.search.placeholder': '搜索旅程…',
|
||||
'journey.search.noResults': '没有与"{query}"匹配的旅程',
|
||||
'journey.title': '旅程',
|
||||
'journey.subtitle': '实时记录你的旅行',
|
||||
'journey.new': '新建旅程',
|
||||
@@ -1875,6 +1950,7 @@ const zh: Record<string, string> = {
|
||||
'journey.status.active': '进行中',
|
||||
'journey.status.completed': '已完成',
|
||||
'journey.status.upcoming': '即将开始',
|
||||
'journey.status.archived': '已归档',
|
||||
'journey.checkin.add': '签到',
|
||||
'journey.checkin.namePlaceholder': '地点名称',
|
||||
'journey.checkin.notesPlaceholder': '备注(可选)',
|
||||
@@ -1951,6 +2027,7 @@ const zh: Record<string, string> = {
|
||||
'journey.verdict.couldBeBetter': '有待改进',
|
||||
'journey.synced.places': '个地点',
|
||||
'journey.synced.synced': '已同步',
|
||||
'journey.editor.discardChangesConfirm': '您有未保存的更改。要放弃吗?',
|
||||
'journey.editor.uploadPhotos': '上传照片',
|
||||
'journey.editor.uploading': '上传中...',
|
||||
'journey.editor.fromGallery': '从相册',
|
||||
@@ -2028,6 +2105,11 @@ const zh: Record<string, string> = {
|
||||
'journey.settings.name': '名称',
|
||||
'journey.settings.subtitle': '副标题',
|
||||
'journey.settings.subtitlePlaceholder': '例如 泰国、越南和柬埔寨',
|
||||
'journey.settings.endJourney': '归档旅程',
|
||||
'journey.settings.reopenJourney': '恢复旅程',
|
||||
'journey.settings.archived': '旅程已归档',
|
||||
'journey.settings.reopened': '旅程已重新开启',
|
||||
'journey.settings.endDescription': '隐藏直播标记。您可以随时重新开启。',
|
||||
'journey.settings.delete': '删除',
|
||||
'journey.settings.deleteJourney': '删除旅程',
|
||||
'journey.settings.deleteMessage': '删除"{title}"?所有条目和照片将丢失。',
|
||||
@@ -2163,6 +2245,55 @@ const zh: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': '搜索位置、解析地图 URL 和反向地理编码坐标',
|
||||
'oauth.scope.weather:read.label': '天气预报',
|
||||
'oauth.scope.weather:read.description': '获取行程地点和日期的天气预报',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': '欢迎使用 TREK',
|
||||
'system_notice.welcome_v1.body': '您的全能旅行规划器。制定行程、与朋友分享旅行,随时保持井然有序——在线或离线均可。',
|
||||
'system_notice.welcome_v1.cta_label': '规划行程',
|
||||
'system_notice.welcome_v1.hero_alt': '风景优美的旅游目的地与 TREK 界面',
|
||||
'system_notice.welcome_v1.highlight_plan': '逐日行程规划',
|
||||
'system_notice.welcome_v1.highlight_share': '与旅行伙伴协作',
|
||||
'system_notice.welcome_v1.highlight_offline': '移动端支持离线使用',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': '上一条通知',
|
||||
'system_notice.pager.next': '下一条通知',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': '转到通知 {n}',
|
||||
'system_notice.pager.position': '通知 {current}/{total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': '3.0 版照片已迁移',
|
||||
'system_notice.v3_photos.body': '行程规划器中的**照片**标签已被移除。您的照片安全无虑 — TREK 从未修改您的 Immich 或 Synology 相册。\n\n照片现在位于 **Journey** 插件中。Journey 是可选的 — 如果尚未启用,请联系管理员在 Admin → 插件 中开启。',
|
||||
'system_notice.v3_journey.title': '认识 Journey — 旅行日记',
|
||||
'system_notice.v3_journey.body': '将您的旅程记录为展示时间线、照片画廊和互动地图的丰富旅行故事。',
|
||||
'system_notice.v3_journey.cta_label': '打开 Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': '每日时间线与画廊',
|
||||
'system_notice.v3_journey.highlight_photos': '从 Immich 或 Synology 导入',
|
||||
'system_notice.v3_journey.highlight_share': '公开分享 — 无需登录',
|
||||
'system_notice.v3_journey.highlight_export': '导出为 PDF 相册书',
|
||||
'system_notice.v3_features.title': '3.0 版更多亮点',
|
||||
'system_notice.v3_features.body': '此版本还有一些其他值得了解的新功能。',
|
||||
'system_notice.v3_features.highlight_dashboard': '移动优先仪表板重设计',
|
||||
'system_notice.v3_features.highlight_offline': '作为 PWA 的完整离线模式',
|
||||
'system_notice.v3_features.highlight_search': '地点搜索实时自动补全',
|
||||
'system_notice.v3_features.highlight_import': '从 KMZ/KML 文件导入地点',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP:OAuth 2.1 升级',
|
||||
'system_notice.v3_mcp.body': 'MCP 集成已全面重构。OAuth 2.1 现为推荐的身份验证方式。静态令牌(trek_…)已弃用,将在未来版本中移除。',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 推荐(mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 个细粒度权限范围',
|
||||
'system_notice.v3_mcp.highlight_deprecated': '静态 trek_ 令牌已弃用',
|
||||
'system_notice.v3_mcp.highlight_tools': '扩展工具集与提示词',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': '来自我的一封私人信',
|
||||
'system_notice.v3_thankyou.body': '在你继续之前——我想停下来说几句。\n\nTREK 最初只是我为自己的旅行而做的一个业余项目。我从未想过它会成长为 4,000 人信赖的冒险规划工具。每一颗星标、每一个 issue、每一个功能请求——我都会读,它们在全职工作和大学学业之间的深夜里支撑着我继续前行。\n\n我想让你们知道:TREK 将永远开源,永远可自托管,永远属于你们。没有追踪,没有订阅,没有任何附加条件。只是一个热爱旅行的人为同样热爱旅行的你们打造的工具。\n\n特别感谢 [jubnl](https://github.com/jubnl)——你已经成为一位不可思议的合作者。3.0 版本中许多精彩之处都留下了你的印记。感谢你在这个项目还很粗糙的时候就选择了相信它。\n\n也感谢你们每一位——报告了 bug、翻译了文本、向朋友分享了 TREK,或者只是用它规划了一次旅行——**谢谢你们**。你们是这一切存在的原因。\n\n愿我们一起踏上更多的冒险旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社区](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 让你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能让这盏灯一直亮着。',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': '交通',
|
||||
'transport.addManual': '手动添加交通',
|
||||
}
|
||||
|
||||
export default zh
|
||||
|
||||
@@ -4,11 +4,15 @@ const zhTw: Record<string, string> = {
|
||||
'common.showMore': '顯示更多',
|
||||
'common.showLess': '收起',
|
||||
'common.cancel': '取消',
|
||||
'common.clear': '清除',
|
||||
'common.delete': '刪除',
|
||||
'common.edit': '編輯',
|
||||
'common.add': '新增',
|
||||
'common.loading': '載入中...',
|
||||
'common.import': '匯入',
|
||||
'common.select': '選擇',
|
||||
'common.selectAll': '全選',
|
||||
'common.deselectAll': '取消全選',
|
||||
'common.error': '錯誤',
|
||||
'common.unknownError': '未知錯誤',
|
||||
'common.tooManyAttempts': '嘗試次數過多,請稍後再試。',
|
||||
@@ -206,7 +210,6 @@ const zhTw: Record<string, string> = {
|
||||
'settings.ntfyUrl.test': '測試',
|
||||
'settings.ntfyUrl.testSuccess': '測試 Ntfy 通知傳送成功',
|
||||
'settings.ntfyUrl.testFailed': '測試 Ntfy 通知失敗',
|
||||
'settings.ntfyUrl.clearToken': '清除',
|
||||
'settings.ntfyUrl.tokenCleared': '存取權杖已清除',
|
||||
'settings.notificationsDisabled': '通知尚未配置。請聯絡管理員啟用電子郵件或 Webhook 通知。',
|
||||
'settings.notificationsActive': '活躍頻道',
|
||||
@@ -232,22 +235,29 @@ const zhTw: Record<string, string> = {
|
||||
'admin.notifications.adminWebhookPanel.testFailed': '測試 Webhook 傳送失敗',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 後,管理員 Webhook 始終觸發',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': '允許使用者設定自己的 ntfy 主題以接收推播通知。在下方設定預設伺服器以預先填入使用者設定。',
|
||||
'admin.notifications.testNtfy': '傳送測試 Ntfy',
|
||||
'admin.notifications.testNtfySuccess': '測試 Ntfy 傳送成功',
|
||||
'admin.notifications.testNtfyFailed': '測試 Ntfy 失敗',
|
||||
'admin.notifications.adminNtfyPanel.title': '管理員 Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.hint': '此 Ntfy 主題專用於管理員通知(例如版本提醒)。它與每位使用者的主題分開,設定後始終會觸發。',
|
||||
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy 伺服器 URL',
|
||||
'admin.notifications.adminNtfyPanel.serverHint': '同時用作使用者 ntfy 通知的預設伺服器。留空則預設使用 ntfy.sh。使用者可在自己的設定中覆寫此項。',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': '管理員主題',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': '存取權杖(選填)',
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': '管理員存取權杖已清除',
|
||||
'admin.notifications.adminNtfyPanel.saved': '管理員 Ntfy 設定已儲存',
|
||||
'admin.notifications.adminNtfyPanel.test': '傳送測試 Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess': '測試 Ntfy 傳送成功',
|
||||
'admin.notifications.adminNtfyPanel.testFailed': '測試 Ntfy 失敗',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': '設定主題後管理員 Ntfy 始終觸發',
|
||||
'admin.notifications.adminNotificationsHint': '配置哪些渠道傳遞僅管理員通知(例如版本提醒)。',
|
||||
'admin.notifications.tripReminders.title': '行程提醒',
|
||||
'admin.notifications.tripReminders.hint': '在行程開始前發送提醒通知(需要在行程中設定提醒天數)。',
|
||||
'admin.notifications.tripReminders.enabled': '行程提醒已啟用',
|
||||
'admin.notifications.tripReminders.disabled': '行程提醒已停用',
|
||||
'admin.smtp.title': '郵件與通知',
|
||||
'admin.smtp.hint': '用於傳送電子郵件通知的 SMTP 配置。',
|
||||
'admin.smtp.testButton': '傳送測試郵件',
|
||||
@@ -360,6 +370,16 @@ const zhTw: Record<string, string> = {
|
||||
'settings.about.featureRequest': '功能建議',
|
||||
'settings.about.featureRequestHint': '建議新功能',
|
||||
'settings.about.wikiHint': '文件與指南',
|
||||
'settings.about.supporters.badge': '月度支持者',
|
||||
'settings.about.supporters.title': '與 TREK 同行的夥伴',
|
||||
'settings.about.supporters.subtitle': '當你規劃下一段路線時,這些人也在一起規劃 TREK 的未來。他們每月的支持直接用於開發與實際投入的時間——讓 TREK 保持開源。',
|
||||
'settings.about.supporters.since': '自 {date} 起的支持者',
|
||||
'settings.about.supporters.tierEmpty': '成為第一個',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK 是一款自架旅遊規劃器,幫助您從最初構想到最後回憶,整理每次旅行。日程規劃、預算、行李清單、照片及更多功能——全部集中在您自己的伺服器上。',
|
||||
'settings.about.madeWith': '以',
|
||||
'settings.about.madeBy': '由 Maurice 及不斷成長的開源社群製作。',
|
||||
@@ -599,9 +619,29 @@ const zhTw: Record<string, string> = {
|
||||
'admin.fileTypesFormat': '以逗號分隔的副檔名(如 jpg,png,pdf,doc)。使用 * 允許所有型別。',
|
||||
'admin.fileTypesSaved': '檔案型別設定已儲存',
|
||||
|
||||
'admin.placesPhotos.title': '地點照片',
|
||||
'admin.placesPhotos.subtitle': '從 Google Places API 獲取照片。停用可節省 API 配額。Wikimedia 照片不受影響。',
|
||||
'admin.placesAutocomplete.title': '地點自動補全',
|
||||
'admin.placesAutocomplete.subtitle': '使用 Google Places API 提供搜尋建議。停用可節省 API 配額。',
|
||||
'admin.placesDetails.title': '地點詳情',
|
||||
'admin.placesDetails.subtitle': '從 Google Places API 獲取地點詳細資訊(營業時間、評分、網站)。停用可節省 API 配額。',
|
||||
'admin.bagTracking.title': '行李追蹤',
|
||||
'admin.bagTracking.subtitle': '為打包物品啟用重量和行李分配',
|
||||
'admin.collab.chat.title': '聊天',
|
||||
'admin.collab.chat.subtitle': '即時訊息協作',
|
||||
'admin.collab.notes.title': '筆記',
|
||||
'admin.collab.notes.subtitle': '共享筆記和文件',
|
||||
'admin.collab.polls.title': '投票',
|
||||
'admin.collab.polls.subtitle': '群組投票和表決',
|
||||
'admin.collab.whatsnext.title': '下一步',
|
||||
'admin.collab.whatsnext.subtitle': '活動建議和後續步驟',
|
||||
'admin.tabs.config': '配置',
|
||||
'admin.tabs.defaults': '用戶預設設定',
|
||||
'admin.defaultSettings.title': '用戶預設設定',
|
||||
'admin.defaultSettings.description': '設定整個執行個體的預設值。未更改設定的用戶將看到這些值。用戶自己的更改始終優先。',
|
||||
'admin.defaultSettings.saved': '預設值已儲存',
|
||||
'admin.defaultSettings.reset': '重設為內建預設值',
|
||||
'admin.defaultSettings.resetToBuiltIn': '重設',
|
||||
'admin.tabs.templates': '打包模板',
|
||||
'admin.packingTemplates.title': '打包模板',
|
||||
'admin.packingTemplates.subtitle': '建立可複用的旅行打包清單',
|
||||
@@ -887,6 +927,7 @@ const zhTw: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': '計劃',
|
||||
'trip.tabs.transports': '交通',
|
||||
'trip.tabs.reservations': '預訂',
|
||||
'trip.tabs.reservationsShort': '預訂',
|
||||
'trip.tabs.packing': '行李清單',
|
||||
@@ -909,6 +950,8 @@ const zhTw: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': '預訂已新增',
|
||||
'trip.toast.deleted': '已刪除',
|
||||
'trip.confirm.deletePlace': '確定要刪除這個地點嗎?',
|
||||
'trip.confirm.deletePlaces': '刪除 {count} 個地點?',
|
||||
'trip.toast.placesDeleted': '已刪除 {count} 個地點',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': '當天暫無計劃',
|
||||
@@ -953,6 +996,17 @@ const zhTw: Record<string, string> = {
|
||||
'places.importFileError': '匯入失敗',
|
||||
'places.importAllSkipped': '所有地點已在行程中。',
|
||||
'places.gpxImported': '已從 GPX 匯入 {count} 個地點',
|
||||
'places.gpxImportTypes': '要匯入什麼?',
|
||||
'places.gpxImportWaypoints': '路點',
|
||||
'places.gpxImportRoutes': '路線',
|
||||
'places.gpxImportTracks': '軌跡(含路徑幾何)',
|
||||
'places.gpxImportNoneSelected': '請至少選擇一種匯入類型。',
|
||||
'places.kmlImportTypes': '要匯入什麼?',
|
||||
'places.kmlImportPoints': '點(Placemarks)',
|
||||
'places.kmlImportPaths': '路徑(LineStrings)',
|
||||
'places.kmlImportNoneSelected': '請至少選擇一種類型。',
|
||||
'places.selectionCount': '已選 {count} 項',
|
||||
'places.deleteSelected': '刪除所選',
|
||||
'places.kmlKmzImported': '已從 KMZ/KML 匯入 {count} 個地點',
|
||||
'places.urlResolved': '已從 URL 匯入地點',
|
||||
'places.importList': '列表匯入',
|
||||
@@ -969,6 +1023,7 @@ const zhTw: Record<string, string> = {
|
||||
'places.assignToDay': '新增到哪一天?',
|
||||
'places.all': '全部',
|
||||
'places.unplanned': '未規劃',
|
||||
'places.filterTracks': '路線',
|
||||
'places.search': '搜尋地點...',
|
||||
'places.allCategories': '所有分類',
|
||||
'places.categoriesSelected': '個分類',
|
||||
@@ -1052,10 +1107,20 @@ const zhTw: Record<string, string> = {
|
||||
'reservations.meta.flightNumber': '航班號',
|
||||
'reservations.meta.from': '出發',
|
||||
'reservations.meta.to': '到達',
|
||||
'reservations.needsReview': '待確認',
|
||||
'reservations.needsReviewHint': '無法自動匹配機場 — 請確認位置。',
|
||||
'reservations.searchLocation': '搜尋車站、港口、地址...',
|
||||
'airport.searchPlaceholder': '機場代碼或城市(例如 FRA)',
|
||||
'map.connections': '連接',
|
||||
'map.showConnections': '顯示預訂路線',
|
||||
'map.hideConnections': '隱藏預訂路線',
|
||||
'settings.bookingLabels': '預訂路線標籤',
|
||||
'settings.bookingLabelsHint': '在地圖上顯示車站 / 機場名稱。關閉時僅顯示圖示。',
|
||||
'reservations.meta.trainNumber': '車次',
|
||||
'reservations.meta.platform': '站臺',
|
||||
'reservations.meta.seat': '座位',
|
||||
'reservations.meta.checkIn': '入住',
|
||||
'reservations.meta.checkInUntil': '入住截止',
|
||||
'reservations.meta.checkOut': '退房',
|
||||
'reservations.meta.linkAccommodation': '住宿',
|
||||
'reservations.meta.pickAccommodation': '關聯住宿',
|
||||
@@ -1069,7 +1134,7 @@ const zhTw: Record<string, string> = {
|
||||
'reservations.type.hotel': '住宿',
|
||||
'reservations.type.restaurant': '餐廳',
|
||||
'reservations.type.train': '火車',
|
||||
'reservations.type.car': '租車',
|
||||
'reservations.type.car': '汽車',
|
||||
'reservations.type.cruise': '郵輪',
|
||||
'reservations.type.event': '活動',
|
||||
'reservations.type.tour': '旅遊團',
|
||||
@@ -1130,6 +1195,7 @@ const zhTw: Record<string, string> = {
|
||||
'reservations.span.end': '結束',
|
||||
'reservations.span.ongoing': '進行中',
|
||||
'reservations.validation.endBeforeStart': '結束日期/時間必須晚於開始日期/時間',
|
||||
'reservations.addBooking': '新增預訂',
|
||||
|
||||
// Budget
|
||||
'budget.title': '預算',
|
||||
@@ -1540,6 +1606,7 @@ const zhTw: Record<string, string> = {
|
||||
'day.noPlacesForHotel': '請先在旅行中新增地點',
|
||||
'day.allDays': '全部',
|
||||
'day.checkIn': '入住',
|
||||
'day.checkInUntil': '截止',
|
||||
'day.checkOut': '退房',
|
||||
'day.confirmation': '確認號',
|
||||
'day.editAccommodation': '編輯住宿',
|
||||
@@ -1566,6 +1633,7 @@ const zhTw: Record<string, string> = {
|
||||
'memories.providerPassword': '密碼',
|
||||
'memories.providerOTP': 'MFA 驗證碼(如已啟用)',
|
||||
'memories.skipSSLVerification': '跳過 SSL 憑證驗證',
|
||||
'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich',
|
||||
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
|
||||
'memories.testConnection': '測試連線',
|
||||
'memories.testFirst': '請先測試連線',
|
||||
@@ -1727,6 +1795,7 @@ const zhTw: Record<string, string> = {
|
||||
'undo.reorder': '地點已重新排序',
|
||||
'undo.optimize': '路線已最佳化',
|
||||
'undo.deletePlace': '地點已刪除',
|
||||
'undo.deletePlaces': '地點已刪除',
|
||||
'undo.moveDay': '地點已移至另一天',
|
||||
'undo.lock': '地點鎖定已切換',
|
||||
'undo.importGpx': 'GPX 匯入',
|
||||
@@ -1747,7 +1816,11 @@ const zhTw: Record<string, string> = {
|
||||
'todo.unassigned': '未指派',
|
||||
'todo.noCategory': '無分類',
|
||||
'todo.hasDescription': '有說明',
|
||||
'todo.addItem': '新增任務...',
|
||||
'todo.addItem': '新增任務',
|
||||
'todo.sidebar.sortBy': '排序方式',
|
||||
'todo.priority': '優先順序',
|
||||
'todo.newCategoryLabel': '新增',
|
||||
'budget.categoriesLabel': '類別',
|
||||
'todo.newCategory': '分類名稱',
|
||||
'todo.addCategory': '新增分類',
|
||||
'todo.newItem': '新任務',
|
||||
@@ -1814,6 +1887,8 @@ const zhTw: Record<string, string> = {
|
||||
'memories.saveRouteNotConfigured': '此提供商未設定儲存路由',
|
||||
'memories.testRouteNotConfigured': '此提供商未設定測試路由',
|
||||
'memories.fillRequiredFields': '請填寫所有必填欄位',
|
||||
'journey.search.placeholder': '搜尋旅程…',
|
||||
'journey.search.noResults': '沒有符合「{query}」的旅程',
|
||||
'journey.title': '旅程',
|
||||
'journey.subtitle': '即時記錄你的旅行',
|
||||
'journey.new': '新建旅程',
|
||||
@@ -1835,6 +1910,7 @@ const zhTw: Record<string, string> = {
|
||||
'journey.status.active': '進行中',
|
||||
'journey.status.completed': '已完成',
|
||||
'journey.status.upcoming': '即將開始',
|
||||
'journey.status.archived': '已封存',
|
||||
'journey.checkin.add': '打卡',
|
||||
'journey.checkin.namePlaceholder': '地點名稱',
|
||||
'journey.checkin.notesPlaceholder': '備註(可選)',
|
||||
@@ -1911,6 +1987,7 @@ const zhTw: Record<string, string> = {
|
||||
'journey.verdict.couldBeBetter': '有待改進',
|
||||
'journey.synced.places': '個地點',
|
||||
'journey.synced.synced': '已同步',
|
||||
'journey.editor.discardChangesConfirm': '您有未儲存的變更。要放棄嗎?',
|
||||
'journey.editor.uploadPhotos': '上傳照片',
|
||||
'journey.editor.uploading': '上傳中...',
|
||||
'journey.editor.fromGallery': '從相簿',
|
||||
@@ -1988,6 +2065,11 @@ const zhTw: Record<string, string> = {
|
||||
'journey.settings.name': '名稱',
|
||||
'journey.settings.subtitle': '副標題',
|
||||
'journey.settings.subtitlePlaceholder': '例如 泰國、越南和柬埔寨',
|
||||
'journey.settings.endJourney': '封存旅程',
|
||||
'journey.settings.reopenJourney': '還原旅程',
|
||||
'journey.settings.archived': '旅程已封存',
|
||||
'journey.settings.reopened': '旅程已重新開啟',
|
||||
'journey.settings.endDescription': '隱藏直播標記。您可以隨時重新開啟。',
|
||||
'journey.settings.delete': '刪除',
|
||||
'journey.settings.deleteJourney': '刪除旅程',
|
||||
'journey.settings.deleteMessage': '刪除「{title}」?所有條目和照片將遺失。',
|
||||
@@ -2164,6 +2246,55 @@ const zhTw: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': '搜尋地點、解析地圖 URL 及反向地理編碼坐標',
|
||||
'oauth.scope.weather:read.label': '天氣預報',
|
||||
'oauth.scope.weather:read.description': '取得行程地點及日期的天氣預報',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': '歡迎使用 TREK',
|
||||
'system_notice.welcome_v1.body': '您的全方位旅遊規劃器。建立行程、與朋友分享旅遊,隨時保持條理分明——無論線上或離線皆可。',
|
||||
'system_notice.welcome_v1.cta_label': '規劃行程',
|
||||
'system_notice.welcome_v1.hero_alt': '風景優美的旅遊目的地與 TREK 介面',
|
||||
'system_notice.welcome_v1.highlight_plan': '逐日行程規劃',
|
||||
'system_notice.welcome_v1.highlight_share': '與旅伴協作規劃',
|
||||
'system_notice.welcome_v1.highlight_offline': '行動裝置支援離線使用',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': '上一則通知',
|
||||
'system_notice.pager.next': '下一則通知',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': '前往通知 {n}',
|
||||
'system_notice.pager.position': '通知 {current}/{total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': '3.0 版相片已移至',
|
||||
'system_notice.v3_photos.body': '行程規劃器中的**相片**標籤已被移除。您的相片安全— TREK 從未修改您的 Immich 或 Synology 相簿。\n\n相片現在位於 **Journey** 附加元件中。Journey 為選用 — 若尚未啟用,請聯絡管理員於 Admin → 附加元件 中開啟。',
|
||||
'system_notice.v3_journey.title': '認識 Journey — 旅行日記',
|
||||
'system_notice.v3_journey.body': '將您的旅程記錄為具有時間軸、相片畫庫與互動地圖的豐富旅行故事。',
|
||||
'system_notice.v3_journey.cta_label': '開啟 Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': '每日時間軸與畫庫',
|
||||
'system_notice.v3_journey.highlight_photos': '從 Immich 或 Synology 匯入',
|
||||
'system_notice.v3_journey.highlight_share': '公開分享 — 無需登入',
|
||||
'system_notice.v3_journey.highlight_export': '匯出為 PDF 相簿书',
|
||||
'system_notice.v3_features.title': '3.0 版更多亮點',
|
||||
'system_notice.v3_features.body': '這個版本還有一些其他專項值得了解。',
|
||||
'system_notice.v3_features.highlight_dashboard': '行動先行儀表板重設計',
|
||||
'system_notice.v3_features.highlight_offline': '作為 PWA 的完整離線模式',
|
||||
'system_notice.v3_features.highlight_search': '地點搜尋即時自動補全',
|
||||
'system_notice.v3_features.highlight_import': '從 KMZ/KML 檔案匯入地點',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP:OAuth 2.1 升級',
|
||||
'system_notice.v3_mcp.body': 'MCP 整合已全面重構。OAuth 2.1 現為建議的身份驗證方式。靜態令牌(trek_…)已棄用,將於未來版本移除。',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 建議(mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 個細粒度權限範圍',
|
||||
'system_notice.v3_mcp.highlight_deprecated': '靜態 trek_ 令牌已棄用',
|
||||
'system_notice.v3_mcp.highlight_tools': '擴展工具集與提示詞',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': '來自我的一封私人信',
|
||||
'system_notice.v3_thankyou.body': '在你繼續之前——我想停下來說幾句。\n\nTREK 最初只是我為自己的旅行而做的一個業餘專案。我從未想過它會成長為 4,000 人信賴的冒險規劃工具。每一顆星標、每一個 issue、每一個功能請求——我都會讀,它們在全職工作和大學學業之間的深夜裡支撐著我繼續前行。\n\n我想讓你們知道:TREK 將永遠開源,永遠可自託管,永遠屬於你們。沒有追蹤,沒有訂閱,沒有任何附加條件。只是一個熱愛旅行的人為同樣熱愛旅行的你們打造的工具。\n\n特別感謝 [jubnl](https://github.com/jubnl)——你已經成為一位不可思議的合作者。3.0 版本中許多精彩之處都留下了你的印記。感謝你在這個專案還很粗糙的時候就選擇了相信它。\n\n也感謝你們每一位——回報了 bug、翻譯了文字、向朋友分享了 TREK,或者只是用它規劃了一次旅行——**謝謝你們**。你們是這一切存在的原因。\n\n願我們一起踏上更多的冒險旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社群](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 讓你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能讓這盞燈一直亮著。',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': '交通',
|
||||
'transport.addManual': '手動新增交通',
|
||||
}
|
||||
|
||||
export default zhTw
|
||||
+291
-9
@@ -6,6 +6,30 @@ html { height: 100%; overflow: hidden; background-color: var(--bg-primary); }
|
||||
body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow-scrolling: touch; }
|
||||
|
||||
|
||||
/* Leaflet Popups — Enter-Animation vom Anchor-Tip */
|
||||
.leaflet-popup {
|
||||
animation: trek-popover-enter 220ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
transform-origin: bottom center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
border-radius: 14px !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18) !important;
|
||||
background: var(--bg-card) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border: 1px solid var(--border-faint);
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
background: var(--bg-card) !important;
|
||||
}
|
||||
.leaflet-popup-close-button {
|
||||
transition: color 150ms cubic-bezier(0.23, 1, 0.32, 1), transform 150ms cubic-bezier(0.23, 1, 0.32, 1) !important;
|
||||
}
|
||||
.leaflet-popup-close-button:hover {
|
||||
transform: scale(1.15);
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.atlas-tooltip {
|
||||
background: rgba(10, 10, 20, 0.6) !important;
|
||||
backdrop-filter: blur(20px) saturate(180%) !important;
|
||||
@@ -137,8 +161,268 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Press-Feedback + bessere Easings (Emil Kowalski) ─────────── */
|
||||
/* Buttons sollen antworten wenn sie gedrückt werden. */
|
||||
button:not(:disabled):not([data-no-press]),
|
||||
[role="button"]:not([aria-disabled="true"]):not([data-no-press]) {
|
||||
transition-property: transform, color, background-color, border-color, box-shadow, opacity, filter !important;
|
||||
transition-duration: 180ms;
|
||||
transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
button:not(:disabled):not([data-no-press]):active,
|
||||
[role="button"]:not([aria-disabled="true"]):not([data-no-press]):active {
|
||||
transform: scale(0.97);
|
||||
transition-duration: 80ms;
|
||||
}
|
||||
|
||||
/* Tailwind-Default-Easing durch ease-out-quint ersetzen.
|
||||
Eingebaute CSS-Easings sind kraftlos; ease-out-quint hat Punch. */
|
||||
.transition,
|
||||
.transition-all,
|
||||
.transition-colors,
|
||||
.transition-opacity,
|
||||
.transition-transform,
|
||||
.transition-shadow {
|
||||
transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
/* Input-Focus transitions — border + ring faden weich ein */
|
||||
input, textarea, select {
|
||||
transition: border-color 150ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
box-shadow 150ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
background-color 150ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
/* Back-Button Icon-Slide on hover */
|
||||
.trek-back-btn .trek-back-icon {
|
||||
transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.trek-back-btn:hover .trek-back-icon {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
/* Global focus-visible ring — konsistent überall */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
button:focus-visible, [role="button"]:focus-visible, a:focus-visible {
|
||||
outline-offset: 3px;
|
||||
}
|
||||
input:focus-visible, textarea:focus-visible, select:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Theme crossfade — beim Dark/Light switch, Hauptflächen + Text faden ihre Farben.
|
||||
Sparingly: nur background-color und color bekommen eine Transition. */
|
||||
html.trek-theme-transitioning,
|
||||
html.trek-theme-transitioning body,
|
||||
html.trek-theme-transitioning *:not(img):not(video):not(canvas):not([class*="trek-skeleton"]):not(.leaflet-layer) {
|
||||
transition:
|
||||
background-color 320ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
color 320ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
border-color 320ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
fill 320ms cubic-bezier(0.23, 1, 0.32, 1) !important;
|
||||
}
|
||||
|
||||
/* Touch-Geräte: iOS-Tap-Highlight weg (wir haben eigenes Press-Feedback) */
|
||||
@media (hover: none) {
|
||||
button, [role="button"], a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
html, body {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Tabular-nums global für Time/Date/Currency/Counter */
|
||||
time, .tabular-nums, [data-tabular],
|
||||
input[type="number"], input[type="time"], input[type="date"], input[type="datetime-local"] {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Wenn Element explizit ease-in-out nutzt (z.B. Accordions), nicht überschreiben.
|
||||
Tailwind setzt ease-in-out via eigener Klasse — die gewinnt durch letzte Deklaration. */
|
||||
|
||||
/* Press-Scale für clickbare Divs (Cards, Tiles) — sanfter als Buttons */
|
||||
[data-press]:active {
|
||||
transform: scale(0.985);
|
||||
transition-duration: 80ms;
|
||||
}
|
||||
|
||||
/* ── Popover/Dropdown Enter-Animationen ─────────────────────────
|
||||
Emil: Popovers sollen von ihrem Trigger aus scalen, nicht vom Center.
|
||||
Start bei scale(0.95) — nichts in der echten Welt poppt aus dem Nichts. */
|
||||
@keyframes trek-menu-enter {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(-4px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
@keyframes trek-popover-enter {
|
||||
from { opacity: 0; transform: scale(0.96); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes trek-modal-enter {
|
||||
from { opacity: 0; transform: scale(0.97); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes trek-backdrop-enter {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes trek-toast-enter {
|
||||
from { opacity: 0; transform: translateY(8px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes trek-progress-fill {
|
||||
from { width: 0%; }
|
||||
to { width: var(--trek-progress-to, 0%); }
|
||||
}
|
||||
|
||||
/* Pie-Chart Reveal — rotate + fade-in, gibt dem Kreisdiagramm ein "Draw"-Gefühl */
|
||||
@keyframes trek-pie-reveal {
|
||||
from { opacity: 0; transform: rotate(-90deg) scale(0.85); }
|
||||
to { opacity: 1; transform: rotate(0deg) scale(1); }
|
||||
}
|
||||
.trek-pie-reveal {
|
||||
animation: trek-pie-reveal 900ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
||||
transform-origin: center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Bar-Chart Reveal — horizontaler Fill von links */
|
||||
@keyframes trek-bar-fill {
|
||||
from { transform: scaleX(0); }
|
||||
to { transform: scaleX(1); }
|
||||
}
|
||||
.trek-bar-fill {
|
||||
animation: trek-bar-fill 700ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
||||
transform-origin: left center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Page-Transition — subtiler Fade-Up beim Mount */
|
||||
@keyframes trek-page-enter {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.trek-page-enter {
|
||||
animation: trek-page-enter 220ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
||||
}
|
||||
|
||||
/* Skeleton shimmer — ein fließender Gradient-Strip überquert den Platzhalter */
|
||||
@keyframes trek-shimmer {
|
||||
from { background-position: -200% 0; }
|
||||
to { background-position: 200% 0; }
|
||||
}
|
||||
.trek-skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-tertiary) 0%,
|
||||
var(--bg-hover) 50%,
|
||||
var(--bg-tertiary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: trek-shimmer 1.6s linear infinite;
|
||||
border-radius: 8px;
|
||||
color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
.dark .trek-skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255,255,255,0.04) 0%,
|
||||
rgba(255,255,255,0.08) 50%,
|
||||
rgba(255,255,255,0.04) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
.trek-menu-enter {
|
||||
animation: trek-menu-enter 200ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
transform-origin: top right;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.trek-menu-enter-left {
|
||||
animation: trek-menu-enter 200ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
transform-origin: top left;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.trek-popover-enter {
|
||||
animation: trek-popover-enter 180ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.trek-modal-enter {
|
||||
animation: trek-modal-enter 220ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Mobile-Drawer-Feel — Modal slidet von unten rein, wird unten am Screen angedockt */
|
||||
@keyframes trek-drawer-enter {
|
||||
from { opacity: 0; transform: translateY(100%); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.trek-modal-enter {
|
||||
animation: trek-drawer-enter 320ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
border-bottom-left-radius: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
margin-top: auto !important;
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
.trek-backdrop-enter {
|
||||
animation: trek-backdrop-enter 180ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.trek-toast-enter {
|
||||
animation: trek-toast-enter 260ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Stagger-Helpers für Listen — Enter-Animation mit Offset */
|
||||
@keyframes trek-fade-up {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.trek-stagger > * {
|
||||
animation: trek-fade-up 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
||||
}
|
||||
.trek-stagger > *:nth-child(1) { animation-delay: 0ms; }
|
||||
.trek-stagger > *:nth-child(2) { animation-delay: 40ms; }
|
||||
.trek-stagger > *:nth-child(3) { animation-delay: 80ms; }
|
||||
.trek-stagger > *:nth-child(4) { animation-delay: 120ms; }
|
||||
.trek-stagger > *:nth-child(5) { animation-delay: 160ms; }
|
||||
.trek-stagger > *:nth-child(6) { animation-delay: 200ms; }
|
||||
.trek-stagger > *:nth-child(7) { animation-delay: 240ms; }
|
||||
.trek-stagger > *:nth-child(8) { animation-delay: 280ms; }
|
||||
.trek-stagger > *:nth-child(n+9) { animation-delay: 320ms; }
|
||||
|
||||
/* Reduced motion — Emil's Accessibility-Regel: fewer and gentler, not zero */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-menu-enter, .trek-menu-enter-left, .trek-popover-enter,
|
||||
.trek-modal-enter, .trek-toast-enter, .trek-stagger > * {
|
||||
animation: trek-backdrop-enter 120ms ease-out;
|
||||
}
|
||||
.trek-skeleton {
|
||||
animation: none;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
button:not(:disabled):not([data-no-press]):active,
|
||||
[role="button"]:not([aria-disabled="true"]):not([data-no-press]):active,
|
||||
[data-press]:active {
|
||||
transform: none;
|
||||
}
|
||||
/* Parallax & lift disablen */
|
||||
.group:hover img,
|
||||
.group:hover .cover-img { transform: none !important; }
|
||||
*:hover { translate: none !important; }
|
||||
}
|
||||
|
||||
/* ── Design tokens ─────────────────────────────── */
|
||||
:root {
|
||||
/* Easing curves — stärker als die CSS-Defaults, siehe easing.dev */
|
||||
--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--ease-in-out-quint: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--nav-h: 0px;
|
||||
--bottom-nav-h: 0px;
|
||||
@@ -323,7 +607,7 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Scrollbalken */
|
||||
/* Scrollbars — styled on desktop, hidden on mobile */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -333,21 +617,23 @@ body {
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
* { scrollbar-width: none; }
|
||||
::-webkit-scrollbar { width: 0; height: 0; }
|
||||
}
|
||||
|
||||
.route-info-pill { background: none !important; border: none !important; box-shadow: none !important; width: auto !important; height: auto !important; margin: 0 !important; }
|
||||
.chat-scroll { overflow-y: auto !important; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
|
||||
.chat-scroll::-webkit-scrollbar { width: 0; background: transparent; }
|
||||
@@ -405,6 +691,7 @@ img[alt="TREK"] {
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
|
||||
@@ -447,11 +734,6 @@ img[alt="TREK"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Scroll-Container */
|
||||
.scroll-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d1d5db #f1f5f9;
|
||||
}
|
||||
|
||||
/* Toast-Animationen */
|
||||
@keyframes slideUp {
|
||||
|
||||
+162
-21
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import apiClient, { adminApi, authApi, notificationsApi } from '../api/client'
|
||||
import DevNotificationsPanel from '../components/Admin/DevNotificationsPanel'
|
||||
import DefaultUserSettingsTab from '../components/Admin/DefaultUserSettingsTab'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
@@ -10,6 +11,7 @@ import { getApiErrorMessage } from '../types'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import Modal from '../components/shared/Modal'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { useCountUp } from '../hooks/useCountUp'
|
||||
import CategoryManager from '../components/Admin/CategoryManager'
|
||||
import BackupPanel from '../components/Admin/BackupPanel'
|
||||
import GitHubPanel from '../components/Admin/GitHubPanel'
|
||||
@@ -160,6 +162,21 @@ function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast
|
||||
)
|
||||
}
|
||||
|
||||
function AdminStatCard({ label, value, icon: Icon }: { label: string; value: number; icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }> }): React.ReactElement {
|
||||
const animated = useCountUp(value, 900)
|
||||
return (
|
||||
<div className="rounded-xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
|
||||
<div>
|
||||
<p className="text-xl font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{animated}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminPage(): React.ReactElement {
|
||||
const { demoMode, serverTimezone } = useAuthStore()
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -169,6 +186,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
const TABS = [
|
||||
{ id: 'users', label: t('admin.tabs.users') },
|
||||
{ id: 'config', label: t('admin.tabs.config') },
|
||||
{ id: 'defaults', label: t('admin.tabs.defaults') },
|
||||
{ id: 'addons', label: t('admin.tabs.addons') },
|
||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||
{ id: 'notifications', label: t('admin.tabs.notifications') },
|
||||
@@ -192,6 +210,22 @@ export default function AdminPage(): React.ReactElement {
|
||||
const [bagTrackingEnabled, setBagTrackingEnabled] = useState<boolean>(false)
|
||||
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
|
||||
|
||||
// Places photos
|
||||
const [placesPhotosEnabled, setPlacesPhotosEnabledState] = useState<boolean>(true)
|
||||
useEffect(() => { adminApi.getPlacesPhotos().then(d => setPlacesPhotosEnabledState(d.enabled)).catch(() => {}) }, [])
|
||||
|
||||
// Places autocomplete
|
||||
const [placesAutocompleteEnabled, setPlacesAutocompleteEnabledState] = useState<boolean>(true)
|
||||
useEffect(() => { adminApi.getPlacesAutocomplete().then(d => setPlacesAutocompleteEnabledState(d.enabled)).catch(() => {}) }, [])
|
||||
|
||||
// Places details
|
||||
const [placesDetailsEnabled, setPlacesDetailsEnabledState] = useState<boolean>(true)
|
||||
useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, [])
|
||||
|
||||
// Collab features
|
||||
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true })
|
||||
useEffect(() => { adminApi.getCollabFeatures().then(d => setCollabFeatures(d)).catch(() => {}) }, [])
|
||||
|
||||
// OIDC config
|
||||
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', discovery_url: '' })
|
||||
const [savingOidc, setSavingOidc] = useState<boolean>(false)
|
||||
@@ -236,7 +270,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
|
||||
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
|
||||
|
||||
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, logout } = useAuthStore()
|
||||
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
@@ -547,15 +581,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
{ label: t('admin.stats.places'), value: stats.totalPlaces, icon: Map },
|
||||
{ label: t('admin.stats.files'), value: stats.totalFiles || 0, icon: FileText },
|
||||
].map(({ label, value, icon: Icon }) => (
|
||||
<div key={label} className="rounded-xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
|
||||
<div>
|
||||
<p className="text-xl font-bold" style={{ color: 'var(--text-primary)' }}>{value}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AdminStatCard key={label} label={label} value={value} icon={Icon} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -611,7 +637,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
<th className="px-5 py-3 text-right">{t('admin.table.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
<tbody className="divide-y divide-slate-100 trek-stagger">
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className={`hover:bg-slate-50 transition-colors ${u.id === currentUser?.id ? 'bg-slate-50/60' : ''}`}>
|
||||
<td className="px-5 py-3">
|
||||
@@ -797,6 +823,10 @@ export default function AdminPage(): React.ReactElement {
|
||||
const next = !bagTrackingEnabled
|
||||
setBagTrackingEnabled(next)
|
||||
try { await adminApi.updateBagTracking(next) } catch { setBagTrackingEnabled(!next) }
|
||||
}} collabFeatures={collabFeatures} onToggleCollabFeature={async (key: string) => {
|
||||
const next = { ...collabFeatures, [key]: !collabFeatures[key] }
|
||||
setCollabFeatures(next)
|
||||
try { await adminApi.updateCollabFeatures({ [key]: next[key] }) } catch { setCollabFeatures(collabFeatures) }
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
@@ -881,7 +911,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggleAuthSetting('oidc_registration', !oidcRegistration, setOidcRegistration)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||
style={{ background: oidcRegistration ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span
|
||||
@@ -908,7 +938,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleRequireMfa(!requireMfa)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||
style={{ background: requireMfa ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span
|
||||
@@ -1013,6 +1043,66 @@ export default function AdminPage(): React.ReactElement {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Place Photos Toggle */}
|
||||
<div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.placesPhotos.title')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesPhotos.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const next = !placesPhotosEnabled
|
||||
setPlacesPhotosEnabledState(next)
|
||||
setPlacesPhotosEnabled(next)
|
||||
try { await adminApi.updatePlacesPhotos(next) } catch { setPlacesPhotosEnabledState(!next); setPlacesPhotosEnabled(!next) }
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||
style={{ background: placesPhotosEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesPhotosEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Place Autocomplete Toggle */}
|
||||
<div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.placesAutocomplete.title')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesAutocomplete.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const next = !placesAutocompleteEnabled
|
||||
setPlacesAutocompleteEnabledState(next)
|
||||
setPlacesAutocompleteEnabled(next)
|
||||
try { await adminApi.updatePlacesAutocomplete(next) } catch { setPlacesAutocompleteEnabledState(!next); setPlacesAutocompleteEnabled(!next) }
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||
style={{ background: placesAutocompleteEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesAutocompleteEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Place Details Toggle */}
|
||||
<div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.placesDetails.title')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesDetails.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const next = !placesDetailsEnabled
|
||||
setPlacesDetailsEnabledState(next)
|
||||
setPlacesDetailsEnabled(next)
|
||||
try { await adminApi.updatePlacesDetails(next) } catch { setPlacesDetailsEnabledState(!next); setPlacesDetailsEnabled(!next) }
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||
style={{ background: placesDetailsEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesDetailsEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Open-Meteo Weather Info */}
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-800 overflow-hidden">
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
@@ -1170,6 +1260,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
const emailActive = activeChans.includes('email')
|
||||
const webhookActive = activeChans.includes('webhook')
|
||||
const ntfyActive = activeChans.includes('ntfy')
|
||||
const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false'
|
||||
|
||||
const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => {
|
||||
const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy'].filter(Boolean).join(',') || 'none'
|
||||
@@ -1245,7 +1336,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
|
||||
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||
style={{ background: smtpValues.smtp_skip_tls_verify === 'true' ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: smtpValues.smtp_skip_tls_verify === 'true' ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
@@ -1328,6 +1419,37 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trip Reminders Toggle */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.notifications.tripReminders.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.tripReminders.hint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const next = !tripRemindersActive
|
||||
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: next ? 'true' : 'false' }))
|
||||
try {
|
||||
await authApi.updateAppSettings({ notify_trip_reminder: next ? 'true' : 'false' })
|
||||
toast.success(next ? t('admin.notifications.tripReminders.enabled') : t('admin.notifications.tripReminders.disabled'))
|
||||
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||
}).catch(() => {})
|
||||
} catch {
|
||||
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: tripRemindersActive ? 'true' : 'false' }))
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
|
||||
style={{ background: tripRemindersActive ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: tripRemindersActive ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Webhook Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
@@ -1396,6 +1518,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
placeholder={t('admin.notifications.adminNtfyPanel.serverPlaceholder')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminNtfyPanel.serverHint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminNtfyPanel.topicLabel')}</label>
|
||||
@@ -1409,13 +1532,29 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminNtfyPanel.tokenLabel')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={smtpValues.admin_ntfy_token === '••••••••' ? '' : smtpValues.admin_ntfy_token || ''}
|
||||
onChange={e => setSmtpValues(prev => ({ ...prev, admin_ntfy_token: e.target.value }))}
|
||||
placeholder={smtpValues.admin_ntfy_token === '••••••••' ? '••••••••' : ''}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={smtpValues.admin_ntfy_token === '••••••••' ? '' : smtpValues.admin_ntfy_token || ''}
|
||||
onChange={e => setSmtpValues(prev => ({ ...prev, admin_ntfy_token: e.target.value }))}
|
||||
placeholder={smtpValues.admin_ntfy_token === '••••••••' ? '••••••••' : ''}
|
||||
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
{smtpValues.admin_ntfy_token === '••••••••' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await authApi.updateAppSettings({ admin_ntfy_token: '' })
|
||||
setSmtpValues(prev => ({ ...prev, admin_ntfy_token: '' }))
|
||||
toast.success(t('admin.notifications.adminNtfyPanel.tokenCleared'))
|
||||
} catch { toast.error(t('common.error')) }
|
||||
}}
|
||||
className="px-3 py-2 border border-red-300 text-red-600 rounded-lg text-sm font-medium hover:bg-red-50 transition-colors"
|
||||
>
|
||||
{t('common.clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1476,6 +1615,8 @@ export default function AdminPage(): React.ReactElement {
|
||||
|
||||
{activeTab === 'github' && <GitHubPanel isPrerelease={updateInfo?.is_prerelease ?? false} />}
|
||||
|
||||
{activeTab === 'defaults' && <DefaultUserSettingsTab />}
|
||||
|
||||
{activeTab === 'dev-notifications' && <DevNotificationsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -938,7 +938,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
ref={panelRef}
|
||||
onMouseMove={handlePanelMouseMove}
|
||||
onMouseLeave={handlePanelMouseLeave}
|
||||
className="hidden md:flex flex-col absolute z-10 overflow-hidden transition-all duration-300"
|
||||
className="hidden md:flex flex-col absolute z-10 overflow-hidden transition-[width,height,transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{
|
||||
bottom: 16,
|
||||
left: '50%',
|
||||
|
||||
@@ -416,15 +416,10 @@ describe('DashboardPage', () => {
|
||||
expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Find settings button — it's the gear icon button without title or text
|
||||
// Find settings button — the gear icon button (icon-only, no visible label)
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const settingsButton = allBtns.find(
|
||||
btn => {
|
||||
const title = btn.getAttribute('title');
|
||||
const text = btn.textContent?.trim() || '';
|
||||
// Settings gear: no title, no meaningful text, not the notification bell
|
||||
return !title && !text && btn.querySelector('.lucide-settings');
|
||||
}
|
||||
const settingsButton = allBtns.find(btn =>
|
||||
btn.querySelector('.lucide-settings') && !btn.textContent?.trim()
|
||||
);
|
||||
|
||||
expect(settingsButton).toBeDefined();
|
||||
@@ -646,14 +641,10 @@ describe('DashboardPage', () => {
|
||||
expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Open widget settings
|
||||
// Open widget settings — gear icon button (icon-only, no visible label)
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const settingsButton = allBtns.find(
|
||||
btn => {
|
||||
const title = btn.getAttribute('title');
|
||||
const text = btn.textContent?.trim() || '';
|
||||
return !title && !text && btn.querySelector('.lucide-settings');
|
||||
}
|
||||
const settingsButton = allBtns.find(btn =>
|
||||
btn.querySelector('.lucide-settings') && !btn.textContent?.trim()
|
||||
);
|
||||
|
||||
expect(settingsButton).toBeDefined();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { tripsApi } from '../api/client'
|
||||
import { tripRepo } from '../repo/tripRepo'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
@@ -13,6 +13,7 @@ import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
|
||||
import TripFormModal from '../components/Trips/TripFormModal'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { useCountUp } from '../hooks/useCountUp'
|
||||
import {
|
||||
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
||||
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft, Users,
|
||||
@@ -152,6 +153,28 @@ interface TripCardProps {
|
||||
dark?: boolean
|
||||
}
|
||||
|
||||
function SpotlightStats({ trip, totalDays, t }: { trip: DashboardTrip; totalDays: number; t: TripCardProps['t'] }): React.ReactElement {
|
||||
const days = useCountUp(trip.day_count || totalDays)
|
||||
const places = useCountUp(trip.place_count || 0)
|
||||
const buddies = useCountUp(trip.shared_count || 0)
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2.5 p-3.5 bg-black/25 backdrop-blur-sm border border-white/10 rounded-2xl">
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none tabular-nums">{days}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.days')}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none tabular-nums">{places}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.places')}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none tabular-nums">{buddies}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.buddies')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: TripCardProps): React.ReactElement {
|
||||
const status = getTripStatus(trip)
|
||||
const isLive = status === 'ongoing'
|
||||
@@ -173,16 +196,16 @@ function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick(trip)}
|
||||
className="group relative rounded-3xl overflow-hidden cursor-pointer mb-8"
|
||||
style={{ minHeight: 340, boxShadow: '0 8px 40px rgba(0,0,0,0.13)' }}
|
||||
className="group relative rounded-3xl overflow-hidden cursor-pointer mb-8 transition-[transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-1 hover:shadow-[0_16px_60px_rgba(0,0,0,0.22)] active:scale-[0.995]"
|
||||
style={{ minHeight: 340, boxShadow: '0 8px 40px rgba(0,0,0,0.13)', isolation: 'isolate' }}
|
||||
>
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0" style={{
|
||||
<div className="absolute inset-0 overflow-hidden rounded-3xl" style={{
|
||||
background: trip.cover_image ? undefined : tripGradient(trip.id),
|
||||
}}>
|
||||
{trip.cover_image && (
|
||||
<>
|
||||
<img src={trip.cover_image} className="w-full h-full object-cover" alt="" />
|
||||
<img src={trip.cover_image} className="w-full h-full object-cover transition-transform duration-[1200ms] ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-[1.06]" alt="" />
|
||||
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.6) 100%)' }} />
|
||||
</>
|
||||
)}
|
||||
@@ -233,7 +256,14 @@ function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
|
||||
<span className="opacity-70">{t('dashboard.mobile.daysLeft', { count: daysLeft })}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-white/15 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-white rounded-full relative" style={{ width: `${progress}%` }}>
|
||||
<div
|
||||
className="h-full bg-white rounded-full relative"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
animation: 'trek-progress-fill 900ms cubic-bezier(0.23,1,0.32,1) both',
|
||||
['--trek-progress-to' as string]: `${progress}%`,
|
||||
}}
|
||||
>
|
||||
<span className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-[0_0_12px_rgba(255,255,255,0.9)]" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,20 +271,7 @@ function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-2.5 p-3.5 bg-black/25 backdrop-blur-sm border border-white/10 rounded-2xl">
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{trip.day_count || totalDays}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.days')}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{trip.place_count || 0}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.places')}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{trip.shared_count || 0}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.buddies')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<SpotlightStats trip={trip} totalDays={totalDays} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -278,13 +295,13 @@ function MobileTripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(trip)}
|
||||
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
className="group rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-[transform,box-shadow,border-color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-md"
|
||||
style={{ background: 'var(--bg-card)', isolation: 'isolate' }}
|
||||
>
|
||||
{/* Cover */}
|
||||
<div className="relative h-[120px] overflow-hidden" style={{ background: trip.cover_image ? undefined : tripGradient(trip.id) }}>
|
||||
{trip.cover_image && (
|
||||
<img src={trip.cover_image} className="absolute inset-0 w-full h-full object-cover" alt="" />
|
||||
<img src={trip.cover_image} className="absolute inset-0 w-full h-full object-cover transition-transform duration-[800ms] ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-[1.08]" alt="" />
|
||||
)}
|
||||
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.5) 100%)' }} />
|
||||
|
||||
@@ -370,13 +387,13 @@ function TripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, local
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick(trip)}
|
||||
className="group rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-lg hover:border-zinc-300 dark:hover:border-zinc-600"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
className="group rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-[transform,box-shadow,border-color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:border-zinc-300 dark:hover:border-zinc-600"
|
||||
style={{ background: 'var(--bg-card)', isolation: 'isolate' }}
|
||||
>
|
||||
{/* Cover */}
|
||||
<div className="relative h-[140px] overflow-hidden" style={{ background: trip.cover_image ? undefined : tripGradient(trip.id) }}>
|
||||
{trip.cover_image && (
|
||||
<img src={trip.cover_image} className="absolute inset-0 w-full h-full object-cover" alt="" />
|
||||
<img src={trip.cover_image} className="absolute inset-0 w-full h-full object-cover transition-transform duration-[800ms] ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-[1.08]" alt="" />
|
||||
)}
|
||||
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.55) 100%)' }} />
|
||||
|
||||
@@ -658,11 +675,14 @@ function IconBtn({ onClick, title, danger, loading, children }: { onClick: () =>
|
||||
// ── Skeleton ─────────────────────────────────────────────────────────────────
|
||||
function SkeletonCard(): React.ReactElement {
|
||||
return (
|
||||
<div style={{ background: 'white', borderRadius: 16, overflow: 'hidden', border: '1px solid #f3f4f6' }}>
|
||||
<div style={{ height: 120, background: '#f3f4f6', animation: 'pulse 1.5s ease-in-out infinite' }} />
|
||||
<div style={{ padding: '12px 14px 14px' }}>
|
||||
<div style={{ height: 14, background: '#f3f4f6', borderRadius: 6, marginBottom: 8, width: '70%' }} />
|
||||
<div style={{ height: 11, background: '#f3f4f6', borderRadius: 6, width: '50%' }} />
|
||||
<div
|
||||
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
>
|
||||
<div className="trek-skeleton" style={{ height: 120, borderRadius: 0 }} />
|
||||
<div style={{ padding: '12px 14px 14px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="trek-skeleton" style={{ height: 14, width: '70%' }} />
|
||||
<div className="trek-skeleton" style={{ height: 11, width: '50%' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -689,6 +709,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
}
|
||||
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const { demoMode, user } = useAuthStore()
|
||||
@@ -709,6 +730,13 @@ export default function DashboardPage(): React.ReactElement {
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [showWidgetSettings])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('create') === '1') {
|
||||
setShowForm(true)
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => { loadTrips() }, [])
|
||||
|
||||
const loadTrips = async () => {
|
||||
@@ -889,61 +917,74 @@ export default function DashboardPage(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop header */}
|
||||
<div className="hidden md:flex" style={{ alignItems: 'center', justifyContent: 'space-between', marginBottom: 28 }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 800, color: 'var(--text-primary)' }}>{t('dashboard.title')}</h1>
|
||||
<p style={{ margin: '3px 0 0', fontSize: 13, color: '#9ca3af' }}>
|
||||
{/* Desktop header — unified toolbar */}
|
||||
<div className="hidden md:block" style={{ marginBottom: 20 }}>
|
||||
<div style={{
|
||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||
border: '1px solid var(--border-primary)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
||||
padding: '14px 16px 14px 22px',
|
||||
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
{t('dashboard.title')}
|
||||
</h2>
|
||||
<div style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{isLoading ? t('common.loading')
|
||||
: trips.length > 0 ? `${t(trips.length !== 1 ? 'dashboard.subtitle.activeMany' : 'dashboard.subtitle.activeOne', { count: trips.length })}${archivedTrips.length > 0 ? t('dashboard.subtitle.archivedSuffix', { count: archivedTrips.length }) : ''}`
|
||||
: t('dashboard.subtitle.empty')}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
|
||||
{/* View mode toggle */}
|
||||
<button
|
||||
onClick={toggleViewMode}
|
||||
title={viewMode === 'grid' ? t('dashboard.listView') : t('dashboard.gridView')}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0 14px', height: 37,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||
transition: 'background 0.15s, border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.borderColor = 'var(--text-faint)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||
>
|
||||
{viewMode === 'grid' ? <List size={15} /> : <LayoutGrid size={15} />}
|
||||
</button>
|
||||
{/* Widget settings */}
|
||||
<button
|
||||
onClick={() => setShowWidgetSettings(s => s ? false : true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0 14px', height: 37,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||
transition: 'background 0.15s, border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.borderColor = 'var(--text-faint)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||
>
|
||||
<Settings size={15} />
|
||||
</button>
|
||||
{can('trip_create') && <button
|
||||
onClick={() => { setEditingTrip(null); setShowForm(true) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 7, padding: '9px 18px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12,
|
||||
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Plus size={15} /> {t('dashboard.newTrip')}
|
||||
</button>}
|
||||
</span>
|
||||
|
||||
<div style={{ display: 'inline-flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={toggleViewMode}
|
||||
title={viewMode === 'grid' ? t('dashboard.listView') : t('dashboard.gridView')}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '7px 11px', borderRadius: 99,
|
||||
background: 'transparent', color: 'var(--text-muted)',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-muted)' }}
|
||||
>
|
||||
{viewMode === 'grid' ? <List size={15} /> : <LayoutGrid size={15} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowWidgetSettings(s => s ? false : true)}
|
||||
title={t('dashboard.widgets') || 'Widgets'}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '7px 11px', borderRadius: 99,
|
||||
background: showWidgetSettings ? 'var(--bg-card)' : 'transparent',
|
||||
color: showWidgetSettings ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
boxShadow: showWidgetSettings ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => { if (!showWidgetSettings) { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.color = 'var(--text-primary)' } }}
|
||||
onMouseLeave={e => { if (!showWidgetSettings) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-muted)' } }}
|
||||
>
|
||||
<Settings size={15} />
|
||||
</button>
|
||||
{can('trip_create') && (
|
||||
<button
|
||||
onClick={() => { setEditingTrip(null); setShowForm(true) }}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
marginLeft: 2,
|
||||
}}
|
||||
className="hover:opacity-[0.88]"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.5} /> {t('dashboard.newTrip')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -981,7 +1022,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
{/* Loading skeletons */}
|
||||
{isLoading && (
|
||||
<>
|
||||
<div style={{ height: 260, background: '#e5e7eb', borderRadius: 20, marginBottom: 32, animation: 'pulse 1.5s ease-in-out infinite' }} />
|
||||
<div className="trek-skeleton" style={{ height: 260, borderRadius: 24, marginBottom: 32 }} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
|
||||
{[1, 2, 3].map(i => <SkeletonCard key={i} />)}
|
||||
</div>
|
||||
@@ -1047,7 +1088,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
{/* Trips — desktop grid or list */}
|
||||
{!isLoading && (viewMode === 'grid' ? rest : trips).length > 0 && (
|
||||
viewMode === 'grid' ? (
|
||||
<div className="trip-grid hidden md:grid" style={{ gap: 16, marginBottom: 40 }}>
|
||||
<div className="trip-grid hidden md:grid trek-stagger" style={{ gap: 16, marginBottom: 40 }}>
|
||||
{rest.map(trip => (
|
||||
<TripCard
|
||||
key={trip.id}
|
||||
@@ -1062,7 +1103,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden md:flex" style={{ flexDirection: 'column', gap: 8, marginBottom: 40 }}>
|
||||
<div className="hidden md:flex trek-stagger" style={{ flexDirection: 'column', gap: 8, marginBottom: 40 }}>
|
||||
{trips.map(trip => (
|
||||
<TripListItem
|
||||
key={trip.id}
|
||||
|
||||
@@ -176,7 +176,7 @@ const mockJourneyDetail = {
|
||||
avatar: null,
|
||||
},
|
||||
],
|
||||
stats: { entries: 2, photos: 1, cities: 2 },
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
};
|
||||
|
||||
// ── MSW Handlers ─────────────────────────────────────────────────────────────
|
||||
@@ -265,8 +265,8 @@ describe('JourneyDetailPage', () => {
|
||||
await renderAndWait();
|
||||
const timelineBtn = screen.getByRole('button', { name: /timeline/i });
|
||||
expect(timelineBtn).toBeInTheDocument();
|
||||
// Timeline entries are visible by default
|
||||
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
|
||||
// Timeline entries are visible by default (gallery also mounted but hidden, so multiple matches are expected)
|
||||
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -274,8 +274,8 @@ describe('JourneyDetailPage', () => {
|
||||
describe('FE-PAGE-JOURNEYDETAIL-004: Shows entry cards with titles', () => {
|
||||
it('renders all entry titles in timeline view', async () => {
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
|
||||
expect(screen.getByText('Florence Day')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -362,12 +362,12 @@ describe('JourneyDetailPage', () => {
|
||||
expect(screen.getAllByText('Days').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Entries').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Photos').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Cities').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Places').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders stat values', async () => {
|
||||
await renderAndWait();
|
||||
// stats.entries = 2, stats.photos = 1, stats.cities = 2
|
||||
// stats.entries = 2, stats.photos = 1, stats.places = 2
|
||||
// Entries count appears in hero and sidebar
|
||||
const twos = screen.getAllByText('2');
|
||||
expect(twos.length).toBeGreaterThanOrEqual(1);
|
||||
@@ -474,7 +474,7 @@ describe('JourneyDetailPage', () => {
|
||||
// ── FE-PAGE-JOURNEYDETAIL-018 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-018: Empty state when no entries', () => {
|
||||
it('shows "No entries yet" when journey has no entries', async () => {
|
||||
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, cities: 0 } });
|
||||
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, places: 0 } });
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
|
||||
@@ -484,7 +484,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
it('shows hint text to add a trip', async () => {
|
||||
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, cities: 0 } });
|
||||
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, places: 0 } });
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
|
||||
@@ -567,7 +567,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [multiPhotoEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 3, cities: 2 },
|
||||
stats: { entries: 2, photos: 3, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -610,12 +610,12 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [...mockJourneyDetail.entries, skeletonEntry],
|
||||
stats: { entries: 3, photos: 1, cities: 3 },
|
||||
stats: { entries: 3, photos: 1, places: 3 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Venice Visit')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Venice Visit').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Skeleton card shows "Add Entry" CTA
|
||||
@@ -650,15 +650,15 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [...mockJourneyDetail.entries, checkinEntry],
|
||||
stats: { entries: 3, photos: 1, cities: 2 },
|
||||
stats: { entries: 3, photos: 1, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Quick stop at cafe')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Quick stop at cafe').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Cafe Roma/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Cafe Roma/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Grabbed an espresso')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -674,7 +674,10 @@ describe('JourneyDetailPage', () => {
|
||||
// ── FE-PAGE-JOURNEYDETAIL-027 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-027: Shows loading spinner before data loads', () => {
|
||||
it('renders a spinner while journey data is loading', () => {
|
||||
// Do NOT await the waitFor -- we check the loading state before data arrives
|
||||
// Pre-seed the store into a loading state (current: null, loading: true).
|
||||
// We can't rely on render() timing because RTL wraps in act(), which flushes
|
||||
// all microtasks including the MSW response before render() returns.
|
||||
useJourneyStore.setState({ loading: true, current: null });
|
||||
render(<JourneyDetailPage />);
|
||||
// The spinner has animate-spin class on a div
|
||||
const spinner = document.querySelector('.animate-spin');
|
||||
@@ -704,15 +707,26 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-030 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-030: Active status badge shows Live indicator', () => {
|
||||
it('renders a "Live" badge for active journeys', async () => {
|
||||
it('renders a "Live" badge when linked trip spans today', async () => {
|
||||
setupDefaultHandlers({
|
||||
trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }],
|
||||
});
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('Live')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render "Live" badge when linked trip is in the past', async () => {
|
||||
await renderAndWait();
|
||||
expect(screen.queryByText('Live')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-031 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-031: Synced with Trips badge renders', () => {
|
||||
it('renders the "Synced with Trips" text in the hero', async () => {
|
||||
it('renders the "Synced with Trips" text in the hero for live journeys', async () => {
|
||||
setupDefaultHandlers({
|
||||
trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }],
|
||||
});
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('Synced with Trips')).toBeInTheDocument();
|
||||
});
|
||||
@@ -738,7 +752,7 @@ describe('JourneyDetailPage', () => {
|
||||
it('shows the place count in the sidebar map', async () => {
|
||||
await renderAndWait();
|
||||
// The sidebar map shows "N Places" text
|
||||
expect(screen.getByText(/Places/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Places/).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1103,8 +1117,9 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
// Map view renders a location list with entry titles/location names
|
||||
// The MapView component shows entry names in clickable location items
|
||||
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
|
||||
expect(screen.getByText('Florence Day')).toBeInTheDocument();
|
||||
// (timeline is still mounted but hidden, so multiple matches are expected)
|
||||
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1163,8 +1178,8 @@ describe('JourneyDetailPage', () => {
|
||||
expect(dayBadges.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Each day group shows its entries
|
||||
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
|
||||
expect(screen.getByText('Florence Day')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1714,7 +1729,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [emptyEntry],
|
||||
stats: { entries: 1, photos: 0, cities: 1 },
|
||||
stats: { entries: 1, photos: 0, places: 1 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -1864,8 +1879,10 @@ describe('JourneyDetailPage', () => {
|
||||
expect(screen.getAllByTestId('journey-map').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Click the "Arrived in Rome" location item
|
||||
const romeItem = screen.getByText('Arrived in Rome');
|
||||
// Click the "Arrived in Rome" location item in the map view's location list
|
||||
// (timeline is still mounted but hidden, so find the one inside a cursor-pointer container)
|
||||
const romeItems = screen.getAllByText('Arrived in Rome');
|
||||
const romeItem = romeItems.find(el => el.closest('[class*="cursor-pointer"]')) ?? romeItems[0];
|
||||
await user.click(romeItem);
|
||||
|
||||
// After clicking, the item should gain active styles (translate-x-0.5 on the container)
|
||||
@@ -1927,7 +1944,7 @@ describe('JourneyDetailPage', () => {
|
||||
{ ...mockJourneyDetail.entries[0], id: 10, entry_date: '2026-03-15' },
|
||||
{ ...mockJourneyDetail.entries[1], id: 11, entry_date: '2026-03-15', location_lat: 41.95, location_lng: 12.55 },
|
||||
];
|
||||
setupDefaultHandlers({ entries: twoOnSameDay, stats: { entries: 2, photos: 1, cities: 2 } });
|
||||
setupDefaultHandlers({ entries: twoOnSameDay, stats: { entries: 2, photos: 1, places: 2 } });
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
await waitFor(() => {
|
||||
@@ -2002,7 +2019,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [immichEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 1, cities: 2 },
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -2036,7 +2053,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [synologyEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 1, cities: 2 },
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -2633,7 +2650,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [multiPhotoEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 5, cities: 2 },
|
||||
stats: { entries: 2, photos: 5, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -2658,7 +2675,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [twoPhotoEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 2, cities: 2 },
|
||||
stats: { entries: 2, photos: 2, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -3042,7 +3059,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [mockJourneyDetail.entries[0], noLocEntry],
|
||||
stats: { entries: 2, photos: 1, cities: 1 },
|
||||
stats: { entries: 2, photos: 1, places: 1 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -3525,7 +3542,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [entryWithMultiPhotos, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 2, cities: 2 },
|
||||
stats: { entries: 2, photos: 2, places: 2 },
|
||||
});
|
||||
|
||||
server.use(
|
||||
@@ -3562,8 +3579,8 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-148 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-148: EntryEditor file upload for existing entry calls API directly', () => {
|
||||
it('uploading a file on an existing entry calls the upload API immediately', async () => {
|
||||
describe('FE-PAGE-JOURNEYDETAIL-148: EntryEditor queues file uploads until save (#727)', () => {
|
||||
it('uploading a file on an existing entry stays pending until Save is clicked', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
let uploadCalled = false;
|
||||
|
||||
@@ -3601,7 +3618,11 @@ describe('JourneyDetailPage', () => {
|
||||
const testFile = new File(['data'], 'upload.jpg', { type: 'image/jpeg' });
|
||||
await user.upload(fileInput, testFile);
|
||||
|
||||
// For existing entries, upload happens immediately
|
||||
// Picked file is queued locally — upload should NOT fire until Save.
|
||||
expect(uploadCalled).toBe(false);
|
||||
|
||||
// Saving triggers the queued upload.
|
||||
await user.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(uploadCalled).toBe(true);
|
||||
});
|
||||
@@ -3617,7 +3638,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [mockJourneyDetail.entries[0], noTitleEntry],
|
||||
stats: { entries: 2, photos: 1, cities: 2 },
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
|
||||
@@ -19,8 +19,13 @@ import {
|
||||
UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil,
|
||||
Laugh, Smile, Meh, Annoyed, Frown,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
|
||||
Archive, ArchiveRestore,
|
||||
} from 'lucide-react'
|
||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||
import { useIsMobile } from '../hooks/useIsMobile'
|
||||
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
|
||||
const GRADIENTS = [
|
||||
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
||||
@@ -84,7 +89,15 @@ export default function JourneyDetailPage() {
|
||||
const fullMapRef = useRef<JourneyMapHandle>(null)
|
||||
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
// Role-based permissions (server-provided via my_role). Fall back to
|
||||
// "owner" when the field isn't present yet (legacy responses) so behavior
|
||||
// matches the pre-permissions era.
|
||||
const myRole = (current as any)?.my_role ?? 'owner'
|
||||
const canEditEntries = myRole === 'owner' || myRole === 'editor'
|
||||
const canEditJourney = myRole === 'owner'
|
||||
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
|
||||
const [viewingEntry, setViewingEntry] = useState<JourneyEntry | null>(null)
|
||||
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(null)
|
||||
const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null }[]; index: number } | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<JourneyEntry | null>(null)
|
||||
@@ -158,6 +171,12 @@ export default function JourneyDetailPage() {
|
||||
setActiveLocationId(id)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (view === 'map') {
|
||||
requestAnimationFrame(() => fullMapRef.current?.invalidateSize())
|
||||
}
|
||||
}, [view])
|
||||
|
||||
const mapEntries = useMemo(
|
||||
() => (current?.entries || []).filter(e => e.location_lat && e.location_lng),
|
||||
[current?.entries]
|
||||
@@ -202,10 +221,100 @@ export default function JourneyDetailPage() {
|
||||
const dayGroups = groupByDate(timelineEntries)
|
||||
const sortedDates = [...dayGroups.keys()].sort()
|
||||
|
||||
const tripDateMin = current.trips.length
|
||||
? current.trips.reduce((min: string, t: any) => t.start_date && (!min || t.start_date < min) ? t.start_date : min, '')
|
||||
: null
|
||||
const tripDateMax = current.trips.length
|
||||
? current.trips.reduce((max: string, t: any) => t.end_date && (!max || t.end_date > max) ? t.end_date : max, '')
|
||||
: null
|
||||
const lifecycle = computeJourneyLifecycle(current.status, tripDateMin || null, tripDateMax || null)
|
||||
|
||||
const showMobileCombined = isMobile && view === 'timeline'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
<Navbar />
|
||||
<div style={{ paddingTop: 'var(--nav-h, 0px)' }}>
|
||||
|
||||
{/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */}
|
||||
{showMobileCombined && (
|
||||
<MobileMapTimeline
|
||||
entries={timelineEntries}
|
||||
mapEntries={sidebarMapItems}
|
||||
dark={document.documentElement.classList.contains('dark')}
|
||||
readOnly={!canEditEntries}
|
||||
onEntryClick={(entry) => setViewingEntry(entry)}
|
||||
onAddEntry={canEditEntries ? () => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
|
||||
} : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fullscreen entry view (mobile) */}
|
||||
{viewingEntry && (
|
||||
<MobileEntryView
|
||||
entry={viewingEntry}
|
||||
readOnly={!canEditEntries}
|
||||
onClose={() => setViewingEntry(null)}
|
||||
onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }}
|
||||
onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floating top bar on mobile combined view: back | tabs+title | settings */}
|
||||
{showMobileCombined && (
|
||||
<div
|
||||
className="fixed left-0 right-0 z-30 flex items-start justify-between gap-2 px-4"
|
||||
style={{ top: 'calc(var(--nav-h, 56px) + 12px)' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => navigate('/journey')}
|
||||
aria-label={t('journey.detail.backToJourney')}
|
||||
className="w-10 h-10 flex-shrink-0 rounded-lg bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 shadow-lg text-zinc-700 dark:text-zinc-200 flex items-center justify-center hover:bg-white dark:hover:bg-zinc-800 active:scale-95 transition-transform"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col items-center gap-1">
|
||||
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
|
||||
<button
|
||||
onClick={() => setView('timeline')}
|
||||
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium bg-zinc-900 dark:bg-white text-white dark:text-zinc-900"
|
||||
>
|
||||
<MapPin size={13} />
|
||||
{t('journey.detail.journeyTab') || 'Journey'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('gallery')}
|
||||
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
|
||||
>
|
||||
<Grid size={13} />
|
||||
{t('journey.share.gallery')}
|
||||
</button>
|
||||
</div>
|
||||
{current?.title && (
|
||||
<div className="max-w-full truncate text-center text-[11px] font-medium text-zinc-700 dark:text-zinc-200 px-2.5 py-0.5 rounded-full bg-white/80 dark:bg-zinc-800/80 backdrop-blur-md border border-zinc-200/60 dark:border-zinc-700/60 shadow-sm">
|
||||
{current.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canEditJourney ? (
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
aria-label={t('journey.settings.title')}
|
||||
className="w-10 h-10 flex-shrink-0 rounded-lg bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 shadow-lg text-zinc-700 dark:text-zinc-200 flex items-center justify-center hover:bg-white dark:hover:bg-zinc-800 active:scale-95 transition-transform"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-10 h-10 flex-shrink-0" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className={showMobileCombined ? 'hidden' : ''}>
|
||||
<div className="max-w-[1440px] mx-auto px-0 md:px-8 pt-0 md:py-6">
|
||||
|
||||
{/* Back link — desktop */}
|
||||
@@ -228,16 +337,28 @@ export default function JourneyDetailPage() {
|
||||
<div className="relative z-[3] flex items-center justify-between mb-5">
|
||||
{/* Desktop: badges */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{current.status === 'active' && (
|
||||
{lifecycle === 'live' && (
|
||||
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-white/15 backdrop-blur rounded-full text-[10px] font-semibold uppercase">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Live
|
||||
{t('journey.frontpage.live')}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle !== 'archived' && current.trips.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
<RefreshCw size={11} />
|
||||
{t('journey.detail.syncedWithTrips')}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle !== 'live' && lifecycle !== 'archived' && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
{t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle === 'archived' && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
{t('journey.status.archived')}
|
||||
</div>
|
||||
)}
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
<RefreshCw size={11} />
|
||||
{t('journey.detail.syncedWithTrips')}
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile: back button on the left */}
|
||||
<button
|
||||
@@ -263,7 +384,9 @@ export default function JourneyDetailPage() {
|
||||
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><MoreHorizontal size={14} /></button>
|
||||
{canEditJourney && (
|
||||
<button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><MoreHorizontal size={14} /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -276,7 +399,7 @@ export default function JourneyDetailPage() {
|
||||
<div className="flex gap-8">
|
||||
{[
|
||||
{ value: sortedDates.length, label: t('journey.stats.days') },
|
||||
{ value: current.stats.cities, label: t('journey.stats.cities') },
|
||||
{ value: current.stats.places, label: t('journey.stats.places') },
|
||||
{ value: current.stats.entries, label: t('journey.stats.entries') },
|
||||
{ value: current.stats.photos, label: t('journey.stats.photos') },
|
||||
].map(s => (
|
||||
@@ -298,11 +421,17 @@ export default function JourneyDetailPage() {
|
||||
{/* View Controls */}
|
||||
<div className="flex items-center justify-between mt-5 mb-5">
|
||||
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden">
|
||||
{[
|
||||
{ id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
||||
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||
{ id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||
].map(v => (
|
||||
{(isMobile
|
||||
? [
|
||||
{ id: 'timeline' as const, icon: MapPin, label: t('journey.detail.journeyTab') || 'Journey' },
|
||||
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||
]
|
||||
: [
|
||||
{ id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
||||
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||
{ id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||
]
|
||||
).map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
@@ -317,22 +446,22 @@ export default function JourneyDetailPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{view === 'timeline' && (
|
||||
{canEditEntries && (!isMobile ? view === 'timeline' : view !== 'gallery') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
|
||||
}}
|
||||
className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center hover:bg-zinc-800 dark:hover:bg-zinc-100"
|
||||
className={`w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center hover:bg-zinc-800 dark:hover:bg-zinc-100 ${isMobile && view === 'timeline' ? 'hidden' : ''}`}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
{view === 'timeline' && (
|
||||
<div className="flex flex-col gap-6 pb-24 md:pb-6">
|
||||
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */}
|
||||
{!isMobile && (
|
||||
<div className={`flex flex-col gap-6 pb-24 md:pb-6${view === 'timeline' ? '' : ' hidden'}`}>
|
||||
{sortedDates.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
|
||||
@@ -349,7 +478,7 @@ export default function JourneyDetailPage() {
|
||||
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
|
||||
|
||||
return (
|
||||
<div key={date} className="flex flex-col gap-3">
|
||||
<div key={date} className="flex flex-col gap-3 trek-stagger">
|
||||
<div className="sticky top-0 md:top-[68px] z-[5] bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[13px] font-bold">
|
||||
@@ -367,12 +496,13 @@ export default function JourneyDetailPage() {
|
||||
{entries.map(entry => (
|
||||
<div key={entry.id} data-entry-id={String(entry.id)}>
|
||||
{entry.type === 'skeleton' ? (
|
||||
<SkeletonCard entry={entry} onClick={() => setEditingEntry(entry)} />
|
||||
<SkeletonCard entry={entry} onClick={canEditEntries ? () => setEditingEntry(entry) : undefined} />
|
||||
) : entry.type === 'checkin' ? (
|
||||
<CheckinCard entry={entry} onClick={() => setEditingEntry(entry)} />
|
||||
<CheckinCard entry={entry} onClick={canEditEntries ? () => setEditingEntry(entry) : undefined} />
|
||||
) : (
|
||||
<EntryCard
|
||||
entry={entry}
|
||||
readOnly={!canEditEntries}
|
||||
onEdit={() => setEditingEntry(entry)}
|
||||
onDelete={() => setDeleteTarget(entry)}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
||||
@@ -387,7 +517,7 @@ export default function JourneyDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Gallery View */}
|
||||
{view === 'gallery' && (
|
||||
<div className={view === 'gallery' ? '' : 'hidden'}>
|
||||
<GalleryView
|
||||
entries={current.entries}
|
||||
journeyId={current.id}
|
||||
@@ -396,17 +526,21 @@ export default function JourneyDetailPage() {
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
||||
onRefresh={() => loadJourney(Number(id))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Full Map View */}
|
||||
{view === 'map' && <div className="pb-24 md:pb-6"><MapView
|
||||
entries={current.entries}
|
||||
mapEntries={mapEntries}
|
||||
sortedDates={sortedDates}
|
||||
activeLocationId={activeLocationId}
|
||||
fullMapRef={fullMapRef}
|
||||
onLocationClick={handleLocationClick}
|
||||
/></div>}
|
||||
{/* Full Map View (desktop only — mobile uses combined view) */}
|
||||
{!isMobile && (
|
||||
<div className={`pb-24 md:pb-6${view === 'map' ? '' : ' hidden'}`}>
|
||||
<MapView
|
||||
entries={current.entries}
|
||||
mapEntries={mapEntries}
|
||||
sortedDates={sortedDates}
|
||||
activeLocationId={activeLocationId}
|
||||
fullMapRef={fullMapRef}
|
||||
onLocationClick={handleLocationClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right sidebar — hidden on mobile */}
|
||||
@@ -433,7 +567,7 @@ export default function JourneyDetailPage() {
|
||||
{ value: sortedDates.length, label: t('journey.stats.days') },
|
||||
{ value: current.stats.entries, label: t('journey.stats.entries') },
|
||||
{ value: current.stats.photos, label: t('journey.stats.photos') },
|
||||
{ value: current.stats.cities, label: t('journey.stats.cities') },
|
||||
{ value: current.stats.places, label: t('journey.stats.places') },
|
||||
].map(s => (
|
||||
<div key={s.label} className="rounded-lg bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-zinc-700/50 px-3 py-2.5">
|
||||
<div className="text-[18px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white leading-none mb-0.5">{s.value}</div>
|
||||
@@ -819,12 +953,14 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
if (!files?.length) return
|
||||
setGalleryUploading(true)
|
||||
try {
|
||||
// find existing "Gallery" entry or create one
|
||||
// find existing "Gallery" entry or create one. The stored title is the
|
||||
// literal 'Gallery' (server-side checks look for this exact string) —
|
||||
// do not send a translated label here.
|
||||
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
|
||||
let entryId = galleryEntry?.id
|
||||
if (!entryId) {
|
||||
const entry = await journeyApi.createEntry(journeyId, {
|
||||
title: t('journey.share.gallery'),
|
||||
title: 'Gallery',
|
||||
entry_date: new Date().toISOString().split('T')[0],
|
||||
type: 'entry',
|
||||
})
|
||||
@@ -908,11 +1044,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
|
||||
{allPhotos.map(({ photo, entry }) => (
|
||||
{allPhotos.map(({ photo, entry }, i) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
||||
onClick={() => onPhotoClick(entry.photos, entry.photos.indexOf(photo))}
|
||||
onClick={() => onPhotoClick(allPhotos.map(a => a.photo), i)}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(photo, 'thumbnail')}
|
||||
@@ -960,12 +1096,12 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
trips={trips}
|
||||
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
|
||||
onClose={() => setShowPicker(false)}
|
||||
onAdd={async (assetIds, entryId) => {
|
||||
onAdd={async (groups, entryId) => {
|
||||
let targetId = entryId
|
||||
if (!targetId) {
|
||||
try {
|
||||
const entry = await journeyApi.createEntry(journeyId, {
|
||||
title: t('journey.share.gallery'),
|
||||
title: 'Gallery',
|
||||
entry_date: new Date().toISOString().split('T')[0],
|
||||
type: 'entry',
|
||||
})
|
||||
@@ -973,10 +1109,12 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
} catch { return }
|
||||
}
|
||||
let added = 0
|
||||
try {
|
||||
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds)
|
||||
added = result.added || 0
|
||||
} catch {}
|
||||
for (const group of groups) {
|
||||
try {
|
||||
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase)
|
||||
added += result.added || 0
|
||||
} catch {}
|
||||
}
|
||||
if (added > 0) {
|
||||
toast.success(t('journey.photosAdded', { count: added }))
|
||||
onRefresh()
|
||||
@@ -1139,8 +1277,9 @@ function VerdictSection({ pros, cons }: { pros: string[]; cons: string[] }) {
|
||||
|
||||
// ── Entry Card ────────────────────────────────────────────────────────────
|
||||
|
||||
function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
||||
function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
entry: JourneyEntry
|
||||
readOnly?: boolean
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
||||
@@ -1157,7 +1296,7 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
||||
const hasProscons = prosArr.length > 0 || consArr.length > 0
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden transition-all hover:border-zinc-400 dark:hover:border-zinc-500 hover:shadow-sm">
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:border-zinc-400 dark:hover:border-zinc-500 hover:shadow-sm">
|
||||
|
||||
{/* Hero area: photos with title overlay */}
|
||||
{photos.length > 0 ? (
|
||||
@@ -1183,20 +1322,22 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
||||
</div>
|
||||
|
||||
{/* Menu top-right */}
|
||||
<div className="absolute top-2.5 right-3 z-[2]">
|
||||
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-8 h-8 rounded-[10px] bg-black/40 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/50">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
|
||||
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
|
||||
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
|
||||
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="absolute top-2.5 right-3 z-[2]">
|
||||
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-8 h-8 rounded-[10px] bg-black/40 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/50">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
|
||||
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
|
||||
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
|
||||
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title on photo */}
|
||||
{entry.title && (
|
||||
@@ -1220,20 +1361,22 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-7 h-7 rounded-md flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
|
||||
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
|
||||
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
|
||||
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="relative">
|
||||
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-7 h-7 rounded-md flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
|
||||
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
|
||||
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
|
||||
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1272,12 +1415,12 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
||||
)
|
||||
}
|
||||
|
||||
function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => void }) {
|
||||
function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white dark:bg-zinc-900 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-3.5 flex items-center gap-3 transition-all hover:border-solid hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer"
|
||||
className={`bg-white dark:bg-zinc-900 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-3.5 flex items-center gap-3 transition-[border-color,border-style] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:border-solid hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 flex-shrink-0">
|
||||
<MapPin size={14} />
|
||||
@@ -1297,11 +1440,11 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick: () =>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => void }) {
|
||||
function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3.5 py-2.5 flex items-center gap-2.5 transition-all hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer"
|
||||
className={`bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3.5 py-2.5 flex items-center gap-2.5 transition-colors duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="w-7 h-7 rounded-lg bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center flex-shrink-0">
|
||||
<MapPin size={13} />
|
||||
@@ -1423,6 +1566,24 @@ function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading:
|
||||
)
|
||||
}
|
||||
|
||||
// ── Photo date grouping ───────────────────────────────────────────────────
|
||||
|
||||
function groupPhotosByDate(photos: any[]): { date: string; label: string; assets: any[] }[] {
|
||||
const map = new Map<string, any[]>()
|
||||
for (const asset of photos) {
|
||||
const key = asset.takenAt ? asset.takenAt.slice(0, 10) : '__unknown__'
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(asset)
|
||||
}
|
||||
return [...map.entries()].map(([date, assets]) => ({
|
||||
date,
|
||||
label: date === '__unknown__'
|
||||
? 'Unknown date'
|
||||
: new Date(date + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }),
|
||||
assets,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Provider Picker ───────────────────────────────────────────────────────
|
||||
|
||||
function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: {
|
||||
@@ -1432,20 +1593,21 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
trips: JourneyTrip[]
|
||||
existingAssetIds: Set<string>
|
||||
onClose: () => void
|
||||
onAdd: (assetIds: string[], entryId: number | null) => Promise<void>
|
||||
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise<void>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [albums, setAlbums] = useState<any[]>([])
|
||||
const [albums, setAlbums] = useState<Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>>([])
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
|
||||
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [searchPage, setSearchPage] = useState(1)
|
||||
const [searchFrom, setSearchFrom] = useState('')
|
||||
const [searchTo, setSearchTo] = useState('')
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string }>>(new Map())
|
||||
const [customFrom, setCustomFrom] = useState('')
|
||||
const [customTo, setCustomTo] = useState('')
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
|
||||
@@ -1500,13 +1662,14 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
|
||||
}
|
||||
|
||||
const loadAlbumPhotos = async (albumId: string) => {
|
||||
const loadAlbumPhotos = async (album: { id: string; passphrase?: string }) => {
|
||||
const signal = cancelPending()
|
||||
setLoading(true)
|
||||
setPhotos([])
|
||||
setHasMore(false)
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/albums/${albumId}/photos`, { credentials: 'include', signal })
|
||||
const qs = album.passphrase ? `?passphrase=${encodeURIComponent(album.passphrase)}` : ''
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/albums/${album.id}/photos${qs}`, { credentials: 'include', signal })
|
||||
if (res.ok) setPhotos((await res.json()).assets || [])
|
||||
} catch (e: any) { if (e.name !== 'AbortError') {} }
|
||||
if (!signal.aborted) setLoading(false)
|
||||
@@ -1536,8 +1699,12 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
|
||||
const toggleAsset = (id: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
const next = new Map(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase })
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -1547,7 +1714,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
: t('journey.picker.newGallery')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[85vh] flex flex-col overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
@@ -1567,7 +1734,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
{[
|
||||
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
|
||||
{ id: 'custom' as const, label: t('journey.picker.dateRange') },
|
||||
{ id: 'all' as const, label: t('journey.picker.allPhotos') },
|
||||
{ id: 'all' as const, label: t('journey.picker.allPhotos'), short: t('common.all') },
|
||||
{ id: 'album' as const, label: t('journey.picker.albums') },
|
||||
].map(f => (
|
||||
<button
|
||||
@@ -1579,7 +1746,12 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
: 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
{f.short ? (
|
||||
<>
|
||||
<span className="hidden sm:inline">{f.label}</span>
|
||||
<span className="sm:hidden">{f.short}</span>
|
||||
</>
|
||||
) : f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -1625,7 +1797,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
{albums.map((a: any) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => { setSelectedAlbum(a.id); loadAlbumPhotos(a.id) }}
|
||||
onClick={() => { setSelectedAlbum(a.id); setSelectedAlbumPassphrase(a.passphrase); loadAlbumPhotos(a) }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
|
||||
selectedAlbum === a.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
|
||||
@@ -1699,9 +1871,9 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
<button
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelected(new Set())
|
||||
setSelected(new Map())
|
||||
} else {
|
||||
setSelected(new Set(selectable.map((a: any) => a.id)))
|
||||
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase }])))
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
@@ -1732,51 +1904,60 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5">
|
||||
{photos.map((asset: any) => {
|
||||
const isSelected = selected.has(asset.id)
|
||||
const alreadyAdded = existingAssetIds.has(asset.id)
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
||||
className={`relative aspect-square rounded-lg overflow-hidden ${
|
||||
alreadyAdded
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={e => {
|
||||
const img = e.currentTarget
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original`
|
||||
if (!img.src.includes('/original')) img.src = original
|
||||
}}
|
||||
/>
|
||||
{alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{asset.city && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
|
||||
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{groupPhotosByDate(photos).map(group => (
|
||||
<div key={group.date}>
|
||||
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5 mb-1">
|
||||
{group.assets.map((asset: any) => {
|
||||
const isSelected = selected.has(asset.id)
|
||||
const alreadyAdded = existingAssetIds.has(asset.id)
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
||||
className={`relative aspect-square rounded-lg overflow-hidden ${
|
||||
alreadyAdded
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={e => {
|
||||
const img = e.currentTarget
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`
|
||||
if (!img.src.includes('/original')) img.src = original
|
||||
}}
|
||||
/>
|
||||
{alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{asset.city && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
|
||||
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && !selectedAlbum && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
|
||||
</div>
|
||||
@@ -1794,7 +1975,16 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAdd([...selected], targetEntryId)}
|
||||
onClick={() => {
|
||||
const groupMap = new Map<string | undefined, string[]>()
|
||||
for (const [assetId, { passphrase }] of selected.entries()) {
|
||||
const list = groupMap.get(passphrase) || []
|
||||
list.push(assetId)
|
||||
groupMap.set(passphrase, list)
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([passphrase, assetIds]) => ({ assetIds, passphrase }))
|
||||
onAdd(groups, targetEntryId)
|
||||
}}
|
||||
disabled={selected.size === 0}
|
||||
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
@@ -1838,7 +2028,7 @@ function DatePicker({ value, onChange, tripDates }: {
|
||||
for (let i = 0; i < firstDow; i++) cells.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||
|
||||
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : t('journey.picker.selectDate')
|
||||
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -1847,7 +2037,14 @@ function DatePicker({ value, onChange, tripDates }: {
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-left flex items-center justify-between"
|
||||
>
|
||||
<span>{formatted}</span>
|
||||
{formatted ? (
|
||||
<span>{formatted}</span>
|
||||
) : (
|
||||
<span>
|
||||
<span className="hidden sm:inline">{t('journey.picker.selectDate')}</span>
|
||||
<span className="sm:hidden">{t('common.date')}</span>
|
||||
</span>
|
||||
)}
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
</button>
|
||||
|
||||
@@ -1946,6 +2143,31 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const storyRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Track which fields differ from the entry we started editing so we can
|
||||
// warn before discarding on close/cancel.
|
||||
const originalPros = (entry.pros_cons?.pros ?? []).join('\n')
|
||||
const originalCons = (entry.pros_cons?.cons ?? []).join('\n')
|
||||
const isDirty = (
|
||||
title !== (entry.title || '') ||
|
||||
story !== (entry.story || '') ||
|
||||
entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) ||
|
||||
entryTime !== (entry.entry_time || '') ||
|
||||
locationName !== (entry.location_name || '') ||
|
||||
(locationLat ?? null) !== (entry.location_lat ?? null) ||
|
||||
(locationLng ?? null) !== (entry.location_lng ?? null) ||
|
||||
mood !== (entry.mood || '') ||
|
||||
weather !== (entry.weather || '') ||
|
||||
pros.filter(p => p.trim()).join('\n') !== originalPros ||
|
||||
cons.filter(c => c.trim()).join('\n') !== originalCons ||
|
||||
pendingFiles.length > 0 ||
|
||||
pendingLinkIds.length > 0
|
||||
)
|
||||
|
||||
const handleClose = () => {
|
||||
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
@@ -1960,7 +2182,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
mood: mood || null,
|
||||
weather: weather || null,
|
||||
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
|
||||
type: (entry.type === 'skeleton' && story.trim()) ? 'entry' : undefined,
|
||||
type: ((entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0)) ? 'entry' : undefined),
|
||||
})
|
||||
// upload queued files after entry is created
|
||||
if (pendingFiles.length > 0 && entryId) {
|
||||
@@ -1983,29 +2205,19 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files?.length) return
|
||||
if (entry.id === 0) {
|
||||
// queue files for upload after save
|
||||
setPendingFiles(prev => [...prev, ...Array.from(files)])
|
||||
} else {
|
||||
setUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
for (const f of files) formData.append('photos', f)
|
||||
const newPhotos = await onUploadPhotos(entry.id, formData)
|
||||
if (newPhotos?.length) setPhotos(prev => [...prev, ...newPhotos])
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
// Queue files locally until Save so cancel/close actually discards. This
|
||||
// keeps photo behavior consistent with text fields — no silent persistence.
|
||||
setPendingFiles(prev => [...prev, ...Array.from(files)])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<div className="fixed inset-0 z-[9999] flex items-end sm:items-center sm:justify-center sm:p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}</h2>
|
||||
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<button onClick={handleClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -2158,7 +2370,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{pros.map((p, i) => (
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800/30 rounded-[10px]">
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0" />
|
||||
<input
|
||||
value={p}
|
||||
@@ -2192,7 +2404,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{cons.map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-800/30 rounded-[10px]">
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0" />
|
||||
<input
|
||||
value={c}
|
||||
@@ -2256,7 +2468,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
/>
|
||||
{locationLat && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<MapPin size={13} className="text-emerald-500" />
|
||||
<MapPin size={13} className="text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2303,8 +2515,10 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
const active = mood === key
|
||||
return (
|
||||
<button key={key} onClick={() => setMood(active ? '' : key)}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-all"
|
||||
style={{ background: active ? config.bg : 'transparent', color: active ? config.text : '#71717A', borderColor: active ? config.text + '30' : '#E4E4E7' }}>
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-all ${
|
||||
active ? '' : 'border-zinc-200 dark:border-zinc-700 text-zinc-500'
|
||||
}`}
|
||||
style={active ? { background: config.bg, color: config.text, borderColor: config.text + '30' } : undefined}>
|
||||
<Icon size={12} />
|
||||
{t(config.label)}
|
||||
</button>
|
||||
@@ -2334,8 +2548,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50" style={{ paddingBottom: 'max(16px, env(safe-area-inset-bottom, 16px))' }}>
|
||||
<button onClick={handleClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50">
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
@@ -2384,7 +2598,7 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
@@ -2481,7 +2695,7 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
@@ -2727,6 +2941,21 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
}
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [archiving, setArchiving] = useState(false)
|
||||
|
||||
const handleArchiveToggle = async () => {
|
||||
setArchiving(true)
|
||||
try {
|
||||
const newStatus = journey.status === 'archived' ? 'active' : 'archived'
|
||||
await updateJourney(journey.id, { status: newStatus })
|
||||
toast.success(newStatus === 'archived' ? t('journey.settings.archived') : t('journey.settings.reopened'))
|
||||
onSaved()
|
||||
} catch {
|
||||
toast.error(t('journey.settings.saveFailed'))
|
||||
} finally {
|
||||
setArchiving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
@@ -2738,8 +2967,8 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
|
||||
@@ -2832,6 +3061,25 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
</div>
|
||||
<div className="flex-1 text-[12px] font-medium text-zinc-900 dark:text-white">{c.username}</div>
|
||||
<span className={`text-[9px] font-medium px-1.5 py-0.5 rounded-full ${c.role === 'owner' ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>{c.role}</span>
|
||||
{c.role !== 'owner' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!window.confirm(t('journey.contributors.removeConfirm', { username: c.username }))) return
|
||||
try {
|
||||
await journeyApi.removeContributor(journey.id, c.user_id)
|
||||
toast.success(t('journey.contributors.removed'))
|
||||
onSaved()
|
||||
} catch {
|
||||
toast.error(t('journey.contributors.removeFailed'))
|
||||
}
|
||||
}}
|
||||
aria-label={t('journey.contributors.remove')}
|
||||
title={t('journey.contributors.remove')}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
@@ -2851,16 +3099,28 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-wrap items-center gap-2 px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<div className="flex items-center gap-1.5 px-4 md:px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2 mr-auto"
|
||||
aria-label={t('journey.settings.delete')}
|
||||
title={t('journey.settings.delete')}
|
||||
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-2 md:px-2.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{t('journey.settings.delete')}
|
||||
<Trash2 size={14} />
|
||||
<span className="hidden md:inline">{t('journey.settings.delete')}</span>
|
||||
</button>
|
||||
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving || !title.trim()} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
|
||||
<button
|
||||
onClick={handleArchiveToggle}
|
||||
disabled={archiving}
|
||||
aria-label={journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
|
||||
title={t('journey.settings.endDescription')}
|
||||
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-2 md:px-2.5 text-[12px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded-lg mr-auto disabled:opacity-40"
|
||||
>
|
||||
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
|
||||
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
|
||||
</button>
|
||||
<button onClick={onClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving || !title.trim()} className="h-9 px-3.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,9 @@ function buildJourneyListItem(overrides: Record<string, unknown> = {}) {
|
||||
status: 'draft' as const,
|
||||
entry_count: 0,
|
||||
photo_count: 0,
|
||||
city_count: 0,
|
||||
place_count: 0,
|
||||
trip_date_min: null as string | null,
|
||||
trip_date_max: null as string | null,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
...overrides,
|
||||
@@ -194,7 +196,7 @@ describe('JourneyPage', () => {
|
||||
|
||||
// FE-PAGE-JOURNEY-008
|
||||
it('FE-PAGE-JOURNEY-008: shows active journey hero when active journey exists', async () => {
|
||||
const active = buildJourneyListItem({ id: 10, title: 'Active Trip', status: 'active' });
|
||||
const active = buildJourneyListItem({ id: 10, title: 'Active Trip', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
|
||||
const other = buildJourneyListItem({ id: 11, title: 'Completed Trip', status: 'completed' });
|
||||
setupDefaultHandlers([active, other]);
|
||||
|
||||
@@ -320,13 +322,13 @@ describe('JourneyPage', () => {
|
||||
});
|
||||
|
||||
// FE-PAGE-JOURNEY-013
|
||||
it('FE-PAGE-JOURNEY-013: journey card shows entry/photo/city counts', async () => {
|
||||
it('FE-PAGE-JOURNEY-013: journey card shows entry/photo/place counts', async () => {
|
||||
const j1 = buildJourneyListItem({
|
||||
id: 20,
|
||||
title: 'Stats Journey',
|
||||
entry_count: 12,
|
||||
photo_count: 47,
|
||||
city_count: 5,
|
||||
place_count: 5,
|
||||
});
|
||||
setupDefaultHandlers([j1]);
|
||||
|
||||
@@ -335,7 +337,7 @@ describe('JourneyPage', () => {
|
||||
expect(screen.getByText('Stats Journey')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The card renders entry_count, photo_count, city_count values
|
||||
// The card renders entry_count, photo_count, place_count values
|
||||
expect(screen.getByText('12')).toBeInTheDocument();
|
||||
expect(screen.getByText('47')).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
@@ -361,6 +363,8 @@ describe('JourneyPage', () => {
|
||||
id: 40,
|
||||
title: 'Recent Active',
|
||||
status: 'active',
|
||||
trip_date_min: '2020-01-01',
|
||||
trip_date_max: '2099-12-31',
|
||||
updated_at: Date.now() - 60000, // 1 minute ago
|
||||
});
|
||||
setupDefaultHandlers([active]);
|
||||
@@ -380,6 +384,8 @@ describe('JourneyPage', () => {
|
||||
id: 41,
|
||||
title: 'Hours Active',
|
||||
status: 'active',
|
||||
trip_date_min: '2020-01-01',
|
||||
trip_date_max: '2099-12-31',
|
||||
updated_at: Date.now() - 3 * 3600000, // 3 hours ago
|
||||
});
|
||||
setupDefaultHandlers([active]);
|
||||
@@ -399,6 +405,8 @@ describe('JourneyPage', () => {
|
||||
id: 42,
|
||||
title: 'Days Active',
|
||||
status: 'active',
|
||||
trip_date_min: '2020-01-01',
|
||||
trip_date_max: '2099-12-31',
|
||||
updated_at: Date.now() - 5 * 24 * 3600000, // 5 days ago
|
||||
});
|
||||
setupDefaultHandlers([active]);
|
||||
@@ -414,7 +422,7 @@ describe('JourneyPage', () => {
|
||||
|
||||
// FE-PAGE-JOURNEY-018
|
||||
it('FE-PAGE-JOURNEY-018: active journey hero shows "Continue writing" button', async () => {
|
||||
const active = buildJourneyListItem({ id: 50, title: 'Writing Journey', status: 'active' });
|
||||
const active = buildJourneyListItem({ id: 50, title: 'Writing Journey', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
|
||||
setupDefaultHandlers([active]);
|
||||
|
||||
render(<JourneyPage />);
|
||||
@@ -427,7 +435,7 @@ describe('JourneyPage', () => {
|
||||
|
||||
// FE-PAGE-JOURNEY-019
|
||||
it('FE-PAGE-JOURNEY-019: active journey hero shows Live and Synced badges', async () => {
|
||||
const active = buildJourneyListItem({ id: 51, title: 'Live Journey', status: 'active' });
|
||||
const active = buildJourneyListItem({ id: 51, title: 'Live Journey', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
|
||||
setupDefaultHandlers([active]);
|
||||
|
||||
render(<JourneyPage />);
|
||||
@@ -442,7 +450,7 @@ describe('JourneyPage', () => {
|
||||
// FE-PAGE-JOURNEY-020
|
||||
it('FE-PAGE-JOURNEY-020: clicking active journey hero navigates to its detail page', async () => {
|
||||
const user = userEvent.setup();
|
||||
const active = buildJourneyListItem({ id: 60, title: 'Clickable Hero', status: 'active' });
|
||||
const active = buildJourneyListItem({ id: 60, title: 'Clickable Hero', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
|
||||
setupDefaultHandlers([active]);
|
||||
|
||||
render(<JourneyPage />);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
import { journeyApi } from '../api/client'
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Check, X, ChevronRight, RefreshCw, Users,
|
||||
} from 'lucide-react'
|
||||
import type { Journey } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
|
||||
const GRADIENTS = [
|
||||
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
||||
@@ -43,6 +44,9 @@ export default function JourneyPage() {
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [availableTrips, setAvailableTrips] = useState<any[]>([])
|
||||
const [selectedTripIds, setSelectedTripIds] = useState<Set<number>>(new Set())
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// suggestion
|
||||
const [suggestions, setSuggestions] = useState<any[]>([])
|
||||
@@ -56,12 +60,22 @@ export default function JourneyPage() {
|
||||
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
|
||||
|
||||
const activeJourney = useMemo(() => {
|
||||
return journeys.find(j => j.status === 'active') || null
|
||||
}, [journeys])
|
||||
if (searchQuery.trim()) return null
|
||||
return journeys.find(j => {
|
||||
const j2 = j as any
|
||||
return computeJourneyLifecycle(j.status, j2.trip_date_min, j2.trip_date_max) === 'live'
|
||||
}) || null
|
||||
}, [journeys, searchQuery])
|
||||
|
||||
const otherJourneys = useMemo(() => {
|
||||
return journeys.filter(j => j.id !== activeJourney?.id)
|
||||
}, [journeys, activeJourney])
|
||||
const filteredJourneys = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase()
|
||||
if (!q) return journeys.filter(j => j.id !== activeJourney?.id)
|
||||
return journeys.filter(j => {
|
||||
const inTitle = j.title.toLowerCase().includes(q)
|
||||
const inSubtitle = j.subtitle?.toLowerCase().includes(q) ?? false
|
||||
return inTitle || inSubtitle
|
||||
})
|
||||
}, [journeys, activeJourney, searchQuery])
|
||||
|
||||
const openCreateModal = async (preSelectedTripId?: number) => {
|
||||
setShowCreate(true)
|
||||
@@ -99,35 +113,79 @@ export default function JourneyPage() {
|
||||
<div style={{ paddingTop: 'var(--nav-h, 56px)' }}>
|
||||
<div className="max-w-[1440px] mx-auto">
|
||||
|
||||
{/* Header — mobile: just a create button */}
|
||||
<div className="md:hidden px-5 pt-5 pb-4">
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[14px] font-semibold active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.5} />
|
||||
{t('journey.frontpage.createJourney')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Header — desktop */}
|
||||
<div className="hidden md:flex items-start justify-between px-8 pt-10 pb-7">
|
||||
<div>
|
||||
<h1 className="text-[32px] font-extrabold tracking-[-0.025em] text-zinc-900 dark:text-white leading-none">{t('journey.title')}</h1>
|
||||
<p className="text-[13px] text-zinc-500 mt-1.5">{t("journey.frontpage.subtitle")}</p>
|
||||
</div>
|
||||
{/* Header — mobile */}
|
||||
<div className="md:hidden px-5 pt-5 pb-4 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="w-9 h-9 rounded-[10px] border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700">
|
||||
<Search size={15} />
|
||||
<button
|
||||
onClick={() => {
|
||||
if (searchOpen) {
|
||||
setSearchOpen(false)
|
||||
setSearchQuery('')
|
||||
} else {
|
||||
setSearchOpen(true)
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50)
|
||||
}
|
||||
}}
|
||||
className="w-10 h-10 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex-shrink-0"
|
||||
>
|
||||
{searchOpen ? <X size={15} /> : <Search size={15} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-[10px] bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-all hover:-translate-y-px"
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[14px] font-semibold active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<Plus size={14} />
|
||||
<Plus size={16} strokeWidth={2.5} />
|
||||
{t('journey.frontpage.createJourney')}
|
||||
</button>
|
||||
</div>
|
||||
{searchOpen && (
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Escape') { setSearchQuery(''); setSearchOpen(false) } }}
|
||||
placeholder={t('journey.search.placeholder')}
|
||||
className="w-full px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-400 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header — desktop (unified toolbar) */}
|
||||
<div className="hidden md:block px-8 pt-10 pb-7">
|
||||
<div style={{
|
||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||
border: '1px solid var(--border-primary)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
||||
padding: '14px 16px 14px 22px',
|
||||
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
{t('journey.title')}
|
||||
</h2>
|
||||
<div style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{t('journey.frontpage.subtitle')}
|
||||
</span>
|
||||
|
||||
<div style={{ display: 'inline-flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
marginLeft: 2,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.5} />
|
||||
{t('journey.frontpage.createJourney')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 md:px-8 pb-16">
|
||||
@@ -180,7 +238,7 @@ export default function JourneyPage() {
|
||||
|
||||
<div
|
||||
onClick={() => navigate(`/journey/${activeJourney.id}`)}
|
||||
className="relative rounded-3xl overflow-hidden cursor-pointer transition-all duration-300 hover:-translate-y-1 hover:shadow-xl h-[340px] md:h-[400px]"
|
||||
className="relative rounded-3xl overflow-hidden cursor-pointer transition-[transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-1 hover:shadow-xl h-[340px] md:h-[400px]"
|
||||
style={{ background: pickGradient(activeJourney.id) }}
|
||||
>
|
||||
{/* Cover image */}
|
||||
@@ -226,7 +284,7 @@ export default function JourneyPage() {
|
||||
{[
|
||||
{ val: (activeJourney as any).entry_count ?? '--', label: t("journey.stats.entries") },
|
||||
{ val: (activeJourney as any).photo_count ?? '--', label: t("journey.stats.photos") },
|
||||
{ val: (activeJourney as any).city_count ?? '--', label: t("journey.stats.cities") },
|
||||
{ val: (activeJourney as any).place_count ?? '--', label: t("journey.stats.places") },
|
||||
].map(s => (
|
||||
<div key={s.label} className="flex flex-col gap-1">
|
||||
<span className="text-[28px] font-extrabold tracking-[-0.02em] leading-none">{s.val}</span>
|
||||
@@ -243,11 +301,24 @@ export default function JourneyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search results info */}
|
||||
{searchQuery.trim() && (
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="text-[13px] text-zinc-500">
|
||||
{filteredJourneys.length === 0
|
||||
? t('journey.search.noResults', { query: searchQuery.trim() })
|
||||
: `${filteredJourneys.length} ${t('journey.frontpage.journeys')}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Journeys */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.allJourneys")}</span>
|
||||
<span className="text-[11px] text-zinc-400">{journeys.length} {t('journey.frontpage.journeys')}</span>
|
||||
</div>
|
||||
{!searchQuery.trim() && (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.allJourneys")}</span>
|
||||
<span className="text-[11px] text-zinc-400">{journeys.length} {t('journey.frontpage.journeys')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && journeys.length === 0 ? (
|
||||
<div className="flex justify-center py-16">
|
||||
@@ -255,16 +326,16 @@ export default function JourneyPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-[18px]">
|
||||
{otherJourneys.map(j => (
|
||||
{filteredJourneys.map(j => (
|
||||
<JourneyCard key={j.id} journey={j} onClick={() => navigate(`/journey/${j.id}`)} />
|
||||
))}
|
||||
|
||||
{/* Create card */}
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="group min-h-[320px] rounded-2xl border-[1.5px] border-dashed border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex flex-col items-center justify-center gap-2.5 hover:border-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-all cursor-pointer hover:-translate-y-0.5"
|
||||
className="group min-h-[320px] rounded-2xl border-[1.5px] border-dashed border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex flex-col items-center justify-center gap-2.5 hover:border-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-[border-color,background-color,transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] cursor-pointer hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-400 group-hover:bg-white dark:group-hover:bg-zinc-700 transition-all group-hover:rotate-90 duration-300">
|
||||
<div className="w-14 h-14 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-400 group-hover:bg-white dark:group-hover:bg-zinc-700 transition-[background-color,transform] group-hover:rotate-90 duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]">
|
||||
<Plus size={22} />
|
||||
</div>
|
||||
<span className="text-[14px] font-semibold text-zinc-700 dark:text-zinc-300">{t("journey.frontpage.createNew")}</span>
|
||||
@@ -279,7 +350,7 @@ export default function JourneyPage() {
|
||||
{/* Create Modal */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-7 pt-6 pb-5 border-b border-zinc-200 dark:border-zinc-700">
|
||||
@@ -323,7 +394,7 @@ export default function JourneyPage() {
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all ${
|
||||
className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-[border-color,background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${
|
||||
selected
|
||||
? 'border-zinc-900 dark:border-zinc-400 bg-zinc-50 dark:bg-zinc-800'
|
||||
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500'
|
||||
@@ -386,17 +457,18 @@ export default function JourneyPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; city_count?: number }; onClick: () => void }) {
|
||||
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; place_count?: number; trip_date_min?: string | null; trip_date_max?: string | null }; onClick: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const j = journey
|
||||
const entryCount = j.entry_count ?? 0
|
||||
const photoCount = j.photo_count ?? 0
|
||||
const cityCount = j.city_count ?? 0
|
||||
const placeCount = j.place_count ?? 0
|
||||
const lifecycle = computeJourneyLifecycle(j.status, j.trip_date_min, j.trip_date_max)
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden cursor-pointer transition-all duration-250 hover:border-zinc-400 hover:-translate-y-1 hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] flex flex-col"
|
||||
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden cursor-pointer transition-[transform,box-shadow,border-color] duration-250 ease-[cubic-bezier(0.23,1,0.32,1)] hover:border-zinc-400 hover:-translate-y-1 hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] flex flex-col"
|
||||
>
|
||||
{/* Cover */}
|
||||
<div className="h-[170px] relative overflow-hidden" style={{ background: pickGradient(j.id) }}>
|
||||
@@ -424,15 +496,22 @@ function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?:
|
||||
{j.subtitle && (
|
||||
<p className="text-[12px] text-zinc-500 mt-1">{j.subtitle}</p>
|
||||
)}
|
||||
{j.status === 'draft' && (
|
||||
<span className="inline-flex self-start mt-1.5 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 uppercase tracking-wide">{t('journey.status.draft')}</span>
|
||||
{lifecycle !== 'live' && (
|
||||
<span className={`inline-flex self-start mt-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wide ${
|
||||
lifecycle === 'archived' ? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500' :
|
||||
lifecycle === 'upcoming' ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' :
|
||||
lifecycle === 'completed' ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' :
|
||||
'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'
|
||||
}`}>
|
||||
{t(`journey.status.${lifecycle}`)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-2.5 mt-auto pt-3.5 border-t border-zinc-100 dark:border-zinc-800" style={{ marginTop: j.subtitle ? 14 : 'auto' }}>
|
||||
{[
|
||||
{ val: entryCount, label: t('journey.stats.entries') },
|
||||
{ val: photoCount, label: t('journey.stats.photos') },
|
||||
{ val: cityCount, label: t('journey.stats.cities') },
|
||||
{ val: placeCount, label: t('journey.stats.places') },
|
||||
].map(s => (
|
||||
<div key={s.label} className="flex flex-col gap-1">
|
||||
<span className={`text-[16px] font-bold leading-none tracking-[-0.01em] ${s.val > 0 ? 'text-zinc-900 dark:text-white' : 'text-zinc-300 dark:text-zinc-600'}`}>
|
||||
|
||||
@@ -109,7 +109,7 @@ const mockJourneyData = {
|
||||
stats: {
|
||||
entries: 2,
|
||||
photos: 1,
|
||||
cities: 2,
|
||||
places: 2,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -354,7 +354,7 @@ describe('JourneyPublicPage', () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
stats: { entries: 1, photos: 3, cities: 0 },
|
||||
stats: { entries: 1, photos: 3, places: 0 },
|
||||
};
|
||||
|
||||
server.use(
|
||||
@@ -383,7 +383,7 @@ describe('JourneyPublicPage', () => {
|
||||
it('FE-PAGE-PUBLICJOURNEY-015: stats display shows entries, photos, and cities counts', async () => {
|
||||
const customData = {
|
||||
...mockJourneyData,
|
||||
stats: { entries: 14, photos: 83, cities: 7 },
|
||||
stats: { entries: 14, photos: 83, places: 7 },
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/public/journey/test-share-token', () => HttpResponse.json(customData)),
|
||||
|
||||
@@ -7,6 +7,8 @@ import { List, Grid, MapPin, Camera, BookOpen, Image } from 'lucide-react'
|
||||
import JourneyMap from '../components/Journey/JourneyMap'
|
||||
import JournalBody from '../components/Journey/JournalBody'
|
||||
import PhotoLightbox from '../components/Journey/PhotoLightbox'
|
||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||
import { useIsMobile } from '../hooks/useIsMobile'
|
||||
|
||||
interface PublicEntry {
|
||||
id: number
|
||||
@@ -62,6 +64,7 @@ export default function JourneyPublicPage() {
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
const isMobile = useIsMobile()
|
||||
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
|
||||
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null }[]; index: number } | null>(null)
|
||||
const { t } = useTranslation()
|
||||
@@ -173,7 +176,7 @@ export default function JourneyPublicPage() {
|
||||
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><Camera size={12} /> {stats.photos} {t('journey.stats.photos')}</span>
|
||||
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><MapPin size={12} /> {stats.cities} {t('journey.stats.places')}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><MapPin size={12} /> {stats.places} {t('journey.stats.places')}</span>
|
||||
</div>
|
||||
|
||||
<div className="relative" style={{ marginTop: 12, fontSize: 9, fontWeight: 500, letterSpacing: 1.5, textTransform: 'uppercase', opacity: 0.25 }}>{t('journey.public.readOnly')}</div>
|
||||
@@ -202,8 +205,20 @@ export default function JourneyPublicPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
{view === 'timeline' && perms.share_timeline && (
|
||||
{/* Mobile combined map+timeline (public, read-only) */}
|
||||
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
|
||||
<MobileMapTimeline
|
||||
entries={entries}
|
||||
mapEntries={mapEntries.map(e => ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title, mood: e.mood, entry_date: e.entry_date }))}
|
||||
dark={document.documentElement.classList.contains('dark')}
|
||||
readOnly
|
||||
onEntryClick={() => {}}
|
||||
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Timeline (desktop, or mobile without map permission) */}
|
||||
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{sortedDates.map(date => {
|
||||
const dayEntries = groupedEntries.get(date)!
|
||||
|
||||
@@ -574,7 +574,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
{ Icon: FolderOpen, label: t('login.features.files'), desc: t('login.features.filesDesc') },
|
||||
{ Icon: Route, label: t('login.features.routes'), desc: t('login.features.routesDesc') },
|
||||
].map(({ Icon, label, desc }) => (
|
||||
<div key={label} style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 14, padding: '14px 12px', border: '1px solid rgba(255,255,255,0.06)', textAlign: 'left', transition: 'all 0.2s' }}
|
||||
<div key={label} style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 14, padding: '14px 12px', border: '1px solid rgba(255,255,255,0.06)', textAlign: 'left', transition: 'background 200ms cubic-bezier(0.23,1,0.32,1), border-color 200ms cubic-bezier(0.23,1,0.32,1)' }}
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)' }}
|
||||
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.06)' }}>
|
||||
<Icon size={17} style={{ color: 'rgba(255,255,255,0.7)', marginBottom: 7 }} />
|
||||
@@ -619,7 +619,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
border: 'none', borderRadius: 12,
|
||||
fontSize: 14, fontWeight: 700, cursor: 'pointer',
|
||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
textDecoration: 'none', transition: 'all 0.15s',
|
||||
textDecoration: 'none', transition: 'background 180ms cubic-bezier(0.23,1,0.32,1)',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#1f2937' }}
|
||||
@@ -764,9 +764,21 @@ export default function LoginPage(): React.ReactElement {
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword(v => !v)} style={{
|
||||
position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: '#9ca3af',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#9ca3af',
|
||||
width: 22, height: 22,
|
||||
}}>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
<Eye size={16} style={{
|
||||
position: 'absolute', inset: 3,
|
||||
opacity: showPassword ? 0 : 1,
|
||||
transform: showPassword ? 'scale(0.7) rotate(-20deg)' : 'scale(1) rotate(0)',
|
||||
transition: 'opacity 180ms cubic-bezier(0.23,1,0.32,1), transform 180ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}} />
|
||||
<EyeOff size={16} style={{
|
||||
position: 'absolute', inset: 3,
|
||||
opacity: showPassword ? 1 : 0,
|
||||
transform: showPassword ? 'scale(1) rotate(0)' : 'scale(0.7) rotate(20deg)',
|
||||
transition: 'opacity 180ms cubic-bezier(0.23,1,0.32,1), transform 180ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -816,7 +828,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
border: '1px solid #d1d5db', borderRadius: 12,
|
||||
fontSize: 14, fontWeight: 600, cursor: 'pointer',
|
||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
textDecoration: 'none', transition: 'all 0.15s',
|
||||
textDecoration: 'none', transition: 'background 180ms cubic-bezier(0.23,1,0.32,1), border-color 180ms cubic-bezier(0.23,1,0.32,1)',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#f9fafb'; e.currentTarget.style.borderColor = '#9ca3af' }}
|
||||
@@ -837,7 +849,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
color: '#451a03', border: 'none', borderRadius: 14,
|
||||
fontSize: 15, fontWeight: 700, cursor: isLoading ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||
opacity: isLoading ? 0.7 : 1, transition: 'all 0.2s',
|
||||
opacity: isLoading ? 0.7 : 1, transition: 'transform 200ms cubic-bezier(0.23,1,0.32,1), box-shadow 200ms cubic-bezier(0.23,1,0.32,1), opacity 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||
boxShadow: '0 2px 12px rgba(245, 158, 11, 0.3)',
|
||||
}}
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { if (!isLoading) e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 16px rgba(245, 158, 11, 0.4)' }}
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function RegisterPage(): React.ReactElement {
|
||||
required
|
||||
placeholder="johndoe"
|
||||
minLength={3}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-[border-color,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,7 +115,7 @@ export default function RegisterPage(): React.ReactElement {
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="your@email.com"
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-[border-color,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,7 +130,7 @@ export default function RegisterPage(): React.ReactElement {
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder={t('register.minChars')}
|
||||
className="w-full pl-10 pr-12 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||
className="w-full pl-10 pr-12 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-[border-color,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -152,7 +152,7 @@ export default function RegisterPage(): React.ReactElement {
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder={t('register.repeatPassword')}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-[border-color,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user