diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 79a16af..0406909 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -7,8 +7,19 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm steps: + - name: Prepare platform tag-safe name + run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV + - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 @@ -18,8 +29,52 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - uses: docker/build-push-action@v6 + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 with: context: . - push: true - tags: mauriceboe/nomad:latest + platforms: ${{ matrix.platform }} + outputs: type=image,name=mauriceboe/nomad,push-by-digest=true,name-canonical=true,push=true + no-cache: true + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest artifact + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: build + steps: + - name: Download build digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Create and push multi-arch manifest + working-directory: /tmp/digests + run: | + mapfile -t digests < <(printf 'mauriceboe/nomad@sha256:%s\n' *) + docker buildx imagetools create -t mauriceboe/nomad:latest "${digests[@]}" + + - name: Inspect manifest + run: docker buildx imagetools inspect mauriceboe/nomad:latest diff --git a/client/index.html b/client/index.html index 89c3c58..0e4e508 100644 --- a/client/index.html +++ b/client/index.html @@ -1,15 +1,15 @@ - + - NOMAD + TREK - + diff --git a/client/package-lock.json b/client/package-lock.json index b3defeb..05a5fdf 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { - "name": "nomad-client", - "version": "2.6.0", + "name": "trek-client", + "version": "2.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "nomad-client", - "version": "2.6.0", + "name": "trek-client", + "version": "2.6.1", "dependencies": { "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", @@ -17,8 +17,10 @@ "react-dropzone": "^14.4.1", "react-leaflet": "^4.2.1", "react-leaflet-cluster": "^2.1.0", + "react-markdown": "^10.1.0", "react-router-dom": "^6.22.2", "react-window": "^2.2.7", + "remark-gfm": "^4.0.1", "topojson-client": "^3.1.0", "zustand": "^4.5.2" }, @@ -3216,13 +3218,30 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -3230,6 +3249,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/leaflet": { "version": "1.9.21", "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", @@ -3240,18 +3268,31 @@ "@types/geojson": "*" } }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3292,6 +3333,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -3564,6 +3617,16 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -3794,6 +3857,56 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -3896,6 +4009,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -3985,7 +4108,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -4046,7 +4168,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4060,6 +4181,19 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -4115,6 +4249,15 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4125,6 +4268,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dfa": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", @@ -4369,6 +4525,28 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -4395,6 +4573,12 @@ "node": ">=0.8.x" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4938,6 +5122,46 @@ "node": ">= 0.4" } }, + "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", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hsl-to-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz", @@ -4953,6 +5177,16 @@ "integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==", "license": "ISC" }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hyphen": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz", @@ -4972,6 +5206,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4987,6 +5227,30 @@ "node": ">= 0.4" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5141,6 +5405,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5200,6 +5474,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -5270,6 +5554,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -5665,6 +5961,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5706,6 +6012,16 @@ "sourcemap-codec": "^1.4.8" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5715,6 +6031,276 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/media-engine": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz", @@ -5731,6 +6317,569 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5796,7 +6945,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -5950,6 +7098,31 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-svg-path": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", @@ -6236,6 +7409,16 @@ "react-is": "^16.13.1" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -6369,6 +7552,33 @@ "react-leaflet": "^4.0.0" } }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -6546,6 +7756,72 @@ "regjsparser": "bin/parser" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -7052,6 +8328,16 @@ "dev": true, "license": "MIT" }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -7162,6 +8448,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stringify-object": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", @@ -7187,6 +8487,24 @@ "node": ">=10" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -7442,6 +8760,26 @@ "punycode": "^2.1.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -7649,6 +8987,25 @@ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", @@ -7662,6 +9019,74 @@ "node": ">=8" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -7729,6 +9154,34 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -8304,6 +9757,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/client/package.json b/client/package.json index febb80b..ab617ff 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { - "name": "nomad-client", - "version": "2.6.1", + "name": "trek-client", + "version": "2.6.2", "private": true, "type": "module", "scripts": { @@ -19,8 +19,10 @@ "react-dropzone": "^14.4.1", "react-leaflet": "^4.2.1", "react-leaflet-cluster": "^2.1.0", + "react-markdown": "^10.1.0", "react-router-dom": "^6.22.2", "react-window": "^2.2.7", + "remark-gfm": "^4.0.1", "topojson-client": "^3.1.0", "zustand": "^4.5.2" }, diff --git a/client/public/logo-dark.svg b/client/public/logo-dark.svg index 2a26bc7..56684fb 100644 --- a/client/public/logo-dark.svg +++ b/client/public/logo-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/client/public/logo-light.svg b/client/public/logo-light.svg index 2c218b9..eb276d6 100644 --- a/client/public/logo-light.svg +++ b/client/public/logo-light.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/client/public/text-dark.svg b/client/public/text-dark.svg index daa0c2d..f5b15d7 100644 --- a/client/public/text-dark.svg +++ b/client/public/text-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/client/public/text-light.svg b/client/public/text-light.svg index d44ac5e..6cc7306 100644 --- a/client/public/text-light.svg +++ b/client/public/text-light.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 534fca0..46fcb7c 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -143,8 +143,9 @@ export const addonsApi = { export const mapsApi = { search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data), - details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${placeId}`, { params: { lang } }).then(r => r.data), - placePhoto: (placeId: string) => apiClient.get(`/maps/place-photo/${placeId}`).then(r => r.data), + details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data), + placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data), + reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data), } export const budgetApi = { @@ -158,12 +159,16 @@ export const budgetApi = { } export const filesApi = { - list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/files`).then(r => r.data), + list: (tripId: number | string, trash?: boolean) => apiClient.get(`/trips/${tripId}/files`, { params: trash ? { trash: 'true' } : {} }).then(r => r.data), upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data), + toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data), + restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data), + permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data), + emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data), } export const reservationsApi = { diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index c1e46f1..b8eb170 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -84,7 +84,7 @@ export default function AddonManager() {

{t('admin.addons.title')}

- {t('admin.addons.subtitleBefore')}NOMAD{t('admin.addons.subtitleAfter')} + {t('admin.addons.subtitleBefore')}TREK{t('admin.addons.subtitleAfter')}

diff --git a/client/src/components/Admin/GitHubPanel.tsx b/client/src/components/Admin/GitHubPanel.tsx index 492a0f3..c31f375 100644 --- a/client/src/components/Admin/GitHubPanel.tsx +++ b/client/src/components/Admin/GitHubPanel.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' -import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2 } from 'lucide-react' +import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee } from 'lucide-react' import { useTranslation } from '../../i18n' +import apiClient from '../../api/client' const REPO = 'mauriceboe/NOMAD' const PER_PAGE = 10 @@ -17,9 +18,8 @@ export default function GitHubPanel() { const fetchReleases = async (pageNum = 1, append = false) => { try { - const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=${PER_PAGE}&page=${pageNum}`) - if (!res.ok) throw new Error(`GitHub API: ${res.status}`) - const data = await res.json() + const res = await apiClient.get(`/auth/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } }) + const data = res.data setReleases(prev => append ? [...prev, ...data] : data) setHasMore(data.length === PER_PAGE) } catch (err: unknown) { @@ -112,30 +112,63 @@ export default function GitHubPanel() { return elements } - if (loading) { - return ( -
-
- -
-
- ) - } - - if (error) { - return ( -
-
-

{t('admin.github.error')}

-

{error}

-
-
- ) - } - return (
- {/* Header card */} + {/* Support cards */} +
+ { 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' }} + > +
+ +
+
+
Ko-fi
+
{language === 'de' ? 'Hilft mir, TREK weiterzuentwickeln' : 'Helps me keep building TREK'}
+
+ +
+ { 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' }} + > +
+ +
+
+
Buy Me a Coffee
+
{language === 'de' ? 'Hilft mir, TREK weiterzuentwickeln' : 'Helps me keep building TREK'}
+
+ +
+
+ + {/* Loading / Error / Releases */} + {loading ? ( +
+
+ +
+
+ ) : error ? ( +
+
+

{t('admin.github.error')}

+

{error}

+
+
+ ) : (
@@ -258,6 +291,7 @@ export default function GitHubPanel() { )}
+ )}
) } diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index db34077..171375f 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -7,6 +7,7 @@ import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check } from 'lucide-r import CustomSelect from '../shared/CustomSelect' import { budgetApi } from '../../api/client' import type { BudgetItem, BudgetMember } from '../../types' +import { currencyDecimals } from '../../utils/formatters' interface TripMember { id: number @@ -34,7 +35,8 @@ const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5 const fmtNum = (v, locale, cur) => { if (v == null || isNaN(v)) return '-' - return Number(v).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ' + (SYMBOLS[cur] || cur) + const d = currencyDecimals(cur) + return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur) } const calcPP = (p, n) => (n > 0 ? p / n : null) @@ -543,7 +545,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro )} - handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder="0,00" locale={locale} editTooltip={t('budget.editTooltip')} /> + handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} /> {hasMultipleMembers ? ( @@ -620,7 +622,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
- {Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + {Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
{SYMBOLS[currency] || currency} {currency}
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && ( diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx index ea78eb1..a09dedb 100644 --- a/client/src/components/Collab/CollabNotes.tsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -1,7 +1,9 @@ import ReactDOM from 'react-dom' import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import DOM from 'react-dom' -import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink } from 'lucide-react' +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react' import { collabApi } from '../../api/client' import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' @@ -412,7 +414,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca outline: 'none', boxSizing: 'border-box', resize: 'vertical', - minHeight: 90, + minHeight: 180, lineHeight: 1.5, }} /> @@ -690,13 +692,14 @@ interface NoteCardProps { onUpdate: (noteId: number, data: Partial) => Promise onDelete: (noteId: number) => Promise onEdit: (note: CollabNote) => void + onView: (note: CollabNote) => void onPreviewFile: (file: NoteFile) => void getCategoryColor: (category: string) => string tripId: number t: (key: string) => string } -function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) { +function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) { const [hovered, setHovered] = useState(false) const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) } @@ -749,6 +752,14 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile
+ {note.content && ( + + )}
{/* ── New Note Modal ── */} + {/* View note modal */} + {viewingNote && ReactDOM.createPortal( +
setViewingNote(null)} + > +
e.stopPropagation()} + > +
+
+
{viewingNote.title}
+ {viewingNote.category && ( + {viewingNote.category} + )} +
+
+ + +
+
+
+ {viewingNote.content || ''} +
+
+
, + document.body + )} + {showNewModal && ( setShowNewModal(false)} diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index a30f26c..c5fc0bd 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -1,15 +1,15 @@ import ReactDOM from 'react-dom' -import { useState, useCallback } from 'react' -import DOM from 'react-dom' +import { useState, useCallback, useRef } from 'react' import { useDropzone } from 'react-dropzone' -import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote } from 'lucide-react' +import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' -import type { Place, Reservation, TripFile } from '../../types' +import { filesApi } from '../../api/client' +import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types' function isImage(mimeType) { if (!mimeType) return false - return mimeType.startsWith('image/') // covers jpg, png, gif, webp, etc. + return mimeType.startsWith('image/') } function getFileIcon(mimeType) { @@ -68,7 +68,7 @@ function ImageLightbox({ file, onClose }: ImageLightboxProps) { ) } -// Source badge — unified style for both place and reservation +// Source badge interface SourceBadgeProps { icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }> label: string @@ -89,40 +89,156 @@ function SourceBadge({ icon: Icon, label }: SourceBadgeProps) { ) } +function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?: string | null; size?: number }) { + const [hover, setHover] = useState(false) + const [pos, setPos] = useState({ top: 0, left: 0 }) + const ref = useRef(null) + + const onEnter = () => { + if (ref.current) { + const rect = ref.current.getBoundingClientRect() + setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 }) + } + setHover(true) + } + + return ( + <> +
setHover(false)} + style={{ + width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)', + background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', + fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0, + cursor: 'default', + }}> + {avatarUrl + ? + : name?.[0]?.toUpperCase() + } +
+ {hover && ReactDOM.createPortal( +
+ {name} +
, + document.body + )} + + ) +} + interface FileManagerProps { files?: TripFile[] - onUpload: (fd: FormData) => Promise + onUpload: (fd: FormData) => Promise onDelete: (fileId: number) => Promise onUpdate: (fileId: number, data: Partial) => Promise places: Place[] + days?: Day[] + assignments?: AssignmentsMap reservations?: Reservation[] tripId: number allowedFileTypes: Record } -export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId, allowedFileTypes }: FileManagerProps) { +export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) { const [uploading, setUploading] = useState(false) const [filterType, setFilterType] = useState('all') const [lightboxFile, setLightboxFile] = useState(null) + const [showTrash, setShowTrash] = useState(false) + const [trashFiles, setTrashFiles] = useState([]) + const [loadingTrash, setLoadingTrash] = useState(false) const toast = useToast() const { t, locale } = useTranslation() + const loadTrash = useCallback(async () => { + setLoadingTrash(true) + try { + const data = await filesApi.list(tripId, true) + setTrashFiles(data.files || []) + } catch { /* */ } + setLoadingTrash(false) + }, [tripId]) + + const toggleTrash = useCallback(() => { + if (!showTrash) loadTrash() + setShowTrash(v => !v) + }, [showTrash, loadTrash]) + + const refreshFiles = useCallback(async () => { + if (onUpdate) onUpdate(0, {} as any) + }, [onUpdate]) + + const handleStar = async (fileId: number) => { + try { + await filesApi.toggleStar(tripId, fileId) + refreshFiles() + } catch { /* */ } + } + + const handleRestore = async (fileId: number) => { + try { + await filesApi.restore(tripId, fileId) + setTrashFiles(prev => prev.filter(f => f.id !== fileId)) + refreshFiles() + toast.success(t('files.toast.restored')) + } catch { + toast.error(t('files.toast.restoreError')) + } + } + + const handlePermanentDelete = async (fileId: number) => { + if (!confirm(t('files.confirm.permanentDelete'))) return + try { + await filesApi.permanentDelete(tripId, fileId) + setTrashFiles(prev => prev.filter(f => f.id !== fileId)) + toast.success(t('files.toast.deleted')) + } catch { + toast.error(t('files.toast.deleteError')) + } + } + + const handleEmptyTrash = async () => { + if (!confirm(t('files.confirm.emptyTrash'))) return + try { + await filesApi.emptyTrash(tripId) + setTrashFiles([]) + toast.success(t('files.toast.trashEmptied') || 'Trash emptied') + } catch { + toast.error(t('files.toast.deleteError')) + } + } + + const [lastUploadedIds, setLastUploadedIds] = useState([]) + const onDrop = useCallback(async (acceptedFiles) => { if (acceptedFiles.length === 0) return setUploading(true) + const uploadedIds: number[] = [] try { for (const file of acceptedFiles) { const formData = new FormData() formData.append('file', file) - await onUpload(formData) + const result = await onUpload(formData) + const fileObj = result?.file || result + if (fileObj?.id) uploadedIds.push(fileObj.id) } toast.success(t('files.uploaded', { count: acceptedFiles.length })) + // Open assign modal for the last uploaded file + const lastId = uploadedIds[uploadedIds.length - 1] + if (lastId && (places.length > 0 || reservations.length > 0)) { + setAssignFileId(lastId) + } } catch { toast.error(t('files.uploadError')) } finally { setUploading(false) } - }, [onUpload, toast, t]) + }, [onUpload, toast, t, places, reservations]) const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, @@ -130,24 +246,24 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, noClick: false, }) - // Paste support const handlePaste = useCallback((e) => { const items = e.clipboardData?.items if (!items) return - const files = [] + const pastedFiles = [] for (const item of Array.from(items)) { if (item.kind === 'file') { const file = item.getAsFile() - if (file) files.push(file) + if (file) pastedFiles.push(file) } } - if (files.length > 0) { + if (pastedFiles.length > 0) { e.preventDefault() - onDrop(files) + onDrop(pastedFiles) } }, [onDrop]) const filteredFiles = files.filter(f => { + if (filterType === 'starred') return !!f.starred if (filterType === 'pdf') return f.mime_type === 'application/pdf' if (filterType === 'image') return isImage(f.mime_type) if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text') @@ -156,16 +272,25 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, }) const handleDelete = async (id) => { - if (!confirm(t('files.confirm.delete'))) return try { await onDelete(id) - toast.success(t('files.toast.deleted')) + toast.success(t('files.toast.trashed') || 'Moved to trash') } catch { toast.error(t('files.toast.deleteError')) } } const [previewFile, setPreviewFile] = useState(null) + const [assignFileId, setAssignFileId] = useState(null) + + const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => { + try { + await filesApi.update(tripId, fileId, data) + refreshFiles() + } catch { + toast.error(t('files.toast.assignError')) + } + } const openFile = (file) => { if (isImage(file.mime_type)) { @@ -175,12 +300,259 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, } } + const renderFileRow = (file: TripFile, isTrash = false) => { + const FileIcon = getFileIcon(file.mime_type) + const linkedPlace = places?.find(p => p.id === file.place_id) + const linkedReservation = file.reservation_id + ? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title }) + : null + const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`) + + return ( +
e.currentTarget.style.borderColor = 'var(--text-faint)'} + onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'} + className="group" + > + {/* Icon or thumbnail */} +
!isTrash && openFile({ ...file, url: fileUrl })} + style={{ + flexShrink: 0, width: 36, height: 36, borderRadius: 8, + background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', + cursor: isTrash ? 'default' : 'pointer', overflow: 'hidden', + }} + > + {isImage(file.mime_type) + ? + : (() => { + const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?' + const isPdf = file.mime_type === 'application/pdf' + return ( +
+ {ext} +
+ ) + })() + } +
+ + {/* Info */} +
+
+ {file.uploaded_by_name && ( + + )} + {!isTrash && file.starred ? : null} + !isTrash && openFile({ ...file, url: fileUrl })} + style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }} + > + {file.original_name} + +
+ + {file.description && ( +

{file.description}

+ )} + +
+ {file.file_size && {formatSize(file.file_size)}} + {formatDateWithLocale(file.created_at, locale)} + + {linkedPlace && ( + + )} + {linkedReservation && ( + + )} + {file.note_id && ( + + )} +
+
+ + {/* Actions — always visible on mobile, hover on desktop */} +
+ {isTrash ? ( + <> + + + + ) : ( + <> + + + + + + )} +
+
+ ) + } + return (
{/* Lightbox */} {lightboxFile && setLightboxFile(null)} />} - {/* Datei-Vorschau Modal — portal to body to escape stacking context */} + {/* Assign modal */} + {assignFileId && ReactDOM.createPortal( +
setAssignFileId(null)}> +
e.stopPropagation()}> +
+
+
{t('files.assignTitle')}
+
+ {files.find(f => f.id === assignFileId)?.original_name || ''} +
+
+ +
+
+
+ {t('files.noteLabel') || 'Note'} +
+ f.id === assignFileId)?.description || ''} + onBlur={e => { + const val = e.target.value.trim() + const file = files.find(f => f.id === assignFileId) + if (file && val !== (file.description || '')) { + handleAssign(file.id, { description: val } as any) + } + }} + onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} + style={{ + width: '100%', padding: '7px 10px', fontSize: 13, borderRadius: 8, + border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)', + color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', + }} + /> +
+
+ {(() => { + const file = files.find(f => f.id === assignFileId) + if (!file) return null + const assignedPlaceIds = new Set() + const dayGroups: { day: Day; dayPlaces: Place[] }[] = [] + for (const day of days) { + const da = assignments[String(day.id)] || [] + const dayPlaces = da.map(a => places.find(p => p.id === a.place?.id || p.id === a.place_id)).filter(Boolean) as Place[] + if (dayPlaces.length > 0) { + dayGroups.push({ day, dayPlaces }) + dayPlaces.forEach(p => assignedPlaceIds.add(p.id)) + } + } + const unassigned = places.filter(p => !assignedPlaceIds.has(p.id)) + const placeBtn = (p: Place) => ( + + ) + + const placesSection = places.length > 0 && ( +
+
+ {t('files.assignPlace')} +
+ {dayGroups.map(({ day, dayPlaces }) => ( +
+
+ {day.title || `${t('dayplan.dayN', { n: day.day_number })}${day.date ? ` · ${day.date}` : ''}`} +
+ {dayPlaces.map(placeBtn)} +
+ ))} + {unassigned.length > 0 && ( +
+ {dayGroups.length > 0 &&
{t('files.unassigned')}
} + {unassigned.map(placeBtn)} +
+ )} +
+ ) + + const bookingsSection = reservations.length > 0 && ( +
+
+ {t('files.assignBooking')} +
+ {reservations.map(r => ( + + ))} +
+ ) + + const hasBoth = placesSection && bookingsSection + return ( +
+
{placesSection}
+ {hasBoth &&
} + {hasBoth &&
} +
{bookingsSection}
+
+ ) + })()} +
+
+
, + document.body + )} + + {/* PDF preview modal */} {previewFile && ReactDOM.createPortal(
-

{t('files.title')}

+

{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}

- {files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length })} + {showTrash + ? `${trashFiles.length} ${trashFiles.length === 1 ? 'file' : 'files'}` + : (files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length }))}

