diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 79a16af..05ad1f8 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,63 @@ 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/trek,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: + - uses: actions/checkout@v4 + + - name: Get version from package.json + id: version + run: echo "VERSION=$(node -p "require('./server/package.json').version")" >> $GITHUB_OUTPUT + + - 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/trek@sha256:%s\n' *) + docker buildx imagetools create \ + -t mauriceboe/trek:latest \ + -t mauriceboe/trek:${{ steps.version.outputs.VERSION }} \ + -t mauriceboe/nomad:latest \ + -t mauriceboe/nomad:${{ steps.version.outputs.VERSION }} \ + "${digests[@]}" + + - name: Inspect manifest + run: docker buildx imagetools inspect mauriceboe/trek:latest diff --git a/README.md b/README.md index 056804f..c5954f1 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,26 @@ - NOMAD + TREK
- Navigation Organizer for Maps, Activities & Destinations + Your Trips. Your Plan.

License: AGPL v3 - Docker Pulls - GitHub Stars - Last Commit + Docker Pulls + GitHub Stars + Last Commit

A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
- Live Demo — Try NOMAD without installing. Resets hourly. + Live Demo — Try TREK without installing. Resets hourly.

-![NOMAD Screenshot](docs/screenshot.png) +![TREK Screenshot](docs/screenshot.png) ![NOMAD Screenshot 2](docs/screenshot-2.png)
@@ -50,7 +50,7 @@ - **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support - **Packing Lists** — Categorized checklists with progress tracking, color coding, and smart suggestions - **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file) -- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and NOMAD branding +- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and TREK branding ### Mobile & PWA - **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed @@ -92,19 +92,19 @@ ## Quick Start ```bash -docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/nomad +docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek ``` The app runs on port `3000`. The first user to register becomes the admin. ### Install as App (PWA) -NOMAD works as a Progressive Web App — no App Store needed: +TREK works as a Progressive Web App — no App Store needed: -1. Open your NOMAD instance in the browser (HTTPS required) +1. Open your TREK instance in the browser (HTTPS required) 2. **iOS**: Share button → "Add to Home Screen" 3. **Android**: Menu → "Install app" or "Add to Home Screen" -4. NOMAD launches fullscreen with its own icon, just like a native app +4. TREK launches fullscreen with its own icon, just like a native app
Docker Compose (recommended for production) @@ -112,8 +112,8 @@ NOMAD works as a Progressive Web App — no App Store needed: ```yaml services: app: - image: mauriceboe/nomad:latest - container_name: nomad + image: mauriceboe/trek:latest + container_name: trek ports: - "3000:3000" environment: @@ -142,20 +142,20 @@ docker compose pull && docker compose up -d **Docker Run** — use the same volume paths from your original `docker run` command: ```bash -docker pull mauriceboe/nomad -docker rm -f nomad -docker run -d --name nomad -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/nomad +docker pull mauriceboe/trek +docker rm -f trek +docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek ``` -> **Tip:** Not sure which paths you used? Run `docker inspect nomad --format '{{json .Mounts}}'` before removing the container. +> **Tip:** Not sure which paths you used? Run `docker inspect trek --format '{{json .Mounts}}'` before removing the container. Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data. ### Reverse Proxy (recommended) -For production, put NOMAD behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik). +For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik). -> **Important:** NOMAD uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path. +> **Important:** TREK uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
Nginx @@ -220,14 +220,14 @@ API keys are configured in the **Admin Panel** after login. Keys set by the admi 1. Go to [Google Cloud Console](https://console.cloud.google.com/) 2. Create a project and enable the **Places API (New)** 3. Create an API key under Credentials -4. In NOMAD: Admin Panel → Settings → Google Maps +4. In TREK: Admin Panel → Settings → Google Maps ## Building from Source ```bash -git clone https://github.com/mauriceboe/NOMAD.git +git clone https://github.com/mauriceboe/TREK.git cd NOMAD -docker build -t nomad . +docker build -t trek . ``` ## Data & Backups diff --git a/SECURITY.md b/SECURITY.md index 7e5862b..5d0d2e0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea ## Scope -This policy covers the NOMAD application and its Docker image (`mauriceboe/nomad`). +This policy covers the TREK application and its Docker image (`mauriceboe/nomad`). Third-party dependencies are monitored via GitHub Dependabot. 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..9a80671 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.2", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "nomad-client", - "version": "2.6.0", + "name": "trek-client", + "version": "2.6.2", "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 9824b14..48657dd 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -147,8 +147,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 = { @@ -162,12 +163,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..b82d6df 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')}