+
- {/* Upload zone */} -
- - - {uploading ? ( -
-
- {t('files.uploading')} + {showTrash ? ( + /* Trash view */ +
+ {trashFiles.length > 0 && ( +
+ +
+ )} + {loadingTrash ? ( +
+
+
+ ) : trashFiles.length === 0 ? ( +
+ +

{t('files.trashEmpty') || 'Trash is empty'}

+
+ ) : ( +
+ {trashFiles.map(file => renderFileRow(file, true))} +
+ )} +
+ ) : ( + <> + {/* Upload zone */} +
+ + + {uploading ? ( +
+
+ {t('files.uploading')} +
+ ) : ( + <> +

{t('files.dropzone')}

+

{t('files.dropzoneHint')}

+

+ {(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB +

+ + )}
- ) : ( - <> -

{t('files.dropzone')}

-

{t('files.dropzoneHint')}

-

- {(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB -

- - )} -
- {/* Filter tabs */} -
- {[ - { id: 'all', label: t('files.filterAll') }, - { 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 => ( - - ))} - - {filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })} - -
- - {/* File list */} -
- {filteredFiles.length === 0 ? ( -
- -

{t('files.empty')}

-

{t('files.emptyHint')}

+ {/* Filter tabs */} +
+ {[ + { id: 'all', label: t('files.filterAll') }, + ...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []), + { 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 => ( + + ))} + + {filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })} +
- ) : ( -
- {filteredFiles.map(file => { - const FileIcon = getFileIcon(file.mime_type) - const linkedPlace = places?.find(p => p.id === file.place_id) - const linkedReservation = file.reservation_id - ? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title }) - : null - const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`) - return ( -
e.currentTarget.style.borderColor = 'var(--text-faint)'} - onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'} - className="group" - > - {/* Icon or thumbnail */} -
openFile({ ...file, url: fileUrl })} - style={{ - flexShrink: 0, width: 36, height: 36, borderRadius: 8, - background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', - cursor: 'pointer', overflow: 'hidden', - }} - > - {isImage(file.mime_type) - ? - : (() => { - const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?' - const isPdf = file.mime_type === 'application/pdf' - return ( -
- {ext} -
- ) - })() - } -
- - {/* Info */} -
-
openFile({ ...file, url: fileUrl })} - style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} - > - {file.original_name} -
- -
- {file.file_size && {formatSize(file.file_size)}} - {formatDateWithLocale(file.created_at, locale)} - - {linkedPlace && ( - - )} - {linkedReservation && ( - - )} - {file.note_id && ( - - )} -
- - {file.description && !linkedReservation && ( -

{file.description}

- )} -
- - {/* Actions */} -
- - -
-
- ) - })} + {/* File list */} +
+ {filteredFiles.length === 0 ? ( +
+ +

{t('files.empty')}

+

{t('files.emptyHint')}

+
+ ) : ( +
+ {filteredFiles.map(file => renderFileRow(file))} +
+ )}
- )} -
+ + )}
) diff --git a/client/src/components/Layout/DemoBanner.tsx b/client/src/components/Layout/DemoBanner.tsx index fcb49a7..7a4386f 100644 --- a/client/src/components/Layout/DemoBanner.tsx +++ b/client/src/components/Layout/DemoBanner.tsx @@ -25,7 +25,7 @@ const texts: Record = { de: { titleBefore: 'Willkommen bei ', titleAfter: '', - title: 'Willkommen zur NOMAD Demo', + title: 'Willkommen zur TREK Demo', description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.', resetIn: 'Naechster Reset in', minutes: 'Minuten', @@ -48,7 +48,7 @@ const texts: Record = { ['Dokumente', 'Dateien an Reisen anhaengen'], ['Widgets', 'Waehrungsrechner & Zeitzonen'], ], - whatIs: 'Was ist NOMAD?', + whatIs: 'Was ist TREK?', whatIsDesc: 'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.', selfHost: 'Open Source — ', selfHostLink: 'selbst hosten', @@ -57,7 +57,7 @@ const texts: Record = { en: { titleBefore: 'Welcome to ', titleAfter: '', - title: 'Welcome to the NOMAD Demo', + title: 'Welcome to the TREK Demo', description: 'You can view, edit and create trips. All changes are automatically reset every hour.', resetIn: 'Next reset in', minutes: 'minutes', @@ -80,7 +80,7 @@ const texts: Record = { ['Documents', 'Attach files to trips'], ['Widgets', 'Currency converter & timezones'], ], - whatIs: 'What is NOMAD?', + whatIs: 'What is TREK?', whatIsDesc: 'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.', selfHost: 'Open source — ', selfHostLink: 'self-host it', @@ -123,7 +123,7 @@ export default function DemoBanner(): React.ReactElement | null {

- {t.titleBefore}NOMAD{t.titleAfter} + {t.titleBefore}TREK{t.titleAfter}

@@ -151,7 +151,7 @@ export default function DemoBanner(): React.ReactElement | null {
- {/* What is NOMAD */} + {/* What is TREK */}
- {language === 'de' ? 'Was ist ' : 'What is '}NOMAD? + {language === 'de' ? 'Was ist ' : 'What is '}TREK?

{t.whatIsDesc}

@@ -213,7 +213,7 @@ export default function DemoBanner(): React.ReactElement | null {
{t.selfHost} - {t.selfHostLink} diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index c07d159..52ce0cf 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -91,8 +91,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: )} - NOMAD - NOMAD + TREK + TREK {/* Global addon nav items */} @@ -231,7 +231,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: {appVersion && (
- NOMAD + TREK v{appVersion}
diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 6743965..fa26706 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -182,6 +182,16 @@ function MapClickHandler({ onClick }: MapClickHandlerProps) { return null } +function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) { + const map = useMap() + useEffect(() => { + if (!onContextMenu) return + map.on('contextmenu', onContextMenu) + return () => map.off('contextmenu', onContextMenu) + }, [map, onContextMenu]) + return null +} + // ── Route travel time label ── interface RouteLabelProps { midpoint: [number, number] @@ -234,6 +244,7 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) { // Module-level photo cache shared with PlaceAvatar const mapPhotoCache = new Map() +const mapPhotoInFlight = new Set() export function MapView({ places = [], @@ -243,6 +254,7 @@ export function MapView({ selectedPlaceId = null, onMarkerClick, onMapClick, + onMapContextMenu = null, center = [48.8566, 2.3522], zoom = 10, tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', @@ -264,23 +276,32 @@ export function MapView({ }, [leftWidth, rightWidth, hasInspector]) const [photoUrls, setPhotoUrls] = useState({}) - // Fetch Google photos for places that have google_place_id but no image_url + // Fetch photos for places (Google or Wikimedia Commons fallback) useEffect(() => { places.forEach(place => { - if (place.image_url || !place.google_place_id) return - if (mapPhotoCache.has(place.google_place_id)) { - const cached = mapPhotoCache.get(place.google_place_id) - if (cached) setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: cached })) + if (place.image_url) return + const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` + if (!cacheKey) return + if (mapPhotoCache.has(cacheKey)) { + const cached = mapPhotoCache.get(cacheKey) + if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached })) return } - mapsApi.placePhoto(place.google_place_id) + if (mapPhotoInFlight.has(cacheKey)) return + const photoId = place.google_place_id || place.osm_id + if (!photoId && !(place.lat && place.lng)) return + mapPhotoInFlight.add(cacheKey) + mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) .then(data => { if (data.photoUrl) { - mapPhotoCache.set(place.google_place_id, data.photoUrl) - setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: data.photoUrl })) + mapPhotoCache.set(cacheKey, data.photoUrl) + setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl })) + } else { + mapPhotoCache.set(cacheKey, null) } + mapPhotoInFlight.delete(cacheKey) }) - .catch(() => { mapPhotoCache.set(place.google_place_id, null) }) + .catch(() => { mapPhotoCache.set(cacheKey, null); mapPhotoInFlight.delete(cacheKey) }) }) }, [places]) @@ -302,6 +323,7 @@ export function MapView({ 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} /> + {places.map((place) => { const isSelected = place.id === selectedPlaceId - const resolvedPhotoUrl = place.image_url || (place.google_place_id && photoUrls[place.google_place_id]) || null + const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` + const resolvedPhotoUrl = place.image_url || (cacheKey && photoUrls[cacheKey]) || null const orderNumbers = dayOrderMap[place.id] ?? null const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected) diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index abaf03f..cf162f7 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -190,7 +190,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()} - ${escHtml(day.title || `Tag ${day.day_number}`)} + ${escHtml(day.title || tr('dayplan.dayN', { n: day.day_number }))} ${day.date ? `${shortDate(day.date, loc)}` : ''} ${cost ? `${cost}` : ''}
@@ -199,7 +199,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor }).join('') const html = ` - + diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 7cb9d5a..b22376c 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -61,6 +61,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri const [weather, setWeather] = useState(null) const [loading, setLoading] = useState(false) const [accommodation, setAccommodation] = useState(null) + const [dayAccommodations, setDayAccommodations] = useState([]) const [accommodations, setAccommodations] = useState([]) const [showHotelPicker, setShowHotelPicker] = useState(false) const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id }) @@ -81,10 +82,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri accommodationsApi.list(tripId) .then(data => { setAccommodations(data.accommodations || []) - const acc = (data.accommodations || []).find(a => + const allForDay = (data.accommodations || []).filter(a => days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id) ) - setAccommodation(acc || null) + setDayAccommodations(allForDay) + setAccommodation(allForDay[0] || null) }) .catch(() => {}) }, [tripId, day?.id]) @@ -287,57 +289,101 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
{t('day.accommodation')}
- {accommodation ? ( -
- {/* Hotel header */} -
-
- {accommodation.place_image ? ( - - ) : ( - - )} -
-
-
{accommodation.place_name}
- {accommodation.place_address &&
{accommodation.place_address}
} -
- -
- {/* Details row */} - {/* Details grid */} -
- {accommodation.check_in && ( -
-
{fmtTime(accommodation.check_in)}
-
- {t('day.checkIn')} + {dayAccommodations.length > 0 ? ( +
+ {dayAccommodations.map(acc => { + const isCheckInDay = acc.start_day_id === day.id + const isCheckOutDay = acc.end_day_id === day.id + const isMiddleDay = !isCheckInDay && !isCheckOutDay + const dayLabel = isCheckInDay && isCheckOutDay ? t('day.checkIn') + ' & ' + t('day.checkOut') + : isCheckInDay ? t('day.checkIn') + : isCheckOutDay ? t('day.checkOut') + : null + const linked = reservations.find(r => r.accommodation_id === acc.id) + const confirmed = linked?.status === 'confirmed' + + return ( +
+ {/* Day label */} + {dayLabel && ( +
+ {isCheckInDay && } + {isCheckOutDay && !isCheckInDay && } + {dayLabel} +
+ )} + {/* Hotel header */} +
+
+ {acc.place_image ? ( + + ) : ( + + )} +
+
+
{acc.place_name}
+ {acc.place_address &&
{acc.place_address}
} +
+ +
-
- )} - {accommodation.check_out && ( -
-
{fmtTime(accommodation.check_out)}
-
- {t('day.checkOut')} + {/* Details grid */} +
+ {acc.check_in && ( +
+
{fmtTime(acc.check_in)}
+
+ {t('day.checkIn')} +
+
+ )} + {acc.check_out && ( +
+
{fmtTime(acc.check_out)}
+
+ {t('day.checkOut')} +
+
+ )} + {acc.confirmation && ( +
+
{acc.confirmation}
+
+ {t('day.confirmation')} +
+
+ )}
+ {/* Linked booking */} + {linked && ( +
+
+
+
{linked.title}
+
+ {confirmed ? t('reservations.confirmed') : t('reservations.pending')} + {linked.confirmation_number && #{linked.confirmation_number}} +
+
+
+ )}
- )} - {accommodation.confirmation && ( -
-
{accommodation.confirmation}
-
- {t('day.confirmation')} -
-
- )} - -
+ ) + })} + {/* Add another hotel */} +
) : ( {(() => { - const acc = accommodations.find(a => day.id >= a.start_day_id && day.id <= a.end_day_id) - return acc ? ( - { e.stopPropagation(); onPlaceClick(acc.place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}> - - {acc.place_name} - - ) : null + const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id) + if (dayAccs.length === 0) return null + return dayAccs.map(acc => { + const isCheckIn = acc.start_day_id === day.id + const isCheckOut = acc.end_day_id === day.id + const bg = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.08)' : isCheckIn ? 'rgba(34,197,94,0.08)' : 'var(--bg-secondary)' + const border = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.2)' : isCheckIn ? 'rgba(34,197,94,0.2)' : 'var(--border-primary)' + const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-muted)' + return ( + { e.stopPropagation(); onPlaceClick(acc.place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}> + + {acc.place_name} + + ) + }) })()}
)} @@ -735,6 +743,14 @@ export default function DayPlanSidebar({ {res.reservation_end_time && ` – ${res.reservation_end_time}`} )} + {(() => { + const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) + if (!meta) return null + if (meta.airline && meta.flight_number) return {meta.airline} {meta.flight_number} + if (meta.flight_number) return {meta.flight_number} + if (meta.train_number) return {meta.train_number} + return null + })()}
) })()} @@ -979,7 +995,7 @@ export default function DayPlanSidebar({ {totalCost > 0 && (
{t('dayplan.totalCost')} - {totalCost.toFixed(2)} {currency} + {totalCost.toFixed(currencyDecimals(currency))} {currency}
)} diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index a40ce48..6f48961 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -42,6 +42,7 @@ interface PlaceFormModalProps { onClose: () => void onSave: (data: PlaceFormData, files?: File[]) => Promise | void place: Place | null + prefillCoords?: { lat: number; lng: number; name?: string; address?: string } | null tripId: number categories: Category[] onCategoryCreated: (category: Category) => void @@ -50,7 +51,7 @@ interface PlaceFormModalProps { } export default function PlaceFormModal({ - isOpen, onClose, onSave, place, tripId, categories, + isOpen, onClose, onSave, place, prefillCoords, tripId, categories, onCategoryCreated, assignmentId, dayAssignments = [], }: PlaceFormModalProps) { const [form, setForm] = useState(DEFAULT_FORM) @@ -81,11 +82,19 @@ export default function PlaceFormModal({ transport_mode: place.transport_mode || 'walking', website: place.website || '', }) + } else if (prefillCoords) { + setForm({ + ...DEFAULT_FORM, + lat: String(prefillCoords.lat), + lng: String(prefillCoords.lng), + name: prefillCoords.name || '', + address: prefillCoords.address || '', + }) } else { setForm(DEFAULT_FORM) } setPendingFiles([]) - }, [place, isOpen]) + }, [place, prefillCoords, isOpen]) const handleChange = (field, value) => { setForm(prev => ({ ...prev, [field]: value })) @@ -112,6 +121,9 @@ export default function PlaceFormModal({ lat: result.lat || prev.lat, lng: result.lng || prev.lng, google_place_id: result.google_place_id || prev.google_place_id, + osm_id: result.osm_id || prev.osm_id, + website: result.website || prev.website, + phone: result.phone || prev.phone, })) setMapsResults([]) setMapsSearch('') diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 68dff2d..e1cf7ae 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -20,23 +20,21 @@ function setSessionCache(key, value) { try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {} } -function useGoogleDetails(googlePlaceId, language) { +function usePlaceDetails(googlePlaceId, osmId, language) { const [details, setDetails] = useState(null) - const cacheKey = `gdetails_${googlePlaceId}_${language}` + const detailId = googlePlaceId || osmId + const cacheKey = `gdetails_${detailId}_${language}` useEffect(() => { - if (!googlePlaceId) { setDetails(null); return } - // In-memory cache (fastest) + if (!detailId) { setDetails(null); return } if (detailsCache.has(cacheKey)) { setDetails(detailsCache.get(cacheKey)); return } - // sessionStorage cache (survives reload) const cached = getSessionCache(cacheKey) if (cached) { detailsCache.set(cacheKey, cached); setDetails(cached); return } - // Fetch from API - mapsApi.details(googlePlaceId, language).then(data => { + mapsApi.details(detailId, language).then(data => { detailsCache.set(cacheKey, data.place) setSessionCache(cacheKey, data.place) setDetails(data.place) }).catch(() => {}) - }, [googlePlaceId, language]) + }, [detailId, language]) return details } @@ -138,7 +136,7 @@ export default function PlaceInspector({ const [nameValue, setNameValue] = useState('') const nameInputRef = useRef(null) const fileInputRef = useRef(null) - const googleDetails = useGoogleDetails(place?.google_place_id, language) + const googleDetails = usePlaceDetails(place?.google_place_id, place?.osm_id, language) const startNameEdit = () => { if (!onUpdatePlace) return @@ -327,20 +325,20 @@ export default function PlaceInspector({
{/* Telefon */} - {place.phone && ( + {(place.phone || googleDetails?.phone) && ( )} - {/* Description */} - {(place.description || place.notes) && ( + {/* Description / Summary */} + {(place.description || place.notes || googleDetails?.summary) && (

- {place.description || place.notes} + {place.description || place.notes || googleDetails?.summary}

)} @@ -391,6 +389,20 @@ export default function PlaceInspector({ )}
{res.notes &&
{res.notes}
} + {(() => { + const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) + if (!meta || Object.keys(meta).length === 0) return null + const parts: string[] = [] + if (meta.airline && meta.flight_number) parts.push(`${meta.airline} ${meta.flight_number}`) + else if (meta.flight_number) parts.push(meta.flight_number) + if (meta.departure_airport && meta.arrival_airport) parts.push(`${meta.departure_airport} → ${meta.arrival_airport}`) + if (meta.train_number) parts.push(meta.train_number) + if (meta.platform) parts.push(`Gl. ${meta.platform}`) + if (meta.check_in_time) parts.push(`Check-in ${meta.check_in_time}`) + if (meta.check_out_time) parts.push(`Check-out ${meta.check_out_time}`) + if (parts.length === 0) return null + return
{parts.join(' · ')}
+ })()}
) })()} @@ -502,8 +514,12 @@ export default function PlaceInspector({ window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={} label={{t('inspector.google')}} /> )} - {place.website && ( - window.open(place.website, '_blank')} variant="ghost" icon={} + {!googleDetails?.google_maps_url && place.lat && place.lng && ( + window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank')} variant="ghost" icon={} + label={Google Maps} /> + )} + {(place.website || googleDetails?.website) && ( + window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={} label={{t('inspector.website')}} /> )}
diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index eb6d772..dafaac4 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -6,7 +6,7 @@ import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { CustomDatePicker } from '../shared/CustomDateTimePicker' import CustomTimePicker from '../shared/CustomTimePicker' -import type { Day, Place, Reservation, TripFile, AssignmentsMap } from '../../types' +import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types' const TYPE_OPTIONS = [ { value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane }, @@ -58,17 +58,22 @@ interface ReservationModalProps { files?: TripFile[] onFileUpload: (fd: FormData) => Promise onFileDelete: (fileId: number) => Promise + accommodations?: Accommodation[] } -export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete }: ReservationModalProps) { +export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) { const toast = useToast() const { t, locale } = useTranslation() const fileInputRef = useRef(null) const [form, setForm] = useState({ title: '', type: 'other', status: 'pending', - reservation_time: '', location: '', confirmation_number: '', - notes: '', assignment_id: '', + reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '', + notes: '', assignment_id: '', accommodation_id: '', + meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '', + meta_train_number: '', meta_platform: '', meta_seat: '', + meta_check_in_time: '', meta_check_out_time: '', + hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', }) const [isSaving, setIsSaving] = useState(false) const [uploadingFile, setUploadingFile] = useState(false) @@ -81,6 +86,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p useEffect(() => { if (reservation) { + const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {}) setForm({ title: reservation.title || '', type: reservation.type || 'other', @@ -91,12 +97,28 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p confirmation_number: reservation.confirmation_number || '', 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_train_number: meta.train_number || '', + meta_platform: meta.platform || '', + meta_seat: meta.seat || '', + meta_check_in_time: meta.check_in_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 || '' })(), + hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(), }) } else { setForm({ title: '', type: 'other', status: 'pending', reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '', - notes: '', assignment_id: '', + notes: '', assignment_id: '', accommodation_id: '', + meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '', + meta_train_number: '', meta_platform: '', meta_seat: '', + meta_check_in_time: '', meta_check_out_time: '', }) setPendingFiles([]) } @@ -109,10 +131,41 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p if (!form.title.trim()) return setIsSaving(true) try { - const saved = await onSave({ - ...form, + const metadata: Record = {} + 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 + } else if (form.type === 'hotel') { + if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_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 + } + const saveData: Record = { + title: form.title, type: form.type, status: form.status, + reservation_time: form.reservation_time, reservation_end_time: form.reservation_end_time, + 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, + } + // 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_out: form.meta_check_out_time || null, + confirmation: form.confirmation_number || null, + } + } + const saved = await onSave(saveData) if (!reservation?.id && saved?.id && pendingFiles.length > 0) { for (const file of pendingFiles) { const fd = new FormData() @@ -190,7 +243,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
- {/* Assignment Picker + Date */} + {/* Assignment Picker + Date (hidden for hotels) */} + {form.type !== 'hotel' && (
{assignmentOptions.length > 0 && (
@@ -231,24 +285,29 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p />
+ )} {/* Start Time + End Time + Status */}
-
- - { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()} - onChange={t => { - const [d] = (form.reservation_time || '').split('T') - const date = d || new Date().toISOString().split('T')[0] - set('reservation_time', t ? `${date}T${t}` : date) - }} - /> -
-
- - set('reservation_end_time', v)} /> -
+ {form.type !== 'hotel' && ( + <> +
+ + { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()} + onChange={t => { + const [d] = (form.reservation_time || '').split('T') + const date = d || new Date().toISOString().split('T')[0] + set('reservation_time', t ? `${date}T${t}` : date) + }} + /> +
+
+ + set('reservation_end_time', v)} /> +
+ + )}
+ {/* Type-specific fields */} + {form.type === 'flight' && ( +
+
+ + set('meta_airline', e.target.value)} + placeholder="Lufthansa" style={inputStyle} /> +
+
+ + set('meta_flight_number', e.target.value)} + placeholder="LH 123" style={inputStyle} /> +
+
+ + set('meta_departure_airport', e.target.value)} + placeholder="FRA" style={inputStyle} /> +
+
+ + set('meta_arrival_airport', e.target.value)} + placeholder="NRT" style={inputStyle} /> +
+
+ )} + + {form.type === 'hotel' && ( + <> + {/* Hotel place + day range */} +
+
+ + { + set('hotel_place_id', value) + const p = places.find(pl => pl.id === value) + if (p) { + if (!form.title) set('title', p.name) + if (!form.location && p.address) set('location', p.address) + } + }} + placeholder={t('reservations.meta.pickHotel')} + options={[ + { value: '', label: '—' }, + ...places.map(p => ({ value: p.id, label: p.name })), + ]} + searchable + size="sm" + /> +
+
+ + set('hotel_start_day', value)} + placeholder={t('reservations.meta.selectDay')} + options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))} + size="sm" + /> +
+
+ + set('hotel_end_day', value)} + placeholder={t('reservations.meta.selectDay')} + options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))} + size="sm" + /> +
+
+ {/* Check-in/out times */} +
+
+ + set('meta_check_in_time', v)} /> +
+
+ + set('meta_check_out_time', v)} /> +
+
+ + )} + + {form.type === 'train' && ( +
+
+ + set('meta_train_number', e.target.value)} + placeholder="ICE 123" style={inputStyle} /> +
+
+ + set('meta_platform', e.target.value)} + placeholder="12" style={inputStyle} /> +
+
+ + set('meta_seat', e.target.value)} + placeholder="42A" style={inputStyle} /> +
+
+ )} + {/* Notes */}
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 86fae51..c8de359 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -112,7 +112,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{/* Details */} - {(r.reservation_time || r.confirmation_number || r.location || linked) && ( + {(r.reservation_time || r.confirmation_number || r.location || linked || r.metadata) && (
{/* Row 1: Date, Time, Code */} {(r.reservation_time || r.confirmation_number) && ( @@ -139,8 +139,34 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo )}
)} + {/* 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: meta.check_in_time }) + if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: meta.check_out_time }) + if (cells.length === 0) return null + return ( +
+ {cells.map((c, i) => ( +
+
{c.label}
+
{c.value}
+
+ ))} +
+ ) + })()} {/* Row 2: Location + Assignment */} - {(r.location || linked) && ( + {(r.location || linked || r.accommodation_name) && (
{r.location && (
@@ -151,6 +177,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
)} + {r.accommodation_name && ( +
+
{t('reservations.meta.linkAccommodation')}
+
+ + {r.accommodation_name} +
+
+ )} {linked && (
{t('reservations.linkAssignment')}
diff --git a/client/src/components/shared/PlaceAvatar.tsx b/client/src/components/shared/PlaceAvatar.tsx index 57e18f2..c250160 100644 --- a/client/src/components/shared/PlaceAvatar.tsx +++ b/client/src/components/shared/PlaceAvatar.tsx @@ -9,34 +9,53 @@ interface Category { } interface PlaceAvatarProps { - place: Pick + place: Pick size?: number category?: Category | null } -const googlePhotoCache = new Map() +const photoCache = new Map() +const photoInFlight = new Set() export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) { const [photoSrc, setPhotoSrc] = useState(place.image_url || null) useEffect(() => { if (place.image_url) { setPhotoSrc(place.image_url); return } - if (!place.google_place_id) { setPhotoSrc(null); return } + const photoId = place.google_place_id || place.osm_id + if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return } - if (googlePhotoCache.has(place.google_place_id)) { - setPhotoSrc(googlePhotoCache.get(place.google_place_id)!) + const cacheKey = photoId || `${place.lat},${place.lng}` + if (photoCache.has(cacheKey)) { + const cached = photoCache.get(cacheKey) + if (cached) setPhotoSrc(cached) return } - mapsApi.placePhoto(place.google_place_id) + if (photoInFlight.has(cacheKey)) { + // Another instance is already fetching, wait for it + const check = setInterval(() => { + if (photoCache.has(cacheKey)) { + clearInterval(check) + const cached = photoCache.get(cacheKey) + if (cached) setPhotoSrc(cached) + } + }, 200) + return () => clearInterval(check) + } + photoInFlight.add(cacheKey) + mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) .then((data: { photoUrl?: string }) => { if (data.photoUrl) { - googlePhotoCache.set(place.google_place_id!, data.photoUrl) + photoCache.set(cacheKey, data.photoUrl) setPhotoSrc(data.photoUrl) + } else { + photoCache.set(cacheKey, null) } + photoInFlight.delete(cacheKey) }) - .catch(() => {}) - }, [place.id, place.image_url, place.google_place_id]) + .catch(() => { photoCache.set(cacheKey, null); photoInFlight.delete(cacheKey) }) + }, [place.id, place.image_url, place.google_place_id, place.osm_id]) const bgColor = category?.color || '#6366f1' const IconComp = getCategoryIcon(category?.icon) diff --git a/client/src/components/shared/Toast.tsx b/client/src/components/shared/Toast.tsx index 22d2c36..29ceb60 100644 --- a/client/src/components/shared/Toast.tsx +++ b/client/src/components/shared/Toast.tsx @@ -19,6 +19,13 @@ declare global { let toastIdCounter = 0 +const ICON_COLORS: Record = { + success: '#22c55e', + error: '#ef4444', + warning: '#f59e0b', + info: '#6366f1', +} + export function ToastContainer() { const [toasts, setToasts] = useState([]) @@ -31,7 +38,7 @@ export function ToastContainer() { setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t)) setTimeout(() => { setToasts(prev => prev.filter(t => t.id !== id)) - }, 300) + }, 400) }, duration) } @@ -42,7 +49,7 @@ export function ToastContainer() { setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t)) setTimeout(() => { setToasts(prev => prev.filter(t => t.id !== id)) - }, 300) + }, 400) }, []) useEffect(() => { @@ -51,42 +58,83 @@ export function ToastContainer() { }, [addToast]) const icons: Record = { - success: , - error: , - warning: , - info: , - } - - const bgColors: Record = { - success: 'bg-white border-l-4 border-emerald-500', - error: 'bg-white border-l-4 border-red-500', - warning: 'bg-white border-l-4 border-amber-500', - info: 'bg-white border-l-4 border-blue-500', + success: , + error: , + warning: , + info: , } return ( -
- {toasts.map(toast => ( -
- {icons[toast.type] || icons.info} -

{toast.message}

- -
- ))} -
+ {icons[toast.type] || icons.info} + + {toast.message} + + +
+ ))} +
+ ) } diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 2fc0d5b..2bec291 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -191,7 +191,7 @@ const de: Record = { 'login.signingIn': 'Anmelden…', 'login.signIn': 'Anmelden', 'login.createAdmin': 'Admin-Konto erstellen', - 'login.createAdminHint': 'Erstelle das erste Admin-Konto für NOMAD.', + 'login.createAdminHint': 'Erstelle das erste Admin-Konto für TREK.', 'login.createAccount': 'Konto erstellen', 'login.createAccountHint': 'Neues Konto registrieren.', 'login.creating': 'Erstelle…', @@ -295,7 +295,7 @@ const de: Record = { // Addons 'admin.tabs.addons': 'Addons', 'admin.addons.title': 'Addons', - 'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um NOMAD nach deinen Wünschen anzupassen.', + 'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.', 'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ', 'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.', 'admin.addons.enabled': 'Aktiviert', @@ -310,7 +310,7 @@ const de: Record = { // Weather info 'admin.weather.title': 'Wetterdaten', 'admin.weather.badge': 'Seit 24. März 2026', - 'admin.weather.description': 'NOMAD nutzt Open-Meteo als Wetterdatenquelle. Open-Meteo ist ein kostenloser, quelloffener Wetterdienst — es wird kein API-Schlüssel benötigt.', + 'admin.weather.description': 'TREK nutzt Open-Meteo als Wetterdatenquelle. Open-Meteo ist ein kostenloser, quelloffener Wetterdienst — es wird kein API-Schlüssel benötigt.', 'admin.weather.forecast': '16-Tage-Vorhersage', 'admin.weather.forecastDesc': 'Statt bisher 5 Tage (OpenWeatherMap)', 'admin.weather.climate': 'Historische Klimadaten', @@ -333,11 +333,11 @@ const de: Record = { 'admin.github.by': 'von', 'admin.update.available': 'Update verfügbar', - 'admin.update.text': 'NOMAD {version} ist verfügbar. Du verwendest {current}.', + 'admin.update.text': 'TREK {version} ist verfügbar. Du verwendest {current}.', 'admin.update.button': 'Auf GitHub ansehen', 'admin.update.install': 'Update installieren', 'admin.update.confirmTitle': 'Update installieren?', - 'admin.update.confirmText': 'NOMAD wird von {current} auf {version} aktualisiert. Der Server startet danach automatisch neu.', + 'admin.update.confirmText': 'TREK wird von {current} auf {version} aktualisiert. Der Server startet danach automatisch neu.', 'admin.update.dataInfo': 'Alle Daten (Reisen, Benutzer, API-Schlüssel, Uploads, Vacay, Atlas, Budgets) bleiben erhalten.', 'admin.update.warning': 'Die App ist während des Neustarts kurz nicht erreichbar.', 'admin.update.confirm': 'Jetzt aktualisieren', @@ -347,7 +347,7 @@ const de: Record = { 'admin.update.backupHint': 'Wir empfehlen, vor dem Update ein Backup zu erstellen und herunterzuladen.', 'admin.update.backupLink': 'Zum Backup', 'admin.update.howTo': 'Update-Anleitung', - 'admin.update.dockerText': 'Deine NOMAD-Instanz läuft in Docker. Um auf {version} zu aktualisieren, führe folgende Befehle auf deinem Server aus:', + 'admin.update.dockerText': 'Deine TREK-Instanz läuft in Docker. Um auf {version} zu aktualisieren, führe folgende Befehle auf deinem Server aus:', 'admin.update.reloadHint': 'Bitte lade die Seite in wenigen Sekunden neu.', // Vacay addon @@ -393,9 +393,9 @@ const de: Record = { 'vacay.carryOver': 'Urlaubsmitnahme', 'vacay.carryOverHint': 'Resturlaub automatisch ins Folgejahr übertragen', 'vacay.sharing': 'Teilen', - 'vacay.sharingHint': 'Teile deinen Urlaubsplan mit anderen NOMAD-Benutzern', + 'vacay.sharingHint': 'Teile deinen Urlaubsplan mit anderen TREK-Benutzern', 'vacay.owner': 'Besitzer', - 'vacay.shareEmailPlaceholder': 'E-Mail des NOMAD-Benutzers', + 'vacay.shareEmailPlaceholder': 'E-Mail des TREK-Benutzers', 'vacay.shareSuccess': 'Plan erfolgreich geteilt', 'vacay.shareError': 'Plan konnte nicht geteilt werden', 'vacay.dissolve': 'Fusion auflösen', @@ -407,7 +407,7 @@ const de: Record = { 'vacay.noData': 'Keine Daten', 'vacay.changeColor': 'Farbe ändern', 'vacay.inviteUser': 'Benutzer einladen', - 'vacay.inviteHint': 'Lade einen anderen NOMAD-Benutzer ein, um einen gemeinsamen Urlaubskalender zu teilen.', + 'vacay.inviteHint': 'Lade einen anderen TREK-Benutzer ein, um einen gemeinsamen Urlaubskalender zu teilen.', 'vacay.selectUser': 'Benutzer wählen', 'vacay.sendInvite': 'Einladung senden', 'vacay.inviteSent': 'Einladung gesendet', @@ -586,6 +586,23 @@ const de: Record = { 'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)', 'reservations.notes': 'Notizen', 'reservations.notesPlaceholder': 'Zusätzliche Notizen...', + 'reservations.meta.airline': 'Airline', + 'reservations.meta.flightNumber': 'Flugnr.', + 'reservations.meta.from': 'Von', + 'reservations.meta.to': 'Nach', + 'reservations.meta.trainNumber': 'Zugnr.', + 'reservations.meta.platform': 'Gleis', + 'reservations.meta.seat': 'Sitzplatz', + 'reservations.meta.checkIn': 'Check-in', + 'reservations.meta.checkOut': 'Check-out', + 'reservations.meta.linkAccommodation': 'Unterkunft', + 'reservations.meta.pickAccommodation': 'Mit Unterkunft verknüpfen', + 'reservations.meta.noAccommodation': 'Keine', + 'reservations.meta.hotelPlace': 'Hotel', + 'reservations.meta.pickHotel': 'Hotel auswählen', + 'reservations.meta.fromDay': 'Von', + 'reservations.meta.toDay': 'Bis', + 'reservations.meta.selectDay': 'Tag wählen', 'reservations.type.flight': 'Flug', 'reservations.type.hotel': 'Hotel', 'reservations.type.restaurant': 'Restaurant', @@ -679,6 +696,28 @@ const de: Record = { 'files.sourceBooking': 'Buchung', 'files.attach': 'Anhängen', 'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)', + 'files.trash': 'Papierkorb', + 'files.trashEmpty': 'Papierkorb ist leer', + 'files.emptyTrash': 'Papierkorb leeren', + 'files.restore': 'Wiederherstellen', + 'files.star': 'Markieren', + 'files.unstar': 'Markierung entfernen', + 'files.assign': 'Zuweisen', + 'files.assignTitle': 'Datei zuweisen', + 'files.assignPlace': 'Ort', + 'files.assignBooking': 'Buchung', + 'files.unassigned': 'Nicht zugewiesen', + 'files.unlink': 'Verknüpfung entfernen', + 'files.toast.trashed': 'In den Papierkorb verschoben', + 'files.toast.restored': 'Datei wiederhergestellt', + 'files.toast.trashEmptied': 'Papierkorb geleert', + 'files.toast.assigned': 'Datei zugewiesen', + 'files.toast.assignError': 'Zuweisung fehlgeschlagen', + 'files.toast.restoreError': 'Wiederherstellung fehlgeschlagen', + 'files.confirm.permanentDelete': 'Diese Datei endgültig löschen? Das kann nicht rückgängig gemacht werden.', + 'files.confirm.emptyTrash': 'Alle Dateien im Papierkorb endgültig löschen? Das kann nicht rückgängig gemacht werden.', + 'files.noteLabel': 'Notiz', + 'files.notePlaceholder': 'Notiz hinzufügen...', // Packing 'packing.title': 'Packliste', @@ -968,7 +1007,6 @@ const de: Record = { 'collab.chat.justNow': 'gerade eben', 'collab.chat.minutesAgo': 'vor {n} Min.', 'collab.chat.hoursAgo': 'vor {n} Std.', - 'collab.chat.yesterday': 'gestern', 'collab.notes.title': 'Notizen', 'collab.notes.new': 'Neue Notiz', 'collab.notes.empty': 'Noch keine Notizen', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 4e0a76e..73c42a2 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -191,7 +191,7 @@ const en: Record = { 'login.signingIn': 'Signing in…', 'login.signIn': 'Sign In', 'login.createAdmin': 'Create Admin Account', - 'login.createAdminHint': 'Set up the first admin account for NOMAD.', + 'login.createAdminHint': 'Set up the first admin account for TREK.', 'login.createAccount': 'Create Account', 'login.createAccountHint': 'Register a new account.', 'login.creating': 'Creating…', @@ -295,7 +295,7 @@ const en: Record = { // Addons 'admin.tabs.addons': 'Addons', 'admin.addons.title': 'Addons', - 'admin.addons.subtitle': 'Enable or disable features to customize your NOMAD experience.', + 'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.', 'admin.addons.subtitleBefore': 'Enable or disable features to customize your ', 'admin.addons.subtitleAfter': ' experience.', 'admin.addons.enabled': 'Enabled', @@ -310,7 +310,7 @@ const en: Record = { // Weather info 'admin.weather.title': 'Weather Data', 'admin.weather.badge': 'Since March 24, 2026', - 'admin.weather.description': 'NOMAD uses Open-Meteo as its weather data source. Open-Meteo is a free, open-source weather service — no API key required.', + 'admin.weather.description': 'TREK uses Open-Meteo as its weather data source. Open-Meteo is a free, open-source weather service — no API key required.', 'admin.weather.forecast': '16-day forecast', 'admin.weather.forecastDesc': 'Previously 5 days (OpenWeatherMap)', 'admin.weather.climate': 'Historical climate data', @@ -333,11 +333,11 @@ const en: Record = { 'admin.github.by': 'by', 'admin.update.available': 'Update available', - 'admin.update.text': 'NOMAD {version} is available. You are running {current}.', + 'admin.update.text': 'TREK {version} is available. You are running {current}.', 'admin.update.button': 'View on GitHub', 'admin.update.install': 'Install Update', 'admin.update.confirmTitle': 'Install Update?', - 'admin.update.confirmText': 'NOMAD will be updated from {current} to {version}. The server will restart automatically afterwards.', + 'admin.update.confirmText': 'TREK will be updated from {current} to {version}. The server will restart automatically afterwards.', 'admin.update.dataInfo': 'All your data (trips, users, API keys, uploads, Vacay, Atlas, budgets) will be preserved.', 'admin.update.warning': 'The app will be briefly unavailable during the restart.', 'admin.update.confirm': 'Update Now', @@ -347,7 +347,7 @@ const en: Record = { 'admin.update.backupHint': 'We recommend creating a backup before updating.', 'admin.update.backupLink': 'Go to Backup', 'admin.update.howTo': 'How to Update', - 'admin.update.dockerText': 'Your NOMAD instance runs in Docker. To update to {version}, run the following commands on your server:', + 'admin.update.dockerText': 'Your TREK instance runs in Docker. To update to {version}, run the following commands on your server:', 'admin.update.reloadHint': 'Please reload the page in a few seconds.', // Vacay addon @@ -393,9 +393,9 @@ const en: Record = { 'vacay.carryOver': 'Carry Over', 'vacay.carryOverHint': 'Automatically carry remaining vacation days into the next year', 'vacay.sharing': 'Sharing', - 'vacay.sharingHint': 'Share your vacation plan with other NOMAD users', + 'vacay.sharingHint': 'Share your vacation plan with other TREK users', 'vacay.owner': 'Owner', - 'vacay.shareEmailPlaceholder': 'Email of NOMAD user', + 'vacay.shareEmailPlaceholder': 'Email of TREK user', 'vacay.shareSuccess': 'Plan shared successfully', 'vacay.shareError': 'Could not share plan', 'vacay.dissolve': 'Dissolve Fusion', @@ -407,7 +407,7 @@ const en: Record = { 'vacay.noData': 'No data', 'vacay.changeColor': 'Change color', 'vacay.inviteUser': 'Invite User', - 'vacay.inviteHint': 'Invite another NOMAD user to share a combined vacation calendar.', + 'vacay.inviteHint': 'Invite another TREK user to share a combined vacation calendar.', 'vacay.selectUser': 'Select user', 'vacay.sendInvite': 'Send Invite', 'vacay.inviteSent': 'Invite sent', @@ -586,6 +586,23 @@ const en: Record = { 'reservations.timeAlt': 'Time (alternative, e.g. 19:30)', 'reservations.notes': 'Notes', 'reservations.notesPlaceholder': 'Additional notes...', + 'reservations.meta.airline': 'Airline', + 'reservations.meta.flightNumber': 'Flight No.', + 'reservations.meta.from': 'From', + 'reservations.meta.to': 'To', + 'reservations.meta.trainNumber': 'Train No.', + 'reservations.meta.platform': 'Platform', + 'reservations.meta.seat': 'Seat', + 'reservations.meta.checkIn': 'Check-in', + 'reservations.meta.checkOut': 'Check-out', + 'reservations.meta.linkAccommodation': 'Accommodation', + 'reservations.meta.pickAccommodation': 'Link to accommodation', + 'reservations.meta.noAccommodation': 'None', + 'reservations.meta.hotelPlace': 'Hotel', + 'reservations.meta.pickHotel': 'Select hotel', + 'reservations.meta.fromDay': 'From', + 'reservations.meta.toDay': 'To', + 'reservations.meta.selectDay': 'Select day', 'reservations.type.flight': 'Flight', 'reservations.type.hotel': 'Hotel', 'reservations.type.restaurant': 'Restaurant', @@ -679,6 +696,28 @@ const en: Record = { 'files.sourceBooking': 'Booking', 'files.attach': 'Attach', 'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)', + 'files.trash': 'Trash', + 'files.trashEmpty': 'Trash is empty', + 'files.emptyTrash': 'Empty Trash', + 'files.restore': 'Restore', + 'files.star': 'Star', + 'files.unstar': 'Unstar', + 'files.assign': 'Assign', + 'files.assignTitle': 'Assign File', + 'files.assignPlace': 'Place', + 'files.assignBooking': 'Booking', + 'files.unassigned': 'Unassigned', + 'files.unlink': 'Remove link', + 'files.toast.trashed': 'Moved to trash', + 'files.toast.restored': 'File restored', + 'files.toast.trashEmptied': 'Trash emptied', + 'files.toast.assigned': 'File assigned', + 'files.toast.assignError': 'Assignment failed', + 'files.toast.restoreError': 'Restore failed', + 'files.confirm.permanentDelete': 'Permanently delete this file? This cannot be undone.', + 'files.confirm.emptyTrash': 'Permanently delete all trashed files? This cannot be undone.', + 'files.noteLabel': 'Note', + 'files.notePlaceholder': 'Add a note...', // Packing 'packing.title': 'Packing List', @@ -968,7 +1007,6 @@ const en: Record = { 'collab.chat.justNow': 'just now', 'collab.chat.minutesAgo': '{n}m ago', 'collab.chat.hoursAgo': '{n}h ago', - 'collab.chat.yesterday': 'yesterday', 'collab.notes.title': 'Notes', 'collab.notes.new': 'New Note', 'collab.notes.empty': 'No notes yet', diff --git a/client/src/index.css b/client/src/index.css index f45c88e..cbba8d1 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -337,7 +337,7 @@ body { } /* Brand images: no save/copy/drag */ -img[alt="NOMAD"] { +img[alt="TREK"] { pointer-events: none; user-select: none; -webkit-user-select: none; @@ -460,3 +460,23 @@ img[alt="NOMAD"] { align-items: center; justify-content: center; } + +/* Markdown in Collab Notes */ +.collab-note-md strong, .collab-note-md-full strong { font-weight: 700 !important; } +.collab-note-md em, .collab-note-md-full em { font-style: italic !important; } +.collab-note-md h1, .collab-note-md h2, .collab-note-md h3 { font-weight: 700 !important; margin: 0; } +.collab-note-md-full h1 { font-size: 1.3em !important; font-weight: 700 !important; margin: 0.8em 0 0.3em; } +.collab-note-md-full h2 { font-size: 1.15em !important; font-weight: 700 !important; margin: 0.8em 0 0.3em; } +.collab-note-md-full h3 { font-size: 1em !important; font-weight: 700 !important; margin: 0.8em 0 0.3em; } +.collab-note-md p, .collab-note-md-full p { margin: 0 0 0.3em; } +.collab-note-md ul, .collab-note-md-full ul { list-style-type: disc !important; padding-left: 1.4em !important; margin: 0.2em 0; } +.collab-note-md ol, .collab-note-md-full ol { list-style-type: decimal !important; padding-left: 1.4em !important; margin: 0.2em 0; } +.collab-note-md li, .collab-note-md-full li { display: list-item !important; margin: 0.1em 0; } +.collab-note-md code, .collab-note-md-full code { font-size: 0.9em; padding: 1px 5px; border-radius: 4px; background: var(--bg-secondary); } +.collab-note-md-full pre { padding: 10px 12px; border-radius: 8px; background: var(--bg-secondary); overflow-x: auto; margin: 0.5em 0; } +.collab-note-md-full pre code { padding: 0; background: none; } +.collab-note-md a, .collab-note-md-full a { color: #3b82f6; text-decoration: underline; } +.collab-note-md blockquote, .collab-note-md-full blockquote { border-left: 3px solid var(--border-primary); padding-left: 12px; margin: 0.5em 0; color: var(--text-muted); } +.collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; } +.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; } +.collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; } diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 84e9d9c..5ed5423 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -186,7 +186,7 @@ export default function LoginPage(): React.ReactElement { position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, }}> - NOMAD + TREK

{t('login.tagline')}

@@ -384,7 +384,7 @@ export default function LoginPage(): React.ReactElement {
{/* Logo */}
- NOMAD + TREK

@@ -429,7 +429,7 @@ export default function LoginPage(): React.ReactElement {
- NOMAD + TREK

{t('login.tagline')}

diff --git a/client/src/pages/RegisterPage.tsx b/client/src/pages/RegisterPage.tsx index 1146b64..762d1f1 100644 --- a/client/src/pages/RegisterPage.tsx +++ b/client/src/pages/RegisterPage.tsx @@ -75,7 +75,7 @@ export default function RegisterPage(): React.ReactElement {
- NOMAD + TREK
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index baedaa9..89b239b 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -33,7 +33,7 @@ export default function TripPlannerPage(): React.ReactElement | null { const { id: tripId } = useParams<{ id: string }>() const navigate = useNavigate() const toast = useToast() - const { t } = useTranslation() + const { t, language } = useTranslation() const { settings } = useSettingsStore() const tripStore = useTripStore() const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore @@ -44,7 +44,10 @@ export default function TripPlannerPage(): React.ReactElement | null { const [tripMembers, setTripMembers] = useState([]) const loadAccommodations = useCallback(() => { - if (tripId) accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) + if (tripId) { + accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) + tripStore.loadReservations(tripId) + } }, [tripId]) useEffect(() => { @@ -83,6 +86,7 @@ export default function TripPlannerPage(): React.ReactElement | null { const [showDayDetail, setShowDayDetail] = useState(null) const [showPlaceForm, setShowPlaceForm] = useState(false) const [editingPlace, setEditingPlace] = useState(null) + const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string } | null>(null) const [editingAssignmentId, setEditingAssignmentId] = useState(null) const [showTripForm, setShowTripForm] = useState(false) const [showMembersModal, setShowMembersModal] = useState(false) @@ -145,6 +149,22 @@ export default function TripPlannerPage(): React.ReactElement | null { setSelectedPlaceId(null) }, []) + const handleMapContextMenu = useCallback(async (e) => { + e.originalEvent?.preventDefault() + const { lat, lng } = e.latlng + setPrefillCoords({ lat, lng }) + setEditingPlace(null) + setEditingAssignmentId(null) + setShowPlaceForm(true) + try { + const { mapsApi } = await import('../api/client') + const data = await mapsApi.reverse(lat, lng, language) + if (data.name || data.address) { + setPrefillCoords(prev => prev ? { ...prev, name: data.name || '', address: data.address || '' } : prev) + } + } catch { /* best effort */ } + }, [language]) + const handleSavePlace = useCallback(async (data) => { const pendingFiles = data._pendingFiles delete data._pendingFiles @@ -236,18 +256,30 @@ export default function TripPlannerPage(): React.ReactElement | null { const r = await tripStore.updateReservation(tripId, editingReservation.id, data) toast.success(t('trip.toast.reservationUpdated')) setShowReservationModal(false) + if (data.type === 'hotel') { + accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) + } return r } else { const r = await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null }) toast.success(t('trip.toast.reservationAdded')) setShowReservationModal(false) + // Refresh accommodations if hotel was created + if (data.type === 'hotel') { + accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) + } return r } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } } const handleDeleteReservation = async (id) => { - try { await tripStore.deleteReservation(tripId, id); toast.success(t('trip.toast.deleted')) } + try { + await tripStore.deleteReservation(tripId, id) + toast.success(t('trip.toast.deleted')) + // Refresh accommodations in case a hotel booking was deleted + accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) + } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } } @@ -345,6 +377,7 @@ export default function TripPlannerPage(): React.ReactElement | null { selectedPlaceId={selectedPlaceId} onMarkerClick={handleMarkerClick} onMapClick={handleMapClick} + onMapContextMenu={handleMapContextMenu} center={defaultCenter} zoom={defaultZoom} tileUrl={mapTileUrl} @@ -400,7 +433,7 @@ export default function TripPlannerPage(): React.ReactElement | null { onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }} - onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null) }} + onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} @@ -605,8 +638,10 @@ export default function TripPlannerPage(): React.ReactElement | null { files={files || []} onUpload={(fd) => tripStore.addFile(tripId, fd)} onDelete={(id) => tripStore.deleteFile(tripId, id)} - onUpdate={null} + onUpdate={(id, data) => tripStore.loadFiles(tripId)} places={places} + days={days} + assignments={assignments} reservations={reservations} tripId={tripId} allowedFileTypes={allowedFileTypes} @@ -621,10 +656,10 @@ export default function TripPlannerPage(): React.ReactElement | null { )}
- { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null) }} onSave={handleSavePlace} place={editingPlace} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} /> + { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} /> setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} /> setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} /> - { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} /> + { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} accommodations={tripAccommodations} /> setDeletePlaceId(null)} diff --git a/client/src/types.ts b/client/src/types.ts index c411637..761ae47 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -1,4 +1,4 @@ -// Shared types for the NOMAD travel planner +// Shared types for the TREK travel planner export interface User { id: number @@ -46,6 +46,7 @@ export interface Place { price: string | null image_url: string | null google_place_id: string | null + osm_id: string | null place_time: string | null end_time: string | null created_at: string @@ -114,6 +115,7 @@ export interface Reservation { id: number trip_id: number name: string + title?: string type: string | null status: 'pending' | 'confirmed' date: string | null @@ -121,17 +123,30 @@ export interface Reservation { confirmation_number: string | null notes: string | null url: string | null + accommodation_id?: number | null + metadata?: Record | null created_at: string } export interface TripFile { id: number trip_id: number + place_id?: number | null + reservation_id?: number | null + note_id?: number | null + uploaded_by?: number | null + uploaded_by_name?: string | null + uploaded_by_avatar?: string | null filename: string original_name: string + file_size?: number | null mime_type: string - size: number + description?: string | null + starred?: number + deleted_at?: string | null created_at: string + reservation_title?: string + url?: string } export interface Settings { diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts index e2e47c3..4fd3409 100644 --- a/client/src/utils/formatters.ts +++ b/client/src/utils/formatters.ts @@ -1,5 +1,11 @@ import type { AssignmentsMap } from '../types' +const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF']) + +export function currencyDecimals(currency: string): number { + return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2 +} + export function formatDate(dateStr: string | null | undefined, locale: string): string | null { if (!dateStr) return null return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { diff --git a/client/vite.config.js b/client/vite.config.js index cc6a319..a2d3632 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -66,9 +66,9 @@ export default defineConfig({ ], }, manifest: { - name: 'NOMAD \u2014 Travel Planner', - short_name: 'NOMAD', - description: 'Navigation Organizer for Maps, Activities & Destinations', + name: 'TREK \u2014 Travel Planner', + short_name: 'TREK', + description: 'Travel Resource & Exploration Kit', theme_color: '#111827', background_color: '#0f172a', display: 'standalone', diff --git a/server/package-lock.json b/server/package-lock.json index 6be8c5f..28bc934 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "nomad-server", - "version": "2.6.0", + "version": "2.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nomad-server", - "version": "2.6.0", + "version": "2.6.1", "dependencies": { "archiver": "^6.0.1", "bcryptjs": "^2.4.3", diff --git a/server/package.json b/server/package.json index a8f78aa..39ae754 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { - "name": "nomad-server", - "version": "2.6.1", + "name": "trek-server", + "version": "2.6.2", "main": "src/index.ts", "scripts": { "start": "node --import tsx src/index.ts", diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index dfcdf9e..a56f08e 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -193,6 +193,18 @@ function runMigrations(db: Database.Database): void { () => { try { db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time TEXT'); } catch {} }, + () => { + try { db.exec('ALTER TABLE places ADD COLUMN osm_id TEXT'); } catch {} + }, + () => { + try { db.exec('ALTER TABLE trip_files ADD COLUMN uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {} + try { db.exec('ALTER TABLE trip_files ADD COLUMN starred INTEGER DEFAULT 0'); } catch {} + try { db.exec('ALTER TABLE trip_files ADD COLUMN deleted_at TEXT'); } catch {} + }, + () => { + try { db.exec('ALTER TABLE reservations ADD COLUMN accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL'); } catch {} + try { db.exec('ALTER TABLE reservations ADD COLUMN metadata TEXT'); } catch {} + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/demo/demo-seed.ts b/server/src/demo/demo-seed.ts index 1ef6734..57a747d 100644 --- a/server/src/demo/demo-seed.ts +++ b/server/src/demo/demo-seed.ts @@ -3,9 +3,9 @@ import Database from 'better-sqlite3'; function seedDemoData(db: Database.Database): { adminId: number; demoId: number } { const ADMIN_USER = process.env.DEMO_ADMIN_USER || 'admin'; - const ADMIN_EMAIL = process.env.DEMO_ADMIN_EMAIL || 'admin@nomad.app'; + const ADMIN_EMAIL = process.env.DEMO_ADMIN_EMAIL || 'admin@trek.app'; const ADMIN_PASS = process.env.DEMO_ADMIN_PASS || 'admin12345'; - const DEMO_EMAIL = 'demo@nomad.app'; + const DEMO_EMAIL = 'demo@trek.app'; const DEMO_PASS = 'demo12345'; // Create admin user if not exists diff --git a/server/src/index.ts b/server/src/index.ts index 3e16ee5..7b1b09a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -64,8 +64,8 @@ app.use(helmet({ }, crossOriginEmbedderPolicy: false, })); -// Redirect HTTP to HTTPS in production -if (process.env.NODE_ENV === 'production' && process.env.FORCE_HTTPS !== 'false') { +// Redirect HTTP to HTTPS (opt-in via FORCE_HTTPS=true) +if (process.env.FORCE_HTTPS === 'true') { app.use((req: Request, res: Response, next: NextFunction) => { if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next(); res.redirect(301, 'https://' + req.headers.host + req.url); @@ -172,7 +172,7 @@ import * as scheduler from './scheduler'; const PORT = process.env.PORT || 3001; const server = app.listen(PORT, () => { - console.log(`NOMAD API running on port ${PORT}`); + console.log(`TREK API running on port ${PORT}`); console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED'); if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') { diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index e4928e1..53e538b 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -171,7 +171,7 @@ router.get('/version-check', async (_req: Request, res: Response) => { try { const resp = await fetch( 'https://api.github.com/repos/mauriceboe/NOMAD/releases/latest', - { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'NOMAD-Server' } } + { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } } ); if (!resp.ok) return res.json({ current: currentVersion, latest: currentVersion, update_available: false }); const data = await resp.json() as { tag_name?: string; html_url?: string }; diff --git a/server/src/routes/atlas.ts b/server/src/routes/atlas.ts index c6e2f11..d5a7227 100644 --- a/server/src/routes/atlas.ts +++ b/server/src/routes/atlas.ts @@ -24,12 +24,18 @@ const COUNTRY_BOXES: Record = { }; function getCountryFromCoords(lat: number, lng: number): string | null { + let bestCode: string | null = null; + let bestArea = Infinity; for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) { if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) { - return code; + const area = (maxLng - minLng) * (maxLat - minLat); + if (area < bestArea) { + bestArea = area; + bestCode = code; + } } } - return null; + return bestCode; } const NAME_TO_CODE: Record = { diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index e64eb8e..2996128 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -84,10 +84,10 @@ router.get('/app-config', (_req: Request, res: Response) => { const isDemo = process.env.DEMO_MODE === 'true'; const { version } = require('../../package.json'); const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get(); - const oidcDisplayName = (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get() as { value: string } | undefined)?.value || null; + const oidcDisplayName = process.env.OIDC_DISPLAY_NAME || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get() as { value: string } | undefined)?.value || null; const oidcConfigured = !!( - (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value && - (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value + (process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) && + (process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value) ); res.json({ allow_registration: isDemo ? false : allowRegistration, @@ -98,7 +98,7 @@ router.get('/app-config', (_req: Request, res: Response) => { oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined, allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv', demo_mode: isDemo, - demo_email: isDemo ? 'demo@nomad.app' : undefined, + demo_email: isDemo ? 'demo@trek.app' : undefined, demo_password: isDemo ? 'demo12345' : undefined, }); }); @@ -107,7 +107,7 @@ router.post('/demo-login', (_req: Request, res: Response) => { if (process.env.DEMO_MODE !== 'true') { return res.status(404).json({ error: 'Not found' }); } - const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@nomad.app') as User | undefined; + const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@trek.app') as User | undefined; if (!user) return res.status(500).json({ error: 'Demo user not found' }); const token = generateToken(user); const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...safe } = user; @@ -205,7 +205,7 @@ router.get('/me', authenticate, (req: Request, res: Response) => { router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => { const authReq = req as AuthRequest; - if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') { + if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@trek.app') { return res.status(403).json({ error: 'Password change is disabled in demo mode.' }); } const { current_password, new_password } = req.body; @@ -229,7 +229,7 @@ router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req router.delete('/me', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') { + if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@trek.app') { return res.status(403).json({ error: 'Account deletion is disabled in demo mode.' }); } if (authReq.user.role === 'admin') { @@ -497,4 +497,30 @@ router.get('/travel-stats', authenticate, (req: Request, res: Response) => { }); }); +// GitHub releases proxy (cached, avoids client-side rate limits) +let releasesCache: { data: unknown[]; fetchedAt: number } | null = null; +const RELEASES_CACHE_TTL = 30 * 60 * 1000; + +router.get('/github-releases', authenticate, async (req: Request, res: Response) => { + const page = parseInt(req.query.page as string) || 1; + const perPage = Math.min(parseInt(req.query.per_page as string) || 10, 30); + + if (page === 1 && releasesCache && Date.now() - releasesCache.fetchedAt < RELEASES_CACHE_TTL) { + return res.json(releasesCache.data.slice(0, perPage)); + } + + try { + const resp = await fetch( + `https://api.github.com/repos/mauriceboe/NOMAD/releases?per_page=${perPage}&page=${page}`, + { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } } + ); + if (!resp.ok) return res.json([]); + const data = await resp.json(); + if (page === 1) releasesCache = { data, fetchedAt: Date.now() }; + res.json(data); + } catch { + res.status(500).json({ error: 'Failed to fetch releases' }); + } +}); + export default router; diff --git a/server/src/routes/days.ts b/server/src/routes/days.ts index b739b39..c59e1b7 100644 --- a/server/src/routes/days.ts +++ b/server/src/routes/days.ts @@ -219,9 +219,27 @@ accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, r 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, confirmation || null, notes || null); - const accommodation = getAccommodationWithPlace(result.lastInsertRowid); + const accommodationId = result.lastInsertRowid; + + // Auto-create linked reservation for this accommodation + const placeName = (db.prepare('SELECT name FROM places WHERE id = ?').get(place_id) as { name: string } | undefined)?.name || 'Hotel'; + const startDayDate = (db.prepare('SELECT date FROM days WHERE id = ?').get(start_day_id) as { date: string } | undefined)?.date || null; + const meta: Record = {}; + if (check_in) meta.check_in_time = check_in; + if (check_out) meta.check_out_time = check_out; + db.prepare(` + INSERT INTO reservations (trip_id, day_id, title, reservation_time, location, confirmation_number, notes, status, type, accommodation_id, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, 'confirmed', 'hotel', ?, ?) + `).run( + tripId, start_day_id, placeName, startDayDate || null, null, + confirmation || null, notes || null, accommodationId, + Object.keys(meta).length > 0 ? JSON.stringify(meta) : null + ); + + const accommodation = getAccommodationWithPlace(accommodationId); res.status(201).json({ accommodation }); broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id'] as string); + broadcast(tripId, 'reservation:created', {}, req.headers['x-socket-id'] as string); }); accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { @@ -260,6 +278,16 @@ accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request, 'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?' ).run(newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckOut, newConfirmation, newNotes, id); + // Sync check-in/out/confirmation to linked reservation + const linkedRes = db.prepare('SELECT id, metadata FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number; metadata: string | null } | undefined; + if (linkedRes) { + const meta = linkedRes.metadata ? JSON.parse(linkedRes.metadata) : {}; + if (newCheckIn) meta.check_in_time = newCheckIn; + if (newCheckOut) meta.check_out_time = newCheckOut; + db.prepare('UPDATE reservations SET metadata = ?, confirmation_number = COALESCE(?, confirmation_number) WHERE id = ?') + .run(JSON.stringify(meta), newConfirmation || null, linkedRes.id); + } + const accommodation = getAccommodationWithPlace(Number(id)); res.json({ accommodation }); broadcast(tripId, 'accommodation:updated', { accommodation }, req.headers['x-socket-id'] as string); @@ -271,6 +299,13 @@ accommodationsRouter.delete('/:id', authenticate, requireTripAccess, (req: Reque const existing = db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId); if (!existing) return res.status(404).json({ error: 'Accommodation not found' }); + // Delete linked reservation + const linkedRes = db.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number } | undefined; + if (linkedRes) { + db.prepare('DELETE FROM reservations WHERE id = ?').run(linkedRes.id); + broadcast(tripId, 'reservation:deleted', { reservationId: linkedRes.id }, req.headers['x-socket-id'] as string); + } + db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(id); res.json({ success: true }); broadcast(tripId, 'accommodation:deleted', { accommodationId: Number(id) }, req.headers['x-socket-id'] as string); diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts index aac21dc..378f074 100644 --- a/server/src/routes/files.ts +++ b/server/src/routes/files.ts @@ -57,6 +57,13 @@ function verifyTripOwnership(tripId: string | number, userId: number) { return canAccessTrip(tripId, userId); } +const FILE_SELECT = ` + SELECT f.*, r.title as reservation_title, u.username as uploaded_by_name, u.avatar as uploaded_by_avatar + FROM trip_files f + LEFT JOIN reservations r ON f.reservation_id = r.id + LEFT JOIN users u ON f.uploaded_by = u.id +`; + function formatFile(file: TripFile) { return { ...file, @@ -64,24 +71,23 @@ function formatFile(file: TripFile) { }; } +// List files (excludes soft-deleted by default) router.get('/', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; + const showTrash = req.query.trash === 'true'; const trip = verifyTripOwnership(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); - const files = db.prepare(` - SELECT f.*, r.title as reservation_title - FROM trip_files f - LEFT JOIN reservations r ON f.reservation_id = r.id - WHERE f.trip_id = ? - ORDER BY f.created_at DESC - `).all(tripId) as TripFile[]; + const where = showTrash ? 'f.trip_id = ? AND f.deleted_at IS NOT NULL' : 'f.trip_id = ? AND f.deleted_at IS NULL'; + const files = db.prepare(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`).all(tripId) as TripFile[]; res.json({ files: files.map(formatFile) }); }); +// Upload file router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single('file'), (req: Request, res: Response) => { + const authReq = req as AuthRequest; const { tripId } = req.params; const { place_id, description, reservation_id } = req.body; @@ -90,8 +96,8 @@ router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single } const result = db.prepare(` - INSERT INTO trip_files (trip_id, place_id, reservation_id, filename, original_name, file_size, mime_type, description) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO trip_files (trip_id, place_id, reservation_id, filename, original_name, file_size, mime_type, description, uploaded_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( tripId, place_id || null, @@ -100,19 +106,16 @@ router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single req.file.originalname, req.file.size, req.file.mimetype, - description || null + description || null, + authReq.user.id ); - const file = db.prepare(` - SELECT f.*, r.title as reservation_title - FROM trip_files f - LEFT JOIN reservations r ON f.reservation_id = r.id - WHERE f.id = ? - `).get(result.lastInsertRowid) as TripFile; + const file = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(result.lastInsertRowid) as TripFile; res.status(201).json({ file: formatFile(file) }); broadcast(tripId, 'file:created', { file: formatFile(file) }, req.headers['x-socket-id'] as string); }); +// Update file metadata router.put('/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; @@ -126,7 +129,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => { db.prepare(` UPDATE trip_files SET - description = COALESCE(?, description), + description = ?, place_id = ?, reservation_id = ? WHERE id = ? @@ -137,16 +140,31 @@ router.put('/:id', authenticate, (req: Request, res: Response) => { id ); - const updated = db.prepare(` - SELECT f.*, r.title as reservation_title - FROM trip_files f - LEFT JOIN reservations r ON f.reservation_id = r.id - WHERE f.id = ? - `).get(id) as TripFile; + const updated = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile; res.json({ file: formatFile(updated) }); broadcast(tripId, 'file:updated', { file: formatFile(updated) }, req.headers['x-socket-id'] as string); }); +// Toggle starred +router.patch('/:id/star', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, id } = req.params; + + const trip = verifyTripOwnership(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined; + if (!file) return res.status(404).json({ error: 'File not found' }); + + const newStarred = file.starred ? 0 : 1; + db.prepare('UPDATE trip_files SET starred = ? WHERE id = ?').run(newStarred, id); + + const updated = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile; + res.json({ file: formatFile(updated) }); + broadcast(tripId, 'file:updated', { file: formatFile(updated) }, req.headers['x-socket-id'] as string); +}); + +// Soft-delete (move to trash) router.delete('/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; @@ -157,6 +175,40 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => { const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined; if (!file) return res.status(404).json({ error: 'File not found' }); + db.prepare('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?').run(id); + res.json({ success: true }); + broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string); +}); + +// Restore from trash +router.post('/:id/restore', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, id } = req.params; + + const trip = verifyTripOwnership(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL').get(id, tripId) as TripFile | undefined; + if (!file) return res.status(404).json({ error: 'File not found in trash' }); + + db.prepare('UPDATE trip_files SET deleted_at = NULL WHERE id = ?').run(id); + + const restored = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile; + res.json({ file: formatFile(restored) }); + broadcast(tripId, 'file:created', { file: formatFile(restored) }, req.headers['x-socket-id'] as string); +}); + +// Permanently delete from trash +router.delete('/:id/permanent', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, id } = req.params; + + const trip = verifyTripOwnership(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL').get(id, tripId) as TripFile | undefined; + if (!file) return res.status(404).json({ error: 'File not found in trash' }); + const filePath = path.join(filesDir, file.filename); if (fs.existsSync(filePath)) { try { fs.unlinkSync(filePath); } catch (e) { console.error('Error deleting file:', e); } @@ -167,4 +219,24 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => { broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string); }); +// Empty entire trash +router.delete('/trash/empty', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + + const trip = verifyTripOwnership(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + const trashed = db.prepare('SELECT * FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').all(tripId) as TripFile[]; + for (const file of trashed) { + const filePath = path.join(filesDir, file.filename); + if (fs.existsSync(filePath)) { + try { fs.unlinkSync(filePath); } catch (e) { console.error('Error deleting file:', e); } + } + } + + db.prepare('DELETE FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').run(tripId); + res.json({ success: true, deleted: trashed.length }); +}); + export default router; diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts index 0cd18e9..b71e80d 100644 --- a/server/src/routes/maps.ts +++ b/server/src/routes/maps.ts @@ -13,6 +13,166 @@ interface NominatimResult { lon: string; } +interface OverpassElement { + tags?: Record; +} + +interface WikiCommonsPage { + imageinfo?: { url?: string; extmetadata?: { Artist?: { value?: string } } }[]; +} + +const UA = 'TREK Travel Planner (https://github.com/mauriceboe/NOMAD)'; + +// ── OSM Enrichment: Overpass API for details ────────────────────────────────── + +async function fetchOverpassDetails(osmType: string, osmId: string): Promise { + const typeMap: Record = { node: 'node', way: 'way', relation: 'rel' }; + const oType = typeMap[osmType]; + if (!oType) return null; + const query = `[out:json][timeout:5];${oType}(${osmId});out tags;`; + try { + const res = await fetch('https://overpass-api.de/api/interpreter', { + method: 'POST', + headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `data=${encodeURIComponent(query)}`, + }); + if (!res.ok) return null; + const data = await res.json() as { elements?: OverpassElement[] }; + return data.elements?.[0] || null; + } catch { return null; } +} + +function parseOpeningHours(ohString: string): { weekdayDescriptions: string[]; openNow: boolean | null } { + const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; + const LONG = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + const result: string[] = LONG.map(d => `${d}: ?`); + + // Parse segments like "Mo-Fr 09:00-18:00; Sa 10:00-14:00" + for (const segment of ohString.split(';')) { + const trimmed = segment.trim(); + if (!trimmed) continue; + const match = trimmed.match(/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i); + if (!match) continue; + const [, daysPart, timePart] = match; + const dayIndices = new Set(); + for (const range of daysPart.split(',')) { + const parts = range.trim().split('-').map(d => DAYS.indexOf(d.trim())); + if (parts.length === 2 && parts[0] >= 0 && parts[1] >= 0) { + for (let i = parts[0]; i !== (parts[1] + 1) % 7; i = (i + 1) % 7) dayIndices.add(i); + dayIndices.add(parts[1]); + } else if (parts[0] >= 0) { + dayIndices.add(parts[0]); + } + } + for (const idx of dayIndices) { + result[idx] = `${LONG[idx]}: ${timePart.trim()}`; + } + } + + // Compute openNow + let openNow: boolean | null = null; + try { + const now = new Date(); + const jsDay = now.getDay(); + const dayIdx = jsDay === 0 ? 6 : jsDay - 1; + const todayLine = result[dayIdx]; + const timeRanges = [...todayLine.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)]; + if (timeRanges.length > 0) { + const nowMins = now.getHours() * 60 + now.getMinutes(); + openNow = timeRanges.some(m => { + const start = parseInt(m[1]) * 60 + parseInt(m[2]); + const end = parseInt(m[3]) * 60 + parseInt(m[4]); + return end > start ? nowMins >= start && nowMins < end : nowMins >= start || nowMins < end; + }); + } + } catch { /* best effort */ } + + return { weekdayDescriptions: result, openNow }; +} + +function buildOsmDetails(tags: Record, osmType: string, osmId: string) { + let opening_hours: string[] | null = null; + let open_now: boolean | null = null; + if (tags.opening_hours) { + const parsed = parseOpeningHours(tags.opening_hours); + const hasData = parsed.weekdayDescriptions.some(line => !line.endsWith('?')); + if (hasData) { + opening_hours = parsed.weekdayDescriptions; + open_now = parsed.openNow; + } + } + return { + website: tags['contact:website'] || tags.website || null, + phone: tags['contact:phone'] || tags.phone || null, + opening_hours, + open_now, + osm_url: `https://www.openstreetmap.org/${osmType}/${osmId}`, + summary: tags.description || null, + source: 'openstreetmap' as const, + }; +} + +// ── Wikimedia Commons: Free place photos ────────────────────────────────────── + +async function fetchWikimediaPhoto(lat: number, lng: number, name?: string): Promise<{ photoUrl: string; attribution: string | null } | null> { + // Strategy 1: Search Wikipedia for the place name → get the article image + if (name) { + try { + const searchParams = new URLSearchParams({ + action: 'query', format: 'json', + titles: name, + prop: 'pageimages', + piprop: 'original', + pilimit: '1', + redirects: '1', + }); + const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } }); + if (res.ok) { + const data = await res.json() as { query?: { pages?: Record } }; + const pages = data.query?.pages; + if (pages) { + for (const page of Object.values(pages)) { + if (page.original?.source) { + return { photoUrl: page.original.source, attribution: 'Wikipedia' }; + } + } + } + } + } catch { /* fall through to geosearch */ } + } + + // Strategy 2: Wikimedia Commons geosearch by coordinates + const params = new URLSearchParams({ + action: 'query', format: 'json', + generator: 'geosearch', + ggsprimary: 'all', + ggsnamespace: '6', + ggsradius: '300', + ggscoord: `${lat}|${lng}`, + ggslimit: '5', + prop: 'imageinfo', + iiprop: 'url|extmetadata|mime', + iiurlwidth: '600', + }); + try { + const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { 'User-Agent': UA } }); + if (!res.ok) return null; + const data = await res.json() as { query?: { pages?: Record } }; + const pages = data.query?.pages; + if (!pages) return null; + for (const page of Object.values(pages)) { + const info = page.imageinfo?.[0]; + // Only use actual photos (JPEG/PNG), skip SVGs and PDFs + const mime = (info as { mime?: string })?.mime || ''; + if (info?.url && (mime.startsWith('image/jpeg') || mime.startsWith('image/png'))) { + const attribution = info.extmetadata?.Artist?.value?.replace(/<[^>]+>/g, '').trim() || null; + return { photoUrl: info.url, attribution }; + } + } + return null; + } catch { return null; } +} + interface GooglePlaceResult { id: string; displayName?: { text: string }; @@ -69,13 +229,13 @@ async function searchNominatim(query: string, lang?: string) { 'accept-language': lang || 'en', }); const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, { - headers: { 'User-Agent': 'NOMAD Travel Planner (https://github.com/mauriceboe/NOMAD)' }, + headers: { 'User-Agent': 'TREK Travel Planner (https://github.com/mauriceboe/NOMAD)' }, }); if (!response.ok) throw new Error('Nominatim API error'); const data = await response.json() as NominatimResult[]; return data.map(item => ({ google_place_id: null, - osm_id: `${item.osm_type}/${item.osm_id}`, + osm_id: `${item.osm_type}:${item.osm_id}`, name: item.name || item.display_name?.split(',')[0] || '', address: item.display_name || '', lat: parseFloat(item.lat) || null, @@ -145,6 +305,21 @@ router.get('/details/:placeId', authenticate, async (req: Request, res: Response const authReq = req as AuthRequest; const { placeId } = req.params; + // OSM details: placeId is "node:123456" or "way:123456" etc. + if (placeId.includes(':')) { + const [osmType, osmId] = placeId.split(':'); + try { + const element = await fetchOverpassDetails(osmType, osmId); + if (!element?.tags) return res.json({ place: buildOsmDetails({}, osmType, osmId) }); + res.json({ place: buildOsmDetails(element.tags, osmType, osmId) }); + } catch (err: unknown) { + console.error('OSM details error:', err); + res.status(500).json({ error: 'Error fetching OSM details' }); + } + return; + } + + // Google details const apiKey = getMapsKey(authReq.user.id); if (!apiKey) { return res.status(400).json({ error: 'Google Maps API key not configured' }); @@ -187,6 +362,7 @@ router.get('/details/:placeId', authenticate, async (req: Request, res: Response time: r.relativePublishTimeDescription || null, photo: r.authorAttribution?.photoUri || null, })), + source: 'google' as const, }; res.json({ place }); @@ -205,11 +381,28 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution }); } + // Wikimedia Commons fallback for OSM places (using lat/lng query params) + const lat = parseFloat(req.query.lat as string); + const lng = parseFloat(req.query.lng as string); + const apiKey = getMapsKey(authReq.user.id); - if (!apiKey) { - return res.status(400).json({ error: 'Google Maps API key not configured' }); + const isCoordLookup = placeId.startsWith('coords:'); + + // No Google key or coordinate-only lookup → try Wikimedia + if (!apiKey || isCoordLookup) { + if (!isNaN(lat) && !isNaN(lng)) { + try { + const wiki = await fetchWikimediaPhoto(lat, lng, req.query.name as string); + if (wiki) { + photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() }); + return res.json(wiki); + } + } catch { /* fall through */ } + } + return res.status(404).json({ error: 'No photo available' }); } + // Google Photos try { const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, { headers: { @@ -259,4 +452,26 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp } }); +// Reverse geocoding via Nominatim +router.get('/reverse', authenticate, async (req: Request, res: Response) => { + const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string }; + if (!lat || !lng) return res.status(400).json({ error: 'lat and lng required' }); + try { + const params = new URLSearchParams({ + lat, lon: lng, format: 'json', addressdetails: '1', zoom: '18', + 'accept-language': lang || 'en', + }); + const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, { + headers: { 'User-Agent': UA }, + }); + if (!response.ok) return res.json({ name: null, address: null }); + const data = await response.json() as { name?: string; display_name?: string; address?: Record }; + const addr = data.address || {}; + const name = data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null; + res.json({ name, address: data.display_name || null }); + } catch { + res.json({ name: null, address: null }); + } +}); + export default router; diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts index 405ea8a..404f017 100644 --- a/server/src/routes/oidc.ts +++ b/server/src/routes/oidc.ts @@ -52,10 +52,10 @@ setInterval(() => { function getOidcConfig() { const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null; - const issuer = get('oidc_issuer'); - const clientId = get('oidc_client_id'); - const clientSecret = get('oidc_client_secret'); - const displayName = get('oidc_display_name') || 'SSO'; + const issuer = process.env.OIDC_ISSUER || get('oidc_issuer'); + const clientId = process.env.OIDC_CLIENT_ID || get('oidc_client_id'); + const clientSecret = process.env.OIDC_CLIENT_SECRET || get('oidc_client_secret'); + const displayName = process.env.OIDC_DISPLAY_NAME || get('oidc_display_name') || 'SSO'; if (!issuer || !clientId || !clientSecret) return null; return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName }; } diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts index 57c9169..9acddb7 100644 --- a/server/src/routes/places.ts +++ b/server/src/routes/places.ts @@ -78,7 +78,7 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: const { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, - duration_minutes, notes, image_url, google_place_id, website, phone, + duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, transport_mode, tags = [] } = req.body; @@ -89,13 +89,13 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: const result = db.prepare(` INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, - duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, transport_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( tripId, name, description || null, lat || null, lng || null, address || null, category_id || null, price || null, currency || null, place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || null, - google_place_id || null, website || null, phone || null, transport_mode || 'walking' + google_place_id || null, osm_id || null, website || null, phone || null, transport_mode || 'walking' ); const placeId = result.lastInsertRowid; diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts index 7cbbc66..302c1a1 100644 --- a/server/src/routes/reservations.ts +++ b/server/src/routes/reservations.ts @@ -18,10 +18,13 @@ router.get('/', authenticate, (req: Request, res: Response) => { if (!trip) return res.status(404).json({ error: 'Trip not found' }); const reservations = db.prepare(` - SELECT r.*, d.day_number, p.name as place_name, r.assignment_id + SELECT r.*, d.day_number, p.name as place_name, r.assignment_id, + ap.place_id as accommodation_place_id, acc_p.name as accommodation_name FROM reservations r LEFT JOIN days d ON r.day_id = d.id LEFT JOIN places p ON r.place_id = p.id + LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id + LEFT JOIN places acc_p ON ap.place_id = acc_p.id WHERE r.trip_id = ? ORDER BY r.reservation_time ASC, r.created_at ASC `).all(tripId); @@ -32,16 +35,29 @@ router.get('/', authenticate, (req: Request, res: Response) => { router.post('/', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body; + const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation } = req.body; const trip = verifyTripOwnership(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!title) return res.status(400).json({ error: 'Title is required' }); + // Auto-create accommodation for hotel reservations + let resolvedAccommodationId = accommodation_id || null; + if (type === 'hotel' && !resolvedAccommodationId && create_accommodation) { + const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation; + if (accPlaceId && start_day_id && end_day_id) { + const accResult = db.prepare( + 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null); + resolvedAccommodationId = accResult.lastInsertRowid; + broadcast(tripId, 'accommodation:created', {}, req.headers['x-socket-id'] as string); + } + } + const result = db.prepare(` - INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( tripId, day_id || null, @@ -54,14 +70,32 @@ router.post('/', authenticate, (req: Request, res: Response) => { confirmation_number || null, notes || null, status || 'pending', - type || 'other' + type || 'other', + resolvedAccommodationId, + metadata ? JSON.stringify(metadata) : null ); + // Sync check-in/out to accommodation if linked + if (accommodation_id && metadata) { + const meta = typeof metadata === 'string' ? JSON.parse(metadata) : metadata; + if (meta.check_in_time || meta.check_out_time) { + db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?') + .run(meta.check_in_time || null, meta.check_out_time || null, accommodation_id); + } + if (confirmation_number) { + db.prepare('UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?') + .run(confirmation_number, accommodation_id); + } + } + const reservation = db.prepare(` - SELECT r.*, d.day_number, p.name as place_name, r.assignment_id + SELECT r.*, d.day_number, p.name as place_name, r.assignment_id, + ap.place_id as accommodation_place_id, acc_p.name as accommodation_name FROM reservations r LEFT JOIN days d ON r.day_id = d.id LEFT JOIN places p ON r.place_id = p.id + LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id + LEFT JOIN places acc_p ON ap.place_id = acc_p.id WHERE r.id = ? `).get(result.lastInsertRowid); @@ -72,7 +106,7 @@ router.post('/', authenticate, (req: Request, res: Response) => { router.put('/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; - const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body; + const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation } = req.body; const trip = verifyTripOwnership(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); @@ -80,6 +114,24 @@ router.put('/:id', authenticate, (req: Request, res: Response) => { const reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as Reservation | undefined; if (!reservation) return res.status(404).json({ error: 'Reservation not found' }); + // Update or create accommodation for hotel reservations + let resolvedAccId = accommodation_id !== undefined ? (accommodation_id || null) : reservation.accommodation_id; + if (type === 'hotel' && create_accommodation) { + const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation; + if (accPlaceId && start_day_id && end_day_id) { + if (resolvedAccId) { + db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?') + .run(accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId); + } else { + const accResult = db.prepare( + 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null); + resolvedAccId = accResult.lastInsertRowid; + } + broadcast(tripId, 'accommodation:updated', {}, req.headers['x-socket-id'] as string); + } + } + db.prepare(` UPDATE reservations SET title = COALESCE(?, title), @@ -92,7 +144,9 @@ router.put('/:id', authenticate, (req: Request, res: Response) => { place_id = ?, assignment_id = ?, status = COALESCE(?, status), - type = COALESCE(?, type) + type = COALESCE(?, type), + accommodation_id = ?, + metadata = ? WHERE id = ? `).run( title || null, @@ -106,14 +160,34 @@ router.put('/:id', authenticate, (req: Request, res: Response) => { assignment_id !== undefined ? (assignment_id || null) : reservation.assignment_id, status || null, type || null, + resolvedAccId, + metadata !== undefined ? (metadata ? JSON.stringify(metadata) : null) : reservation.metadata, id ); + // Sync check-in/out to accommodation if linked + const resolvedMeta = metadata !== undefined ? metadata : (reservation.metadata ? JSON.parse(reservation.metadata as string) : null); + if (resolvedAccId && resolvedMeta) { + const meta = typeof resolvedMeta === 'string' ? JSON.parse(resolvedMeta) : resolvedMeta; + if (meta.check_in_time || meta.check_out_time) { + db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?') + .run(meta.check_in_time || null, meta.check_out_time || null, resolvedAccId); + } + const resolvedConf = confirmation_number !== undefined ? confirmation_number : reservation.confirmation_number; + if (resolvedConf) { + db.prepare('UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?') + .run(resolvedConf, resolvedAccId); + } + } + const updated = db.prepare(` - SELECT r.*, d.day_number, p.name as place_name, r.assignment_id + SELECT r.*, d.day_number, p.name as place_name, r.assignment_id, + ap.place_id as accommodation_place_id, acc_p.name as accommodation_name FROM reservations r LEFT JOIN days d ON r.day_id = d.id LEFT JOIN places p ON r.place_id = p.id + LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id + LEFT JOIN places acc_p ON ap.place_id = acc_p.id WHERE r.id = ? `).get(id); @@ -128,9 +202,15 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => { const trip = verifyTripOwnership(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); - const reservation = db.prepare('SELECT id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId); + const reservation = db.prepare('SELECT id, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as { id: number; accommodation_id: number | null } | undefined; if (!reservation) return res.status(404).json({ error: 'Reservation not found' }); + // Delete linked accommodation if exists + if (reservation.accommodation_id) { + db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(reservation.accommodation_id); + broadcast(tripId, 'accommodation:deleted', { accommodationId: reservation.accommodation_id }, req.headers['x-socket-id'] as string); + } + db.prepare('DELETE FROM reservations WHERE id = ?').run(id); res.json({ success: true }); broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string); diff --git a/server/src/routes/vacay.ts b/server/src/routes/vacay.ts index fcf9e94..02b13d9 100644 --- a/server/src/routes/vacay.ts +++ b/server/src/routes/vacay.ts @@ -69,6 +69,7 @@ function getOwnPlan(userId: number) { const yr = new Date().getFullYear(); db.prepare('INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)').run(plan.id, yr); db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)').run(userId, plan.id, yr); + db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(userId, plan.id, '#6366f1'); } return plan; } @@ -296,11 +297,15 @@ router.post('/invite/accept', (req: Request, res: Response) => { const COLORS = ['#6366f1','#ec4899','#14b8a6','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#64748b','#be185d','#0d9488']; const existingColors = (db.prepare('SELECT color FROM vacay_user_colors WHERE plan_id = ? AND user_id != ?').all(plan_id, authReq.user.id) as { color: string }[]).map(r => r.color); const myColor = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(authReq.user.id, plan_id) as { color: string } | undefined; - if (myColor && existingColors.includes(myColor.color)) { + const effectiveColor = myColor?.color || '#6366f1'; + if (existingColors.includes(effectiveColor)) { const available = COLORS.find(c => !existingColors.includes(c)); if (available) { - db.prepare('UPDATE vacay_user_colors SET color = ? WHERE user_id = ? AND plan_id = ?').run(available, authReq.user.id, plan_id); + db.prepare(`INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?) + ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color`).run(authReq.user.id, plan_id, available); } + } else if (!myColor) { + db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(authReq.user.id, plan_id, effectiveColor); } const targetYears = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(plan_id) as { year: number }[]; diff --git a/server/src/types.ts b/server/src/types.ts index cce6dbd..b9f2142 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -60,6 +60,7 @@ export interface Place { notes?: string | null; image_url?: string | null; google_place_id?: string | null; + osm_id?: string | null; website?: string | null; phone?: string | null; transport_mode?: string; @@ -145,6 +146,8 @@ export interface Reservation { notes?: string | null; status: string; type: string; + accommodation_id?: number | null; + metadata?: string | null; created_at?: string; day_number?: number; place_name?: string; @@ -156,11 +159,15 @@ export interface TripFile { place_id?: number | null; reservation_id?: number | null; note_id?: number | null; + uploaded_by?: number | null; + uploaded_by_name?: string | null; filename: string; original_name: string; file_size?: number | null; mime_type?: string | null; description?: string | null; + starred?: number; + deleted_at?: string | null; created_at?: string; reservation_title?: string; url?: string;