@@ -136,8 +136,21 @@ interface AddonRowProps { t: (key: string) => string } +function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } { + const nameKey = `admin.addons.catalog.${addon.id}.name` + const descKey = `admin.addons.catalog.${addon.id}.description` + const translatedName = t(nameKey) + const translatedDescription = t(descKey) + + return { + name: translatedName !== nameKey ? translatedName : addon.name, + description: translatedDescription !== descKey ? translatedDescription : addon.description, + } +} + function AddonRow({ addon, onToggle, t }: AddonRowProps) { const isComingSoon = false + const label = getAddonLabel(t, addon) return (
{/* Icon */} @@ -148,7 +161,7 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) { {/* Info */}
- {addon.name} + {label.name} {isComingSoon && ( Coming Soon @@ -161,7 +174,7 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) { {addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')}
-

{addon.description}

+

{label.description}

{/* Toggle */} diff --git a/client/src/components/Admin/GitHubPanel.tsx b/client/src/components/Admin/GitHubPanel.tsx index 492a0f3..141b701 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 { useTranslation } from '../../i18n' +import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee } from 'lucide-react' +import { getLocaleForLanguage, 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) { @@ -46,7 +46,7 @@ export default function GitHubPanel() { const formatDate = (dateStr) => { const d = new Date(dateStr) - return d.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric' }) + return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' }) } // Simple markdown-to-html for release notes (handles headers, bold, lists, links) @@ -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 */} + + + {/* 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/Dashboard/TimezoneWidget.tsx b/client/src/components/Dashboard/TimezoneWidget.tsx index 70ffff4..5ee97e5 100644 --- a/client/src/components/Dashboard/TimezoneWidget.tsx +++ b/client/src/components/Dashboard/TimezoneWidget.tsx @@ -23,9 +23,9 @@ const POPULAR_ZONES = [ { label: 'Cairo', tz: 'Africa/Cairo' }, ] -function getTime(tz) { +function getTime(tz, locale) { try { - return new Date().toLocaleTimeString('de-DE', { timeZone: tz, hour: '2-digit', minute: '2-digit' }) + return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit' }) } catch { return '—' } } @@ -41,7 +41,7 @@ function getOffset(tz) { } export default function TimezoneWidget() { - const { t } = useTranslation() + const { t, locale } = useTranslation() const [zones, setZones] = useState(() => { const saved = localStorage.getItem('dashboard_timezones') return saved ? JSON.parse(saved) : [ @@ -70,7 +70,7 @@ export default function TimezoneWidget() { const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz)) - const localTime = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone const localZone = rawZone.split('/').pop().replace(/_/g, ' ') // Show abbreviated timezone name (e.g. CET, CEST, EST) @@ -96,7 +96,7 @@ export default function TimezoneWidget() { {zones.map(z => (
-

{getTime(z.tz)}

+

{getTime(z.tz, locale)}

{z.label} {getOffset(z.tz)}

))}
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..9d0bc20 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,12 +80,44 @@ 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', close: 'Got it', }, + es: { + titleBefore: 'Bienvenido a ', + titleAfter: '', + title: 'Bienvenido a la demo de TREK', + description: 'Puedes ver, editar y crear viajes. Todos los cambios se restablecen automáticamente cada hora.', + resetIn: 'Próximo reinicio en', + minutes: 'minutos', + uploadNote: 'Las subidas de archivos (fotos, documentos, portadas) están desactivadas en el modo demo.', + fullVersionTitle: 'Además, en la versión completa:', + features: [ + 'Subida de archivos (fotos, documentos, portadas)', + 'Gestión de claves API (Google Maps, tiempo)', + 'Gestión de usuarios y permisos', + 'Copias de seguridad automáticas', + 'Gestión de addons (activar/desactivar)', + 'Inicio de sesión único OIDC / SSO', + ], + addonsTitle: 'Complementos modulares (se pueden desactivar en la versión completa)', + addons: [ + ['Vacaciones', 'Planificador de vacaciones con calendario, festivos y fusión de usuarios'], + ['Atlas', 'Mapa del mundo con países visitados y estadísticas de viaje'], + ['Equipaje', 'Listas de comprobación para cada viaje'], + ['Presupuesto', 'Control de gastos con reparto'], + ['Documentos', 'Adjunta archivos a los viajes'], + ['Widgets', 'Conversor de divisas y zonas horarias'], + ], + whatIs: '¿Qué es TREK?', + whatIsDesc: 'Un planificador de viajes autohospedado con colaboración en tiempo real, mapas interactivos, inicio de sesión OIDC y modo oscuro.', + selfHost: 'Código abierto — ', + selfHostLink: 'alójalo tú mismo', + close: 'Entendido', + }, } const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield] @@ -123,7 +155,7 @@ export default function DemoBanner(): React.ReactElement | null {

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

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

{t.whatIsDesc}

@@ -213,7 +245,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..0e85d14 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -67,6 +67,12 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {}) } + const getAddonName = (addon: Addon): string => { + const key = `admin.addons.catalog.${addon.id}.name` + const translated = t(key) + return translated !== key ? translated : addon.name + } + return (
diff --git a/client/src/components/Photos/PhotoGallery.tsx b/client/src/components/Photos/PhotoGallery.tsx index fdbdf35..fe64a19 100644 --- a/client/src/components/Photos/PhotoGallery.tsx +++ b/client/src/components/Photos/PhotoGallery.tsx @@ -3,7 +3,7 @@ import { PhotoLightbox } from './PhotoLightbox' import { PhotoUpload } from './PhotoUpload' import { Upload, Camera } from 'lucide-react' import Modal from '../shared/Modal' -import { useTranslation } from '../../i18n' +import { getLocaleForLanguage, useTranslation } from '../../i18n' import type { Photo, Place, Day } from '../../types' interface PhotoGalleryProps { @@ -17,7 +17,7 @@ interface PhotoGalleryProps { } export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }: PhotoGalleryProps) { - const { t } = useTranslation() + const { t, language } = useTranslation() const [lightboxIndex, setLightboxIndex] = useState(null) const [showUpload, setShowUpload] = useState(false) const [filterDayId, setFilterDayId] = useState('') @@ -53,7 +53,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla

Fotos

- {photos.length} Foto{photos.length !== 1 ? 's' : ''} + {photos.length} {photos.length !== 1 ? 'Fotos' : 'Foto'}

@@ -65,7 +65,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla {(days || []).map(day => ( ))} @@ -84,7 +84,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium whitespace-nowrap" > - Fotos hochladen + {t('common.upload')}
@@ -101,7 +101,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla style={{ display: 'inline-flex', margin: '0 auto' }} > - Fotos hochladen + {t('common.upload')}
) : ( @@ -146,7 +146,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla setShowUpload(false)} - title="Fotos hochladen" + title={t('common.upload')} size="lg" > s.settings.temperature_unit) === 'fahrenheit' const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const fmtTime = (v) => formatTime12(v, is12h) @@ -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]) @@ -136,7 +138,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri if (!day) return null const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString( - language === 'de' ? 'de-DE' : 'en-US', + getLocaleForLanguage(language), { weekday: 'long', day: 'numeric', month: 'long' } ) : null @@ -268,7 +270,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
{r.reservation_time?.includes('T') && ( - {new Date(r.reservation_time).toLocaleTimeString(language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit', hour12: is12h })} + {new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })} {r.reservation_end_time && ` – ${fmtTime(r.reservation_end_time)}`} )} @@ -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..a6b993e 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 @@ -314,7 +312,7 @@ export default function PlaceInspector({ icon={} text={<> {googleDetails.rating.toFixed(1)} - {googleDetails.rating_count ? ({googleDetails.rating_count.toLocaleString('de-DE')}) : ''} + {googleDetails.rating_count ? ({googleDetails.rating_count.toLocaleString(locale)}) : ''} {shortReview && · „{shortReview.text}"} } color="var(--text-secondary)" bg="var(--bg-hover)" @@ -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/Vacay/VacayMonthCard.tsx b/client/src/components/Vacay/VacayMonthCard.tsx index a3f7894..a012d0d 100644 --- a/client/src/components/Vacay/VacayMonthCard.tsx +++ b/client/src/components/Vacay/VacayMonthCard.tsx @@ -5,8 +5,10 @@ import type { HolidaysMap, VacayEntry } from '../../types' const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] +const WEEKDAYS_ES = ['Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sa', 'Do'] const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'] +const MONTHS_ES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'] interface VacayMonthCardProps { year: number @@ -25,8 +27,8 @@ export default function VacayMonthCard({ onCellClick, companyMode, blockWeekends }: VacayMonthCardProps) { const { language } = useTranslation() - const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN - const monthNames = language === 'de' ? MONTHS_DE : MONTHS_EN + const weekdays = language === 'de' ? WEEKDAYS_DE : language === 'es' ? WEEKDAYS_ES : WEEKDAYS_EN + const monthNames = language === 'de' ? MONTHS_DE : language === 'es' ? MONTHS_ES : MONTHS_EN const weeks = useMemo(() => { const firstDay = new Date(year, month, 1) diff --git a/client/src/components/Vacay/VacaySettings.tsx b/client/src/components/Vacay/VacaySettings.tsx index c700eff..626f29e 100644 --- a/client/src/components/Vacay/VacaySettings.tsx +++ b/client/src/components/Vacay/VacaySettings.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react' import { useVacayStore } from '../../store/vacayStore' -import { useTranslation } from '../../i18n' +import { getIntlLanguage, useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import CustomSelect from '../shared/CustomSelect' import apiClient from '../../api/client' @@ -24,7 +24,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { useEffect(() => { apiClient.get('/addons/vacay/holidays/countries').then(r => { let displayNames - try { displayNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ } + try { displayNames = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' }) } catch { /* */ } const list = r.data.map(c => ({ value: c.countryCode, label: displayNames ? (displayNames.of(c.countryCode) || c.name) : c.name, @@ -49,7 +49,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { }) if (allCounties.size > 0) { let subdivisionNames - try { subdivisionNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ } + try { subdivisionNames = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' }) } catch { /* */ } const regionList = [...allCounties].sort().map(c => { let label = c.split('-')[1] || c // Try Intl for full subdivision name (not all browsers support subdivision codes) 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/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx index ffa7bdb..408bdf7 100644 --- a/client/src/i18n/TranslationContext.tsx +++ b/client/src/i18n/TranslationContext.tsx @@ -2,10 +2,20 @@ import React, { createContext, useContext, useMemo, ReactNode } from 'react' import { useSettingsStore } from '../store/settingsStore' import de from './translations/de' import en from './translations/en' +import es from './translations/es' type TranslationStrings = Record -const translations: Record = { de, en } +const translations: Record = { de, en, es } +const LOCALES: Record = { de: 'de-DE', en: 'en-US', es: 'es-ES' } + +export function getLocaleForLanguage(language: string): string { + return LOCALES[language] || LOCALES.en +} + +export function getIntlLanguage(language: string): string { + return language === 'de' || language === 'es' ? language : 'en' +} interface TranslationContextValue { t: (key: string, params?: Record) => string @@ -13,18 +23,18 @@ interface TranslationContextValue { locale: string } -const TranslationContext = createContext({ t: (k: string) => k, language: 'de', locale: 'de-DE' }) +const TranslationContext = createContext({ t: (k: string) => k, language: 'en', locale: 'en-US' }) interface TranslationProviderProps { children: ReactNode } export function TranslationProvider({ children }: TranslationProviderProps) { - const language = useSettingsStore((s) => s.settings.language) || 'de' + const language = useSettingsStore((s) => s.settings.language) || 'en' const value = useMemo((): TranslationContextValue => { - const strings = translations[language] || translations.de - const fallback = translations.de + const strings = translations[language] || translations.en + const fallback = translations.en function t(key: string, params?: Record): string { let val: string = strings[key] ?? fallback[key] ?? key @@ -36,7 +46,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) { return val } - return { t, language, locale: language === 'en' ? 'en-US' : 'de-DE' } + return { t, language, locale: getLocaleForLanguage(language) } }, [language]) return {children} diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index 957f127..31d9369 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -1 +1 @@ -export { TranslationProvider, useTranslation } from './TranslationContext' +export { TranslationProvider, useTranslation, getLocaleForLanguage, getIntlLanguage } from './TranslationContext' diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 428cbbd..d18384c 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -207,7 +207,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…', @@ -222,6 +222,7 @@ const de: Record = { 'login.oidc.invalidState': 'Ungültige Sitzung. Bitte erneut versuchen.', 'login.demoFailed': 'Demo-Login fehlgeschlagen', 'login.oidcSignIn': 'Anmelden mit {name}', + 'login.oidcOnly': 'Passwort-Authentifizierung ist deaktiviert. Bitte melde dich über deinen SSO-Anbieter an.', 'login.demoHint': 'Demo ausprobieren — ohne Registrierung', 'login.mfaTitle': 'Zwei-Faktor-Authentifizierung', 'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.', @@ -308,6 +309,8 @@ const de: Record = { 'admin.oidcIssuer': 'Issuer URL', 'admin.oidcIssuerHint': 'Die OpenID Connect Issuer URL des Anbieters. z.B. https://accounts.google.com', 'admin.oidcSaved': 'OIDC-Konfiguration gespeichert', + 'admin.oidcOnlyMode': 'Passwort-Authentifizierung deaktivieren', + 'admin.oidcOnlyModeHint': 'Wenn aktiviert, ist nur SSO-Login erlaubt. Passwort-Login und Registrierung werden blockiert.', // File Types 'admin.fileTypes': 'Erlaubte Dateitypen', @@ -318,7 +321,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', @@ -333,7 +336,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', @@ -356,11 +359,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', @@ -370,7 +373,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 @@ -416,9 +419,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', @@ -430,7 +433,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', @@ -609,6 +612,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', @@ -702,6 +722,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', @@ -991,7 +1033,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 6bf8718..1778181 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -207,7 +207,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…', @@ -222,6 +222,7 @@ const en: Record = { 'login.oidc.invalidState': 'Invalid session. Please try again.', 'login.demoFailed': 'Demo login failed', 'login.oidcSignIn': 'Sign in with {name}', + 'login.oidcOnly': 'Password authentication is disabled. Please sign in using your SSO provider.', 'login.demoHint': 'Try the demo — no registration needed', 'login.mfaTitle': 'Two-factor authentication', 'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.', @@ -308,6 +309,8 @@ const en: Record = { 'admin.oidcIssuer': 'Issuer URL', 'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com', 'admin.oidcSaved': 'OIDC configuration saved', + 'admin.oidcOnlyMode': 'Disable password authentication', + 'admin.oidcOnlyModeHint': 'When enabled, only SSO login is permitted. Password-based login and registration are blocked.', // File Types 'admin.fileTypes': 'Allowed File Types', @@ -318,7 +321,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', @@ -333,7 +336,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', @@ -356,11 +359,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', @@ -370,7 +373,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 @@ -416,9 +419,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', @@ -430,7 +433,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', @@ -609,6 +612,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', @@ -702,6 +722,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', @@ -991,7 +1033,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/i18n/translations/es.js b/client/src/i18n/translations/es.js new file mode 100644 index 0000000..2146dfb --- /dev/null +++ b/client/src/i18n/translations/es.js @@ -0,0 +1,1061 @@ +const es = { + // Common + 'common.save': 'Guardar', + 'common.cancel': 'Cancelar', + 'common.delete': 'Eliminar', + 'common.edit': 'Editar', + 'common.add': 'Añadir', + 'common.loading': 'Cargando...', + 'common.error': 'Error', + 'common.back': 'Atrás', + 'common.all': 'Todo', + 'common.close': 'Cerrar', + 'common.open': 'Abrir', + 'common.upload': 'Subir', + 'common.search': 'Buscar', + 'common.confirm': 'Confirmar', + 'common.ok': 'Aceptar', + 'common.yes': 'Sí', + 'common.no': 'No', + 'common.or': 'o', + 'common.none': 'Ninguno', + 'common.date': 'Fecha', + 'common.rename': 'Renombrar', + 'common.name': 'Nombre', + 'common.email': 'Correo', + 'common.password': 'Contraseña', + 'common.saving': 'Guardando...', + 'common.update': 'Actualizar', + 'common.change': 'Cambiar', + 'common.uploading': 'Subiendo…', + 'common.backToPlanning': 'Volver a la planificación', + 'common.reset': 'Restablecer', + + // Navbar + 'nav.trip': 'Viaje', + 'nav.share': 'Compartir', + 'nav.settings': 'Ajustes', + 'nav.admin': 'Administración', + 'nav.logout': 'Cerrar sesión', + 'nav.lightMode': 'Modo claro', + 'nav.darkMode': 'Modo oscuro', + 'nav.autoMode': 'Modo automático', + 'nav.administrator': 'Administrador', + 'nav.myTrips': 'Mis viajes', + + // Dashboard + 'dashboard.title': 'Mis viajes', + 'dashboard.subtitle.loading': 'Cargando viajes...', + 'dashboard.subtitle.trips': '{count} viajes ({archived} archivados)', + 'dashboard.subtitle.empty': 'Empieza tu primer viaje', + 'dashboard.subtitle.activeOne': '{count} viaje activo', + 'dashboard.subtitle.activeMany': '{count} viajes activos', + 'dashboard.subtitle.archivedSuffix': ' · {count} archivados', + 'dashboard.newTrip': 'Nuevo viaje', + 'dashboard.currency': 'Divisa', + 'dashboard.timezone': 'Zonas horarias', + 'dashboard.localTime': 'Hora local', + 'dashboard.emptyTitle': 'Aún no hay viajes', + 'dashboard.emptyText': 'Crea tu primer viaje y empieza a planificar', + 'dashboard.emptyButton': 'Crear primer viaje', + 'dashboard.nextTrip': 'Próximo viaje', + 'dashboard.shared': 'Compartido', + 'dashboard.sharedBy': 'Compartido por {name}', + 'dashboard.days': 'Días', + 'dashboard.places': 'Lugares', + 'dashboard.archive': 'Archivar', + 'dashboard.restore': 'Restaurar', + 'dashboard.archived': 'Archivado', + 'dashboard.status.ongoing': 'En curso', + 'dashboard.status.today': 'Hoy', + 'dashboard.status.tomorrow': 'Mañana', + 'dashboard.status.past': 'Pasado', + 'dashboard.status.daysLeft': 'Quedan {count} días', + 'dashboard.toast.loadError': 'No se pudieron cargar los viajes', + 'dashboard.toast.created': '¡Viaje creado correctamente!', + 'dashboard.toast.createError': 'No se pudo crear el viaje', + 'dashboard.toast.updated': '¡Viaje actualizado!', + 'dashboard.toast.updateError': 'No se pudo actualizar el viaje', + 'dashboard.toast.deleted': 'Viaje eliminado', + 'dashboard.toast.deleteError': 'No se pudo eliminar el viaje', + 'dashboard.toast.archived': 'Viaje archivado', + 'dashboard.toast.archiveError': 'No se pudo archivar el viaje', + 'dashboard.toast.restored': 'Viaje restaurado', + 'dashboard.toast.restoreError': 'No se pudo restaurar el viaje', + 'dashboard.confirm.delete': '¿Eliminar el viaje "{title}"? Todos los lugares y planes se borrarán permanentemente.', + 'dashboard.editTrip': 'Editar viaje', + 'dashboard.createTrip': 'Crear nuevo viaje', + 'dashboard.tripTitle': 'Título', + 'dashboard.tripTitlePlaceholder': 'p. ej. Verano en Japón', + 'dashboard.tripDescription': 'Descripción', + 'dashboard.tripDescriptionPlaceholder': '¿De qué trata este viaje?', + 'dashboard.startDate': 'Fecha de inicio', + 'dashboard.endDate': 'Fecha de fin', + 'dashboard.noDateHint': 'Sin fecha definida: se crearán 7 días por defecto. Puedes cambiarlo cuando quieras.', + 'dashboard.coverImage': 'Imagen de portada', + 'dashboard.addCoverImage': 'Añadir imagen de portada', + 'dashboard.coverSaved': 'Imagen de portada guardada', + 'dashboard.coverUploadError': 'Error al subir la imagen', + 'dashboard.coverRemoveError': 'Error al eliminar la imagen', + 'dashboard.titleRequired': 'El título es obligatorio', + 'dashboard.endDateError': 'La fecha de fin debe ser posterior a la de inicio', + + // Settings + 'settings.title': 'Ajustes', + 'settings.subtitle': 'Configura tus ajustes personales', + 'settings.map': 'Mapa', + 'settings.mapTemplate': 'Plantilla del mapa', + 'settings.mapTemplatePlaceholder.select': 'Seleccionar plantilla...', + 'settings.mapDefaultHint': 'Déjalo vacío para OpenStreetMap (por defecto)', + 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + 'settings.mapHint': 'Plantilla de URL para los mosaicos del mapa', + 'settings.latitude': 'Latitud', + 'settings.longitude': 'Longitud', + 'settings.saveMap': 'Guardar mapa', + 'settings.apiKeys': 'Claves API', + 'settings.mapsKey': 'Clave API de Google Maps', + 'settings.mapsKeyHint': 'Necesaria para buscar lugares. Consíguela en console.cloud.google.com', + 'settings.weatherKey': 'Clave API de OpenWeatherMap', + 'settings.weatherKeyHint': 'Para datos meteorológicos. Gratis en openweathermap.org/api', + 'settings.keyPlaceholder': 'Introduce la clave...', + 'settings.configured': 'Configurado', + 'settings.saveKeys': 'Guardar claves', + 'settings.display': 'Visualización', + 'settings.colorMode': 'Modo de color', + 'settings.light': 'Claro', + 'settings.dark': 'Oscuro', + 'settings.auto': 'Automático', + 'settings.language': 'Idioma', + 'settings.temperature': 'Unidad de temperatura', + 'settings.timeFormat': 'Formato de hora', + 'settings.routeCalculation': 'Cálculo de ruta', + 'settings.on': 'Activado', + 'settings.off': 'Desactivado', + 'settings.account': 'Cuenta', + 'settings.username': 'Usuario', + 'settings.email': 'Correo', + 'settings.role': 'Rol', + 'settings.roleAdmin': 'Administrador', + 'settings.oidcLinked': 'Vinculado con', + 'settings.changePassword': 'Cambiar contraseña', + 'settings.currentPassword': 'Contraseña actual', + 'settings.newPassword': 'Nueva contraseña', + 'settings.confirmPassword': 'Confirmar nueva contraseña', + 'settings.updatePassword': 'Actualizar contraseña', + 'settings.passwordRequired': 'Introduce la contraseña actual y la nueva', + 'settings.passwordTooShort': 'La contraseña debe tener al menos 8 caracteres', + 'settings.passwordMismatch': 'Las contraseñas no coinciden', + 'settings.passwordChanged': 'Contraseña cambiada correctamente', + 'settings.deleteAccount': 'Eliminar cuenta', + 'settings.deleteAccountTitle': '¿Eliminar tu cuenta?', + 'settings.deleteAccountWarning': 'Tu cuenta y todos tus viajes, lugares y archivos se eliminarán permanentemente. Esta acción no se puede deshacer.', + 'settings.deleteAccountConfirm': 'Eliminar permanentemente', + 'settings.deleteBlockedTitle': 'No es posible eliminarla', + 'settings.deleteBlockedMessage': 'Eres el único administrador. Asciende a otro usuario a administrador antes de eliminar tu cuenta.', + 'settings.roleUser': 'Usuario', + 'settings.saveProfile': 'Guardar perfil', + 'settings.toast.mapSaved': 'Ajustes del mapa guardados', + 'settings.toast.keysSaved': 'Claves API guardadas', + 'settings.toast.displaySaved': 'Ajustes de visualización guardados', + 'settings.toast.profileSaved': 'Perfil guardado', + 'settings.uploadAvatar': 'Subir foto de perfil', + 'settings.removeAvatar': 'Eliminar foto de perfil', + 'settings.avatarUploaded': 'Foto de perfil actualizada', + 'settings.avatarRemoved': 'Foto de perfil eliminada', + 'settings.avatarError': 'Falló la subida', + + // Login + 'login.error': 'Inicio de sesión fallido. Revisa tus credenciales.', + 'login.tagline': 'Tus viajes.\nTu plan.', + 'login.description': 'Planifica viajes en colaboración con mapas interactivos, presupuestos y sincronización en tiempo real.', + 'login.features.maps': 'Mapas interactivos', + 'login.features.mapsDesc': 'Google Places, rutas y agrupación', + 'login.features.realtime': 'Sincronización en tiempo real', + 'login.features.realtimeDesc': 'Planificad juntos mediante WebSocket', + 'login.features.budget': 'Control de presupuesto', + 'login.features.budgetDesc': 'Categorías, gráficos y costes por persona', + 'login.features.collab': 'Colaboración', + 'login.features.collabDesc': 'Multiusuario con viajes compartidos', + 'login.features.packing': 'Listas de equipaje', + 'login.features.packingDesc': 'Categorías, progreso y sugerencias', + 'login.features.bookings': 'Reservas', + 'login.features.bookingsDesc': 'Vuelos, hoteles, restaurantes y más', + 'login.features.files': 'Documentos', + 'login.features.filesDesc': 'Sube y gestiona documentos', + 'login.features.routes': 'Rutas inteligentes', + 'login.features.routesDesc': 'Optimización automática y exportación a Google Maps', + 'login.selfHosted': 'Autoalojado · Código abierto · Tus datos siguen siendo tuyos', + 'login.title': 'Iniciar sesión', + 'login.subtitle': 'Bienvenido de nuevo', + 'login.signingIn': 'Iniciando sesión…', + 'login.signIn': 'Entrar', + 'login.createAdmin': 'Crear cuenta de administrador', + 'login.createAdminHint': 'Configura la primera cuenta administradora de NOMAD.', + 'login.createAccount': 'Crear cuenta', + 'login.createAccountHint': 'Crea una cuenta nueva.', + 'login.creating': 'Creando…', + 'login.noAccount': '¿No tienes cuenta?', + 'login.hasAccount': '¿Ya tienes cuenta?', + 'login.register': 'Registrarse', + 'login.emailPlaceholder': 'tu@correo.com', + 'login.username': 'Usuario', + 'login.oidc.registrationDisabled': 'El registro está desactivado. Contacta con tu administrador.', + 'login.oidc.noEmail': 'No se recibió ningún correo del proveedor.', + 'login.oidc.tokenFailed': 'La autenticación falló.', + 'login.oidc.invalidState': 'Sesión no válida. Inténtalo de nuevo.', + 'login.demoFailed': 'Falló el acceso a la demo', + 'login.oidcSignIn': 'Entrar con {name}', + 'login.demoHint': 'Prueba la demo: no necesitas registrarte', + + // Register + 'register.passwordMismatch': 'Las contraseñas no coinciden', + 'register.passwordTooShort': 'La contraseña debe tener al menos 6 caracteres', + 'register.failed': 'Falló el registro', + 'register.getStarted': 'Empezar', + 'register.subtitle': 'Crea una cuenta y empieza a planificar tus viajes.', + 'register.feature1': 'Planes de viaje ilimitados', + 'register.feature2': 'Vista de mapa interactiva', + 'register.feature3': 'Gestiona lugares y categorías', + 'register.feature4': 'Haz seguimiento de las reservas', + 'register.feature5': 'Crea listas de equipaje', + 'register.feature6': 'Guarda fotos y archivos', + 'register.createAccount': 'Crear cuenta', + 'register.startPlanning': 'Empieza a planificar tu viaje', + 'register.minChars': 'Mín. 6 caracteres', + 'register.confirmPassword': 'Confirmar contraseña', + 'register.repeatPassword': 'Repetir contraseña', + 'register.registering': 'Registrando...', + 'register.register': 'Registrarse', + 'register.hasAccount': '¿Ya tienes cuenta?', + 'register.signIn': 'Iniciar sesión', + + // Admin + 'admin.title': 'Administración', + 'admin.subtitle': 'Gestión de usuarios y ajustes del sistema', + 'admin.tabs.users': 'Usuarios', + 'admin.tabs.categories': 'Categorías', + 'admin.tabs.backup': 'Copia de seguridad', + 'admin.stats.users': 'Usuarios', + 'admin.stats.trips': 'Viajes', + 'admin.stats.places': 'Lugares', + 'admin.stats.photos': 'Fotos', + 'admin.stats.files': 'Archivos', + 'admin.table.user': 'Usuario', + 'admin.table.email': 'Correo', + 'admin.table.role': 'Rol', + 'admin.table.created': 'Creado', + 'admin.table.lastLogin': 'Último acceso', + 'admin.table.actions': 'Acciones', + 'admin.you': '(Tú)', + 'admin.editUser': 'Editar usuario', + 'admin.newPassword': 'Nueva contraseña', + 'admin.newPasswordHint': 'Déjalo vacío para mantener la contraseña actual', + 'admin.deleteUser': '¿Eliminar al usuario "{name}"? Todos sus viajes se borrarán permanentemente.', + 'admin.deleteUserTitle': 'Eliminar usuario', + 'admin.newPasswordPlaceholder': 'Introduce una nueva contraseña…', + 'admin.toast.loadError': 'No se pudieron cargar los datos de administración', + 'admin.toast.userUpdated': 'Usuario actualizado', + 'admin.toast.updateError': 'No se pudo actualizar', + 'admin.toast.userDeleted': 'Usuario eliminado', + 'admin.toast.deleteError': 'No se pudo eliminar', + 'admin.toast.cannotDeleteSelf': 'No puedes eliminar tu propia cuenta', + 'admin.toast.userCreated': 'Usuario creado', + 'admin.toast.createError': 'No se pudo crear el usuario', + 'admin.toast.fieldsRequired': 'Usuario, correo y contraseña son obligatorios', + 'admin.createUser': 'Crear usuario', + 'admin.tabs.settings': 'Ajustes', + 'admin.allowRegistration': 'Permitir el registro', + 'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos', + 'admin.apiKeys': 'Claves API', + 'admin.apiKeysHint': 'Opcional. Activa datos ampliados de lugares, como fotos y previsión del tiempo.', + 'admin.mapsKey': 'Clave API de Google Maps', + 'admin.mapsKeyHint': 'Obligatoria para buscar lugares. Consíguela en console.cloud.google.com', + 'admin.mapsKeyHintLong': 'Sin una clave API, la búsqueda de lugares usa OpenStreetMap. Con una clave de Google también se pueden cargar fotos, valoraciones y horarios de apertura. Consíguela en console.cloud.google.com.', + 'admin.recommended': 'Recomendado', + 'admin.weatherKey': 'Clave API de OpenWeatherMap', + 'admin.weatherKeyHint': 'Para datos meteorológicos. Gratis en openweathermap.org', + 'admin.validateKey': 'Probar', + 'admin.keyValid': 'Conectado', + 'admin.keyInvalid': 'No válida', + 'admin.keySaved': 'Claves API guardadas', + 'admin.oidcTitle': 'Inicio de sesión único (OIDC)', + 'admin.oidcSubtitle': 'Permite iniciar sesión mediante proveedores externos como Google, Apple, Authentik o Keycloak.', + 'admin.oidcDisplayName': 'Nombre visible', + 'admin.oidcIssuer': 'URL del emisor', + 'admin.oidcIssuerHint': 'La URL Issuer de OpenID Connect del proveedor. Ej.: https://accounts.google.com', + 'admin.oidcSaved': 'Configuración OIDC guardada', + + // File Types + 'admin.fileTypes': 'Tipos de archivo permitidos', + 'admin.fileTypesHint': 'Configura qué tipos de archivo pueden subir los usuarios.', + 'admin.fileTypesFormat': 'Extensiones separadas por comas (p. ej. jpg,png,pdf,doc). Usa * para permitir todos los tipos.', + 'admin.fileTypesSaved': 'Ajustes de tipos de archivo guardados', + + // Addons + 'admin.tabs.addons': 'Complementos', + 'admin.addons.title': 'Complementos', + 'admin.addons.subtitle': 'Activa o desactiva funciones para personalizar tu experiencia en NOMAD.', + 'admin.addons.subtitleBefore': 'Activa o desactiva funciones para personalizar tu experiencia en ', + 'admin.addons.subtitleAfter': '.', + 'admin.addons.enabled': 'Activo', + 'admin.addons.disabled': 'Desactivado', + 'admin.addons.type.trip': 'Viaje', + 'admin.addons.type.global': 'Global', + 'admin.addons.tripHint': 'Disponible como pestaña dentro de cada viaje', + 'admin.addons.globalHint': 'Disponible como sección independiente en la navegación principal', + 'admin.addons.toast.updated': 'Complemento actualizado', + 'admin.addons.toast.error': 'No se pudo actualizar el complemento', + 'admin.addons.noAddons': 'No hay complementos disponibles', + 'admin.weather.title': 'Datos meteorológicos', + 'admin.weather.badge': 'Desde el 24 de marzo de 2026', + 'admin.weather.description': 'NOMAD utiliza Open-Meteo como fuente de datos meteorológicos. Open-Meteo es un servicio meteorológico gratuito y de código abierto: no requiere clave API.', + 'admin.weather.forecast': 'Pronóstico de 16 días', + 'admin.weather.forecastDesc': 'Antes eran 5 días (OpenWeatherMap)', + 'admin.weather.climate': 'Datos climáticos históricos', + 'admin.weather.climateDesc': 'Promedios de los últimos 85 años para fechas posteriores al pronóstico de 16 días', + 'admin.weather.requests': '10.000 solicitudes / día', + 'admin.weather.requestsDesc': 'Gratis, sin necesidad de clave API', + 'admin.weather.locationHint': 'El tiempo se basa en el primer lugar con coordenadas de cada día. Si no hay ningún lugar asignado a un día, se usa como referencia cualquier lugar de la lista.', + + // GitHub + 'admin.tabs.github': 'GitHub', + 'admin.github.title': 'Historial de versiones', + 'admin.github.subtitle': 'Últimas novedades de {repo}', + 'admin.github.latest': 'Última', + 'admin.github.prerelease': 'Prelanzamiento', + 'admin.github.showDetails': 'Mostrar detalles', + 'admin.github.hideDetails': 'Ocultar detalles', + 'admin.github.loadMore': 'Cargar más', + 'admin.github.loading': 'Cargando...', + 'admin.github.error': 'No se pudieron cargar las versiones', + 'admin.github.by': 'por', + 'admin.update.available': 'Actualización disponible', + 'admin.update.text': 'NOMAD {version} está disponible. Estás usando {current}.', + 'admin.update.button': 'Ver en GitHub', + 'admin.update.install': 'Instalar actualización', + 'admin.update.confirmTitle': '¿Instalar actualización?', + 'admin.update.confirmText': 'NOMAD se actualizará de {current} a {version}. Después, el servidor se reiniciará automáticamente.', + 'admin.update.dataInfo': 'Todos tus datos (viajes, usuarios, claves API, subidas, Vacay, Atlas, presupuestos) se conservarán.', + 'admin.update.warning': 'La app estará brevemente no disponible durante el reinicio.', + 'admin.update.confirm': 'Actualizar ahora', + 'admin.update.installing': 'Actualizando…', + 'admin.update.success': '¡Actualización instalada! El servidor se está reiniciando…', + 'admin.update.failed': 'La actualización falló', + 'admin.update.backupHint': 'Recomendamos crear una copia de seguridad antes de actualizar.', + 'admin.update.backupLink': 'Ir a Copia de seguridad', + 'admin.update.howTo': 'Cómo actualizar', + 'admin.update.dockerText': 'Tu instancia de NOMAD se ejecuta en Docker. Para actualizar a {version}, ejecuta los siguientes comandos en tu servidor:', + 'admin.update.reloadHint': 'Recarga la página en unos segundos.', + + // Vacay addon + 'vacay.subtitle': 'Planifica y gestiona días de vacaciones', + 'vacay.settings': 'Ajustes', + 'vacay.year': 'Año', + 'vacay.addYear': 'Añadir año', + 'vacay.removeYear': 'Eliminar año', + 'vacay.removeYearConfirm': '¿Eliminar {year}?', + 'vacay.removeYearHint': 'Todas las vacaciones y festivos de empresa de este año se borrarán permanentemente.', + 'vacay.remove': 'Eliminar', + 'vacay.persons': 'Personas', + 'vacay.noPersons': 'No se han añadido personas', + 'vacay.addPerson': 'Añadir persona', + 'vacay.editPerson': 'Editar persona', + 'vacay.removePerson': 'Eliminar persona', + 'vacay.removePersonConfirm': '¿Eliminar a {name}?', + 'vacay.removePersonHint': 'Todas las vacaciones de esta persona se borrarán permanentemente.', + 'vacay.personName': 'Nombre', + 'vacay.personNamePlaceholder': 'Introduce un nombre', + 'vacay.color': 'Color', + 'vacay.add': 'Añadir', + 'vacay.legend': 'Leyenda', + 'vacay.publicHoliday': 'Festivo', + 'vacay.companyHoliday': 'Festivo de empresa', + 'vacay.weekend': 'Fin de semana', + 'vacay.modeVacation': 'Vacaciones', + 'vacay.modeCompany': 'Festivo de empresa', + 'vacay.entitlement': 'Derecho', + 'vacay.entitlementDays': 'Días', + 'vacay.used': 'Usados', + 'vacay.remaining': 'Restantes', + 'vacay.carriedOver': 'de {year}', + 'vacay.blockWeekends': 'Bloquear fines de semana', + 'vacay.blockWeekendsHint': 'Impide marcar vacaciones en sábados y domingos', + 'vacay.publicHolidays': 'Festivos', + 'vacay.publicHolidaysHint': 'Marcar festivos en el calendario', + 'vacay.selectCountry': 'Seleccionar país', + 'vacay.selectRegion': 'Seleccionar región (opcional)', + 'vacay.companyHolidays': 'Festivos de empresa', + 'vacay.companyHolidaysHint': 'Permitir marcar días festivos comunes de la empresa', + 'vacay.companyHolidaysNoDeduct': 'Los festivos de empresa no descuentan días de vacaciones.', + 'vacay.carryOver': 'Arrastrar saldo', + 'vacay.carryOverHint': 'Trasladar automáticamente los días restantes al año siguiente', + 'vacay.sharing': 'Compartir', + 'vacay.sharingHint': 'Comparte tu calendario de vacaciones con otros usuarios de NOMAD', + 'vacay.owner': 'Propietario', + 'vacay.shareEmailPlaceholder': 'Correo electrónico del usuario de NOMAD', + 'vacay.shareSuccess': 'Plan compartido correctamente', + 'vacay.shareError': 'No se pudo compartir el plan', + 'vacay.dissolve': 'Deshacer fusión', + 'vacay.dissolveHint': 'Separar de nuevo los calendarios. Tus entradas se conservarán.', + 'vacay.dissolveAction': 'Disolver', + 'vacay.dissolved': 'Calendario separado', + 'vacay.fusedWith': 'Fusionado con', + 'vacay.you': 'tú', + 'vacay.noData': 'Sin datos', + 'vacay.changeColor': 'Cambiar color', + 'vacay.inviteUser': 'Invitar usuario', + 'vacay.inviteHint': 'Invita a otro usuario de NOMAD a compartir un calendario combinado de vacaciones.', + 'vacay.selectUser': 'Seleccionar usuario', + 'vacay.sendInvite': 'Enviar invitación', + 'vacay.inviteSent': 'Invitación enviada', + 'vacay.inviteError': 'No se pudo enviar la invitación', + 'vacay.pending': 'pendiente', + 'vacay.noUsersAvailable': 'No hay usuarios disponibles', + 'vacay.accept': 'Aceptar', + 'vacay.decline': 'Rechazar', + 'vacay.acceptFusion': 'Aceptar y fusionar', + 'vacay.inviteTitle': 'Solicitud de fusión', + 'vacay.inviteWantsToFuse': 'quiere compartir un calendario de vacaciones contigo.', + 'vacay.fuseInfo1': 'Ambos veréis todas las entradas de vacaciones en un único calendario compartido.', + 'vacay.fuseInfo2': 'Ambas partes pueden crear y editar entradas mutuamente.', + 'vacay.fuseInfo3': 'Ambas partes pueden borrar entradas y cambiar el número de días de vacaciones disponibles.', + 'vacay.fuseInfo4': 'Ajustes como festivos y festivos de empresa se comparten.', + 'vacay.fuseInfo5': 'La fusión puede disolverse en cualquier momento por cualquiera de las partes. Tus entradas se conservarán.', + + // Atlas addon + 'atlas.subtitle': 'Tu huella viajera por el mundo', + 'atlas.countries': 'Países', + 'atlas.trips': 'Viajes', + 'atlas.places': 'Lugares', + 'atlas.days': 'Días', + 'atlas.visitedCountries': 'Países visitados', + 'atlas.cities': 'Ciudades', + 'atlas.noData': 'Aún no hay datos de viaje', + 'atlas.noDataHint': 'Crea un viaje y añade lugares para ver tu mapa del mundo', + 'atlas.lastTrip': 'Último viaje', + 'atlas.nextTrip': 'Próximo viaje', + 'atlas.daysLeft': 'días restantes', + 'atlas.streak': 'Racha', + 'atlas.year': 'año', + 'atlas.years': 'años', + 'atlas.yearInRow': 'año seguido', + 'atlas.yearsInRow': 'años seguidos', + 'atlas.tripIn': 'viaje en', + 'atlas.tripsIn': 'viajes en', + 'atlas.since': 'desde', + 'atlas.europe': 'Europa', + 'atlas.asia': 'Asia', + 'atlas.northAmerica': 'América del Norte', + 'atlas.southAmerica': 'América del Sur', + 'atlas.africa': 'África', + 'atlas.oceania': 'Oceanía', + 'atlas.other': 'Otros', + 'atlas.firstVisit': 'Primer viaje', + 'atlas.lastVisitLabel': 'Último viaje', + 'atlas.tripSingular': 'Viaje', + 'atlas.tripPlural': 'Viajes', + 'atlas.placeVisited': 'Lugar visitado', + 'atlas.placesVisited': 'Lugares visitados', + + // Trip Planner + 'trip.tabs.plan': 'Plan', + 'trip.tabs.reservations': 'Reservas', + 'trip.tabs.reservationsShort': 'Reservas', + 'trip.tabs.packing': 'Lista de equipaje', + 'trip.tabs.packingShort': 'Equipaje', + 'trip.tabs.budget': 'Presupuesto', + 'trip.tabs.memories': 'Recuerdos', + 'trip.tabs.files': 'Archivos', + 'trip.loading': 'Cargando viaje...', + 'trip.mobilePlan': 'Plan', + 'trip.mobilePlaces': 'Lugares', + 'trip.toast.placeUpdated': 'Lugar actualizado', + 'trip.toast.placeAdded': 'Lugar añadido', + 'trip.toast.placeDeleted': 'Lugar eliminado', + 'trip.toast.selectDay': 'Selecciona primero un día', + 'trip.toast.assignedToDay': 'Lugar asignado al día', + 'trip.toast.reorderError': 'No se pudo reordenar', + 'trip.toast.reservationUpdated': 'Reserva actualizada', + 'trip.toast.reservationAdded': 'Reserva añadida', + 'trip.toast.deleted': 'Eliminado', + 'trip.confirm.deletePlace': '¿Seguro que quieres eliminar este lugar?', + + // Day Plan Sidebar + 'dayplan.emptyDay': 'No hay lugares planificados para este día', + 'dayplan.addNote': 'Añadir nota', + 'dayplan.editNote': 'Editar nota', + 'dayplan.noteAdd': 'Añadir nota', + 'dayplan.noteEdit': 'Editar nota', + 'dayplan.noteTitle': 'Nota', + 'dayplan.noteSubtitle': 'Nota diaria', + 'dayplan.totalCost': 'Coste total', + 'dayplan.days': 'Días', + 'dayplan.dayN': 'Día {n}', + 'dayplan.calculating': 'Calculando...', + 'dayplan.route': 'Ruta', + 'dayplan.optimize': 'Optimizar', + 'dayplan.optimized': 'Ruta optimizada', + 'dayplan.routeError': 'No se pudo calcular la ruta', + 'dayplan.toast.needTwoPlaces': 'Se necesitan al menos dos lugares para optimizar la ruta', + 'dayplan.toast.routeOptimized': 'Ruta optimizada', + 'dayplan.toast.noGeoPlaces': 'No se encontraron lugares con coordenadas para calcular la ruta', + 'dayplan.confirmed': 'Confirmado', + 'dayplan.pendingRes': 'Pendiente', + 'dayplan.pdf': 'PDF', + 'dayplan.pdfTooltip': 'Exportar plan diario como PDF', + 'dayplan.pdfError': 'No se pudo exportar el PDF', + + // Places Sidebar + 'places.addPlace': 'Añadir lugar/actividad', + 'places.assignToDay': '¿A qué día añadirlo?', + 'places.all': 'Todo', + 'places.unplanned': 'Sin planificar', + 'places.search': 'Buscar lugares...', + 'places.allCategories': 'Todas las categorías', + 'places.count': '{count} lugares', + 'places.countSingular': '1 lugar', + 'places.allPlanned': 'Todos los lugares están planificados', + 'places.noneFound': 'No se encontraron lugares', + 'places.editPlace': 'Editar lugar', + 'places.formName': 'Nombre', + 'places.formNamePlaceholder': 'p. ej. Torre Eiffel', + 'places.formDescription': 'Descripción', + 'places.formDescriptionPlaceholder': 'Descripción breve...', + 'places.formAddress': 'Dirección', + 'places.formAddressPlaceholder': 'Calle, ciudad, país', + 'places.formLat': 'Latitud (p. ej. 48.8566)', + 'places.formLng': 'Longitud (p. ej. 2.3522)', + 'places.formCategory': 'Categoría', + 'places.noCategory': 'Sin categoría', + 'places.categoryNamePlaceholder': 'Nombre de la categoría', + 'places.formTime': 'Hora', + 'places.startTime': 'Inicio', + 'places.endTime': 'Fin', + 'places.endTimeBeforeStart': 'La hora de fin es anterior a la de inicio', + 'places.timeCollision': 'Solapamiento horario con:', + 'places.formWebsite': 'Página web', + 'places.formNotesPlaceholder': 'Notas personales...', + 'places.formReservation': 'Reserva', + 'places.reservationNotesPlaceholder': 'Notas de reserva, número de confirmación...', + 'places.mapsSearchPlaceholder': 'Buscar lugares...', + 'places.mapsSearchError': 'La búsqueda de lugares falló.', + 'places.osmHint': 'Usando búsqueda con OpenStreetMap (sin fotos, horarios ni valoraciones). Añade una clave API de Google en Ajustes para obtener todos los detalles.', + 'places.osmActive': 'Búsqueda mediante OpenStreetMap (sin fotos, valoraciones ni horarios). Añade una clave API de Google en Ajustes para datos ampliados.', + 'places.categoryCreateError': 'No se pudo crear la categoría', + 'places.nameRequired': 'Introduce un nombre', + 'places.saveError': 'No se pudo guardar', + + // Place Inspector + 'inspector.opened': 'Abierto', + 'inspector.closed': 'Cerrado', + 'inspector.openingHours': 'Horario de apertura', + 'inspector.showHours': 'Mostrar horario', + 'inspector.files': 'Archivos', + 'inspector.filesCount': '{count} archivos', + 'inspector.removeFromDay': 'Quitar del día', + 'inspector.addToDay': 'Añadir al día', + 'inspector.confirmedRes': 'Reserva confirmada', + 'inspector.pendingRes': 'Reserva pendiente', + 'inspector.google': 'Abrir en Google Maps', + 'inspector.website': 'Abrir la web', + 'inspector.addRes': 'Reserva', + 'inspector.editRes': 'Editar reserva', + 'inspector.participants': 'Participantes', + + // Reservations + 'reservations.title': 'Reservas', + 'reservations.empty': 'Aún no hay reservas', + 'reservations.emptyHint': 'Añade reservas de vuelos, hoteles y más', + 'reservations.add': 'Añadir reserva', + 'reservations.addManual': 'Reserva manual', + 'reservations.placeHint': 'Consejo: es mejor crear las reservas directamente desde un lugar para vincularlas con el plan del día.', + 'reservations.confirmed': 'Confirmada', + 'reservations.pending': 'Pendiente', + 'reservations.summary': '{confirmed} confirmadas, {pending} pendientes', + 'reservations.fromPlan': 'Del plan', + 'reservations.showFiles': 'Mostrar archivos', + 'reservations.editTitle': 'Editar reserva', + 'reservations.status': 'Estado', + 'reservations.datetime': 'Fecha y hora', + 'reservations.startTime': 'Hora de inicio', + 'reservations.endTime': 'Hora de fin', + 'reservations.date': 'Fecha', + 'reservations.time': 'Hora', + 'reservations.timeAlt': 'Hora (alternativa, p. ej. 19:30)', + 'reservations.notes': 'Notas', + 'reservations.notesPlaceholder': 'Notas adicionales...', + 'reservations.type.flight': 'Vuelo', + 'reservations.type.hotel': 'Hotel', + 'reservations.type.restaurant': 'Restaurante', + 'reservations.type.train': 'Tren', + 'reservations.type.car': 'Coche de alquiler', + 'reservations.type.cruise': 'Crucero', + 'reservations.type.event': 'Evento', + 'reservations.type.tour': 'Tour', + 'reservations.type.other': 'Otro', + 'reservations.confirm.delete': '¿Seguro que quieres eliminar la reserva "{name}"?', + 'reservations.toast.updated': 'Reserva actualizada', + 'reservations.toast.removed': 'Reserva eliminada', + 'reservations.toast.fileUploaded': 'Archivo subido', + 'reservations.toast.uploadError': 'No se pudo subir', + 'reservations.newTitle': 'Nueva reserva', + 'reservations.bookingType': 'Tipo de reserva', + 'reservations.titleLabel': 'Título', + 'reservations.titlePlaceholder': 'p. ej. Lufthansa LH123, Hotel Adlon, ...', + 'reservations.locationAddress': 'Ubicación / dirección', + 'reservations.locationPlaceholder': 'Dirección, aeropuerto, hotel...', + 'reservations.confirmationCode': 'Código de reserva', + 'reservations.confirmationPlaceholder': 'p. ej. ABC12345', + 'reservations.day': 'Día', + 'reservations.noDay': 'Sin día', + 'reservations.place': 'Lugar', + 'reservations.noPlace': 'Sin lugar', + 'reservations.pendingSave': 'se guardará…', + 'reservations.uploading': 'Subiendo...', + 'reservations.attachFile': 'Adjuntar archivo', + 'reservations.toast.saveError': 'No se pudo guardar', + 'reservations.toast.updateError': 'No se pudo actualizar', + 'reservations.toast.deleteError': 'No se pudo eliminar', + 'reservations.confirm.remove': '¿Eliminar la reserva de "{name}"?', + 'reservations.linkAssignment': 'Vincular a una asignación del día', + 'reservations.pickAssignment': 'Selecciona una asignación de tu plan...', + 'reservations.noAssignment': 'Sin vínculo (independiente)', + + // Budget + 'budget.title': 'Presupuesto', + 'budget.emptyTitle': 'Aún no se ha creado ningún presupuesto', + 'budget.emptyText': 'Crea categorías y entradas para planificar el presupuesto de tu viaje', + 'budget.emptyPlaceholder': 'Introduce el nombre de la categoría...', + 'budget.createCategory': 'Crear categoría', + 'budget.category': 'Categoría', + 'budget.categoryName': 'Nombre de la categoría', + 'budget.table.name': 'Nombre', + 'budget.table.total': 'Total', + 'budget.table.persons': 'Personas', + 'budget.table.days': 'Días', + 'budget.table.perPerson': 'Por persona', + 'budget.table.perDay': 'Por día', + 'budget.table.perPersonDay': 'Por pers. / día', + 'budget.table.note': 'Nota', + 'budget.newEntry': 'Nueva entrada', + 'budget.defaultEntry': 'Nueva entrada', + 'budget.defaultCategory': 'Nueva categoría', + 'budget.total': 'Total', + 'budget.totalBudget': 'Presupuesto total', + 'budget.byCategory': 'Por categoría', + 'budget.editTooltip': 'Haz clic para editar', + 'budget.confirm.deleteCategory': '¿Seguro que quieres eliminar la categoría "{name}" con {count} entradas?', + 'budget.deleteCategory': 'Eliminar categoría', + 'budget.perPerson': 'Por persona', + 'budget.paid': 'Pagado', + 'budget.open': 'Abrir', + 'budget.noMembers': 'No hay miembros asignados', + + // Files + 'files.title': 'Archivos', + 'files.count': '{count} archivos', + 'files.countSingular': '1 archivo', + 'files.uploaded': '{count} archivos subidos', + 'files.uploadError': 'La subida falló', + 'files.dropzone': 'Arrastra aquí los archivos', + 'files.dropzoneHint': 'o haz clic para explorar', + 'files.allowedTypes': 'Imágenes, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Máx. 50 MB', + 'files.uploading': 'Subiendo...', + 'files.filterAll': 'Todo', + 'files.filterPdf': 'PDF', + 'files.filterImages': 'Imágenes', + 'files.filterDocs': 'Documentos', + 'files.filterCollab': 'Notas de colaboración', + 'files.sourceCollab': 'Desde notas de colaboración', + 'files.empty': 'Aún no hay archivos', + 'files.emptyHint': 'Sube archivos para adjuntarlos a tu viaje', + 'files.openTab': 'Abrir en una pestaña nueva', + 'files.confirm.delete': '¿Seguro que quieres eliminar este archivo?', + 'files.toast.deleted': 'Archivo eliminado', + 'files.toast.deleteError': 'No se pudo eliminar el archivo', + 'files.sourcePlan': 'Plan diario', + 'files.sourceBooking': 'Reserva', + 'files.attach': 'Adjuntar', + 'files.pasteHint': 'También puedes pegar imágenes desde el portapapeles (Ctrl+V)', + + // Packing + 'packing.title': 'Lista de equipaje', + 'packing.empty': 'La lista de equipaje está vacía', + 'packing.progress': '{packed} de {total} preparados ({percent}%)', + 'packing.clearChecked': 'Eliminar {count} marcados', + 'packing.clearCheckedShort': 'Eliminar {count}', + 'packing.suggestions': 'Sugerencias', + 'packing.suggestionsTitle': 'Añadir sugerencias', + 'packing.allSuggested': 'Todas las sugerencias añadidas', + 'packing.allPacked': '¡Todo preparado!', + 'packing.addPlaceholder': 'Añadir nuevo elemento...', + 'packing.categoryPlaceholder': 'Categoría...', + 'packing.filterAll': 'Todo', + 'packing.filterOpen': 'Pendientes', + 'packing.filterDone': 'Hecho', + 'packing.emptyTitle': 'La lista de equipaje está vacía', + 'packing.emptyHint': 'Añade elementos o usa las sugerencias', + 'packing.emptyFiltered': 'Ningún elemento coincide con este filtro', + 'packing.menuRename': 'Renombrar', + 'packing.menuCheckAll': 'Marcar todo', + 'packing.menuUncheckAll': 'Desmarcar todo', + 'packing.menuDeleteCat': 'Eliminar categoría', + 'packing.changeCategory': 'Cambiar categoría', + 'packing.confirm.clearChecked': '¿Seguro que quieres eliminar {count} elementos marcados?', + 'packing.confirm.deleteCat': '¿Seguro que quieres eliminar la categoría "{name}" con {count} elementos?', + 'packing.defaultCategory': 'Otros', + 'packing.toast.saveError': 'No se pudo guardar', + 'packing.toast.deleteError': 'No se pudo eliminar', + 'packing.toast.renameError': 'No se pudo renombrar', + 'packing.toast.addError': 'No se pudo añadir', + + // Packing suggestions + 'packing.suggestions.items': [ + { name: 'Pasaporte', category: 'Documentos' }, + { name: 'Documento de identidad', category: 'Documentos' }, + { name: 'Seguro de viaje', category: 'Documentos' }, + { name: 'Billetes de vuelo', category: 'Documentos' }, + { name: 'Tarjeta de crédito', category: 'Finanzas' }, + { name: 'Efectivo', category: 'Finanzas' }, + { name: 'Visado', category: 'Documentos' }, + { name: 'Camisetas', category: 'Ropa' }, + { name: 'Pantalones', category: 'Ropa' }, + { name: 'Ropa interior', category: 'Ropa' }, + { name: 'Calcetines', category: 'Ropa' }, + { name: 'Chaqueta', category: 'Ropa' }, + { name: 'Pijama', category: 'Ropa' }, + { name: 'Ropa de baño', category: 'Ropa' }, + { name: 'Impermeable', category: 'Ropa' }, + { name: 'Zapatos cómodos', category: 'Ropa' }, + { name: 'Cepillo de dientes', category: 'Aseo' }, + { name: 'Pasta de dientes', category: 'Aseo' }, + { name: 'Champú', category: 'Aseo' }, + { name: 'Desodorante', category: 'Aseo' }, + { name: 'Protector solar', category: 'Aseo' }, + { name: 'Maquinilla de afeitar', category: 'Aseo' }, + { name: 'Cargador', category: 'Electrónica' }, + { name: 'Batería externa', category: 'Electrónica' }, + { name: 'Auriculares', category: 'Electrónica' }, + { name: 'Adaptador de viaje', category: 'Electrónica' }, + { name: 'Cámara', category: 'Electrónica' }, + { name: 'Analgésicos', category: 'Salud' }, + { name: 'Tiritas', category: 'Salud' }, + { name: 'Desinfectante', category: 'Salud' }, + ], + + // Members / Sharing + 'members.shareTrip': 'Compartir viaje', + 'members.inviteUser': 'Invitar usuario', + 'members.selectUser': 'Seleccionar usuario…', + 'members.invite': 'Invitar', + 'members.allHaveAccess': 'Todos los usuarios ya tienen acceso.', + 'members.access': 'Acceso', + 'members.person': 'persona', + 'members.persons': 'personas', + 'members.you': 'tú', + 'members.owner': 'Propietario', + 'members.leaveTrip': 'Abandonar viaje', + 'members.removeAccess': 'Quitar acceso', + 'members.confirmLeave': '¿Abandonar el viaje? Perderás el acceso.', + 'members.confirmRemove': '¿Quitar el acceso de este usuario?', + 'members.loadError': 'No se pudieron cargar los miembros', + 'members.added': 'añadido', + 'members.addError': 'No se pudo añadir', + 'members.removed': 'Miembro eliminado', + 'members.removeError': 'No se pudo eliminar', + + // Categories (Admin) + 'categories.title': 'Categorías', + 'categories.subtitle': 'Gestiona categorías para lugares', + 'categories.new': 'Nueva categoría', + 'categories.empty': 'Aún no hay categorías', + 'categories.namePlaceholder': 'Nombre de la categoría', + 'categories.icon': 'Icono', + 'categories.color': 'Color', + 'categories.customColor': 'Elegir color personalizado', + 'categories.preview': 'Vista previa', + 'categories.defaultName': 'Categoría', + 'categories.update': 'Actualizar', + 'categories.create': 'Crear', + 'categories.confirm.delete': '¿Eliminar la categoría? Los lugares de esta categoría no se eliminarán.', + 'categories.toast.loadError': 'No se pudieron cargar las categorías', + 'categories.toast.nameRequired': 'Introduce un nombre', + 'categories.toast.updated': 'Categoría actualizada', + 'categories.toast.created': 'Categoría creada', + 'categories.toast.saveError': 'No se pudo guardar', + 'categories.toast.deleted': 'Categoría eliminada', + 'categories.toast.deleteError': 'No se pudo eliminar', + + // Backup (Admin) + 'backup.title': 'Copia de seguridad de datos', + 'backup.subtitle': 'Base de datos y todos los archivos subidos', + 'backup.refresh': 'Actualizar', + 'backup.upload': 'Subir copia de seguridad', + 'backup.uploading': 'Subiendo…', + 'backup.create': 'Crear copia', + 'backup.creating': 'Creando…', + 'backup.empty': 'Aún no hay copias', + 'backup.createFirst': 'Crear la primera copia', + 'backup.download': 'Descargar', + 'backup.restore': 'Restaurar', + 'backup.confirm.restore': '¿Restaurar la copia "{name}"?\n\nTodos los datos actuales serán reemplazados por la copia.', + 'backup.confirm.uploadRestore': '¿Subir y restaurar el archivo de copia "{name}"?\n\nTodos los datos actuales se sobrescribirán.', + 'backup.confirm.delete': '¿Eliminar la copia "{name}"?', + 'backup.toast.loadError': 'No se pudieron cargar las copias', + 'backup.toast.created': 'Copia de seguridad creada correctamente', + 'backup.toast.createError': 'No se pudo crear la copia', + 'backup.toast.restored': 'Copia restaurada. La página se recargará…', + 'backup.toast.restoreError': 'No se pudo restaurar', + 'backup.toast.uploadError': 'No se pudo subir', + 'backup.toast.deleted': 'Copia eliminada', + 'backup.toast.deleteError': 'No se pudo eliminar', + 'backup.toast.downloadError': 'La descarga falló', + 'backup.toast.settingsSaved': 'Ajustes de copia automática guardados', + 'backup.toast.settingsError': 'No se pudieron guardar los ajustes', + 'backup.auto.title': 'Copia automática', + 'backup.auto.subtitle': 'Copia de seguridad automática según una programación', + 'backup.auto.enable': 'Activar copia automática', + 'backup.auto.enableHint': 'Se crearán copias automáticamente según la frecuencia elegida', + 'backup.auto.interval': 'Intervalo', + 'backup.auto.keepLabel': 'Eliminar copias antiguas después de', + 'backup.interval.hourly': 'Cada hora', + 'backup.interval.daily': 'Diaria', + 'backup.interval.weekly': 'Semanal', + 'backup.interval.monthly': 'Mensual', + 'backup.keep.1day': '1 día', + 'backup.keep.3days': '3 días', + 'backup.keep.7days': '7 días', + 'backup.keep.14days': '14 días', + 'backup.keep.30days': '30 días', + 'backup.keep.forever': 'Conservar para siempre', + + // Photos + 'photos.allDays': 'Todos los días', + 'photos.title': 'Recuerdos', + 'photos.noPhotos': 'Aún no hay fotos', + 'photos.uploadHint': 'Sube y organiza las fotos compartidas de este viaje', + 'photos.clickToSelect': 'o haz clic para seleccionar', + 'photos.dropHere': 'Suelta aquí las fotos...', + 'photos.dropTitle': 'Suelta aquí las fotos', + 'photos.fileHint': 'JPG, PNG, GIF, WebP · máx. 10 MB · hasta 30 fotos', + 'photos.selectedCount': '{count} foto(s) seleccionada(s)', + 'photos.sharedAlbum': '{count} recuerdos en este álbum compartido', + 'photos.sharedAlbumFor': '{count} recuerdos en {trip}', + 'photos.allPlaces': 'Todos los lugares', + 'photos.view.grid': 'Cuadrícula', + 'photos.view.day': 'Por día', + 'photos.view.place': 'Por lugar', + 'photos.stats.total': 'Fotos', + 'photos.stats.days': 'Días', + 'photos.stats.places': 'Lugares', + 'photos.stats.latest': 'Última subida', + 'photos.sectionCount': '{count} foto(s)', + 'photos.ungrouped': 'Sin clasificar', + 'photos.featured': 'Recuerdo destacado', + 'photos.coverFallback': 'Portada del álbum compartido', + 'photos.coverHint': 'Una imagen destacada para este álbum de viaje', + 'photos.mapTitle': 'Mapa de recuerdos', + 'photos.mapHint': 'Explora los lugares vinculados en el mismo mapa que usamos en el plan', + 'photos.mapEmpty': 'Vincula tus fotos a lugares para verlas ubicadas en el mapa.', + 'photos.linkDay': 'Vincular día', + 'photos.noDay': 'Sin día', + 'photos.linkPlace': 'Vincular lugar', + 'photos.noPlace': 'Sin lugar', + 'photos.captionLabel': 'Pie de foto (para todas)', + 'photos.captionPlaceholder': 'Pie de foto opcional...', + 'photos.addCaption': 'Añadir un pie de foto...', + 'photos.uploadN': 'Subida de {n} foto(s)', + 'admin.addons.catalog.memories.name': 'Recuerdos', + 'admin.addons.catalog.memories.description': 'Álbumes de fotos compartidos para cada viaje', + 'admin.addons.catalog.packing.name': 'Equipaje', + 'admin.addons.catalog.packing.description': 'Prepara tu equipaje con listas de comprobación para cada viaje', + 'admin.addons.catalog.budget.name': 'Presupuesto', + 'admin.addons.catalog.budget.description': 'Controla los gastos y planifica el presupuesto del viaje', + 'admin.addons.catalog.documents.name': 'Documentos', + 'admin.addons.catalog.documents.description': 'Guarda y gestiona la documentación del viaje', + 'admin.addons.catalog.vacay.name': 'Vacaciones', + 'admin.addons.catalog.vacay.description': 'Planificador personal de vacaciones con vista de calendario', + 'admin.addons.catalog.atlas.name': 'Atlas', + 'admin.addons.catalog.atlas.description': 'Mapa del mundo con los países visitados y estadísticas de viaje', + 'admin.addons.catalog.collab.name': 'Colaboración', + 'admin.addons.catalog.collab.description': 'Notas, encuestas y chat en tiempo real para organizar el viaje', + + // Backup restore modal + 'backup.restoreConfirmTitle': '¿Restaurar copia?', + 'backup.restoreWarning': 'Todos los datos actuales (viajes, lugares, usuarios, subidas) serán reemplazados permanentemente por la copia. Esta acción no se puede deshacer.', + 'backup.restoreTip': 'Consejo: crea una copia del estado actual antes de restaurar.', + 'backup.restoreConfirm': 'Sí, restaurar', + + // PDF + 'pdf.travelPlan': 'Plan de viaje', + 'pdf.planned': 'Planificado', + 'pdf.costLabel': 'Coste EUR', + 'pdf.preview': 'Vista previa PDF', + 'pdf.saveAsPdf': 'Guardar como PDF', + + // Planner + 'planner.places': 'Lugares', + 'planner.bookings': 'Reservas', + 'planner.packingList': 'Lista de equipaje', + 'planner.documents': 'Documentos', + 'planner.dayPlan': 'Plan por días', + 'planner.reservations': 'Reservas', + 'planner.minTwoPlaces': 'Se necesitan al menos 2 lugares con coordenadas', + 'planner.noGeoPlaces': 'No hay lugares con coordenadas disponibles', + 'planner.routeCalculated': 'Ruta calculada', + 'planner.routeCalcFailed': 'No se pudo calcular la ruta', + 'planner.routeError': 'Error al calcular la ruta', + 'planner.routeOptimized': 'Ruta optimizada', + 'planner.reservationUpdated': 'Reserva actualizada', + 'planner.reservationAdded': 'Reserva añadida', + 'planner.confirmDeleteReservation': '¿Eliminar reserva?', + 'planner.reservationDeleted': 'Reserva eliminada', + 'planner.days': 'Días', + 'planner.allPlaces': 'Todos los lugares', + 'planner.totalPlaces': '{n} lugares en total', + 'planner.noDaysPlanned': 'Aún no hay días planificados', + 'planner.editTrip': 'Editar viaje →', + 'planner.placeOne': '1 lugar', + 'planner.placeN': '{n} lugares', + 'planner.addNote': 'Añadir nota', + 'planner.noEntries': 'No hay entradas para este día', + 'planner.addPlace': 'Añadir lugar/actividad', + 'planner.addPlaceShort': '+ Añadir lugar/actividad', + 'planner.resPending': 'Reserva pendiente · ', + 'planner.resConfirmed': 'Reserva confirmada · ', + 'planner.notePlaceholder': 'Nota…', + 'planner.noteTimePlaceholder': 'Hora (opcional)', + 'planner.noteExamplePlaceholder': 'p. ej. S3 a las 14:30 desde la estación central, ferry desde el muelle 7, pausa para comer…', + 'planner.totalCost': 'Coste total', + 'planner.searchPlaces': 'Buscar lugares…', + 'planner.allCategories': 'Todas las categorías', + 'planner.noPlacesFound': 'No se encontraron lugares', + 'planner.addFirstPlace': 'Añadir el primer lugar', + 'planner.noReservations': 'Sin reservas', + 'planner.addFirstReservation': 'Añadir la primera reserva', + 'planner.new': 'Nuevo', + 'planner.addToDay': '+ Día', + 'planner.calculating': 'Calculando…', + 'planner.route': 'Ruta', + 'planner.optimize': 'Optimizar', + 'planner.openGoogleMaps': 'Abrir en Google Maps', + 'planner.selectDayHint': 'Selecciona un día de la lista izquierda para ver su plan', + 'planner.noPlacesForDay': 'Aún no hay lugares para este día', + 'planner.addPlacesLink': 'Añadir lugares →', + 'planner.minTotal': 'min en total', + 'planner.noReservation': 'Sin reserva', + 'planner.removeFromDay': 'Quitar del día', + 'planner.addToThisDay': 'Añadir al día', + 'planner.overview': 'Vista general', + 'planner.noDays': 'No hay días todavía', + 'planner.editTripToAddDays': 'Edita el viaje para añadir días', + 'planner.dayCount': '{n} días', + 'planner.clickToUnlock': 'Haz clic para desbloquear', + 'planner.keepPosition': 'Mantener posición durante la optimización de ruta', + 'planner.dayDetails': 'Detalles del día', + 'planner.dayN': 'Día {n}', + 'planner.notes': 'Notas', + 'planner.addDayNote': 'Añadir notas para este día...', + + // Dashboard Stats + 'stats.countries': 'Países', + 'stats.cities': 'Ciudades', + 'stats.trips': 'Viajes', + 'stats.places': 'Lugares', + 'stats.worldProgress': 'Progreso mundial', + 'stats.visited': 'visitados', + 'stats.remaining': 'restantes', + 'stats.visitedCountries': 'Países visitados', + + // Day Detail Panel + 'day.precipProb': 'Probabilidad de lluvia', + 'day.precipitation': 'Precipitación', + 'day.wind': 'Viento', + 'day.sunrise': 'Amanecer', + 'day.sunset': 'Atardecer', + 'day.hourlyForecast': 'Pronóstico por horas', + 'day.climateHint': 'Promedios históricos: el pronóstico real está disponible dentro de los 16 días previos a la fecha.', + 'day.noWeather': 'No hay datos meteorológicos disponibles. Añade un lugar con coordenadas.', + 'day.overview': 'Resumen diario', + 'day.accommodation': 'Alojamiento', + 'day.addAccommodation': 'Añadir alojamiento', + 'day.hotelDayRange': 'Aplicar a los días', + 'day.noPlacesForHotel': 'Añade primero lugares al viaje', + 'day.allDays': 'Todos', + 'day.checkIn': 'Check-in', + 'day.checkOut': 'Check-out', + 'day.confirmation': 'Confirmación', + 'day.editAccommodation': 'Editar alojamiento', + 'day.reservations': 'Reservas', + + // Collab Addon + 'collab.tabs.chat': 'Mensajes', + 'collab.tabs.notes': 'Notas', + 'collab.tabs.polls': 'Encuestas', + 'collab.whatsNext.title': 'Qué viene ahora', + 'collab.whatsNext.today': 'Hoy', + 'collab.whatsNext.tomorrow': 'Mañana', + 'collab.whatsNext.empty': 'No hay actividades próximas', + 'collab.whatsNext.until': 'hasta', + 'collab.whatsNext.emptyHint': 'Las actividades con hora aparecerán aquí', + 'collab.chat.send': 'Enviar', + 'collab.chat.placeholder': 'Escribe un mensaje...', + 'collab.chat.empty': 'Empieza la conversación', + 'collab.chat.emptyHint': 'Los mensajes se comparten con todos los miembros del viaje', + 'collab.chat.emptyDesc': 'Comparte ideas, planes y novedades con tu grupo de viaje', + 'collab.chat.today': 'Hoy', + 'collab.chat.yesterday': 'Ayer', + 'collab.chat.deletedMessage': 'eliminó un mensaje', + 'collab.chat.loadMore': 'Cargar mensajes anteriores', + 'collab.chat.justNow': 'justo ahora', + 'collab.chat.minutesAgo': 'hace {n} min', + 'collab.chat.hoursAgo': 'hace {n} h', + 'collab.notes.title': 'Notas', + 'collab.notes.new': 'Nueva nota', + 'collab.notes.empty': 'Aún no hay notas', + 'collab.notes.emptyHint': 'Empieza a capturar ideas y planes', + 'collab.notes.all': 'Todas', + 'collab.notes.titlePlaceholder': 'Título de la nota', + 'collab.notes.contentPlaceholder': 'Escribe algo...', + 'collab.notes.categoryPlaceholder': 'Categoría', + 'collab.notes.newCategory': 'Nueva categoría...', + 'collab.notes.category': 'Categoría', + 'collab.notes.noCategory': 'Sin categoría', + 'collab.notes.color': 'Color', + 'collab.notes.save': 'Guardar', + 'collab.notes.cancel': 'Cancelar', + 'collab.notes.edit': 'Editar', + 'collab.notes.delete': 'Eliminar', + 'collab.notes.pin': 'Fijar', + 'collab.notes.unpin': 'Desfijar', + 'collab.notes.daysAgo': 'hace {n} d', + 'collab.notes.categorySettings': 'Gestionar categorías', + 'collab.notes.create': 'Crear', + 'collab.notes.website': 'Sitio web', + 'collab.notes.websitePlaceholder': 'https://...', + 'collab.notes.attachFiles': 'Adjuntar archivos', + 'collab.notes.noCategoriesYet': 'Aún no hay categorías', + 'collab.notes.emptyDesc': 'Crea una nota para empezar', + 'collab.polls.title': 'Encuestas', + 'collab.polls.new': 'Nueva encuesta', + 'collab.polls.empty': 'Aún no hay encuestas', + 'collab.polls.emptyHint': 'Pregunta al grupo y votad juntos', + 'collab.polls.question': 'Pregunta', + 'collab.polls.questionPlaceholder': '¿Qué deberíamos hacer?', + 'collab.polls.addOption': '+ Añadir opción', + 'collab.polls.optionPlaceholder': 'Opción {n}', + 'collab.polls.create': 'Crear encuesta', + 'collab.polls.close': 'Cerrar', + 'collab.polls.closed': 'Cerrada', + 'collab.polls.votes': '{n} votos', + 'collab.polls.vote': '{n} voto', + 'collab.polls.multipleChoice': 'Selección múltiple', + 'collab.polls.multiChoice': 'Selección múltiple', + 'collab.polls.deadline': 'Fecha límite', + 'collab.polls.option': 'Opción', + 'collab.polls.options': 'Opciones', + 'collab.polls.delete': 'Eliminar', + 'collab.polls.closedSection': 'Cerradas', +} + +export default es diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts new file mode 100644 index 0000000..9e11b4e --- /dev/null +++ b/client/src/i18n/translations/es.ts @@ -0,0 +1 @@ +export { default } from './es.js' 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/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 8f72c83..44a6ce9 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -39,6 +39,7 @@ interface OidcConfig { client_secret: string client_secret_set: boolean display_name: string + oidc_only: boolean } interface UpdateInfo { @@ -72,7 +73,7 @@ export default function AdminPage(): React.ReactElement { const [createForm, setCreateForm] = useState<{ username: string; email: string; password: string; role: string }>({ username: '', email: '', password: '', role: 'user' }) // OIDC config - const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '' }) + const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false }) const [savingOidc, setSavingOidc] = useState(false) // Registration toggle @@ -246,7 +247,7 @@ export default function AdminPage(): React.ReactElement { const handleSaveUser = async () => { try { - const payload = { + const payload: { username?: string; email?: string; role: string; password?: string } = { username: editForm.username.trim() || undefined, email: editForm.email.trim() || undefined, role: editForm.role, @@ -715,11 +716,31 @@ export default function AdminPage(): React.ReactElement { className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" />
+ {/* OIDC-only mode toggle */} +
+
+

{t('admin.oidcOnlyMode')}

+

{t('admin.oidcOnlyModeHint')}

+
+ +
+
@@ -287,9 +291,14 @@ export default function LoginPage(): React.ReactElement { return (
- {/* Sprach-Toggle oben rechts */} + {/* Language toggle */} {/* Left — branding */} @@ -405,7 +414,7 @@ export default function LoginPage(): React.ReactElement {
{/* Logo */}
- NOMAD + TREK

@@ -450,11 +459,39 @@ export default function LoginPage(): React.ReactElement {
- NOMAD + TREK

{t('login.tagline')}

+ {oidcOnly ? ( + <> +

{t('login.title')}

+

{t('login.oidcOnly')}

+ {error && ( +
+ {error} +
+ )} + ) => { e.currentTarget.style.background = '#1f2937' }} + onMouseLeave={(e: React.MouseEvent) => { e.currentTarget.style.background = '#111827' }} + > + + {t('login.oidcSignIn', { name: appConfig?.oidc_display_name || 'SSO' })} + + + ) : ( + <>

{mode === 'login' && mfaStep ? t('login.mfaTitle') @@ -586,10 +623,11 @@ export default function LoginPage(): React.ReactElement {

)} + )}

- {/* OIDC / SSO login button */} - {appConfig?.oidc_configured && ( + {/* OIDC / SSO login button (only when OIDC is configured but not in oidc-only mode) */} + {appConfig?.oidc_configured && !oidcOnly && ( <>
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/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 2cd985d..8b0d3d2 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -71,6 +71,13 @@ export default function SettingsPage(): React.ReactElement { const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') + const [oidcOnlyMode, setOidcOnlyMode] = useState(false) + + useEffect(() => { + authApi.getAppConfig?.().then((config) => { + if (config?.oidc_only_mode) setOidcOnlyMode(true) + }).catch(() => {}) + }, []) const [mfaQr, setMfaQr] = useState(null) const [mfaSecret, setMfaSecret] = useState(null) @@ -269,6 +276,7 @@ export default function SettingsPage(): React.ReactElement { {[ { value: 'de', label: 'Deutsch' }, { value: 'en', label: 'English' }, + { value: 'es', label: 'Español' }, ].map(opt => (
+ )} {/* MFA */}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index baedaa9..a4ea63b 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(() => { @@ -64,7 +67,7 @@ export default function TripPlannerPage(): React.ReactElement | null { ...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []), ...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []), ...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []), - ...(enabledAddons.collab ? [{ id: 'collab', label: 'Collab' }] : []), + ...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name') }] : []), ] const [activeTab, setActiveTab] = useState(() => { @@ -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/pages/VacayPage.tsx b/client/src/pages/VacayPage.tsx index 93dff4b..ff2ac88 100644 --- a/client/src/pages/VacayPage.tsx +++ b/client/src/pages/VacayPage.tsx @@ -128,7 +128,7 @@ export default function VacayPage(): React.ReactElement {
-

Vacay

+

{t('admin.addons.catalog.vacay.name')}

{t('vacay.subtitle')}

diff --git a/client/src/types.ts b/client/src/types.ts index e5bd1f5..d434f0f 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 @@ -48,6 +48,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 @@ -116,6 +117,7 @@ export interface Reservation { id: number trip_id: number name: string + title?: string type: string | null status: 'pending' | 'confirmed' date: string | null @@ -123,17 +125,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/docker-compose.yml b/docker-compose.yml index 1374f29..1acc607 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: app: - image: mauriceboe/nomad:2.5.5 - container_name: nomad + image: mauriceboe/trek:latest + container_name: trek ports: - "3000:3000" environment: diff --git a/server/package-lock.json b/server/package-lock.json index 30612cc..bbd2eca 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { - "name": "nomad-server", - "version": "2.6.1", + "name": "trek-server", + "version": "2.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "nomad-server", - "version": "2.6.1", + "name": "trek-server", + "version": "2.6.2", "dependencies": { "archiver": "^6.0.1", "bcryptjs": "^2.4.3", @@ -32,7 +32,7 @@ "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", - "@types/express": "^5.0.6", + "@types/express": "^4.17.25", "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.1.0", "@types/node": "^25.5.0", @@ -569,21 +569,22 @@ } }, "node_modules/@types/express": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", - "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^2" + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" } }, "node_modules/@types/express-serve-static-core": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", - "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", "dev": true, "license": "MIT", "dependencies": { @@ -611,6 +612,13 @@ "@types/node": "*" } }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -690,13 +698,25 @@ } }, "node_modules/@types/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", "@types/node": "*" } }, diff --git a/server/package.json b/server/package.json index 6994726..453f80d 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", @@ -31,7 +31,7 @@ "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", - "@types/express": "^5.0.6", + "@types/express": "^4.17.25", "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.1.0", "@types/node": "^25.5.0", diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 0a30fed..2c1161a 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -196,6 +196,16 @@ function runMigrations(db: Database.Database): void { () => { try { db.exec('ALTER TABLE users ADD COLUMN mfa_enabled INTEGER DEFAULT 0'); } catch {} try { db.exec('ALTER TABLE users ADD COLUMN mfa_secret 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 {} }, ]; 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..74af704 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -63,9 +63,10 @@ app.use(helmet({ } }, crossOriginEmbedderPolicy: false, + hsts: process.env.FORCE_HTTPS === 'true' ? { maxAge: 31536000, includeSubDomains: false } : 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 +173,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..b460e77 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -94,7 +94,7 @@ router.put('/users/:id', (req: Request, res: Response) => { router.delete('/users/:id', (req: Request, res: Response) => { const authReq = req as AuthRequest; - if (parseInt(req.params.id) === authReq.user.id) { + if (parseInt(req.params.id as string) === authReq.user.id) { return res.status(400).json({ error: 'Cannot delete own account' }); } @@ -122,16 +122,18 @@ router.get('/oidc', (_req: Request, res: Response) => { client_id: get('oidc_client_id'), client_secret_set: !!secret, display_name: get('oidc_display_name'), + oidc_only: get('oidc_only') === 'true', }); }); router.put('/oidc', (req: Request, res: Response) => { - const { issuer, client_id, client_secret, display_name } = req.body; + const { issuer, client_id, client_secret, display_name, oidc_only } = req.body; const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || ''); set('oidc_issuer', issuer); set('oidc_client_id', client_id); if (client_secret !== undefined) set('oidc_client_secret', client_secret); set('oidc_display_name', display_name); + set('oidc_only', oidc_only ? 'true' : 'false'); res.json({ success: true }); }); @@ -171,7 +173,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 b14f61a..d4bbe31 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -91,6 +91,17 @@ function rateLimiter(maxAttempts: number, windowMs: number) { } const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW); +function isOidcOnlyMode(): boolean { + const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null; + const enabled = get('oidc_only') === 'true'; + if (!enabled) return false; + const oidcConfigured = !!( + (process.env.OIDC_ISSUER || get('oidc_issuer')) && + (process.env.OIDC_CLIENT_ID || get('oidc_client_id')) + ); + return oidcConfigured; +} + function maskKey(key: string | null | undefined): string | null { if (!key) return null; if (key.length <= 8) return '--------'; @@ -116,11 +127,13 @@ 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) ); + const oidcOnlySetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value; + const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true'; res.json({ allow_registration: isDemo ? false : allowRegistration, has_users: userCount > 0, @@ -128,9 +141,10 @@ router.get('/app-config', (_req: Request, res: Response) => { has_maps_key: hasGoogleKey, oidc_configured: oidcConfigured, oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined, + oidc_only_mode: oidcOnlyMode, 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, }); }); @@ -139,7 +153,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 safe = stripUserForClient(user) as Record; @@ -150,6 +164,9 @@ router.post('/register', authLimiter, (req: Request, res: Response) => { const { username, email, password } = req.body; const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; + if (userCount > 0 && isOidcOnlyMode()) { + return res.status(403).json({ error: 'Password authentication is disabled. Please sign in with SSO.' }); + } if (userCount > 0) { const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined; if (setting?.value === 'false') { @@ -199,6 +216,10 @@ router.post('/register', authLimiter, (req: Request, res: Response) => { }); router.post('/login', authLimiter, (req: Request, res: Response) => { + if (isOidcOnlyMode()) { + return res.status(403).json({ error: 'Password authentication is disabled. Please sign in with SSO.' }); + } + const { email, password } = req.body; if (!email || !password) { @@ -247,7 +268,10 @@ 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 (isOidcOnlyMode()) { + return res.status(403).json({ error: 'Password authentication is disabled.' }); + } + 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; @@ -271,7 +295,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') { diff --git a/server/src/routes/backup.ts b/server/src/routes/backup.ts index f1c4883..e18190d 100644 --- a/server/src/routes/backup.ts +++ b/server/src/routes/backup.ts @@ -5,7 +5,7 @@ import multer from 'multer'; import path from 'path'; import fs from 'fs'; import { authenticate, adminOnly } from '../middleware/auth'; -import scheduler from '../scheduler'; +import * as scheduler from '../scheduler'; import { db, closeDb, reinitialize } from '../db/database'; const router = express.Router(); @@ -211,19 +211,52 @@ router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, }); router.get('/auto-settings', (_req: Request, res: Response) => { - res.json({ settings: scheduler.loadSettings() }); + try { + res.json({ settings: scheduler.loadSettings() }); + } catch (err: unknown) { + console.error('[backup] GET auto-settings:', err); + res.status(500).json({ error: 'Could not load backup settings' }); + } }); +function parseAutoBackupBody(body: Record): { + enabled: boolean; + interval: string; + keep_days: number; +} { + const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1; + const rawInterval = body.interval; + const interval = + typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval) + ? rawInterval + : 'daily'; + const rawKeep = body.keep_days; + let keepNum: number; + if (typeof rawKeep === 'number' && Number.isFinite(rawKeep)) { + keepNum = Math.floor(rawKeep); + } else if (typeof rawKeep === 'string' && rawKeep.trim() !== '') { + keepNum = parseInt(rawKeep, 10); + } else { + keepNum = NaN; + } + const keep_days = Number.isFinite(keepNum) && keepNum >= 0 ? keepNum : 7; + return { enabled, interval, keep_days }; +} + router.put('/auto-settings', (req: Request, res: Response) => { - const { enabled, interval, keep_days } = req.body; - const settings = { - enabled: !!enabled, - interval: scheduler.VALID_INTERVALS.includes(interval) ? interval : 'daily', - keep_days: Number.isInteger(keep_days) && keep_days >= 0 ? keep_days : 7, - }; - scheduler.saveSettings(settings); - scheduler.start(); - res.json({ settings }); + try { + const settings = parseAutoBackupBody((req.body || {}) as Record); + scheduler.saveSettings(settings); + scheduler.start(); + res.json({ settings }); + } catch (err: unknown) { + console.error('[backup] PUT auto-settings:', err); + const msg = err instanceof Error ? err.message : String(err); + res.status(500).json({ + error: 'Could not save auto-backup settings', + detail: process.env.NODE_ENV !== 'production' ? msg : undefined, + }); + } }); router.delete('/:filename', (req: Request, res: Response) => { 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..176aa3c 100644 --- a/server/src/routes/places.ts +++ b/server/src/routes/places.ts @@ -22,7 +22,7 @@ interface UnsplashSearchResponse { const router = express.Router({ mergeParams: true }); router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => { - const { tripId } = req.params; + const { tripId } = req.params const { search, category, tag } = req.query; let query = ` @@ -41,12 +41,12 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) = if (category) { query += ' AND p.category_id = ?'; - params.push(category); + params.push(category as string); } if (tag) { query += ' AND p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)'; - params.push(tag); + params.push(tag as string); } query += ' ORDER BY p.created_at DESC'; @@ -73,12 +73,12 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) = }); router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => { - const { tripId } = req.params; + const { tripId } = req.params 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; @@ -107,13 +107,13 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: } } - const place = getPlaceWithTags(placeId); + const place = getPlaceWithTags(Number(placeId)); res.status(201).json({ place }); broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); }); router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { - const { tripId, id } = req.params; + const { tripId, id } = req.params const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId); if (!placeCheck) { @@ -126,7 +126,7 @@ router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { tripId, id } = req.params; + const { tripId, id } = req.params const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined; if (!place) { @@ -166,7 +166,7 @@ router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, r }); router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => { - const { tripId, id } = req.params; + const { tripId, id } = req.params const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined; if (!existingPlace) { @@ -238,7 +238,7 @@ router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name }); router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { - const { tripId, id } = req.params; + const { tripId, id } = req.params const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId); if (!place) { 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/scheduler.ts b/server/src/scheduler.ts index 0d415f3..2a8a75d 100644 --- a/server/src/scheduler.ts +++ b/server/src/scheduler.ts @@ -1,4 +1,4 @@ -import cron from 'node-cron'; +import cron, { type ScheduledTask } from 'node-cron'; import archiver from 'archiver'; import path from 'path'; import fs from 'fs'; @@ -23,7 +23,7 @@ interface BackupSettings { keep_days: number; } -let currentTask: cron.ScheduledTask | null = null; +let currentTask: ScheduledTask | null = null; function loadSettings(): BackupSettings { try { @@ -110,7 +110,7 @@ function start(): void { } // Demo mode: hourly reset of demo user data -let demoTask: cron.ScheduledTask | null = null; +let demoTask: ScheduledTask | null = null; function startDemoReset(): void { if (demoTask) { demoTask.stop(); demoTask = null; } diff --git a/server/src/types.ts b/server/src/types.ts index 68b12cf..495b8c4 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -62,6 +62,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; @@ -147,6 +148,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; @@ -158,11 +161,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; diff --git a/unraid-template.xml b/unraid-template.xml new file mode 100644 index 0000000..8fe29a1 --- /dev/null +++ b/unraid-template.xml @@ -0,0 +1,30 @@ + + + TREK + mauriceboe/trek + https://hub.docker.com/r/mauriceboe/trek + + latest + Latest stable release + + bridge + false + https://github.com/mauriceboe/TREK/issues + https://github.com/mauriceboe/TREK + TREK is a self-hosted, real-time collaborative travel planner with interactive maps, budgets, bookings, packing lists, file management, and more. Plan trips together with your group — changes sync instantly across all connected users. Includes OIDC/SSO support, dark mode, PWA, and a modular addon system (Vacay, Atlas, Collab, Budget, Packing). + Productivity: Tools: + http://[IP]:[PORT:3000] + https://raw.githubusercontent.com/mauriceboe/TREK/main/unraid-template.xml + https://raw.githubusercontent.com/mauriceboe/TREK/main/client/public/icons/icon-dark.svg + + + Support TREK development + https://ko-fi.com/mauriceboe + + 3000 + /mnt/user/appdata/trek/data + /mnt/user/appdata/trek/uploads + production + + 3000 +